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:
@@ -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('、') : '全部平台'
|
||||
}
|
||||
@@ -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')
|
||||
},
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user