feat(desktop): drop parked review flow, add publish management
SaaS 侧人工审核后才创建 publish job,desktop 不再做二次审核:移除 manual/waiting_user/parked/from_parked 状态机与 LeaseFromParked 查询, desktop client 只执行发布并新增"发布管理"页(待发布队列 / 历史 / 再次 发送)。同时抽离 @geo/publisher-platforms 共享适配器包、新增 Redis-based desktop_presence 与 publish_record_support,刷新 admin-web 发布弹窗与 媒体库;plan A / spec 文档同步口径。
This commit is contained in:
@@ -9,9 +9,11 @@ import (
|
||||
"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/tenant/repository"
|
||||
@@ -20,20 +22,32 @@ import (
|
||||
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, logger *zap.Logger) *PublishJobService {
|
||||
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"`
|
||||
Mode string `json:"mode" binding:"required,oneof=auto manual"`
|
||||
}
|
||||
|
||||
type CreatePublishJobRequest struct {
|
||||
@@ -61,6 +75,10 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
|
||||
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 {
|
||||
@@ -70,6 +88,50 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
|
||||
|
||||
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{
|
||||
@@ -85,31 +147,41 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
|
||||
return nil, response.ErrInternal(50097, "desktop_publish_job_create_failed", "failed to create desktop publish job")
|
||||
}
|
||||
|
||||
taskIDs := make([]string, 0, len(req.Accounts))
|
||||
createdTasks := make([]*repository.DesktopTask, 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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
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.ClientID == nil {
|
||||
return nil, response.ErrConflict(40992, "desktop_account_client_missing", "one or more selected desktop accounts are not currently bound to a desktop client")
|
||||
}
|
||||
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,
|
||||
"mode": accountReq.Mode,
|
||||
"account_id": account.DesktopID.String(),
|
||||
"platform": account.Platform,
|
||||
"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")
|
||||
@@ -120,9 +192,9 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
|
||||
JobID: job.DesktopID,
|
||||
TenantID: actor.TenantID,
|
||||
WorkspaceID: actor.PrimaryWorkspaceID,
|
||||
TargetAccountID: account.DesktopID,
|
||||
TargetClientID: *account.ClientID,
|
||||
Platform: account.Platform,
|
||||
TargetAccountID: target.account.DesktopID,
|
||||
TargetClientID: target.targetClientID,
|
||||
Platform: target.account.Platform,
|
||||
Kind: "publish",
|
||||
Payload: payloadJSON,
|
||||
Status: "queued",
|
||||
@@ -138,6 +210,7 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
|
||||
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 {
|
||||
if publishErr := publishDesktopTaskEvent(context.Background(), s.messaging, DesktopTaskEvent{
|
||||
@@ -160,6 +233,62 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
|
||||
}, 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 task.TargetClientID != client.ID {
|
||||
return nil, response.ErrForbidden(40381, "desktop_task_not_target_client", "desktop task is assigned to another desktop client")
|
||||
}
|
||||
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, ok := extractInt64FromMap(payload, "article_id")
|
||||
if !ok || articleID <= 0 {
|
||||
return nil, response.ErrBadRequest(40096, "desktop_task_article_missing", "desktop publish task is missing article_id")
|
||||
}
|
||||
|
||||
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) logWarn(message string, err error, fields ...zap.Field) {
|
||||
if s.logger == nil || err == nil {
|
||||
return
|
||||
@@ -167,3 +296,28 @@ func (s *PublishJobService) logWarn(message string, err error, fields ...zap.Fie
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user