feat(enterprise-site): add WordPress support and scheduled auto-publish to sites
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:
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user