feat(compliance): add content compliance detection across tenant, ops, and clients

Implements the Revision 8/9 compliance design: tenant + ops backends, ops-web
content-safety pages, admin-web editor/publish gate integration, desktop publish
block surfacing, and supporting migrations / shared types / config plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 20:48:14 +08:00
parent 81577b6154
commit 745cdd79cf
73 changed files with 12747 additions and 1892 deletions
@@ -47,6 +47,8 @@ interface PublishTaskItem {
publishType: string | null
fallbackReason: string | null
errorMessage: string | null
complianceBlockedRecordId: number | null
complianceBlockedAt: number | null
}
let refreshTimer: ReturnType<typeof setInterval> | null = null
@@ -84,7 +86,14 @@ function statusLabel(status: DesktopTaskInfo['status']): string {
return map[status]
}
function isComplianceBlockedTask(task: PublishTaskItem): boolean {
return task.status === 'aborted' && task.complianceBlockedRecordId !== null
}
function statusLabelForTask(task: PublishTaskItem): string {
if (isComplianceBlockedTask(task)) {
return '合规阻断'
}
if (task.status === 'succeeded' && task.publishType === 'draft') {
return '已存草稿'
}
@@ -193,6 +202,67 @@ function normalizeTaskErrorMessage(message: string | null): string | null {
return normalizePublisherErrorMessage(normalized) ?? 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
}
}
function inlineErrorPreview(message: string): string {
const compact = message.replace(/\s+/g, ' ').trim()
const maxLength = INLINE_ERROR_HEAD_CHARS + INLINE_ERROR_TAIL_CHARS + 3
@@ -207,6 +277,9 @@ function summaryForTask(
publishType: string | null = null,
fallbackReason: string | null = null,
): string {
if (status === 'aborted' && fallbackReason === 'compliance_blocked') {
return '定时发布前的合规重检拦截了本次任务,请修改文章后重新发布。'
}
switch (status) {
case 'queued':
return '任务已进入队列,待前面的发布任务完成后继续执行。'
@@ -435,7 +508,17 @@ const publishTasks = computed<PublishTaskItem[]>(() =>
const result = task.result ?? {}
const taskError = task.error ?? {}
const publishType = extractString(result.publish_type)
const fallbackReason = extractString(result.fallback_reason)
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 =
payload.content_ref &&
typeof payload.content_ref === 'object' &&
@@ -468,10 +551,13 @@ const publishTasks = computed<PublishTaskItem[]>(() =>
publishType,
fallbackReason,
errorMessage: normalizeTaskErrorMessage(
extractString(taskError.message) ??
complianceBlockedReason ??
extractString(taskError.message) ??
extractString(taskError.detail) ??
extractString(taskError.code),
),
complianceBlockedRecordId,
complianceBlockedAt: task.compliance_blocked_at ? parseTimestamp(task.compliance_blocked_at) : null,
}
}),
)
@@ -651,6 +737,14 @@ const tableScroll = { x: 1080 } as const
<span v-else class="cell-sub strong-text">{{ record.summary }}</span>
<span class="cell-sub meta-footer">
<span class="attempt-chip">尝试 {{ record.attempts }} </span>
<template v-if="record.complianceBlockedRecordId">
<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>租约 {{ formatRelativeTime(record.leaseExpiresAt) }} 到期</span>