feat(publish): add tenant publish management with desktop deep-link

Expose tenant-authenticated publish task list and retry endpoints so the
admin web can show desktop publish state without an Electron bridge, and
register a `shengxintui://` deep-link so the page can hand off to the
desktop client workbench.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 12:07:28 +08:00
parent c347f583a4
commit c1e7c5e90c
15 changed files with 1429 additions and 56 deletions
@@ -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