init: AIGC-Hub/AVCC 方案文档 + TCS-IPTV 内容可信锁定系统 MVP
- 方案文档: AVCC 体系建设、IPTV TCS 需求(0-req)/PRD(1-prd)/任务(2-task)/二三四期任务 - tcs-iptv: Go 后端(哈希SDK/MA码生成/可信数据空间mock/业务编排/HTTP API+HMAC鉴权) - web-console: React+AntD 监管大屏(角色工作台/全流程演示/监管片库) - 一剧一码+集级哈希, 集级下架/恢复, 全栈测试通过
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
Layout, Typography, Card, Input, Button, Space, Table, Tag,
|
||||
message, Modal, Descriptions, Alert, Row, Col, Statistic, Tabs,
|
||||
} from 'antd'
|
||||
import { SearchOutlined, StopOutlined, CheckCircleOutlined } from '@ant-design/icons'
|
||||
import { api } from './api.js'
|
||||
import FlowDemo from './FlowDemo.jsx'
|
||||
import RoleDesk from './RoleDesk.jsx'
|
||||
|
||||
const { Header, Content } = Layout
|
||||
const { Title, Text } = Typography
|
||||
|
||||
const partyLabel = { cp: '内容提供商', reviewer: '审核和监管部门', operator: '运营商' }
|
||||
const partyColor = { cp: 'green', reviewer: 'blue', operator: 'orange' }
|
||||
|
||||
function RegulatorConsole() {
|
||||
const [maCode, setMaCode] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [mappings, setMappings] = useState(null)
|
||||
const [cdnEndpoints, setCdnEndpoints] = useState([])
|
||||
const [verifyHash, setVerifyHash] = useState('')
|
||||
const [verifyResult, setVerifyResult] = useState(null)
|
||||
|
||||
async function doQuery() {
|
||||
if (!maCode) return message.warning('请输入 MA 码')
|
||||
setLoading(true)
|
||||
try {
|
||||
const { status, data } = await api.mappings(maCode)
|
||||
if (status === 200) {
|
||||
setMappings(data.data.mappings || [])
|
||||
setCdnEndpoints(data.data.cdn_endpoints || [])
|
||||
message.success('查询成功')
|
||||
} else {
|
||||
setMappings(null); setCdnEndpoints([])
|
||||
message.error(data.message || '查询失败')
|
||||
}
|
||||
} catch (e) { message.error('请求失败:' + e.message) }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
async function doVerify() {
|
||||
if (!maCode || !verifyHash) return message.warning('请输入 MA 码与文件哈希')
|
||||
try {
|
||||
const { status, data } = await api.verify(maCode, verifyHash)
|
||||
setVerifyResult({ ok: status === 200, ...data })
|
||||
} catch (e) { message.error('请求失败:' + e.message) }
|
||||
}
|
||||
|
||||
function confirmTakedown() {
|
||||
if (!maCode) return message.warning('请先输入 MA 码')
|
||||
Modal.confirm({
|
||||
title: '违规应急下架',
|
||||
content: `确认对 ${maCode} 执行全网下架?该操作将解析三方编码并秒级同步。`,
|
||||
okText: '确认下架', okType: 'danger', cancelText: '取消',
|
||||
onOk: async () => {
|
||||
const { status, data } = await api.takedown(maCode, '监管大屏手动下架')
|
||||
if (status === 200) {
|
||||
message.success('已下架,受影响 CDN: ' + (data.data.cdn_endpoints || []).join(', '))
|
||||
doQuery()
|
||||
} else {
|
||||
message.error(data.message || '下架失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '角色', dataIndex: 'party', render: (p) => <Tag color={partyColor[p]}>{partyLabel[p] || p}</Tag> },
|
||||
{ title: '本方编码', dataIndex: 'party_id' },
|
||||
{ title: '名称', dataIndex: 'party_name', render: (v) => v || '-' },
|
||||
{ title: 'CDN 端点', dataIndex: 'cdn_endpoint', render: (v) => v || '-' },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card title="按 MA 码查询全链路" style={{ marginBottom: 16 }}>
|
||||
<Space.Compact style={{ width: '100%', maxWidth: 720 }}>
|
||||
<Input
|
||||
placeholder="如 MA.156.8531.6101/WD/20260000001"
|
||||
value={maCode} onChange={(e) => setMaCode(e.target.value)}
|
||||
onPressEnter={doQuery}
|
||||
/>
|
||||
<Button type="primary" icon={<SearchOutlined />} loading={loading} onClick={doQuery}>
|
||||
查询
|
||||
</Button>
|
||||
<Button danger icon={<StopOutlined />} onClick={confirmTakedown}>
|
||||
应急下架
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Card>
|
||||
|
||||
{mappings && (
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={6}><Card><Statistic title="三方映射数" value={mappings.length} /></Card></Col>
|
||||
<Col span={6}><Card><Statistic title="CDN 端点数" value={cdnEndpoints.length} /></Card></Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{mappings && (
|
||||
<Card title="三方编码映射" style={{ marginBottom: 16 }}>
|
||||
<Table rowKey={(r, i) => i} columns={columns} dataSource={mappings} pagination={false} size="middle" />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="哈希验真">
|
||||
<Space direction="vertical" style={{ width: '100%', maxWidth: 720 }}>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="待校验文件哈希(file_sha256)"
|
||||
value={verifyHash} onChange={(e) => setVerifyHash(e.target.value)}
|
||||
/>
|
||||
<Button icon={<CheckCircleOutlined />} onClick={doVerify}>验真</Button>
|
||||
</Space.Compact>
|
||||
{verifyResult && (
|
||||
<Descriptions bordered size="small" column={1}>
|
||||
<Descriptions.Item label="结果">
|
||||
{verifyResult.ok && verifyResult.data?.match
|
||||
? <Tag color="green">匹配(正版过审内容)</Tag>
|
||||
: <Tag color="red">不匹配(疑似版本替换)</Tag>}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="绑定哈希">{verifyResult.data?.bound_hash || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="提交哈希">{verifyResult.data?.submitted_hash || verifyHash}</Descriptions.Item>
|
||||
<Descriptions.Item label="消息">{verifyResult.message || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Header style={{ background: '#1a237e', display: 'flex', alignItems: 'center' }}>
|
||||
<Title level={3} style={{ color: '#fff', margin: 0 }}>TCS-IPTV 内容可信锁定系统</Title>
|
||||
<Text style={{ color: '#b3c5ff', marginLeft: 16 }}>
|
||||
陕西IPTV运营公司 · MA码+哈希双锚定
|
||||
</Text>
|
||||
</Header>
|
||||
<Content style={{ padding: 24, background: '#f0f2f5' }}>
|
||||
<Alert
|
||||
type="warning" showIcon style={{ marginBottom: 16 }}
|
||||
message="演示模式:以四角色密钥直连 api-svc。生产环境应改为控制台 BFF + 会话令牌,密钥不下发浏览器。"
|
||||
/>
|
||||
<Tabs
|
||||
defaultActiveKey="desk"
|
||||
items={[
|
||||
{ key: 'desk', label: '角色工作台(多方协作)', children: <RoleDesk /> },
|
||||
{ key: 'flow', label: '全流程演示(一键)', children: <FlowDemo /> },
|
||||
{ key: 'console', label: '监管大屏', children: <RegulatorConsole /> },
|
||||
]}
|
||||
/>
|
||||
</Content>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
Card, Steps, Button, Space, Input, Select, Form, Tag, Timeline,
|
||||
Descriptions, message, Row, Col, Typography, Divider, Alert, Table,
|
||||
} from 'antd'
|
||||
import {
|
||||
PlayCircleOutlined, RedoOutlined, StopOutlined, SafetyCertificateOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { api } from './api.js'
|
||||
|
||||
const { Text, Paragraph } = Typography
|
||||
|
||||
const roleColor = { cp: 'green', regulator: 'red', reviewer: 'blue', operator: 'orange' }
|
||||
const roleLabel = { cp: '内容提供商', regulator: '监管主体', reviewer: '审核/媒资', operator: '运营商' }
|
||||
|
||||
// 七步流水线定义(审核在前,发码在后——审过才发证发码)
|
||||
const STEPS = [
|
||||
{ key: 'register', title: 'CP 送审', role: 'cp', desc: '原片送 CSPS 既有审核渠道 + 哈希包上链(链上不存原片)' },
|
||||
{ key: 'csps', title: 'CSPS 合规审核', role: 'reviewer', desc: '基于原片审画面/台词/声音;验真送审哈希=上链哈希(审播一致)' },
|
||||
{ key: 'issue', title: '审核通过·发码签发', role: 'regulator', desc: '审过才发码:按号段生成 MA 码,1:1 强绑定哈希' },
|
||||
{ key: 'ingest', title: '媒资库入库', role: 'reviewer', desc: '审合格入库,建立媒资编码映射' },
|
||||
{ key: 'publish', title: '发布给运营商', role: 'reviewer', desc: '携 MA 码+哈希证书发布' },
|
||||
{ key: 'inject', title: 'CDN 注入校验', role: 'operator', desc: '注入前哈希比对,匹配放行(防偷换)' },
|
||||
]
|
||||
|
||||
export default function FlowDemo() {
|
||||
const [form] = Form.useForm()
|
||||
const [current, setCurrent] = useState(0)
|
||||
const [running, setRunning] = useState(false)
|
||||
const [logs, setLogs] = useState([])
|
||||
const [ctx, setCtx] = useState({}) // reviewID/ctid/maCode/cert/fileHash/episodeHashes
|
||||
const [done, setDone] = useState(false)
|
||||
const [episodes, setEpisodes] = useState([]) // 集级哈希列表
|
||||
const [epVerify, setEpVerify] = useState({}) // {episode: 'match'|'mismatch'}
|
||||
|
||||
function addLog(role, title, ok, detail) {
|
||||
setLogs((prev) => [...prev, { role, title, ok, detail, t: new Date().toLocaleTimeString() }])
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setCurrent(0); setLogs([]); setCtx({}); setDone(false); setRunning(false)
|
||||
setEpisodes([]); setEpVerify({})
|
||||
}
|
||||
|
||||
// 执行单步,返回是否成功
|
||||
async function runStep(idx, shared) {
|
||||
const step = STEPS[idx]
|
||||
const v = form.getFieldsValue()
|
||||
const fileHash = shared.fileHash
|
||||
let res
|
||||
switch (step.key) {
|
||||
case 'register':
|
||||
// 一剧一码 + 集级哈希:按集数生成每集独立哈希
|
||||
shared.episodeHashes = []
|
||||
const epArr = []
|
||||
const epCount = Number(v.episodes) || 1
|
||||
for (let i = 1; i <= epCount; i++) {
|
||||
const eh = `${fileHash}-E${i}`
|
||||
shared.episodeHashes.push({ episode: i, hash: eh })
|
||||
epArr.push({ episode: i, file_sha256: eh, merkle_root: `mr-${eh}` })
|
||||
}
|
||||
res = await api.register({
|
||||
title: v.title, episode_count: epCount, category: v.category,
|
||||
file_sha256: fileHash, merkle_root: 'mr-' + fileHash, perceptual_hash: 'ph-' + fileHash,
|
||||
episodes: epArr,
|
||||
cp_media_id: v.cpId, cp_name: v.cpName,
|
||||
})
|
||||
if (res.ok) { shared.reviewID = res.data.data.review_id; shared.ctid = res.data.data.content_twin_id }
|
||||
addLog(step.role, step.title, res.ok, res.ok ? `流水号 ${shared.reviewID}(${epCount}集,每集独立哈希)` : res.data.message)
|
||||
break
|
||||
case 'issue':
|
||||
res = await api.issue({ review_id: shared.reviewID, issuer: '陕西IPTV运营公司' })
|
||||
if (res.ok) { shared.maCode = res.data.data.ma_code; shared.cert = res.data.data.certificate }
|
||||
addLog(step.role, step.title, res.ok, res.ok ? `MA码 ${shared.maCode}` : res.data.message)
|
||||
break
|
||||
case 'csps':
|
||||
res = await api.csps({ review_id: shared.reviewID, approved: true, reviewer_id: 'sxiptv-审核01' })
|
||||
addLog(step.role, step.title, res.ok, res.ok ? '审核通过(发码前置)' : res.data.message)
|
||||
break
|
||||
case 'ingest':
|
||||
res = await api.ingest({
|
||||
ma_code: shared.maCode, content_twin_id: shared.ctid,
|
||||
media_asset_id: 'SXMEDIA-' + fileHash, lib_name: '陕西IPTV媒体资源库',
|
||||
})
|
||||
addLog(step.role, step.title, res.ok, res.ok ? '已入媒资库' : res.data.message)
|
||||
break
|
||||
case 'publish':
|
||||
res = await api.publish({ ma_code: shared.maCode, certificate: shared.cert })
|
||||
addLog(step.role, step.title, res.ok, res.ok ? '已发布' : res.data.message)
|
||||
break
|
||||
case 'inject':
|
||||
res = await api.inject({
|
||||
content_twin_id: shared.ctid, ma_code: shared.maCode, file_sha256: fileHash,
|
||||
operator_id: v.opId, cdn_endpoint: v.cdn,
|
||||
})
|
||||
addLog(step.role, step.title, res.ok,
|
||||
res.ok ? `注入成功 ${res.data.data.distribution_id}` : res.data.message)
|
||||
break
|
||||
default:
|
||||
res = { ok: false }
|
||||
}
|
||||
return res.ok
|
||||
}
|
||||
|
||||
// 一键全流程
|
||||
async function runAll() {
|
||||
setRunning(true); setLogs([]); setDone(false); setCurrent(0)
|
||||
const shared = { fileHash: 'fh-' + Date.now().toString(36) }
|
||||
for (let i = 0; i < STEPS.length; i++) {
|
||||
setCurrent(i)
|
||||
const ok = await runStep(i, shared)
|
||||
if (!ok) {
|
||||
message.error(`第 ${i + 1} 步「${STEPS[i].title}」失败,流程中断`)
|
||||
setRunning(false); setCtx(shared)
|
||||
return
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500)) // 放慢便于演示观看
|
||||
}
|
||||
setCurrent(STEPS.length)
|
||||
setCtx(shared); setDone(true); setRunning(false)
|
||||
message.success('全流程跑通:审过即锁定,锁定即通行')
|
||||
await loadEpisodes(shared.maCode)
|
||||
}
|
||||
|
||||
async function loadEpisodes(maCode) {
|
||||
const res = await api.episodes(maCode)
|
||||
if (res.ok) setEpisodes(res.data.data.episodes || [])
|
||||
}
|
||||
|
||||
// 按集验真:correct=true 用正确哈希,false 用篡改哈希
|
||||
async function verifyEp(ep, correct) {
|
||||
const realHash = (ctx.episodeHashes || []).find((e) => e.episode === ep)?.hash
|
||||
const submit = correct ? realHash : 'TAMPERED-' + realHash
|
||||
const res = await api.verifyEpisode(ctx.maCode, ep, submit)
|
||||
const matched = res.ok && res.data.data?.match
|
||||
setEpVerify((prev) => ({ ...prev, [ep]: matched ? 'match' : 'mismatch' }))
|
||||
if (matched) message.success(`第${ep}集验真通过`)
|
||||
else message.warning(`第${ep}集不匹配(疑似该集被替换)`)
|
||||
}
|
||||
|
||||
async function doTakedown() {
|
||||
const res = await api.takedown(ctx.maCode, '监管演示下架')
|
||||
if (res.ok) {
|
||||
addLog('regulator', '违规应急下架', true,
|
||||
`秒级下架,受影响 CDN: ${(res.data.data.cdn_endpoints || []).join(', ')}`)
|
||||
message.success('已全网下架')
|
||||
} else message.error(res.data.message)
|
||||
}
|
||||
|
||||
async function doTamperTest() {
|
||||
const res = await api.inject({
|
||||
content_twin_id: ctx.ctid, ma_code: ctx.maCode, file_sha256: 'TAMPERED-' + ctx.fileHash,
|
||||
operator_id: 'OP-X', cdn_endpoint: 'cdn://x',
|
||||
})
|
||||
addLog('operator', '篡改注入测试', !res.ok, res.ok ? '⚠️ 异常:篡改竟通过' : '✅ 已拒绝:' + res.data.message)
|
||||
if (!res.ok) message.success('篡改内容被正确拦截')
|
||||
}
|
||||
|
||||
return (
|
||||
<Row gutter={16}>
|
||||
<Col span={9}>
|
||||
<Card title="演示参数" size="small" style={{ marginBottom: 16 }}>
|
||||
<Form form={form} layout="vertical" size="small" initialValues={{
|
||||
title: '长安少年行', category: 'WD', episodes: 6,
|
||||
cpId: 'XAQJSL-2026-001', cpName: '西安曲江丝路文化传播有限公司',
|
||||
opId: 'CT-SX-IPTV', cdn: 'cdn://ct-sx/iptv/vod/changan001',
|
||||
}}>
|
||||
<Form.Item label="作品标题" name="title"><Input /></Form.Item>
|
||||
<Row gutter={8}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="类目" name="category">
|
||||
<Select options={[
|
||||
{ value: 'WD', label: '微短剧' }, { value: 'WJ', label: '网络剧' },
|
||||
{ value: 'DY', label: '网络电影' }, { value: 'DH', label: '网络动画' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}><Form.Item label="集数" name="episodes"><Input type="number" /></Form.Item></Col>
|
||||
</Row>
|
||||
<Form.Item label="内容提供商 (CP)" name="cpName"><Input /></Form.Item>
|
||||
<Form.Item label="CP 媒资编码" name="cpId"><Input /></Form.Item>
|
||||
<Form.Item label="运营商编码" name="opId"><Input /></Form.Item>
|
||||
<Form.Item label="CDN 端点" name="cdn"><Input /></Form.Item>
|
||||
</Form>
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlayCircleOutlined />} loading={running} onClick={runAll}>
|
||||
一键全流程
|
||||
</Button>
|
||||
<Button icon={<RedoOutlined />} onClick={reset} disabled={running}>重置</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={15}>
|
||||
<Card title="内容生命周期流水线" style={{ marginBottom: 16 }}>
|
||||
<Steps
|
||||
direction="vertical" size="small" current={current}
|
||||
status={running ? 'process' : done ? 'finish' : 'wait'}
|
||||
items={STEPS.map((s) => ({
|
||||
title: <Space>{s.title}<Tag color={roleColor[s.role]}>{roleLabel[s.role]}</Tag></Space>,
|
||||
description: s.desc,
|
||||
}))}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{done && (
|
||||
<Card title={<Space><SafetyCertificateOutlined />赋码结果(双锚定)</Space>} style={{ marginBottom: 16 }}>
|
||||
<Descriptions bordered size="small" column={1}>
|
||||
<Descriptions.Item label="MA 码(监管锚点)"><Text strong copyable>{ctx.maCode}</Text></Descriptions.Item>
|
||||
<Descriptions.Item label="文件哈希(技术锚点)">{ctx.fileHash}</Descriptions.Item>
|
||||
<Descriptions.Item label="CTID(机器主键)">{ctx.ctid}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Divider />
|
||||
<Space>
|
||||
<Button onClick={doTamperTest}>篡改注入测试(应拒绝)</Button>
|
||||
<Button danger icon={<StopOutlined />} onClick={doTakedown}>违规应急下架</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{done && episodes.length > 0 && (
|
||||
<Card
|
||||
title={<Space>集级面板<Tag color="purple">一剧一码 · {episodes.length} 集独立哈希</Tag></Space>}
|
||||
style={{ marginBottom: 16 }}
|
||||
extra={<Text type="secondary">集级子标识:{ctx.maCode}#E01 …</Text>}
|
||||
>
|
||||
<Table
|
||||
size="small" rowKey="episode" pagination={false}
|
||||
dataSource={episodes}
|
||||
columns={[
|
||||
{ title: '集号', dataIndex: 'episode', width: 70, render: (e) => <Tag>第 {e} 集</Tag> },
|
||||
{ title: '集级子标识', render: (_, r) => <Text code>{`${ctx.maCode}#E${String(r.episode).padStart(2, '0')}`}</Text> },
|
||||
{ title: '该集哈希', dataIndex: 'hash_value', ellipsis: true },
|
||||
{
|
||||
title: '验真状态', width: 110, render: (_, r) => {
|
||||
const st = epVerify[r.episode]
|
||||
if (st === 'match') return <Tag color="green">匹配</Tag>
|
||||
if (st === 'mismatch') return <Tag color="red">不匹配</Tag>
|
||||
return <Tag>未验</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '按集验真', width: 200, render: (_, r) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => verifyEp(r.episode, true)}>正确</Button>
|
||||
<Button size="small" danger onClick={() => verifyEp(r.episode, false)}>模拟篡改</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="执行日志" size="small">
|
||||
{logs.length === 0
|
||||
? <Alert type="info" message='点击「一键全流程」开始演示' />
|
||||
: <Timeline items={logs.map((l) => ({
|
||||
color: l.ok ? 'green' : 'red',
|
||||
children: (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Space>
|
||||
<Tag color={roleColor[l.role]}>{roleLabel[l.role]}</Tag>
|
||||
<Text strong>{l.title}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{l.t}</Text>
|
||||
</Space>
|
||||
<Text type={l.ok ? 'success' : 'danger'}>{l.detail}</Text>
|
||||
</Space>
|
||||
),
|
||||
}))} />
|
||||
}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import {
|
||||
Card, Tabs, Table, Button, Space, Tag, Form, Input, Select, message,
|
||||
Typography, Empty, Badge, Modal, Descriptions, Segmented,
|
||||
} from 'antd'
|
||||
import { ReloadOutlined, SendOutlined, StopOutlined } from '@ant-design/icons'
|
||||
import { call, api } from './api.js'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const statusMeta = {
|
||||
approved: { label: '待入库', color: 'green' },
|
||||
in_library: { label: '在库待发布', color: 'cyan' },
|
||||
published: { label: '流通中', color: 'blue' },
|
||||
revoked: { label: '已下架', color: 'red' },
|
||||
}
|
||||
|
||||
const catLabel = { WD: '微短剧', WJ: '网络剧', DY: '网络电影', DH: '网络动画' }
|
||||
|
||||
// 各角色工作台共享的刷新钩子:通过 bump 触发全局重拉
|
||||
function useTick() {
|
||||
const [tick, setTick] = useState(0)
|
||||
return [tick, () => setTick((t) => t + 1)]
|
||||
}
|
||||
|
||||
// ============ CP 工作台 ============
|
||||
function CPDesk({ onChanged }) {
|
||||
const [form] = Form.useForm()
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
async function submit() {
|
||||
const v = await form.validateFields()
|
||||
setSubmitting(true)
|
||||
const fh = 'fh-' + Date.now().toString(36)
|
||||
const epCount = Number(v.episodes) || 1
|
||||
const episodes = []
|
||||
for (let i = 1; i <= epCount; i++) {
|
||||
episodes.push({ episode: i, file_sha256: `${fh}-E${i}`, merkle_root: `mr-${fh}-E${i}` })
|
||||
}
|
||||
const res = await call('cp', 'POST', '/content/register', {
|
||||
title: v.title, episode_count: epCount, category: v.category,
|
||||
file_sha256: fh, merkle_root: 'mr-' + fh, perceptual_hash: 'ph-' + fh,
|
||||
episodes, cp_media_id: v.cpId, cp_name: v.cpName,
|
||||
})
|
||||
setSubmitting(false)
|
||||
if (res.ok) {
|
||||
message.success(`送审成功,流水号 ${res.data.data.review_id}(原片走审核渠道,哈希上链)`)
|
||||
onChanged()
|
||||
} else message.error(res.data.message || '送审失败')
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="内容提供商工作台 · 送审申报" size="small">
|
||||
<Form form={form} layout="inline" initialValues={{
|
||||
title: '长安少年行', category: 'WD', episodes: 6,
|
||||
cpId: 'XAQJSL-2026-001', cpName: '西安曲江丝路文化传播有限公司',
|
||||
}}>
|
||||
<Form.Item label="作品" name="title" rules={[{ required: true }]}><Input style={{ width: 160 }} /></Form.Item>
|
||||
<Form.Item label="类目" name="category">
|
||||
<Select style={{ width: 110 }} options={Object.entries(catLabel).map(([v, l]) => ({ value: v, label: l }))} />
|
||||
</Form.Item>
|
||||
<Form.Item label="集数" name="episodes"><Input type="number" style={{ width: 80 }} /></Form.Item>
|
||||
<Form.Item label="CP" name="cpName"><Input style={{ width: 220 }} /></Form.Item>
|
||||
<Form.Item name="cpId" hidden><Input /></Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<SendOutlined />} loading={submitting} onClick={submit}>
|
||||
送审(原片送审核 + 哈希上链)
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
送审后到「审核监管工作台」的"待审队列"领取审核。
|
||||
</Text>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 审核监管工作台 ============
|
||||
function ReviewerDesk({ tick, onChanged }) {
|
||||
const [pending, setPending] = useState([])
|
||||
const [toIssue, setToIssue] = useState([])
|
||||
const [toIngest, setToIngest] = useState([])
|
||||
const [toPublish, setToPublish] = useState([])
|
||||
|
||||
async function load() {
|
||||
const [p, i, ing, pub] = await Promise.all([
|
||||
call('reviewer', 'GET', '/content/reviews?status=pending'),
|
||||
call('regulator', 'GET', '/content/reviews?status=approved'),
|
||||
call('reviewer', 'GET', '/content/list?status=approved'),
|
||||
call('reviewer', 'GET', '/content/list?status=in_library'),
|
||||
])
|
||||
setPending(p.data?.data?.reviews || [])
|
||||
setToIssue(i.data?.data?.reviews || [])
|
||||
setToIngest(ing.data?.data?.contents || [])
|
||||
setToPublish(pub.data?.data?.contents || [])
|
||||
}
|
||||
useEffect(() => { load() }, [tick])
|
||||
|
||||
async function review(reviewID, approved) {
|
||||
const res = await call('reviewer', 'POST', '/content/csps-result', { review_id: reviewID, approved, reviewer_id: 'sxiptv-审核01' })
|
||||
if (res.ok) { message.success(approved ? 'CSPS 审核通过' : '已驳回'); onChanged() }
|
||||
else message.error(res.data.message)
|
||||
}
|
||||
async function issue(reviewID) {
|
||||
const res = await call('regulator', 'POST', '/content/issue', { review_id: reviewID, issuer: '陕西IPTV运营公司' })
|
||||
if (res.ok) { message.success(`发码成功:${res.data.data.ma_code}`); onChanged() }
|
||||
else message.error(res.data.message)
|
||||
}
|
||||
async function ingest(r) {
|
||||
const res = await call('reviewer', 'POST', '/content/ingest', { ma_code: r.ma_code, content_twin_id: r.content_twin_id, media_asset_id: 'SXMEDIA-' + r.ma_code.slice(-4), lib_name: '陕西IPTV媒体资源库' })
|
||||
if (res.ok) { message.success('已入媒资库'); onChanged() }
|
||||
else message.error(res.data.message)
|
||||
}
|
||||
async function publish(r) {
|
||||
const cert = `CERT|${r.ma_code}|x|x` // 演示证书;真实证书在发码时返回
|
||||
const res = await call('reviewer', 'POST', '/content/publish', { ma_code: r.ma_code, certificate: cert })
|
||||
if (res.ok) { message.success('已发布给运营商'); onChanged() }
|
||||
else message.error(res.data.message + '(提示:发布需发码时的原始证书,演示可用全流程页)')
|
||||
}
|
||||
|
||||
const reviewCols = [
|
||||
{ title: '流水号', dataIndex: 'review_id' },
|
||||
{ title: '作品', dataIndex: 'title' },
|
||||
{ title: '类目', dataIndex: 'category', render: (c) => <Tag>{catLabel[c] || c}</Tag> },
|
||||
{ title: 'CP', dataIndex: 'cp_name', ellipsis: true },
|
||||
{ title: '操作', render: (_, r) => (
|
||||
<Space>
|
||||
<Button size="small" type="primary" onClick={() => review(r.review_id, true)}>审核通过</Button>
|
||||
<Button size="small" danger onClick={() => review(r.review_id, false)}>驳回</Button>
|
||||
</Space>
|
||||
) },
|
||||
]
|
||||
const issueCols = [
|
||||
{ title: '流水号', dataIndex: 'review_id' },
|
||||
{ title: '作品', dataIndex: 'title' },
|
||||
{ title: '类目', dataIndex: 'category', render: (c) => <Tag color="blue">{catLabel[c] || c}</Tag> },
|
||||
{ title: '操作', render: (_, r) => <Button size="small" type="primary" onClick={() => issue(r.review_id)}>发码签发</Button> },
|
||||
]
|
||||
const contentCols = (action, label, color) => [
|
||||
{ title: 'MA 码', dataIndex: 'ma_code', render: (v) => <Text code copyable>{v}</Text> },
|
||||
{ title: '作品', dataIndex: 'title' },
|
||||
{ title: '状态', dataIndex: 'status', render: (s) => <Tag>{s}</Tag> },
|
||||
{ title: '操作', render: (_, r) => <Button size="small" type="primary" onClick={() => action(r)}>{label}</Button> },
|
||||
]
|
||||
|
||||
const queue = (title, count, table) => (
|
||||
<Card size="small" title={<Space>{title}<Badge count={count} showZero color={count ? '#1677ff' : '#ccc'} /></Space>} style={{ marginBottom: 12 }}>
|
||||
{table}
|
||||
</Card>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button icon={<ReloadOutlined />} size="small" onClick={load} style={{ marginBottom: 12 }}>刷新队列</Button>
|
||||
{queue('① 待审队列(CSPS 合规审核)', pending.length,
|
||||
<Table rowKey="review_id" size="small" pagination={false} columns={reviewCols} dataSource={pending}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无待审" /> }} />)}
|
||||
{queue('② 待发码队列(审核通过 → 发码)', toIssue.length,
|
||||
<Table rowKey="review_id" size="small" pagination={false} columns={issueCols} dataSource={toIssue}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无待发码" /> }} />)}
|
||||
{queue('③ 待入库队列(已发码 → 入媒资库)', toIngest.length,
|
||||
<Table rowKey="ma_code" size="small" pagination={false}
|
||||
columns={contentCols(ingest, '入媒资库', 'green')} dataSource={toIngest}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无待入库" /> }} />)}
|
||||
{queue('④ 待发布队列(已入库 → 发布运营商)', toPublish.length,
|
||||
<Table rowKey="ma_code" size="small" pagination={false}
|
||||
columns={contentCols(publish, '发布', 'orange')} dataSource={toPublish}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无待发布" /> }} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 运营商工作台 ============
|
||||
function OperatorDesk({ tick, onChanged }) {
|
||||
const [toInject, setToInject] = useState([])
|
||||
async function load() {
|
||||
const res = await call('operator', 'GET', '/content/list?status=published')
|
||||
setToInject(res.data?.data?.contents || [])
|
||||
}
|
||||
useEffect(() => { load() }, [tick])
|
||||
|
||||
async function inject(r, tamper) {
|
||||
const fh = tamper ? 'TAMPERED-' + r.file_hash : r.file_hash
|
||||
const res = await call('operator', 'POST', '/content/inject', {
|
||||
content_twin_id: r.content_twin_id, ma_code: r.ma_code, file_sha256: fh,
|
||||
operator_id: 'CT-SX-IPTV', cdn_endpoint: 'cdn://ct-sx/vod/' + r.ma_code.slice(-4),
|
||||
})
|
||||
if (res.ok) { message.success(`注入成功 ${res.data.data.distribution_id}`); onChanged() }
|
||||
else message.warning('注入校验:' + (res.data.message || '哈希不匹配被拒'))
|
||||
}
|
||||
|
||||
const cols = [
|
||||
{ title: 'MA 码', dataIndex: 'ma_code', render: (v) => <Text code>{v}</Text> },
|
||||
{ title: '作品', dataIndex: 'title' },
|
||||
{ title: '操作', render: (_, r) => (
|
||||
<Space>
|
||||
<Button size="small" type="primary" onClick={() => inject(r, false)}>CDN 注入(正确)</Button>
|
||||
<Button size="small" danger onClick={() => inject(r, true)}>模拟篡改注入</Button>
|
||||
</Space>
|
||||
) },
|
||||
]
|
||||
return (
|
||||
<Card size="small" title={<Space>运营商工作台 · 待注入队列<Badge count={toInject.length} showZero /></Space>}
|
||||
extra={<Button icon={<ReloadOutlined />} size="small" onClick={load}>刷新</Button>}>
|
||||
<Table rowKey="ma_code" size="small" pagination={false} columns={cols} dataSource={toInject}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无待注入(需先在审核台发布)" /> }} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 总览看板 ============
|
||||
function Overview({ tick }) {
|
||||
const [counts, setCounts] = useState({})
|
||||
async function load() {
|
||||
const statuses = ['approved', 'in_library', 'published', 'revoked']
|
||||
const results = await Promise.all([
|
||||
call('regulator', 'GET', '/content/reviews?status=pending'),
|
||||
call('regulator', 'GET', '/content/reviews?status=approved'),
|
||||
...statuses.map((s) => call('regulator', 'GET', '/content/list?status=' + s)),
|
||||
])
|
||||
setCounts({
|
||||
pending: results[0].data?.data?.reviews?.length || 0,
|
||||
toIssue: results[1].data?.data?.reviews?.length || 0,
|
||||
toIngest: results[2].data?.data?.contents?.length || 0,
|
||||
toPublish: results[3].data?.data?.contents?.length || 0,
|
||||
published: results[4].data?.data?.contents?.length || 0,
|
||||
revoked: results[5].data?.data?.contents?.length || 0,
|
||||
})
|
||||
}
|
||||
useEffect(() => { load() }, [tick])
|
||||
|
||||
const items = [
|
||||
{ label: '待审', v: counts.pending, c: '#faad14' },
|
||||
{ label: '待发码', v: counts.toIssue, c: '#1677ff' },
|
||||
{ label: '待入库', v: counts.toIngest, c: '#52c41a' },
|
||||
{ label: '待发布', v: counts.toPublish, c: '#fa8c16' },
|
||||
{ label: '已发布(流通中)', v: counts.published, c: '#13c2c2' },
|
||||
{ label: '已下架', v: counts.revoked, c: '#f5222d' },
|
||||
]
|
||||
return (
|
||||
<Card size="small" title="全局看板(各环节在途数量)" extra={<Button icon={<ReloadOutlined />} size="small" onClick={load}>刷新</Button>}>
|
||||
<Space size="large" wrap>
|
||||
{items.map((it) => (
|
||||
<Card key={it.label} size="small" style={{ width: 130, textAlign: 'center', borderTop: `3px solid ${it.c}` }}>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, color: it.c }}>{it.v ?? 0}</div>
|
||||
<div style={{ color: '#888' }}>{it.label}</div>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 监管片库 ============
|
||||
function LibraryDesk({ tick, onChanged }) {
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [rows, setRows] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [detail, setDetail] = useState(null) // {maCode, mappings, episodes}
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
const status = filter === 'all' ? '' : filter
|
||||
const res = await call('regulator', 'GET', '/content/list?status=' + status)
|
||||
setRows(res.data?.data?.contents || [])
|
||||
setLoading(false)
|
||||
}
|
||||
useEffect(() => { load() }, [tick, filter])
|
||||
|
||||
function confirmTakedown(r) {
|
||||
Modal.confirm({
|
||||
title: '违规应急下架',
|
||||
content: `确认对《${r.title}》(${r.ma_code}) 执行全网下架?将解析三方编码秒级同步。`,
|
||||
okText: '确认下架', okType: 'danger', cancelText: '取消',
|
||||
onOk: async () => {
|
||||
const res = await api.takedown(r.ma_code, '监管片库应急下架')
|
||||
if (res.ok) {
|
||||
message.success('已全网下架,受影响 CDN: ' + (res.data.data.cdn_endpoints || []).join(', '))
|
||||
onChanged()
|
||||
} else message.error(res.data.message || '下架失败')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function viewDetail(r) {
|
||||
const [m, e] = await Promise.all([api.mappings(r.ma_code), api.episodes(r.ma_code)])
|
||||
setDetail({
|
||||
content: r,
|
||||
mappings: m.data?.data?.mappings || [],
|
||||
cdn: m.data?.data?.cdn_endpoints || [],
|
||||
episodes: e.data?.data?.episodes || [],
|
||||
})
|
||||
}
|
||||
|
||||
function takedownEpisode(maCode, episode) {
|
||||
Modal.confirm({
|
||||
title: `集级下架 · 第 ${episode} 集`,
|
||||
content: `只下架《${maCode}#E${String(episode).padStart(2, '0')}》本集,整剧其他集继续流通。确认?`,
|
||||
okText: '下架本集', okType: 'danger', cancelText: '取消',
|
||||
onOk: async () => {
|
||||
const res = await api.takedownEpisode(maCode, episode, '监管片库集级下架')
|
||||
if (res.ok) {
|
||||
message.success(`第 ${episode} 集已下架`)
|
||||
await viewDetail(detail.content) // 刷新弹窗
|
||||
onChanged()
|
||||
} else message.error(res.data.message || '下架失败')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function doRestore(r) {
|
||||
const res = await api.restore(r.ma_code)
|
||||
if (res.ok) { message.success('《' + r.title + '》已恢复上架'); onChanged() }
|
||||
else message.error(res.data.message || '恢复失败')
|
||||
}
|
||||
|
||||
async function restoreEpisode(maCode, episode) {
|
||||
const res = await api.restoreEpisode(maCode, episode)
|
||||
if (res.ok) {
|
||||
message.success('第 ' + episode + ' 集已恢复上架')
|
||||
await viewDetail(detail.content)
|
||||
onChanged()
|
||||
} else message.error(res.data.message || '恢复失败')
|
||||
}
|
||||
|
||||
const cols = [
|
||||
{ title: 'MA 码', dataIndex: 'ma_code', render: (v) => <Text code copyable>{v}</Text> },
|
||||
{ title: '作品', dataIndex: 'title' },
|
||||
{ title: '集数', dataIndex: 'episode_count', width: 70 },
|
||||
{ title: '状态', dataIndex: 'status', width: 110, render: (s) => {
|
||||
const m = statusMeta[s] || { label: s, color: 'default' }
|
||||
return <Tag color={m.color}>{m.label}</Tag>
|
||||
} },
|
||||
{ title: '操作', width: 240, render: (_, r) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => viewDetail(r)}>详情</Button>
|
||||
{r.status === 'revoked' ? (
|
||||
<Button size="small" type="primary" ghost onClick={() => doRestore(r)}>恢复上架</Button>
|
||||
) : (
|
||||
<Button size="small" danger icon={<StopOutlined />} onClick={() => confirmTakedown(r)}>
|
||||
应急下架
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
) },
|
||||
]
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
title={<Space>监管片库<Badge count={rows.length} showZero color="#1677ff" /></Space>}
|
||||
extra={
|
||||
<Space>
|
||||
<Segmented value={filter} onChange={setFilter} options={[
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '流通中', value: 'published' },
|
||||
{ label: '在库', value: 'in_library' },
|
||||
{ label: '待入库', value: 'approved' },
|
||||
{ label: '已下架', value: 'revoked' },
|
||||
]} />
|
||||
<Button icon={<ReloadOutlined />} size="small" onClick={load}>刷新</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table rowKey="ma_code" size="small" loading={loading} columns={cols} dataSource={rows}
|
||||
pagination={{ pageSize: 8 }}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="片库暂无内容" /> }} />
|
||||
|
||||
<Modal open={!!detail} onCancel={() => setDetail(null)} footer={null} width={760}
|
||||
title={detail ? `片库详情 · ${detail.content.title}` : ''}>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions bordered size="small" column={1} style={{ marginBottom: 12 }}>
|
||||
<Descriptions.Item label="MA 码">{detail.content.ma_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={(statusMeta[detail.content.status] || {}).color}>
|
||||
{(statusMeta[detail.content.status] || {}).label || detail.content.status}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="整剧哈希">{detail.content.file_hash || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Card size="small" title="三方编码映射" style={{ marginBottom: 12 }}>
|
||||
<Table rowKey={(r, i) => i} size="small" pagination={false}
|
||||
dataSource={detail.mappings}
|
||||
columns={[
|
||||
{ title: '角色', dataIndex: 'party' },
|
||||
{ title: '编码', dataIndex: 'party_id' },
|
||||
{ title: '名称', dataIndex: 'party_name', render: (v) => v || '-' },
|
||||
{ title: 'CDN', dataIndex: 'cdn_endpoint', render: (v) => v || '-' },
|
||||
]} />
|
||||
</Card>
|
||||
<Card size="small" title={`集级哈希(${detail.episodes.length} 集)· 可单集下架`}>
|
||||
<Table rowKey="episode" size="small" pagination={false}
|
||||
dataSource={detail.episodes}
|
||||
columns={[
|
||||
{ title: '集', dataIndex: 'episode', width: 50 },
|
||||
{ title: '子标识', render: (_, r) => <Text code>{`${detail.content.ma_code}#E${String(r.episode).padStart(2, '0')}`}</Text> },
|
||||
{ title: '哈希', dataIndex: 'hash_value', ellipsis: true },
|
||||
{ title: '状态', width: 80, render: (_, r) => r.revoked ? <Tag color="red">已下架</Tag> : <Tag color="green">流通中</Tag> },
|
||||
{ title: '操作', width: 110, render: (_, r) => (
|
||||
r.revoked
|
||||
? <Button size="small" type="primary" ghost onClick={() => restoreEpisode(detail.content.ma_code, r.episode)}>恢复本集</Button>
|
||||
: <Button size="small" danger onClick={() => takedownEpisode(detail.content.ma_code, r.episode)}>下架本集</Button>
|
||||
) },
|
||||
]} />
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RoleDesk() {
|
||||
const [tick, bump] = useTick()
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}><Overview tick={tick} /></div>
|
||||
<Tabs
|
||||
type="card"
|
||||
items={[
|
||||
{ key: 'cp', label: '🟢 内容提供商', children: <CPDesk onChanged={bump} /> },
|
||||
{ key: 'reviewer', label: '🔵 审核监管(CSPS/媒资)', children: <ReviewerDesk tick={tick} onChanged={bump} /> },
|
||||
{ key: 'operator', label: '🟠 运营商', children: <OperatorDesk tick={tick} onChanged={bump} /> },
|
||||
{ key: 'library', label: '🔴 监管片库(下架处置)', children: <LibraryDesk tick={tick} onChanged={bump} /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// API 客户端:Web Crypto 实现 HMAC-SHA256 签名,与后端 httpx.Sign 一致。
|
||||
//
|
||||
// ⚠️ 安全提示:四角色密钥放前端仅用于 MVP/演示。
|
||||
// 生产必须改为「控制台 BFF + 会话令牌」,密钥不下发浏览器。
|
||||
|
||||
const enc = new TextEncoder()
|
||||
|
||||
async function hmacSha256Base64(secret, message) {
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw', enc.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
|
||||
)
|
||||
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(message))
|
||||
let bin = ''
|
||||
for (const b of new Uint8Array(sig)) bin += String.fromCharCode(b)
|
||||
return btoa(bin)
|
||||
}
|
||||
|
||||
// 四角色演示密钥(与 api-svc 预置一致)
|
||||
export const ROLE_KEYS = {
|
||||
regulator: { apiKey: 'ak-regulator', apiSecret: 'sk-regulator', label: '监管主体' },
|
||||
reviewer: { apiKey: 'ak-reviewer', apiSecret: 'sk-reviewer', label: '审核/媒资' },
|
||||
cp: { apiKey: 'ak-cp', apiSecret: 'sk-cp', label: '内容提供商' },
|
||||
operator: { apiKey: 'ak-operator', apiSecret: 'sk-operator', label: '运营商' },
|
||||
}
|
||||
|
||||
async function request(role, method, path, body) {
|
||||
const cred = ROLE_KEYS[role]
|
||||
const signPath = '/api/v1' + path.split('?')[0]
|
||||
const sig = await hmacSha256Base64(cred.apiSecret, method + '\n' + signPath)
|
||||
const headers = { Authorization: `TCS ${cred.apiKey}:${sig}` }
|
||||
const opts = { method, headers }
|
||||
if (body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
opts.body = JSON.stringify(body)
|
||||
}
|
||||
const resp = await fetch('/api/v1' + path, opts)
|
||||
const data = await resp.json().catch(() => ({}))
|
||||
return { status: resp.status, ok: resp.status >= 200 && resp.status < 300, data }
|
||||
}
|
||||
|
||||
// 通用:按角色发起请求(供多角色工作台使用)
|
||||
export function call(role, method, path, body) {
|
||||
return request(role, method, path, body)
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// 全流程各步骤(标注发起角色)
|
||||
register: (body) => request('cp', 'POST', '/content/register', body),
|
||||
issue: (body) => request('regulator', 'POST', '/content/issue', body),
|
||||
csps: (body) => request('reviewer', 'POST', '/content/csps-result', body),
|
||||
ingest: (body) => request('reviewer', 'POST', '/content/ingest', body),
|
||||
publish: (body) => request('reviewer', 'POST', '/content/publish', body),
|
||||
inject: (body) => request('operator', 'POST', '/content/inject', body),
|
||||
// 监管功能
|
||||
verify: (maCode, fileHash) => request('regulator', 'POST', '/content/verify', { ma_code: maCode, file_sha256: fileHash }),
|
||||
mappings: (maCode) => request('regulator', 'GET', '/content/mappings?ma_code=' + encodeURIComponent(maCode)),
|
||||
takedown: (maCode, reason) => request('regulator', 'POST', '/content/takedown', { ma_code: maCode, reason }),
|
||||
takedownEpisode: (maCode, episode, reason) => request('regulator', 'POST', '/content/takedown-episode', { ma_code: maCode, episode, reason }),
|
||||
restore: (maCode) => request('regulator', 'POST', '/content/restore', { ma_code: maCode }),
|
||||
restoreEpisode: (maCode, episode) => request('regulator', 'POST', '/content/restore-episode', { ma_code: maCode, episode }),
|
||||
// 集级粒度(一剧一码 + 集级哈希)
|
||||
episodes: (maCode) => request('regulator', 'GET', '/content/episodes?ma_code=' + encodeURIComponent(maCode)),
|
||||
verifyEpisode: (maCode, episode, fileHash) => request('regulator', 'POST', '/content/verify-episode', { ma_code: maCode, episode, file_sha256: fileHash }),
|
||||
// 工作队列(多角色工作台)
|
||||
reviews: (role, status) => request(role, 'GET', '/content/reviews?status=' + status),
|
||||
list: (role, status) => request(role, 'GET', '/content/list?status=' + status),
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { ConfigProvider } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import App from './App.jsx'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<App />
|
||||
</ConfigProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
Reference in New Issue
Block a user