Files
geo/server/internal/tenant/app/publish_job_service.go
T

448 lines
15 KiB
Go
Raw Normal View History

package app
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
goredis "github.com/redis/go-redis/v9"
"go.uber.org/zap"
"github.com/geo-platform/tenant-api/internal/shared/auth"
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/shared/stream"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type PublishJobService struct {
pool *pgxpool.Pool
messaging *rabbitmq.Client
redis *goredis.Client
logger *zap.Logger
cache sharedcache.Cache
}
func NewPublishJobService(
pool *pgxpool.Pool,
messaging *rabbitmq.Client,
redis *goredis.Client,
logger *zap.Logger,
) *PublishJobService {
return &PublishJobService{
pool: pool,
messaging: messaging,
redis: redis,
logger: logger,
}
}
func (s *PublishJobService) WithCache(c sharedcache.Cache) *PublishJobService {
s.cache = c
return s
}
type CreatePublishJobAccountRequest struct {
AccountID string `json:"account_id" binding:"required"`
}
type CreatePublishJobRequest struct {
Title string `json:"title" binding:"required"`
ContentRef map[string]any `json:"content_ref" binding:"required"`
Accounts []CreatePublishJobAccountRequest `json:"accounts" binding:"required,min=1"`
ScheduledAt *time.Time `json:"scheduled_at"`
}
type CreatePublishJobResponse struct {
JobID string `json:"job_id"`
TaskIDs []string `json:"task_ids"`
}
func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req CreatePublishJobRequest) (*CreatePublishJobResponse, error) {
if actor.TenantID == 0 || actor.PrimaryWorkspaceID == 0 || actor.UserID == 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")
}
contentRefJSON, err := marshalOptionalJSON(req.ContentRef)
if err != nil {
return nil, response.ErrBadRequest(40091, "invalid_content_ref", "content_ref must be serializable")
}
articleID, err := resolvePublishArticleID(req.ContentRef)
if err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(50096, "desktop_publish_job_begin_failed", "failed to start desktop publish job transaction")
}
defer tx.Rollback(ctx)
accountRepo := repository.NewDesktopAccountRepository(tx)
taskRepo := repository.NewDesktopTaskRepository(tx)
articleMeta, err := loadPublishableArticleMeta(ctx, tx, actor.TenantID, articleID)
if err != nil {
return nil, err
}
type publishTarget struct {
account *repository.DesktopAccount
accountSeed *platformAccountSeed
targetClientID uuid.UUID
}
targets := make([]publishTarget, 0, len(req.Accounts))
for _, accountReq := range req.Accounts {
accountID, parseErr := uuid.Parse(strings.TrimSpace(accountReq.AccountID))
if parseErr != nil {
return nil, response.ErrBadRequest(40092, "invalid_desktop_account_id", "account_id must be a uuid")
}
account, lookupErr := accountRepo.GetByDesktopID(ctx, accountID, actor.PrimaryWorkspaceID)
if lookupErr != nil {
if errors.Is(lookupErr, pgx.ErrNoRows) {
return nil, response.ErrBadRequest(40093, "desktop_account_not_found", "one or more selected desktop accounts do not exist")
}
return nil, response.ErrInternal(50098, "desktop_account_lookup_failed", "failed to load selected desktop account")
}
if account.Health != "live" {
return nil, response.ErrConflict(40993, "desktop_account_not_publishable", "one or more selected desktop accounts require re-authorization before publishing")
}
targetClientID := s.resolveTargetClientID(ctx, account)
if targetClientID == nil {
return nil, response.ErrConflict(40992, "desktop_account_client_missing", "one or more selected desktop accounts are not currently available on any desktop client")
}
accountSeed, seedErr := loadPlatformAccountSeedByDesktopID(ctx, tx, actor.TenantID, actor.PrimaryWorkspaceID, account.DesktopID)
if seedErr != nil {
return nil, seedErr
}
targets = append(targets, publishTarget{
account: account,
accountSeed: accountSeed,
targetClientID: *targetClientID,
})
}
jobID := uuid.New()
job, err := taskRepo.CreatePublishJob(ctx, repository.CreateDesktopPublishJobParams{
DesktopID: jobID,
TenantID: actor.TenantID,
WorkspaceID: actor.PrimaryWorkspaceID,
CreatedByUserID: actor.UserID,
Title: req.Title,
ContentRef: contentRefJSON,
ScheduledAt: req.ScheduledAt,
})
if err != nil {
return nil, response.ErrInternal(50097, "desktop_publish_job_create_failed", "failed to create desktop publish job")
}
accountSeeds := make([]platformAccountSeed, 0, len(targets))
for _, target := range targets {
accountSeeds = append(accountSeeds, *target.accountSeed)
}
if publishBatchRequiresCover(accountSeeds) && strings.TrimSpace(pointerStringValue(articleMeta.CoverAssetURL)) == "" {
return nil, response.ErrBadRequest(40044, "publish_cover_required", "baijiahao publishing requires a cover image")
}
publishBatchID, publishRecordIDs, err := createPublishBatchRecords(
ctx,
tx,
actor.TenantID,
actor.UserID,
articleID,
"publish",
articleMeta.CoverAssetURL,
accountSeeds,
)
if err != nil {
return nil, err
}
taskIDs := make([]string, 0, len(targets))
createdTasks := make([]*repository.DesktopTask, 0, len(targets))
for _, target := range targets {
publishRecordID := publishRecordIDs[target.accountSeed.ID]
payloadJSON, payloadErr := marshalOptionalJSON(map[string]any{
"title": req.Title,
"content_ref": req.ContentRef,
"account_id": target.account.DesktopID.String(),
"platform": target.account.Platform,
"article_id": articleID,
"publish_batch_id": publishBatchID,
"publish_record_id": publishRecordID,
"platform_account_id": target.accountSeed.ID,
})
if payloadErr != nil {
return nil, response.ErrBadRequest(40094, "invalid_desktop_task_payload", "publish job payload must be serializable")
}
task, createErr := taskRepo.CreateTask(ctx, repository.CreateDesktopTaskParams{
DesktopID: uuid.New(),
JobID: job.DesktopID,
TenantID: actor.TenantID,
WorkspaceID: actor.PrimaryWorkspaceID,
TargetAccountID: target.account.DesktopID,
TargetClientID: target.targetClientID,
Platform: target.account.Platform,
Kind: "publish",
Payload: payloadJSON,
Status: "queued",
})
if createErr != nil {
return nil, response.ErrInternal(50099, "desktop_task_create_failed", "failed to create desktop publish task")
}
createdTasks = append(createdTasks, task)
taskIDs = append(taskIDs, task.DesktopID.String())
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50100, "desktop_publish_job_commit_failed", "failed to commit desktop publish job")
}
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &articleID)
for _, task := range createdTasks {
title, businessDate, schedulerGroupKey, questionText := deriveDesktopTaskEventMetadataFromPayload(task.Kind, task.Payload)
if publishErr := publishDesktopTaskEvent(context.Background(), s.messaging, DesktopTaskEvent{
Type: "task_available",
TaskID: task.DesktopID.String(),
JobID: task.JobID.String(),
WorkspaceID: task.WorkspaceID,
TargetAccountID: task.TargetAccountID.String(),
TargetClientID: task.TargetClientID.String(),
Platform: task.Platform,
Title: title,
BusinessDate: businessDate,
SchedulerGroupKey: schedulerGroupKey,
QuestionText: questionText,
Status: task.Status,
Kind: task.Kind,
UpdatedAt: task.UpdatedAt,
}); publishErr != nil {
s.logWarn("desktop task available event publish failed", publishErr, zap.String("task_id", task.DesktopID.String()))
}
s.publishDispatchAvailable(task, stream.DesktopDispatchEvent{
Type: "task_available",
TaskID: task.DesktopID.String(),
JobID: task.JobID.String(),
WorkspaceID: task.WorkspaceID,
TargetAccountID: task.TargetAccountID.String(),
TargetClientID: task.TargetClientID.String(),
Platform: task.Platform,
Title: title,
BusinessDate: businessDate,
SchedulerGroupKey: schedulerGroupKey,
QuestionText: questionText,
Status: task.Status,
Kind: task.Kind,
UpdatedAt: task.UpdatedAt,
})
}
return &CreatePublishJobResponse{
JobID: job.DesktopID.String(),
TaskIDs: taskIDs,
}, nil
}
func (s *PublishJobService) RetryByDesktopTask(
ctx context.Context,
client *repository.DesktopClient,
taskID uuid.UUID,
) (*CreatePublishJobResponse, error) {
if client == nil {
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client 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, client.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.authorizePublishTaskForClientUser(ctx, client, 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, client.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: client.TenantID,
UserID: client.UserID,
PrimaryWorkspaceID: client.WorkspaceID,
}, 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,
task *repository.DesktopTask,
) error {
if client == nil || task == nil {
return response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
}
var ownsTask bool
err := s.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM desktop_publish_jobs
WHERE desktop_id = $1
AND workspace_id = $2
AND created_by_user_id = $3
)
`, task.JobID, client.WorkspaceID, client.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
}
if publishRecordID, ok := extractInt64FromMap(payload, "publish_record_id"); ok && publishRecordID > 0 {
var articleID int64
err := s.pool.QueryRow(ctx, `
SELECT article_id
FROM publish_records
WHERE id = $1 AND tenant_id = $2
`, publishRecordID, tenantID).Scan(&articleID)
if err == nil && articleID > 0 {
return articleID, nil
}
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return 0, response.ErrInternal(50113, "desktop_publish_record_lookup_failed", "failed to lookup desktop publish record")
}
}
if publishBatchID, ok := extractInt64FromMap(payload, "publish_batch_id"); ok && publishBatchID > 0 {
var articleID int64
err := s.pool.QueryRow(ctx, `
SELECT article_id
FROM publish_batches
WHERE id = $1 AND tenant_id = $2
`, publishBatchID, tenantID).Scan(&articleID)
if err == nil && articleID > 0 {
return articleID, nil
}
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return 0, response.ErrInternal(50114, "desktop_publish_batch_lookup_failed", "failed to lookup desktop publish batch")
}
}
return 0, response.ErrBadRequest(40096, "desktop_task_article_missing", "desktop publish task is missing article_id")
}
func resolveRetryArticleIDFromPayload(payload map[string]any) (int64, bool) {
if articleID, ok := extractInt64FromMap(payload, "article_id"); ok && articleID > 0 {
return articleID, true
}
if len(payload) == 0 {
return 0, false
}
nestedContentRef, ok := payload["content_ref"].(map[string]any)
if !ok {
return 0, false
}
if articleID, ok := extractInt64FromMap(nestedContentRef, "article_id", "id"); ok && articleID > 0 {
return articleID, true
}
return 0, false
}
// publishDispatchAvailable pushes a per-client dispatch frame onto the topic
// exchange. Every tenant-api instance consumes the exchange and forwards the
// frame to the WebSocket owning the target client id. This is the primary
// signal the desktop client reacts to; the fanout event above is kept only so
// that other UIs (admin-web) see the same task lifecycle.
func (s *PublishJobService) publishDispatchAvailable(task *repository.DesktopTask, event stream.DesktopDispatchEvent) {
if s == nil || s.messaging == nil || task == nil {
return
}
if err := publishDesktopDispatchEvent(context.Background(), s.messaging, s.logger, event); err != nil {
s.logWarn("desktop dispatch publish failed", err,
zap.String("task_id", task.DesktopID.String()),
)
}
}
func (s *PublishJobService) logWarn(message string, err error, fields ...zap.Field) {
if s.logger == nil || err == nil {
return
}
fields = append(fields, zap.Error(err))
s.logger.Warn(message, fields...)
}
func (s *PublishJobService) resolveTargetClientID(ctx context.Context, account *repository.DesktopAccount) *uuid.UUID {
if account == nil {
return nil
}
presence := loadDesktopAccountPresence(ctx, s.redis, []uuid.UUID{account.DesktopID})
if clientID, ok := presence[account.DesktopID]; ok {
return &clientID
}
if account.ClientID == nil {
return nil
}
clientID := *account.ClientID
return &clientID
}
func stringPointerValue(value *string) string {
if value == nil {
return ""
}
return strings.TrimSpace(*value)
}