feat(schedule-tasks): support auto-publish via media accounts

Replace the legacy target_platform string on schedule tasks with a
workspace-aware auto-publish payload (auto_publish + publish_account_ids
JSONB + cover_asset_url + cover_image_asset_id) and migrate the schema,
sqlc queries, generated models, domain struct, ScheduleTaskService DTOs,
and dispatch worker to round-trip the new fields. PromptRuleGeneration
gains a WithPublishJobService hook so executeGeneration can enqueue an
auto-publish job once the article is ready, and worker-generate wires
the publish-job service in. On the admin-web side, extract
PublishArticleModal's account-card builders into a shared
publish-account-cards module, rebuild GenerateTaskDrawer's schedule
mode around account selection plus a CoverPickerModal, and surface the
new "auto publish" column on ScheduleTaskTab. Shared types and i18n
strings cover the new fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 01:32:19 +08:00
parent c9267d2fae
commit 6e6e19cccb
22 changed files with 1380 additions and 603 deletions
@@ -9,6 +9,7 @@ import (
"time"
"github.com/jackc/pgx/v5/pgxpool"
"go.uber.org/zap"
"github.com/geo-platform/tenant-api/internal/shared/auth"
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
@@ -31,6 +32,8 @@ type PromptRuleGenerationService struct {
articleTimeout time.Duration
maxOutputTokens int64
cache sharedcache.Cache
publishJobs *PublishJobService
logger *zap.Logger
}
type promptRuleGenerationJob struct {
@@ -46,10 +49,9 @@ type promptRuleGenerationJob struct {
}
type GenerateFromRuleRequest struct {
PromptRuleID int64 `json:"prompt_rule_id" binding:"required"`
BrandID *int64 `json:"brand_id"`
TargetPlatform *string `json:"target_platform"`
ExtraParams map[string]interface{} `json:"extra_params"`
PromptRuleID int64 `json:"prompt_rule_id" binding:"required"`
BrandID *int64 `json:"brand_id"`
ExtraParams map[string]interface{} `json:"extra_params"`
}
type GenerateFromRuleResponse struct {
@@ -69,16 +71,20 @@ type GenerateFromRuleItem struct {
}
type SchedulePromptRuleGenerationInput struct {
ScheduleTaskID int64
OperatorID *int64
TenantID int64
PromptRuleID int64
BrandID *int64
Name string
TargetPlatform *string
EnableWebSearch bool
GenerateCount int
ScheduledFor time.Time
ScheduleTaskID int64
OperatorID *int64
TenantID int64
PromptRuleID int64
BrandID *int64
Name string
WorkspaceID int64
AutoPublish bool
PublishAccountIDs []string
CoverAssetURL *string
CoverImageAssetID *int64
EnableWebSearch bool
GenerateCount int
ScheduledFor time.Time
}
type promptRuleGenerationRecord struct {
@@ -96,13 +102,12 @@ type promptRuleGenerationRecord struct {
const promptRuleGeneratingTitlePlaceholder = "标题生成中"
type enqueuePromptRuleGenerationInput struct {
OperatorID *int64
TenantID int64
PromptRuleID int64
BrandID *int64
TargetPlatform *string
ExtraParams map[string]interface{}
FallbackName string
OperatorID *int64
TenantID int64
PromptRuleID int64
BrandID *int64
ExtraParams map[string]interface{}
FallbackName string
}
func NewPromptRuleGenerationService(
@@ -138,6 +143,16 @@ func (s *PromptRuleGenerationService) WithCache(c sharedcache.Cache) *PromptRule
return s
}
func (s *PromptRuleGenerationService) WithPublishJobService(publishJobs *PublishJobService) *PromptRuleGenerationService {
s.publishJobs = publishJobs
return s
}
func (s *PromptRuleGenerationService) WithLogger(logger *zap.Logger) *PromptRuleGenerationService {
s.logger = logger
return s
}
func (s *PromptRuleGenerationService) GenerateFromRule(ctx context.Context, req GenerateFromRuleRequest) (*GenerateFromRuleResponse, error) {
actor := auth.MustActor(ctx)
extra := clonePromptRuleExtraParams(req.ExtraParams)
@@ -159,13 +174,12 @@ func (s *PromptRuleGenerationService) GenerateFromRule(ctx context.Context, req
var lastErr error
for idx := 0; idx < generateCount; idx++ {
result, enqueueErr := s.enqueuePromptRuleGeneration(ctx, enqueuePromptRuleGenerationInput{
OperatorID: &actor.UserID,
TenantID: actor.TenantID,
PromptRuleID: req.PromptRuleID,
BrandID: req.BrandID,
TargetPlatform: req.TargetPlatform,
ExtraParams: clonePromptRuleExtraParams(extra),
FallbackName: "即时任务",
OperatorID: &actor.UserID,
TenantID: actor.TenantID,
PromptRuleID: req.PromptRuleID,
BrandID: req.BrandID,
ExtraParams: clonePromptRuleExtraParams(extra),
FallbackName: "即时任务",
})
if enqueueErr != nil {
lastErr = enqueueErr
@@ -193,6 +207,19 @@ func (s *PromptRuleGenerationService) EnqueueScheduledGeneration(ctx context.Con
"schedule_task_id": input.ScheduleTaskID,
"enable_web_search": input.EnableWebSearch,
}
if input.WorkspaceID > 0 {
extra["workspace_id"] = input.WorkspaceID
}
if input.AutoPublish {
extra["schedule_auto_publish"] = true
extra["schedule_publish_account_ids"] = input.PublishAccountIDs
if input.CoverAssetURL != nil {
extra["schedule_cover_asset_url"] = strings.TrimSpace(*input.CoverAssetURL)
}
if input.CoverImageAssetID != nil {
extra["schedule_cover_image_asset_id"] = *input.CoverImageAssetID
}
}
if input.GenerateCount > 0 {
extra["generate_count"] = input.GenerateCount
}
@@ -204,13 +231,12 @@ func (s *PromptRuleGenerationService) EnqueueScheduledGeneration(ctx context.Con
}
return s.enqueuePromptRuleGeneration(ctx, enqueuePromptRuleGenerationInput{
OperatorID: input.OperatorID,
TenantID: input.TenantID,
PromptRuleID: input.PromptRuleID,
BrandID: input.BrandID,
TargetPlatform: input.TargetPlatform,
ExtraParams: extra,
FallbackName: "定时任务",
OperatorID: input.OperatorID,
TenantID: input.TenantID,
PromptRuleID: input.PromptRuleID,
BrandID: input.BrandID,
ExtraParams: extra,
FallbackName: "定时任务",
})
}
@@ -254,10 +280,12 @@ func (s *PromptRuleGenerationService) enqueuePromptRuleGeneration(ctx context.Co
inputParams := map[string]interface{}{
"prompt_rule_id": input.PromptRuleID,
"task_name": taskName,
"target_platform": strings.TrimSpace(derefString(input.TargetPlatform)),
"enable_web_search": extractBool(extra, "enable_web_search"),
"generate_count": generateCount,
}
if workspaceID := extractInt(extra, "workspace_id"); workspaceID > 0 {
inputParams["workspace_id"] = workspaceID
}
if generationMode != "" {
inputParams["generation_mode"] = generationMode
}
@@ -267,10 +295,15 @@ func (s *PromptRuleGenerationService) enqueuePromptRuleGeneration(ctx context.Co
if scheduledFor := strings.TrimSpace(extractString(extra, "scheduled_for")); scheduledFor != "" {
inputParams["scheduled_for"] = scheduledFor
}
platformIDs := parsePlatformIDs(derefString(input.TargetPlatform))
if len(platformIDs) > 0 {
inputParams["target_platforms"] = platformIDs
if extractBool(extra, "schedule_auto_publish") {
inputParams["schedule_auto_publish"] = true
inputParams["schedule_publish_account_ids"] = extractStringList(extra["schedule_publish_account_ids"], 64)
if coverURL := strings.TrimSpace(extractString(extra, "schedule_cover_asset_url")); coverURL != "" {
inputParams["schedule_cover_asset_url"] = coverURL
}
if coverImageAssetID := extractInt(extra, "schedule_cover_image_asset_id"); coverImageAssetID > 0 {
inputParams["schedule_cover_image_asset_id"] = coverImageAssetID
}
}
if input.BrandID != nil {
inputParams["brand_id"] = *input.BrandID
@@ -301,7 +334,16 @@ func (s *PromptRuleGenerationService) enqueuePromptRuleGeneration(ctx context.Co
}
inputJSON, _ := json.Marshal(inputParams)
wizardStateJSON, _ := mergeArticleWizardState(nil, promptRuleGeneratingTitlePlaceholder, platformIDs, nil, NullableInt64Input{})
var coverAssetURL *string
var coverImageAssetID NullableInt64Input
if coverURL := strings.TrimSpace(extractString(inputParams, "schedule_cover_asset_url")); coverURL != "" {
coverAssetURL = &coverURL
}
if rawCoverImageAssetID := int64(extractInt(inputParams, "schedule_cover_image_asset_id")); rawCoverImageAssetID > 0 {
coverImageAssetID.Set = true
coverImageAssetID.Value = &rawCoverImageAssetID
}
wizardStateJSON, _ := mergeArticleWizardState(nil, promptRuleGeneratingTitlePlaceholder, nil, coverAssetURL, coverImageAssetID)
tx, err := s.pool.Begin(ctx)
if err != nil {
@@ -557,6 +599,74 @@ func (s *PromptRuleGenerationService) executeGeneration(ctx context.Context, job
if s.streamEnabled {
s.streams.Complete(job.ArticleID, job.TaskID, title, content)
}
s.enqueueScheduleAutoPublish(cleanupCtx, job, title)
}
func (s *PromptRuleGenerationService) enqueueScheduleAutoPublish(ctx context.Context, job promptRuleGenerationJob, title string) {
if s == nil || s.publishJobs == nil || !extractBool(job.InputParams, "schedule_auto_publish") {
return
}
workspaceID := int64(extractInt(job.InputParams, "workspace_id"))
accountIDs := extractStringList(job.InputParams["schedule_publish_account_ids"], 64)
coverURL := strings.TrimSpace(extractString(job.InputParams, "schedule_cover_asset_url"))
if workspaceID <= 0 || job.OperatorID <= 0 || len(accountIDs) == 0 {
s.logAutoPublishWarn("schedule auto publish skipped because configuration is incomplete", nil,
zap.Int64("generation_task_id", job.TaskID),
zap.Int64("article_id", job.ArticleID),
zap.Int64("workspace_id", workspaceID),
zap.Int("account_count", len(accountIDs)),
zap.Bool("has_cover", coverURL != ""),
)
return
}
accounts := make([]CreatePublishJobAccountRequest, 0, len(accountIDs))
for _, accountID := range accountIDs {
accountID = strings.TrimSpace(accountID)
if accountID == "" {
continue
}
accounts = append(accounts, CreatePublishJobAccountRequest{AccountID: accountID})
}
if len(accounts) == 0 {
return
}
publishTitle := strings.TrimSpace(title)
if publishTitle == "" {
publishTitle = "定时生成文章"
}
if _, err := s.publishJobs.Create(ctx, auth.Actor{
TenantID: job.TenantID,
UserID: job.OperatorID,
PrimaryWorkspaceID: workspaceID,
}, CreatePublishJobRequest{
Title: publishTitle,
ContentRef: map[string]any{
"article_id": job.ArticleID,
},
Accounts: accounts,
}); err != nil {
s.logAutoPublishWarn("schedule auto publish enqueue failed", err,
zap.Int64("generation_task_id", job.TaskID),
zap.Int64("article_id", job.ArticleID),
zap.Int64("workspace_id", workspaceID),
zap.Int("account_count", len(accounts)),
)
}
}
func (s *PromptRuleGenerationService) logAutoPublishWarn(message string, err error, fields ...zap.Field) {
if s == nil || s.logger == nil {
return
}
if err != nil {
fields = append(fields, zap.Error(err))
}
s.logger.Warn(message, fields...)
}
func (s *PromptRuleGenerationService) failGeneration(ctx context.Context, job promptRuleGenerationJob, title, stage string, genErr error) {
@@ -632,9 +742,6 @@ func buildPromptRuleGenerationPrompt(rule *promptRuleGenerationRecord, params ma
if rule.DefaultWordCount != nil && *rule.DefaultWordCount > 0 {
supplements = append(supplements, prompts.PromptRuleWordCountSupplement(*rule.DefaultWordCount))
}
if target := strings.TrimSpace(extractString(params, "target_platform")); target != "" {
supplements = append(supplements, prompts.PromptRuleTargetPlatformSupplement(target))
}
if len(supplements) > 0 {
sections = append(sections, prompts.PromptRuleSupplementSection(supplements))
@@ -678,9 +785,6 @@ func buildPromptRuleKnowledgeQuery(rule *promptRuleGenerationRecord, params map[
if keywords := extractStringList(params["keywords"], 16); len(keywords) > 0 {
parts = append(parts, strings.Join(keywords, "\n"))
}
if target := strings.TrimSpace(extractString(params, "target_platform")); target != "" {
parts = append(parts, target)
}
if rule != nil {
if text := strings.TrimSpace(rule.PromptContent); text != "" {
parts = append(parts, text)