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
+114
View File
@@ -0,0 +1,114 @@
import { getAIPlatformCatalogItem } from '@geo/shared-types'
export const publishPlatformCatalog: Record<string, { label: string; short: string }> = {
toutiaohao: { label: '头条号', short: '头' },
baijiahao: { label: '百家号', short: '百' },
sohuhao: { label: '搜狐号', short: '搜' },
qiehao: { label: '企鹅号', short: 'Q' },
zhihu: { label: '知乎', short: '知' },
wangyihao: { label: '网易号', short: '网' },
jianshu: { label: '简书', short: '简' },
bilibili: { label: 'bilibili', short: 'B' },
juejin: { label: '稀土掘金', short: '掘' },
smzdm: { label: '什么值得买', short: '值' },
weixin_gzh: { label: '微信公众号', short: '微' },
zol: { label: '中关村在线', short: 'Z' },
dongchedi: { label: '懂车帝', short: '懂' },
}
export const publishPlatformOptions = Object.entries(publishPlatformCatalog).map(
([value, item]) => ({
value,
label: item.label,
}),
)
export const complianceLevelOptions = [
{ label: '阻断', value: 'block' },
{ label: '高风险', value: 'high' },
{ label: '中风险', value: 'medium' },
{ label: '提示', value: 'info' },
]
export const complianceModeOptions = [
{ label: '强制阻断', value: 'mandatory' },
{ label: '仅提示确认', value: 'advisory' },
{ label: '关闭检测', value: 'disabled' },
]
export const complianceSourceOptions = [
{ label: '词库', value: 'dict' },
{ label: '正则', value: 'regex' },
{ label: 'LLM', value: 'llm' },
]
export function levelLabel(level?: string | null): string {
return complianceLevelOptions.find((item) => item.value === level)?.label ?? level ?? '--'
}
export function levelColor(level?: string | null): string {
if (level === 'block') return 'red'
if (level === 'high') return 'orange'
if (level === 'medium') return 'gold'
if (level === 'info') return 'blue'
return 'default'
}
export function modeLabel(mode?: string | null): string {
return complianceModeOptions.find((item) => item.value === mode)?.label ?? mode ?? '--'
}
export function modeColor(mode?: string | null): string {
if (mode === 'mandatory') return 'red'
if (mode === 'advisory') return 'gold'
if (mode === 'disabled') return 'default'
return 'blue'
}
export function decisionLabel(decision?: string | null): string {
if (decision === 'pass') return '通过'
if (decision === 'needs_ack') return '待确认'
if (decision === 'block') return '阻断'
return decision ?? '--'
}
export function decisionColor(decision?: string | null): string {
if (decision === 'pass') return 'green'
if (decision === 'needs_ack') return 'gold'
if (decision === 'block') return 'red'
return 'default'
}
export function reviewStatusLabel(status?: string | null): string {
if (status === 'pending') return '待审'
if (status === 'approved') return '已通过'
if (status === 'rejected') return '已驳回'
if (status === 'cancelled') return '已撤回'
if (status === 'none') return '无'
return status ?? '--'
}
export function reviewStatusColor(status?: string | null): string {
if (status === 'pending') return 'gold'
if (status === 'approved') return 'green'
if (status === 'rejected') return 'red'
if (status === 'cancelled') return 'default'
return 'blue'
}
export function sourceLabel(source?: string | null): string {
return complianceSourceOptions.find((item) => item.value === source)?.label ?? source ?? '--'
}
export function platformLabel(platform?: string | null): string {
const normalized = String(platform ?? '').trim()
if (!normalized) return '全部平台'
const ai = getAIPlatformCatalogItem(normalized)
if (ai) return ai.label
return publishPlatformCatalog[normalized]?.label ?? normalized
}
export function platformListLabel(platforms?: string[] | null): string {
const normalized = (platforms ?? []).map((item) => item.trim()).filter(Boolean)
return normalized.length ? normalized.map(platformLabel).join('、') : '全部平台'
}
+191
View File
@@ -0,0 +1,191 @@
import type { ComplianceCheckResult, ComplianceManualReview } from '@geo/shared-types'
import { http } from '@/lib/http'
export type ComplianceLevel = 'block' | 'high' | 'medium' | 'info'
export type ComplianceMode = 'mandatory' | 'advisory' | 'disabled'
export type CompliancePlatformScope = 'all' | 'specific'
export interface OpsComplianceDictionary {
id: number
code: string
name: string
platform_scope: CompliancePlatformScope
applicable_platforms: string[]
default_level: ComplianceLevel
enabled: boolean
version: number
description?: string | null
created_at: string
updated_at: string
term_count: number
}
export interface UpsertComplianceDictionaryRequest {
code?: string
name: string
platform_scope: CompliancePlatformScope
applicable_platforms: string[]
default_level: ComplianceLevel
enabled?: boolean
description?: string | null
}
export interface OpsComplianceTerm {
id: number
dictionary_id: number
match_type: 'exact' | 'regex'
pattern: string
level_override?: ComplianceLevel | null
hint?: string | null
reference_law?: string | null
enabled: boolean
created_at: string
updated_at: string
}
export interface CreateComplianceTermRequest {
match_type: 'exact' | 'regex'
pattern: string
level_override?: ComplianceLevel | null
hint?: string | null
reference_law?: string | null
enabled?: boolean
}
export interface CompliancePolicy {
id?: number
master_enabled?: boolean | null
enforcement_mode: ComplianceMode
enabled_dictionaries: number[]
llm_judge_enabled: boolean
llm_categories: string[]
revision: number
updated_at?: string
}
export interface UpdateCompliancePolicyRequest {
master_enabled?: boolean
enforcement_mode?: ComplianceMode
enabled_dictionaries?: number[]
llm_judge_enabled?: boolean
llm_categories?: string[]
}
export interface ComplianceStats {
check_count: number
blocked_count: number
needs_ack_count: number
passed_count: number
ack_count: number
manual_pending_count: number
manual_approved_count: number
manual_rejected_count: number
by_level: Record<string, number>
by_source: Record<string, number>
}
export interface PagedResult<T> {
items: T[]
total: number
page: number
page_size: number
}
export const complianceApi = {
dictionaries() {
return http.get<OpsComplianceDictionary[]>('/compliance/dictionaries')
},
createDictionary(payload: UpsertComplianceDictionaryRequest) {
return http.post<OpsComplianceDictionary, UpsertComplianceDictionaryRequest>(
'/compliance/dictionaries',
payload,
)
},
updateDictionary(id: number, payload: UpsertComplianceDictionaryRequest) {
return http.put<OpsComplianceDictionary, UpsertComplianceDictionaryRequest>(
`/compliance/dictionaries/${id}`,
payload,
)
},
deleteDictionary(id: number) {
return http.delete<{ deleted: boolean }>(`/compliance/dictionaries/${id}`)
},
terms(dictionaryId: number) {
return http.get<OpsComplianceTerm[]>(`/compliance/dictionaries/${dictionaryId}/terms`)
},
createTerm(dictionaryId: number, payload: CreateComplianceTermRequest) {
return http.post<OpsComplianceTerm, CreateComplianceTermRequest>(
`/compliance/dictionaries/${dictionaryId}/terms`,
payload,
)
},
batchImportTerms(dictionaryId: number, terms: CreateComplianceTermRequest[]) {
return http.post<OpsComplianceTerm[], { terms: CreateComplianceTermRequest[] }>(
`/compliance/dictionaries/${dictionaryId}/terms:batch-import`,
{ terms },
)
},
deleteTerm(dictionaryId: number, termId: number) {
return http.delete<{ deleted: boolean }>(`/compliance/dictionaries/${dictionaryId}/terms/${termId}`)
},
publishDictionary(id: number) {
return http.post<OpsComplianceDictionary>(`/compliance/dictionaries/${id}/publish`)
},
rollbackDictionary(id: number, version: number) {
return http.post<OpsComplianceDictionary, { version: number }>(
`/compliance/dictionaries/${id}/rollback`,
{ version },
)
},
globalPolicy() {
return http.get<CompliancePolicy>('/compliance/policy/global')
},
updateGlobalPolicy(payload: UpdateCompliancePolicyRequest) {
return http.put<CompliancePolicy, UpdateCompliancePolicyRequest>(
'/compliance/policy/global',
payload,
)
},
setMasterSwitch(enabled: boolean) {
return http.post<CompliancePolicy, { enabled: boolean }>(
'/compliance/policy/global/master-switch',
{ enabled },
)
},
manualReviews(params: {
status?: string
tenant_id?: number
page?: number
page_size?: number
}) {
return http.get<PagedResult<ComplianceManualReview>>('/compliance/manual-reviews', params)
},
manualReview(id: number) {
return http.get<ComplianceManualReview>(`/compliance/manual-reviews/${id}`)
},
approveManualReview(id: number, reason: string) {
return http.post<ComplianceManualReview, { reason: string }>(
`/compliance/manual-reviews/${id}/approve`,
{ reason },
)
},
rejectManualReview(id: number, reason: string) {
return http.post<ComplianceManualReview, { reason: string }>(
`/compliance/manual-reviews/${id}/reject`,
{ reason },
)
},
records(params: {
tenant_id?: number
article_id?: number
decision?: string
page?: number
page_size?: number
}) {
return http.get<PagedResult<ComplianceCheckResult>>('/compliance/records', params)
},
stats() {
return http.get<ComplianceStats>('/compliance/stats')
},
}
+1
View File
@@ -97,6 +97,7 @@ async function unwrap<T>(promise: Promise<{ data: ApiEnvelope<T> }>): Promise<T>
export const http = {
get: <T>(url: string, params?: Record<string, unknown>) => unwrap<T>(client.get(url, { params })),
post: <T, B = unknown>(url: string, body?: B) => unwrap<T>(client.post(url, body)),
put: <T, B = unknown>(url: string, body?: B) => unwrap<T>(client.put(url, body)),
patch: <T, B = unknown>(url: string, body?: B) => unwrap<T>(client.patch(url, body)),
delete: <T>(url: string) => unwrap<T>(client.delete(url)),
}