feat(enterprise-site): add WordPress support and scheduled auto-publish to sites
Frontend CI / Frontend (push) Successful in 4m17s
Backend CI / Backend (push) Failing after 6m42s

Add WordPress as an enterprise-site CMS type alongside pbootcms, and let
schedule tasks auto-publish generated articles to enterprise sites.

- WordPress connection/publisher service and tests; register wordpress
  media platform and relax cms_type CHECK constraint
- New publish_enterprise_site_targets JSONB column on schedule_tasks with
  array type constraint; thread targets through scheduler dispatch,
  prompt/KOL generation, and worker
- Admin UI: enterprise-site targets in generate/schedule/publish flows,
  KOL package form, MediaView, and i18n strings

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 19:44:24 +08:00
parent 41f5623791
commit c7bad83496
30 changed files with 2946 additions and 696 deletions
+12
View File
@@ -64,6 +64,18 @@ func main() {
generationCfg,
app.Config().LLM.MaxOutputTokens,
).WithCache(app.Cache).WithLogger(app.Logger).WithConfigProvider(generationProvider)
publishJobSvc := tenantapp.NewPublishJobServiceWithConfig(
app.DB,
app.RabbitMQ,
app.Redis,
app.Logger,
app.ConfigStore,
).WithCache(app.Cache)
enterpriseSiteSvc := tenantapp.NewEnterpriseSiteService(app.DB, app.ConfigStore).
WithCache(app.Cache).
WithObjectStorage(app.ObjectStorage)
promptRuleSvc.WithPublishJobService(publishJobSvc)
promptRuleSvc.WithEnterpriseSiteService(enterpriseSiteSvc)
kolGenerationSvc := tenantapp.NewKolGenerationService(
app.DB,
app.KolSubscriptions,
+8 -1
View File
@@ -74,7 +74,11 @@ func main() {
app.Logger,
app.ConfigStore,
).WithCache(app.Cache)
enterpriseSiteSvc := tenantapp.NewEnterpriseSiteService(app.DB, app.ConfigStore).
WithCache(app.Cache).
WithObjectStorage(app.ObjectStorage)
promptRuleSvc.WithPublishJobService(publishJobSvc)
promptRuleSvc.WithEnterpriseSiteService(enterpriseSiteSvc)
imitationSvc := tenantapp.NewArticleImitationService(
app.DB,
@@ -93,7 +97,10 @@ func main() {
app.Logger,
generationCfg,
app.Config().LLM.MaxOutputTokens,
).WithCache(app.Cache).WithConfigProvider(app.ConfigStore)
).WithCache(app.Cache).
WithPublishJobService(publishJobSvc).
WithEnterpriseSiteService(enterpriseSiteSvc).
WithConfigProvider(app.ConfigStore)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -40,30 +40,31 @@ type ScheduleDispatchWorker struct {
}
type dueScheduleTask struct {
ID int64
TenantID int64
WorkspaceID int64
OperatorID *int64
TargetType string
PromptRuleID *int64
SubscriptionPromptID *int64
BrandID *int64
Name string
CronExpr string
ScheduleDays []string
ScheduleTimeMode string
RandomWindowStart string
RandomWindowEnd string
GenerationInput map[string]interface{}
AutoPublish bool
PublishAccountIDs []string
CoverAssetURL *string
CoverImageAssetID *int64
EnableWebSearch bool
GenerateCount int
StartAt *time.Time
EndAt *time.Time
ScheduledFor time.Time
ID int64
TenantID int64
WorkspaceID int64
OperatorID *int64
TargetType string
PromptRuleID *int64
SubscriptionPromptID *int64
BrandID *int64
Name string
CronExpr string
ScheduleDays []string
ScheduleTimeMode string
RandomWindowStart string
RandomWindowEnd string
GenerationInput map[string]interface{}
AutoPublish bool
PublishAccountIDs []string
PublishEnterpriseSiteTargets []tenantapp.ScheduleEnterpriseSiteTarget
CoverAssetURL *string
CoverImageAssetID *int64
EnableWebSearch bool
GenerateCount int
StartAt *time.Time
EndAt *time.Time
ScheduledFor time.Time
}
type scheduleTaskCacheUpdate struct {
@@ -376,7 +377,7 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context, runtimeC
SELECT id, tenant_id, workspace_id, operator_id, target_type, prompt_rule_id,
subscription_prompt_id, brand_id, name, cron_expr, schedule_days, schedule_time_mode,
random_window_start::text, random_window_end::text, generation_input_json,
auto_publish, publish_account_ids, cover_asset_url, cover_image_asset_id,
auto_publish, publish_account_ids, publish_enterprise_site_targets, cover_asset_url, cover_image_asset_id,
enable_web_search, generate_count, start_at, end_at, next_run_at
FROM schedule_tasks
WHERE deleted_at IS NULL
@@ -400,17 +401,18 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context, runtimeC
}, 0, runtimeCfg.BatchSize)
for rows.Next() {
var (
task dueScheduleTask
workspaceID pgtype.Int8
operatorID pgtype.Int8
promptRuleID pgtype.Int8
subscriptionPromptID pgtype.Int8
generationInputJSON []byte
scheduleDaysJSON []byte
publishAccountIDsJSON []byte
startAt pgtype.Timestamptz
endAt pgtype.Timestamptz
nextRunAt pgtype.Timestamptz
task dueScheduleTask
workspaceID pgtype.Int8
operatorID pgtype.Int8
promptRuleID pgtype.Int8
subscriptionPromptID pgtype.Int8
generationInputJSON []byte
scheduleDaysJSON []byte
publishAccountIDsJSON []byte
publishEnterpriseSiteTargetsJSON []byte
startAt pgtype.Timestamptz
endAt pgtype.Timestamptz
nextRunAt pgtype.Timestamptz
)
if err := rows.Scan(
&task.ID,
@@ -430,6 +432,7 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context, runtimeC
&generationInputJSON,
&task.AutoPublish,
&publishAccountIDsJSON,
&publishEnterpriseSiteTargetsJSON,
&task.CoverAssetURL,
&task.CoverImageAssetID,
&task.EnableWebSearch,
@@ -456,6 +459,7 @@ func (w *ScheduleDispatchWorker) claimDueSchedules(ctx context.Context, runtimeC
task.TargetType = tenantapp.ScheduleTargetPromptRule
}
task.PublishAccountIDs = decodeDueSchedulePublishAccountIDs(publishAccountIDsJSON)
task.PublishEnterpriseSiteTargets = tenantapp.DecodeScheduleEnterpriseSiteTargets(publishEnterpriseSiteTargetsJSON)
if task.GenerateCount <= 0 {
task.GenerateCount = 1
}
@@ -589,20 +593,21 @@ func (w *ScheduleDispatchWorker) enqueueDueGeneration(ctx context.Context, task
return nil, errors.New("schedule prompt_rule target missing prompt_rule_id")
}
return w.promptRuleService.EnqueueScheduledGeneration(ctx, tenantapp.SchedulePromptRuleGenerationInput{
ScheduleTaskID: task.ID,
OperatorID: task.OperatorID,
TenantID: task.TenantID,
PromptRuleID: *task.PromptRuleID,
BrandID: task.BrandID,
Name: task.Name,
WorkspaceID: task.WorkspaceID,
AutoPublish: task.AutoPublish,
PublishAccountIDs: task.PublishAccountIDs,
CoverAssetURL: task.CoverAssetURL,
CoverImageAssetID: task.CoverImageAssetID,
EnableWebSearch: task.EnableWebSearch,
GenerateCount: generateCount,
ScheduledFor: task.ScheduledFor,
ScheduleTaskID: task.ID,
OperatorID: task.OperatorID,
TenantID: task.TenantID,
PromptRuleID: *task.PromptRuleID,
BrandID: task.BrandID,
Name: task.Name,
WorkspaceID: task.WorkspaceID,
AutoPublish: task.AutoPublish,
PublishAccountIDs: task.PublishAccountIDs,
PublishEnterpriseSiteTargets: task.PublishEnterpriseSiteTargets,
CoverAssetURL: task.CoverAssetURL,
CoverImageAssetID: task.CoverImageAssetID,
EnableWebSearch: task.EnableWebSearch,
GenerateCount: generateCount,
ScheduledFor: task.ScheduledFor,
})
case tenantapp.ScheduleTargetKolSubscriptionPrompt:
if w.kolService == nil {
@@ -613,22 +618,23 @@ func (w *ScheduleDispatchWorker) enqueueDueGeneration(ctx context.Context, task
}
variables, _ := task.GenerationInput["variables"].(map[string]interface{})
kolResp, err := w.kolService.EnqueueScheduledGeneration(ctx, tenantapp.ScheduleKolGenerationInput{
ScheduleTaskID: task.ID,
OperatorID: task.OperatorID,
TenantID: task.TenantID,
WorkspaceID: task.WorkspaceID,
BrandID: task.BrandID,
SubscriptionPromptID: *task.SubscriptionPromptID,
Name: task.Name,
Variables: variables,
EnableWebSearch: scheduleGenerationInputBool(task.GenerationInput, "enable_web_search", task.EnableWebSearch),
KnowledgeGroupIDs: scheduleGenerationInputInt64s(task.GenerationInput, "knowledge_group_ids"),
AutoPublish: task.AutoPublish,
PublishAccountIDs: task.PublishAccountIDs,
CoverAssetURL: task.CoverAssetURL,
CoverImageAssetID: task.CoverImageAssetID,
GenerateCount: generateCount,
ScheduledFor: task.ScheduledFor,
ScheduleTaskID: task.ID,
OperatorID: task.OperatorID,
TenantID: task.TenantID,
WorkspaceID: task.WorkspaceID,
BrandID: task.BrandID,
SubscriptionPromptID: *task.SubscriptionPromptID,
Name: task.Name,
Variables: variables,
EnableWebSearch: scheduleGenerationInputBool(task.GenerationInput, "enable_web_search", task.EnableWebSearch),
KnowledgeGroupIDs: scheduleGenerationInputInt64s(task.GenerationInput, "knowledge_group_ids"),
AutoPublish: task.AutoPublish,
PublishAccountIDs: task.PublishAccountIDs,
PublishEnterpriseSiteTargets: task.PublishEnterpriseSiteTargets,
CoverAssetURL: task.CoverAssetURL,
CoverImageAssetID: task.CoverImageAssetID,
GenerateCount: generateCount,
ScheduledFor: task.ScheduledFor,
})
if err != nil {
return nil, err
@@ -66,10 +66,34 @@ type pbootCMSRequestError struct {
routeUnavailable bool
}
type unsupportedCMSPublisher struct {
cmsType string
}
func (err *pbootCMSRequestError) Error() string {
return err.message
}
func (p unsupportedCMSPublisher) Ping(context.Context, enterpriseSiteCredential) (*EnterpriseSiteCapability, map[string]any, error) {
return nil, nil, p.err()
}
func (p unsupportedCMSPublisher) Categories(context.Context, enterpriseSiteCredential) ([]EnterpriseSiteCategoryItem, []map[string]any, error) {
return nil, nil, p.err()
}
func (p unsupportedCMSPublisher) Publish(context.Context, enterpriseSiteCredential, cmsPublishRequest) (*cmsPublishResult, map[string]any, error) {
return nil, nil, p.err()
}
func (p unsupportedCMSPublisher) err() error {
cmsType := strings.TrimSpace(p.cmsType)
if cmsType == "" {
cmsType = "CMS"
}
return fmt.Errorf("unsupported cms type: %s", cmsType)
}
func newPBootCMSPublisher(client *http.Client) *pbootCMSPublisher {
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
@@ -6,7 +6,10 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"regexp"
"strconv"
@@ -45,11 +48,9 @@ type EnterpriseSiteService struct {
func NewEnterpriseSiteService(pool *pgxpool.Pool, cfg config.Provider) *EnterpriseSiteService {
return &EnterpriseSiteService{
pool: pool,
cfg: cfg,
httpClient: &http.Client{
Timeout: 25 * time.Second,
},
pool: pool,
cfg: cfg,
httpClient: newEnterpriseSiteHTTPClient(cfg),
compliance: tenantcompliance.NewService(pool, cfg, nil),
}
}
@@ -344,6 +345,9 @@ func (s *EnterpriseSiteService) Create(ctx context.Context, req CreateEnterprise
if err != nil {
return nil, err
}
if err := validateEnterpriseSiteCMSURL(normalized.SiteURL, normalized.CMSType); err != nil {
return nil, err
}
ciphertext, err := encryptEnterpriseSiteCredential(normalized.Secret, s.credentialKeyMaterial())
if err != nil {
return nil, response.ErrInternal(50221, "enterprise_site_secret_encrypt_failed", "企业站点凭证保存失败")
@@ -465,6 +469,9 @@ func (s *EnterpriseSiteService) Update(ctx context.Context, id int64, req Update
if !validEnterpriseSiteStatus(next.Status) {
next.Status = "draft"
}
if err := validateEnterpriseSiteCMSURL(next.SiteURL, next.CMSType); err != nil {
return nil, err
}
if !strings.EqualFold(next.SiteURL, current.SiteURL) {
if err := s.ensureEnterpriseSiteURLAvailable(ctx, actor.TenantID, workspaceID, id, next.SiteURL, next.CMSType); err != nil {
@@ -1361,8 +1368,10 @@ func (s *EnterpriseSiteService) publisherFor(cmsType string) cmsPublisher {
switch strings.TrimSpace(cmsType) {
case pbootCMSPlatformID:
return newPBootCMSPublisher(s.httpClient)
case wordpressPlatformID:
return newWordPressPublisher(s.httpClient)
default:
return newPBootCMSPublisher(s.httpClient)
return unsupportedCMSPublisher{cmsType: cmsType}
}
}
@@ -1514,7 +1523,7 @@ func normalizeCreateEnterpriseSiteConnectionRequest(req CreateEnterpriseSiteConn
return normalizedEnterpriseSiteConnectionRequest{}, err
}
cmsType := normalizeEnterpriseCMSType(req.CMSType)
if cmsType != pbootCMSPlatformID {
if !validEnterpriseCMSType(cmsType) {
return normalizedEnterpriseSiteConnectionRequest{}, response.ErrBadRequest(40040, "enterprise_site_cms_not_supported", "当前版本暂不支持该 CMS 类型")
}
appid := strings.TrimSpace(req.AppID)
@@ -1562,6 +1571,13 @@ func normalizeEnterpriseSiteURL(value string) (string, error) {
if scheme != "https" && scheme != "http" {
return "", response.ErrBadRequest(40041, "invalid_enterprise_site_url", "站点域名必须使用 http 或 https")
}
host := strings.TrimSpace(parsed.Hostname())
if host == "" {
return "", response.ErrBadRequest(40041, "invalid_enterprise_site_url", "站点域名格式不正确")
}
if isEnterpriseSiteBlockedHost(host) {
return "", response.ErrBadRequest(40041, "invalid_enterprise_site_url", "站点域名不可使用内网或系统保留地址")
}
parsed.Scheme = scheme
parsed.Path = strings.TrimRight(parsed.Path, "/")
parsed.RawQuery = ""
@@ -1569,10 +1585,130 @@ func normalizeEnterpriseSiteURL(value string) (string, error) {
return strings.TrimRight(parsed.String(), "/"), nil
}
func validateEnterpriseSiteCMSURL(siteURL, cmsType string) error {
parsed, err := url.Parse(strings.TrimSpace(siteURL))
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return response.ErrBadRequest(40041, "invalid_enterprise_site_url", "站点域名格式不正确")
}
if strings.EqualFold(strings.TrimSpace(cmsType), wordpressPlatformID) &&
strings.EqualFold(parsed.Scheme, "http") &&
!isEnterpriseSiteLoopbackHost(parsed.Hostname()) {
return response.ErrBadRequest(40041, "wordpress_https_required", "WordPress 站点使用 Application Password 时必须使用 HTTPS")
}
return nil
}
func isEnterpriseSiteBlockedHost(host string) bool {
host = strings.Trim(strings.ToLower(strings.TrimSpace(host)), "[]")
if host == "" {
return true
}
if isEnterpriseSiteLoopbackHost(host) || host == "localhost" {
return false
}
if addr, err := netip.ParseAddr(host); err == nil {
return isEnterpriseSiteBlockedAddr(addr)
}
return false
}
func isEnterpriseSiteLoopbackHost(host string) bool {
host = strings.Trim(strings.ToLower(strings.TrimSpace(host)), "[]")
if host == "localhost" {
return true
}
addr, err := netip.ParseAddr(host)
return err == nil && addr.IsLoopback()
}
func isEnterpriseSiteBlockedAddr(addr netip.Addr) bool {
if !addr.IsValid() {
return true
}
if addr.Is4In6() {
addr = addr.Unmap()
}
if addr.IsLoopback() {
return false
}
if addr.IsPrivate() ||
addr.IsLinkLocalUnicast() ||
addr.IsLinkLocalMulticast() ||
addr.IsMulticast() ||
addr.IsUnspecified() ||
addr.IsInterfaceLocalMulticast() {
return true
}
return enterpriseSiteBlockedAddrPrefix(addr)
}
func enterpriseSiteBlockedAddrPrefix(addr netip.Addr) bool {
blockedPrefixes := []netip.Prefix{
netip.MustParsePrefix("169.254.0.0/16"),
netip.MustParsePrefix("100.64.0.0/10"),
netip.MustParsePrefix("192.0.0.0/24"),
netip.MustParsePrefix("192.0.2.0/24"),
netip.MustParsePrefix("198.18.0.0/15"),
netip.MustParsePrefix("198.51.100.0/24"),
netip.MustParsePrefix("203.0.113.0/24"),
netip.MustParsePrefix("::/128"),
netip.MustParsePrefix("::1/128"),
netip.MustParsePrefix("fc00::/7"),
netip.MustParsePrefix("fe80::/10"),
netip.MustParsePrefix("2002::/16"),
netip.MustParsePrefix("ff00::/8"),
}
for _, prefix := range blockedPrefixes {
if prefix.Contains(addr) {
return true
}
}
return false
}
func newEnterpriseSiteHTTPClient(config.Provider) *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
host, _, err := net.SplitHostPort(address)
if err != nil {
host = address
}
if isEnterpriseSiteBlockedHost(host) {
return nil, fmt.Errorf("enterprise site target host is blocked")
}
conn, err := dialer.DialContext(ctx, network, address)
if err != nil {
return nil, err
}
remoteAddr := conn.RemoteAddr()
if tcpAddr, ok := remoteAddr.(*net.TCPAddr); ok {
if addr, ok := netip.AddrFromSlice(tcpAddr.IP); ok && isEnterpriseSiteBlockedAddr(addr) {
_ = conn.Close()
return nil, fmt.Errorf("enterprise site target address is blocked")
}
}
return conn, nil
}
return &http.Client{
Timeout: 25 * time.Second,
Transport: transport,
}
}
func normalizeEnterpriseCMSType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func validEnterpriseCMSType(cmsType string) bool {
switch strings.TrimSpace(cmsType) {
case pbootCMSPlatformID, wordpressPlatformID:
return true
default:
return false
}
}
func normalizeOptionalEnterpriseCodePtr(value *string) *string {
if value == nil {
return nil
@@ -2486,6 +2622,27 @@ func sanitizeEnterpriseSiteError(err error) string {
if strings.HasPrefix(lower, "pbootcms ") && strings.Contains(lower, " endpoint is unavailable") {
return "CMS 接口不可用,请确认接口安装路径"
}
if strings.HasPrefix(lower, "request wordpress ") {
return "WordPress 请求失败,请检查站点地址和网络连通性"
}
if strings.HasPrefix(lower, "parse wordpress response") {
return "WordPress 响应解析失败,请确认 REST API 返回 JSON 格式"
}
if strings.Contains(lower, "rest_not_logged_in") || strings.Contains(lower, "incorrect_password") || strings.Contains(lower, "invalid_username") {
return "WordPress 认证失败,请使用 WordPress 用户名和 Application Password"
}
if strings.HasPrefix(lower, "invalid wordpress site url") {
return "站点域名格式不正确"
}
if strings.Contains(lower, "invalid wordpress category id") {
return "WordPress 栏目 ID 不正确,请先同步栏目后再发布"
}
if strings.HasPrefix(lower, "wordpress ") && strings.Contains(lower, " returned http ") {
return "WordPress REST API 请求失败,请检查站点权限和接口状态"
}
if strings.Contains(lower, "unsupported cms type") {
return "当前版本暂不支持该 CMS 类型"
}
if len([]rune(message)) > 300 {
runes := []rune(message)
return string(runes[:300])
@@ -67,6 +67,30 @@ func TestEnterpriseSiteUpdateRealDatabaseRepro(t *testing.T) {
}
}
func TestValidateEnterpriseSiteCMSURLRequiresHTTPSForRemoteWordPress(t *testing.T) {
if err := validateEnterpriseSiteCMSURL("http://example.com", wordpressPlatformID); err == nil {
t.Fatalf("remote wordpress http url should be rejected")
}
if err := validateEnterpriseSiteCMSURL("http://127.0.0.1:6060", wordpressPlatformID); err != nil {
t.Fatalf("local wordpress http url should be allowed for development: %v", err)
}
}
func TestNormalizeEnterpriseSiteURLRejectsReservedTargets(t *testing.T) {
for _, raw := range []string{
"http://169.254.169.254",
"http://10.0.0.8",
"http://[fe80::1]",
} {
if _, err := normalizeEnterpriseSiteURL(raw); err == nil {
t.Fatalf("reserved site url %q should be rejected", raw)
}
}
if got, err := normalizeEnterpriseSiteURL("http://127.0.0.1:6060"); err != nil || got != "http://127.0.0.1:6060" {
t.Fatalf("local loopback url = %q, %v; want allowed", got, err)
}
}
func TestEnterpriseSiteDescriptionUsesRenderedHTMLText(t *testing.T) {
got := enterpriseSiteDescription(
"<h2>2026年合肥全屋定制行业发展现状</h2><p>消费者需求升级。</p>",
@@ -452,6 +476,18 @@ func TestEnterpriseSiteNormalizeLegacyPublishErrorStateKeepsPingError(t *testing
}
}
func TestSanitizeEnterpriseSiteErrorTranslatesWordPressAuthFailure(t *testing.T) {
got := sanitizeEnterpriseSiteError(&wordpressRequestError{
message: "wordpress wp/v2/users/me returned http 401: rest_not_logged_in: 您目前没有登录。",
status: 401,
code: "rest_not_logged_in",
})
want := "WordPress 认证失败,请使用 WordPress 用户名和 Application Password"
if got != want {
t.Fatalf("sanitize error = %q, want %q", got, want)
}
}
type enterpriseSiteImageObjectStorage struct {
content []byte
gotKey string
@@ -0,0 +1,334 @@
package app
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
const wordpressPlatformID = "wordpress"
type wordpressPublisher struct {
client *http.Client
}
type wordpressAPIIndex struct {
Name string `json:"name"`
Description string `json:"description"`
URL string `json:"url"`
Home string `json:"home"`
Namespaces []string `json:"namespaces"`
Headers map[string]string `json:"-"`
}
type wordpressCategory struct {
ID int64 `json:"id"`
Count int `json:"count"`
Name string `json:"name"`
Slug string `json:"slug"`
Parent int64 `json:"parent"`
Link string `json:"link"`
}
type wordpressPost struct {
ID int64 `json:"id"`
Link string `json:"link"`
Status string `json:"status"`
}
type wordpressErrorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
}
type wordpressRequestError struct {
message string
status int
code string
}
func (err *wordpressRequestError) Error() string {
return err.message
}
func newWordPressPublisher(client *http.Client) *wordpressPublisher {
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
return &wordpressPublisher{client: client}
}
func (p *wordpressPublisher) Ping(ctx context.Context, credential enterpriseSiteCredential) (*EnterpriseSiteCapability, map[string]any, error) {
var payload map[string]any
if err := p.request(ctx, http.MethodGet, credential, "", nil, nil, &payload); err != nil {
return nil, nil, err
}
var me map[string]any
if err := p.request(ctx, http.MethodGet, credential, "wp/v2/users/me", nil, nil, &me); err != nil {
return nil, nil, err
}
capability := &EnterpriseSiteCapability{
CMSType: wordpressPlatformID,
SiteURL: strings.TrimSpace(firstStringFromMap(payload, "home", "url")),
SiteName: stringPointerFromMap(payload, "name"),
APIAuth: true,
}
if capability.SiteURL == "" {
capability.SiteURL = credential.SiteURL
}
safeRaw := map[string]any{
"cms_type": wordpressPlatformID,
"site_url": capability.SiteURL,
"site_name": enterpriseSiteStringPointerValue(capability.SiteName),
"api_auth": true,
"authenticated": true,
}
if id := firstStringFromMap(me, "id", "slug", "name"); id != "" {
safeRaw["authenticated_user"] = id
}
return capability, safeRaw, nil
}
func (p *wordpressPublisher) Categories(ctx context.Context, credential enterpriseSiteCredential) ([]EnterpriseSiteCategoryItem, []map[string]any, error) {
query := url.Values{}
query.Set("per_page", "100")
query.Set("hide_empty", "false")
items := make([]EnterpriseSiteCategoryItem, 0)
rawItems := make([]map[string]any, 0)
for page := 1; page <= 50; page++ {
query.Set("page", strconv.Itoa(page))
var categories []wordpressCategory
resp, err := p.requestWithResponse(ctx, http.MethodGet, credential, "wp/v2/categories", query, nil, &categories)
if err != nil {
return nil, nil, err
}
for _, category := range categories {
if category.ID <= 0 || strings.TrimSpace(category.Name) == "" {
continue
}
raw := map[string]any{
"id": category.ID,
"count": category.Count,
"name": category.Name,
"slug": category.Slug,
"parent": category.Parent,
"link": category.Link,
}
parentRemoteID := wordpressParentCategoryID(category.Parent)
slug := normalizeStringPointer(&category.Slug)
items = append(items, EnterpriseSiteCategoryItem{
RemoteID: strconv.FormatInt(category.ID, 10),
ParentRemoteID: parentRemoteID,
Name: strings.TrimSpace(category.Name),
Slug: slug,
Raw: raw,
})
rawItems = append(rawItems, raw)
}
totalPages := wordpressHeaderInt(resp.Header, "X-WP-TotalPages")
if totalPages <= 0 || page >= totalPages {
break
}
}
return items, rawItems, nil
}
func (p *wordpressPublisher) Publish(ctx context.Context, credential enterpriseSiteCredential, req cmsPublishRequest) (*cmsPublishResult, map[string]any, error) {
categoryID := strings.TrimSpace(req.CategoryID)
if categoryID == "" {
categoryID = strings.TrimSpace(credential.DefaultScode)
}
categoryNumber, err := strconv.ParseInt(categoryID, 10, 64)
if err != nil || categoryNumber <= 0 {
return nil, nil, fmt.Errorf("invalid wordpress category id")
}
body := map[string]any{
"title": strings.TrimSpace(req.Title),
"content": strings.TrimSpace(req.ContentHTML),
"status": wordpressStatusFromPublishType(req.PublishType),
"categories": []int64{categoryNumber},
}
if description := strings.TrimSpace(req.Description); description != "" {
body["excerpt"] = description
}
var payload wordpressPost
if _, err := p.requestWithResponse(ctx, http.MethodPost, credential, "wp/v2/posts", nil, body, &payload); err != nil {
return nil, nil, err
}
raw := map[string]any{
"id": payload.ID,
"link": payload.Link,
"status": payload.Status,
}
result := &cmsPublishResult{
RemoteID: strconv.FormatInt(payload.ID, 10),
URL: strings.TrimSpace(payload.Link),
Status: strings.TrimSpace(payload.Status),
}
if result.Status == "" {
result.Status = "published"
}
return result, raw, nil
}
func (p *wordpressPublisher) request(ctx context.Context, method string, credential enterpriseSiteCredential, path string, query url.Values, body any, out any) error {
_, err := p.requestWithResponse(ctx, method, credential, path, query, body, out)
return err
}
func (p *wordpressPublisher) requestWithResponse(ctx context.Context, method string, credential enterpriseSiteCredential, path string, query url.Values, body any, out any) (*http.Response, error) {
endpoint, err := wordpressEndpoint(credential.SiteURL, path)
if err != nil {
return nil, err
}
if strings.EqualFold(endpoint.Scheme, "http") && !isEnterpriseSiteLoopbackHost(endpoint.Hostname()) {
return nil, response.ErrBadRequest(40041, "wordpress_https_required", "WordPress 站点使用 Application Password 时必须使用 HTTPS")
}
if query != nil {
requestQuery := endpoint.Query()
for key, values := range query {
requestQuery.Del(key)
for _, value := range values {
requestQuery.Add(key, value)
}
}
endpoint.RawQuery = requestQuery.Encode()
}
var reader io.Reader
if body != nil {
raw, marshalErr := json.Marshal(body)
if marshalErr != nil {
return nil, fmt.Errorf("marshal wordpress request: %w", marshalErr)
}
reader = bytes.NewReader(raw)
}
httpReq, err := http.NewRequestWithContext(ctx, method, endpoint.String(), reader)
if err != nil {
return nil, fmt.Errorf("build wordpress request: %w", err)
}
httpReq.Header.Set("User-Agent", "GeoRankly-WordPress/1.0")
httpReq.SetBasicAuth(strings.TrimSpace(credential.AppID), strings.TrimSpace(credential.Secret))
if body != nil {
httpReq.Header.Set("Content-Type", "application/json")
}
resp, err := p.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("request wordpress %s: %w", path, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
if err != nil {
return resp, fmt.Errorf("read wordpress response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return resp, wordpressHTTPError(path, resp.StatusCode, raw)
}
if out == nil || len(raw) == 0 || string(raw) == "null" {
return resp, nil
}
if err := json.Unmarshal(raw, out); err != nil {
return resp, fmt.Errorf("parse wordpress response: %w", err)
}
return resp, nil
}
func wordpressEndpoint(siteURL string, path string) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(siteURL))
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, fmt.Errorf("invalid wordpress site url")
}
basePath := strings.TrimRight(parsed.Path, "/")
cleanPath := strings.Trim(path, "/")
restRoute := "/"
if cleanPath != "" {
restRoute += cleanPath
}
parsed.Path = basePath + "/index.php"
query := url.Values{}
query.Set("rest_route", restRoute)
parsed.RawQuery = query.Encode()
parsed.Fragment = ""
return parsed, nil
}
func wordpressHTTPError(path string, status int, raw []byte) error {
var payload wordpressErrorResponse
if err := json.Unmarshal(raw, &payload); err == nil {
code := strings.TrimSpace(payload.Code)
message := strings.TrimSpace(payload.Message)
if code != "" || message != "" {
detail := message
if code != "" && message != "" {
detail = code + ": " + message
} else if code != "" {
detail = code
}
return &wordpressRequestError{
message: fmt.Sprintf("wordpress %s returned http %d: %s", path, status, detail),
status: status,
code: code,
}
}
}
return &wordpressRequestError{
message: fmt.Sprintf("wordpress %s returned http %d", path, status),
status: status,
}
}
func (err *wordpressRequestError) Status() int {
if err == nil {
return 0
}
return err.status
}
func (err *wordpressRequestError) Code() string {
if err == nil {
return ""
}
return err.code
}
func wordpressHeaderInt(header http.Header, key string) int {
value := strings.TrimSpace(header.Get(key))
if value == "" {
return 0
}
number, err := strconv.Atoi(value)
if err != nil {
return 0
}
return number
}
func wordpressParentCategoryID(parent int64) *string {
if parent <= 0 {
return nil
}
value := strconv.FormatInt(parent, 10)
return &value
}
func wordpressStatusFromPublishType(publishType string) string {
if strings.TrimSpace(publishType) == "draft" {
return "draft"
}
return "publish"
}
@@ -0,0 +1,165 @@
package app
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestWordPressPublisherCategoriesUsesRESTAPIAndBasicAuth(t *testing.T) {
const username = "editor@example.com"
const applicationPassword = "abcd efgh ijkl mnop"
var gotAuth string
var gotPath string
var gotQuery string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
gotPath = r.URL.Path
gotQuery = r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-WP-TotalPages", "1")
_, _ = w.Write([]byte(`[
{"id": 12, "name": "案例", "slug": "case", "parent": 0, "count": 3, "link": "https://example.test/category/case/"}
]`))
}))
defer server.Close()
publisher := newWordPressPublisher(server.Client())
items, raw, err := publisher.Categories(context.Background(), enterpriseSiteCredential{
SiteURL: server.URL,
AppID: username,
Secret: applicationPassword,
})
if err != nil {
t.Fatalf("sync wordpress categories: %v", err)
}
wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+applicationPassword))
if gotAuth != wantAuth {
t.Fatalf("authorization = %q, want %q", gotAuth, wantAuth)
}
if gotPath != "/index.php" {
t.Fatalf("path = %q, want wordpress categories endpoint", gotPath)
}
if !strings.Contains(gotQuery, "rest_route=%2Fwp%2Fv2%2Fcategories") ||
!strings.Contains(gotQuery, "per_page=100") ||
!strings.Contains(gotQuery, "hide_empty=false") {
t.Fatalf("query = %q, want wordpress rest_route, per_page and hide_empty", gotQuery)
}
if len(items) != 1 || items[0].RemoteID != "12" || items[0].Name != "案例" {
t.Fatalf("items = %#v, want parsed wordpress category", items)
}
if len(raw) != 1 || raw[0]["slug"] != "case" {
t.Fatalf("raw = %#v, want preserved category payload", raw)
}
}
func TestWordPressPublisherPingReturnsSafeRawSummary(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.Contains(r.URL.RawQuery, "rest_route=%2Fwp%2Fv2%2Fusers%2Fme"):
_, _ = w.Write([]byte(`{"id": 7, "name": "admin", "email": "admin@example.test", "capabilities": {"administrator": true}}`))
default:
_, _ = w.Write([]byte(`{"name": "Example", "home": "https://example.test", "routes": {"/wp/v2/users/me": {"sensitive": true}}}`))
}
}))
defer server.Close()
publisher := newWordPressPublisher(server.Client())
capability, raw, err := publisher.Ping(context.Background(), enterpriseSiteCredential{
SiteURL: server.URL,
AppID: "editor",
Secret: "application-password",
})
if err != nil {
t.Fatalf("ping wordpress: %v", err)
}
if capability.SiteName == nil || *capability.SiteName != "Example" || !capability.APIAuth {
t.Fatalf("capability = %#v, want sanitized wordpress capability", capability)
}
if raw["api_auth"] != true || raw["authenticated"] != true {
t.Fatalf("raw = %#v, want safe auth summary", raw)
}
if _, ok := raw["routes"]; ok {
t.Fatalf("raw leaked wordpress index routes: %#v", raw)
}
if user, ok := raw["authenticated_user"].(map[string]any); ok || user != nil {
t.Fatalf("raw leaked authenticated user body: %#v", raw)
}
if strings.Contains(fmt.Sprintf("%v", raw), "admin@example.test") {
t.Fatalf("raw leaked authenticated user email: %#v", raw)
}
}
func TestWordPressPublisherPublishCreatesPost(t *testing.T) {
var gotPath string
var gotQuery string
var gotPayload map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotQuery = r.URL.RawQuery
if r.Method != http.MethodPost {
t.Fatalf("method = %s, want POST", r.Method)
}
if _, _, ok := r.BasicAuth(); !ok {
t.Fatalf("missing basic auth")
}
if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil {
t.Fatalf("decode request body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id": 88, "link": "https://example.test/post/88/", "status": "publish"}`))
}))
defer server.Close()
publisher := newWordPressPublisher(server.Client())
result, raw, err := publisher.Publish(context.Background(), enterpriseSiteCredential{
SiteURL: server.URL,
AppID: "editor",
Secret: "application-password",
}, cmsPublishRequest{
Title: "测试标题",
ContentHTML: "<p>正文</p>",
CategoryID: "12",
Description: "摘要",
PublishType: "draft",
})
if err != nil {
t.Fatalf("publish wordpress post: %v", err)
}
if gotPath != "/index.php" {
t.Fatalf("path = %q, want wordpress posts endpoint", gotPath)
}
if !strings.Contains(gotQuery, "rest_route=%2Fwp%2Fv2%2Fposts") {
t.Fatalf("query = %q, want wordpress posts rest_route", gotQuery)
}
if gotPayload == nil {
t.Fatalf("missing request payload")
}
if gotPayload["title"] != "测试标题" || gotPayload["content"] != "<p>正文</p>" || gotPayload["status"] != "draft" {
t.Fatalf("payload = %#v, want wordpress post body", gotPayload)
}
categories, ok := gotPayload["categories"].([]any)
if !ok || len(categories) != 1 {
t.Fatalf("categories = %#v, want [12]", gotPayload["categories"])
}
categoryID, ok := categories[0].(float64)
if !ok || categoryID != 12 {
t.Fatalf("category id = %#v, want 12", categories[0])
}
if result.RemoteID != "88" || result.URL != "https://example.test/post/88/" {
t.Fatalf("result = %#v, want wordpress post result", result)
}
if raw["id"] != int64(88) {
t.Fatalf("raw = %#v, want saved wordpress response", raw)
}
}
@@ -39,22 +39,23 @@ type KolGenerationSubmitResponse struct {
}
type ScheduleKolGenerationInput struct {
ScheduleTaskID int64
OperatorID *int64
TenantID int64
WorkspaceID int64
BrandID *int64
SubscriptionPromptID int64
Name string
Variables map[string]any
EnableWebSearch bool
KnowledgeGroupIDs []int64
AutoPublish bool
PublishAccountIDs []string
CoverAssetURL *string
CoverImageAssetID *int64
GenerateCount int
ScheduledFor time.Time
ScheduleTaskID int64
OperatorID *int64
TenantID int64
WorkspaceID int64
BrandID *int64
SubscriptionPromptID int64
Name string
Variables map[string]any
EnableWebSearch bool
KnowledgeGroupIDs []int64
AutoPublish bool
PublishAccountIDs []string
PublishEnterpriseSiteTargets []ScheduleEnterpriseSiteTarget
CoverAssetURL *string
CoverImageAssetID *int64
GenerateCount int
ScheduledFor time.Time
}
type KolSubscriptionPromptSchemaResponse struct {
@@ -465,6 +466,7 @@ func attachKolScheduleTaskInput(input map[string]any, schedule ScheduleKolGenera
if schedule.AutoPublish {
input["schedule_auto_publish"] = true
input["schedule_publish_account_ids"] = schedule.PublishAccountIDs
input["schedule_publish_enterprise_site_targets"] = schedule.PublishEnterpriseSiteTargets
if schedule.CoverAssetURL != nil {
input["schedule_cover_asset_url"] = strings.TrimSpace(*schedule.CoverAssetURL)
}
@@ -25,16 +25,17 @@ import (
)
type PromptRuleGenerationService struct {
pool *pgxpool.Pool
llm llm.Client
rabbitMQ *rabbitmq.Client
knowledge *KnowledgeService
streams *stream.GenerationHub
cfg generationRuntimeConfig
configProvider runtimeConfigProvider
cache sharedcache.Cache
publishJobs *PublishJobService
logger *zap.Logger
pool *pgxpool.Pool
llm llm.Client
rabbitMQ *rabbitmq.Client
knowledge *KnowledgeService
streams *stream.GenerationHub
cfg generationRuntimeConfig
configProvider runtimeConfigProvider
cache sharedcache.Cache
publishJobs *PublishJobService
enterpriseSites *EnterpriseSiteService
logger *zap.Logger
}
type promptRuleGenerationJob struct {
@@ -72,20 +73,21 @@ type GenerateFromRuleItem struct {
}
type SchedulePromptRuleGenerationInput struct {
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
ScheduleTaskID int64
OperatorID *int64
TenantID int64
PromptRuleID int64
BrandID *int64
Name string
WorkspaceID int64
AutoPublish bool
PublishAccountIDs []string
PublishEnterpriseSiteTargets []ScheduleEnterpriseSiteTarget
CoverAssetURL *string
CoverImageAssetID *int64
EnableWebSearch bool
GenerateCount int
ScheduledFor time.Time
}
type promptRuleGenerationRecord struct {
@@ -143,6 +145,11 @@ func (s *PromptRuleGenerationService) WithPublishJobService(publishJobs *Publish
return s
}
func (s *PromptRuleGenerationService) WithEnterpriseSiteService(enterpriseSites *EnterpriseSiteService) *PromptRuleGenerationService {
s.enterpriseSites = enterpriseSites
return s
}
func (s *PromptRuleGenerationService) WithLogger(logger *zap.Logger) *PromptRuleGenerationService {
s.logger = logger
return s
@@ -232,6 +239,7 @@ func (s *PromptRuleGenerationService) EnqueueScheduledGeneration(ctx context.Con
if input.AutoPublish {
extra["schedule_auto_publish"] = true
extra["schedule_publish_account_ids"] = input.PublishAccountIDs
extra["schedule_publish_enterprise_site_targets"] = input.PublishEnterpriseSiteTargets
if input.CoverAssetURL != nil {
extra["schedule_cover_asset_url"] = strings.TrimSpace(*input.CoverAssetURL)
}
@@ -323,6 +331,7 @@ func (s *PromptRuleGenerationService) enqueuePromptRuleGeneration(ctx context.Co
if extractBool(extra, "schedule_auto_publish") {
inputParams["schedule_auto_publish"] = true
inputParams["schedule_publish_account_ids"] = extractStringList(extra["schedule_publish_account_ids"], 64)
inputParams["schedule_publish_enterprise_site_targets"] = extractScheduleEnterpriseSiteTargets(extra["schedule_publish_enterprise_site_targets"], scheduleEnterpriseSiteTargetLimit)
if coverURL := strings.TrimSpace(extractString(extra, "schedule_cover_asset_url")); coverURL != "" {
inputParams["schedule_cover_asset_url"] = coverURL
}
@@ -635,69 +644,26 @@ func (s *PromptRuleGenerationService) executeGeneration(ctx context.Context, job
s.streams.Complete(job.ArticleID, job.TaskID, title, content)
}
s.enqueueScheduleAutoPublish(cleanupCtx, job, title)
s.enqueueScheduleAutoPublish(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") {
func (s *PromptRuleGenerationService) enqueueScheduleAutoPublish(job promptRuleGenerationJob, title string) {
if s == 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 = "定时生成文章"
}
brandID := int64(extractInt(job.InputParams, "brand_id"))
publishCtx := ctx
if brandID > 0 {
publishCtx = auth.WithCurrentBrandID(ctx, brandID)
}
if _, err := s.publishJobs.Create(publishCtx, 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)),
)
}
StartScheduleAutoPublish(ScheduleAutoPublishInput{
TenantID: job.TenantID,
WorkspaceID: int64(extractInt(job.InputParams, "workspace_id")),
OperatorID: job.OperatorID,
BrandID: int64(extractInt(job.InputParams, "brand_id")),
ArticleID: job.ArticleID,
GenerationTaskID: job.TaskID,
Title: title,
InputParams: job.InputParams,
PublishJobs: s.publishJobs,
EnterpriseSites: s.enterpriseSites,
Logger: s.logger,
})
}
func (s *PromptRuleGenerationService) logAutoPublishWarn(message string, err error, fields ...zap.Field) {
@@ -0,0 +1,400 @@
package app
import (
"context"
"encoding/json"
"fmt"
"runtime/debug"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
const (
scheduleEnterpriseSiteTargetLimit = 64
scheduleAutoPublishTimeout = 15 * time.Minute
scheduleEnterpriseSiteTargetTimeout = 45 * time.Second
scheduleEnterpriseSitePublishParallel = 4
)
var runScheduleAutoPublishBackground = func(fn func()) {
go fn()
}
type ScheduleEnterpriseSiteTarget struct {
SiteID int64 `json:"site_id"`
CategoryID string `json:"category_id"`
PublishType string `json:"publish_type,omitempty"`
}
type ScheduleAutoPublishInput struct {
TenantID int64
WorkspaceID int64
OperatorID int64
BrandID int64
ArticleID int64
GenerationTaskID int64
Title string
InputParams map[string]interface{}
PublishJobs *PublishJobService
EnterpriseSites *EnterpriseSiteService
Logger *zap.Logger
}
func StartScheduleAutoPublish(input ScheduleAutoPublishInput) {
if !extractBool(input.InputParams, "schedule_auto_publish") {
return
}
runScheduleAutoPublishBackground(func() {
defer func() {
if recovered := recover(); recovered != nil {
logScheduleAutoPublishWarn(input.Logger, "schedule auto publish panic recovered", nil,
zap.Any("panic", recovered),
zap.ByteString("stack", debug.Stack()),
zap.Int64("generation_task_id", input.GenerationTaskID),
zap.Int64("article_id", input.ArticleID),
)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), scheduleAutoPublishTimeout)
defer cancel()
RunScheduleAutoPublish(ctx, input)
})
}
func RunScheduleAutoPublish(ctx context.Context, input ScheduleAutoPublishInput) {
if !extractBool(input.InputParams, "schedule_auto_publish") {
return
}
accountIDs := normalizeSchedulePublishAccountIDs(extractStringList(input.InputParams["schedule_publish_account_ids"], 64))
enterpriseTargets := extractScheduleEnterpriseSiteTargets(input.InputParams["schedule_publish_enterprise_site_targets"], scheduleEnterpriseSiteTargetLimit)
if len(accountIDs) == 0 && len(enterpriseTargets) == 0 {
return
}
if input.WorkspaceID <= 0 || input.OperatorID <= 0 || input.ArticleID <= 0 {
logScheduleAutoPublishWarn(input.Logger, "schedule auto publish skipped because configuration is incomplete", nil,
zap.Int64("generation_task_id", input.GenerationTaskID),
zap.Int64("article_id", input.ArticleID),
zap.Int64("workspace_id", input.WorkspaceID),
zap.Int64("operator_id", input.OperatorID),
zap.Int("account_count", len(accountIDs)),
zap.Int("enterprise_site_count", len(enterpriseTargets)),
)
return
}
brandID := input.BrandID
if brandID <= 0 {
brandID = int64(extractInt(input.InputParams, "brand_id"))
}
if brandID <= 0 {
logScheduleAutoPublishWarn(input.Logger, "schedule auto publish skipped because brand context is missing", nil,
zap.Int64("generation_task_id", input.GenerationTaskID),
zap.Int64("article_id", input.ArticleID),
)
return
}
publishTitle := strings.TrimSpace(input.Title)
if publishTitle == "" {
publishTitle = strings.TrimSpace(extractString(input.InputParams, "task_name"))
}
if publishTitle == "" {
publishTitle = "定时生成文章"
}
publishCtx := auth.WithCurrentBrandID(ctx, brandID)
publishCtx = auth.WithCurrentWorkspaceID(publishCtx, input.WorkspaceID)
actor := auth.Actor{
TenantID: input.TenantID,
UserID: input.OperatorID,
PrimaryWorkspaceID: input.WorkspaceID,
}
publishCtx = auth.WithActor(publishCtx, actor)
if len(accountIDs) > 0 {
runScheduleMediaAccountAutoPublish(publishCtx, input, actor, accountIDs, publishTitle)
}
if len(enterpriseTargets) > 0 {
runScheduleEnterpriseSiteAutoPublish(publishCtx, input, enterpriseTargets)
}
}
func runScheduleMediaAccountAutoPublish(ctx context.Context, input ScheduleAutoPublishInput, actor auth.Actor, accountIDs []string, title string) {
if input.PublishJobs == nil {
logScheduleAutoPublishWarn(input.Logger, "schedule media auto publish skipped because publish job service is unavailable", nil,
zap.Int64("generation_task_id", input.GenerationTaskID),
zap.Int64("article_id", input.ArticleID),
zap.Int("account_count", len(accountIDs)),
)
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
}
if _, err := input.PublishJobs.Create(ctx, actor, CreatePublishJobRequest{
Title: title,
ContentRef: map[string]any{
"article_id": input.ArticleID,
},
Accounts: accounts,
}); err != nil {
logScheduleAutoPublishWarn(input.Logger, "schedule media auto publish enqueue failed", err,
zap.Int64("generation_task_id", input.GenerationTaskID),
zap.Int64("article_id", input.ArticleID),
zap.Int64("workspace_id", input.WorkspaceID),
zap.Int("account_count", len(accounts)),
)
}
}
func runScheduleEnterpriseSiteAutoPublish(ctx context.Context, input ScheduleAutoPublishInput, targets []ScheduleEnterpriseSiteTarget) {
if input.EnterpriseSites == nil {
logScheduleAutoPublishWarn(input.Logger, "schedule enterprise site auto publish skipped because enterprise site service is unavailable", nil,
zap.Int64("generation_task_id", input.GenerationTaskID),
zap.Int64("article_id", input.ArticleID),
zap.Int("enterprise_site_count", len(targets)),
)
return
}
parallelism := scheduleEnterpriseSitePublishParallel
if parallelism <= 0 {
parallelism = 1
}
if parallelism > len(targets) {
parallelism = len(targets)
}
sem := make(chan struct{}, parallelism)
var wg sync.WaitGroup
for _, target := range targets {
if target.SiteID <= 0 || strings.TrimSpace(target.CategoryID) == "" {
continue
}
select {
case <-ctx.Done():
logScheduleAutoPublishWarn(input.Logger, "schedule enterprise site auto publish stopped because context is done", ctx.Err(),
zap.Int64("generation_task_id", input.GenerationTaskID),
zap.Int64("article_id", input.ArticleID),
zap.Int64("workspace_id", input.WorkspaceID),
)
wg.Wait()
return
case sem <- struct{}{}:
}
wg.Add(1)
go func(target ScheduleEnterpriseSiteTarget) {
defer wg.Done()
defer func() { <-sem }()
defer func() {
if recovered := recover(); recovered != nil {
logScheduleAutoPublishWarn(input.Logger, "schedule enterprise site auto publish panic recovered", nil,
zap.Any("panic", recovered),
zap.ByteString("stack", debug.Stack()),
zap.Int64("generation_task_id", input.GenerationTaskID),
zap.Int64("article_id", input.ArticleID),
zap.Int64("workspace_id", input.WorkspaceID),
zap.Int64("enterprise_site_id", target.SiteID),
)
}
}()
runScheduleEnterpriseSiteTargetAutoPublish(ctx, input, target)
}(target)
}
wg.Wait()
}
func runScheduleEnterpriseSiteTargetAutoPublish(ctx context.Context, input ScheduleAutoPublishInput, target ScheduleEnterpriseSiteTarget) {
targetCtx, cancel := context.WithTimeout(ctx, scheduleEnterpriseSiteTargetTimeout)
defer cancel()
if _, err := input.EnterpriseSites.PublishArticle(targetCtx, target.SiteID, PublishEnterpriseSiteArticleRequest{
ArticleID: input.ArticleID,
CategoryID: target.CategoryID,
PublishType: target.PublishType,
}); err != nil {
logScheduleAutoPublishWarn(input.Logger, "schedule enterprise site auto publish failed", err,
zap.Int64("generation_task_id", input.GenerationTaskID),
zap.Int64("article_id", input.ArticleID),
zap.Int64("workspace_id", input.WorkspaceID),
zap.Int64("enterprise_site_id", target.SiteID),
zap.String("category_id", target.CategoryID),
)
}
}
func logScheduleAutoPublishWarn(logger *zap.Logger, message string, err error, fields ...zap.Field) {
if logger == nil {
return
}
if err != nil {
fields = append(fields, zap.Error(err))
}
logger.Warn(message, fields...)
}
func NormalizeScheduleEnterpriseSiteTargets(values []ScheduleEnterpriseSiteTarget) []ScheduleEnterpriseSiteTarget {
if len(values) == 0 {
return []ScheduleEnterpriseSiteTarget{}
}
seen := make(map[int64]struct{}, len(values))
normalized := make([]ScheduleEnterpriseSiteTarget, 0, len(values))
for _, value := range values {
siteID := value.SiteID
categoryID := strings.TrimSpace(value.CategoryID)
if siteID <= 0 || categoryID == "" {
continue
}
if _, exists := seen[siteID]; exists {
continue
}
seen[siteID] = struct{}{}
normalized = append(normalized, ScheduleEnterpriseSiteTarget{
SiteID: siteID,
CategoryID: categoryID,
PublishType: normalizeEnterprisePublishType(value.PublishType),
})
}
return normalized
}
func DecodeScheduleEnterpriseSiteTargets(raw []byte) []ScheduleEnterpriseSiteTarget {
if len(raw) == 0 {
return []ScheduleEnterpriseSiteTarget{}
}
var values []ScheduleEnterpriseSiteTarget
if err := json.Unmarshal(raw, &values); err == nil {
return NormalizeScheduleEnterpriseSiteTargets(values)
}
var loose []map[string]interface{}
if err := json.Unmarshal(raw, &loose); err != nil {
return []ScheduleEnterpriseSiteTarget{}
}
return normalizeScheduleEnterpriseSiteTargetMaps(loose, scheduleEnterpriseSiteTargetLimit)
}
func extractScheduleEnterpriseSiteTargets(value interface{}, limit int) []ScheduleEnterpriseSiteTarget {
switch typed := value.(type) {
case []ScheduleEnterpriseSiteTarget:
return limitScheduleEnterpriseSiteTargets(NormalizeScheduleEnterpriseSiteTargets(typed), limit)
case []map[string]interface{}:
return normalizeScheduleEnterpriseSiteTargetMaps(typed, limit)
case []interface{}:
items := make([]map[string]interface{}, 0, len(typed))
for _, item := range typed {
switch target := item.(type) {
case map[string]interface{}:
items = append(items, target)
case ScheduleEnterpriseSiteTarget:
items = append(items, map[string]interface{}{
"site_id": target.SiteID,
"category_id": target.CategoryID,
"publish_type": target.PublishType,
})
}
if limit > 0 && len(items) >= limit {
break
}
}
return normalizeScheduleEnterpriseSiteTargetMaps(items, limit)
default:
return []ScheduleEnterpriseSiteTarget{}
}
}
func normalizeScheduleEnterpriseSiteTargetMaps(values []map[string]interface{}, limit int) []ScheduleEnterpriseSiteTarget {
targets := make([]ScheduleEnterpriseSiteTarget, 0, len(values))
for _, value := range values {
siteID := extractInt64FromTargetMap(value, "site_id", "connection_id", "id")
categoryID := strings.TrimSpace(extractStringFromTargetMap(value, "category_id", "remote_id", "scode"))
publishType := strings.TrimSpace(extractStringFromTargetMap(value, "publish_type", "status"))
targets = append(targets, ScheduleEnterpriseSiteTarget{
SiteID: siteID,
CategoryID: categoryID,
PublishType: publishType,
})
if limit > 0 && len(targets) >= limit {
break
}
}
return limitScheduleEnterpriseSiteTargets(NormalizeScheduleEnterpriseSiteTargets(targets), limit)
}
func limitScheduleEnterpriseSiteTargets(values []ScheduleEnterpriseSiteTarget, limit int) []ScheduleEnterpriseSiteTarget {
if limit > 0 && len(values) > limit {
return values[:limit]
}
return values
}
func extractInt64FromTargetMap(payload map[string]interface{}, keys ...string) int64 {
for _, key := range keys {
raw, ok := payload[key]
if !ok || raw == nil {
continue
}
switch typed := raw.(type) {
case int64:
return typed
case int:
return int64(typed)
case int32:
return int64(typed)
case float64:
return int64(typed)
case json.Number:
value, _ := typed.Int64()
return value
case string:
value, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
return value
default:
text := strings.TrimSpace(fmt.Sprintf("%v", typed))
value, _ := strconv.ParseInt(text, 10, 64)
return value
}
}
return 0
}
func extractStringFromTargetMap(payload map[string]interface{}, keys ...string) string {
for _, key := range keys {
raw, ok := payload[key]
if !ok || raw == nil {
continue
}
switch typed := raw.(type) {
case string:
return typed
default:
return fmt.Sprintf("%v", typed)
}
}
return ""
}
func encodeScheduleEnterpriseSiteTargets(targets []ScheduleEnterpriseSiteTarget) ([]byte, error) {
targets = NormalizeScheduleEnterpriseSiteTargets(targets)
raw, err := json.Marshal(targets)
if err != nil {
return nil, response.ErrBadRequest(40029, "invalid_publish_enterprise_site_targets", "企业站点发布目标格式不正确")
}
return raw, nil
}
@@ -0,0 +1,48 @@
package app
import (
"testing"
"time"
)
func TestStartScheduleAutoPublishReturnsAfterScheduling(t *testing.T) {
previousRunner := runScheduleAutoPublishBackground
defer func() {
runScheduleAutoPublishBackground = previousRunner
}()
started := make(chan struct{})
release := make(chan struct{})
done := make(chan struct{})
runScheduleAutoPublishBackground = func(fn func()) {
go func() {
close(started)
<-release
fn()
close(done)
}()
}
StartScheduleAutoPublish(ScheduleAutoPublishInput{
InputParams: map[string]interface{}{
"schedule_auto_publish": true,
},
})
select {
case <-started:
case <-time.After(time.Second):
t.Fatalf("background runner was not scheduled")
}
select {
case <-done:
t.Fatalf("auto publish should not block caller until background work completes")
default:
}
close(release)
select {
case <-done:
case <-time.After(time.Second):
t.Fatalf("background auto publish did not finish")
}
}
@@ -41,68 +41,70 @@ func (s *ScheduleTaskService) WithCache(c sharedcache.Cache) *ScheduleTaskServic
}
type ScheduleTaskRequest struct {
TargetType string `json:"target_type"`
PromptRuleID *int64 `json:"prompt_rule_id"`
SubscriptionPromptID *int64 `json:"subscription_prompt_id"`
BrandID *int64 `json:"brand_id"`
Name string `json:"name" binding:"required"`
CronExpr string `json:"cron_expr"`
ScheduleKind string `json:"schedule_kind"`
ScheduleDays []string `json:"schedule_days"`
ScheduleTimeMode string `json:"schedule_time_mode"`
RandomWindowStart string `json:"random_window_start"`
RandomWindowEnd string `json:"random_window_end"`
GenerationInput map[string]interface{} `json:"generation_input"`
AutoPublish *bool `json:"auto_publish"`
PublishAccountIDs []string `json:"publish_account_ids"`
CoverAssetURL *string `json:"cover_asset_url"`
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
EnableWebSearch *bool `json:"enable_web_search"`
GenerateCount *int `json:"generate_count"`
StartAt *string `json:"start_at"`
EndAt *string `json:"end_at"`
TargetType string `json:"target_type"`
PromptRuleID *int64 `json:"prompt_rule_id"`
SubscriptionPromptID *int64 `json:"subscription_prompt_id"`
BrandID *int64 `json:"brand_id"`
Name string `json:"name" binding:"required"`
CronExpr string `json:"cron_expr"`
ScheduleKind string `json:"schedule_kind"`
ScheduleDays []string `json:"schedule_days"`
ScheduleTimeMode string `json:"schedule_time_mode"`
RandomWindowStart string `json:"random_window_start"`
RandomWindowEnd string `json:"random_window_end"`
GenerationInput map[string]interface{} `json:"generation_input"`
AutoPublish *bool `json:"auto_publish"`
PublishAccountIDs []string `json:"publish_account_ids"`
PublishEnterpriseSiteTargets []ScheduleEnterpriseSiteTarget `json:"publish_enterprise_site_targets"`
CoverAssetURL *string `json:"cover_asset_url"`
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
EnableWebSearch *bool `json:"enable_web_search"`
GenerateCount *int `json:"generate_count"`
StartAt *string `json:"start_at"`
EndAt *string `json:"end_at"`
}
type ScheduleTaskResponse struct {
ID int64 `json:"id"`
WorkspaceID *int64 `json:"workspace_id"`
TargetType string `json:"target_type"`
PromptRuleID *int64 `json:"prompt_rule_id"`
PromptRuleName *string `json:"prompt_rule_name"`
SubscriptionPromptID *int64 `json:"subscription_prompt_id"`
SubscriptionPromptName *string `json:"subscription_prompt_name"`
SubscriptionPackageName *string `json:"subscription_package_name"`
SubscriptionKolName *string `json:"subscription_kol_name"`
BrandID *int64 `json:"brand_id"`
BrandName *string `json:"brand_name"`
Name string `json:"name"`
CronExpr string `json:"cron_expr"`
ScheduleKind string `json:"schedule_kind"`
ScheduleDays []string `json:"schedule_days"`
ScheduleTimeMode string `json:"schedule_time_mode"`
RandomWindowStart string `json:"random_window_start"`
RandomWindowEnd string `json:"random_window_end"`
GenerationInput map[string]interface{} `json:"generation_input"`
AutoPublish bool `json:"auto_publish"`
PublishAccountIDs []string `json:"publish_account_ids"`
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
CoverAssetURL *string `json:"cover_asset_url"`
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
EnableWebSearch bool `json:"enable_web_search"`
GenerateCount int `json:"generate_count"`
StartAt *string `json:"start_at"`
EndAt *string `json:"end_at"`
NextRunAt *string `json:"next_run_at"`
Status string `json:"status"`
LastRunAt *string `json:"last_run_at"`
LastDispatchStatus *string `json:"last_dispatch_status"`
LastDispatchError *string `json:"last_dispatch_error"`
ConsecutiveFailures int `json:"consecutive_failures"`
GenerationStatus *string `json:"generation_status"`
ExecutionTime *string `json:"execution_time"`
GeneratedArticles []InstantTaskArticleLink `json:"generated_articles"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID int64 `json:"id"`
WorkspaceID *int64 `json:"workspace_id"`
TargetType string `json:"target_type"`
PromptRuleID *int64 `json:"prompt_rule_id"`
PromptRuleName *string `json:"prompt_rule_name"`
SubscriptionPromptID *int64 `json:"subscription_prompt_id"`
SubscriptionPromptName *string `json:"subscription_prompt_name"`
SubscriptionPackageName *string `json:"subscription_package_name"`
SubscriptionKolName *string `json:"subscription_kol_name"`
BrandID *int64 `json:"brand_id"`
BrandName *string `json:"brand_name"`
Name string `json:"name"`
CronExpr string `json:"cron_expr"`
ScheduleKind string `json:"schedule_kind"`
ScheduleDays []string `json:"schedule_days"`
ScheduleTimeMode string `json:"schedule_time_mode"`
RandomWindowStart string `json:"random_window_start"`
RandomWindowEnd string `json:"random_window_end"`
GenerationInput map[string]interface{} `json:"generation_input"`
AutoPublish bool `json:"auto_publish"`
PublishAccountIDs []string `json:"publish_account_ids"`
PublishEnterpriseSiteTargets []ScheduleEnterpriseSiteTarget `json:"publish_enterprise_site_targets"`
AutoPublishPlatforms []string `json:"auto_publish_platforms"`
CoverAssetURL *string `json:"cover_asset_url"`
CoverImageAssetID *int64 `json:"cover_image_asset_id"`
EnableWebSearch bool `json:"enable_web_search"`
GenerateCount int `json:"generate_count"`
StartAt *string `json:"start_at"`
EndAt *string `json:"end_at"`
NextRunAt *string `json:"next_run_at"`
Status string `json:"status"`
LastRunAt *string `json:"last_run_at"`
LastDispatchStatus *string `json:"last_dispatch_status"`
LastDispatchError *string `json:"last_dispatch_error"`
ConsecutiveFailures int `json:"consecutive_failures"`
GenerationStatus *string `json:"generation_status"`
ExecutionTime *string `json:"execution_time"`
GeneratedArticles []InstantTaskArticleLink `json:"generated_articles"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type ScheduleTaskListParams struct {
@@ -209,22 +211,22 @@ func (s *ScheduleTaskService) Create(ctx context.Context, req ScheduleTaskReques
tenant_id, workspace_id, operator_id, target_type, prompt_rule_id, subscription_prompt_id,
brand_id, name, cron_expr, schedule_kind, schedule_days, schedule_time_mode,
random_window_start, random_window_end, generation_input_json,
enable_web_search, generate_count, auto_publish, publish_account_ids, cover_asset_url,
cover_image_asset_id, start_at, end_at
enable_web_search, generate_count, auto_publish, publish_account_ids,
publish_enterprise_site_targets, cover_asset_url, cover_image_asset_id, start_at, end_at
)
VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11::jsonb, $12,
$13::time, $14::time, $15::jsonb,
$16, $17, $18, $19::jsonb, $20,
$21, $22::timestamptz, $23::timestamptz
$16, $17, $18, $19::jsonb,
$20::jsonb, $21, $22, $23::timestamptz, $24::timestamptz
)
RETURNING id, created_at, start_at, end_at, status
`, actor.TenantID, actor.PrimaryWorkspaceID, actor.UserID, targetType, promptRuleID, subscriptionPromptID,
req.BrandID, req.Name, plan.CronExpr, plan.ScheduleKind, encodeScheduleDayKeys(plan.ScheduleDays), plan.ScheduleTimeMode,
plan.RandomWindowStart, plan.RandomWindowEnd, encodeScheduleGenerationInput(generationInput),
enableWebSearch, generateCount, publishConfig.AutoPublish, publishConfig.AccountIDsJSON,
publishConfig.CoverAssetURL, publishConfig.CoverImageAssetID, req.StartAt, req.EndAt).
publishConfig.EnterpriseTargetsJSON, publishConfig.CoverAssetURL, publishConfig.CoverImageAssetID, req.StartAt, req.EndAt).
Scan(&id, &createdAt, &startAt, &endAt, &status)
if err != nil {
return nil, response.ErrInternal(50010, "create_failed", "failed to create schedule task")
@@ -282,7 +284,8 @@ func (s *ScheduleTaskService) Create(ctx context.Context, req ScheduleTaskReques
Name: req.Name, CronExpr: plan.CronExpr, ScheduleKind: plan.ScheduleKind, ScheduleDays: plan.ScheduleDays,
ScheduleTimeMode: plan.ScheduleTimeMode, RandomWindowStart: plan.RandomWindowStart,
RandomWindowEnd: plan.RandomWindowEnd, GenerationInput: generationInput, AutoPublish: publishConfig.AutoPublish,
PublishAccountIDs: publishConfig.AccountIDs, AutoPublishPlatforms: publishConfig.PlatformIDs, CoverAssetURL: publishConfig.CoverAssetURL,
PublishAccountIDs: publishConfig.AccountIDs, PublishEnterpriseSiteTargets: publishConfig.EnterpriseTargets,
AutoPublishPlatforms: publishConfig.PlatformIDs, CoverAssetURL: publishConfig.CoverAssetURL,
CoverImageAssetID: publishConfig.CoverImageAssetID, EnableWebSearch: enableWebSearch,
GenerateCount: generateCount,
Status: status, CreatedAt: timeStringFromDBValue(createdAt),
@@ -345,18 +348,19 @@ func (s *ScheduleTaskService) Update(ctx context.Context, id int64, req Schedule
generation_input_json = $12::jsonb,
enable_web_search = $13, generate_count = $14,
workspace_id = $15, auto_publish = $16, publish_account_ids = $17::jsonb,
cover_asset_url = $18, cover_image_asset_id = $19,
start_at = $20::timestamptz, end_at = $21::timestamptz, operator_id = $22,
publish_enterprise_site_targets = $18::jsonb,
cover_asset_url = $19, cover_image_asset_id = $20,
start_at = $21::timestamptz, end_at = $22::timestamptz, operator_id = $23,
last_dispatch_status = NULL, last_dispatch_error = NULL,
consecutive_failures = 0,
updated_at = NOW()
WHERE id = $23 AND tenant_id = $24 AND brand_id = $25 AND deleted_at IS NULL
WHERE id = $24 AND tenant_id = $25 AND brand_id = $26 AND deleted_at IS NULL
RETURNING status, start_at, end_at
`, targetType, promptRuleID, subscriptionPromptID, req.BrandID, req.Name, plan.CronExpr,
plan.ScheduleKind, encodeScheduleDayKeys(plan.ScheduleDays), plan.ScheduleTimeMode,
plan.RandomWindowStart, plan.RandomWindowEnd, encodeScheduleGenerationInput(generationInput),
enableWebSearch, generateCount, actor.PrimaryWorkspaceID, publishConfig.AutoPublish,
publishConfig.AccountIDsJSON, publishConfig.CoverAssetURL, publishConfig.CoverImageAssetID,
publishConfig.AccountIDsJSON, publishConfig.EnterpriseTargetsJSON, publishConfig.CoverAssetURL, publishConfig.CoverImageAssetID,
req.StartAt, req.EndAt, actor.UserID, id, actor.TenantID, brandID).
Scan(&status, &startAt, &endAt)
if err != nil {
@@ -498,8 +502,8 @@ func (s *ScheduleTaskService) loadScheduleTasks(ctx context.Context, tenantID, b
st.subscription_prompt_id, st.brand_id, st.name,
st.cron_expr, st.schedule_kind, st.schedule_days, st.schedule_time_mode,
st.random_window_start::text, st.random_window_end::text, st.generation_input_json,
st.auto_publish, st.publish_account_ids, st.cover_asset_url,
st.cover_image_asset_id, st.enable_web_search, st.generate_count, st.start_at, st.end_at,
st.auto_publish, st.publish_account_ids, st.publish_enterprise_site_targets,
st.cover_asset_url, st.cover_image_asset_id, st.enable_web_search, st.generate_count, st.start_at, st.end_at,
st.next_run_at, st.status, st.last_run_at, st.last_dispatch_status,
st.last_dispatch_error, st.consecutive_failures, st.created_at, st.updated_at,
pr.name AS prompt_rule_name,
@@ -553,8 +557,8 @@ func (s *ScheduleTaskService) loadScheduleTaskDetail(ctx context.Context, tenant
st.subscription_prompt_id, st.brand_id, st.name,
st.cron_expr, st.schedule_kind, st.schedule_days, st.schedule_time_mode,
st.random_window_start::text, st.random_window_end::text, st.generation_input_json,
st.auto_publish, st.publish_account_ids, st.cover_asset_url,
st.cover_image_asset_id, st.enable_web_search, st.generate_count, st.start_at, st.end_at,
st.auto_publish, st.publish_account_ids, st.publish_enterprise_site_targets,
st.cover_asset_url, st.cover_image_asset_id, st.enable_web_search, st.generate_count, st.start_at, st.end_at,
st.next_run_at, st.status, st.last_run_at, st.last_dispatch_status,
st.last_dispatch_error, st.consecutive_failures, st.created_at, st.updated_at,
pr.name AS prompt_rule_name,
@@ -924,13 +928,14 @@ func scanScheduleTaskResponse(scanner interface {
var nextRunAt interface{}
var lastRunAt interface{}
var publishAccountIDsJSON []byte
var publishEnterpriseSiteTargetsJSON []byte
var scheduleDaysJSON []byte
var generationInputJSON []byte
if err := scanner.Scan(&item.ID, &item.WorkspaceID, &item.TargetType, &item.PromptRuleID,
&item.SubscriptionPromptID, &item.BrandID, &item.Name,
&item.CronExpr, &item.ScheduleKind, &scheduleDaysJSON, &item.ScheduleTimeMode,
&item.RandomWindowStart, &item.RandomWindowEnd, &generationInputJSON,
&item.AutoPublish, &publishAccountIDsJSON,
&item.AutoPublish, &publishAccountIDsJSON, &publishEnterpriseSiteTargetsJSON,
&item.CoverAssetURL, &item.CoverImageAssetID, &item.EnableWebSearch, &item.GenerateCount, &startAt, &endAt,
&nextRunAt, &item.Status, &lastRunAt, &item.LastDispatchStatus,
&item.LastDispatchError, &item.ConsecutiveFailures, &createdAt, &updatedAt,
@@ -959,6 +964,7 @@ func scanScheduleTaskResponse(scanner interface {
item.ScheduleDays = decodeScheduleDayKeys(scheduleDaysJSON)
item.GenerationInput = decodeScheduleGenerationInput(generationInputJSON)
item.PublishAccountIDs = decodeSchedulePublishAccountIDs(publishAccountIDsJSON)
item.PublishEnterpriseSiteTargets = DecodeScheduleEnterpriseSiteTargets(publishEnterpriseSiteTargetsJSON)
item.CreatedAt = timeStringFromDBValue(createdAt)
item.UpdatedAt = timeStringFromDBValue(updatedAt)
item.StartAt = timeStringPtrFromDBValue(startAt)
@@ -1061,54 +1067,72 @@ func (s *ScheduleTaskService) validateKolScheduleGenerationInput(ctx context.Con
}
type scheduleAutoPublishConfig struct {
AutoPublish bool
AccountIDs []string
PlatformIDs []string
AccountIDsJSON []byte
CoverAssetURL *string
CoverImageAssetID *int64
AutoPublish bool
AccountIDs []string
EnterpriseTargets []ScheduleEnterpriseSiteTarget
PlatformIDs []string
AccountIDsJSON []byte
EnterpriseTargetsJSON []byte
CoverAssetURL *string
CoverImageAssetID *int64
}
func (s *ScheduleTaskService) normalizeAutoPublishConfig(ctx context.Context, actor auth.Actor, req ScheduleTaskRequest) (scheduleAutoPublishConfig, error) {
autoPublish := boolValue(req.AutoPublish)
accountIDs := normalizeSchedulePublishAccountIDs(req.PublishAccountIDs)
enterpriseTargets := NormalizeScheduleEnterpriseSiteTargets(req.PublishEnterpriseSiteTargets)
config := scheduleAutoPublishConfig{
AutoPublish: autoPublish,
AccountIDs: accountIDs,
AutoPublish: autoPublish,
AccountIDs: accountIDs,
EnterpriseTargets: enterpriseTargets,
}
if !autoPublish {
config.AccountIDs = []string{}
config.EnterpriseTargets = []ScheduleEnterpriseSiteTarget{}
config.AccountIDsJSON = []byte("[]")
config.EnterpriseTargetsJSON = []byte("[]")
return config, nil
}
if actor.PrimaryWorkspaceID <= 0 {
return config, response.ErrBadRequest(40023, "workspace_required", "workspace context is required for automatic publishing")
return config, response.ErrBadRequest(40023, "workspace_required", "自动发布需要工作区上下文")
}
if len(accountIDs) == 0 {
return config, response.ErrBadRequest(40024, "publish_accounts_required", "publish_account_ids is required when auto_publish is enabled")
if len(accountIDs) == 0 && len(enterpriseTargets) == 0 {
return config, response.ErrBadRequest(40024, "publish_targets_required", "开启自动发布后,请至少选择一个发布目标")
}
if err := validateOptionalPositiveInt64(req.CoverImageAssetID, "cover_image_asset_id"); err != nil {
return config, err
}
coverURL := strings.TrimSpace(stringPointerValue(req.CoverAssetURL))
platformIDs, err := s.loadPublishAccountPlatformIDs(ctx, actor.TenantID, actor.PrimaryWorkspaceID, accountIDs)
if err != nil {
return config, err
if len(accountIDs) > 0 {
platformIDs, err := s.loadPublishAccountPlatformIDs(ctx, actor.TenantID, actor.PrimaryWorkspaceID, accountIDs)
if err != nil {
return config, err
}
config.PlatformIDs = normalizePlatformIDs(platformIDs)
if schedulePublishPlatformsRequireCover(platformIDs) && coverURL == "" {
return config, response.ErrBadRequest(40025, "cover_required", "已选媒体账号的平台要求封面图,请先设置封面")
}
}
config.PlatformIDs = normalizePlatformIDs(platformIDs)
if schedulePublishPlatformsRequireCover(platformIDs) && coverURL == "" {
return config, response.ErrBadRequest(40025, "cover_required", "selected publish accounts require cover_asset_url")
if len(enterpriseTargets) > 0 {
if err := s.validateScheduleEnterpriseSiteTargets(ctx, actor.TenantID, actor.PrimaryWorkspaceID, enterpriseTargets); err != nil {
return config, err
}
}
accountIDsJSON, err := json.Marshal(accountIDs)
if err != nil {
return config, response.ErrBadRequest(40026, "invalid_publish_account_ids", "publish_account_ids must be serializable")
return config, response.ErrBadRequest(40026, "invalid_publish_account_ids", "媒体账号发布目标格式不正确")
}
enterpriseTargetsJSON, err := encodeScheduleEnterpriseSiteTargets(enterpriseTargets)
if err != nil {
return config, err
}
config.AccountIDsJSON = accountIDsJSON
config.EnterpriseTargetsJSON = enterpriseTargetsJSON
if coverURL != "" {
config.CoverAssetURL = &coverURL
config.CoverImageAssetID = req.CoverImageAssetID
@@ -1116,6 +1140,48 @@ func (s *ScheduleTaskService) normalizeAutoPublishConfig(ctx context.Context, ac
return config, nil
}
func (s *ScheduleTaskService) validateScheduleEnterpriseSiteTargets(ctx context.Context, tenantID, workspaceID int64, targets []ScheduleEnterpriseSiteTarget) error {
for _, target := range targets {
var status string
if err := s.pool.QueryRow(ctx, `
SELECT status
FROM enterprise_site_connections
WHERE id = $1
AND tenant_id = $2
AND workspace_id = $3
AND deleted_at IS NULL
`, target.SiteID, tenantID, workspaceID).Scan(&status); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return response.ErrBadRequest(40050, "invalid_publish_enterprise_site_targets", "企业站点连接不存在或已删除")
}
return response.ErrInternal(50231, "enterprise_site_lookup_failed", "企业站点连接读取失败")
}
if status == "disabled" {
return response.ErrConflict(40942, "enterprise_site_disabled", "企业站点已禁用")
}
if strings.TrimSpace(target.CategoryID) == "" {
return response.ErrBadRequest(40047, "enterprise_site_category_required", "请选择企业站点栏目")
}
var categoryExists bool
if err := s.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM enterprise_site_categories
WHERE tenant_id = $1
AND workspace_id = $2
AND connection_id = $3
AND remote_id = $4
)
`, tenantID, workspaceID, target.SiteID, target.CategoryID).Scan(&categoryExists); err != nil {
return response.ErrInternal(50230, "enterprise_site_category_lookup_failed", "企业站点栏目校验失败")
}
if !categoryExists {
return response.ErrBadRequest(40049, "enterprise_site_category_not_found", "企业站点栏目不存在,请先同步栏目")
}
}
return nil
}
func (s *ScheduleTaskService) loadPublishAccountPlatformIDs(ctx context.Context, tenantID, workspaceID int64, accountIDs []string) ([]string, error) {
rows, err := s.pool.Query(ctx, `
SELECT platform_id
@@ -66,7 +66,14 @@ func NewArticleHandler(a *bootstrap.App) *ArticleHandler {
a.GenerationStreams,
a.Config().Generation,
a.Config().LLM.MaxOutputTokens,
).WithCache(a.Cache).WithLogger(a.Logger).WithConfigProvider(a.ConfigStore),
).WithCache(a.Cache).
WithLogger(a.Logger).
WithConfigProvider(a.ConfigStore).
WithEnterpriseSiteService(
app.NewEnterpriseSiteService(a.DB, a.ConfigStore).
WithCache(a.Cache).
WithObjectStorage(a.ObjectStorage),
),
imitationSvc: app.NewArticleImitationService(
a.DB,
a.LLM,
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
@@ -23,14 +24,16 @@ import (
const kolGenerationTaskType = "kol"
type KolGenerationWorker struct {
pool *pgxpool.Pool
llm llm.Client
knowledge *tenantapp.KnowledgeService
logger *zap.Logger
cfg config.GenerationConfig
llmCfg config.LLMConfig
provider config.Provider
cache sharedcache.Cache
pool *pgxpool.Pool
llm llm.Client
knowledge *tenantapp.KnowledgeService
logger *zap.Logger
cfg config.GenerationConfig
llmCfg config.LLMConfig
provider config.Provider
cache sharedcache.Cache
publishJobs *tenantapp.PublishJobService
enterpriseSites *tenantapp.EnterpriseSiteService
}
func NewKolGenerationWorker(
@@ -56,6 +59,16 @@ func (w *KolGenerationWorker) WithCache(c sharedcache.Cache) *KolGenerationWorke
return w
}
func (w *KolGenerationWorker) WithPublishJobService(svc *tenantapp.PublishJobService) *KolGenerationWorker {
w.publishJobs = svc
return w
}
func (w *KolGenerationWorker) WithEnterpriseSiteService(svc *tenantapp.EnterpriseSiteService) *KolGenerationWorker {
w.enterpriseSites = svc
return w
}
func (w *KolGenerationWorker) WithConfigProvider(provider config.Provider) *KolGenerationWorker {
w.provider = provider
return w
@@ -225,6 +238,19 @@ func (w *KolGenerationWorker) Process(ctx context.Context, task tenantapp.Claime
cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
tenantapp.InvalidateArticleCaches(cleanupCtx, w.cache, task.TenantID, &task.ArticleID)
tenantapp.StartScheduleAutoPublish(tenantapp.ScheduleAutoPublishInput{
TenantID: task.TenantID,
WorkspaceID: int64(kolIntInput(task.InputParams, "workspace_id")),
OperatorID: task.OperatorID,
BrandID: int64(kolIntInput(task.InputParams, "brand_id")),
ArticleID: task.ArticleID,
GenerationTaskID: task.ID,
Title: title,
InputParams: task.InputParams,
PublishJobs: w.publishJobs,
EnterpriseSites: w.enterpriseSites,
Logger: w.logger,
})
}
func (w *KolGenerationWorker) HandleTaskFailure(ctx context.Context, task tenantapp.ClaimedGenerationTask, stage string, taskErr error) {
@@ -283,6 +309,31 @@ func kolBoolInput(input map[string]interface{}, key string) bool {
return ok && enabled
}
func kolIntInput(input map[string]interface{}, key string) int {
if input == nil {
return 0
}
value, ok := input[key]
if !ok || value == nil {
return 0
}
switch typed := value.(type) {
case int:
return typed
case int32:
return int(typed)
case int64:
return int(typed)
case float64:
return int(typed)
case string:
parsed, _ := strconv.Atoi(strings.TrimSpace(typed))
return parsed
default:
return 0
}
}
func kolKnowledgeGroupIDs(value interface{}) []int64 {
switch typed := value.(type) {
case []int64:
@@ -0,0 +1,9 @@
ALTER TABLE enterprise_site_connections
DROP CONSTRAINT IF EXISTS ck_enterprise_site_connections_cms_type;
ALTER TABLE enterprise_site_connections
ADD CONSTRAINT ck_enterprise_site_connections_cms_type
CHECK (cms_type IN ('pbootcms', 'wordpress', 'dedecms', 'phpcms'));
DELETE FROM media_platforms
WHERE platform_id = 'wordpress';
@@ -0,0 +1,18 @@
INSERT INTO media_platforms (platform_id, name, category, short_name, accent_color, login_url, logo_url, status, sort_order)
VALUES ('wordpress', 'WordPress', 'enterprise', 'WP', '#21759b', NULL, NULL, 'active', 901)
ON CONFLICT (platform_id)
DO UPDATE SET
name = EXCLUDED.name,
category = EXCLUDED.category,
short_name = EXCLUDED.short_name,
accent_color = EXCLUDED.accent_color,
status = EXCLUDED.status,
sort_order = EXCLUDED.sort_order,
updated_at = NOW();
ALTER TABLE enterprise_site_connections
DROP CONSTRAINT IF EXISTS ck_enterprise_site_connections_cms_type;
ALTER TABLE enterprise_site_connections
ADD CONSTRAINT ck_enterprise_site_connections_cms_type
CHECK (cms_type IN ('pbootcms', 'wordpress'));
@@ -0,0 +1,5 @@
ALTER TABLE schedule_tasks
DROP CONSTRAINT IF EXISTS chk_schedule_tasks_publish_enterprise_site_targets_json;
ALTER TABLE schedule_tasks
DROP COLUMN IF EXISTS publish_enterprise_site_targets;
@@ -0,0 +1,6 @@
ALTER TABLE schedule_tasks
ADD COLUMN publish_enterprise_site_targets JSONB NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE schedule_tasks
ADD CONSTRAINT chk_schedule_tasks_publish_enterprise_site_targets_json
CHECK (jsonb_typeof(publish_enterprise_site_targets) = 'array');