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
@@ -33,7 +33,7 @@ import {
tenantAccountsApi,
} from '@/lib/api'
import { coverUploadRequired, deriveCoverFileName } from '@/lib/cover-requirements'
import { formatError } from '@/lib/errors'
import { formatError, formatStoredErrorMessage } from '@/lib/errors'
import {
accountInitial,
buildPublishAccountCards,
@@ -876,7 +876,8 @@ async function handleEnterprisePublishSuccess(
} else if (failed.length > 0) {
notification.error({
message: '企业站发布失败',
description: failed[0]?.error_message || '请检查插件、栏目和 PBootCMS API 配置。',
description:
formatStoredErrorMessage(failed[0]?.error_message) || '请检查插件、栏目和 PBootCMS API 配置。',
placement: 'topRight',
duration: 6,
})
@@ -1094,14 +1095,14 @@ function resolveEnterpriseSiteDisabledReason(
return '该企业站点已禁用。'
}
if (connectionStatus === 'error') {
return site.last_error?.trim() || '该企业站点连接异常,请先测试连通性。'
return formatStoredErrorMessage(site.last_error) || '该企业站点连接异常,请先测试连通性。'
}
return ''
}
function enterpriseSiteConnectionStatus(site: EnterpriseSiteConnection): string {
const status = String(site.status ?? '').trim()
const lastError = site.last_error?.trim()
const lastError = formatStoredErrorMessage(site.last_error)
const publishError = enterpriseSiteLatestPublishError(site)
if (status === 'error' && lastError && publishError && lastError === publishError) {
return 'connected'
@@ -1118,7 +1119,7 @@ function enterpriseSiteLatestPublishError(site: EnterpriseSiteConnection): strin
if (status !== 'failed' && status !== 'publish_failed') {
return ''
}
return record.error_message?.trim() || ''
return formatStoredErrorMessage(record.error_message)
}
function cmsTypeLabel(cmsType: string): string {
+11
View File
@@ -85,6 +85,7 @@ import type {
KolWorkspaceCard,
ListCustomerSupplierMediaResourcesResponse,
ListDesktopPublishTasksParams,
ListPublishRecordsParams,
ListMediaSupplyOrdersParams,
ListMediaSupplyOrdersResponse,
ListMediaSupplyWalletLedgersParams,
@@ -116,6 +117,7 @@ import type {
PublishKolPromptRequest,
PublishEnterpriseSiteArticleRequest,
PublishRecord,
PublishRecordListResponse,
Question,
QuestionCandidateResult,
QuestionCombinationFillRequest,
@@ -345,6 +347,7 @@ const currentBrandScopedPathPatterns = [
/^\/api\/tenant\/schedules(?:\/|$)/,
/^\/api\/tenant\/instant-tasks(?:\/|$)/,
/^\/api\/tenant\/publish-jobs(?:\/|$)/,
/^\/api\/tenant\/publish-records(?:\/|$)/,
/^\/api\/tenant\/publish-tasks\/[^/]+\/retry(?:\/|$)/,
/^\/api\/tenant\/enterprise-sites\/[^/]+\/publish(?:\/|$)/,
/^\/api\/tenant\/media-supply\/orders(?:\/|$)/,
@@ -1386,6 +1389,14 @@ export const publishTasksApi = {
},
}
export const publishRecordsApi = {
list(params: ListPublishRecordsParams = {}) {
return apiClient.get<PublishRecordListResponse>('/api/tenant/publish-records', {
params,
})
},
}
export const enterpriseSitesApi = {
list() {
return apiClient.get<EnterpriseSiteConnection[]>('/api/tenant/enterprise-sites')
+113 -1
View File
@@ -78,6 +78,7 @@ const errorMessageMap: Record<string, string> = {
media_supply_wallet_user_not_found: '用户不存在或不属于当前租户',
invalid_media_supply_wallet_user: '用户参数不正确',
invalid_media_supply_wallet_delta: '调整金额不能为 0',
invalid_id: '参数 ID 不正确',
media_supply_credentials_required: '媒体供应商账号未配置,请联系管理员处理',
media_supply_session_required: '媒体供应商登录状态已失效,请联系管理员处理',
media_supply_challenge_required: '媒体供应商登录验证失败,请联系管理员处理',
@@ -145,6 +146,59 @@ const errorMessageMap: Record<string, string> = {
publisher_plugin_empty_response: '浏览器插件未返回数据,请刷新当前页面后重试',
publisher_plugin_invalid_response: '浏览器插件返回了无效数据,请刷新当前页面后重试',
publisher_plugin_unknown_error: '浏览器插件调用失败,请稍后重试',
enterprise_site_ping_failed: '企业站点连接测试失败',
enterprise_site_category_sync_failed: '企业站点栏目同步失败',
enterprise_site_publish_failed: '企业站点发布失败',
enterprise_site_disabled: '企业站点已禁用',
enterprise_site_category_required: '请选择企业站点栏目',
enterprise_site_categories_empty: 'CMS 未返回可发布栏目',
enterprise_site_already_exists: '该站点域名已被其他企业站点占用',
enterprise_site_create_failed: '企业站点创建失败',
enterprise_site_update_failed: '企业站点更新失败',
enterprise_site_name_required: '请输入站点名称',
enterprise_site_appid_required: '请输入 AppID',
enterprise_site_secret_required: '请输入 Secret',
invalid_enterprise_site_url: '站点域名格式不正确',
enterprise_site_cms_not_supported: '当前版本暂不支持该 CMS 类型',
enterprise_site_query_failed: '企业站点列表读取失败',
enterprise_site_scan_failed: '企业站点数据解析失败',
enterprise_site_delete_failed: '企业站点删除失败',
enterprise_site_not_found: '企业站点连接不存在或已删除',
enterprise_site_ping_update_failed: '企业站点连接状态更新失败',
enterprise_site_category_query_failed: '企业站点栏目读取失败',
enterprise_site_category_scan_failed: '企业站点栏目解析失败',
enterprise_site_category_clear_failed: '企业站点栏目同步失败',
enterprise_site_category_payload_failed: '企业站点栏目同步失败',
enterprise_site_category_insert_failed: '企业站点栏目保存失败',
enterprise_site_sync_update_failed: '企业站点同步状态更新失败',
enterprise_site_category_sync_begin_failed: '企业站点栏目同步失败',
enterprise_site_category_sync_commit_failed: '企业站点栏目同步失败',
enterprise_site_category_lookup_failed: '企业站点栏目校验失败',
enterprise_site_category_not_found: '企业站点栏目不存在,请先同步栏目',
enterprise_site_markdown_render_failed: '文章内容渲染失败',
enterprise_site_article_empty: '文章内容为空',
enterprise_site_publish_payload_failed: '企业站点发布记录创建失败',
enterprise_site_publish_response_failed: 'CMS 响应保存失败',
enterprise_site_publish_begin_failed: '企业站点发布记录创建失败',
enterprise_site_publish_batch_failed: '企业站点发布批次创建失败',
enterprise_site_publish_record_failed: '企业站点发布记录创建失败',
enterprise_site_article_publish_status_failed: '文章发布状态更新失败',
enterprise_site_publish_commit_failed: '企业站点发布记录提交失败',
enterprise_site_publish_ack_failed: '合规确认处理失败',
enterprise_site_publish_finish_begin_failed: '企业站点发布结果更新失败',
enterprise_site_publish_record_update_failed: '企业站点发布记录更新失败',
enterprise_site_publish_batch_update_failed: '企业站点发布批次更新失败',
enterprise_site_article_publish_update_failed: '文章发布状态更新失败',
enterprise_site_publish_connection_update_failed: '企业站点连接状态更新失败',
enterprise_site_publish_finish_commit_failed: '企业站点发布结果提交失败',
enterprise_site_article_lookup_failed: '文章读取失败',
enterprise_site_article_version_lookup_failed: '文章版本读取失败',
enterprise_site_article_version_reconstruct_failed: '文章版本内容恢复失败',
enterprise_site_article_version_snapshot_lookup_failed: '文章版本快照读取失败',
enterprise_site_lookup_failed: '企业站点连接读取失败',
enterprise_site_secret_encrypt_failed: '企业站点凭证保存失败',
enterprise_site_secret_decrypt_failed: '企业站点凭证读取失败',
enterprise_site_url_required: '请填写站点域名',
installation_offline: '桌面客户端未在线,请打开桌面客户端后重试',
network_error: '网络连接失败,请稍后重试',
request_timeout: '请求超时,AI 生成耗时较长,请稍后重试或稍候再查看结果',
@@ -195,6 +249,17 @@ const LEGACY_MEDIA_SUPPLY_REFRESH_PATTERN =
/^failed to refresh supplier price(?: for \d+ selected resources?)?$/i
const LEGACY_MEDIA_SUPPLY_LOCKED_COST_PATTERN =
/^supplier cost increased above locked sell price/i
const LEGACY_CMS_CREDENTIAL_PATTERN =
/^CMS request failed; please verify site credentials and plugin installation$/i
const LEGACY_CMS_REQUEST_FAILED_PATTERN = /^CMS request failed$/i
const LEGACY_ENTERPRISE_SITE_PING_FAILED_PATTERN = /^enterprise site ping failed: /i
const LEGACY_ENTERPRISE_SITE_CATEGORY_SYNC_FAILED_PATTERN =
/^enterprise site category sync failed: /i
const LEGACY_PBOOTCMS_HTTP_PATTERN = /^pbootcms\s+\w+\s+returned http \d+$/i
const LEGACY_PBOOTCMS_CODE_PATTERN = /^pbootcms\s+\w+\s+failed with code \d+$/i
const LEGACY_ENTERPRISE_SITE_FAILED_DETAIL_PATTERN = /^failed to .*enterprise site/i
const LEGACY_ENTERPRISE_SITE_EXISTS_PATTERN =
/^(enterprise site connection already exists|another enterprise site already uses this domain)$/i
function translateRawErrorMessage(raw: string): string | null {
const normalized = raw.trim()
@@ -211,7 +276,50 @@ function translateRawErrorMessage(raw: string): string | null {
if (LEGACY_MEDIA_SUPPLY_LOCKED_COST_PATTERN.test(normalized)) {
return '媒体成本价已变化,当前锁定价格低于成本,请重新提交'
}
if (LEGACY_CMS_CREDENTIAL_PATTERN.test(normalized)) {
return 'CMS 请求失败,请检查站点凭证和接口安装状态'
}
if (LEGACY_CMS_REQUEST_FAILED_PATTERN.test(normalized)) {
return 'CMS 请求失败'
}
if (LEGACY_ENTERPRISE_SITE_PING_FAILED_PATTERN.test(normalized)) {
return `企业站点连接测试失败:${formatStoredErrorMessage(
normalized.replace(LEGACY_ENTERPRISE_SITE_PING_FAILED_PATTERN, ''),
)}`
}
if (LEGACY_ENTERPRISE_SITE_CATEGORY_SYNC_FAILED_PATTERN.test(normalized)) {
return `企业站点栏目同步失败:${formatStoredErrorMessage(
normalized.replace(LEGACY_ENTERPRISE_SITE_CATEGORY_SYNC_FAILED_PATTERN, ''),
)}`
}
const lower = normalized.toLowerCase()
if (lower.startsWith('request pbootcms ')) {
return 'CMS 请求失败,请检查站点地址和网络连通性'
}
if (lower.startsWith('parse pbootcms response')) {
return 'CMS 响应解析失败,请确认接口返回 JSON 格式'
}
if (lower.startsWith('parse pbootcms data')) {
return 'CMS 数据解析失败,请确认接口返回数据格式'
}
if (lower.startsWith('invalid pbootcms site url')) {
return '站点域名格式不正确'
}
if (LEGACY_PBOOTCMS_HTTP_PATTERN.test(normalized)) {
return 'CMS 接口请求失败,请检查站点接口是否可访问'
}
if (LEGACY_PBOOTCMS_CODE_PATTERN.test(normalized)) {
return 'CMS 接口返回失败,请检查站点接口配置'
}
if (lower.startsWith('pbootcms ') && lower.includes(' endpoint is unavailable')) {
return 'CMS 接口不可用,请确认接口安装路径'
}
if (LEGACY_ENTERPRISE_SITE_FAILED_DETAIL_PATTERN.test(normalized)) {
return '企业站点操作失败,请稍后重试'
}
if (LEGACY_ENTERPRISE_SITE_EXISTS_PATTERN.test(normalized)) {
return errorMessageMap.enterprise_site_already_exists
}
const hiddenSupplierToken = ['mei', 'jie', 'quan'].join('')
if (lower.includes(hiddenSupplierToken)) {
return '媒体供应商请求失败,请稍后重试或联系管理员'
@@ -247,7 +355,11 @@ export function formatError(error: unknown): string {
if (isAiPointsInsufficient(error)) {
return message
}
return error.detail ? `${message}: ${error.detail}` : message
const detail = error.detail ? formatStoredErrorMessage(error.detail) : ''
if (detail === message) {
return message
}
return detail ? `${message}${detail}` : message
}
if (error instanceof Error) {
File diff suppressed because it is too large Load Diff
+123 -303
View File
@@ -8,17 +8,17 @@ import {
RetweetOutlined,
SearchOutlined,
} from '@ant-design/icons-vue'
import type { DesktopTaskInfo, JsonValue, TenantPublishTaskListResponse } from '@geo/shared-types'
import type { PublishRecord, PublishRecordListResponse } from '@geo/shared-types'
import { useQuery } from '@tanstack/vue-query'
import type { TableColumnsType } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { publishTasksApi, tenantAccountsApi } from '@/lib/api'
import { publishRecordsApi, publishTasksApi } from '@/lib/api'
import { copyTextToClipboard } from '@/lib/clipboard'
import { formatDateTime } from '@/lib/display'
import { formatError } from '@/lib/errors'
import { formatError, formatStoredErrorMessage } from '@/lib/errors'
import { resolvePlatformLogoUrl } from '@/lib/publish-account-cards'
import { getPublishPlatformMeta, normalizePublishPlatformId } from '@/lib/publish-platforms'
import { useCompanyStore } from '@/stores/company'
@@ -26,56 +26,37 @@ import { useCompanyStore } from '@/stores/company'
const PAGE_SIZE = 10
const INLINE_ERROR_HEAD_CHARS = 52
const INLINE_ERROR_TAIL_CHARS = 10
const ACTIVE_PUBLISH_TASK_POLL_INTERVAL_MS = 5_000
const ACTIVE_PUBLISH_RECORD_POLL_INTERVAL_MS = 5_000
const DONGCHEDI_PLATFORM_EXCEPTION_MESSAGE =
'懂车帝平台返回异常,请打开懂车帝后台查看并处理平台提示,确认账号状态后再重试。'
type PublishTaskStatus = DesktopTaskInfo['status']
interface PublishTaskItem {
interface PublishRecordItem {
id: string
recordId: number
title: string
articleId: number | null
articleId: number
targetType: string
platform: string
platformName: string
platformShortName: string
platformAccent: string
platformLogoUrl: string | null
accountId: string
accountName: string
platformUid: string
status: PublishTaskStatus
status: string
statusLabel: string
statusColor: string
summary: string
attempts: number
leaseExpiresAt: string | null
createdAt: string
updatedAt: string
publishedAt: string | null
externalUrl: string | null
workbenchUrl: string
publishType: string | null
workbenchUrl: string | null
desktopTaskId: string | null
errorMessage: string | null
complianceBlockedRecordId: number | null
complianceBlockedAt: string | null
canOpenWorkbench: boolean
}
function isPlainRecord(value: unknown): value is Record<string, JsonValue> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function extractNumber(value: JsonValue | null | undefined): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === 'string' && /^\d+$/.test(value.trim())) {
return Number.parseInt(value.trim(), 10)
}
return null
}
function extractString(value: JsonValue | string | null | undefined): string | null {
function extractString(value: string | null | undefined): string | null {
if (typeof value !== 'string') {
return null
}
@@ -83,36 +64,30 @@ function extractString(value: JsonValue | string | null | undefined): string | n
return trimmed ? trimmed : null
}
function extractStringId(value: JsonValue | null | undefined): string | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return String(Math.trunc(value))
function normalizeRecordStatus(status: string): string {
const normalized = status.trim().toLowerCase()
if (normalized === 'published' || normalized === 'publish_success') {
return 'success'
}
return extractString(value)
if (normalized === 'canceled') {
return 'cancelled'
}
return normalized || 'unknown'
}
function normalizePublishTaskStatus(status: PublishTaskStatus): PublishTaskStatus {
return status === 'unknown' ? 'failed' : status
function isPendingStatus(status: string): boolean {
return status === 'queued' || status === 'publishing'
}
function isPendingStatus(status: PublishTaskStatus): boolean {
return status === 'queued' || status === 'in_progress'
function hasActivePublishingRecord(page: PublishRecordListResponse | undefined): boolean {
return Boolean(page?.items.some((record) => isPendingStatus(normalizeRecordStatus(record.status))))
}
function hasActivePublishingTask(page: TenantPublishTaskListResponse | undefined): boolean {
return Boolean(page?.items.some((task) => isPendingStatus(normalizePublishTaskStatus(task.status))))
}
function normalizeTaskErrorMessage(messageText: string | null, platform?: string | null): string | null {
function normalizeRecordErrorMessage(messageText: string | null, platform?: string | null): string | null {
const normalized = extractString(messageText)
if (!normalized) {
return null
}
if (normalized.includes('scaffold-only') || normalized.includes('requires later reconcile')) {
return '当前平台的桌面发布适配器未实现,任务已按失败处理。'
}
if (normalized.includes('adapter is not implemented for this platform')) {
return '当前平台的桌面发布适配器未实现,任务已按失败处理。'
}
if (
platform === 'dongchedi' &&
(/\bserver[\s_-]*exception\b/i.test(normalized) ||
@@ -120,71 +95,7 @@ function normalizeTaskErrorMessage(messageText: string | null, platform?: string
) {
return DONGCHEDI_PLATFORM_EXCEPTION_MESSAGE
}
return normalized
}
function complianceLevelLabel(level: string | null): string {
const normalized = level?.trim().toLowerCase()
switch (normalized) {
case 'block':
return '阻断'
case 'high':
return '高风险'
case 'medium':
return '中风险'
case 'info':
return '提示'
default:
return '风险'
}
}
function summarizeComplianceBlockedReason(raw: string | null): string | null {
const normalized = extractString(raw)
if (!normalized) {
return null
}
try {
const parsed = JSON.parse(normalized) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return normalized
}
const record = parsed as Record<string, unknown>
const manualStatus =
typeof record.manual_review_status === 'string' ? record.manual_review_status : ''
if (manualStatus === 'pending') {
return '文章版本正在等待运营人工审阅,旧定时任务已停止派发。'
}
if (manualStatus === 'rejected') {
return '文章版本已被运营驳回,请修改文章后重新发布。'
}
const hitCount = typeof record.hit_count === 'number' ? record.hit_count : null
const highestLevel =
typeof record.highest_level === 'string' ? complianceLevelLabel(record.highest_level) : '风险'
const violations = Array.isArray(record.violations) ? record.violations : []
const matchedTerms = violations
.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return null
}
const violation = item as Record<string, unknown>
return typeof violation.matched_text === 'string' ? violation.matched_text.trim() : null
})
.filter((item): item is string => Boolean(item))
.slice(0, 3)
const hitPart =
hitCount !== null && hitCount > 0
? `命中 ${hitCount}${highestLevel}规则`
: `命中${highestLevel}规则`
return matchedTerms.length > 0
? `${hitPart}${matchedTerms.join('、')}`
: `${hitPart},请修改文章后重新发布。`
} catch {
return normalized === 'compliance_blocked' ? null : normalized
}
return formatStoredErrorMessage(normalized)
}
function inlineErrorPreview(messageText: string): string {
@@ -196,94 +107,50 @@ function inlineErrorPreview(messageText: string): string {
return `${compact.slice(0, INLINE_ERROR_HEAD_CHARS)}...${compact.slice(-INLINE_ERROR_TAIL_CHARS)}`
}
function summaryForTask(
status: PublishTaskStatus,
publishType: string | null = null,
fallbackReason: string | null = null,
): string {
if (status === 'aborted' && fallbackReason === 'compliance_blocked') {
return '定时发布前的合规重检拦截了本次任务,请修改文章后重新发布。'
}
function summaryForRecord(record: PublishRecord, status: string): string {
const targetLabel = record.target_type === 'enterprise_site' ? '企业站点' : '目标平台'
switch (status) {
case 'queued':
return '任务已进入队列,待桌面端继续执行。'
case 'in_progress':
return '当前正在由桌面端执行发送,请避免重复触发。'
case 'succeeded':
return publishType === 'draft'
? '文章已保存到草稿箱,需在平台后台完成发表。'
: '文章已成功发送到目标平台。'
return record.target_type === 'enterprise_site'
? '企业站发布请求已创建,等待服务端处理。'
: '任务已进入桌面端发布队列。'
case 'publishing':
return record.target_type === 'enterprise_site'
? '服务端正在调用站点连接发文。'
: '桌面端正在执行发送。'
case 'success':
return `文章已成功发送到${targetLabel}`
case 'failed':
return '本次发送失败,请检查账号状态或平台提示后重新提交发布。'
case 'aborted':
return '任务已被取消,请检查原因后重新提交发布。'
return record.target_type === 'enterprise_site'
? '本次企业站发布失败,请检查站点连接或发布错误。'
: '本次发送失败,请检查账号状态或平台提示后重新提交。'
case 'cancelled':
return '本次发布已取消。'
case 'unknown':
default:
return '发结果异常,已按失败处理,请检查平台侧结果后重新提交发布。'
return '发结果异常,请检查目标侧结果。'
}
}
function statusMetaForTask(
status: PublishTaskStatus,
publishType: string | null,
complianceBlockedRecordId: number | null,
): { label: string; color: string } {
if (status === 'aborted' && complianceBlockedRecordId !== null) {
return { label: '合规阻断', color: 'error' }
}
if (status === 'succeeded' && publishType === 'draft') {
return { label: '已存草稿', color: 'warning' }
}
function statusMetaForRecord(status: string): { label: string; color: string } {
switch (status) {
case 'queued':
return { label: '等待发布', color: 'processing' }
case 'in_progress':
return { label: '正在发布', color: 'processing' }
case 'succeeded':
return { label: '发成功', color: 'success' }
case 'publishing':
return { label: '发布', color: 'processing' }
case 'success':
return { label: '发成功', color: 'success' }
case 'failed':
case 'unknown':
return { label: '发送失败', color: 'error' }
case 'aborted':
return { label: '发布失败', color: 'error' }
case 'cancelled':
return { label: '已取消', color: 'default' }
case 'unknown':
return { label: '结果异常', color: 'error' }
default:
return { label: status, color: 'default' }
}
}
function buildSohuArticleUrl(
task: DesktopTaskInfo,
result: Record<string, JsonValue>,
): string | null {
if (task.platform !== 'sohuhao' || extractString(result.publish_type) === 'draft') {
return null
}
const articleId = extractStringId(result.external_article_id)
const accountUid = accountMap.value.get(task.target_account_id)?.platform_uid?.trim() ?? ''
if (!articleId || !accountUid) {
return null
}
return `https://www.sohu.com/a/${encodeURIComponent(articleId)}_${encodeURIComponent(accountUid)}`
}
function buildWangyihaoArticleUrl(
task: DesktopTaskInfo,
result: Record<string, JsonValue>,
): string | null {
if (task.platform !== 'wangyihao' || extractString(result.publish_type) === 'draft') {
return null
}
const articleId = extractStringId(result.external_article_id)
if (!articleId) {
return null
}
return `https://www.163.com/dy/article/${encodeURIComponent(articleId)}.html`
}
function isSafeHttpUrl(url: string | null): url is string {
if (!url) {
return false
@@ -299,14 +166,13 @@ function isSafeHttpUrl(url: string | null): url is string {
function buildDesktopWorkbenchUrl(record: {
accountId: string
platform: string
platformUid: string
accountName: string
}): string {
const params = new URLSearchParams({
action: 'open-publish-account-console',
account_id: record.accountId,
platform: record.platform,
platform_uid: record.platformUid,
platform_uid: '',
display_name: record.accountName,
})
return `shengxintui://desktop/workbench?${params.toString()}`
@@ -321,128 +187,89 @@ const copiedErrorTaskId = ref<string | null>(null)
const retryingTaskId = ref<string | null>(null)
const manualRefreshing = ref(false)
const accountsQuery = useQuery({
queryKey: ['tenant', 'desktop-accounts', 'publish-management'],
queryFn: () => tenantAccountsApi.list(),
})
const tasksQuery = useQuery({
const recordsQuery = useQuery({
queryKey: computed(() => [
'tenant',
'publish-tasks',
'publish-records',
companyStore.currentBrandId,
{ page: page.value, page_size: PAGE_SIZE, title: appliedTitle.value },
]),
enabled: computed(() => Boolean(companyStore.currentBrandId)),
queryFn: () =>
publishTasksApi.list({
publishRecordsApi.list({
page: page.value,
page_size: PAGE_SIZE,
title: appliedTitle.value || undefined,
}),
refetchInterval: (query) => {
const data = query.state.data as TenantPublishTaskListResponse | undefined
return hasActivePublishingTask(data) ? ACTIVE_PUBLISH_TASK_POLL_INTERVAL_MS : false
const data = query.state.data as PublishRecordListResponse | undefined
return hasActivePublishingRecord(data) ? ACTIVE_PUBLISH_RECORD_POLL_INTERVAL_MS : false
},
refetchIntervalInBackground: false,
})
const loading = computed(() => tasksQuery.isPending.value || accountsQuery.isPending.value)
const loading = computed(() => recordsQuery.isPending.value)
const refreshing = computed(() => manualRefreshing.value)
const taskPage = computed(() => tasksQuery.data.value)
const pendingCount = computed(() => taskPage.value?.pending_count ?? 0)
const historyTotal = computed(() => taskPage.value?.history_total ?? 0)
const totalCount = computed(() => taskPage.value?.total ?? 0)
const recordPage = computed(() => recordsQuery.data.value)
const pendingCount = computed(() => recordPage.value?.pending_count ?? 0)
const historyTotal = computed(() => recordPage.value?.history_total ?? 0)
const totalCount = computed(() => recordPage.value?.total ?? 0)
const accountMap = computed(() => {
return new Map((accountsQuery.data.value ?? []).map((account) => [account.id, account]))
})
const publishTasks = computed<PublishTaskItem[]>(() =>
(taskPage.value?.items ?? []).map((task) => {
const status = normalizePublishTaskStatus(task.status)
const payload = task.payload ?? {}
const result = task.result ?? {}
const taskError = task.error ?? {}
const platformId = normalizePublishPlatformId(task.platform)
const publishRecords = computed<PublishRecordItem[]>(() =>
(recordPage.value?.items ?? []).map((record) => {
const status = normalizeRecordStatus(record.status)
const platformId = normalizePublishPlatformId(record.platform_id)
const platformMeta = getPublishPlatformMeta(platformId)
const account = accountMap.value.get(task.target_account_id)
const publishType = extractString(result.publish_type)
const complianceBlockedRecordId =
task.compliance_blocked_record_id ??
extractNumber(taskError.record_id) ??
extractNumber(taskError.check_record_id)
const complianceBlockedReason = summarizeComplianceBlockedReason(
task.compliance_blocked_reason ?? extractString(taskError.detail),
)
const fallbackReason =
task.publish_job_status === 'blocked_by_compliance'
? 'compliance_blocked'
: extractString(result.fallback_reason)
const nestedContentRef = isPlainRecord(payload.content_ref) ? payload.content_ref : null
const articleId =
extractNumber(payload.article_id) ??
extractNumber(nestedContentRef?.article_id ?? nestedContentRef?.id)
const accountName = account?.display_name || '待同步账号'
const platformUid = account?.platform_uid ?? ''
const statusMeta = statusMetaForTask(status, publishType, complianceBlockedRecordId)
const isEnterpriseSite = record.target_type === 'enterprise_site'
const accountName =
extractString(record.platform_nickname) ??
(isEnterpriseSite ? '企业自建站点' : '待同步账号')
const statusMeta = statusMetaForRecord(status)
const externalUrl =
publishType === 'draft'
? null
: (extractString(result.external_article_url) ??
extractString(result.externalArticleUrl) ??
buildWangyihaoArticleUrl(task, result) ??
buildSohuArticleUrl(task, result) ??
extractString(result.external_manage_url) ??
extractString(result.externalManageUrl))
extractString(record.external_article_url) ?? extractString(record.external_manage_url)
const desktopAccountId = extractString(record.desktop_account_id)
const workbenchUrl =
!isEnterpriseSite && desktopAccountId
? buildDesktopWorkbenchUrl({
accountId: desktopAccountId,
platform: platformId,
accountName,
})
: null
return {
id: task.id,
title: extractString(payload.title) ?? `发布任务 · ${platformMeta.name}`,
articleId,
id: String(record.id),
recordId: record.id,
title: extractString(record.article_title) ?? `文章 #${record.article_id}`,
articleId: record.article_id,
targetType: record.target_type ?? 'platform_account',
platform: platformId,
platformName: platformMeta.name,
platformName: isEnterpriseSite ? '企业自建站点' : record.platform_name || platformMeta.name,
platformShortName: platformMeta.shortName,
platformAccent: platformMeta.accent,
platformLogoUrl: resolvePlatformLogoUrl(platformId),
accountId: task.target_account_id,
accountName,
platformUid,
status,
statusLabel: statusMeta.label,
statusColor: statusMeta.color,
summary: summaryForTask(status, publishType, fallbackReason),
attempts: task.attempts,
leaseExpiresAt: task.lease_expires_at,
createdAt: task.created_at,
updatedAt: task.updated_at,
summary: summaryForRecord(record, status),
createdAt: record.created_at,
updatedAt: record.updated_at,
publishedAt: record.published_at,
externalUrl: isSafeHttpUrl(externalUrl) ? externalUrl : null,
workbenchUrl: buildDesktopWorkbenchUrl({
accountId: task.target_account_id,
platform: platformId,
platformUid,
accountName,
}),
publishType,
errorMessage: normalizeTaskErrorMessage(
complianceBlockedReason ??
extractString(taskError.message) ??
extractString(taskError.detail) ??
extractString(taskError.code),
platformId,
),
complianceBlockedRecordId,
complianceBlockedAt: task.compliance_blocked_at ?? null,
canOpenWorkbench: Boolean(task.target_account_id),
workbenchUrl,
desktopTaskId: extractString(record.desktop_task_id),
errorMessage: normalizeRecordErrorMessage(record.error_message, platformId),
canOpenWorkbench: Boolean(workbenchUrl),
}
}),
)
const columns = computed<TableColumnsType<PublishTaskItem>>(() => [
const columns = computed<TableColumnsType<PublishRecordItem>>(() => [
{ title: '发文标题', key: 'title', dataIndex: 'title', width: 280, ellipsis: true },
{ title: '账号 / 平台', key: 'account', dataIndex: 'account', width: 190 },
{ title: '目标', key: 'account', dataIndex: 'account', width: 190 },
{ title: '状态', key: 'status', dataIndex: 'status', width: 110 },
{ title: '任务信息', key: 'meta', dataIndex: 'meta', width: 320 },
{ title: '发布信息', key: 'meta', dataIndex: 'meta', width: 320 },
{ title: '时间', key: 'time', dataIndex: 'time', width: 170 },
{ title: '操作', key: 'actions', align: 'right', width: 150, fixed: 'right' },
])
@@ -461,7 +288,7 @@ async function refresh(): Promise<void> {
manualRefreshing.value = true
try {
await Promise.all([tasksQuery.refetch(), accountsQuery.refetch()])
await recordsQuery.refetch()
} finally {
manualRefreshing.value = false
}
@@ -476,18 +303,21 @@ function handlePageChange(nextPage: number): void {
page.value = nextPage
}
function rowClassName(record: PublishTaskItem): string {
function rowClassName(record: PublishRecordItem): string {
return isPendingStatus(record.status) ? 'publish-management-row--pending' : ''
}
function canRetryTask(record: PublishTaskItem): boolean {
return record.status === 'failed' || record.status === 'aborted' || record.status === 'unknown'
function canRetryTask(record: PublishRecordItem): boolean {
return Boolean(record.desktopTaskId && record.status === 'failed')
}
async function retryTask(record: PublishTaskItem): Promise<void> {
async function retryTask(record: PublishRecordItem): Promise<void> {
if (!record.desktopTaskId) {
return
}
retryingTaskId.value = record.id
try {
const result = await publishTasksApi.retry(record.id)
const result = await publishTasksApi.retry(record.desktopTaskId)
const created = result.created_task_ids.length
const existing = result.existing_task_ids.length
const requeued = result.requeued_task_ids?.length ?? 0
@@ -504,7 +334,7 @@ async function retryTask(record: PublishTaskItem): Promise<void> {
} else {
message.success('已提交重试请求')
}
await tasksQuery.refetch()
await recordsQuery.refetch()
} catch (error) {
message.error(formatError(error))
} finally {
@@ -512,7 +342,7 @@ async function retryTask(record: PublishTaskItem): Promise<void> {
}
}
async function copyErrorMessage(record: PublishTaskItem): Promise<void> {
async function copyErrorMessage(record: PublishRecordItem): Promise<void> {
if (!record.errorMessage) {
return
}
@@ -569,8 +399,8 @@ async function copyErrorMessage(record: PublishTaskItem): Promise<void> {
<section class="publish-management-view__table-card">
<div class="publish-management-view__toolbar">
<div>
<p class="eyebrow">Publish Queue</p>
<h3>待发布与已发记录</h3>
<p class="eyebrow">Publish Records</p>
<h3>待发布与已发记录</h3>
</div>
<div class="publish-management-view__filters">
<a-input
@@ -592,12 +422,12 @@ async function copyErrorMessage(record: PublishTaskItem): Promise<void> {
row-key="id"
class="publish-management-table"
:columns="columns"
:data-source="publishTasks"
:data-source="publishRecords"
:pagination="false"
:loading="loading"
:scroll="{ x: 1220 }"
:row-class-name="rowClassName"
:locale="{ emptyText: '暂无匹配的发文任务' }"
:locale="{ emptyText: '暂无匹配的发文记录' }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
@@ -664,18 +494,12 @@ async function copyErrorMessage(record: PublishTaskItem): Promise<void> {
{{ record.summary }}
</span>
<span class="publish-meta-footer">
<span class="attempt-chip">尝试 {{ record.attempts }} </span>
<template v-if="record.complianceBlockedRecordId">
<span class="attempt-chip">记录 #{{ record.recordId }}</span>
<span class="meta-divider">·</span>
<span>{{ record.targetType === 'enterprise_site' ? '企业站点' : '媒体账号' }}</span>
<template v-if="record.publishedAt">
<span class="meta-divider">·</span>
<span>合规记录 #{{ record.complianceBlockedRecordId }}</span>
</template>
<template v-if="record.complianceBlockedAt">
<span class="meta-divider">·</span>
<span>{{ formatDateTime(record.complianceBlockedAt) }} 拦截</span>
</template>
<template v-if="record.leaseExpiresAt">
<span class="meta-divider">·</span>
<span>租约至 {{ formatDateTime(record.leaseExpiresAt, 'HH:mm') }}</span>
<span>{{ formatDateTime(record.publishedAt) }} 发布</span>
</template>
</span>
</div>
@@ -714,15 +538,11 @@ async function copyErrorMessage(record: PublishTaskItem): Promise<void> {
</a-button>
</span>
</a-tooltip>
<a-tooltip
:title="`打开 ${record.accountName} 的${record.platformName}工作台`"
placement="top"
>
<a-tooltip v-if="record.canOpenWorkbench" :title="`打开 ${record.accountName} 的${record.platformName}工作台`" placement="top">
<a-button
type="text"
class="publish-action-btn"
:href="record.workbenchUrl"
:disabled="!record.canOpenWorkbench"
:href="record.workbenchUrl || undefined"
:aria-label="`打开 ${record.accountName} ${record.platformName}工作台`"
>
<template #icon><DesktopOutlined /></template>
@@ -739,7 +559,7 @@ async function copyErrorMessage(record: PublishTaskItem): Promise<void> {
<template #icon><RetweetOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip v-if="isPendingStatus(record.status)" title="发布任务进行中">
<a-tooltip v-if="isPendingStatus(record.status)" title="发布进行中">
<span class="publish-action-placeholder">
<ClockCircleOutlined />
</span>
+55 -14
View File
@@ -34,6 +34,7 @@ REGISTRY_USERNAME="${REGISTRY_USERNAME:-root}"
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-}"
REGISTRY_PLAIN_HTTP="${REGISTRY_PLAIN_HTTP:-true}"
RUN_MIGRATIONS="${RUN_MIGRATIONS:-false}"
ALLOW_SKIP_MIGRATIONS_FOR_APP_ROLLOUT="${ALLOW_SKIP_MIGRATIONS_FOR_APP_ROLLOUT:-false}"
SKIP_DEPLOYMENTS="${SKIP_DEPLOYMENTS:-false}"
TARGET_PLATFORM="${TARGET_PLATFORM:-}"
CLEAN_LOCAL_IMAGES="${CLEAN_LOCAL_IMAGES:-false}"
@@ -60,6 +61,14 @@ DEPLOYABLE_SERVICES=(
ops-web
)
MIGRATION_GATED_SERVICES=(
tenant-api
ops-api
worker-generate
kol-assist-worker
scheduler
)
if [[ -z "${IMAGE_TAG}" ]]; then
echo "IMAGE_TAG is required. Use a unique tag, for example: IMAGE_TAG=$(git -C "${REPO_ROOT}" rev-parse --short=8 HEAD)-$(date +%Y%m%d%H%M%S)" >&2
exit 1
@@ -139,6 +148,14 @@ case "${RUN_MIGRATIONS}" in
;;
esac
case "${ALLOW_SKIP_MIGRATIONS_FOR_APP_ROLLOUT}" in
true|false) ;;
*)
echo "ALLOW_SKIP_MIGRATIONS_FOR_APP_ROLLOUT must be true or false." >&2
exit 1
;;
esac
case "${SKIP_DEPLOYMENTS}" in
true|false) ;;
*)
@@ -162,23 +179,44 @@ case "${WAIT_TIMEOUT}" in
;;
esac
for svc in "${SERVICES[@]}"; do
supported=false
for known in "${DEPLOYABLE_SERVICES[@]}"; do
if [[ "${svc}" == "${known}" ]]; then
supported=true
break
if [[ "${#SERVICES[@]}" -gt 0 ]]; then
for svc in "${SERVICES[@]}"; do
supported=false
for known in "${DEPLOYABLE_SERVICES[@]}"; do
if [[ "${svc}" == "${known}" ]]; then
supported=true
break
fi
done
if [[ "${supported}" != "true" ]]; then
echo "Unsupported service ${svc}. Pass deployment/container names only." >&2
exit 1
fi
done
if [[ "${supported}" != "true" ]]; then
echo "Unsupported service ${svc}. Pass deployment/container names only." >&2
exit 1
fi
done
fi
IMAGE_SERVICES=("${SERVICES[@]}")
if [[ "${RUN_MIGRATIONS}" != "true" && "${ALLOW_SKIP_MIGRATIONS_FOR_APP_ROLLOUT}" != "true" ]]; then
for svc in "${SERVICES[@]}"; do
for gated in "${MIGRATION_GATED_SERVICES[@]}"; do
if [[ "${svc}" == "${gated}" ]]; then
echo "Refusing to roll ${svc} without RUN_MIGRATIONS=true." >&2
echo "Use ALLOW_SKIP_MIGRATIONS_FOR_APP_ROLLOUT=true only for a reviewed no-schema-change rollout." >&2
exit 1
fi
done
done
fi
IMAGE_SERVICES=()
if [[ "${#SERVICES[@]}" -gt 0 ]]; then
IMAGE_SERVICES=("${SERVICES[@]}")
fi
if [[ "${RUN_MIGRATIONS}" == "true" ]]; then
IMAGE_SERVICES=(migrate "${IMAGE_SERVICES[@]}")
if [[ "${#IMAGE_SERVICES[@]}" -gt 0 ]]; then
IMAGE_SERVICES=(migrate "${IMAGE_SERVICES[@]}")
else
IMAGE_SERVICES=(migrate)
fi
fi
if [[ "${#IMAGE_SERVICES[@]}" -eq 0 ]]; then
@@ -274,7 +312,10 @@ run_suffix="$(printf '%s-%s-%s' "${GITHUB_RUN_ID:-manual}" "${GITHUB_RUN_ATTEMPT
archive="/tmp/geo-rankly-images-${safe_tag}-${run_suffix}.tar"
remote_archive="${REMOTE_DIR}/geo-rankly-images-${safe_tag}-${run_suffix}.tar"
remote_lock_dir="${REMOTE_DIR}/.k3s-image-rollout.lock"
service_list="${SERVICES[*]}"
service_list=""
if [[ "${#SERVICES[@]}" -gt 0 ]]; then
service_list="${SERVICES[*]}"
fi
echo "==> Saving images:"
printf ' %s\n' "${images[@]}"
+17
View File
@@ -1262,8 +1262,10 @@ export interface PublishRecord {
id: number
publish_batch_id: number
article_id: number
article_title?: string | null
platform_account_id?: number | null
desktop_account_id: string | null
desktop_task_id?: string | null
target_type?: 'platform_account' | 'enterprise_site' | string
target_connection_id?: number | null
platform_id: string
@@ -1279,6 +1281,21 @@ export interface PublishRecord {
updated_at: string
}
export interface ListPublishRecordsParams {
page?: number
page_size?: number
title?: string
}
export interface PublishRecordListResponse {
items: PublishRecord[]
page: number
page_size: number
total: number
pending_count: number
history_total: number
}
export type EnterpriseSiteCmsType = 'pbootcms' | 'wordpress' | 'dedecms' | 'phpcms'
export type EnterpriseSiteStatus = 'draft' | 'connected' | 'error' | 'disabled'
@@ -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"},