diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index f9d0d8b..2d082f6 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -77,6 +77,7 @@ const enUS = { freeCreate: 'Free Create', imitation: 'Imitation Create', media: 'Media', + publishManagement: 'Publish Management', brandManagement: 'Brand Management', tracking: 'Tracking', trackingDetail: 'Data Details', @@ -202,6 +203,11 @@ const enUS = { description: 'Review desktop-managed media bindings, authorization state, and sync results in one place.', }, + publishManagement: { + title: 'Publish Management', + description: + 'Review desktop publish tasks, external links, account workbench entry points, and retry state.', + }, brands: { title: 'Brand Library', description: 'Manage keywords, question sets, and competitors around each brand.', diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index ea7bf89..4ec3900 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -77,6 +77,7 @@ const zhCN = { freeCreate: "自由创作", imitation: "仿写创作", media: "媒体管理", + publishManagement: "发文管理", brandManagement: "品牌管理", brands: "品牌词库", tracking: "数据追踪", @@ -195,6 +196,10 @@ const zhCN = { title: "媒体管理", description: "统一查看桌面端媒体账号绑定、授权状态与同步结果。", }, + publishManagement: { + title: "发文管理", + description: "查看桌面端发布任务、外链、账号工作台入口和重发状态。", + }, brands: { title: "品牌词库", description: "围绕品牌维护关键词、问题集和竞品信息。", diff --git a/apps/admin-web/src/layouts/AppShell.vue b/apps/admin-web/src/layouts/AppShell.vue index 51f2c93..cced722 100644 --- a/apps/admin-web/src/layouts/AppShell.vue +++ b/apps/admin-web/src/layouts/AppShell.vue @@ -9,6 +9,7 @@ import { PictureOutlined, RadarChartOutlined, SearchOutlined, + SendOutlined, } from '@ant-design/icons-vue' import { useQuery } from '@tanstack/vue-query' import { computed, type Component } from 'vue' @@ -130,6 +131,7 @@ const navSections = computed(() => { title: t('nav.contentManagement'), items: [ { key: '/media', label: t('nav.media'), icon: GlobalOutlined }, + { key: '/publish-management', label: t('nav.publishManagement'), icon: SendOutlined }, { key: '/knowledge', label: t('nav.knowledge'), icon: BookOutlined }, { key: '/images', label: t('nav.images'), icon: PictureOutlined }, ], diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 3f7c567..5e7eeb1 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -69,6 +69,7 @@ import type { KolSubscriptionPromptCard, KolSubscriptionPromptSchema, KolWorkspaceCard, + ListDesktopPublishTasksParams, LoginRequest, LoginResponse, MediaPlatform, @@ -114,6 +115,7 @@ import type { TemplateSaveDraftResponse, TemplateTitleTaskRequest, TemplateTitleTaskResultResponse, + TenantPublishTaskListResponse, UpdateArticleRequest, UpdateKolPackageRequest, UpdateKolPromptRequest, @@ -1105,6 +1107,19 @@ export const publishJobsApi = { }, } +export const publishTasksApi = { + list(params: ListDesktopPublishTasksParams = {}) { + return apiClient.get('/api/tenant/publish-tasks', { + params, + }) + }, + retry(taskId: string) { + return apiClient.post( + `/api/tenant/publish-tasks/${encodeURIComponent(taskId)}/retry`, + ) + }, +} + export const complianceApi = { runtimeStatus() { return apiClient.get('/api/tenant/compliance/runtime-status') diff --git a/apps/admin-web/src/router/index.ts b/apps/admin-web/src/router/index.ts index c564c3c..b1014db 100644 --- a/apps/admin-web/src/router/index.ts +++ b/apps/admin-web/src/router/index.ts @@ -124,6 +124,16 @@ const router = createRouter({ navKey: '/media', }, }, + { + path: 'publish-management', + name: 'publish-management', + component: () => import('@/views/PublishManagementView.vue'), + meta: { + titleKey: 'route.publishManagement.title', + descriptionKey: 'route.publishManagement.description', + navKey: '/publish-management', + }, + }, { path: 'brands', name: 'brands', diff --git a/apps/admin-web/src/views/PublishManagementView.vue b/apps/admin-web/src/views/PublishManagementView.vue new file mode 100644 index 0000000..daeef8e --- /dev/null +++ b/apps/admin-web/src/views/PublishManagementView.vue @@ -0,0 +1,1083 @@ + + + + + diff --git a/apps/desktop-client/electron-builder.yml b/apps/desktop-client/electron-builder.yml index 1f18c0c..1dbe828 100644 --- a/apps/desktop-client/electron-builder.yml +++ b/apps/desktop-client/electron-builder.yml @@ -46,6 +46,10 @@ mac: electronLanguages: - en - zh_CN + protocols: + - name: Shengxintui URL + schemes: + - shengxintui extendInfo: NSCameraUsageDescription: 用于视频会议和录屏功能 NSMicrophoneUsageDescription: 用于音频采集 diff --git a/apps/desktop-client/src/main/bootstrap.ts b/apps/desktop-client/src/main/bootstrap.ts index 68b6e7b..704e7c8 100644 --- a/apps/desktop-client/src/main/bootstrap.ts +++ b/apps/desktop-client/src/main/bootstrap.ts @@ -76,6 +76,7 @@ if (process.platform === 'win32') { } const isDevelopmentRuntime = !app.isPackaged +const desktopDeepLinkScheme = 'shengxintui' function silencePackagedConsole(): void { if (isDevelopmentRuntime) { @@ -119,6 +120,8 @@ let mainWindowCreatePromise: Promise | null = null let loginWindowCreatePromise: Promise | null = null let settingsWindowCreatePromise: Promise | null = null let windowModeSwitchQueue: Promise = Promise.resolve() +let canHandleDesktopDeepLinks = false +const pendingDesktopDeepLinks: string[] = [] const forceCloseWindows = new WeakSet() const appWindowModes = new WeakMap() @@ -489,19 +492,19 @@ function queueWindowModeSwitch( } async function openSettingsWindow(): Promise { - const window = await ensureSettingsWindow(); - const parent = settingsWindowParent(); + const window = await ensureSettingsWindow() + const parent = settingsWindowParent() if (parent && window.getParentWindow() !== parent) { - window.setParentWindow(parent); + window.setParentWindow(parent) } - await revealWindow(window); + await revealWindow(window) } function settingsWindowParent(): ElectronBrowserWindow | undefined { - if (process.platform !== "win32") { - return undefined; + if (process.platform !== 'win32') { + return undefined } - return currentMainWindow() ?? undefined; + return currentMainWindow() ?? undefined } function rendererURL(): string | null { @@ -713,6 +716,79 @@ function isSafeExternalUrl(url: string): boolean { } } +function registerDesktopProtocolClient(): void { + if (app.isDefaultProtocolClient(desktopDeepLinkScheme)) { + return + } + + if (process.defaultApp && process.execPath) { + app.setAsDefaultProtocolClient(desktopDeepLinkScheme, process.execPath, [ + process.argv[1] ?? '', + ]) + return + } + + app.setAsDefaultProtocolClient(desktopDeepLinkScheme) +} + +function extractDesktopDeepLink(argv: string[]): string | null { + return argv.find((item) => item.startsWith(`${desktopDeepLinkScheme}://`)) ?? null +} + +function queueDesktopDeepLink(rawUrl: string | null | undefined): void { + if (!rawUrl) { + return + } + if (!canHandleDesktopDeepLinks) { + pendingDesktopDeepLinks.push(rawUrl) + return + } + handleDesktopDeepLink(rawUrl) +} + +function flushPendingDesktopDeepLinks(): void { + canHandleDesktopDeepLinks = true + const links = pendingDesktopDeepLinks.splice(0) + for (const link of links) { + handleDesktopDeepLink(link) + } +} + +function handleDesktopDeepLink(rawUrl: string): void { + let parsed: URL + try { + parsed = new URL(rawUrl) + } catch { + return + } + if (parsed.protocol !== `${desktopDeepLinkScheme}:` || parsed.hostname !== 'desktop') { + return + } + + if ( + parsed.pathname === '/workbench' || + parsed.searchParams.get('action') === 'open-publish-account-console' + ) { + const account = { + id: parsed.searchParams.get('account_id') ?? '', + platform: parsed.searchParams.get('platform') ?? '', + platformUid: parsed.searchParams.get('platform_uid') ?? '', + displayName: parsed.searchParams.get('display_name') ?? '', + } + if (!account.id || !account.platform) { + return + } + + void openPublishAccountConsole(account).catch((error) => { + console.warn('[desktop-deeplink] open publish account console failed', { + accountId: account.id, + platform: account.platform, + message: error instanceof Error ? error.message : String(error), + }) + }) + } +} + function registerBridgeHandlers(): void { ipcMain.handle('desktop:ping', () => 'pong') ipcMain.handle('desktop:device-info', () => resolveDesktopDeviceInfo()) @@ -830,8 +906,9 @@ function registerBridgeHandlers(): void { }) } -const hasSingleInstanceLock = initSingleInstance(() => { +const hasSingleInstanceLock = initSingleInstance((argv) => { revealActiveWindowSafely('single-instance') + queueDesktopDeepLink(extractDesktopDeepLink(argv)) }) if (!hasSingleInstanceLock) { @@ -842,6 +919,7 @@ if (!hasSingleInstanceLock) { .whenReady() .then(async () => { suppressNativeWindowMenu() + registerDesktopProtocolClient() currentWindowMode = readPersistedWindowMode() initDesktopAppSettings() registerBridgeHandlers() @@ -864,6 +942,8 @@ if (!hasSingleInstanceLock) { revealActiveWindowSafely('tray') }) startDesktopHealthIndicator(() => currentMainWindow()) + queueDesktopDeepLink(extractDesktopDeepLink(process.argv)) + flushPendingDesktopDeepLinks() }) .catch((error) => { console.error('[desktop-main] app.whenReady failed', error) @@ -873,6 +953,12 @@ if (!hasSingleInstanceLock) { revealActiveWindowSafely('activate') }) + app.on('open-url', (event, url) => { + event.preventDefault() + revealActiveWindowSafely('deep-link') + queueDesktopDeepLink(url) + }) + app.on('window-all-closed', () => { if (getDesktopAppSettings().keepRunningInBackground) { return diff --git a/apps/desktop-client/src/main/single-instance.ts b/apps/desktop-client/src/main/single-instance.ts index 81daaec..dde4d67 100644 --- a/apps/desktop-client/src/main/single-instance.ts +++ b/apps/desktop-client/src/main/single-instance.ts @@ -2,7 +2,7 @@ import { app } from 'electron/main' let initialized = false -export function initSingleInstance(onSecondInstance?: () => void): boolean { +export function initSingleInstance(onSecondInstance?: (argv: string[]) => void): boolean { if (initialized) { return true } @@ -13,8 +13,8 @@ export function initSingleInstance(onSecondInstance?: () => void): boolean { return false } - app.on('second-instance', () => { - onSecondInstance?.() + app.on('second-instance', (_event, argv) => { + onSecondInstance?.(argv) }) return true diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 9ac49ca..6462c76 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -241,6 +241,8 @@ export interface DesktopPublishTaskListResponse { history_total: number } +export type TenantPublishTaskListResponse = DesktopPublishTaskListResponse + export interface LeaseDesktopTaskRequest { task_id?: string kind?: 'publish' | 'monitor' diff --git a/server/internal/tenant/app/desktop_task_service.go b/server/internal/tenant/app/desktop_task_service.go index 1a1922f..09f8e04 100644 --- a/server/internal/tenant/app/desktop_task_service.go +++ b/server/internal/tenant/app/desktop_task_service.go @@ -141,6 +141,12 @@ type DesktopPublishTaskList struct { HistoryTotal int `json:"history_total"` } +type publishTaskListOwner struct { + TenantID int64 + WorkspaceID int64 + UserID int64 +} + func (s *DesktopTaskService) Lease(ctx context.Context, client *repository.DesktopClient, req LeaseDesktopTaskRequest, routeTaskID *uuid.UUID) (*LeaseDesktopTaskResponse, error) { if client == nil { return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required") @@ -858,6 +864,26 @@ func (s *DesktopTaskService) ListPublishTasks(ctx context.Context, client *repos if client == nil { return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required") } + return s.listPublishTasksForOwner(ctx, publishTaskListOwner{ + TenantID: client.TenantID, + WorkspaceID: client.WorkspaceID, + UserID: client.UserID, + }, req) +} + +func (s *DesktopTaskService) ListTenantPublishTasks(ctx context.Context, actor auth.Actor, req ListPublishTasksRequest) (*DesktopPublishTaskList, error) { + workspaceID := auth.CurrentWorkspaceID(ctx) + if actor.TenantID == 0 || actor.UserID == 0 || workspaceID == 0 { + return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required") + } + return s.listPublishTasksForOwner(ctx, publishTaskListOwner{ + TenantID: actor.TenantID, + WorkspaceID: workspaceID, + UserID: actor.UserID, + }, req) +} + +func (s *DesktopTaskService) listPublishTasksForOwner(ctx context.Context, owner publishTaskListOwner, req ListPublishTasksRequest) (*DesktopPublishTaskList, error) { if s.pool == nil { return nil, response.ErrServiceUnavailable(50342, "desktop_publish_job_store_unavailable", "desktop publish job store is unavailable") } @@ -877,7 +903,7 @@ func (s *DesktopTaskService) ListPublishTasks(ctx context.Context, client *repos pendingItems, err := s.listPublishTasksByStatuses( ctx, - client, + owner, pendingStatuses, title, 0, @@ -888,7 +914,7 @@ func (s *DesktopTaskService) ListPublishTasks(ctx context.Context, client *repos return nil, err } - historyTotal, err := s.countPublishTasksByStatuses(ctx, client, historyStatuses, title) + historyTotal, err := s.countPublishTasksByStatuses(ctx, owner, historyStatuses, title) if err != nil { return nil, err } @@ -914,7 +940,7 @@ func (s *DesktopTaskService) ListPublishTasks(ctx context.Context, client *repos if remaining > 0 && historyOffset < historyTotal { historyItems, listErr := s.listPublishTasksByStatuses( ctx, - client, + owner, historyStatuses, title, remaining, @@ -939,7 +965,7 @@ func (s *DesktopTaskService) ListPublishTasks(ctx context.Context, client *repos func (s *DesktopTaskService) listPublishTasksByStatuses( ctx context.Context, - client *repository.DesktopClient, + owner publishTaskListOwner, statuses []string, title string, limit int, @@ -974,22 +1000,24 @@ func (s *DesktopTaskService) listPublishTasksByStatuses( JOIN desktop_publish_jobs AS j ON j.desktop_id = t.job_id AND j.workspace_id = t.workspace_id - WHERE t.workspace_id = $1 - AND j.created_by_user_id = $2 + WHERE t.tenant_id = $1 + AND t.workspace_id = $2 + AND j.tenant_id = $1 + AND j.created_by_user_id = $3 AND t.kind = 'publish' - AND t.status = ANY($3) + AND t.status = ANY($4) AND ( - $4 = '' - OR COALESCE(t.payload->>'title', j.title, '') ILIKE '%' || $4 || '%' + $5 = '' + OR COALESCE(t.payload->>'title', j.title, '') ILIKE '%' || $5 || '%' ) ` - args := []any{client.WorkspaceID, client.UserID, statuses, title} + args := []any{owner.TenantID, owner.WorkspaceID, owner.UserID, statuses, title} if strings.TrimSpace(orderBy) != "" { query += "\nORDER BY " + orderBy } if limit > 0 { - query += "\nLIMIT $5 OFFSET $6" + query += "\nLIMIT $6 OFFSET $7" args = append(args, limit, offset) } @@ -1004,7 +1032,7 @@ func (s *DesktopTaskService) listPublishTasksByStatuses( func (s *DesktopTaskService) countPublishTasksByStatuses( ctx context.Context, - client *repository.DesktopClient, + owner publishTaskListOwner, statuses []string, title string, ) (int, error) { @@ -1015,15 +1043,17 @@ func (s *DesktopTaskService) countPublishTasksByStatuses( JOIN desktop_publish_jobs AS j ON j.desktop_id = t.job_id AND j.workspace_id = t.workspace_id - WHERE t.workspace_id = $1 - AND j.created_by_user_id = $2 + WHERE t.tenant_id = $1 + AND t.workspace_id = $2 + AND j.tenant_id = $1 + AND j.created_by_user_id = $3 AND t.kind = 'publish' - AND t.status = ANY($3) + AND t.status = ANY($4) AND ( - $4 = '' - OR COALESCE(t.payload->>'title', j.title, '') ILIKE '%' || $4 || '%' + $5 = '' + OR COALESCE(t.payload->>'title', j.title, '') ILIKE '%' || $5 || '%' ) - `, client.WorkspaceID, client.UserID, statuses, title).Scan(&count) + `, owner.TenantID, owner.WorkspaceID, owner.UserID, statuses, title).Scan(&count) if err != nil { return 0, response.ErrInternal(50109, "desktop_publish_tasks_query_failed", "failed to list desktop publish tasks") } diff --git a/server/internal/tenant/app/publish_job_service.go b/server/internal/tenant/app/publish_job_service.go index e43789e..d269c39 100644 --- a/server/internal/tenant/app/publish_job_service.go +++ b/server/internal/tenant/app/publish_job_service.go @@ -414,6 +414,64 @@ func (s *PublishJobService) RetryByDesktopTask( }) } +func (s *PublishJobService) RetryTenantDesktopTask( + ctx context.Context, + actor auth.Actor, + taskID uuid.UUID, +) (*CreatePublishJobResponse, error) { + workspaceID := auth.CurrentWorkspaceID(ctx) + if actor.TenantID == 0 || actor.UserID == 0 || workspaceID == 0 { + return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required") + } + if s.pool == nil { + return nil, response.ErrServiceUnavailable(50342, "desktop_publish_job_store_unavailable", "desktop publish job store is unavailable") + } + + taskRepo := repository.NewDesktopTaskRepository(s.pool) + task, err := taskRepo.GetByDesktopID(ctx, taskID, workspaceID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, response.ErrNotFound(40482, "desktop_task_not_found", "desktop task not found") + } + return nil, response.ErrInternal(50095, "desktop_task_lookup_failed", "failed to inspect desktop task state") + } + if task.Kind != "publish" { + return nil, response.ErrBadRequest(40095, "desktop_task_not_publish", "only publish tasks can be retried") + } + if err := s.authorizePublishTaskForActor(ctx, actor, workspaceID, task); err != nil { + return nil, err + } + if task.Status == "queued" || task.Status == "in_progress" { + return nil, response.ErrConflict(40994, "desktop_task_retry_conflict", "desktop task is still pending or running") + } + + payload := unmarshalJSONObject(task.Payload) + articleID, err := s.resolveRetryArticleID(ctx, actor.TenantID, payload) + if err != nil { + return nil, err + } + + title := strings.TrimSpace(stringPointerValue(extractStringPointer(payload, "title"))) + if title == "" { + title = "文章发布" + } + + return s.Create(ctx, auth.Actor{ + TenantID: actor.TenantID, + UserID: actor.UserID, + PrimaryWorkspaceID: workspaceID, + Role: actor.Role, + }, CreatePublishJobRequest{ + Title: title, + ContentRef: map[string]any{ + "article_id": articleID, + }, + Accounts: []CreatePublishJobAccountRequest{ + {AccountID: task.TargetAccountID.String()}, + }, + }) +} + func (s *PublishJobService) authorizePublishTaskForClientUser( ctx context.Context, client *repository.DesktopClient, @@ -442,6 +500,36 @@ func (s *PublishJobService) authorizePublishTaskForClientUser( return nil } +func (s *PublishJobService) authorizePublishTaskForActor( + ctx context.Context, + actor auth.Actor, + workspaceID int64, + task *repository.DesktopTask, +) error { + if task == nil || actor.TenantID == 0 || actor.UserID == 0 || workspaceID == 0 { + return response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required") + } + + var ownsTask bool + err := s.pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 + FROM desktop_publish_jobs + WHERE tenant_id = $1 + AND desktop_id = $2 + AND workspace_id = $3 + AND created_by_user_id = $4 + ) + `, actor.TenantID, task.JobID, workspaceID, actor.UserID).Scan(&ownsTask) + if err != nil { + return response.ErrInternal(50115, "desktop_publish_job_lookup_failed", "failed to validate desktop publish task ownership") + } + if !ownsTask { + return response.ErrForbidden(40384, "desktop_publish_task_not_owned", "desktop publish task belongs to another user") + } + return nil +} + func (s *PublishJobService) resolveRetryArticleID(ctx context.Context, tenantID int64, payload map[string]any) (int64, error) { if articleID, ok := resolveRetryArticleIDFromPayload(payload); ok { return articleID, nil diff --git a/server/internal/tenant/transport/desktop_task_handler.go b/server/internal/tenant/transport/desktop_task_handler.go index cf50a05..fc6473d 100644 --- a/server/internal/tenant/transport/desktop_task_handler.go +++ b/server/internal/tenant/transport/desktop_task_handler.go @@ -65,34 +65,10 @@ func (h *DesktopTaskHandler) Lease(c *gin.Context) { } func (h *DesktopTaskHandler) ListPublish(c *gin.Context) { - req := app.ListPublishTasksRequest{ - Page: 1, - PageSize: 10, - Title: strings.TrimSpace(c.Query("title")), + req, ok := parseListPublishTasksRequest(c) + if !ok { + return } - - if rawPage := c.Query("page"); rawPage != "" { - parsed, err := strconv.Atoi(rawPage) - if err != nil || parsed <= 0 { - response.Error(c, response.ErrBadRequest(40001, "invalid_params", "page must be a positive integer")) - return - } - req.Page = parsed - } - - rawPageSize := c.Query("page_size") - if rawPageSize == "" { - rawPageSize = c.Query("limit") - } - if rawPageSize != "" { - parsed, err := strconv.Atoi(rawPageSize) - if err != nil || parsed <= 0 { - response.Error(c, response.ErrBadRequest(40001, "invalid_params", "page_size must be a positive integer")) - return - } - req.PageSize = parsed - } - data, err := h.svc.ListPublishTasks(c.Request.Context(), MustDesktopClient(c.Request.Context()), req) if err != nil { response.Error(c, err) @@ -101,6 +77,19 @@ func (h *DesktopTaskHandler) ListPublish(c *gin.Context) { response.Success(c, data) } +func (h *DesktopTaskHandler) TenantListPublish(c *gin.Context) { + req, ok := parseListPublishTasksRequest(c) + if !ok { + return + } + data, err := h.svc.ListTenantPublishTasks(c.Request.Context(), auth.MustActor(c.Request.Context()), req) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + func (h *DesktopTaskHandler) Extend(c *gin.Context) { desktopID, err := uuid.Parse(c.Param("id")) if err != nil { @@ -158,6 +147,21 @@ func (h *DesktopTaskHandler) RetryPublish(c *gin.Context) { response.Success(c, data) } +func (h *DesktopTaskHandler) TenantRetryPublish(c *gin.Context) { + desktopID, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Error(c, response.ErrBadRequest(40084, "invalid_desktop_task_id", "task id must be a uuid")) + return + } + + data, err := h.publishSvc.RetryTenantDesktopTask(c.Request.Context(), auth.MustActor(c.Request.Context()), desktopID) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + func (h *DesktopTaskHandler) Cancel(c *gin.Context) { desktopID, err := uuid.Parse(c.Param("id")) if err != nil { @@ -179,6 +183,38 @@ func (h *DesktopTaskHandler) Cancel(c *gin.Context) { response.Success(c, data) } +func parseListPublishTasksRequest(c *gin.Context) (app.ListPublishTasksRequest, bool) { + req := app.ListPublishTasksRequest{ + Page: 1, + PageSize: 10, + Title: strings.TrimSpace(c.Query("title")), + } + + if rawPage := c.Query("page"); rawPage != "" { + parsed, err := strconv.Atoi(rawPage) + if err != nil || parsed <= 0 { + response.Error(c, response.ErrBadRequest(40001, "invalid_params", "page must be a positive integer")) + return req, false + } + req.Page = parsed + } + + rawPageSize := c.Query("page_size") + if rawPageSize == "" { + rawPageSize = c.Query("limit") + } + if rawPageSize != "" { + parsed, err := strconv.Atoi(rawPageSize) + if err != nil || parsed <= 0 { + response.Error(c, response.ErrBadRequest(40001, "invalid_params", "page_size must be a positive integer")) + return req, false + } + req.PageSize = parsed + } + + return req, true +} + func (h *DesktopTaskHandler) TenantCancel(c *gin.Context) { desktopID, err := uuid.Parse(c.Param("id")) if err != nil { diff --git a/server/internal/tenant/transport/router.go b/server/internal/tenant/transport/router.go index ec9caf4..4e79e28 100644 --- a/server/internal/tenant/transport/router.go +++ b/server/internal/tenant/transport/router.go @@ -74,6 +74,8 @@ func RegisterRoutes(app *bootstrap.App) { tenantProtected.POST("/accounts/:id/request-delete", desktopAccountHandler.RequestDelete) tenantProtected.POST("/tasks/:id/reconcile", desktopTaskHandler.Reconcile) tenantProtected.POST("/tasks/:id/cancel", desktopTaskHandler.TenantCancel) + tenantProtected.GET("/publish-tasks", desktopTaskHandler.TenantListPublish) + tenantProtected.POST("/publish-tasks/:id/retry", desktopTaskHandler.TenantRetryPublish) tenantProtected.POST("/publish-jobs", publishJobHandler.Create) complianceHandler := NewComplianceHandler(app) diff --git a/server/internal/tenant/transport/router_test.go b/server/internal/tenant/transport/router_test.go index 18655ec..980cfa1 100644 --- a/server/internal/tenant/transport/router_test.go +++ b/server/internal/tenant/transport/router_test.go @@ -30,6 +30,8 @@ func TestProtectedRoutes_RequireAuth(t *testing.T) { {http.MethodPost, "/api/tenant/accounts/:id/request-delete"}, {http.MethodPost, "/api/tenant/tasks/:id/reconcile"}, {http.MethodPost, "/api/tenant/tasks/:id/cancel"}, + {http.MethodGet, "/api/tenant/publish-tasks"}, + {http.MethodPost, "/api/tenant/publish-tasks/:id/retry"}, {http.MethodPost, "/api/tenant/publish-jobs"}, {http.MethodGet, "/api/tenant/media/platforms"}, {http.MethodGet, "/api/tenant/workspace/overview"}, @@ -77,10 +79,12 @@ func TestDesktopClientRoutes_ClientTokenAuth(t *testing.T) { }{ {http.MethodGet, "/api/desktop/dispatch"}, {http.MethodGet, "/api/desktop/accounts"}, + {http.MethodGet, "/api/desktop/publish-tasks"}, {http.MethodGet, "/api/desktop/content/assets/:token"}, {http.MethodPost, "/api/desktop/clients/offline"}, {http.MethodPost, "/api/desktop/tasks/lease"}, {http.MethodPost, "/api/desktop/tasks/:id/result"}, + {http.MethodPost, "/api/desktop/publish-tasks/:id/retry"}, {http.MethodPost, "/api/desktop/monitoring/tasks/lease"}, {http.MethodPost, "/api/desktop/monitoring/tasks/resume"}, {http.MethodPost, "/api/desktop/monitoring/tasks/:id/result"},