feat(tenant): add brand publish-records list and harden enterprise site management

- Add GET /api/tenant/publish-records: brand-scoped paginated list that merges
  in-flight (queued/publishing) records ahead of history (success/failed/cancelled),
  enriched with article title and latest desktop task id; wire up
  service/handler/router/swagger/router_test
- Rework PublishManagementView to consume publish-records instead of desktop
  publish tasks; add publishRecordsApi + shared-types
  (ListPublishRecordsParams, PublishRecordListResponse)
- Enterprise site Update: distinguish explicit null from missing PATCH fields via
  EnterpriseSitePatchString, dedup site_url before write, verify RowsAffected,
  and map unique-constraint violations to a clear conflict
- MediaView: enterprise site edit/delete flows with favicon fallback handling
- Localize enterprise site / PBootCMS error messages to Chinese across the
  backend and the admin-web error map, plus legacy raw-message translation
- deploy: gate migration-coupled service rollouts behind RUN_MIGRATIONS unless
  ALLOW_SKIP_MIGRATIONS_FOR_APP_ROLLOUT=true; handle empty SERVICES safely

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 11:40:46 +08:00
parent 67be43319e
commit a44ed21967
15 changed files with 1418 additions and 764 deletions
@@ -196,6 +196,7 @@ var routeDocs = map[string]routeDoc{
"POST /api/tenant/tasks/:id/reconcile": {"对账发布任务", "管理后台对发布任务做对账:状态校正、结果回填。"},
"POST /api/tenant/tasks/:id/cancel": {"取消发布任务(后台)", "管理员在后台取消还未派发或还在执行的发布任务。"},
"GET /api/tenant/publish-tasks": {"发布任务列表(后台)", "管理后台分页查询本 Workspace 下的发布队列与历史发送结果。"},
"GET /api/tenant/publish-records": {"发布记录列表(后台)", "管理后台分页查询当前品牌的发布中记录和历史发布记录。"},
"POST /api/tenant/publish-tasks/:id/retry": {"重试发布任务(后台)", "管理后台对失败的发布任务重新创建一次发布任务。"},
"POST /api/tenant/publish-jobs": {"批量创建发布任务", "为一批文章/账号批量生成发布任务,进入派发队列。"},
@@ -137,15 +137,35 @@ type CreateEnterpriseSiteConnectionRequest struct {
DefaultScode *string `json:"default_scode"`
}
type EnterpriseSitePatchString struct {
Set bool
Value *string
}
func (v *EnterpriseSitePatchString) UnmarshalJSON(data []byte) error {
v.Set = true
v.Value = nil
trimmed := bytes.TrimSpace(data)
if bytes.Equal(trimmed, []byte("null")) {
return nil
}
var value string
if err := json.Unmarshal(trimmed, &value); err != nil {
return err
}
v.Value = &value
return nil
}
type UpdateEnterpriseSiteConnectionRequest struct {
Name *string `json:"name"`
SiteURL *string `json:"site_url"`
AppID *string `json:"appid"`
Secret *string `json:"secret"`
DefaultAcode *string `json:"default_acode"`
DefaultMcode *string `json:"default_mcode"`
DefaultScode *string `json:"default_scode"`
Status *string `json:"status"`
Name EnterpriseSitePatchString `json:"name"`
SiteURL EnterpriseSitePatchString `json:"site_url"`
AppID EnterpriseSitePatchString `json:"appid"`
Secret EnterpriseSitePatchString `json:"secret"`
DefaultAcode EnterpriseSitePatchString `json:"default_acode"`
DefaultMcode EnterpriseSitePatchString `json:"default_mcode"`
DefaultScode EnterpriseSitePatchString `json:"default_scode"`
Status EnterpriseSitePatchString `json:"status"`
}
type PublishEnterpriseSiteArticleRequest struct {
@@ -219,7 +239,7 @@ func (s *EnterpriseSiteService) List(ctx context.Context) ([]EnterpriseSiteConne
actor := auth.MustActor(ctx)
workspaceID := auth.CurrentWorkspaceID(ctx)
if actor.TenantID == 0 || workspaceID == 0 {
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "缺少工作区上下文")
}
rows, err := s.pool.Query(ctx, `
@@ -292,7 +312,7 @@ func (s *EnterpriseSiteService) List(ctx context.Context) ([]EnterpriseSiteConne
ORDER BY c.created_at DESC, c.id DESC
`, actor.TenantID, workspaceID)
if err != nil {
return nil, response.ErrInternal(50220, "enterprise_site_query_failed", "failed to list enterprise sites")
return nil, response.ErrInternal(50220, "enterprise_site_query_failed", "企业站点列表读取失败")
}
defer rows.Close()
@@ -300,7 +320,7 @@ func (s *EnterpriseSiteService) List(ctx context.Context) ([]EnterpriseSiteConne
for rows.Next() {
record, latest, scanErr := scanEnterpriseSiteConnectionRow(rows)
if scanErr != nil {
return nil, response.ErrInternal(50220, "enterprise_site_scan_failed", "failed to parse enterprise site")
return nil, response.ErrInternal(50220, "enterprise_site_scan_failed", "企业站点数据解析失败")
}
item := enterpriseSiteConnectionResponse(record)
item.LatestRecord = latest
@@ -308,7 +328,7 @@ func (s *EnterpriseSiteService) List(ctx context.Context) ([]EnterpriseSiteConne
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50220, "enterprise_site_scan_failed", "failed to iterate enterprise sites")
return nil, response.ErrInternal(50220, "enterprise_site_scan_failed", "企业站点数据解析失败")
}
return items, nil
}
@@ -317,7 +337,7 @@ func (s *EnterpriseSiteService) Create(ctx context.Context, req CreateEnterprise
actor := auth.MustActor(ctx)
workspaceID := auth.CurrentWorkspaceID(ctx)
if actor.TenantID == 0 || workspaceID == 0 || actor.UserID == 0 {
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "缺少工作区上下文")
}
normalized, err := normalizeCreateEnterpriseSiteConnectionRequest(req)
@@ -326,7 +346,7 @@ func (s *EnterpriseSiteService) Create(ctx context.Context, req CreateEnterprise
}
ciphertext, err := encryptEnterpriseSiteCredential(normalized.Secret, s.credentialKeyMaterial())
if err != nil {
return nil, response.ErrInternal(50221, "enterprise_site_secret_encrypt_failed", "failed to store enterprise site credential")
return nil, response.ErrInternal(50221, "enterprise_site_secret_encrypt_failed", "企业站点凭证保存失败")
}
var id int64
@@ -350,7 +370,10 @@ func (s *EnterpriseSiteService) Create(ctx context.Context, req CreateEnterprise
normalized.DefaultMcode,
normalized.DefaultScode,
).Scan(&id); err != nil {
return nil, response.ErrConflict(40941, "enterprise_site_already_exists", "enterprise site connection already exists")
if isUniqueConstraintError(err, "uk_enterprise_site_connections_active_site") {
return nil, response.ErrConflict(40941, "enterprise_site_already_exists", "该站点域名已被其他企业站点占用")
}
return nil, response.ErrInternal(50232, "enterprise_site_create_failed", "企业站点创建失败")
}
return s.Get(ctx, id)
@@ -360,65 +383,96 @@ func (s *EnterpriseSiteService) Update(ctx context.Context, id int64, req Update
actor := auth.MustActor(ctx)
workspaceID := auth.CurrentWorkspaceID(ctx)
if actor.TenantID == 0 || workspaceID == 0 {
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "缺少工作区上下文")
}
current, err := s.loadConnection(ctx, actor.TenantID, workspaceID, id)
if err != nil {
return nil, err
}
if current.Status == "disabled" && req.Status == nil {
return nil, response.ErrConflict(40942, "enterprise_site_disabled", "enterprise site connection is disabled")
if current.Status == "disabled" && !req.Status.Set {
return nil, response.ErrConflict(40942, "enterprise_site_disabled", "企业站点已禁用")
}
next := *current
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if req.Name.Set {
if req.Name.Value == nil {
return nil, response.ErrBadRequest(40041, "enterprise_site_name_required", "请输入站点名称")
}
name := strings.TrimSpace(*req.Name.Value)
if name == "" {
return nil, response.ErrBadRequest(40041, "enterprise_site_name_required", "site name is required")
return nil, response.ErrBadRequest(40041, "enterprise_site_name_required", "请输入站点名称")
}
next.Name = name
}
if req.SiteURL != nil {
siteURL, normalizeErr := normalizeEnterpriseSiteURL(*req.SiteURL)
if req.SiteURL.Set {
if req.SiteURL.Value == nil {
return nil, response.ErrBadRequest(40041, "enterprise_site_url_required", "请填写站点域名")
}
siteURL, normalizeErr := normalizeEnterpriseSiteURL(*req.SiteURL.Value)
if normalizeErr != nil {
return nil, normalizeErr
}
next.SiteURL = siteURL
}
if req.AppID != nil {
appid := strings.TrimSpace(*req.AppID)
if req.AppID.Set {
if req.AppID.Value == nil {
return nil, response.ErrBadRequest(40042, "enterprise_site_appid_required", "请输入 AppID")
}
appid := strings.TrimSpace(*req.AppID.Value)
if appid == "" {
return nil, response.ErrBadRequest(40042, "enterprise_site_appid_required", "appid is required")
return nil, response.ErrBadRequest(40042, "enterprise_site_appid_required", "请输入 AppID")
}
next.AppID = appid
}
if req.DefaultAcode != nil {
next.DefaultAcode = normalizeOptionalEnterpriseCode(*req.DefaultAcode)
if req.DefaultAcode.Set {
next.DefaultAcode = nil
if req.DefaultAcode.Value != nil {
next.DefaultAcode = normalizeOptionalEnterpriseCode(*req.DefaultAcode.Value)
}
}
if req.DefaultMcode != nil {
next.DefaultMcode = normalizeOptionalEnterpriseCode(*req.DefaultMcode)
if req.DefaultMcode.Set {
next.DefaultMcode = nil
if req.DefaultMcode.Value != nil {
next.DefaultMcode = normalizeOptionalEnterpriseCode(*req.DefaultMcode.Value)
}
}
if req.DefaultScode != nil {
next.DefaultScode = normalizeOptionalEnterpriseCode(*req.DefaultScode)
if req.DefaultScode.Set {
next.DefaultScode = nil
if req.DefaultScode.Value != nil {
next.DefaultScode = normalizeOptionalEnterpriseCode(*req.DefaultScode.Value)
}
}
if req.Status != nil {
status := strings.TrimSpace(*req.Status)
if req.Status.Set {
if req.Status.Value == nil {
return nil, response.ErrBadRequest(40043, "invalid_enterprise_site_status", "状态参数不正确")
}
status := strings.TrimSpace(*req.Status.Value)
if !validEnterpriseSiteStatus(status) {
return nil, response.ErrBadRequest(40043, "invalid_enterprise_site_status", "status is invalid")
return nil, response.ErrBadRequest(40043, "invalid_enterprise_site_status", "状态参数不正确")
}
next.Status = status
}
ciphertext := next.CredentialCiphertext
if req.Secret != nil && strings.TrimSpace(*req.Secret) != "" {
encrypted, encryptErr := encryptEnterpriseSiteCredential(*req.Secret, s.credentialKeyMaterial())
if req.Secret.Set && req.Secret.Value != nil && strings.TrimSpace(*req.Secret.Value) != "" {
encrypted, encryptErr := encryptEnterpriseSiteCredential(*req.Secret.Value, s.credentialKeyMaterial())
if encryptErr != nil {
return nil, response.ErrInternal(50221, "enterprise_site_secret_encrypt_failed", "failed to store enterprise site credential")
return nil, response.ErrInternal(50221, "enterprise_site_secret_encrypt_failed", "企业站点凭证保存失败")
}
ciphertext = encrypted
}
if _, err := s.pool.Exec(ctx, `
if !validEnterpriseSiteStatus(next.Status) {
next.Status = "draft"
}
if !strings.EqualFold(next.SiteURL, current.SiteURL) {
if err := s.ensureEnterpriseSiteURLAvailable(ctx, actor.TenantID, workspaceID, id, next.SiteURL, next.CMSType); err != nil {
return nil, err
}
}
command, err := s.pool.Exec(ctx, `
UPDATE enterprise_site_connections
SET name = $1,
site_url = $2,
@@ -427,8 +481,8 @@ func (s *EnterpriseSiteService) Update(ctx context.Context, id int64, req Update
default_acode = $5,
default_mcode = $6,
default_scode = $7,
status = $8,
last_error = CASE WHEN $8 IN ('draft', 'connected') THEN NULL ELSE last_error END,
status = $8::varchar,
last_error = CASE WHEN $8::text IN ('draft', 'connected') THEN NULL ELSE last_error END,
updated_at = NOW()
WHERE id = $9
AND tenant_id = $10
@@ -439,20 +493,51 @@ func (s *EnterpriseSiteService) Update(ctx context.Context, id int64, req Update
next.SiteURL,
next.AppID,
ciphertext,
next.DefaultAcode,
next.DefaultMcode,
next.DefaultScode,
enterpriseSiteNullableText(next.DefaultAcode),
enterpriseSiteNullableText(next.DefaultMcode),
enterpriseSiteNullableText(next.DefaultScode),
next.Status,
id,
actor.TenantID,
workspaceID,
); err != nil {
return nil, response.ErrConflict(40941, "enterprise_site_already_exists", "enterprise site connection already exists")
)
if err != nil {
if isUniqueConstraintError(err, "uk_enterprise_site_connections_active_site") {
return nil, response.ErrConflict(40941, "enterprise_site_already_exists", "该站点域名已被其他企业站点占用")
}
appErr := response.ErrInternal(50233, "enterprise_site_update_failed", "企业站点更新失败,请检查站点信息后重试")
appErr.Cause = err
return nil, appErr
}
if command.RowsAffected() == 0 {
return nil, response.ErrNotFound(40441, "enterprise_site_not_found", "企业站点连接不存在或已删除")
}
return s.Get(ctx, id)
}
func (s *EnterpriseSiteService) ensureEnterpriseSiteURLAvailable(ctx context.Context, tenantID, workspaceID, currentID int64, siteURL, cmsType string) error {
var existingID int64
err := s.pool.QueryRow(ctx, `
SELECT id
FROM enterprise_site_connections
WHERE tenant_id = $1
AND workspace_id = $2
AND lower(site_url) = lower($3)
AND cms_type = $4
AND deleted_at IS NULL
AND id <> $5
LIMIT 1
`, tenantID, workspaceID, siteURL, cmsType, currentID).Scan(&existingID)
if err == nil {
return response.ErrConflict(40941, "enterprise_site_already_exists", "该站点域名已被其他企业站点占用")
}
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
return response.ErrInternal(50233, "enterprise_site_update_failed", "企业站点连接重复校验失败")
}
func (s *EnterpriseSiteService) Get(ctx context.Context, id int64) (*EnterpriseSiteConnectionResponse, error) {
actor := auth.MustActor(ctx)
workspaceID := auth.CurrentWorkspaceID(ctx)
@@ -477,10 +562,10 @@ func (s *EnterpriseSiteService) Delete(ctx context.Context, id int64) error {
AND deleted_at IS NULL
`, id, actor.TenantID, workspaceID)
if err != nil {
return response.ErrInternal(50222, "enterprise_site_delete_failed", "failed to delete enterprise site")
return response.ErrInternal(50222, "enterprise_site_delete_failed", "企业站点删除失败")
}
if command.RowsAffected() == 0 {
return response.ErrNotFound(40441, "enterprise_site_not_found", "enterprise site connection not found")
return response.ErrNotFound(40441, "enterprise_site_not_found", "企业站点连接不存在或已删除")
}
return nil
}
@@ -514,7 +599,7 @@ func (s *EnterpriseSiteService) Ping(ctx context.Context, id int64) (*Enterprise
updated_at = NOW()
WHERE id = $3 AND tenant_id = $4 AND workspace_id = $5 AND deleted_at IS NULL
`, pluginVersion, siteName, connection.ID, actor.TenantID, workspaceID); err != nil {
return nil, response.ErrInternal(50223, "enterprise_site_ping_update_failed", "failed to update enterprise site ping")
return nil, response.ErrInternal(50223, "enterprise_site_ping_update_failed", "企业站点连接状态更新失败")
}
_ = raw
return capability, nil
@@ -537,12 +622,12 @@ func (s *EnterpriseSiteService) SyncCategories(ctx context.Context, id int64) ([
return nil, response.ErrBadRequest(40045, "enterprise_site_category_sync_failed", sanitizeEnterpriseSiteError(err))
}
if len(items) == 0 {
return nil, response.ErrBadRequest(40046, "enterprise_site_categories_empty", "CMS did not return any publishable categories")
return nil, response.ErrBadRequest(40046, "enterprise_site_categories_empty", "CMS 未返回可发布栏目")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(50224, "enterprise_site_category_sync_begin_failed", "failed to sync enterprise site categories")
return nil, response.ErrInternal(50224, "enterprise_site_category_sync_begin_failed", "企业站点栏目同步失败")
}
defer tx.Rollback(ctx)
@@ -550,13 +635,13 @@ func (s *EnterpriseSiteService) SyncCategories(ctx context.Context, id int64) ([
DELETE FROM enterprise_site_categories
WHERE tenant_id = $1 AND workspace_id = $2 AND connection_id = $3
`, actor.TenantID, workspaceID, connection.ID); err != nil {
return nil, response.ErrInternal(50224, "enterprise_site_category_clear_failed", "failed to sync enterprise site categories")
return nil, response.ErrInternal(50224, "enterprise_site_category_clear_failed", "企业站点栏目同步失败")
}
for _, item := range items {
rawJSON, marshalErr := json.Marshal(item.Raw)
if marshalErr != nil {
return nil, response.ErrInternal(50224, "enterprise_site_category_payload_failed", "failed to sync enterprise site categories")
return nil, response.ErrInternal(50224, "enterprise_site_category_payload_failed", "企业站点栏目同步失败")
}
if _, err := tx.Exec(ctx, `
INSERT INTO enterprise_site_categories (
@@ -573,7 +658,7 @@ func (s *EnterpriseSiteService) SyncCategories(ctx context.Context, id int64) ([
item.Slug,
rawJSON,
); err != nil {
return nil, response.ErrInternal(50224, "enterprise_site_category_insert_failed", "failed to sync enterprise site categories")
return nil, response.ErrInternal(50224, "enterprise_site_category_insert_failed", "企业站点栏目同步失败")
}
}
@@ -585,11 +670,11 @@ func (s *EnterpriseSiteService) SyncCategories(ctx context.Context, id int64) ([
updated_at = NOW()
WHERE id = $1 AND tenant_id = $2 AND workspace_id = $3 AND deleted_at IS NULL
`, connection.ID, actor.TenantID, workspaceID); err != nil {
return nil, response.ErrInternal(50224, "enterprise_site_sync_update_failed", "failed to update enterprise site sync state")
return nil, response.ErrInternal(50224, "enterprise_site_sync_update_failed", "企业站点同步状态更新失败")
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50224, "enterprise_site_category_sync_commit_failed", "failed to sync enterprise site categories")
return nil, response.ErrInternal(50224, "enterprise_site_category_sync_commit_failed", "企业站点栏目同步失败")
}
return s.Categories(ctx, id)
}
@@ -609,7 +694,7 @@ func (s *EnterpriseSiteService) Categories(ctx context.Context, id int64) ([]Ent
ORDER BY COALESCE(parent_remote_id, ''), name ASC, id ASC
`, actor.TenantID, workspaceID, id)
if err != nil {
return nil, response.ErrInternal(50225, "enterprise_site_category_query_failed", "failed to list enterprise site categories")
return nil, response.ErrInternal(50225, "enterprise_site_category_query_failed", "企业站点栏目读取失败")
}
defer rows.Close()
@@ -618,7 +703,7 @@ func (s *EnterpriseSiteService) Categories(ctx context.Context, id int64) ([]Ent
var item EnterpriseSiteCategoryResponse
var raw []byte
if err := rows.Scan(&item.ID, &item.RemoteID, &item.ParentRemoteID, &item.Name, &item.Slug, &raw, &item.SyncedAt); err != nil {
return nil, response.ErrInternal(50225, "enterprise_site_category_scan_failed", "failed to parse enterprise site category")
return nil, response.ErrInternal(50225, "enterprise_site_category_scan_failed", "企业站点栏目解析失败")
}
if len(raw) > 0 {
item.Raw = map[string]any{}
@@ -627,7 +712,7 @@ func (s *EnterpriseSiteService) Categories(ctx context.Context, id int64) ([]Ent
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50225, "enterprise_site_category_scan_failed", "failed to iterate enterprise site categories")
return nil, response.ErrInternal(50225, "enterprise_site_category_scan_failed", "企业站点栏目解析失败")
}
return items, nil
}
@@ -640,7 +725,7 @@ func (s *EnterpriseSiteService) PublishArticle(ctx context.Context, connectionID
return nil, err
}
if actor.TenantID == 0 || workspaceID == 0 || actor.UserID == 0 {
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "workspace context is required")
return nil, response.ErrUnauthorized(40132, "workspace_scope_missing", "缺少工作区上下文")
}
connection, err := s.loadConnection(ctx, actor.TenantID, workspaceID, connectionID)
@@ -648,7 +733,7 @@ func (s *EnterpriseSiteService) PublishArticle(ctx context.Context, connectionID
return nil, err
}
if connection.Status == "disabled" {
return nil, response.ErrConflict(40942, "enterprise_site_disabled", "enterprise site connection is disabled")
return nil, response.ErrConflict(40942, "enterprise_site_disabled", "企业站点已禁用")
}
article, err := s.loadPublishArticle(ctx, actor.TenantID, brandID, req.ArticleID, req.ArticleVersionID)
@@ -660,7 +745,7 @@ func (s *EnterpriseSiteService) PublishArticle(ctx context.Context, connectionID
categoryID = strings.TrimSpace(enterpriseSiteStringPointerValue(connection.DefaultScode))
}
if categoryID == "" {
return nil, response.ErrBadRequest(40047, "enterprise_site_category_required", "category_id is required")
return nil, response.ErrBadRequest(40047, "enterprise_site_category_required", "请选择企业站点栏目")
}
if err := s.ensureCategoryExists(ctx, actor.TenantID, workspaceID, connection.ID, categoryID); err != nil {
return nil, err
@@ -678,12 +763,12 @@ func (s *EnterpriseSiteService) PublishArticle(ctx context.Context, connectionID
if contentHTML == "" {
contentHTML, err = markdownToEnterpriseSiteHTML(markdownContent)
if err != nil {
return nil, response.ErrInternal(50226, "enterprise_site_markdown_render_failed", "failed to render article content")
return nil, response.ErrInternal(50226, "enterprise_site_markdown_render_failed", "文章内容渲染失败")
}
}
contentHTML = enterpriseSiteStripLeadingTitleHeadingHTML(contentHTML, article.Title)
if strings.TrimSpace(contentHTML) == "" {
return nil, response.ErrBadRequest(40048, "enterprise_site_article_empty", "article content is empty")
return nil, response.ErrBadRequest(40048, "enterprise_site_article_empty", "文章内容为空")
}
effectiveVersionID, err := s.compliance.ResolvePublishableVersion(ctx, actor.TenantID, article.ID, article.CurrentVersionID)
@@ -705,9 +790,9 @@ func (s *EnterpriseSiteService) PublishArticle(ctx context.Context, connectionID
if gate != nil {
switch gate.Decision {
case sharedcompliance.GateDecisionBlock:
return nil, compliancePublishError(response.ErrConflict(41001, "compliance_blocked", "content compliance blocked this publish request"), gate)
return nil, compliancePublishError(response.ErrConflict(41001, "compliance_blocked", "内容合规检查未通过,无法发布"), gate)
case sharedcompliance.GateDecisionNeedsAck:
return nil, compliancePublishError(response.ErrConflict(41002, "compliance_needs_ack", "content compliance requires acknowledgement before publishing"), gate)
return nil, compliancePublishError(response.ErrConflict(41002, "compliance_needs_ack", "发布前需要先确认合规提示"), gate)
}
}
@@ -753,7 +838,7 @@ func (s *EnterpriseSiteService) PublishArticle(ctx context.Context, connectionID
"cover_image_asset_id": coverImageAssetID,
})
if err != nil {
return nil, response.ErrInternal(50227, "enterprise_site_publish_payload_failed", "failed to create publish record")
return nil, response.ErrInternal(50227, "enterprise_site_publish_payload_failed", "企业站点发布记录创建失败")
}
publishBatchID, publishRecordID, err := s.createEnterprisePublishRecord(ctx, actor, article, connection, publishType, requestJSON, effectiveVersionID, req.AckRecordID, gate)
@@ -779,7 +864,7 @@ func (s *EnterpriseSiteService) PublishArticle(ctx context.Context, connectionID
rawJSON, err := json.Marshal(raw)
if err != nil {
return nil, response.ErrInternal(50228, "enterprise_site_publish_response_failed", "failed to persist CMS response")
return nil, response.ErrInternal(50228, "enterprise_site_publish_response_failed", "CMS 响应保存失败")
}
externalID := normalizeStringPointer(&result.RemoteID)
externalURL := normalizeStringPointer(&result.URL)
@@ -818,7 +903,7 @@ func (s *EnterpriseSiteService) createEnterprisePublishRecord(
) (int64, int64, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return 0, 0, response.ErrInternal(50227, "enterprise_site_publish_begin_failed", "failed to create publish record")
return 0, 0, response.ErrInternal(50227, "enterprise_site_publish_begin_failed", "企业站点发布记录创建失败")
}
defer tx.Rollback(ctx)
@@ -828,7 +913,7 @@ func (s *EnterpriseSiteService) createEnterprisePublishRecord(
VALUES ($1, $2, $3, 'publishing', $4, $5)
RETURNING id
`, actor.TenantID, article.ID, actor.UserID, publishType, normalizeStringPointer(article.CoverAssetURL)).Scan(&publishBatchID); err != nil {
return 0, 0, response.ErrInternal(50227, "enterprise_site_publish_batch_failed", "failed to create publish record")
return 0, 0, response.ErrInternal(50227, "enterprise_site_publish_batch_failed", "企业站点发布记录创建失败")
}
var publishRecordID int64
@@ -840,7 +925,7 @@ func (s *EnterpriseSiteService) createEnterprisePublishRecord(
VALUES ($1, $2, $3, NULL, $4, 'publishing', $5, 'enterprise_site', $6)
RETURNING id
`, actor.TenantID, publishBatchID, article.ID, connection.CMSType, requestJSON, connection.ID).Scan(&publishRecordID); err != nil {
return 0, 0, response.ErrInternal(50227, "enterprise_site_publish_record_failed", "failed to create publish record")
return 0, 0, response.ErrInternal(50227, "enterprise_site_publish_record_failed", "企业站点发布记录创建失败")
}
if _, err := tx.Exec(ctx, `
@@ -848,7 +933,7 @@ func (s *EnterpriseSiteService) createEnterprisePublishRecord(
SET publish_status = 'publishing', updated_at = NOW()
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, article.ID, actor.TenantID); err != nil {
return 0, 0, response.ErrInternal(50227, "enterprise_site_article_publish_status_failed", "failed to update article publish status")
return 0, 0, response.ErrInternal(50227, "enterprise_site_article_publish_status_failed", "文章发布状态更新失败")
}
if err := s.consumeEnterprisePublishAckTx(ctx, tx, ackRecordID, gate, actor.TenantID, article.ID, articleVersionID); err != nil {
@@ -856,7 +941,7 @@ func (s *EnterpriseSiteService) createEnterprisePublishRecord(
}
if err := tx.Commit(ctx); err != nil {
return 0, 0, response.ErrInternal(50227, "enterprise_site_publish_commit_failed", "failed to create publish record")
return 0, 0, response.ErrInternal(50227, "enterprise_site_publish_commit_failed", "企业站点发布记录创建失败")
}
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &article.ID)
return publishBatchID, publishRecordID, nil
@@ -875,7 +960,7 @@ func (s *EnterpriseSiteService) consumeEnterprisePublishAckTx(
return nil
}
if gate == nil || gate.Result == nil {
return response.ErrBadRequest(41003, "compliance_ack_mismatch", "ack record cannot be consumed without a matching compliance result")
return response.ErrBadRequest(41003, "compliance_ack_mismatch", "合规确认记录与当前文章不匹配")
}
consumed, err := s.compliance.ConsumeAckTx(ctx, tx, *ackRecordID, tenantcompliance.ConsumeAckQuery{
TenantID: tenantID,
@@ -887,10 +972,10 @@ func (s *EnterpriseSiteService) consumeEnterprisePublishAckTx(
ConsumedByJobID: nil,
})
if err != nil {
return response.ErrInternal(50227, "enterprise_site_publish_ack_failed", "failed to consume compliance acknowledgement")
return response.ErrInternal(50227, "enterprise_site_publish_ack_failed", "合规确认处理失败")
}
if !consumed {
return response.ErrBadRequest(41003, "compliance_ack_mismatch", "ack record is invalid or expired")
return response.ErrBadRequest(41003, "compliance_ack_mismatch", "合规确认记录无效或已过期")
}
return nil
}
@@ -912,7 +997,7 @@ func (s *EnterpriseSiteService) finishEnterprisePublishRecord(
) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return response.ErrInternal(50228, "enterprise_site_publish_finish_begin_failed", "failed to update publish record")
return response.ErrInternal(50228, "enterprise_site_publish_finish_begin_failed", "企业站点发布结果更新失败")
}
defer tx.Rollback(ctx)
@@ -932,7 +1017,7 @@ func (s *EnterpriseSiteService) finishEnterprisePublishRecord(
updated_at = NOW()
WHERE id = $7 AND tenant_id = $8
`, status, externalID, externalURL, nullableJSON(responsePayload), publishedAt, errorMessage, publishRecordID, tenantID); err != nil {
return response.ErrInternal(50228, "enterprise_site_publish_record_update_failed", "failed to update publish record")
return response.ErrInternal(50228, "enterprise_site_publish_record_update_failed", "企业站点发布结果更新失败")
}
batchStatus, err := recalculatePublishBatchStatus(ctx, tx, publishBatchID)
@@ -944,7 +1029,7 @@ func (s *EnterpriseSiteService) finishEnterprisePublishRecord(
SET status = $1, updated_at = NOW()
WHERE id = $2 AND tenant_id = $3
`, batchStatus, publishBatchID, tenantID); err != nil {
return response.ErrInternal(50228, "enterprise_site_publish_batch_update_failed", "failed to update publish batch")
return response.ErrInternal(50228, "enterprise_site_publish_batch_update_failed", "企业站点发布批次更新失败")
}
articleStatus := publishBatchStatusToArticleStatus(batchStatus)
@@ -953,7 +1038,7 @@ func (s *EnterpriseSiteService) finishEnterprisePublishRecord(
SET publish_status = $1, updated_at = NOW()
WHERE id = $2 AND tenant_id = $3 AND deleted_at IS NULL
`, articleStatus, articleID, tenantID); err != nil {
return response.ErrInternal(50228, "enterprise_site_article_publish_update_failed", "failed to update article publish status")
return response.ErrInternal(50228, "enterprise_site_article_publish_update_failed", "文章发布状态更新失败")
}
if status == "success" {
@@ -965,12 +1050,12 @@ func (s *EnterpriseSiteService) finishEnterprisePublishRecord(
updated_at = NOW()
WHERE id = $1 AND tenant_id = $2 AND workspace_id = $3 AND deleted_at IS NULL
`, connectionID, tenantID, workspaceID); err != nil {
return response.ErrInternal(50228, "enterprise_site_publish_connection_update_failed", "failed to update enterprise site")
return response.ErrInternal(50228, "enterprise_site_publish_connection_update_failed", "企业站点连接状态更新失败")
}
}
if err := tx.Commit(ctx); err != nil {
return response.ErrInternal(50228, "enterprise_site_publish_finish_commit_failed", "failed to update publish record")
return response.ErrInternal(50228, "enterprise_site_publish_finish_commit_failed", "企业站点发布结果更新失败")
}
invalidateArticleCaches(ctx, s.cache, tenantID, &articleID)
return nil
@@ -1007,7 +1092,7 @@ func (s *EnterpriseSiteService) loadPublishArticle(ctx context.Context, tenantID
if errors.Is(err, pgx.ErrNoRows) {
return article, response.ErrNotFound(40411, "article_not_found", "article not found")
}
return article, response.ErrInternal(50229, "enterprise_site_article_lookup_failed", "failed to load article")
return article, response.ErrInternal(50229, "enterprise_site_article_lookup_failed", "文章读取失败")
}
if err := s.populatePublishArticleContent(ctx, &article, wizardStateJSON, requestedVersionID); err != nil {
return article, err
@@ -1071,7 +1156,7 @@ func (s *EnterpriseSiteService) loadPublishArticleVersionContent(ctx context.Con
}
return response.ErrBadRequest(41005, "invalid_article_version", "article_version_id does not belong to this article")
}
return response.ErrInternal(50231, "enterprise_site_article_version_reconstruct_failed", "failed to reconstruct article version")
return response.ErrInternal(50231, "enterprise_site_article_version_reconstruct_failed", "文章版本内容恢复失败")
}
if content != nil {
article.HTMLContent = strings.TrimSpace(enterpriseSiteStringPointerValue(content.HTMLContent))
@@ -1094,7 +1179,7 @@ func (s *EnterpriseSiteService) loadPublishArticleVersionMetadata(ctx context.Co
if errors.Is(err, pgx.ErrNoRows) {
return response.ErrBadRequest(41005, "invalid_article_version", "article_version_id does not belong to this article")
}
return response.ErrInternal(50231, "enterprise_site_article_version_lookup_failed", "failed to load article version")
return response.ErrInternal(50231, "enterprise_site_article_version_lookup_failed", "文章版本读取失败")
}
if strings.TrimSpace(title) != "" {
article.Title = strings.TrimSpace(title)
@@ -1121,7 +1206,7 @@ func (s *EnterpriseSiteService) loadPublishArticleVersionSnapshotContent(ctx con
}
return response.ErrBadRequest(41005, "invalid_article_version", "article_version_id does not belong to this article")
}
return response.ErrInternal(50231, "enterprise_site_article_version_snapshot_lookup_failed", "failed to load article version")
return response.ErrInternal(50231, "enterprise_site_article_version_snapshot_lookup_failed", "文章版本读取失败")
}
snapshot = enterpriseSiteNormalizeStoredContent(snapshot)
if strings.TrimSpace(snapshot) != "" {
@@ -1180,10 +1265,10 @@ func (s *EnterpriseSiteService) ensureCategoryExists(ctx context.Context, tenant
AND remote_id = $4
)
`, tenantID, workspaceID, connectionID, categoryID).Scan(&exists); err != nil {
return response.ErrInternal(50230, "enterprise_site_category_lookup_failed", "failed to validate enterprise site category")
return response.ErrInternal(50230, "enterprise_site_category_lookup_failed", "企业站点栏目校验失败")
}
if !exists {
return response.ErrBadRequest(40049, "enterprise_site_category_not_found", "category is not synced for this site")
return response.ErrBadRequest(40049, "enterprise_site_category_not_found", "企业站点栏目不存在,请先同步栏目")
}
return nil
}
@@ -1250,9 +1335,9 @@ func (s *EnterpriseSiteService) loadConnection(ctx context.Context, tenantID, wo
&item.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, response.ErrNotFound(40441, "enterprise_site_not_found", "enterprise site connection not found")
return nil, response.ErrNotFound(40441, "enterprise_site_not_found", "企业站点连接不存在或已删除")
}
return nil, response.ErrInternal(50220, "enterprise_site_lookup_failed", "failed to load enterprise site")
return nil, response.ErrInternal(50220, "enterprise_site_lookup_failed", "企业站点连接读取失败")
}
return &item, nil
}
@@ -1260,7 +1345,7 @@ func (s *EnterpriseSiteService) loadConnection(ctx context.Context, tenantID, wo
func (s *EnterpriseSiteService) connectionCredential(connection *enterpriseSiteConnectionRecord) (enterpriseSiteCredential, error) {
secret, err := decryptEnterpriseSiteCredential(connection.CredentialCiphertext, s.credentialKeyMaterial())
if err != nil {
return enterpriseSiteCredential{}, response.ErrInternal(50231, "enterprise_site_secret_decrypt_failed", "failed to read enterprise site credential")
return enterpriseSiteCredential{}, response.ErrInternal(50231, "enterprise_site_secret_decrypt_failed", "企业站点凭证读取失败")
}
return enterpriseSiteCredential{
SiteURL: connection.SiteURL,
@@ -1430,15 +1515,15 @@ func normalizeCreateEnterpriseSiteConnectionRequest(req CreateEnterpriseSiteConn
}
cmsType := normalizeEnterpriseCMSType(req.CMSType)
if cmsType != pbootCMSPlatformID {
return normalizedEnterpriseSiteConnectionRequest{}, response.ErrBadRequest(40040, "enterprise_site_cms_not_supported", "only PBootCMS is supported in this version")
return normalizedEnterpriseSiteConnectionRequest{}, response.ErrBadRequest(40040, "enterprise_site_cms_not_supported", "当前版本暂不支持该 CMS 类型")
}
appid := strings.TrimSpace(req.AppID)
if appid == "" {
return normalizedEnterpriseSiteConnectionRequest{}, response.ErrBadRequest(40042, "enterprise_site_appid_required", "appid is required")
return normalizedEnterpriseSiteConnectionRequest{}, response.ErrBadRequest(40042, "enterprise_site_appid_required", "请输入 AppID")
}
secret := strings.TrimSpace(req.Secret)
if secret == "" {
return normalizedEnterpriseSiteConnectionRequest{}, response.ErrBadRequest(40042, "enterprise_site_secret_required", "secret is required")
return normalizedEnterpriseSiteConnectionRequest{}, response.ErrBadRequest(40042, "enterprise_site_secret_required", "请输入 Secret")
}
name := strings.TrimSpace(req.Name)
if name == "" {
@@ -1464,18 +1549,18 @@ func normalizeCreateEnterpriseSiteConnectionRequest(req CreateEnterpriseSiteConn
func normalizeEnterpriseSiteURL(value string) (string, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "", response.ErrBadRequest(40041, "enterprise_site_url_required", "site_url is required")
return "", response.ErrBadRequest(40041, "enterprise_site_url_required", "请填写站点域名")
}
if !strings.Contains(trimmed, "://") {
trimmed = "https://" + trimmed
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", response.ErrBadRequest(40041, "invalid_enterprise_site_url", "site_url is invalid")
return "", response.ErrBadRequest(40041, "invalid_enterprise_site_url", "站点域名格式不正确")
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "https" && scheme != "http" {
return "", response.ErrBadRequest(40041, "invalid_enterprise_site_url", "site_url must be http or https")
return "", response.ErrBadRequest(40041, "invalid_enterprise_site_url", "站点域名必须使用 http https")
}
parsed.Scheme = scheme
parsed.Path = strings.TrimRight(parsed.Path, "/")
@@ -2372,13 +2457,35 @@ func sanitizeEnterpriseSiteError(err error) string {
}
message := strings.TrimSpace(err.Error())
if message == "" {
return "CMS request failed"
return "CMS 请求失败"
}
lower := strings.ToLower(message)
for _, marker := range []string{"api_secret", "secret", "signature", "appid="} {
if strings.Contains(strings.ToLower(message), strings.ToLower(marker)) {
return "CMS request failed; please verify site credentials and plugin installation"
if strings.Contains(lower, strings.ToLower(marker)) {
return "CMS 请求失败,请检查站点凭证和接口安装状态"
}
}
if strings.HasPrefix(lower, "request pbootcms ") {
return "CMS 请求失败,请检查站点地址和网络连通性"
}
if strings.HasPrefix(lower, "parse pbootcms response") {
return "CMS 响应解析失败,请确认接口返回 JSON 格式"
}
if strings.HasPrefix(lower, "parse pbootcms data") {
return "CMS 数据解析失败,请确认接口返回数据格式"
}
if strings.HasPrefix(lower, "invalid pbootcms site url") {
return "站点域名格式不正确"
}
if strings.HasPrefix(lower, "pbootcms ") && strings.Contains(lower, " returned http ") {
return "CMS 接口请求失败,请检查站点接口是否可访问"
}
if strings.HasPrefix(lower, "pbootcms ") && strings.Contains(lower, " failed with code ") {
return "CMS 接口返回失败,请检查站点接口配置"
}
if strings.HasPrefix(lower, "pbootcms ") && strings.Contains(lower, " endpoint is unavailable") {
return "CMS 接口不可用,请确认接口安装路径"
}
if len([]rune(message)) > 300 {
runes := []rune(message)
return string(runes[:300])
@@ -2393,6 +2500,13 @@ func enterpriseSiteStringPointerValue(value *string) string {
return *value
}
func enterpriseSiteNullableText(value *string) pgtype.Text {
if value == nil {
return pgtype.Text{}
}
return pgtype.Text{String: *value, Valid: true}
}
func enterpriseSiteTextPointer(value pgtype.Text) *string {
if !value.Valid {
return nil
@@ -2,14 +2,71 @@ package app
import (
"context"
"encoding/json"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/config"
"github.com/geo-platform/tenant-api/internal/shared/publicasset"
"github.com/sergi/go-diff/diffmatchpatch"
)
func TestUpdateEnterpriseSiteConnectionRequestTracksNullAndMissingFields(t *testing.T) {
var req UpdateEnterpriseSiteConnectionRequest
if err := json.Unmarshal([]byte(`{"name":"官网","default_scode":null,"secret":""}`), &req); err != nil {
t.Fatalf("unmarshal request: %v", err)
}
if !req.Name.Set || req.Name.Value == nil || *req.Name.Value != "官网" {
t.Fatalf("name patch = %#v, want set string", req.Name)
}
if !req.DefaultScode.Set || req.DefaultScode.Value != nil {
t.Fatalf("default_scode patch = %#v, want explicit null", req.DefaultScode)
}
if !req.Secret.Set || req.Secret.Value == nil || *req.Secret.Value != "" {
t.Fatalf("secret patch = %#v, want set empty string", req.Secret)
}
if req.Status.Set {
t.Fatalf("status patch = %#v, want missing", req.Status)
}
}
func TestEnterpriseSiteUpdateRealDatabaseRepro(t *testing.T) {
dsn := os.Getenv("TEST_ENTERPRISE_SITE_UPDATE_DSN")
if dsn == "" {
t.Skip("set TEST_ENTERPRISE_SITE_UPDATE_DSN to run the real database repro")
}
ctx := auth.WithCurrentWorkspaceID(
auth.WithActor(context.Background(), auth.Actor{UserID: 1, TenantID: 1, PrimaryWorkspaceID: 1}),
1,
)
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect database: %v", err)
}
defer pool.Close()
name := "海翔"
siteURL := "https://youjayou.com"
appID := "admin"
acode := "cn"
mcode := "2"
req := UpdateEnterpriseSiteConnectionRequest{
Name: EnterpriseSitePatchString{Set: true, Value: &name},
SiteURL: EnterpriseSitePatchString{Set: true, Value: &siteURL},
AppID: EnterpriseSitePatchString{Set: true, Value: &appID},
DefaultAcode: EnterpriseSitePatchString{Set: true, Value: &acode},
DefaultMcode: EnterpriseSitePatchString{Set: true, Value: &mcode},
DefaultScode: EnterpriseSitePatchString{Set: true, Value: nil},
}
if _, err := NewEnterpriseSiteService(pool, config.NewStaticProvider(&config.Config{})).Update(ctx, 1, req); err != nil {
t.Fatalf("update enterprise site: %v", err)
}
}
func TestEnterpriseSiteDescriptionUsesRenderedHTMLText(t *testing.T) {
got := enterpriseSiteDescription(
"<h2>2026年合肥全屋定制行业发展现状</h2><p>消费者需求升级。</p>",
+217
View File
@@ -38,8 +38,10 @@ type PublishRecordResponse struct {
ID int64 `json:"id"`
PublishBatchID int64 `json:"publish_batch_id"`
ArticleID int64 `json:"article_id"`
ArticleTitle *string `json:"article_title,omitempty"`
PlatformAccountID *int64 `json:"platform_account_id"`
DesktopAccountID *string `json:"desktop_account_id"`
DesktopTaskID *string `json:"desktop_task_id,omitempty"`
TargetType string `json:"target_type"`
TargetConnectionID *int64 `json:"target_connection_id"`
PlatformID string `json:"platform_id"`
@@ -55,6 +57,21 @@ type PublishRecordResponse struct {
UpdatedAt time.Time `json:"updated_at"`
}
type ListPublishRecordsRequest struct {
Page int
PageSize int
Title string
}
type PublishRecordListResponse struct {
Items []PublishRecordResponse `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int `json:"total"`
PendingCount int `json:"pending_count"`
HistoryTotal int `json:"history_total"`
}
func (s *MediaService) ListPlatforms(ctx context.Context) ([]MediaPlatformResponse, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, platform_id, name, category, short_name, accent_color, login_url, logo_url, status, sort_order
@@ -89,6 +106,206 @@ func (s *MediaService) ListPlatforms(ctx context.Context) ([]MediaPlatformRespon
return items, nil
}
func (s *MediaService) ListPublishRecords(ctx context.Context, req ListPublishRecordsRequest) (*PublishRecordListResponse, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
page := req.Page
if page <= 0 {
page = 1
}
pageSize := req.PageSize
if pageSize <= 0 || pageSize > 50 {
pageSize = 10
}
title := strings.TrimSpace(req.Title)
pendingStatuses := []string{"queued", "publishing"}
historyStatuses := []string{"success", "failed", "cancelled", "canceled"}
pendingItems, err := s.listPublishRecordsByStatuses(ctx, actor.TenantID, brandID, pendingStatuses, title, 0, 0, "pr.created_at ASC, pr.id ASC")
if err != nil {
return nil, err
}
historyTotal, err := s.countPublishRecordsByStatuses(ctx, actor.TenantID, brandID, historyStatuses, title)
if err != nil {
return nil, err
}
offset := (page - 1) * pageSize
items := make([]PublishRecordResponse, 0, pageSize)
total := len(pendingItems) + historyTotal
if offset < len(pendingItems) {
end := offset + pageSize
if end > len(pendingItems) {
end = len(pendingItems)
}
items = append(items, pendingItems[offset:end]...)
}
remaining := pageSize - len(items)
historyOffset := offset - len(pendingItems)
if historyOffset < 0 {
historyOffset = 0
}
if remaining > 0 && historyOffset < historyTotal {
historyItems, listErr := s.listPublishRecordsByStatuses(ctx, actor.TenantID, brandID, historyStatuses, title, remaining, historyOffset, "pr.updated_at DESC, pr.id DESC")
if listErr != nil {
return nil, listErr
}
items = append(items, historyItems...)
}
return &PublishRecordListResponse{
Items: items,
Page: page,
PageSize: pageSize,
Total: total,
PendingCount: len(pendingItems),
HistoryTotal: historyTotal,
}, nil
}
func (s *MediaService) listPublishRecordsByStatuses(
ctx context.Context,
tenantID int64,
brandID int64,
statuses []string,
title string,
limit int,
offset int,
orderBy string,
) ([]PublishRecordResponse, error) {
query := `
SELECT pr.id, pr.publish_batch_id, pr.article_id,
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', '')) AS article_title,
pr.platform_account_id, pa.desktop_id::text, dt.desktop_id::text,
COALESCE(pr.target_type, 'platform_account'), pr.target_connection_id, pr.platform_id,
COALESCE(mp.name, esc.site_name, esc.name, pr.platform_id) AS platform_name,
CASE
WHEN COALESCE(pr.target_type, 'platform_account') = 'enterprise_site'
THEN COALESCE(esc.site_name, esc.name, '企业站点')
ELSE COALESCE(pa.nickname, '')
END AS platform_nickname,
pr.status, pr.external_article_id, pr.external_article_url,
pr.external_manage_url, pr.published_at, pr.error_message, pr.created_at, pr.updated_at
FROM publish_records pr
JOIN articles a ON a.id = pr.article_id AND a.tenant_id = pr.tenant_id
LEFT JOIN article_versions av ON av.id = a.current_version_id AND av.article_id = a.id
LEFT JOIN media_platforms mp ON mp.platform_id = pr.platform_id
LEFT JOIN platform_accounts pa ON pa.id = pr.platform_account_id
LEFT JOIN enterprise_site_connections esc
ON esc.id = pr.target_connection_id
AND esc.tenant_id = pr.tenant_id
LEFT JOIN LATERAL (
SELECT desktop_id
FROM desktop_tasks
WHERE tenant_id = pr.tenant_id
AND kind = 'publish'
AND payload->>'publish_record_id' = pr.id::text
ORDER BY updated_at DESC, desktop_id DESC
LIMIT 1
) dt ON TRUE
WHERE pr.tenant_id = $1
AND a.brand_id = $2
AND a.deleted_at IS NULL
AND pr.status = ANY($3)
AND (
$4 = ''
OR COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), '') ILIKE '%' || $4 || '%'
)
`
args := []any{tenantID, brandID, statuses, title}
if strings.TrimSpace(orderBy) != "" {
query += "\nORDER BY " + orderBy
}
if limit > 0 {
query += "\nLIMIT $5 OFFSET $6"
args = append(args, limit, offset)
}
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, response.ErrInternal(50043, "publish_record_query_failed", "failed to list publish records")
}
defer rows.Close()
items := make([]PublishRecordResponse, 0)
for rows.Next() {
var item PublishRecordResponse
var desktopAccountID *string
var desktopTaskID *string
if err := rows.Scan(
&item.ID,
&item.PublishBatchID,
&item.ArticleID,
&item.ArticleTitle,
&item.PlatformAccountID,
&desktopAccountID,
&desktopTaskID,
&item.TargetType,
&item.TargetConnectionID,
&item.PlatformID,
&item.PlatformName,
&item.PlatformNickname,
&item.Status,
&item.ExternalArticleID,
&item.ExternalArticleURL,
&item.ExternalManageURL,
&item.PublishedAt,
&item.ErrorMessage,
&item.CreatedAt,
&item.UpdatedAt,
); err != nil {
return nil, response.ErrInternal(50043, "publish_record_scan_failed", err.Error())
}
if desktopAccountID != nil && strings.TrimSpace(*desktopAccountID) != "" {
item.DesktopAccountID = desktopAccountID
}
if desktopTaskID != nil && strings.TrimSpace(*desktopTaskID) != "" {
item.DesktopTaskID = desktopTaskID
}
normalizePublishRecordForResponse(&item)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50043, "publish_record_query_failed", "failed to list publish records")
}
return items, nil
}
func (s *MediaService) countPublishRecordsByStatuses(
ctx context.Context,
tenantID int64,
brandID int64,
statuses []string,
title string,
) (int, error) {
var count int
err := s.pool.QueryRow(ctx, `
SELECT COUNT(1)
FROM publish_records pr
JOIN articles a ON a.id = pr.article_id AND a.tenant_id = pr.tenant_id
LEFT JOIN article_versions av ON av.id = a.current_version_id AND av.article_id = a.id
WHERE pr.tenant_id = $1
AND a.brand_id = $2
AND a.deleted_at IS NULL
AND pr.status = ANY($3)
AND (
$4 = ''
OR COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), '') ILIKE '%' || $4 || '%'
)
`, tenantID, brandID, statuses, title).Scan(&count)
if err != nil {
return 0, response.ErrInternal(50043, "publish_record_query_failed", "failed to list publish records")
}
return count, nil
}
func (s *MediaService) ListArticlePublishRecords(ctx context.Context, articleID int64) ([]PublishRecordResponse, error) {
actor := auth.MustActor(ctx)
brandID, err := requireCurrentBrandID(ctx)
@@ -135,7 +135,7 @@ func (h *EnterpriseSiteHandler) PublishArticle(c *gin.Context) {
func enterpriseSiteIDParam(c *gin.Context) (int64, bool) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "enterprise site id must be a positive number"))
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "企业站点 ID 不正确"))
return 0, false
}
return id, true
@@ -2,6 +2,7 @@ package transport
import (
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -29,6 +30,43 @@ func (h *MediaHandler) ListPlatforms(c *gin.Context) {
response.Success(c, data)
}
func (h *MediaHandler) ListAllPublishRecords(c *gin.Context) {
req := app.ListPublishRecordsRequest{
Page: 1,
PageSize: 10,
Title: strings.TrimSpace(c.Query("title")),
}
if rawPage := c.Query("page"); rawPage != "" {
parsed, err := strconv.Atoi(rawPage)
if err != nil || parsed <= 0 {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", "page must be a positive integer"))
return
}
req.Page = parsed
}
rawPageSize := c.Query("page_size")
if rawPageSize == "" {
rawPageSize = c.Query("limit")
}
if rawPageSize != "" {
parsed, err := strconv.Atoi(rawPageSize)
if err != nil || parsed <= 0 {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", "page_size must be a positive integer"))
return
}
req.PageSize = parsed
}
data, err := h.svc.ListPublishRecords(c.Request.Context(), req)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *MediaHandler) ListPublishRecords(c *gin.Context) {
articleID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
@@ -93,6 +93,7 @@ func RegisterRoutes(app *bootstrap.App) {
tenantProtected.POST("/tasks/:id/cancel", desktopTaskHandler.TenantCancel)
tenantProtected.GET("/publish-tasks", desktopTaskHandler.TenantListPublish)
tenantProtected.POST("/publish-tasks/:id/retry", middleware.RequireCurrentBrand(), desktopTaskHandler.TenantRetryPublish)
tenantProtected.GET("/publish-records", middleware.RequireCurrentBrand(), mediaHandler.ListAllPublishRecords)
tenantProtected.POST("/publish-jobs", middleware.RequireCurrentBrand(), publishJobHandler.Create)
complianceHandler := NewComplianceHandler(app)
@@ -33,6 +33,7 @@ func TestProtectedRoutes_RequireAuth(t *testing.T) {
{http.MethodPost, "/api/tenant/tasks/:id/cancel"},
{http.MethodGet, "/api/tenant/publish-tasks"},
{http.MethodPost, "/api/tenant/publish-tasks/:id/retry"},
{http.MethodGet, "/api/tenant/publish-records"},
{http.MethodPost, "/api/tenant/publish-jobs"},
{http.MethodGet, "/api/tenant/media/platforms"},
{http.MethodGet, "/api/tenant/workspace/overview"},