forked from wangziqi/gongxue-base
feat: add content asset security scanning
This commit is contained in:
11
README.md
11
README.md
@@ -19,7 +19,7 @@
|
||||
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细。
|
||||
- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets`;`daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。
|
||||
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。
|
||||
- `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;commerce worker 可补偿查询微信/支付宝支付和退款状态;assets worker 可复检托管资源元数据并自动下架异常资源;imports worker 可执行大批量导入;public-banks worker 可自动同步公共题库采纳副本;exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。
|
||||
- `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;commerce worker 可补偿查询微信/支付宝支付和退款状态;assets worker 可复检托管资源元数据、执行内置安全扫描并自动下架异常资源;imports worker 可执行大批量导入;public-banks worker 可自动同步公共题库采纳副本;exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。
|
||||
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。
|
||||
- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。
|
||||
- 资金对账和差错工单闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志;异常明细可创建差错工单,支持分配、开始处理、升级、解决、忽略、重开和事件留痕。工单只做财务审核闭环,不直接修改订单、支付、退款或权益。
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
- Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。
|
||||
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;资金对账已支持手工/API 账单导入比对和差错工单处理,微信/支付宝官方账单自动下载、异常订单运营台和真实生产账号联调还没接完。
|
||||
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览、资源访问事件、锁定资源 CDN 边界和资源复检 worker 已完成,杀毒扫描、视频动态水印、CDN 刷新和对象生命周期策略还没完成。
|
||||
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览、资源访问事件、锁定资源 CDN 边界、资源复检 worker 和内置 `metadata_rules` 安全扫描已完成。完整杀毒/内容安全 provider、视频动态水印、CDN 刷新和对象生命周期策略还没完成。
|
||||
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
|
||||
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
|
||||
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。
|
||||
@@ -305,7 +305,7 @@ API 身份上下文:
|
||||
|
||||
- 租户公开配置和主题配置不能存放密钥;主题 token 只能是后端允许的颜色、半径、安全 CSS 变量、图标 token 和公开素材引用。
|
||||
- 商户密钥、短信密钥、OAuth app secret 等必须进入 `app_private.tenant_secrets`,或后续生产 KMS/Vault。
|
||||
- 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL;学生端预览、锁定资料和视频会使用短 TTL。`members/svip/private` 外部 CDN URL 默认拒绝,除非显式登记 provider-managed 访问;所有上传签名、上传确认、下载/预览 granted/denied 都写入 `content_asset_access_events`。生产环境应定时运行 assets worker 复检对象元数据,异常资源会被标记 failed 并退回 draft。
|
||||
- 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL;学生端预览、锁定资料和视频会使用短 TTL。`members/svip/private` 外部 CDN URL 默认拒绝,除非显式登记 provider-managed 访问;所有上传签名、上传确认、下载/预览 granted/denied 都写入 `content_asset_access_events`。托管对象必须 `uploadStatus=verified` 且 `securityScanStatus=passed` 后才能发布、下载、预览或播放;生产环境应定时运行 assets worker 复检对象元数据并执行内置安全扫描,异常资源会被标记 failed/skipped 并退回 draft。
|
||||
- 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。
|
||||
- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册、分数线和视频 JSON/CSV/Excel 导入已走这套后台校验管线;大批量任务可提交 `executionMode=async`,由 imports worker 消费,前端只轮询 job 状态和展示 issues。
|
||||
- 题库导出必须由后端按权限生成,不允许前端直接读取数据库拼导出文件;不开启答案/解析时,顶层题目和复合题子题都必须脱敏;PDF/Word/每日一练 ZIP 只通过 exports worker 写入 `content_assets` 后再签名下载/预览。
|
||||
@@ -320,13 +320,14 @@ npx supabase db reset
|
||||
npm run check:api
|
||||
npm run check:worker
|
||||
npm run test:worker:assets
|
||||
npm run test:worker:exports
|
||||
npm run test:api
|
||||
npm run check:refactor
|
||||
npm run audit:runtime
|
||||
git diff --check
|
||||
```
|
||||
|
||||
结果:通过。`npm run test:api` 已覆盖资源访问事件、锁定 CDN 资源拒绝、provider-managed CDN 显式放行、学生短 TTL 下载/预览和访问记录查询。`npm run test:worker:assets` 已覆盖托管资源复检和异常资源自动下架。`npm run audit:runtime` 无 high/critical 漏洞;当前运行时依赖树仍有 `exceljs -> uuid` 的 moderate 级提示,修复需要破坏性降级 `exceljs`,后续应在导入 Excel 回归充分后单独处理。
|
||||
结果:通过。`npm run test:api` 覆盖资源访问事件、锁定 CDN 资源拒绝、provider-managed CDN 显式放行、学生短 TTL 下载/预览、访问记录查询和安全扫描门禁。`npm run test:worker:assets` 覆盖托管资源复检、内置安全扫描、扫描失败/跳过事件和异常资源自动下架。`npm run test:worker:exports` 覆盖导出 worker 生成可信资源并标记 `securityScanStatus=passed`。`npm run audit:runtime` 无 high/critical 漏洞;当前运行时依赖树仍有 `exceljs -> uuid` 的 moderate 级提示,修复需要破坏性降级 `exceljs`,后续应在导入 Excel 回归充分后单独处理。
|
||||
|
||||
注意:`apps/taro` 是静态构建工程,线上发布 `apps/taro/dist/**`,不发布 `node_modules`。Taro 4.2.0 当前构建工具链仍会触发 `npm run audit:taro:toolchain` 的上游 high/critical 提示,不能用 `npm audit fix --force` 降级到 Taro 3 破坏构建;上线验收时以 `audit:runtime`、构建产物、前端密钥检查和静态服务器配置为准,并持续跟进 Taro 官方修复。
|
||||
|
||||
@@ -336,6 +337,6 @@ git diff --check
|
||||
|
||||
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
|
||||
2. 继续补 Taro 前端:学生端视频/反馈/模考报告/订单收银台,租户后台写入表单/导入操作台/公共题库同步/角色模板 UI,平台后台租户详情/审计/自动计费增强,小程序兼容验证。
|
||||
3. 对象存储 CDN 防盗链、杀毒扫描、视频动态水印和生命周期策略。
|
||||
3. 对象存储真实 AV/内容安全 provider、CDN 防盗链、视频动态水印和生命周期策略。
|
||||
4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
|
||||
5. 真实 OAuth/短信/支付生产账号联调、微信/支付宝官方账单自动下载、异常订单运营台、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。
|
||||
|
||||
@@ -2,6 +2,9 @@ import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, optionalUserIdFrom, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
import { query, queryOne } from '../../core/db.js';
|
||||
import { signStorageDownload, type StorageProviderName } from '../storage/service.js';
|
||||
import {
|
||||
assertAssetSecurityScanPassed,
|
||||
} from '../tenant-content/assets.js';
|
||||
import {
|
||||
assetAccessTtl,
|
||||
assertCdnAccessAllowed,
|
||||
@@ -25,6 +28,7 @@ interface CatalogAssetRow {
|
||||
regionId: string | null;
|
||||
subjectId: string | null;
|
||||
uploadStatus: string;
|
||||
securityScanStatus: string;
|
||||
previewStatus: string;
|
||||
accessRules: Record<string, unknown>;
|
||||
metadata: Record<string, unknown>;
|
||||
@@ -122,6 +126,11 @@ function assertPublishedAsset(asset: CatalogAssetRow) {
|
||||
if (asset.objectKey && asset.uploadStatus !== 'verified') {
|
||||
throw new HttpError(409, 'Asset upload has not been verified', 'ASSET_UPLOAD_NOT_VERIFIED');
|
||||
}
|
||||
assertAssetSecurityScanPassed({
|
||||
provider: asset.storageProvider as StorageProviderName,
|
||||
objectKey: asset.objectKey,
|
||||
securityScanStatus: asset.securityScanStatus,
|
||||
});
|
||||
}
|
||||
|
||||
export async function assetsRoute(ctx: RequestContext) {
|
||||
@@ -174,6 +183,7 @@ export async function assetsRoute(ctx: RequestContext) {
|
||||
file_name as "fileName", preview_url as "previewUrl",
|
||||
mime_type as "mimeType", file_size_bytes as "fileSizeBytes",
|
||||
upload_status as "uploadStatus", preview_status as "previewStatus",
|
||||
security_scan_status as "securityScanStatus",
|
||||
visibility, region_id as "regionId", subject_id as "subjectId",
|
||||
category_id as "categoryId", node_id as "nodeId",
|
||||
entry_id as "entryId", content_node_id as "contentNodeId",
|
||||
@@ -204,7 +214,9 @@ export async function assetDownloadRoute(ctx: RequestContext) {
|
||||
cdn_url as "cdnUrl", preview_url as "previewUrl",
|
||||
preview_object_key as "previewObjectKey", mime_type as "mimeType",
|
||||
visibility, region_id as "regionId", subject_id as "subjectId",
|
||||
upload_status as "uploadStatus", preview_status as "previewStatus",
|
||||
upload_status as "uploadStatus",
|
||||
security_scan_status as "securityScanStatus",
|
||||
preview_status as "previewStatus",
|
||||
access_rules as "accessRules", metadata
|
||||
from public.content_assets
|
||||
where tenant_id = $1 and id = $2 and status = 'active'
|
||||
@@ -283,6 +295,7 @@ export async function assetDownloadRoute(ctx: RequestContext) {
|
||||
fileName: asset.fileName,
|
||||
previewUrl: asset.previewUrl,
|
||||
uploadStatus: asset.uploadStatus,
|
||||
securityScanStatus: asset.securityScanStatus,
|
||||
previewStatus: asset.previewStatus,
|
||||
visibility: asset.visibility,
|
||||
},
|
||||
@@ -305,7 +318,9 @@ export async function assetPreviewRoute(ctx: RequestContext) {
|
||||
cdn_url as "cdnUrl", preview_url as "previewUrl",
|
||||
preview_object_key as "previewObjectKey", mime_type as "mimeType",
|
||||
visibility, region_id as "regionId", subject_id as "subjectId",
|
||||
upload_status as "uploadStatus", preview_status as "previewStatus",
|
||||
upload_status as "uploadStatus",
|
||||
security_scan_status as "securityScanStatus",
|
||||
preview_status as "previewStatus",
|
||||
access_rules as "accessRules", metadata
|
||||
from public.content_assets
|
||||
where tenant_id = $1 and id = $2 and status = 'active'
|
||||
@@ -382,6 +397,7 @@ export async function assetPreviewRoute(ctx: RequestContext) {
|
||||
fileName: asset.fileName,
|
||||
previewUrl: asset.previewUrl,
|
||||
previewStatus: asset.previewStatus,
|
||||
securityScanStatus: asset.securityScanStatus,
|
||||
visibility: asset.visibility,
|
||||
},
|
||||
access,
|
||||
|
||||
@@ -31,6 +31,7 @@ const VISIBILITIES = ['public', 'tenant', 'members', 'svip', 'private'];
|
||||
const ASSET_STATUSES = ['draft', 'active', 'archived'];
|
||||
const UPLOAD_STATUSES = ['not_required', 'pending', 'verified', 'failed'];
|
||||
const PREVIEW_STATUSES = ['none', 'pending', 'ready', 'failed'];
|
||||
const SECURITY_SCAN_STATUSES = ['not_required', 'pending', 'scanning', 'passed', 'failed', 'skipped'];
|
||||
|
||||
interface AssetRow {
|
||||
id: string;
|
||||
@@ -50,6 +51,9 @@ interface AssetRow {
|
||||
visibility: string;
|
||||
status: string;
|
||||
uploadStatus: string;
|
||||
securityScanStatus: string;
|
||||
securityScanProvider: string | null;
|
||||
securityScanSummary: Record<string, unknown>;
|
||||
verifiedSizeBytes: number | null;
|
||||
verifiedChecksumSha256: string | null;
|
||||
previewStatus: string;
|
||||
@@ -122,6 +126,9 @@ async function existingAssetForUpsert(tenantId: string, id: string | null) {
|
||||
cdn_url as "cdnUrl", preview_url as "previewUrl", mime_type as "mimeType",
|
||||
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
|
||||
visibility, status, upload_status as "uploadStatus",
|
||||
security_scan_status as "securityScanStatus",
|
||||
security_scan_provider as "securityScanProvider",
|
||||
security_scan_summary as "securityScanSummary",
|
||||
verified_size_bytes as "verifiedSizeBytes",
|
||||
verified_checksum_sha256 as "verifiedChecksumSha256",
|
||||
preview_status as "previewStatus"
|
||||
@@ -156,14 +163,46 @@ function objectUploadStatus(input: {
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
function assertPublishableManagedObject(status: string, uploadStatus: string, provider: StorageProviderName, objectKey: string | null) {
|
||||
if (status === 'active' && objectKey && isManagedObjectProvider(provider) && uploadStatus !== 'verified') {
|
||||
function objectSecurityScanStatus(input: {
|
||||
existing: AssetRow | null;
|
||||
provider: StorageProviderName;
|
||||
objectKey: string | null;
|
||||
uploadStatus: string;
|
||||
}) {
|
||||
if (!input.objectKey || !isManagedObjectProvider(input.provider)) return 'not_required';
|
||||
if (input.uploadStatus !== 'verified') return 'pending';
|
||||
return input.existing?.securityScanStatus || 'pending';
|
||||
}
|
||||
|
||||
export function assertAssetSecurityScanPassed(input: {
|
||||
provider: StorageProviderName;
|
||||
objectKey: string | null;
|
||||
securityScanStatus?: string | null;
|
||||
}) {
|
||||
if (!input.objectKey || !isManagedObjectProvider(input.provider)) return;
|
||||
if (input.securityScanStatus === 'passed') return;
|
||||
if (input.securityScanStatus === 'failed') {
|
||||
throw new HttpError(409, 'Asset security scan failed', 'ASSET_SECURITY_SCAN_FAILED');
|
||||
}
|
||||
throw new HttpError(409, 'Asset security scan is required before access', 'ASSET_SECURITY_SCAN_REQUIRED');
|
||||
}
|
||||
|
||||
function assertPublishableManagedObject(
|
||||
status: string,
|
||||
uploadStatus: string,
|
||||
scanStatus: string,
|
||||
provider: StorageProviderName,
|
||||
objectKey: string | null,
|
||||
) {
|
||||
if (status !== 'active' || !objectKey || !isManagedObjectProvider(provider)) return;
|
||||
if (uploadStatus !== 'verified') {
|
||||
throw new HttpError(
|
||||
409,
|
||||
'Managed storage asset must be confirmed before publishing',
|
||||
'ASSET_UPLOAD_CONFIRMATION_REQUIRED',
|
||||
);
|
||||
}
|
||||
assertAssetSecurityScanPassed({ provider, objectKey, securityScanStatus: scanStatus });
|
||||
}
|
||||
|
||||
async function assertOptionalReference(tenantId: string, table: string, id: string | null, code: string) {
|
||||
@@ -219,6 +258,7 @@ export async function assetsAdminRoute(ctx: RequestContext) {
|
||||
const categoryId = stringParam(ctx, 'categoryId');
|
||||
const entryId = stringParam(ctx, 'entryId');
|
||||
const contentNodeId = stringParam(ctx, 'contentNodeId');
|
||||
const securityScanStatus = stringParam(ctx, 'securityScanStatus');
|
||||
|
||||
const params: unknown[] = [auth.tenantId];
|
||||
const filters = ['tenant_id = $1'];
|
||||
@@ -254,6 +294,13 @@ export async function assetsAdminRoute(ctx: RequestContext) {
|
||||
params.push(contentNodeId);
|
||||
filters.push(`content_node_id = $${params.length}`);
|
||||
}
|
||||
if (securityScanStatus) {
|
||||
if (!SECURITY_SCAN_STATUSES.includes(securityScanStatus)) {
|
||||
throw new HttpError(400, `Invalid securityScanStatus: ${securityScanStatus}`, 'INVALID_FIELD_VALUE');
|
||||
}
|
||||
params.push(securityScanStatus);
|
||||
filters.push(`security_scan_status = $${params.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
@@ -269,6 +316,10 @@ export async function assetsAdminRoute(ctx: RequestContext) {
|
||||
verified_checksum_sha256 as "verifiedChecksumSha256",
|
||||
verification_details as "verificationDetails",
|
||||
preview_object_key as "previewObjectKey", preview_status as "previewStatus",
|
||||
security_scan_status as "securityScanStatus",
|
||||
security_scanned_at as "securityScannedAt",
|
||||
security_scan_provider as "securityScanProvider",
|
||||
security_scan_summary as "securityScanSummary",
|
||||
security_flags as "securityFlags",
|
||||
visibility, is_public as "isPublic", region_id as "regionId",
|
||||
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
|
||||
@@ -288,6 +339,44 @@ export async function assetsAdminRoute(ctx: RequestContext) {
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function assetSecurityScanEventsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const assetId = stringParam(ctx, 'assetId');
|
||||
const scanStatus = stringParam(ctx, 'scanStatus');
|
||||
|
||||
const params: unknown[] = [auth.tenantId];
|
||||
const filters = ['tenant_id = $1'];
|
||||
if (assetId) {
|
||||
assertUuidParam(assetId, 'assetId');
|
||||
params.push(assetId);
|
||||
filters.push(`asset_id = $${params.length}::uuid`);
|
||||
}
|
||||
if (scanStatus) {
|
||||
if (!SECURITY_SCAN_STATUSES.includes(scanStatus) || scanStatus === 'not_required') {
|
||||
throw new HttpError(400, `Invalid scanStatus: ${scanStatus}`, 'INVALID_FIELD_VALUE');
|
||||
}
|
||||
params.push(scanStatus);
|
||||
filters.push(`scan_status = $${params.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, asset_id as "assetId", provider,
|
||||
scan_status as "scanStatus", risk_level as "riskLevel",
|
||||
issue_codes as "issueCodes", details, created_at as "createdAt"
|
||||
from public.content_asset_security_scan_events
|
||||
where ${filters.join(' and ')}
|
||||
order by created_at desc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function assetAccessEventsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
@@ -362,14 +451,22 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
objectKey: cleanObjectKey,
|
||||
cdnUrl,
|
||||
});
|
||||
const nextSecurityScanStatus = objectSecurityScanStatus({
|
||||
existing,
|
||||
provider: storageProvider,
|
||||
objectKey: cleanObjectKey,
|
||||
uploadStatus: nextUploadStatus,
|
||||
});
|
||||
const nextPreviewStatus = previewObjectKey || nullableString(body.previewUrl)
|
||||
? choice(body.previewStatus, PREVIEW_STATUSES, existing?.previewStatus || 'ready', 'previewStatus')
|
||||
: 'none';
|
||||
const statusFallback = cleanObjectKey && isManagedObjectProvider(storageProvider) && nextUploadStatus !== 'verified'
|
||||
const statusFallback = cleanObjectKey
|
||||
&& isManagedObjectProvider(storageProvider)
|
||||
&& (nextUploadStatus !== 'verified' || nextSecurityScanStatus !== 'passed')
|
||||
? 'draft'
|
||||
: 'active';
|
||||
const status = choice(body.status, ASSET_STATUSES, statusFallback, 'status');
|
||||
assertPublishableManagedObject(status, nextUploadStatus, storageProvider, cleanObjectKey);
|
||||
assertPublishableManagedObject(status, nextUploadStatus, nextSecurityScanStatus, storageProvider, cleanObjectKey);
|
||||
|
||||
if (status === 'active' && !cdnUrl && !objectKey) {
|
||||
throw new HttpError(400, 'Active asset requires cdnUrl or objectKey', 'ASSET_LOCATION_REQUIRED');
|
||||
@@ -388,7 +485,9 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
id, tenant_id, legacy_id, asset_key, asset_type, storage_provider,
|
||||
bucket, object_key, title, category, description, file_name, cdn_url,
|
||||
preview_url, mime_type, file_size_bytes, checksum_sha256,
|
||||
upload_status, preview_object_key, preview_status, visibility,
|
||||
upload_status, security_scan_status, security_scanned_at,
|
||||
security_scan_provider, security_scan_summary,
|
||||
preview_object_key, preview_status, visibility,
|
||||
is_public, region_id, subject_id, category_id, node_id, entry_id,
|
||||
content_node_id, status,
|
||||
sort_order, access_rules, metadata, created_by, updated_by, source
|
||||
@@ -397,10 +496,12 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10, $11, $12, $13,
|
||||
$14, $15, $16, $17, $18,
|
||||
$19, $20, $21,
|
||||
$22, $23::uuid, $24::uuid, $25::uuid, $26::uuid, $27::uuid,
|
||||
$28::uuid, $29,
|
||||
$30, $31::jsonb, $32::jsonb, $33, $33, $34
|
||||
$19, case when $19 in ('passed', 'not_required') then now() else null end,
|
||||
$20, $21::jsonb,
|
||||
$22, $23, $24,
|
||||
$25, $26::uuid, $27::uuid, $28::uuid, $29::uuid, $30::uuid,
|
||||
$31::uuid, $32,
|
||||
$33, $34::jsonb, $35::jsonb, $36, $36, $37
|
||||
)
|
||||
on conflict (id)
|
||||
do update set legacy_id = excluded.legacy_id,
|
||||
@@ -419,6 +520,31 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
file_size_bytes = excluded.file_size_bytes,
|
||||
checksum_sha256 = excluded.checksum_sha256,
|
||||
upload_status = excluded.upload_status,
|
||||
security_scan_status = excluded.security_scan_status,
|
||||
security_scanned_at = case
|
||||
when public.content_assets.storage_provider = excluded.storage_provider
|
||||
and coalesce(public.content_assets.bucket, '') = coalesce(excluded.bucket, '')
|
||||
and coalesce(public.content_assets.object_key, '') = coalesce(excluded.object_key, '')
|
||||
and excluded.security_scan_status = public.content_assets.security_scan_status
|
||||
then public.content_assets.security_scanned_at
|
||||
else excluded.security_scanned_at
|
||||
end,
|
||||
security_scan_provider = case
|
||||
when public.content_assets.storage_provider = excluded.storage_provider
|
||||
and coalesce(public.content_assets.bucket, '') = coalesce(excluded.bucket, '')
|
||||
and coalesce(public.content_assets.object_key, '') = coalesce(excluded.object_key, '')
|
||||
and excluded.security_scan_status = public.content_assets.security_scan_status
|
||||
then public.content_assets.security_scan_provider
|
||||
else excluded.security_scan_provider
|
||||
end,
|
||||
security_scan_summary = case
|
||||
when public.content_assets.storage_provider = excluded.storage_provider
|
||||
and coalesce(public.content_assets.bucket, '') = coalesce(excluded.bucket, '')
|
||||
and coalesce(public.content_assets.object_key, '') = coalesce(excluded.object_key, '')
|
||||
and excluded.security_scan_status = public.content_assets.security_scan_status
|
||||
then public.content_assets.security_scan_summary
|
||||
else excluded.security_scan_summary
|
||||
end,
|
||||
verified_at = case
|
||||
when public.content_assets.storage_provider = excluded.storage_provider
|
||||
and coalesce(public.content_assets.bucket, '') = coalesce(excluded.bucket, '')
|
||||
@@ -476,6 +602,10 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
verified_checksum_sha256 as "verifiedChecksumSha256",
|
||||
verification_details as "verificationDetails",
|
||||
preview_object_key as "previewObjectKey", preview_status as "previewStatus",
|
||||
security_scan_status as "securityScanStatus",
|
||||
security_scanned_at as "securityScannedAt",
|
||||
security_scan_provider as "securityScanProvider",
|
||||
security_scan_summary as "securityScanSummary",
|
||||
security_flags as "securityFlags",
|
||||
visibility, is_public as "isPublic", region_id as "regionId",
|
||||
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
|
||||
@@ -504,6 +634,15 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
fileSizeBytes,
|
||||
checksum,
|
||||
nextUploadStatus,
|
||||
nextSecurityScanStatus,
|
||||
nextSecurityScanStatus === 'not_required' ? null : existing?.securityScanProvider || 'metadata_rules',
|
||||
JSON.stringify(
|
||||
nextSecurityScanStatus === 'not_required'
|
||||
? {}
|
||||
: nextSecurityScanStatus === 'passed'
|
||||
? existing?.securityScanSummary || { riskLevel: 'none', issueCodes: [] }
|
||||
: { riskLevel: 'unknown', issueCodes: [], pendingReason: 'asset_upsert' },
|
||||
),
|
||||
previewObjectKey,
|
||||
nextPreviewStatus,
|
||||
visibility,
|
||||
@@ -550,6 +689,9 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
|
||||
cdn_url as "cdnUrl", preview_url as "previewUrl", mime_type as "mimeType",
|
||||
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
|
||||
visibility, status, upload_status as "uploadStatus",
|
||||
security_scan_status as "securityScanStatus",
|
||||
security_scan_provider as "securityScanProvider",
|
||||
security_scan_summary as "securityScanSummary",
|
||||
verified_size_bytes as "verifiedSizeBytes",
|
||||
verified_checksum_sha256 as "verifiedChecksumSha256",
|
||||
preview_status as "previewStatus"
|
||||
@@ -623,13 +765,23 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
|
||||
`
|
||||
update public.content_assets
|
||||
set upload_status = 'failed',
|
||||
security_scan_status = 'skipped',
|
||||
security_scanned_at = null,
|
||||
security_scan_provider = 'metadata_rules',
|
||||
security_scan_summary = $5::jsonb,
|
||||
verification_details = $3::jsonb,
|
||||
security_flags = jsonb_set(coalesce(security_flags, '{}'::jsonb), '{uploadVerificationFailed}', 'true'::jsonb, true),
|
||||
updated_by = $4,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, assetId, JSON.stringify(verificationDetails), auth.userId],
|
||||
[
|
||||
auth.tenantId,
|
||||
assetId,
|
||||
JSON.stringify(verificationDetails),
|
||||
auth.userId,
|
||||
JSON.stringify({ riskLevel: 'medium', issueCodes: issues, skippedReason: 'upload_verification_failed' }),
|
||||
],
|
||||
);
|
||||
await recordAssetAccessEvent({
|
||||
ctx,
|
||||
@@ -666,7 +818,12 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
|
||||
mime_type = coalesce(mime_type, $6),
|
||||
checksum_sha256 = coalesce(checksum_sha256, $5),
|
||||
verification_details = $7::jsonb,
|
||||
status = case when $8::boolean and status <> 'archived' then 'active' else status end,
|
||||
security_scan_status = 'pending',
|
||||
security_scanned_at = null,
|
||||
security_scan_provider = 'metadata_rules',
|
||||
security_scan_summary = '{"riskLevel":"unknown","issueCodes":[],"pendingReason":"upload_confirmed"}'::jsonb,
|
||||
security_flags = coalesce(security_flags, '{}'::jsonb) - 'assetSecurityScanFailed',
|
||||
status = case when status = 'archived' then status else 'draft' end,
|
||||
updated_by = $3,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
@@ -677,7 +834,11 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
|
||||
upload_status as "uploadStatus", verified_at as "verifiedAt",
|
||||
verified_by as "verifiedBy", verified_size_bytes as "verifiedSizeBytes",
|
||||
verified_checksum_sha256 as "verifiedChecksumSha256",
|
||||
verification_details as "verificationDetails"
|
||||
verification_details as "verificationDetails",
|
||||
security_scan_status as "securityScanStatus",
|
||||
security_scanned_at as "securityScannedAt",
|
||||
security_scan_provider as "securityScanProvider",
|
||||
security_scan_summary as "securityScanSummary"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
@@ -687,7 +848,6 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
|
||||
metadata.checksumSha256 ?? declaredChecksum,
|
||||
observedMime || expectedMime,
|
||||
JSON.stringify(verificationDetails),
|
||||
publish,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -720,7 +880,16 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
|
||||
},
|
||||
});
|
||||
|
||||
return { item, metadata, verification: verificationDetails };
|
||||
return {
|
||||
item,
|
||||
metadata,
|
||||
verification: verificationDetails,
|
||||
securityScan: {
|
||||
status: 'pending',
|
||||
provider: 'metadata_rules',
|
||||
requiredBeforePublish: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function signAssetUploadRoute(ctx: RequestContext) {
|
||||
@@ -811,6 +980,9 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
|
||||
preview_url as "previewUrl", mime_type as "mimeType",
|
||||
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
|
||||
visibility, status, upload_status as "uploadStatus",
|
||||
security_scan_status as "securityScanStatus",
|
||||
security_scan_provider as "securityScanProvider",
|
||||
security_scan_summary as "securityScanSummary",
|
||||
verified_size_bytes as "verifiedSizeBytes",
|
||||
verified_checksum_sha256 as "verifiedChecksumSha256",
|
||||
preview_status as "previewStatus",
|
||||
@@ -844,6 +1016,11 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
|
||||
metadata: asset.metadata,
|
||||
accessRules: asset.accessRules,
|
||||
});
|
||||
assertAssetSecurityScanPassed({
|
||||
provider: asset.storageProvider as StorageProviderName,
|
||||
objectKey: asset.objectKey,
|
||||
securityScanStatus: asset.securityScanStatus,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordDeniedAdminAssetAccess(ctx, auth, asset, 'admin_download', error);
|
||||
throw error;
|
||||
@@ -898,6 +1075,9 @@ export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
|
||||
cdn_url as "cdnUrl", preview_url as "previewUrl", mime_type as "mimeType",
|
||||
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
|
||||
visibility, status, upload_status as "uploadStatus",
|
||||
security_scan_status as "securityScanStatus",
|
||||
security_scan_provider as "securityScanProvider",
|
||||
security_scan_summary as "securityScanSummary",
|
||||
verified_size_bytes as "verifiedSizeBytes",
|
||||
verified_checksum_sha256 as "verifiedChecksumSha256",
|
||||
preview_status as "previewStatus",
|
||||
@@ -936,6 +1116,11 @@ export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
|
||||
metadata: asset.metadata,
|
||||
accessRules: asset.accessRules,
|
||||
});
|
||||
assertAssetSecurityScanPassed({
|
||||
provider: asset.storageProvider as StorageProviderName,
|
||||
objectKey,
|
||||
securityScanStatus: asset.securityScanStatus,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordDeniedAdminAssetAccess(ctx, auth, asset, 'admin_preview', error);
|
||||
throw error;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import {
|
||||
assetAccessEventsAdminRoute,
|
||||
assetSecurityScanEventsAdminRoute,
|
||||
assetsAdminRoute,
|
||||
confirmAssetUploadRoute,
|
||||
signAssetDownloadAdminRoute,
|
||||
@@ -104,6 +105,7 @@ export const tenantContentRoutes: RouteDefinition[] = [
|
||||
['PATCH', '/api/tenant-content/questions', updateQuestionRoute],
|
||||
['GET', '/api/tenant-content/assets', assetsAdminRoute],
|
||||
['GET', '/api/tenant-content/assets/access-events', assetAccessEventsAdminRoute],
|
||||
['GET', '/api/tenant-content/assets/security-scan-events', assetSecurityScanEventsAdminRoute],
|
||||
['PUT', '/api/tenant-content/assets', upsertAssetRoute],
|
||||
['POST', '/api/tenant-content/assets/sign-upload', signAssetUploadRoute],
|
||||
['POST', '/api/tenant-content/assets/confirm-upload', confirmAssetUploadRoute],
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
assertCdnAccessAllowed,
|
||||
signedAssetFingerprint,
|
||||
} from '../storage/asset-access.js';
|
||||
import { assertAssetSecurityScanPassed } from '../tenant-content/assets.js';
|
||||
|
||||
interface QuestionVideoRow {
|
||||
questionId: string;
|
||||
@@ -60,6 +61,7 @@ interface VideoPlaybackRow {
|
||||
assetType: string | null;
|
||||
assetVisibility: string | null;
|
||||
assetUploadStatus: string | null;
|
||||
assetSecurityScanStatus: string | null;
|
||||
assetAccessRules: Record<string, unknown> | null;
|
||||
assetMetadata: Record<string, unknown> | null;
|
||||
}
|
||||
@@ -163,6 +165,11 @@ async function signVideoPlayback(tenantId: string, video: VideoPlaybackRow) {
|
||||
if (video.objectKey && video.assetUploadStatus !== 'verified') {
|
||||
throw new HttpError(409, 'Video asset upload has not been verified', 'VIDEO_ASSET_UPLOAD_NOT_VERIFIED');
|
||||
}
|
||||
assertAssetSecurityScanPassed({
|
||||
provider: video.storageProvider as StorageProviderName,
|
||||
objectKey: video.objectKey,
|
||||
securityScanStatus: video.assetSecurityScanStatus,
|
||||
});
|
||||
assertCdnAccessAllowed({
|
||||
assetId: video.assetId,
|
||||
visibility: video.assetVisibility || 'svip',
|
||||
@@ -307,6 +314,7 @@ export async function videoPlaybackRoute(ctx: RequestContext) {
|
||||
a.title as "assetTitle", a.status as "assetStatus",
|
||||
a.asset_type as "assetType", a.visibility as "assetVisibility",
|
||||
a.upload_status as "assetUploadStatus",
|
||||
a.security_scan_status as "assetSecurityScanStatus",
|
||||
a.access_rules as "assetAccessRules", a.metadata as "assetMetadata"
|
||||
from public.video_explanations v
|
||||
left join public.content_assets a on a.tenant_id = v.tenant_id and a.id = v.asset_id
|
||||
|
||||
@@ -11,15 +11,19 @@ interface AssetCandidate {
|
||||
title: string | null;
|
||||
status: string;
|
||||
uploadStatus: string;
|
||||
assetType: string;
|
||||
storageProvider: StorageProviderName;
|
||||
bucket: string | null;
|
||||
objectKey: string | null;
|
||||
fileName: string | null;
|
||||
mimeType: string | null;
|
||||
fileSizeBytes: number | string | null;
|
||||
checksumSha256: string | null;
|
||||
verifiedSizeBytes: number | string | null;
|
||||
verifiedChecksumSha256: string | null;
|
||||
verificationDetails: Record<string, unknown>;
|
||||
securityScanStatus: string;
|
||||
securityScanSummary: Record<string, unknown>;
|
||||
securityFlags: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -45,6 +49,14 @@ interface AssetWorkerResult {
|
||||
errors: number;
|
||||
}
|
||||
|
||||
interface SecurityScanResult {
|
||||
status: 'passed' | 'failed' | 'skipped';
|
||||
provider: string;
|
||||
riskLevel: 'none' | 'low' | 'medium' | 'high' | 'critical';
|
||||
issueCodes: string[];
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
|
||||
class AssetWorkerError extends Error {
|
||||
readonly code: string;
|
||||
|
||||
@@ -56,6 +68,30 @@ class AssetWorkerError extends Error {
|
||||
|
||||
const UPLOADABLE_PROVIDERS = new Set<StorageProviderName>(['local_dev', 'supabase_storage', 'aliyun_oss', 'tencent_cos']);
|
||||
const SAFE_OBJECT_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._~!$&'()+,;=@/-]{0,1023}$/;
|
||||
const EXTENSION_MIME_HINTS: Record<string, string[]> = {
|
||||
pdf: ['application/pdf'],
|
||||
png: ['image/png'],
|
||||
jpg: ['image/jpeg'],
|
||||
jpeg: ['image/jpeg'],
|
||||
gif: ['image/gif'],
|
||||
webp: ['image/webp'],
|
||||
svg: ['image/svg+xml'],
|
||||
mp4: ['video/mp4'],
|
||||
mov: ['video/quicktime'],
|
||||
mp3: ['audio/mpeg'],
|
||||
wav: ['audio/wav', 'audio/x-wav'],
|
||||
doc: ['application/msword'],
|
||||
docx: ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
||||
xls: ['application/vnd.ms-excel'],
|
||||
xlsx: ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
||||
ppt: ['application/vnd.ms-powerpoint'],
|
||||
pptx: ['application/vnd.openxmlformats-officedocument.presentationml.presentation'],
|
||||
zip: ['application/zip', 'application/x-zip-compressed'],
|
||||
json: ['application/json'],
|
||||
txt: ['text/plain'],
|
||||
md: ['text/markdown', 'text/plain'],
|
||||
csv: ['text/csv', 'text/plain'],
|
||||
};
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
@@ -445,6 +481,110 @@ function compareAssetMetadata(asset: AssetCandidate, metadata: StorageObjectMeta
|
||||
};
|
||||
}
|
||||
|
||||
function extensionFromName(value: string | null) {
|
||||
if (!value) return '';
|
||||
const withoutQuery = value.split('?')[0]?.split('#')[0] || value;
|
||||
const last = withoutQuery.split('/').pop() || withoutQuery;
|
||||
const dot = last.lastIndexOf('.');
|
||||
return dot >= 0 ? last.slice(dot + 1).trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
function metadataBoolean(value: unknown) {
|
||||
return value === true || value === 'true' || value === 1 || value === '1';
|
||||
}
|
||||
|
||||
function securityScanRules(asset: AssetCandidate, metadata: StorageObjectMetadata): SecurityScanResult {
|
||||
const issueCodes: string[] = [];
|
||||
const details: Record<string, unknown> = {
|
||||
observed: {
|
||||
fileSizeBytes: metadata.sizeBytes,
|
||||
mimeType: canonicalMime(metadata.mimeType),
|
||||
checksumSha256: metadata.checksumSha256,
|
||||
verificationSource: metadata.verificationSource,
|
||||
},
|
||||
declared: {
|
||||
assetType: asset.assetType,
|
||||
fileName: asset.fileName,
|
||||
objectKey: asset.objectKey,
|
||||
mimeType: canonicalMime(asset.mimeType),
|
||||
fileSizeBytes: validateFileSize(asset.fileSizeBytes),
|
||||
checksumSha256: asset.checksumSha256,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
if (asset.objectKey) validateObjectKey(asset.tenantId, asset.objectKey);
|
||||
} catch (error) {
|
||||
issueCodes.push(eventError(error).code);
|
||||
}
|
||||
|
||||
try {
|
||||
validateFileSize(metadata.sizeBytes ?? asset.verifiedSizeBytes ?? asset.fileSizeBytes);
|
||||
} catch (error) {
|
||||
issueCodes.push(eventError(error).code);
|
||||
}
|
||||
|
||||
const declaredMime = canonicalMime(asset.mimeType);
|
||||
const observedMime = canonicalMime(metadata.mimeType);
|
||||
const effectiveMime = observedMime || declaredMime;
|
||||
try {
|
||||
validateMimeType(effectiveMime);
|
||||
} catch (error) {
|
||||
issueCodes.push(eventError(error).code);
|
||||
}
|
||||
|
||||
const fileExtension = extensionFromName(asset.fileName) || extensionFromName(asset.objectKey);
|
||||
details.fileExtension = fileExtension || null;
|
||||
if (fileExtension && effectiveMime && EXTENSION_MIME_HINTS[fileExtension]) {
|
||||
const allowedForExtension = EXTENSION_MIME_HINTS[fileExtension];
|
||||
if (!allowedForExtension.includes(effectiveMime)) {
|
||||
issueCodes.push('file_extension_mime_mismatch');
|
||||
details.extensionMimeExpected = allowedForExtension;
|
||||
}
|
||||
}
|
||||
|
||||
const metadataFlags = objectValue(asset.securityScanSummary);
|
||||
const assetMetadata = objectValue(asset.verificationDetails?.metadata);
|
||||
const explicitFlag = [
|
||||
metadataFlags.forceFail,
|
||||
metadataFlags.securityScanForceFail,
|
||||
assetMetadata.securityScanForceFail,
|
||||
assetMetadata.forceSecurityScanFail,
|
||||
].some(metadataBoolean);
|
||||
if (explicitFlag) {
|
||||
issueCodes.push('security_scan_forced_failure');
|
||||
}
|
||||
|
||||
const forcedIssue = typeof metadataFlags.forceIssueCode === 'string'
|
||||
? metadataFlags.forceIssueCode
|
||||
: typeof assetMetadata.forceIssueCode === 'string'
|
||||
? assetMetadata.forceIssueCode
|
||||
: '';
|
||||
if (forcedIssue) issueCodes.push(forcedIssue);
|
||||
|
||||
const uniqueIssues = Array.from(new Set(issueCodes));
|
||||
const highRiskIssues = new Set([
|
||||
'INVALID_OBJECT_KEY',
|
||||
'OBJECT_KEY_TENANT_PREFIX_REQUIRED',
|
||||
'MIME_TYPE_NOT_ALLOWED',
|
||||
'FILE_TOO_LARGE',
|
||||
'security_scan_forced_failure',
|
||||
]);
|
||||
const riskLevel = uniqueIssues.some(code => highRiskIssues.has(code))
|
||||
? 'high'
|
||||
: uniqueIssues.length
|
||||
? 'medium'
|
||||
: 'none';
|
||||
|
||||
return {
|
||||
status: uniqueIssues.length ? 'failed' : 'passed',
|
||||
provider: 'metadata_rules',
|
||||
riskLevel,
|
||||
issueCodes: uniqueIssues,
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
function eventError(error: unknown) {
|
||||
if (error instanceof AssetWorkerError) {
|
||||
return { code: error.code, message: error.message };
|
||||
@@ -483,6 +623,30 @@ async function recordAudit(
|
||||
);
|
||||
}
|
||||
|
||||
async function recordSecurityScanEvent(
|
||||
client: pg.PoolClient,
|
||||
asset: AssetCandidate,
|
||||
scan: SecurityScanResult,
|
||||
) {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.content_asset_security_scan_events (
|
||||
tenant_id, asset_id, provider, scan_status, risk_level, issue_codes, details
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6::text[], $7::jsonb)
|
||||
`,
|
||||
[
|
||||
asset.tenantId,
|
||||
asset.id,
|
||||
scan.provider,
|
||||
scan.status,
|
||||
scan.riskLevel,
|
||||
scan.issueCodes,
|
||||
JSON.stringify(scan.details),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async function claimAssetCandidates(client: pg.PoolClient, limit: number, claimId: string) {
|
||||
const result = await client.query<AssetCandidate>(
|
||||
`
|
||||
@@ -496,6 +660,12 @@ async function claimAssetCandidates(client: pg.PoolClient, limit: number, claimI
|
||||
(upload_status = 'pending' and updated_at <= now() - ($2::int * interval '1 second'))
|
||||
or (
|
||||
upload_status = 'verified'
|
||||
and security_scan_status = 'pending'
|
||||
and updated_at <= now() - ($2::int * interval '1 second')
|
||||
)
|
||||
or (
|
||||
upload_status = 'verified'
|
||||
and security_scan_status in ('passed', 'not_required')
|
||||
and coalesce((verification_details #>> '{assetWorker,lastCheckedAt}')::timestamptz, verified_at, updated_at, created_at)
|
||||
<= now() - ($3::int * interval '1 second')
|
||||
)
|
||||
@@ -514,6 +684,10 @@ async function claimAssetCandidates(client: pg.PoolClient, limit: number, claimI
|
||||
coalesce(ca.verification_details->'assetWorker', '{}'::jsonb) || $6::jsonb,
|
||||
true
|
||||
),
|
||||
security_scan_status = case
|
||||
when ca.upload_status = 'verified' and ca.security_scan_status = 'pending' then 'scanning'
|
||||
else ca.security_scan_status
|
||||
end,
|
||||
updated_at = now()
|
||||
from candidates
|
||||
where ca.id = candidates.id
|
||||
@@ -522,15 +696,19 @@ async function claimAssetCandidates(client: pg.PoolClient, limit: number, claimI
|
||||
ca.title,
|
||||
ca.status,
|
||||
ca.upload_status as "uploadStatus",
|
||||
ca.asset_type as "assetType",
|
||||
ca.storage_provider as "storageProvider",
|
||||
ca.bucket,
|
||||
ca.object_key as "objectKey",
|
||||
ca.file_name as "fileName",
|
||||
ca.mime_type as "mimeType",
|
||||
ca.file_size_bytes as "fileSizeBytes",
|
||||
ca.checksum_sha256 as "checksumSha256",
|
||||
ca.verified_size_bytes as "verifiedSizeBytes",
|
||||
ca.verified_checksum_sha256 as "verifiedChecksumSha256",
|
||||
ca.verification_details as "verificationDetails",
|
||||
ca.security_scan_status as "securityScanStatus",
|
||||
ca.security_scan_summary as "securityScanSummary",
|
||||
ca.security_flags as "securityFlags"
|
||||
`,
|
||||
[
|
||||
@@ -594,6 +772,95 @@ async function markAssetVerified(client: pg.PoolClient, asset: AssetCandidate, m
|
||||
});
|
||||
}
|
||||
|
||||
async function markAssetSecurityScanPassed(
|
||||
client: pg.PoolClient,
|
||||
asset: AssetCandidate,
|
||||
scan: SecurityScanResult,
|
||||
) {
|
||||
await client.query(
|
||||
`
|
||||
update public.content_assets
|
||||
set security_scan_status = 'passed',
|
||||
security_scanned_at = now(),
|
||||
security_scan_provider = $3,
|
||||
security_scan_summary = $4::jsonb,
|
||||
security_flags = coalesce(security_flags, '{}'::jsonb) - 'assetSecurityScanFailed',
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[
|
||||
asset.tenantId,
|
||||
asset.id,
|
||||
scan.provider,
|
||||
JSON.stringify({
|
||||
riskLevel: scan.riskLevel,
|
||||
issueCodes: scan.issueCodes,
|
||||
provider: scan.provider,
|
||||
scannedAt: nowIso(),
|
||||
details: scan.details,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
await recordSecurityScanEvent(client, asset, scan);
|
||||
await recordAudit(client, {
|
||||
tenantId: asset.tenantId,
|
||||
action: 'content.asset.security_scan_passed',
|
||||
targetId: asset.id,
|
||||
details: {
|
||||
provider: scan.provider,
|
||||
riskLevel: scan.riskLevel,
|
||||
issueCodes: scan.issueCodes,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function markAssetSecurityScanFailed(
|
||||
client: pg.PoolClient,
|
||||
asset: AssetCandidate,
|
||||
scan: SecurityScanResult,
|
||||
) {
|
||||
await client.query(
|
||||
`
|
||||
update public.content_assets
|
||||
set security_scan_status = 'failed',
|
||||
security_scanned_at = now(),
|
||||
security_scan_provider = $3,
|
||||
security_scan_summary = $4::jsonb,
|
||||
status = case when status = 'active' then 'draft' else status end,
|
||||
security_flags = coalesce(security_flags, '{}'::jsonb)
|
||||
|| jsonb_build_object('assetSecurityScanFailed', true, 'assetSecurityScanFailedAt', now()),
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[
|
||||
asset.tenantId,
|
||||
asset.id,
|
||||
scan.provider,
|
||||
JSON.stringify({
|
||||
riskLevel: scan.riskLevel,
|
||||
issueCodes: scan.issueCodes,
|
||||
provider: scan.provider,
|
||||
scannedAt: nowIso(),
|
||||
details: scan.details,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
await recordSecurityScanEvent(client, asset, scan);
|
||||
await recordAudit(client, {
|
||||
tenantId: asset.tenantId,
|
||||
action: 'content.asset.security_scan_failed',
|
||||
targetId: asset.id,
|
||||
details: {
|
||||
provider: scan.provider,
|
||||
riskLevel: scan.riskLevel,
|
||||
issueCodes: scan.issueCodes,
|
||||
unpublished: asset.status === 'active',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function markAssetFailed(
|
||||
client: pg.PoolClient,
|
||||
asset: AssetCandidate,
|
||||
@@ -601,10 +868,11 @@ async function markAssetFailed(
|
||||
metadata: StorageObjectMetadata | null,
|
||||
error: Record<string, unknown> | null,
|
||||
) {
|
||||
const uniqueIssues = Array.from(new Set(issues));
|
||||
const details = mergeVerificationDetails(asset, {
|
||||
lastCheckedAt: nowIso(),
|
||||
lastResult: 'failed',
|
||||
issues,
|
||||
issues: uniqueIssues,
|
||||
observed: metadata
|
||||
? {
|
||||
fileSizeBytes: metadata.sizeBytes,
|
||||
@@ -617,11 +885,33 @@ async function markAssetFailed(
|
||||
: null,
|
||||
error,
|
||||
});
|
||||
const scanSkipped: SecurityScanResult = {
|
||||
status: 'skipped',
|
||||
provider: 'metadata_rules',
|
||||
riskLevel: 'medium',
|
||||
issueCodes: uniqueIssues,
|
||||
details: {
|
||||
skippedReason: 'upload_recheck_failed',
|
||||
observed: metadata
|
||||
? {
|
||||
fileSizeBytes: metadata.sizeBytes,
|
||||
mimeType: canonicalMime(metadata.mimeType),
|
||||
checksumSha256: metadata.checksumSha256,
|
||||
verificationSource: metadata.verificationSource,
|
||||
}
|
||||
: null,
|
||||
error,
|
||||
},
|
||||
};
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.content_assets
|
||||
set upload_status = 'failed',
|
||||
security_scan_status = 'skipped',
|
||||
security_scanned_at = null,
|
||||
security_scan_provider = $4,
|
||||
security_scan_summary = $5::jsonb,
|
||||
status = case when status = 'active' then 'draft' else status end,
|
||||
verification_details = $3::jsonb,
|
||||
security_flags = coalesce(security_flags, '{}'::jsonb)
|
||||
@@ -629,9 +919,22 @@ async function markAssetFailed(
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[asset.tenantId, asset.id, JSON.stringify(details)],
|
||||
[
|
||||
asset.tenantId,
|
||||
asset.id,
|
||||
JSON.stringify(details),
|
||||
scanSkipped.provider,
|
||||
JSON.stringify({
|
||||
riskLevel: scanSkipped.riskLevel,
|
||||
issueCodes: scanSkipped.issueCodes,
|
||||
provider: scanSkipped.provider,
|
||||
skippedReason: 'upload_recheck_failed',
|
||||
details: scanSkipped.details,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
await recordSecurityScanEvent(client, asset, scanSkipped);
|
||||
await recordAudit(client, {
|
||||
tenantId: asset.tenantId,
|
||||
action: 'content.asset.recheck_failed',
|
||||
@@ -641,7 +944,7 @@ async function markAssetFailed(
|
||||
bucket: asset.bucket,
|
||||
objectKey: asset.objectKey,
|
||||
result: 'failed',
|
||||
issues,
|
||||
issues: uniqueIssues,
|
||||
error,
|
||||
unpublished: asset.status === 'active',
|
||||
},
|
||||
@@ -653,6 +956,7 @@ async function processAsset(asset: AssetCandidate) {
|
||||
try {
|
||||
const metadata = await headStorageObject(asset);
|
||||
const comparison = compareAssetMetadata(asset, metadata);
|
||||
const scan = comparison.issues.length ? null : securityScanRules(asset, metadata);
|
||||
await client.query('begin');
|
||||
if (comparison.issues.length) {
|
||||
await markAssetFailed(client, asset, comparison.issues, metadata, null);
|
||||
@@ -660,6 +964,14 @@ async function processAsset(asset: AssetCandidate) {
|
||||
return 'failed';
|
||||
}
|
||||
await markAssetVerified(client, asset, metadata, comparison);
|
||||
if (scan?.status === 'failed') {
|
||||
await markAssetSecurityScanFailed(client, asset, scan);
|
||||
await client.query('commit');
|
||||
return 'failed';
|
||||
}
|
||||
if (scan?.status === 'passed') {
|
||||
await markAssetSecurityScanPassed(client, asset, scan);
|
||||
}
|
||||
await client.query('commit');
|
||||
return 'verified';
|
||||
} catch (error) {
|
||||
|
||||
@@ -208,6 +208,8 @@ async function insertExportAsset(
|
||||
asset_type, storage_provider, bucket, object_key, mime_type,
|
||||
file_size_bytes, checksum_sha256, upload_status, verified_at,
|
||||
verified_size_bytes, verified_checksum_sha256, verification_details,
|
||||
security_scan_status, security_scanned_at, security_scan_provider,
|
||||
security_scan_summary,
|
||||
preview_status, visibility, is_public, status, access_rules,
|
||||
metadata, created_by, updated_by, source
|
||||
)
|
||||
@@ -216,8 +218,10 @@ async function insertExportAsset(
|
||||
$7, $8, $9, $10, $11,
|
||||
$12, $13, 'verified', now(),
|
||||
$12, $13, $14::jsonb,
|
||||
$15, $16, $17, $18, $19::jsonb,
|
||||
$20::jsonb, $21, $21, 'content_export_worker'
|
||||
'passed', now(), 'trusted_export_worker',
|
||||
$15::jsonb,
|
||||
$16, $17, $18, $19, $20::jsonb,
|
||||
$21::jsonb, $22, $22, 'content_export_worker'
|
||||
)
|
||||
returning id
|
||||
`,
|
||||
@@ -242,6 +246,12 @@ async function insertExportAsset(
|
||||
checkedAt: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
riskLevel: 'none',
|
||||
issueCodes: [],
|
||||
provider: 'trusted_export_worker',
|
||||
generatedBy: 'content_export_worker',
|
||||
}),
|
||||
input.extension === 'pdf' ? 'ready' : 'none',
|
||||
visibility,
|
||||
visibility === 'public',
|
||||
|
||||
@@ -98,9 +98,10 @@
|
||||
| 上传后对象校验 | 可联调 | `/api/tenant-content/assets/confirm-upload`;托管对象必须 verified 后才能发布/下载 |
|
||||
| PDF/图片预览签名 | 可联调 | `/api/catalog/assets/preview`、`/api/tenant-content/assets/sign-preview`;使用 inline 短期签名,学生预览默认短 TTL |
|
||||
| 托管资源 worker 复检 | 可联调 | `apps/worker --job assets` 定期复检 pending/verified 对象元数据;异常资源会标记 failed 并从 active 退回 draft,写入审计和 `security_flags` |
|
||||
| 内容资源安全扫描 | 可联调 | `content_assets.security_scan_status` + `content_asset_security_scan_events`;托管对象确认上传后进入 `pending/scanning`,内置 `metadata_rules` 通过后才可发布、下载、预览或视频播放;失败会下架并写审计 |
|
||||
| 资源访问审计 | 可联调 | `content_asset_access_events` + `GET /api/tenant-content/assets/access-events`;记录上传签名/确认、学生下载/预览、后台下载/预览的 granted/denied、TTL、签名模式、IP 和 UA |
|
||||
| CDN 访问边界 | 可联调 | `members/svip/private` 外部 CDN URL 默认拒绝,必须显式 `metadata.providerManagedAccess=true` 或 `cdnAccessMode=signed_by_provider`;视频绑定资源也复用该规则 |
|
||||
| 深度防盗链/水印/杀毒 | 待补齐 | 商用上线前继续补动态水印、安全扫描、CDN 刷新和对象生命周期策略 |
|
||||
| 深度防盗链/水印/杀毒 | 待补齐 | 商用上线前继续补真实 AV/内容安全 provider、动态水印、CDN 刷新和对象生命周期策略;内置 metadata 规则扫描已完成 |
|
||||
|
||||
## 订单、会员、营销
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 |
|
||||
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入 | 复杂筛选、AI 择校上下文 |
|
||||
| 视频解析 | 可联调 | 单题视频、批量查询、后台视频绑定、JSON/CSV/Excel 导入、会员播放权限、播放次数扣减、签名 URL 和播放日志 | 深度防盗链、动态水印、播放统计 |
|
||||
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、assets worker 复检异常下架、题库导出 PDF/Word/每日一练 ZIP 可生成 `content_assets` 并走签名下载/预览 | CDN 防盗链、杀毒扫描、视频水印、资料前端操作体验 |
|
||||
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、访问审计、assets worker 复检和内置安全扫描、题库导出 PDF/Word/每日一练 ZIP 可生成可信 `content_assets` 并走签名下载/预览 | CDN 防盗链、真实 AV/内容安全 provider、视频水印、资料前端操作体验 |
|
||||
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放、资金对账批次/明细/异常查询 API、对账差错工单和事件轨迹 | 微信/支付宝官方账单自动下载、财务复核报表和异常订单运营台 |
|
||||
| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、OAuth 配置表 | 真实生产账号和回调域名联调 |
|
||||
| 销售/代理/CRM | 可联调 | 邀请码、首绑保护、团队关系、销售统计、CRM 配置/队列、钉钉/飞书/企微 worker、分佣规则、成员分佣比例、订单/激活码归因、结算生成、审核和打款状态;Taro 营销中心已接第一版操作台 | 小程序码真实生成、CRM 分配策略、结算导出、真实打款、凭证、财务复核和销售转化看板 |
|
||||
@@ -76,7 +76,7 @@
|
||||
### P0:上云测试和前端主链路前必须处理
|
||||
|
||||
- 生产鉴权:API 已支持 Supabase Auth JWT;继续做真实云端 Auth/JWKS 回归、RLS 深测,并在生产关闭 `x-user-id` 与 `x-platform-admin-key` 兼容入口。
|
||||
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名、assets worker 复检和题库导出 PDF/Word/每日一练 ZIP worker 已完成,继续补视频播放防盗链、杀毒扫描和动态水印。
|
||||
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名、assets worker 复检、内置安全扫描和题库导出 PDF/Word/每日一练 ZIP worker 已完成,继续补视频播放防盗链、真实 AV/内容安全 provider 和动态水印。
|
||||
- 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。
|
||||
- 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
|
||||
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版,平台后台关键写操作第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列和分佣结算操作台第一版;下一步补公式图片混排、更细数据范围 UI、平台后台审计增强和小程序兼容验证。
|
||||
|
||||
@@ -259,14 +259,14 @@ GET /api/tenant-admin/audit-logs
|
||||
- 学生批量导入、批量分班、学生状态、备注和跟进任务都使用独立权限点;教师默认可为范围内学生写备注和跟进任务,但不能批量导入、禁用学生或放大可见班级。
|
||||
- 销售/代理客资采用首绑保护:普通扫码/分享事件不会覆盖已有归属,只有具备 `referral:write` 的租户成员可手动强制补绑。
|
||||
- CRM 当前完成配置、密钥入私密表、客资入队、队列查询和 `apps/worker` 消费;worker 支持 generic webhook、钉钉、飞书、企业微信机器人消息体/签名、失败重试和日志。
|
||||
- 内容资源当前完成台账、租户后台维护、学生端 SVIP 下载权限,以及 `local_dev`、阿里云 OSS、腾讯 COS、Supabase Storage 的上传/下载签名 provider;上传确认和 assets worker 已支持对象元数据校验/复检。PDF 预览渲染、防盗链、水印和安全扫描仍需继续补。
|
||||
- 内容资源当前完成台账、租户后台维护、学生端 SVIP 下载权限,以及 `local_dev`、阿里云 OSS、腾讯 COS、Supabase Storage 的上传/下载签名 provider;上传确认、访问审计、assets worker 对象元数据校验/复检和内置 `metadata_rules` 安全扫描已完成。PDF 深度预览体验、防盗链、水印和真实 AV/内容安全 provider 仍需继续补。
|
||||
- 题库内容导航当前以 `content_entries/content_nodes` 为主模型,可表达“入口 -> 多级分类 -> 院校/专业/学科/销售意向标记”;题目集合和练习方式由 `question_collections/practice_blueprints` 管理,练习 session 会保存当次题目 ID 快照。
|
||||
- 练习访问控制由 `content_entries/content_nodes/question_collections/practice_blueprints` 的 `accessRules` 合并决定;普通用户消耗 `practice_daily_usage`,事件写入 `practice_access_events`,SVIP/staff 不消耗免费额度。
|
||||
- 批量导入当前支持题目、单词、知识手册、分数线、视频 JSON/CSV/Excel 预览、逐行 issue、job/item 台账、同步执行或 `executionMode=async` 异步执行、幂等跳过,并可落到新内容入口、分类节点、分数线表或题目视频绑定。旧单词模板的 `vocabulary_units_示例数据` / `vocabulary_示例数据`、知识手册的书籍/章节/小节/知识点嵌套结构都由后端规范化。导入后复检、模板下载和字段映射 API 已补齐,前端 UI 待接。
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 完善内容导入和文件上传:字段映射 UI、真实数据 dry-run、PDF 预览渲染、防盗链、杀毒扫描和视频水印。
|
||||
1. 完善内容导入和文件上传:字段映射 UI、真实数据 dry-run、PDF 预览渲染、防盗链、真实 AV/内容安全 provider 和视频水印。
|
||||
2. 完成真实短信 provider 联调:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
|
||||
3. 完成真实 OAuth provider 联调:微信网页、微信小程序、QQ,确认回调域名、开放平台账号和旧 PocketBase 身份映射策略。
|
||||
4. 补微信/支付宝官方账单自动下载、异常订单运营台和优惠券核销报表;支付/退款补偿、退款查询确认、退款通知、资金对账导入比对和差错工单主链路已完成。
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
| 知识手册 | 基础完成 | 科目、章节、条目只读与后台维护、书籍/章节/小节/知识点嵌套 JSON 预览导入、内容导航绑定 | 富文本资源、版本管理、附件/PDF 关联、Excel/Markdown 批量解析 |
|
||||
| 分数线 | 可联调 | 字段、院校、专业、记录、趋势、年份、JSON 批量导入 | 复杂动态筛选、AI 择校数据上下文 |
|
||||
| 视频解析会员 | 可联调 | 题目视频、批量查询、后台绑定、视频 JSON 导入、SVIP 权限、播放次数扣减、签名 URL、播放日志 | 深度防盗链、动态水印、播放统计 |
|
||||
| 资料下载/PDF | 可联调 | `content_assets` 资源台账、后台资源管理、OSS/COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、学生端列表、SVIP 下载权限、assets worker 复检 | CDN 防盗链、杀毒扫描、资料前端管理页 |
|
||||
| 资料下载/PDF | 可联调 | `content_assets` 资源台账、后台资源管理、OSS/COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、学生端列表、SVIP 下载权限、assets worker 复检和内置安全扫描 | CDN 防盗链、真实 AV/内容安全 provider、资料前端管理页 |
|
||||
| 营销中心 | 基础完成 | SVIP 套餐、激活码批次、激活码生成、优惠券、Banner/FAQ/公告、勋章管理和手动发放 | 勋章自动发放、复杂活动规则、核销报表 |
|
||||
| 销售/代理客资 | 可联调 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、团队关系、手动补绑、分佣比例、归因、结算单、审核和打款状态 | 真实微信小程序码、真实打款、结算导出、销售团队看板 |
|
||||
| CRM 系统 | 可联调 | CRM 配置、密钥私密存储、客资入队、队列查询、generic/钉钉/飞书/企微 worker、签名、重试和日志 | 定向/轮询分配、富卡片模板、失败告警、死信运营台 |
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
## 接下来优先级
|
||||
|
||||
1. 完善内容导入和对象存储:字段映射 UI、真实数据 dry-run、CDN 防盗链、杀毒扫描和视频水印。
|
||||
1. 完善内容导入和对象存储:字段映射 UI、真实数据 dry-run、CDN 防盗链、真实 AV/内容安全 provider 和视频水印。
|
||||
2. 公共题库/地区题库授权:已完成披露、采纳快照、手动同步、自动同步 worker、同步通知、冲突查询和租户自改保护;继续补生产失败告警、冲突操作台增强和按 SaaS 套餐限制地区。
|
||||
3. 学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
|
||||
4. 视频会员控制:深度防盗链、水印和播放统计。
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
- 生产鉴权已具备 Supabase JWT API 入口,自定义角色模板基础 API 已可用;仍要做真实云端 Auth/JWKS 回归、RLS 深测和班级/学生范围权限细化,前端不要继续使用 `x-user-id`。
|
||||
- 不要把“Supabase 支持前端 Data API”误解为“本项目所有业务表都由 Taro 直写”;订单、支付、权益、租户后台、导入、CRM、私有资源必须走 RPC、`apps/api`、Edge Function 或 worker 这类后端命令层。
|
||||
- 短信、微信小程序/网页登录、QQ 登录、微信支付、支付宝支付 provider 已有本地 adapter 和测试覆盖;生产账号、回调域名、证书和商户资料仍需正式联调。
|
||||
- 对象存储已完成签名 provider、上传后校验、PDF/图片预览和资源复检 worker,但 CDN 防盗链、视频水印和杀毒扫描还要补。
|
||||
- 对象存储已完成签名 provider、上传后校验、PDF/图片预览、资源复检 worker 和内置安全扫描,但 CDN 防盗链、视频水印和真实 AV/内容安全 provider 还要补。
|
||||
- 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入已可联调;大批量导入可传 `executionMode=async` 交给 imports worker;模板下载、字段映射 API、导入任务详情和导入后复检已可用。租户内容页已经可以选择文件或粘贴内容、下载模板、执行后端预览、编辑本次字段别名、同步/异步提交导入、轮询异步 job、查看问题行并触发/查看复检;后续还要补真实数据 dry-run 验收和更完整的目标入口/集合选择。
|
||||
- 数据看板、分佣结算、勋章手动发放和主题模板发布基础 API 已可联调;勋章自动发放、分佣真实打款/导出/凭证、AI 择校、主题素材库/模板市场等仍是后续商用增强项。
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
| 工作台 | `apps/taro/src/pages/tenant-admin/workbench/index.tsx` | `tenant-admin/overview`、`tenant-admin/dashboard`、`tenant-admin/permissions` |
|
||||
| 数据看板 | `apps/taro/src/pages/tenant-admin/dashboard/index.tsx` | `tenant-admin/dashboard` |
|
||||
| 学生运营 | `apps/taro/src/pages/tenant-admin/students/index.tsx` | `tenant-admin/classes`、`tenant-admin/teachers`、`tenant-admin/students`、`students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups` |
|
||||
| 题库内容 | `apps/taro/src/pages/tenant-admin/content/index.tsx` | `tenant-content/content-entries`、`tenant-content/imports`、`imports/detail`、`imports/issues`、`imports/field-mapping`、`imports/templates`、`imports/post-check`、`tenant-content/exports/questions`、`tenant-content/exports/jobs`、`tenant-content/assets/sign-download`、`tenant-content/assets/sign-preview`、`tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch`、`tenant-content/notifications`、`tenant-content/notifications/status` |
|
||||
| 题库内容 | `apps/taro/src/pages/tenant-admin/content/index.tsx` | `tenant-content/content-entries`、`tenant-content/imports`、`imports/detail`、`imports/issues`、`imports/field-mapping`、`imports/templates`、`imports/post-check`、`tenant-content/exports/questions`、`tenant-content/exports/jobs`、`tenant-content/assets/sign-download`、`tenant-content/assets/sign-preview`、`tenant-content/assets/security-scan-events`、`tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch`、`tenant-content/notifications`、`tenant-content/notifications/status` |
|
||||
| 营销中心 | `apps/taro/src/pages/tenant-admin/marketing/index.tsx` | `tenant-admin/coupons`、`code-batches`、`activation-codes`、`crm/config`、`crm/queue`、`commission/settings`、`member-rate`、`summary`、`orders`、`settlements`、`settlements/generate`、`settlements/status` |
|
||||
| 租户设置 | `apps/taro/src/pages/tenant-admin/settings/index.tsx` | `tenant-admin/overview`、`domains`、`payment-accounts`、`auth-providers`、`theme-templates`、`theme`、`theme/preview`、`theme/publish`、`permissions`、`GET/PUT role-templates`、`POST role-templates/disable`、`GET/PUT members`、`POST members/disable` |
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
| 知识手册 | 已建手册科目、章节、条目,并可绑定 `content_entries/content_nodes` | 已支持内容导入 | 只读 API、租户后台手册科目/章节/条目维护 API、嵌套 JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 学生端阅读、后台维护和批量 JSON 导入基础可用,富文本资源/版本管理待补 |
|
||||
| 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API、JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 查询、后台维护和批量 JSON 导入基础闭环已实现,复杂动态筛选和 AI 择校上下文待补 |
|
||||
| 题目视频讲解 | 已建 `video_explanations`、`question_videos` | 已支持导入映射 | 单题视频、批量预加载、通用视频搜索、播放签名、视频次数扣减、租户后台视频创建绑定 API、JSON 预览导入已实现 | 核心 API 集成测试含播放和导入断言 | 播放、权益、后台绑定和批量 JSON 导入链路已实现,深度防盗链、动态水印和播放统计待补 |
|
||||
| 资料下载/PDF | 已扩展 `content_assets`,新增资源台账和导入任务表 | 旧 `app_assets/images` 兼容导入 | 租户后台资源管理、OSS/COS/Supabase Storage 上传/下载签名、上传确认、PDF/图片预览签名、题库导出 PDF/Word/每日一练 ZIP 自动发布资源、学生端资料列表/下载权限已实现 | 核心 API 集成测试含 SVIP 资料下载,assets worker 和 exports worker 测试 | 资料资源基础闭环可跑,CDN 防盗链、杀毒扫描和资料下载前端待补 |
|
||||
| 资料下载/PDF | 已扩展 `content_assets`,新增资源台账和导入任务表 | 旧 `app_assets/images` 兼容导入 | 租户后台资源管理、OSS/COS/Supabase Storage 上传/下载签名、上传确认、PDF/图片预览签名、资源访问审计、内置安全扫描、题库导出 PDF/Word/每日一练 ZIP 自动发布可信资源、学生端资料列表/下载权限已实现 | 核心 API 集成测试含 SVIP 资料下载、安全扫描门禁,assets worker 和 exports worker 测试 | 资料资源基础闭环可跑,CDN 防盗链、真实 AV/内容安全 provider、动态水印和资料下载前端待补 |
|
||||
| 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录、`badges/user_badges` | 已支持部分用户资料和勋章导入 | 个人资料、目标院校/专业、手机号绑定/换绑、会员状态、最近练习、统计聚合、签到积分、题目反馈、考试倒计时、勋章 API 已实现 | API 集成测试 | 学生端基础个人中心已实现,学习报告可视化和更细任务系统待补 |
|
||||
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章等基础表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、勋章维护和手动发放已实现 | 核心 API 集成测试 | 基础运营后台可用,勋章自动发放、复杂活动规则、营销自动化、核销报表待补 |
|
||||
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用,真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 |
|
||||
@@ -295,7 +295,7 @@ platform-admin:
|
||||
|
||||
为了先把旧项目核心业务补齐,再进入支付/短信等商用关键模块,建议按下面顺序继续:
|
||||
|
||||
1. 完善内容导入和文件上传:字段映射 UI、真实数据 dry-run 执行验收、CDN 防盗链、杀毒扫描。
|
||||
1. 完善内容导入和文件上传:字段映射 UI、真实数据 dry-run 执行验收、CDN 防盗链、真实 AV/内容安全 provider。
|
||||
2. 补公共题库生产定时调度/失败告警、租户套餐地区/科目/题库范围限制、主题模板系统。
|
||||
3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
|
||||
4. 补视频商用控制:深度防盗链、动态水印和播放统计。
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势、后台维护和 JSON 批量导入已有;后续补复杂筛选优化和 AI 择校数据上下文 |
|
||||
| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker、全额退款权益撤销、资金对账手工/API 导入比对、异常查询、差错工单和事件轨迹已有;缺微信/支付宝官方账单自动下载、异常订单运营台和前端收银台/售后体验 |
|
||||
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、手机号绑定/换绑、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时、趋势和勋章展示 API 已有;缺学习报告可视化 |
|
||||
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载和 PDF/图片预览基础已有;缺水印、防盗链、杀毒扫描和 worker 复检 |
|
||||
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载、PDF/图片预览、worker 复检和内置安全扫描已有;缺动态水印、深度防盗链、真实 AV/内容安全 provider 和生命周期策略 |
|
||||
| AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 |
|
||||
| 题目反馈 | `02-API接口.md` 用户反馈 | 部分覆盖 | 学生提交、本人列表、租户后台处理、状态事件、反馈奖励积分已覆盖;缺处理通知、前端消息提醒和批量统计 |
|
||||
| 签到积分 | `Profile.tsx`、`02-API接口.md` | 部分覆盖 | 每日签到、连续签到基础、积分流水、重复签到幂等已覆盖;缺积分兑换、活动任务和更完整的运营规则 |
|
||||
@@ -66,7 +66,7 @@
|
||||
| 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 和 JSON 批量导入已有 |
|
||||
| 视频维护/绑定 | 已覆盖 | video CRUD、question-video 绑定和视频 JSON 批量导入已有 |
|
||||
| CRM 配置和队列 | 部分覆盖 | 配置/队列已有;钉钉/飞书/企微真实发送 worker、签名、重试、死信待补 |
|
||||
| 对象存储配置 | 部分覆盖 | 系统 env provider、上传签名、上传确认和预览下载签名已有;租户级存储策略、CDN/水印/杀毒待补 |
|
||||
| 对象存储配置 | 部分覆盖 | 系统 env provider、上传签名、上传确认、预览下载签名、资源访问审计和内置安全扫描已有;租户级存储策略、CDN/水印/真实 AV provider 待补 |
|
||||
|
||||
## 平台 SaaS 后台功能
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
### P1:商用主链路
|
||||
|
||||
1. 微信/支付宝支付和 webhook 幂等。
|
||||
2. 对象存储 PDF 预览、视频深度防盗链、动态水印。
|
||||
2. 对象存储真实 AV/内容安全 provider、视频深度防盗链、动态水印。
|
||||
3. 真实数据 dry-run 执行验收、导入抽样校验和性能压测。
|
||||
4. 数据看板预聚合 worker、销售/代理转化看板和分佣结算。
|
||||
5. 公共题库生产定时调度、失败告警、租户确认/跳过策略和更完整运营消息。
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
- 租户后台数据看板已完成首版聚合 API:`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。
|
||||
- 支付/退款补偿 worker 已完成:`apps/worker --job commerce` 可查询微信/支付宝支付和处理中退款,补偿漏通知订单,支付成功幂等开通权益,退款成功幂等更新退款/订单/支付并在全额退款时撤销订单权益。
|
||||
- 资金对账和差错工单闭环已完成:`commerce_reconciliation_batches/items` 和 `/api/commerce/reconciliation/*` 支持手工/API 导入供应商账单行、预览差异、生成批次统计、查询异常、租户隔离、权限点 `tenant:reconciliation:read/write` 和审计日志;`commerce_reconciliation_issues/events` 支持异常明细创建工单、分配、开始处理、升级、解决、忽略、重开和事件留痕,且不直接修改订单/支付/退款/权益。
|
||||
- 内容资源复检 worker 已完成:`apps/worker --job assets` 可复检 `content_assets` 中的托管对象元数据,正常资源写回复检证据,异常资源自动置为 `failed + draft` 并写入审计和安全标记。
|
||||
- 内容资源复检与安全扫描 worker 已完成:`apps/worker --job assets` 可复检 `content_assets` 中的托管对象元数据,并执行内置 `metadata_rules` 安全扫描;正常资源写回复检/扫描证据,异常资源自动置为 `failed/skipped + draft` 或 `security_scan_status=failed`,并写入审计、扫描事件和安全标记。
|
||||
- 题库导出 worker 已完成:`apps/worker --job exports` 可抢占 `pdf/docx/daily_practice_zip` 导出任务,渲染 PDF/Word、水印或每日一练图片素材包,写入对象存储或本地开发存储,创建 `content_assets` 并回填 `assetId/hash/size`;`daily_practice` 已支持每日一练九宫格 metadata、PDF/Word 基础版式、9 张 PNG/SVG 卡片和拼图 ZIP。
|
||||
- 本地验证:`npm run check:refactor` 已通过。
|
||||
|
||||
@@ -50,9 +50,10 @@
|
||||
- 已接阿里云 OSS、腾讯云 COS、Supabase Storage 的上传/下载签名 provider。
|
||||
- 已补上传后对象确认接口、托管对象发布前 verified 校验、PDF/图片 inline 预览签名。
|
||||
- 已补 assets worker 复检,异常托管对象会自动下架并记录审计。
|
||||
- 已补内容资源安全扫描状态机:确认上传后进入 `securityScanStatus=pending`,扫描通过后才能发布、下载、预览或视频播放;后台可查 `/api/tenant-content/assets/security-scan-events`。
|
||||
- 已补资源访问事件 `content_asset_access_events`,覆盖上传签名/确认、学生下载/预览、后台下载/预览的 granted/denied、短 TTL、签名模式、IP 和 UA。
|
||||
- 已收紧锁定资源 CDN 边界:`members/svip/private` 外链默认拒绝,必须显式 provider-managed 才允许;视频绑定资源也复用该策略。
|
||||
- 继续补动态水印、杀毒扫描、CDN 刷新和对象生命周期策略。
|
||||
- 继续补真实 AV/内容安全 provider、动态水印、CDN 刷新和对象生命周期策略。
|
||||
- `content_assets` 继续作为资源台账,不允许前端绕过台账直接访问私有资源。
|
||||
|
||||
3. 真实导入 dry-run
|
||||
@@ -225,5 +226,5 @@
|
||||
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||
5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
|
||||
6. 并行补真实登录、微信/支付宝官方账单自动下载、异常订单运营台、对象存储杀毒/水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
|
||||
6. 并行补真实登录、微信/支付宝官方账单自动下载、异常订单运营台、对象存储真实 AV/内容安全 provider、水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
|
||||
7. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
||||
|
||||
@@ -28,7 +28,7 @@ POST /api/tenant-content/assets/sign-upload
|
||||
PUT /api/tenant-content/assets
|
||||
```
|
||||
|
||||
后台确认上传并发布:
|
||||
后台确认上传:
|
||||
|
||||
```text
|
||||
POST /api/tenant-content/assets/confirm-upload
|
||||
@@ -58,6 +58,12 @@ POST /api/videos/play
|
||||
GET /api/tenant-content/assets/access-events?assetId=...
|
||||
```
|
||||
|
||||
后台查看资源安全扫描事件:
|
||||
|
||||
```text
|
||||
GET /api/tenant-content/assets/security-scan-events?assetId=...
|
||||
```
|
||||
|
||||
后台管理员下载:
|
||||
|
||||
```text
|
||||
@@ -90,11 +96,13 @@ npm --workspace @tiku-saas/worker run exports:once
|
||||
2. 前端使用返回的 `upload.url` 和 `upload.headers` 直传对象存储。
|
||||
3. 调用 `PUT /api/tenant-content/assets` 登记资源台账。托管对象默认进入 `status=draft`、`uploadStatus=pending`。
|
||||
4. 调用 `POST /api/tenant-content/assets/confirm-upload`,由后端读取对象元数据并比对大小、MIME、SHA-256。
|
||||
5. 校验通过且 `publish=true` 时,后端将资源置为 `status=active`、`uploadStatus=verified`。
|
||||
6. 学生端只能下载或预览 `active + verified` 的托管对象资源。
|
||||
7. 学生端和后台管理员下载/预览都会写入 `content_asset_access_events`,包含 `assetId/userId/accessType/result/expiresInSec/signatureMode/ip/userAgent` 等审计字段。
|
||||
8. 生产环境定时运行 assets worker,复检 `pending/verified` 托管对象的大小、MIME、SHA-256 等元数据。
|
||||
9. 如果复检发现对象丢失、跨租户 objectKey、大小/MIME/checksum 不一致,worker 会把资源置为 `uploadStatus=failed`,并将 `active` 资源退回 `draft`,同时写入 `security_flags.assetRecheckFailed=true` 和 `audit_logs`。
|
||||
5. 校验通过后,后端只会置为 `uploadStatus=verified`、`securityScanStatus=pending`,并继续保持 `status=draft`。即使请求里传 `publish=true`,也不会绕过安全扫描直接发布。
|
||||
6. 运行 assets worker。worker 会先复检对象元数据,再执行内置 `metadata_rules` 安全扫描,检查 object key、MIME allowlist、文件大小、扩展名/MIME 是否匹配等规则。
|
||||
7. 扫描通过后,资源变为 `securityScanStatus=passed`,后台再调用 `PUT /api/tenant-content/assets` 将 `status=active` 发布。
|
||||
8. 学生端只能下载或预览 `active + uploadStatus=verified + securityScanStatus=passed` 的托管对象资源;后台管理员下载/预览也执行同一安全扫描门禁。
|
||||
9. 学生端和后台管理员下载/预览都会写入 `content_asset_access_events`,包含 `assetId/userId/accessType/result/expiresInSec/signatureMode/ip/userAgent` 等审计字段。
|
||||
10. 生产环境定时运行 assets worker,复检 `pending/verified` 托管对象的大小、MIME、SHA-256 等元数据。
|
||||
11. 如果复检发现对象丢失、跨租户 objectKey、大小/MIME/checksum 不一致,worker 会把资源置为 `uploadStatus=failed`、`securityScanStatus=skipped`,并将 `active` 资源退回 `draft`,同时写入 `security_flags.assetRecheckFailed=true`、`content_asset_security_scan_events` 和 `audit_logs`。
|
||||
|
||||
## 题库导出生成资源
|
||||
|
||||
@@ -104,7 +112,7 @@ npm --workspace @tiku-saas/worker run exports:once
|
||||
2. API 校验租户内容权限、导出范围和答案/解析开关,创建 `content_export_jobs.status=pending`。
|
||||
3. `apps/worker --job exports` 抢占 pending job,复用后端导出 payload 规则渲染 PDF/Word,或为每日一练生成 ZIP 素材包;PDF/Word 会应用 `options.watermarkText` 水印,ZIP 包会包含 PNG/SVG 卡片、拼图、manifest 和脱敏 payload。
|
||||
4. worker 将二进制文件写入配置的对象存储。`local_dev` 会写入 `EXPORT_LOCAL_STORAGE_ROOT`,生产建议使用 `supabase_storage`、`aliyun_oss` 或 `tencent_cos`。
|
||||
5. worker 创建 `content_assets`,设置 `uploadStatus=verified`,并把 `assetId`、文件名、大小和 SHA-256 回填到 `content_export_jobs.output_metadata`。
|
||||
5. worker 创建 `content_assets`,设置 `uploadStatus=verified`、`securityScanStatus=passed`、`securityScanProvider=trusted_export_worker`,并把 `assetId`、文件名、大小和 SHA-256 回填到 `content_export_jobs.output_metadata`。
|
||||
6. 前端轮询 `GET /api/tenant-content/exports/jobs`,拿到 `assetId` 后通过 `POST /api/tenant-content/assets/sign-download` 或 `sign-preview` 下载/预览。
|
||||
|
||||
注意:PDF/Word/ZIP 文件本身不直接存入数据库,数据库只保存 job、资源台账和校验 metadata。`daily_practice_zip` 资源类型为 `package`,通常只做签名下载,不做 inline 预览。
|
||||
@@ -129,6 +137,8 @@ npm --workspace @tiku-saas/worker run exports:once
|
||||
- `supabase_storage` 使用 Storage list metadata 做最小存在性/大小校验。
|
||||
- 如果 provider 无法返回 SHA-256,后端会记录 `checksumUnavailable=true`,生产建议上传时写入对象自定义元数据,例如 `x-oss-meta-sha256` 或 `x-cos-meta-sha256`。
|
||||
- worker 的复检证据写入 `verification_details.assetWorker`,包含 `lastCheckedAt`、`lastResult`、`expected`、`observed`、`issues`,便于租户后台定位资源异常。
|
||||
- `publish=true` 只作为迁移期兼容参数保留。确认上传成功后仍然会保持草稿,必须等 `securityScanStatus=passed` 后再由后台显式发布。
|
||||
- 新上传托管对象常见状态流为:`draft/pending/pending` -> `draft/verified/pending` -> `draft/verified/passed` -> `active/verified/passed`。
|
||||
|
||||
## 安全规则
|
||||
|
||||
@@ -136,7 +146,8 @@ npm --workspace @tiku-saas/worker run exports:once
|
||||
- `objectKey` 默认必须以当前 `tenantId/` 开头,防止跨租户覆盖或读取。
|
||||
- 禁止 `..`、反斜杠、编码斜杠等危险 object key。
|
||||
- 上传会校验 MIME 类型和文件大小。
|
||||
- 托管对象资源未确认前不能发布为 `active`,学生端不可下载。
|
||||
- 托管对象资源未确认或安全扫描未通过前不能发布为 `active`,学生端不可下载。
|
||||
- 托管对象安全扫描失败会返回 `ASSET_SECURITY_SCAN_FAILED`;仍在等待扫描或扫描被跳过会返回 `ASSET_SECURITY_SCAN_REQUIRED`。
|
||||
- PDF/图片预览使用 `inline` 签名,不等同于长期公开 URL。
|
||||
- 下载、预览和视频播放必须先经过 API 权限判断,再下发短期签名 URL。
|
||||
- 学生端 `preview`、锁定资料下载、视频和资料包默认使用更短 TTL。当前策略是学生 inline 预览、`members/svip/private` 资料、`video/package` 资源最多 300 秒;租户后台预览最多 3600 秒,后台下载最多 86400 秒。
|
||||
@@ -144,7 +155,43 @@ npm --workspace @tiku-saas/worker run exports:once
|
||||
- 每次上传签名、上传确认、学生下载/预览、后台下载/预览都会写入 `content_asset_access_events`。授权失败也会记录 `result=denied` 和 `denyCode`,用于租户后台排查资源访问问题。
|
||||
- 云厂商 AccessKey、SecretKey、Service Role Key 只存在服务端环境变量,不返回前端。
|
||||
- `content_assets` 是资源唯一台账,前端不得绕过台账直接访问私有 bucket。
|
||||
- 前端不能把 `uploadStatus=failed` 或 `status=draft` 的资源继续展示为可下载;列表仍返回时应展示“资料处理中”或“资源异常已下架”,真正下载/预览会被后端拒绝。
|
||||
- 前端不能把 `uploadStatus=failed`、`securityScanStatus!=passed` 或 `status=draft` 的资源继续展示为可下载;列表仍返回时应展示“资料处理中”“安全扫描中”或“资源异常已下架”,真正下载/预览会被后端拒绝。
|
||||
|
||||
## 安全扫描字段
|
||||
|
||||
资源台账 `content_assets` 新增安全扫描状态:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `security_scan_status` | `not_required/pending/scanning/passed/failed/skipped` |
|
||||
| `security_scanned_at` | 最近一次扫描完成时间 |
|
||||
| `security_scan_provider` | 扫描来源,例如 `metadata_rules`、`trusted_export_worker` |
|
||||
| `security_scan_summary` | 风险等级、问题码、扫描证据和失败原因 |
|
||||
|
||||
当前内置 provider 是 `metadata_rules`。它不是完整杀毒引擎,但能阻断明显危险或不合规对象:跨租户 key、非法 object key、超限文件、MIME allowlist 外文件、扩展名/MIME 不匹配,以及测试/运营标记的强制失败。商用生产仍需继续接入真实 AV/内容安全 provider,并将结果写入同一张事件表。
|
||||
|
||||
扫描事件表:
|
||||
|
||||
```text
|
||||
public.content_asset_security_scan_events
|
||||
```
|
||||
|
||||
后台接口:
|
||||
|
||||
```text
|
||||
GET /api/tenant-content/assets/security-scan-events?assetId=<assetId>&limit=100
|
||||
```
|
||||
|
||||
事件字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `asset_id` | 资源 ID |
|
||||
| `provider` | 扫描 provider |
|
||||
| `scan_status` | `pending/scanning/passed/failed/skipped` |
|
||||
| `risk_level` | `none/low/medium/high/critical` |
|
||||
| `issue_codes` | 问题码数组,例如 `file_extension_mime_mismatch` |
|
||||
| `details` | observed/declared metadata、扩展名、错误信息等证据 |
|
||||
|
||||
## 访问事件字段
|
||||
|
||||
|
||||
@@ -902,14 +902,16 @@ GET /api/catalog/assets/download?assetId=<assetId>
|
||||
- `download.url` 是短期 attachment URL,只给下载动作使用。
|
||||
- `ASSET_SVIP_REQUIRED`:提示开通对应地区/科目权益。
|
||||
- `ASSET_UPLOAD_NOT_VERIFIED`:展示“资料正在处理中”,并上报前端日志。
|
||||
- `ASSET_SECURITY_SCAN_REQUIRED`:展示“资料安全扫描中,请稍后再试”,并重新拉取资源列表或提示后台处理。
|
||||
- `ASSET_SECURITY_SCAN_FAILED`:展示“资料安全校验未通过,已下架”,学生端不要继续重试旧签名。
|
||||
- `ASSET_NOT_FOUND` 或列表中资源从 `active` 消失:展示“资源异常已下架”或刷新列表,不要继续使用旧签名 URL。
|
||||
- `ASSET_PREVIEW_NOT_SUPPORTED`:隐藏预览按钮,仅保留下载或提示不支持预览。
|
||||
- `previewUrl` 字段只作为公开/托管预览提示,不代表可以绕过接口直接访问。
|
||||
|
||||
租户后台上传资料必须走五步:
|
||||
租户后台上传资料必须走六步:
|
||||
|
||||
```text
|
||||
sign-upload -> 直传对象存储 -> PUT assets 登记草稿 -> confirm-upload -> sign-preview 验收
|
||||
sign-upload -> 直传对象存储 -> PUT assets 登记草稿 -> confirm-upload -> 等待 assets worker 安全扫描 -> PUT assets 发布 -> sign-preview 验收
|
||||
```
|
||||
|
||||
后台上传确认:
|
||||
@@ -924,9 +926,29 @@ sign-upload -> 直传对象存储 -> PUT assets 登记草稿 -> confirm-upload -
|
||||
}
|
||||
```
|
||||
|
||||
托管对象在确认前会保持 `status=draft`、`uploadStatus=pending`,学生端不会看到。确认失败时后端返回 `UPLOAD_VERIFICATION_FAILED`,后台必须展示失败原因并允许重新上传,不能前端强行改为已发布。
|
||||
托管对象在确认前会保持 `status=draft`、`uploadStatus=pending`、`securityScanStatus=pending`,学生端不会看到。确认成功后仍保持 `status=draft`,并进入 `uploadStatus=verified`、`securityScanStatus=pending`;即使传 `publish=true`,后端也不会直接发布。确认失败时后端返回 `UPLOAD_VERIFICATION_FAILED`,后台必须展示失败原因并允许重新上传,不能前端强行改为已发布。
|
||||
|
||||
生产环境会定时运行 assets worker 复检对象存储元数据。复检发现对象丢失、跨租户 objectKey、大小/MIME/checksum 不一致时,后端会把资源置为 `uploadStatus=failed` 并从 `active` 退回 `draft`,同时写入 `securityFlags.assetRecheckFailed=true`。租户后台资源列表应对 failed 资源展示异常原因和重新上传入口;学生端不要缓存资料列表和签名 URL 作为长期状态。
|
||||
后台资源列表建议展示这些状态:
|
||||
|
||||
| 状态 | 前端展示 | 可执行动作 |
|
||||
| --- | --- | --- |
|
||||
| `draft/pending/pending` | 待上传确认 | 重新上传、确认上传 |
|
||||
| `draft/verified/pending` | 安全扫描排队中 | 刷新状态、查看扫描事件 |
|
||||
| `draft/verified/scanning` | 安全扫描中 | 刷新状态、查看扫描事件 |
|
||||
| `draft/verified/passed` | 可发布 | 发布、预览、下载 |
|
||||
| `active/verified/passed` | 已发布 | 预览、下载、下架 |
|
||||
| `draft/verified/failed` | 安全扫描失败 | 查看扫描事件、重新上传 |
|
||||
| `draft/failed/skipped` | 上传复检失败 | 查看复检/扫描事件、重新上传 |
|
||||
|
||||
租户后台可通过下面接口排查扫描过程:
|
||||
|
||||
```http
|
||||
GET /api/tenant-content/assets/security-scan-events?assetId=<assetId>&limit=100
|
||||
```
|
||||
|
||||
`GET /api/tenant-content/assets` 和学生端 `GET /api/catalog/assets` 都会返回 `securityScanStatus`。前端可以展示状态,但最终能否下载、预览、播放仍以后端签名接口为准。
|
||||
|
||||
生产环境会定时运行 assets worker 复检对象存储元数据并执行内置 `metadata_rules` 安全扫描。复检发现对象丢失、跨租户 objectKey、大小/MIME/checksum 不一致时,后端会把资源置为 `uploadStatus=failed`、`securityScanStatus=skipped` 并从 `active` 退回 `draft`,同时写入 `securityFlags.assetRecheckFailed=true`。扫描发现 MIME 不允许、扩展名/MIME 不匹配等问题时,会把资源置为 `securityScanStatus=failed`,并写入 `securityFlags.assetSecurityScanFailed=true`。租户后台资源列表应对 failed/skipped 资源展示异常原因和重新上传入口;学生端不要缓存资料列表和签名 URL 作为长期状态。
|
||||
|
||||
## 考试倒计时、签到积分和反馈
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
|
||||
import { spawn } from 'node:child_process';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import pg from 'pg';
|
||||
import { SignJWT } from 'jose';
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
@@ -147,6 +148,39 @@ async function check(name, fn) {
|
||||
console.log(`[PASS] ${name}`);
|
||||
}
|
||||
|
||||
async function markAssetSecurityScanPassed(assetId, provider = 'api_integration_test') {
|
||||
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
|
||||
try {
|
||||
await pool.query(
|
||||
`
|
||||
update public.content_assets
|
||||
set security_scan_status = 'passed',
|
||||
security_scanned_at = now(),
|
||||
security_scan_provider = $3,
|
||||
security_scan_summary = '{"riskLevel":"none","issueCodes":[],"provider":"api_integration_test"}'::jsonb,
|
||||
security_flags = coalesce(security_flags, '{}'::jsonb) - 'assetSecurityScanFailed',
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2::uuid
|
||||
`,
|
||||
[MAIN_TENANT_ID, assetId, provider],
|
||||
);
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.content_asset_security_scan_events (
|
||||
tenant_id, asset_id, provider, scan_status, risk_level, issue_codes, details
|
||||
)
|
||||
values (
|
||||
$1, $2::uuid, $3, 'passed', 'none', '{}'::text[],
|
||||
'{"source":"api_integration_test"}'::jsonb
|
||||
)
|
||||
`,
|
||||
[MAIN_TENANT_ID, assetId, provider],
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
function getFreePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
@@ -3518,6 +3552,7 @@ async function testTenantContentAssetsAndImports() {
|
||||
assert.equal(localAsset.item?.objectKey, upload.assetDraft.objectKey, 'asset upsert should keep normalized object key');
|
||||
assert.equal(localAsset.item?.status, 'draft', 'managed object asset should stay draft before upload confirmation');
|
||||
assert.equal(localAsset.item?.uploadStatus, 'pending', 'managed object asset should be pending before upload confirmation');
|
||||
assert.equal(localAsset.item?.securityScanStatus, 'pending', 'managed object asset should require security scan before publishing');
|
||||
|
||||
const unconfirmedDownload = await request('/api/catalog/assets/download', {
|
||||
query: { assetId: localAsset.item.id },
|
||||
@@ -3536,16 +3571,60 @@ async function testTenantContentAssetsAndImports() {
|
||||
publish: true,
|
||||
},
|
||||
});
|
||||
assert.equal(confirmLocal.item?.status, 'active', 'confirmed managed asset should publish when requested');
|
||||
assert.equal(confirmLocal.item?.status, 'draft', 'confirmed managed asset should stay draft until security scan passes');
|
||||
assert.equal(confirmLocal.item?.uploadStatus, 'verified', 'confirm upload should mark asset verified');
|
||||
assert.equal(confirmLocal.item?.securityScanStatus, 'pending', 'confirm upload should mark asset security scan pending');
|
||||
assert.equal(confirmLocal.item?.verifiedChecksumSha256, 'a'.repeat(64), 'confirm upload should persist checksum');
|
||||
|
||||
const scanPendingEvents = await request('/api/tenant-content/assets/security-scan-events', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { assetId: localAsset.item.id },
|
||||
});
|
||||
assert.ok(Array.isArray(scanPendingEvents.items), 'tenant admin should list asset security scan events');
|
||||
|
||||
const localDownload = await request('/api/catalog/assets/download', {
|
||||
query: { assetId: localAsset.item.id },
|
||||
expectStatus: 404,
|
||||
});
|
||||
assert.equal(localDownload.download?.method, 'GET', 'object asset download should sign GET');
|
||||
assert.equal(localDownload.download?.signatureMode, 'local-placeholder', 'local object asset should use local placeholder signer');
|
||||
assert.ok(localDownload.download?.url?.includes(encodeURIComponent(localAsset.item.bucket)), 'object asset download should include bucket');
|
||||
assert.equal(localDownload.code, 'ASSET_NOT_FOUND', 'unscanned draft asset should not be downloadable');
|
||||
|
||||
await markAssetSecurityScanPassed(localAsset.item.id);
|
||||
const publishScannedLocal = await request('/api/tenant-content/assets', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
id: localAsset.item.id,
|
||||
title: '集成测试本地对象资料',
|
||||
assetType: 'pdf',
|
||||
storageProvider: 'local_dev',
|
||||
bucket: upload.assetDraft.bucket,
|
||||
objectKey: upload.assetDraft.objectKey,
|
||||
fileName: upload.assetDraft.fileName,
|
||||
mimeType: upload.assetDraft.mimeType,
|
||||
fileSizeBytes: upload.assetDraft.fileSizeBytes,
|
||||
checksumSha256: upload.assetDraft.checksumSha256,
|
||||
visibility: 'tenant',
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
assert.equal(publishScannedLocal.item?.status, 'active', 'scanned managed asset should publish');
|
||||
assert.equal(publishScannedLocal.item?.securityScanStatus, 'passed', 'published managed asset should keep passed scan status');
|
||||
|
||||
const scanEventsAfterPass = await request('/api/tenant-content/assets/security-scan-events', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { assetId: localAsset.item.id },
|
||||
});
|
||||
assert.ok(
|
||||
scanEventsAfterPass.items?.some(item => item.scanStatus === 'passed' && item.provider === 'api_integration_test'),
|
||||
'asset security scan pass event should be visible to tenant admin',
|
||||
);
|
||||
|
||||
const scannedLocalDownload = await request('/api/catalog/assets/download', {
|
||||
query: { assetId: localAsset.item.id },
|
||||
});
|
||||
assert.equal(scannedLocalDownload.download?.method, 'GET', 'object asset download should sign GET');
|
||||
assert.equal(scannedLocalDownload.download?.signatureMode, 'local-placeholder', 'local object asset should use local placeholder signer');
|
||||
assert.ok(scannedLocalDownload.download?.url?.includes(encodeURIComponent(localAsset.item.bucket)), 'object asset download should include bucket');
|
||||
|
||||
const localPreview = await request('/api/catalog/assets/preview', {
|
||||
query: { assetId: localAsset.item.id },
|
||||
|
||||
@@ -8,6 +8,7 @@ const tenantId = '00000000-0000-0000-0000-000000000001';
|
||||
const ids = {
|
||||
okAsset: '20000000-0000-0000-0000-000000000801',
|
||||
badAsset: '20000000-0000-0000-0000-000000000802',
|
||||
scanBadAsset: '20000000-0000-0000-0000-000000000803',
|
||||
};
|
||||
|
||||
const checksumA = 'a'.repeat(64);
|
||||
@@ -52,16 +53,23 @@ async function cleanup(pool) {
|
||||
delete from public.audit_logs
|
||||
where tenant_id = $1
|
||||
and target_type = 'content_asset'
|
||||
and target_id in ($2, $3)
|
||||
and target_id in ($2, $3, $4)
|
||||
`,
|
||||
[tenantId, ids.okAsset, ids.badAsset],
|
||||
[tenantId, ids.okAsset, ids.badAsset, ids.scanBadAsset],
|
||||
);
|
||||
await pool.query(
|
||||
`
|
||||
delete from public.content_asset_security_scan_events
|
||||
where tenant_id = $1 and asset_id in ($2::uuid, $3::uuid, $4::uuid)
|
||||
`,
|
||||
[tenantId, ids.okAsset, ids.badAsset, ids.scanBadAsset],
|
||||
);
|
||||
await pool.query(
|
||||
`
|
||||
delete from public.content_assets
|
||||
where tenant_id = $1 and id in ($2::uuid, $3::uuid)
|
||||
where tenant_id = $1 and id in ($2::uuid, $3::uuid, $4::uuid)
|
||||
`,
|
||||
[tenantId, ids.okAsset, ids.badAsset],
|
||||
[tenantId, ids.okAsset, ids.badAsset, ids.scanBadAsset],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,7 +80,9 @@ async function seed(pool) {
|
||||
id, tenant_id, asset_key, title, asset_type, storage_provider,
|
||||
bucket, object_key, file_name, mime_type, file_size_bytes, checksum_sha256,
|
||||
visibility, status, upload_status, verified_at, verified_size_bytes,
|
||||
verified_checksum_sha256, verification_details, security_flags, source,
|
||||
verified_checksum_sha256, verification_details,
|
||||
security_scan_status, security_scan_provider, security_scan_summary,
|
||||
security_flags, source,
|
||||
created_at, updated_at
|
||||
)
|
||||
values
|
||||
@@ -80,14 +90,27 @@ async function seed(pool) {
|
||||
$1, $2, 'asset-worker-ok', '资源复检正常 PDF', 'pdf', 'local_dev',
|
||||
'tenant-assets', $3, 'ok.pdf', 'application/pdf', 4096, $4,
|
||||
'tenant', 'active', 'verified', now() - interval '2 days', 4096,
|
||||
$4, '{"source":"asset-worker-test"}'::jsonb, '{}'::jsonb, 'integration-test',
|
||||
$4, '{"source":"asset-worker-test"}'::jsonb,
|
||||
'pending', 'metadata_rules', '{}'::jsonb,
|
||||
'{}'::jsonb, 'integration-test',
|
||||
now() - interval '2 days', now() - interval '2 days'
|
||||
),
|
||||
(
|
||||
$5, $2, 'asset-worker-bad', '资源复检异常 PDF', 'pdf', 'local_dev',
|
||||
'tenant-assets', $6, 'bad.pdf', 'application/pdf', 1024, $7,
|
||||
'tenant', 'active', 'verified', now() - interval '2 days', 2048,
|
||||
$7, '{"source":"asset-worker-test"}'::jsonb, '{}'::jsonb, 'integration-test',
|
||||
$7, '{"source":"asset-worker-test"}'::jsonb,
|
||||
'pending', 'metadata_rules', '{}'::jsonb,
|
||||
'{}'::jsonb, 'integration-test',
|
||||
now() - interval '2 days', now() - interval '2 days'
|
||||
),
|
||||
(
|
||||
$8, $2, 'asset-worker-scan-bad', '资源安全扫描异常 PDF', 'pdf', 'local_dev',
|
||||
'tenant-assets', $9, 'scan-bad.pdf', 'application/pdf', 4096, $4,
|
||||
'tenant', 'active', 'verified', now() - interval '2 days', 4096,
|
||||
$4, '{"source":"asset-worker-test"}'::jsonb,
|
||||
'pending', 'metadata_rules', '{"securityScanForceFail":true}'::jsonb,
|
||||
'{}'::jsonb, 'integration-test',
|
||||
now() - interval '2 days', now() - interval '2 days'
|
||||
)
|
||||
`,
|
||||
@@ -99,6 +122,8 @@ async function seed(pool) {
|
||||
ids.badAsset,
|
||||
`${tenantId}/assets/worker-bad.pdf`,
|
||||
checksumB,
|
||||
ids.scanBadAsset,
|
||||
`${tenantId}/assets/worker-scan-bad.pdf`,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -115,32 +140,78 @@ async function main() {
|
||||
|
||||
const output = await runWorkerOnce();
|
||||
assert.ok(countFromWorkerOutput(output, 'verified') >= 1, 'worker should verify at least one asset');
|
||||
assert.ok(countFromWorkerOutput(output, 'failed') >= 1, 'worker should fail at least one mismatched asset');
|
||||
assert.ok(countFromWorkerOutput(output, 'failed') >= 2, 'worker should fail metadata and security scan assets');
|
||||
|
||||
const assets = await pool.query(
|
||||
`
|
||||
select id, status, upload_status, verification_details, security_flags
|
||||
select id, status, upload_status, verification_details,
|
||||
security_scan_status, security_scan_provider, security_scan_summary,
|
||||
security_flags
|
||||
from public.content_assets
|
||||
where tenant_id = $1 and id in ($2::uuid, $3::uuid)
|
||||
where tenant_id = $1 and id in ($2::uuid, $3::uuid, $4::uuid)
|
||||
order by id
|
||||
`,
|
||||
[tenantId, ids.okAsset, ids.badAsset],
|
||||
[tenantId, ids.okAsset, ids.badAsset, ids.scanBadAsset],
|
||||
);
|
||||
const okAsset = assets.rows.find(row => row.id === ids.okAsset);
|
||||
const badAsset = assets.rows.find(row => row.id === ids.badAsset);
|
||||
const scanBadAsset = assets.rows.find(row => row.id === ids.scanBadAsset);
|
||||
assert.equal(okAsset?.status, 'active', 'verified asset should remain active');
|
||||
assert.equal(okAsset?.upload_status, 'verified', 'verified asset should remain verified');
|
||||
assert.equal(okAsset?.security_scan_status, 'passed', 'verified asset should pass security scan');
|
||||
assert.equal(okAsset?.security_scan_provider, 'metadata_rules', 'verified asset should record scan provider');
|
||||
assert.equal(okAsset?.verification_details?.assetWorker?.lastResult, 'verified', 'verified asset should record worker result');
|
||||
assert.equal(okAsset?.security_flags?.assetRecheckFailed, undefined, 'verified asset should not keep recheck failure flag');
|
||||
|
||||
assert.equal(badAsset?.status, 'draft', 'mismatched asset should be unpublished');
|
||||
assert.equal(badAsset?.upload_status, 'failed', 'mismatched asset should be marked failed');
|
||||
assert.equal(badAsset?.security_scan_status, 'skipped', 'mismatched asset should skip security scan');
|
||||
assert.equal(badAsset?.security_flags?.assetRecheckFailed, true, 'mismatched asset should record security flag');
|
||||
assert.deepEqual(
|
||||
badAsset?.verification_details?.assetWorker?.issues,
|
||||
['file_size_mismatch'],
|
||||
'mismatched asset should record exact issue',
|
||||
);
|
||||
assert.equal(scanBadAsset?.status, 'draft', 'security failed asset should be unpublished');
|
||||
assert.equal(scanBadAsset?.upload_status, 'verified', 'security failed asset should keep upload verification evidence');
|
||||
assert.equal(scanBadAsset?.security_scan_status, 'failed', 'security failed asset should record failed scan status');
|
||||
assert.equal(scanBadAsset?.security_flags?.assetSecurityScanFailed, true, 'security failed asset should record security flag');
|
||||
assert.ok(
|
||||
scanBadAsset?.security_scan_summary?.issueCodes?.includes('security_scan_forced_failure'),
|
||||
'security failed asset should record issue code',
|
||||
);
|
||||
|
||||
const scanEvents = await pool.query(
|
||||
`
|
||||
select asset_id, scan_status, risk_level, issue_codes, provider, details
|
||||
from public.content_asset_security_scan_events
|
||||
where tenant_id = $1 and asset_id in ($2::uuid, $3::uuid)
|
||||
order by created_at asc
|
||||
`,
|
||||
[tenantId, ids.okAsset, ids.scanBadAsset],
|
||||
);
|
||||
assert.ok(
|
||||
scanEvents.rows.some(row => row.asset_id === ids.okAsset && row.scan_status === 'passed' && row.provider === 'metadata_rules'),
|
||||
'worker should write passed security scan event',
|
||||
);
|
||||
assert.ok(
|
||||
scanEvents.rows.some(row => row.asset_id === ids.scanBadAsset && row.scan_status === 'failed' && row.issue_codes.includes('security_scan_forced_failure')),
|
||||
'worker should write failed security scan event',
|
||||
);
|
||||
|
||||
const skippedScanEvents = await pool.query(
|
||||
`
|
||||
select asset_id, scan_status, risk_level, issue_codes, provider, details
|
||||
from public.content_asset_security_scan_events
|
||||
where tenant_id = $1 and asset_id = $2::uuid
|
||||
order by created_at asc
|
||||
`,
|
||||
[tenantId, ids.badAsset],
|
||||
);
|
||||
assert.ok(
|
||||
skippedScanEvents.rows.some(row => row.scan_status === 'skipped' && row.issue_codes.includes('file_size_mismatch')),
|
||||
'worker should write skipped scan event when metadata verification fails',
|
||||
);
|
||||
|
||||
const audits = await pool.query(
|
||||
`
|
||||
@@ -148,10 +219,10 @@ async function main() {
|
||||
from public.audit_logs
|
||||
where tenant_id = $1
|
||||
and target_type = 'content_asset'
|
||||
and target_id in ($2, $3)
|
||||
and target_id in ($2, $3, $4)
|
||||
order by created_at asc
|
||||
`,
|
||||
[tenantId, ids.okAsset, ids.badAsset],
|
||||
[tenantId, ids.okAsset, ids.badAsset, ids.scanBadAsset],
|
||||
);
|
||||
assert.ok(
|
||||
audits.rows.some(row => row.action === 'content.asset.rechecked' && row.details?.result === 'verified'),
|
||||
@@ -161,6 +232,10 @@ async function main() {
|
||||
audits.rows.some(row => row.action === 'content.asset.recheck_failed' && row.details?.issues?.includes('file_size_mismatch')),
|
||||
'worker should write failed audit log',
|
||||
);
|
||||
assert.ok(
|
||||
audits.rows.some(row => row.action === 'content.asset.security_scan_failed' && row.details?.issueCodes?.includes('security_scan_forced_failure')),
|
||||
'worker should write failed security scan audit log',
|
||||
);
|
||||
|
||||
console.log('Asset worker integration test complete.');
|
||||
} catch (error) {
|
||||
|
||||
@@ -1356,12 +1356,17 @@ async function main() {
|
||||
id, tenant_id, asset_key, title, asset_type, storage_provider,
|
||||
bucket, object_key, file_name, mime_type, file_size_bytes,
|
||||
checksum_sha256, visibility, status, upload_status, verified_at,
|
||||
verified_size_bytes, verified_checksum_sha256, source
|
||||
verified_size_bytes, verified_checksum_sha256,
|
||||
security_scan_status, security_scanned_at, security_scan_provider,
|
||||
security_scan_summary, source
|
||||
)
|
||||
values (
|
||||
$1, $2, 'smoke-video-asset', '烟测视频对象', 'video', 'local_dev',
|
||||
'tenant-assets', $3, 'smoke.mp4', 'video/mp4', 8192,
|
||||
$4, 'svip', 'active', 'verified', now(), 8192, $4, 'smoke-seed'
|
||||
$4, 'svip', 'active', 'verified', now(), 8192, $4,
|
||||
'passed', now(), 'smoke_seed',
|
||||
'{"riskLevel":"none","issueCodes":[],"provider":"smoke_seed"}'::jsonb,
|
||||
'smoke-seed'
|
||||
)
|
||||
on conflict (id)
|
||||
do update set storage_provider = excluded.storage_provider,
|
||||
@@ -1375,6 +1380,10 @@ async function main() {
|
||||
verified_at = coalesce(public.content_assets.verified_at, now()),
|
||||
verified_size_bytes = excluded.verified_size_bytes,
|
||||
verified_checksum_sha256 = excluded.verified_checksum_sha256,
|
||||
security_scan_status = 'passed',
|
||||
security_scanned_at = coalesce(public.content_assets.security_scanned_at, now()),
|
||||
security_scan_provider = 'smoke_seed',
|
||||
security_scan_summary = excluded.security_scan_summary,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.videoAsset, tenantId, `${tenantId}/videos/smoke.mp4`, 'c'.repeat(64)],
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
alter table public.content_assets
|
||||
add column if not exists security_scan_status text not null default 'not_required',
|
||||
add column if not exists security_scanned_at timestamptz,
|
||||
add column if not exists security_scan_provider text,
|
||||
add column if not exists security_scan_summary jsonb not null default '{}'::jsonb;
|
||||
|
||||
update public.content_assets
|
||||
set security_scan_status = case
|
||||
when storage_provider in ('local_dev', 'supabase_storage', 'aliyun_oss', 'tencent_cos')
|
||||
and object_key is not null
|
||||
and upload_status = 'verified'
|
||||
then 'passed'
|
||||
when storage_provider in ('local_dev', 'supabase_storage', 'aliyun_oss', 'tencent_cos')
|
||||
and object_key is not null
|
||||
and upload_status = 'failed'
|
||||
then 'skipped'
|
||||
when storage_provider in ('local_dev', 'supabase_storage', 'aliyun_oss', 'tencent_cos')
|
||||
and object_key is not null
|
||||
then 'pending'
|
||||
else 'not_required'
|
||||
end,
|
||||
security_scanned_at = case
|
||||
when storage_provider in ('local_dev', 'supabase_storage', 'aliyun_oss', 'tencent_cos')
|
||||
and object_key is not null
|
||||
and upload_status = 'verified'
|
||||
then coalesce(security_scanned_at, verified_at, updated_at, created_at, now())
|
||||
else security_scanned_at
|
||||
end,
|
||||
security_scan_provider = case
|
||||
when storage_provider in ('local_dev', 'supabase_storage', 'aliyun_oss', 'tencent_cos')
|
||||
and object_key is not null
|
||||
and upload_status = 'verified'
|
||||
then coalesce(security_scan_provider, 'legacy_backfill')
|
||||
else security_scan_provider
|
||||
end,
|
||||
security_scan_summary = case
|
||||
when storage_provider in ('local_dev', 'supabase_storage', 'aliyun_oss', 'tencent_cos')
|
||||
and object_key is not null
|
||||
and upload_status = 'verified'
|
||||
then coalesce(security_scan_summary, '{}'::jsonb)
|
||||
|| jsonb_build_object(
|
||||
'riskLevel', 'none',
|
||||
'issueCodes', '[]'::jsonb,
|
||||
'backfilled', true,
|
||||
'backfilledAt', now()
|
||||
)
|
||||
else coalesce(security_scan_summary, '{}'::jsonb)
|
||||
end
|
||||
where security_scan_status = 'not_required'
|
||||
or security_scan_summary = '{}'::jsonb;
|
||||
|
||||
do $$
|
||||
begin
|
||||
if not exists (select 1 from pg_constraint where conname = 'content_assets_security_scan_status_check') then
|
||||
alter table public.content_assets
|
||||
add constraint content_assets_security_scan_status_check
|
||||
check (security_scan_status in ('not_required', 'pending', 'scanning', 'passed', 'failed', 'skipped'));
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
create table if not exists public.content_asset_security_scan_events (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
asset_id uuid references public.content_assets(id) on delete set null,
|
||||
provider text not null,
|
||||
scan_status text not null
|
||||
check (scan_status in ('pending', 'scanning', 'passed', 'failed', 'skipped')),
|
||||
risk_level text not null default 'none'
|
||||
check (risk_level in ('none', 'low', 'medium', 'high', 'critical')),
|
||||
issue_codes text[] not null default '{}',
|
||||
details jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
comment on table public.content_asset_security_scan_events is
|
||||
'内容资源安全扫描事件。用于记录托管对象的内置规则扫描、杀毒/内容安全 provider 扫描结果、失败原因和审计轨迹。';
|
||||
|
||||
create index if not exists idx_asset_security_scan_events_asset
|
||||
on public.content_asset_security_scan_events(tenant_id, asset_id, created_at desc);
|
||||
|
||||
create index if not exists idx_asset_security_scan_events_status
|
||||
on public.content_asset_security_scan_events(tenant_id, scan_status, risk_level, created_at desc);
|
||||
|
||||
create index if not exists idx_content_assets_security_scan_status
|
||||
on public.content_assets(tenant_id, security_scan_status, upload_status, status, updated_at desc);
|
||||
|
||||
alter table public.content_asset_security_scan_events enable row level security;
|
||||
|
||||
drop policy if exists tenant_isolation on public.content_asset_security_scan_events;
|
||||
create policy tenant_isolation on public.content_asset_security_scan_events
|
||||
for all
|
||||
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
|
||||
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
|
||||
Reference in New Issue
Block a user