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:
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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[@]}"
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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"},
|
||||
|
||||
Reference in New Issue
Block a user