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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user