forked from wangziqi/gongxue-base
feat: add external asset security scanner
This commit is contained in:
@@ -56,6 +56,12 @@ WORKER_ASSET_BATCH_SIZE=50
|
|||||||
WORKER_ASSET_MIN_AGE_SECONDS=300
|
WORKER_ASSET_MIN_AGE_SECONDS=300
|
||||||
WORKER_ASSET_RECHECK_INTERVAL_SECONDS=86400
|
WORKER_ASSET_RECHECK_INTERVAL_SECONDS=86400
|
||||||
WORKER_ASSET_REQUEST_TIMEOUT_MS=10000
|
WORKER_ASSET_REQUEST_TIMEOUT_MS=10000
|
||||||
|
# 内容资源安全扫描。生产必须使用 metadata_rules,http,HTTP scanner 必须是 HTTPS 且 fail-open=false。
|
||||||
|
WORKER_ASSET_SECURITY_SCANNER=metadata_rules
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS=10000
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
|
||||||
|
|
||||||
# Worker 配置:大批量内容导入
|
# Worker 配置:大批量内容导入
|
||||||
WORKER_IMPORT_BATCH_SIZE=5
|
WORKER_IMPORT_BATCH_SIZE=5
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
- Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。
|
- Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。
|
||||||
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;资金对账已支持手工/API 账单导入比对和差错工单处理,微信/支付宝官方账单自动下载、异常订单运营台和真实生产账号联调还没接完。
|
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;资金对账已支持手工/API 账单导入比对和差错工单处理,微信/支付宝官方账单自动下载、异常订单运营台和真实生产账号联调还没接完。
|
||||||
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览、资源访问事件、锁定资源 CDN 边界、资源复检 worker 和内置 `metadata_rules` 安全扫描已完成。完整杀毒/内容安全 provider、视频动态水印、CDN 刷新和对象生命周期策略还没完成。
|
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览、资源访问事件、锁定资源 CDN 边界、资源复检 worker、内置 `metadata_rules` 安全扫描和外部 HTTP 杀毒/内容安全 scanner 接入层已完成。生产还要配置真实扫描服务 endpoint/token,并继续补视频动态水印、CDN 刷新和对象生命周期策略。
|
||||||
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
|
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
|
||||||
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
|
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
|
||||||
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。
|
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。
|
||||||
@@ -305,7 +305,7 @@ API 身份上下文:
|
|||||||
|
|
||||||
- 租户公开配置和主题配置不能存放密钥;主题 token 只能是后端允许的颜色、半径、安全 CSS 变量、图标 token 和公开素材引用。
|
- 租户公开配置和主题配置不能存放密钥;主题 token 只能是后端允许的颜色、半径、安全 CSS 变量、图标 token 和公开素材引用。
|
||||||
- 商户密钥、短信密钥、OAuth app secret 等必须进入 `app_private.tenant_secrets`,或后续生产 KMS/Vault。
|
- 商户密钥、短信密钥、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`。托管对象必须 `uploadStatus=verified` 且 `securityScanStatus=passed` 后才能发布、下载、预览或播放;生产环境应定时运行 assets worker 复检对象元数据并执行内置安全扫描,异常资源会被标记 failed/skipped 并退回 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 复检对象元数据,执行 `metadata_rules` 和外部 HTTP scanner,异常资源会被标记 failed/skipped 并退回 draft。
|
||||||
- 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。
|
- 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。
|
||||||
- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册、分数线和视频 JSON/CSV/Excel 导入已走这套后台校验管线;大批量任务可提交 `executionMode=async`,由 imports worker 消费,前端只轮询 job 状态和展示 issues。
|
- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册、分数线和视频 JSON/CSV/Excel 导入已走这套后台校验管线;大批量任务可提交 `executionMode=async`,由 imports worker 消费,前端只轮询 job 状态和展示 issues。
|
||||||
- 题库导出必须由后端按权限生成,不允许前端直接读取数据库拼导出文件;不开启答案/解析时,顶层题目和复合题子题都必须脱敏;PDF/Word/每日一练 ZIP 只通过 exports worker 写入 `content_assets` 后再签名下载/预览。
|
- 题库导出必须由后端按权限生成,不允许前端直接读取数据库拼导出文件;不开启答案/解析时,顶层题目和复合题子题都必须脱敏;PDF/Word/每日一练 ZIP 只通过 exports worker 写入 `content_assets` 后再签名下载/预览。
|
||||||
@@ -327,7 +327,7 @@ npm run audit:runtime
|
|||||||
git diff --check
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|
||||||
结果:通过。`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 回归充分后单独处理。
|
结果:通过。`npm run test:api` 覆盖资源访问事件、锁定 CDN 资源拒绝、provider-managed CDN 显式放行、学生短 TTL 下载/预览、访问记录查询和安全扫描门禁。`npm run test:worker:assets` 覆盖托管资源复检、内置安全扫描、外部 HTTP scanner 通过/失败/不可用 fail-closed、扫描失败/跳过事件和异常资源自动下架。`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 官方修复。
|
注意:`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 官方修复。
|
||||||
|
|
||||||
@@ -337,6 +337,6 @@ git diff --check
|
|||||||
|
|
||||||
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
|
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
|
||||||
2. 继续补 Taro 前端:学生端视频/反馈/模考报告/订单收银台,租户后台写入表单/导入操作台/公共题库同步/角色模板 UI,平台后台租户详情/审计/自动计费增强,小程序兼容验证。
|
2. 继续补 Taro 前端:学生端视频/反馈/模考报告/订单收银台,租户后台写入表单/导入操作台/公共题库同步/角色模板 UI,平台后台租户详情/审计/自动计费增强,小程序兼容验证。
|
||||||
3. 对象存储真实 AV/内容安全 provider、CDN 防盗链、视频动态水印和生命周期策略。
|
3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、视频动态水印和生命周期策略。
|
||||||
4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
|
4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
|
||||||
5. 真实 OAuth/短信/支付生产账号联调、微信/支付宝官方账单自动下载、异常订单运营台、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。
|
5. 真实 OAuth/短信/支付生产账号联调、微信/支付宝官方账单自动下载、异常订单运营台、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ export interface WorkerConfig {
|
|||||||
assetMinAgeSeconds: number;
|
assetMinAgeSeconds: number;
|
||||||
assetRecheckIntervalSeconds: number;
|
assetRecheckIntervalSeconds: number;
|
||||||
assetRequestTimeoutMs: number;
|
assetRequestTimeoutMs: number;
|
||||||
|
assetSecurityScanner: string;
|
||||||
|
assetSecurityScanHttpEndpoint: string;
|
||||||
|
assetSecurityScanHttpToken: string;
|
||||||
|
assetSecurityScanHttpTimeoutMs: number;
|
||||||
|
assetSecurityScanFailOpen: boolean;
|
||||||
importBatchSize: number;
|
importBatchSize: number;
|
||||||
importWorkerId: string;
|
importWorkerId: string;
|
||||||
importBackoffSeconds: number[];
|
importBackoffSeconds: number[];
|
||||||
@@ -68,6 +73,11 @@ export const config: WorkerConfig = {
|
|||||||
assetMinAgeSeconds: envNumber('WORKER_ASSET_MIN_AGE_SECONDS', 300),
|
assetMinAgeSeconds: envNumber('WORKER_ASSET_MIN_AGE_SECONDS', 300),
|
||||||
assetRecheckIntervalSeconds: envNumber('WORKER_ASSET_RECHECK_INTERVAL_SECONDS', 60 * 60 * 24),
|
assetRecheckIntervalSeconds: envNumber('WORKER_ASSET_RECHECK_INTERVAL_SECONDS', 60 * 60 * 24),
|
||||||
assetRequestTimeoutMs: envNumber('WORKER_ASSET_REQUEST_TIMEOUT_MS', 10_000),
|
assetRequestTimeoutMs: envNumber('WORKER_ASSET_REQUEST_TIMEOUT_MS', 10_000),
|
||||||
|
assetSecurityScanner: envString('WORKER_ASSET_SECURITY_SCANNER', 'metadata_rules'),
|
||||||
|
assetSecurityScanHttpEndpoint: envString('WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT', ''),
|
||||||
|
assetSecurityScanHttpToken: envString('WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN', ''),
|
||||||
|
assetSecurityScanHttpTimeoutMs: envNumber('WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS', 10_000),
|
||||||
|
assetSecurityScanFailOpen: envBoolean('WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN', false),
|
||||||
importBatchSize: envNumber('WORKER_IMPORT_BATCH_SIZE', 5),
|
importBatchSize: envNumber('WORKER_IMPORT_BATCH_SIZE', 5),
|
||||||
importWorkerId: envString('WORKER_IMPORT_ID', `imports-${process.pid}`),
|
importWorkerId: envString('WORKER_IMPORT_ID', `imports-${process.pid}`),
|
||||||
importBackoffSeconds: envList('WORKER_IMPORT_BACKOFF_SECONDS', '30,120,600,1800')
|
importBackoffSeconds: envList('WORKER_IMPORT_BACKOFF_SECONDS', '30,120,600,1800')
|
||||||
|
|||||||
@@ -493,6 +493,83 @@ function metadataBoolean(value: unknown) {
|
|||||||
return value === true || value === 'true' || value === 1 || value === '1';
|
return value === true || value === 'true' || value === 1 || value === '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RISK_LEVEL_ORDER: Record<SecurityScanResult['riskLevel'], number> = {
|
||||||
|
none: 0,
|
||||||
|
low: 1,
|
||||||
|
medium: 2,
|
||||||
|
high: 3,
|
||||||
|
critical: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeRiskLevel(value: unknown, fallback: SecurityScanResult['riskLevel'] = 'high'): SecurityScanResult['riskLevel'] {
|
||||||
|
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
||||||
|
if (normalized === 'none' || normalized === 'low' || normalized === 'medium' || normalized === 'high' || normalized === 'critical') {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function highestRiskLevel(results: SecurityScanResult[]): SecurityScanResult['riskLevel'] {
|
||||||
|
return results.reduce<SecurityScanResult['riskLevel']>((highest, result) => (
|
||||||
|
RISK_LEVEL_ORDER[result.riskLevel] > RISK_LEVEL_ORDER[highest] ? result.riskLevel : highest
|
||||||
|
), 'none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIssueCodes(value: unknown, fallback: string[] = []) {
|
||||||
|
const rawItems = Array.isArray(value) ? value : fallback;
|
||||||
|
return Array.from(new Set(
|
||||||
|
rawItems
|
||||||
|
.map(item => String(item || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(item => item.replace(/[^A-Za-z0-9_.:-]/g, '_').slice(0, 96))
|
||||||
|
.filter(Boolean),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scannerModes() {
|
||||||
|
return Array.from(new Set(
|
||||||
|
config.assetSecurityScanner
|
||||||
|
.split(',')
|
||||||
|
.map(item => item.trim().toLowerCase())
|
||||||
|
.filter(Boolean),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeProviderDetails(value: unknown, depth = 0): unknown {
|
||||||
|
if (depth > 5) return '[max_depth]';
|
||||||
|
if (value === null || value === undefined) return value;
|
||||||
|
if (typeof value === 'string') return value.length > 2000 ? `${value.slice(0, 2000)}...` : value;
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') return value;
|
||||||
|
if (Array.isArray(value)) return value.slice(0, 50).map(item => sanitizeProviderDetails(item, depth + 1));
|
||||||
|
if (typeof value !== 'object') return String(value);
|
||||||
|
|
||||||
|
const sanitized: Record<string, unknown> = {};
|
||||||
|
for (const [key, child] of Object.entries(value as Record<string, unknown>).slice(0, 100)) {
|
||||||
|
const normalizedKey = key.toLowerCase().replace(/[-_\s]/g, '');
|
||||||
|
if (
|
||||||
|
normalizedKey.includes('secret') ||
|
||||||
|
normalizedKey.includes('token') ||
|
||||||
|
normalizedKey.includes('password') ||
|
||||||
|
normalizedKey.includes('privatekey') ||
|
||||||
|
normalizedKey.includes('apikey') ||
|
||||||
|
normalizedKey.includes('authorization')
|
||||||
|
) {
|
||||||
|
sanitized[key] = '[redacted]';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sanitized[key] = sanitizeProviderDetails(child, depth + 1);
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endpointHost(value: string) {
|
||||||
|
try {
|
||||||
|
return new URL(value).host;
|
||||||
|
} catch {
|
||||||
|
return 'invalid_endpoint';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function securityScanRules(asset: AssetCandidate, metadata: StorageObjectMetadata): SecurityScanResult {
|
function securityScanRules(asset: AssetCandidate, metadata: StorageObjectMetadata): SecurityScanResult {
|
||||||
const issueCodes: string[] = [];
|
const issueCodes: string[] = [];
|
||||||
const details: Record<string, unknown> = {
|
const details: Record<string, unknown> = {
|
||||||
@@ -585,6 +662,153 @@ function securityScanRules(asset: AssetCandidate, metadata: StorageObjectMetadat
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeSecurityScanResults(results: SecurityScanResult[]): SecurityScanResult {
|
||||||
|
const uniqueIssueCodes = Array.from(new Set(results.flatMap(result => result.issueCodes)));
|
||||||
|
const failed = results.find(result => result.status === 'failed');
|
||||||
|
const skipped = results.find(result => result.status === 'skipped');
|
||||||
|
return {
|
||||||
|
status: failed ? 'failed' : skipped ? 'skipped' : 'passed',
|
||||||
|
provider: results.map(result => result.provider).join('+'),
|
||||||
|
riskLevel: highestRiskLevel(results),
|
||||||
|
issueCodes: uniqueIssueCodes,
|
||||||
|
details: {
|
||||||
|
results: results.map(result => ({
|
||||||
|
provider: result.provider,
|
||||||
|
status: result.status,
|
||||||
|
riskLevel: result.riskLevel,
|
||||||
|
issueCodes: result.issueCodes,
|
||||||
|
details: result.details,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function externalHttpSecurityScan(
|
||||||
|
asset: AssetCandidate,
|
||||||
|
metadata: StorageObjectMetadata,
|
||||||
|
metadataRulesResult: SecurityScanResult,
|
||||||
|
): Promise<SecurityScanResult> {
|
||||||
|
const endpoint = config.assetSecurityScanHttpEndpoint.trim();
|
||||||
|
const provider = 'http_security_scan';
|
||||||
|
const unavailableIssue = ['external_security_scan_unavailable'];
|
||||||
|
const unavailableDetails = (error: unknown) => ({
|
||||||
|
endpointHost: endpoint ? endpointHost(endpoint) : null,
|
||||||
|
failOpen: config.assetSecurityScanFailOpen,
|
||||||
|
error: eventError(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!endpoint) {
|
||||||
|
return {
|
||||||
|
status: config.assetSecurityScanFailOpen ? 'passed' : 'failed',
|
||||||
|
provider,
|
||||||
|
riskLevel: config.assetSecurityScanFailOpen ? 'low' : 'high',
|
||||||
|
issueCodes: unavailableIssue,
|
||||||
|
details: unavailableDetails(new AssetWorkerError('WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT is required', 'ASSET_SECURITY_SCAN_HTTP_ENDPOINT_REQUIRED')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
assetId: asset.id,
|
||||||
|
tenantId: asset.tenantId,
|
||||||
|
assetType: asset.assetType,
|
||||||
|
storageProvider: asset.storageProvider,
|
||||||
|
bucket: asset.bucket,
|
||||||
|
objectKey: asset.objectKey,
|
||||||
|
fileName: asset.fileName,
|
||||||
|
mimeType: canonicalMime(metadata.mimeType) || canonicalMime(asset.mimeType),
|
||||||
|
fileSizeBytes: metadata.sizeBytes ?? normalizeSafeInteger(asset.fileSizeBytes),
|
||||||
|
checksumSha256: metadata.checksumSha256 || asset.verifiedChecksumSha256 || asset.checksumSha256,
|
||||||
|
metadata: {
|
||||||
|
observed: {
|
||||||
|
fileSizeBytes: metadata.sizeBytes,
|
||||||
|
mimeType: canonicalMime(metadata.mimeType),
|
||||||
|
checksumSha256: metadata.checksumSha256,
|
||||||
|
etag: metadata.etag,
|
||||||
|
lastModified: metadata.lastModified,
|
||||||
|
verificationSource: metadata.verificationSource,
|
||||||
|
},
|
||||||
|
declared: objectValue(metadataRulesResult.details).declared || {},
|
||||||
|
},
|
||||||
|
requestedAt: nowIso(),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithTimeout(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
accept: 'application/json',
|
||||||
|
...(config.assetSecurityScanHttpToken ? { authorization: `Bearer ${config.assetSecurityScanHttpToken}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}, config.assetSecurityScanHttpTimeoutMs);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new AssetWorkerError(`External security scanner returned HTTP ${response.status}`, 'ASSET_SECURITY_SCAN_HTTP_STATUS');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await response.json() as Record<string, unknown>;
|
||||||
|
const status = typeof body.status === 'string' ? body.status.trim().toLowerCase() : '';
|
||||||
|
if (status !== 'passed' && status !== 'failed') {
|
||||||
|
throw new AssetWorkerError('External security scanner returned invalid status', 'ASSET_SECURITY_SCAN_INVALID_RESPONSE');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyIssueCodes = normalizeIssueCodes(body.issueCodes, status === 'failed' ? ['external_security_scan_failed'] : []);
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
provider,
|
||||||
|
riskLevel: normalizeRiskLevel(body.riskLevel, status === 'failed' ? 'high' : 'none'),
|
||||||
|
issueCodes: bodyIssueCodes,
|
||||||
|
details: {
|
||||||
|
endpointHost: endpointHost(endpoint),
|
||||||
|
httpStatus: response.status,
|
||||||
|
scannerProvider: typeof body.provider === 'string' ? body.provider.slice(0, 120) : null,
|
||||||
|
scannerDetails: sanitizeProviderDetails(body.details || {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: config.assetSecurityScanFailOpen ? 'passed' : 'failed',
|
||||||
|
provider,
|
||||||
|
riskLevel: config.assetSecurityScanFailOpen ? 'low' : 'high',
|
||||||
|
issueCodes: unavailableIssue,
|
||||||
|
details: unavailableDetails(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSecurityScans(asset: AssetCandidate, metadata: StorageObjectMetadata): Promise<SecurityScanResult> {
|
||||||
|
const metadataRulesResult = securityScanRules(asset, metadata);
|
||||||
|
if (metadataRulesResult.status !== 'passed') {
|
||||||
|
return metadataRulesResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modes = scannerModes();
|
||||||
|
if (modes.length === 0 || (modes.length === 1 && modes[0] === 'metadata_rules')) {
|
||||||
|
return metadataRulesResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsupportedModes = modes.filter(mode => mode !== 'metadata_rules' && mode !== 'http');
|
||||||
|
if (unsupportedModes.length > 0) {
|
||||||
|
return mergeSecurityScanResults([
|
||||||
|
metadataRulesResult,
|
||||||
|
{
|
||||||
|
status: 'failed',
|
||||||
|
provider: 'asset_security_scanner_config',
|
||||||
|
riskLevel: 'high',
|
||||||
|
issueCodes: ['asset_security_scanner_unsupported'],
|
||||||
|
details: { configuredModes: unsupportedModes },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [metadataRulesResult];
|
||||||
|
if (modes.includes('http')) {
|
||||||
|
results.push(await externalHttpSecurityScan(asset, metadata, metadataRulesResult));
|
||||||
|
}
|
||||||
|
return mergeSecurityScanResults(results);
|
||||||
|
}
|
||||||
|
|
||||||
function eventError(error: unknown) {
|
function eventError(error: unknown) {
|
||||||
if (error instanceof AssetWorkerError) {
|
if (error instanceof AssetWorkerError) {
|
||||||
return { code: error.code, message: error.message };
|
return { code: error.code, message: error.message };
|
||||||
@@ -956,7 +1180,7 @@ async function processAsset(asset: AssetCandidate) {
|
|||||||
try {
|
try {
|
||||||
const metadata = await headStorageObject(asset);
|
const metadata = await headStorageObject(asset);
|
||||||
const comparison = compareAssetMetadata(asset, metadata);
|
const comparison = compareAssetMetadata(asset, metadata);
|
||||||
const scan = comparison.issues.length ? null : securityScanRules(asset, metadata);
|
const scan = comparison.issues.length ? null : await runSecurityScans(asset, metadata);
|
||||||
await client.query('begin');
|
await client.query('begin');
|
||||||
if (comparison.issues.length) {
|
if (comparison.issues.length) {
|
||||||
await markAssetFailed(client, asset, comparison.issues, metadata, null);
|
await markAssetFailed(client, asset, comparison.issues, metadata, null);
|
||||||
|
|||||||
@@ -98,10 +98,10 @@
|
|||||||
| 上传后对象校验 | 可联调 | `/api/tenant-content/assets/confirm-upload`;托管对象必须 verified 后才能发布/下载 |
|
| 上传后对象校验 | 可联调 | `/api/tenant-content/assets/confirm-upload`;托管对象必须 verified 后才能发布/下载 |
|
||||||
| PDF/图片预览签名 | 可联调 | `/api/catalog/assets/preview`、`/api/tenant-content/assets/sign-preview`;使用 inline 短期签名,学生预览默认短 TTL |
|
| 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` |
|
| 托管资源 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_assets.security_scan_status` + `content_asset_security_scan_events`;托管对象确认上传后进入 `pending/scanning`,内置 `metadata_rules` 和可选外部 HTTP scanner 通过后才可发布、下载、预览或视频播放;失败或外部 scanner 不可用默认 fail-closed,会下架并写审计 |
|
||||||
| 资源访问审计 | 可联调 | `content_asset_access_events` + `GET /api/tenant-content/assets/access-events`;记录上传签名/确认、学生下载/预览、后台下载/预览的 granted/denied、TTL、签名模式、IP 和 UA |
|
| 资源访问审计 | 可联调 | `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 访问边界 | 可联调 | `members/svip/private` 外部 CDN URL 默认拒绝,必须显式 `metadata.providerManagedAccess=true` 或 `cdnAccessMode=signed_by_provider`;视频绑定资源也复用该规则 |
|
||||||
| 深度防盗链/水印/杀毒 | 待补齐 | 商用上线前继续补真实 AV/内容安全 provider、动态水印、CDN 刷新和对象生命周期策略;内置 metadata 规则扫描已完成 |
|
| 深度防盗链/水印/杀毒 | 部分覆盖 | 内置 metadata 规则扫描、外部 HTTP scanner 接入层、失败关闭和生产 readiness 阻断已完成;商用上线前继续联调真实 AV/内容安全服务、动态水印、CDN 刷新和对象生命周期策略 |
|
||||||
|
|
||||||
## 订单、会员、营销
|
## 订单、会员、营销
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 |
|
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 |
|
||||||
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入 | 复杂筛选、AI 择校上下文 |
|
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入 | 复杂筛选、AI 择校上下文 |
|
||||||
| 视频解析 | 可联调 | 单题视频、批量查询、后台视频绑定、JSON/CSV/Excel 导入、会员播放权限、播放次数扣减、签名 URL 和播放日志 | 深度防盗链、动态水印、播放统计 |
|
| 视频解析 | 可联调 | 单题视频、批量查询、后台视频绑定、JSON/CSV/Excel 导入、会员播放权限、播放次数扣减、签名 URL 和播放日志 | 深度防盗链、动态水印、播放统计 |
|
||||||
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、访问审计、assets worker 复检和内置安全扫描、题库导出 PDF/Word/每日一练 ZIP 可生成可信 `content_assets` 并走签名下载/预览 | CDN 防盗链、真实 AV/内容安全 provider、视频水印、资料前端操作体验 |
|
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、访问审计、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层、题库导出 PDF/Word/每日一练 ZIP 可生成可信 `content_assets` 并走签名下载/预览 | CDN 防盗链、真实 AV/内容安全服务联调、视频水印、资料前端操作体验 |
|
||||||
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放、资金对账批次/明细/异常查询 API、对账差错工单和事件轨迹 | 微信/支付宝官方账单自动下载、财务复核报表和异常订单运营台 |
|
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放、资金对账批次/明细/异常查询 API、对账差错工单和事件轨迹 | 微信/支付宝官方账单自动下载、财务复核报表和异常订单运营台 |
|
||||||
| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、OAuth 配置表 | 真实生产账号和回调域名联调 |
|
| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、OAuth 配置表 | 真实生产账号和回调域名联调 |
|
||||||
| 销售/代理/CRM | 可联调 | 邀请码、首绑保护、团队关系、销售统计、CRM 配置/队列、钉钉/飞书/企微 worker、分佣规则、成员分佣比例、订单/激活码归因、结算生成、审核和打款状态;Taro 营销中心已接第一版操作台 | 小程序码真实生成、CRM 分配策略、结算导出、真实打款、凭证、财务复核和销售转化看板 |
|
| 销售/代理/CRM | 可联调 | 邀请码、首绑保护、团队关系、销售统计、CRM 配置/队列、钉钉/飞书/企微 worker、分佣规则、成员分佣比例、订单/激活码归因、结算生成、审核和打款状态;Taro 营销中心已接第一版操作台 | 小程序码真实生成、CRM 分配策略、结算导出、真实打款、凭证、财务复核和销售转化看板 |
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
### P0:上云测试和前端主链路前必须处理
|
### P0:上云测试和前端主链路前必须处理
|
||||||
|
|
||||||
- 生产鉴权:API 已支持 Supabase Auth JWT;继续做真实云端 Auth/JWKS 回归、RLS 深测,并在生产关闭 `x-user-id` 与 `x-platform-admin-key` 兼容入口。
|
- 生产鉴权: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 已完成,继续补视频播放防盗链、真实 AV/内容安全 provider 和动态水印。
|
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层和题库导出 PDF/Word/每日一练 ZIP worker 已完成,继续补视频播放防盗链、真实 AV/内容安全服务联调和动态水印。
|
||||||
- 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。
|
- 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。
|
||||||
- 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
|
- 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
|
||||||
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版,平台后台关键写操作第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列和分佣结算操作台第一版;下一步补公式图片混排、更细数据范围 UI、平台后台审计增强和小程序兼容验证。
|
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版,平台后台关键写操作第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列和分佣结算操作台第一版;下一步补公式图片混排、更细数据范围 UI、平台后台审计增强和小程序兼容验证。
|
||||||
|
|||||||
@@ -259,14 +259,14 @@ GET /api/tenant-admin/audit-logs
|
|||||||
- 学生批量导入、批量分班、学生状态、备注和跟进任务都使用独立权限点;教师默认可为范围内学生写备注和跟进任务,但不能批量导入、禁用学生或放大可见班级。
|
- 学生批量导入、批量分班、学生状态、备注和跟进任务都使用独立权限点;教师默认可为范围内学生写备注和跟进任务,但不能批量导入、禁用学生或放大可见班级。
|
||||||
- 销售/代理客资采用首绑保护:普通扫码/分享事件不会覆盖已有归属,只有具备 `referral:write` 的租户成员可手动强制补绑。
|
- 销售/代理客资采用首绑保护:普通扫码/分享事件不会覆盖已有归属,只有具备 `referral:write` 的租户成员可手动强制补绑。
|
||||||
- CRM 当前完成配置、密钥入私密表、客资入队、队列查询和 `apps/worker` 消费;worker 支持 generic webhook、钉钉、飞书、企业微信机器人消息体/签名、失败重试和日志。
|
- CRM 当前完成配置、密钥入私密表、客资入队、队列查询和 `apps/worker` 消费;worker 支持 generic webhook、钉钉、飞书、企业微信机器人消息体/签名、失败重试和日志。
|
||||||
- 内容资源当前完成台账、租户后台维护、学生端 SVIP 下载权限,以及 `local_dev`、阿里云 OSS、腾讯 COS、Supabase Storage 的上传/下载签名 provider;上传确认、访问审计、assets worker 对象元数据校验/复检和内置 `metadata_rules` 安全扫描已完成。PDF 深度预览体验、防盗链、水印和真实 AV/内容安全 provider 仍需继续补。
|
- 内容资源当前完成台账、租户后台维护、学生端 SVIP 下载权限,以及 `local_dev`、阿里云 OSS、腾讯 COS、Supabase Storage 的上传/下载签名 provider;上传确认、访问审计、assets worker 对象元数据校验/复检、内置 `metadata_rules` 安全扫描和外部 HTTP scanner 接入层已完成。PDF 深度预览体验、防盗链、水印和真实 AV/内容安全服务联调仍需继续补。
|
||||||
- 题库内容导航当前以 `content_entries/content_nodes` 为主模型,可表达“入口 -> 多级分类 -> 院校/专业/学科/销售意向标记”;题目集合和练习方式由 `question_collections/practice_blueprints` 管理,练习 session 会保存当次题目 ID 快照。
|
- 题库内容导航当前以 `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 不消耗免费额度。
|
- 练习访问控制由 `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 待接。
|
- 批量导入当前支持题目、单词、知识手册、分数线、视频 JSON/CSV/Excel 预览、逐行 issue、job/item 台账、同步执行或 `executionMode=async` 异步执行、幂等跳过,并可落到新内容入口、分类节点、分数线表或题目视频绑定。旧单词模板的 `vocabulary_units_示例数据` / `vocabulary_示例数据`、知识手册的书籍/章节/小节/知识点嵌套结构都由后端规范化。导入后复检、模板下载和字段映射 API 已补齐,前端 UI 待接。
|
||||||
|
|
||||||
## 下一步
|
## 下一步
|
||||||
|
|
||||||
1. 完善内容导入和文件上传:字段映射 UI、真实数据 dry-run、PDF 预览渲染、防盗链、真实 AV/内容安全 provider 和视频水印。
|
1. 完善内容导入和文件上传:字段映射 UI、真实数据 dry-run、PDF 预览渲染、防盗链、真实 AV/内容安全服务联调和视频水印。
|
||||||
2. 完成真实短信 provider 联调:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
|
2. 完成真实短信 provider 联调:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
|
||||||
3. 完成真实 OAuth provider 联调:微信网页、微信小程序、QQ,确认回调域名、开放平台账号和旧 PocketBase 身份映射策略。
|
3. 完成真实 OAuth provider 联调:微信网页、微信小程序、QQ,确认回调域名、开放平台账号和旧 PocketBase 身份映射策略。
|
||||||
4. 补微信/支付宝官方账单自动下载、异常订单运营台和优惠券核销报表;支付/退款补偿、退款查询确认、退款通知、资金对账导入比对和差错工单主链路已完成。
|
4. 补微信/支付宝官方账单自动下载、异常订单运营台和优惠券核销报表;支付/退款补偿、退款查询确认、退款通知、资金对账导入比对和差错工单主链路已完成。
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
| 知识手册 | 基础完成 | 科目、章节、条目只读与后台维护、书籍/章节/小节/知识点嵌套 JSON 预览导入、内容导航绑定 | 富文本资源、版本管理、附件/PDF 关联、Excel/Markdown 批量解析 |
|
| 知识手册 | 基础完成 | 科目、章节、条目只读与后台维护、书籍/章节/小节/知识点嵌套 JSON 预览导入、内容导航绑定 | 富文本资源、版本管理、附件/PDF 关联、Excel/Markdown 批量解析 |
|
||||||
| 分数线 | 可联调 | 字段、院校、专业、记录、趋势、年份、JSON 批量导入 | 复杂动态筛选、AI 择校数据上下文 |
|
| 分数线 | 可联调 | 字段、院校、专业、记录、趋势、年份、JSON 批量导入 | 复杂动态筛选、AI 择校数据上下文 |
|
||||||
| 视频解析会员 | 可联调 | 题目视频、批量查询、后台绑定、视频 JSON 导入、SVIP 权限、播放次数扣减、签名 URL、播放日志 | 深度防盗链、动态水印、播放统计 |
|
| 视频解析会员 | 可联调 | 题目视频、批量查询、后台绑定、视频 JSON 导入、SVIP 权限、播放次数扣减、签名 URL、播放日志 | 深度防盗链、动态水印、播放统计 |
|
||||||
| 资料下载/PDF | 可联调 | `content_assets` 资源台账、后台资源管理、OSS/COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、学生端列表、SVIP 下载权限、assets worker 复检和内置安全扫描 | CDN 防盗链、真实 AV/内容安全 provider、资料前端管理页 |
|
| 资料下载/PDF | 可联调 | `content_assets` 资源台账、后台资源管理、OSS/COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、学生端列表、SVIP 下载权限、assets worker 复检、内置安全扫描和外部 HTTP scanner 接入层 | CDN 防盗链、真实 AV/内容安全服务联调、资料前端管理页 |
|
||||||
| 营销中心 | 基础完成 | SVIP 套餐、激活码批次、激活码生成、优惠券、Banner/FAQ/公告、勋章管理和手动发放 | 勋章自动发放、复杂活动规则、核销报表 |
|
| 营销中心 | 基础完成 | SVIP 套餐、激活码批次、激活码生成、优惠券、Banner/FAQ/公告、勋章管理和手动发放 | 勋章自动发放、复杂活动规则、核销报表 |
|
||||||
| 销售/代理客资 | 可联调 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、团队关系、手动补绑、分佣比例、归因、结算单、审核和打款状态 | 真实微信小程序码、真实打款、结算导出、销售团队看板 |
|
| 销售/代理客资 | 可联调 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、团队关系、手动补绑、分佣比例、归因、结算单、审核和打款状态 | 真实微信小程序码、真实打款、结算导出、销售团队看板 |
|
||||||
| CRM 系统 | 可联调 | CRM 配置、密钥私密存储、客资入队、队列查询、generic/钉钉/飞书/企微 worker、签名、重试和日志 | 定向/轮询分配、富卡片模板、失败告警、死信运营台 |
|
| CRM 系统 | 可联调 | CRM 配置、密钥私密存储、客资入队、队列查询、generic/钉钉/飞书/企微 worker、签名、重试和日志 | 定向/轮询分配、富卡片模板、失败告警、死信运营台 |
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
## 接下来优先级
|
## 接下来优先级
|
||||||
|
|
||||||
1. 完善内容导入和对象存储:字段映射 UI、真实数据 dry-run、CDN 防盗链、真实 AV/内容安全 provider 和视频水印。
|
1. 完善内容导入和对象存储:字段映射 UI、真实数据 dry-run、CDN 防盗链、真实 AV/内容安全服务联调和视频水印。
|
||||||
2. 公共题库/地区题库授权:已完成披露、采纳快照、手动同步、自动同步 worker、同步通知、冲突查询和租户自改保护;继续补生产失败告警、冲突操作台增强和按 SaaS 套餐限制地区。
|
2. 公共题库/地区题库授权:已完成披露、采纳快照、手动同步、自动同步 worker、同步通知、冲突查询和租户自改保护;继续补生产失败告警、冲突操作台增强和按 SaaS 套餐限制地区。
|
||||||
3. 学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
|
3. 学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
|
||||||
4. 视频会员控制:深度防盗链、水印和播放统计。
|
4. 视频会员控制:深度防盗链、水印和播放统计。
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
- 生产鉴权已具备 Supabase JWT API 入口,自定义角色模板基础 API 已可用;仍要做真实云端 Auth/JWKS 回归、RLS 深测和班级/学生范围权限细化,前端不要继续使用 `x-user-id`。
|
- 生产鉴权已具备 Supabase JWT API 入口,自定义角色模板基础 API 已可用;仍要做真实云端 Auth/JWKS 回归、RLS 深测和班级/学生范围权限细化,前端不要继续使用 `x-user-id`。
|
||||||
- 不要把“Supabase 支持前端 Data API”误解为“本项目所有业务表都由 Taro 直写”;订单、支付、权益、租户后台、导入、CRM、私有资源必须走 RPC、`apps/api`、Edge Function 或 worker 这类后端命令层。
|
- 不要把“Supabase 支持前端 Data API”误解为“本项目所有业务表都由 Taro 直写”;订单、支付、权益、租户后台、导入、CRM、私有资源必须走 RPC、`apps/api`、Edge Function 或 worker 这类后端命令层。
|
||||||
- 短信、微信小程序/网页登录、QQ 登录、微信支付、支付宝支付 provider 已有本地 adapter 和测试覆盖;生产账号、回调域名、证书和商户资料仍需正式联调。
|
- 短信、微信小程序/网页登录、QQ 登录、微信支付、支付宝支付 provider 已有本地 adapter 和测试覆盖;生产账号、回调域名、证书和商户资料仍需正式联调。
|
||||||
- 对象存储已完成签名 provider、上传后校验、PDF/图片预览、资源复检 worker 和内置安全扫描,但 CDN 防盗链、视频水印和真实 AV/内容安全 provider 还要补。
|
- 对象存储已完成签名 provider、上传后校验、PDF/图片预览、资源复检 worker、内置安全扫描和外部 HTTP scanner 接入层;CDN 防盗链、视频水印和真实 AV/内容安全服务联调还要补。
|
||||||
- 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入已可联调;大批量导入可传 `executionMode=async` 交给 imports worker;模板下载、字段映射 API、导入任务详情和导入后复检已可用。租户内容页已经可以选择文件或粘贴内容、下载模板、执行后端预览、编辑本次字段别名、同步/异步提交导入、轮询异步 job、查看问题行并触发/查看复检;后续还要补真实数据 dry-run 验收和更完整的目标入口/集合选择。
|
- 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入已可联调;大批量导入可传 `executionMode=async` 交给 imports worker;模板下载、字段映射 API、导入任务详情和导入后复检已可用。租户内容页已经可以选择文件或粘贴内容、下载模板、执行后端预览、编辑本次字段别名、同步/异步提交导入、轮询异步 job、查看问题行并触发/查看复检;后续还要补真实数据 dry-run 验收和更完整的目标入口/集合选择。
|
||||||
- 数据看板、分佣结算、勋章手动发放和主题模板发布基础 API 已可联调;勋章自动发放、分佣真实打款/导出/凭证、AI 择校、主题素材库/模板市场等仍是后续商用增强项。
|
- 数据看板、分佣结算、勋章手动发放和主题模板发布基础 API 已可联调;勋章自动发放、分佣真实打款/导出/凭证、AI 择校、主题素材库/模板市场等仍是后续商用增强项。
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
| 知识手册 | 已建手册科目、章节、条目,并可绑定 `content_entries/content_nodes` | 已支持内容导入 | 只读 API、租户后台手册科目/章节/条目维护 API、嵌套 JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 学生端阅读、后台维护和批量 JSON 导入基础可用,富文本资源/版本管理待补 |
|
| 知识手册 | 已建手册科目、章节、条目,并可绑定 `content_entries/content_nodes` | 已支持内容导入 | 只读 API、租户后台手册科目/章节/条目维护 API、嵌套 JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 学生端阅读、后台维护和批量 JSON 导入基础可用,富文本资源/版本管理待补 |
|
||||||
| 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API、JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 查询、后台维护和批量 JSON 导入基础闭环已实现,复杂动态筛选和 AI 择校上下文待补 |
|
| 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API、JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 查询、后台维护和批量 JSON 导入基础闭环已实现,复杂动态筛选和 AI 择校上下文待补 |
|
||||||
| 题目视频讲解 | 已建 `video_explanations`、`question_videos` | 已支持导入映射 | 单题视频、批量预加载、通用视频搜索、播放签名、视频次数扣减、租户后台视频创建绑定 API、JSON 预览导入已实现 | 核心 API 集成测试含播放和导入断言 | 播放、权益、后台绑定和批量 JSON 导入链路已实现,深度防盗链、动态水印和播放统计待补 |
|
| 题目视频讲解 | 已建 `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 防盗链、真实 AV/内容安全 provider、动态水印和资料下载前端待补 |
|
| 资料下载/PDF | 已扩展 `content_assets`,新增资源台账和导入任务表 | 旧 `app_assets/images` 兼容导入 | 租户后台资源管理、OSS/COS/Supabase Storage 上传/下载签名、上传确认、PDF/图片预览签名、资源访问审计、内置安全扫描、外部 HTTP scanner 接入层、题库导出 PDF/Word/每日一练 ZIP 自动发布可信资源、学生端资料列表/下载权限已实现 | 核心 API 集成测试含 SVIP 资料下载、安全扫描门禁,assets worker 覆盖内置规则与外部 scanner 通过/失败/不可用 fail-closed,exports worker 测试 | 资料资源基础闭环可跑,真实 AV/内容安全服务联调、CDN 防盗链、动态水印和资料下载前端待补 |
|
||||||
| 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录、`badges/user_badges` | 已支持部分用户资料和勋章导入 | 个人资料、目标院校/专业、手机号绑定/换绑、会员状态、最近练习、统计聚合、签到积分、题目反馈、考试倒计时、勋章 API 已实现 | API 集成测试 | 学生端基础个人中心已实现,学习报告可视化和更细任务系统待补 |
|
| 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录、`badges/user_badges` | 已支持部分用户资料和勋章导入 | 个人资料、目标院校/专业、手机号绑定/换绑、会员状态、最近练习、统计聚合、签到积分、题目反馈、考试倒计时、勋章 API 已实现 | API 集成测试 | 学生端基础个人中心已实现,学习报告可视化和更细任务系统待补 |
|
||||||
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章等基础表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、勋章维护和手动发放已实现 | 核心 API 集成测试 | 基础运营后台可用,勋章自动发放、复杂活动规则、营销自动化、核销报表待补 |
|
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章等基础表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、勋章维护和手动发放已实现 | 核心 API 集成测试 | 基础运营后台可用,勋章自动发放、复杂活动规则、营销自动化、核销报表待补 |
|
||||||
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用,真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 |
|
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用,真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 |
|
||||||
@@ -295,7 +295,7 @@ platform-admin:
|
|||||||
|
|
||||||
为了先把旧项目核心业务补齐,再进入支付/短信等商用关键模块,建议按下面顺序继续:
|
为了先把旧项目核心业务补齐,再进入支付/短信等商用关键模块,建议按下面顺序继续:
|
||||||
|
|
||||||
1. 完善内容导入和文件上传:字段映射 UI、真实数据 dry-run 执行验收、CDN 防盗链、真实 AV/内容安全 provider。
|
1. 完善内容导入和文件上传:字段映射 UI、真实数据 dry-run 执行验收、CDN 防盗链、真实 AV/内容安全服务联调。
|
||||||
2. 补公共题库生产定时调度/失败告警、租户套餐地区/科目/题库范围限制、主题模板系统。
|
2. 补公共题库生产定时调度/失败告警、租户套餐地区/科目/题库范围限制、主题模板系统。
|
||||||
3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
|
3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
|
||||||
4. 补视频商用控制:深度防盗链、动态水印和播放统计。
|
4. 补视频商用控制:深度防盗链、动态水印和播放统计。
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势、后台维护和 JSON 批量导入已有;后续补复杂筛选优化和 AI 择校数据上下文 |
|
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势、后台维护和 JSON 批量导入已有;后续补复杂筛选优化和 AI 择校数据上下文 |
|
||||||
| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker、全额退款权益撤销、资金对账手工/API 导入比对、异常查询、差错工单和事件轨迹已有;缺微信/支付宝官方账单自动下载、异常订单运营台和前端收银台/售后体验 |
|
| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker、全额退款权益撤销、资金对账手工/API 导入比对、异常查询、差错工单和事件轨迹已有;缺微信/支付宝官方账单自动下载、异常订单运营台和前端收银台/售后体验 |
|
||||||
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、手机号绑定/换绑、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时、趋势和勋章展示 API 已有;缺学习报告可视化 |
|
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、手机号绑定/换绑、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时、趋势和勋章展示 API 已有;缺学习报告可视化 |
|
||||||
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载、PDF/图片预览、worker 复检和内置安全扫描已有;缺动态水印、深度防盗链、真实 AV/内容安全 provider 和生命周期策略 |
|
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载、PDF/图片预览、worker 复检、内置安全扫描和外部 HTTP scanner 接入层已有;缺动态水印、深度防盗链、真实 AV/内容安全服务联调和生命周期策略 |
|
||||||
| AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 |
|
| AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 |
|
||||||
| 题目反馈 | `02-API接口.md` 用户反馈 | 部分覆盖 | 学生提交、本人列表、租户后台处理、状态事件、反馈奖励积分已覆盖;缺处理通知、前端消息提醒和批量统计 |
|
| 题目反馈 | `02-API接口.md` 用户反馈 | 部分覆盖 | 学生提交、本人列表、租户后台处理、状态事件、反馈奖励积分已覆盖;缺处理通知、前端消息提醒和批量统计 |
|
||||||
| 签到积分 | `Profile.tsx`、`02-API接口.md` | 部分覆盖 | 每日签到、连续签到基础、积分流水、重复签到幂等已覆盖;缺积分兑换、活动任务和更完整的运营规则 |
|
| 签到积分 | `Profile.tsx`、`02-API接口.md` | 部分覆盖 | 每日签到、连续签到基础、积分流水、重复签到幂等已覆盖;缺积分兑换、活动任务和更完整的运营规则 |
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
| 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 和 JSON 批量导入已有 |
|
| 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 和 JSON 批量导入已有 |
|
||||||
| 视频维护/绑定 | 已覆盖 | video CRUD、question-video 绑定和视频 JSON 批量导入已有 |
|
| 视频维护/绑定 | 已覆盖 | video CRUD、question-video 绑定和视频 JSON 批量导入已有 |
|
||||||
| CRM 配置和队列 | 部分覆盖 | 配置/队列已有;钉钉/飞书/企微真实发送 worker、签名、重试、死信待补 |
|
| CRM 配置和队列 | 部分覆盖 | 配置/队列已有;钉钉/飞书/企微真实发送 worker、签名、重试、死信待补 |
|
||||||
| 对象存储配置 | 部分覆盖 | 系统 env provider、上传签名、上传确认、预览下载签名、资源访问审计和内置安全扫描已有;租户级存储策略、CDN/水印/真实 AV provider 待补 |
|
| 对象存储配置 | 部分覆盖 | 系统 env provider、上传签名、上传确认、预览下载签名、资源访问审计、内置安全扫描和外部 HTTP scanner 接入层已有;租户级存储策略、CDN/水印/真实 AV 服务联调待补 |
|
||||||
|
|
||||||
## 平台 SaaS 后台功能
|
## 平台 SaaS 后台功能
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
### P1:商用主链路
|
### P1:商用主链路
|
||||||
|
|
||||||
1. 微信/支付宝支付和 webhook 幂等。
|
1. 微信/支付宝支付和 webhook 幂等。
|
||||||
2. 对象存储真实 AV/内容安全 provider、视频深度防盗链、动态水印。
|
2. 对象存储真实 AV/内容安全服务联调、视频深度防盗链、动态水印。
|
||||||
3. 真实数据 dry-run 执行验收、导入抽样校验和性能压测。
|
3. 真实数据 dry-run 执行验收、导入抽样校验和性能压测。
|
||||||
4. 数据看板预聚合 worker、销售/代理转化看板和分佣结算。
|
4. 数据看板预聚合 worker、销售/代理转化看板和分佣结算。
|
||||||
5. 公共题库生产定时调度、失败告警、租户确认/跳过策略和更完整运营消息。
|
5. 公共题库生产定时调度、失败告警、租户确认/跳过策略和更完整运营消息。
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
- 租户后台数据看板已完成首版聚合 API:`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。
|
- 租户后台数据看板已完成首版聚合 API:`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。
|
||||||
- 支付/退款补偿 worker 已完成:`apps/worker --job commerce` 可查询微信/支付宝支付和处理中退款,补偿漏通知订单,支付成功幂等开通权益,退款成功幂等更新退款/订单/支付并在全额退款时撤销订单权益。
|
- 支付/退款补偿 worker 已完成:`apps/worker --job commerce` 可查询微信/支付宝支付和处理中退款,补偿漏通知订单,支付成功幂等开通权益,退款成功幂等更新退款/订单/支付并在全额退款时撤销订单权益。
|
||||||
- 资金对账和差错工单闭环已完成:`commerce_reconciliation_batches/items` 和 `/api/commerce/reconciliation/*` 支持手工/API 导入供应商账单行、预览差异、生成批次统计、查询异常、租户隔离、权限点 `tenant:reconciliation:read/write` 和审计日志;`commerce_reconciliation_issues/events` 支持异常明细创建工单、分配、开始处理、升级、解决、忽略、重开和事件留痕,且不直接修改订单/支付/退款/权益。
|
- 资金对账和差错工单闭环已完成:`commerce_reconciliation_batches/items` 和 `/api/commerce/reconciliation/*` 支持手工/API 导入供应商账单行、预览差异、生成批次统计、查询异常、租户隔离、权限点 `tenant:reconciliation:read/write` 和审计日志;`commerce_reconciliation_issues/events` 支持异常明细创建工单、分配、开始处理、升级、解决、忽略、重开和事件留痕,且不直接修改订单/支付/退款/权益。
|
||||||
- 内容资源复检与安全扫描 worker 已完成:`apps/worker --job assets` 可复检 `content_assets` 中的托管对象元数据,并执行内置 `metadata_rules` 安全扫描;正常资源写回复检/扫描证据,异常资源自动置为 `failed/skipped + draft` 或 `security_scan_status=failed`,并写入审计、扫描事件和安全标记。
|
- 内容资源复检与安全扫描 worker 已完成:`apps/worker --job assets` 可复检 `content_assets` 中的托管对象元数据,并执行内置 `metadata_rules` 和可选外部 HTTP scanner;正常资源写回复检/扫描证据,异常资源自动置为 `failed/skipped + draft` 或 `security_scan_status=failed`,外部 scanner 不可用默认 fail-closed,并写入审计、扫描事件和安全标记。
|
||||||
- 题库导出 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。
|
- 题库导出 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` 已通过。
|
- 本地验证:`npm run check:refactor` 已通过。
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
- 已补内容资源安全扫描状态机:确认上传后进入 `securityScanStatus=pending`,扫描通过后才能发布、下载、预览或视频播放;后台可查 `/api/tenant-content/assets/security-scan-events`。
|
- 已补内容资源安全扫描状态机:确认上传后进入 `securityScanStatus=pending`,扫描通过后才能发布、下载、预览或视频播放;后台可查 `/api/tenant-content/assets/security-scan-events`。
|
||||||
- 已补资源访问事件 `content_asset_access_events`,覆盖上传签名/确认、学生下载/预览、后台下载/预览的 granted/denied、短 TTL、签名模式、IP 和 UA。
|
- 已补资源访问事件 `content_asset_access_events`,覆盖上传签名/确认、学生下载/预览、后台下载/预览的 granted/denied、短 TTL、签名模式、IP 和 UA。
|
||||||
- 已收紧锁定资源 CDN 边界:`members/svip/private` 外链默认拒绝,必须显式 provider-managed 才允许;视频绑定资源也复用该策略。
|
- 已收紧锁定资源 CDN 边界:`members/svip/private` 外链默认拒绝,必须显式 provider-managed 才允许;视频绑定资源也复用该策略。
|
||||||
- 继续补真实 AV/内容安全 provider、动态水印、CDN 刷新和对象生命周期策略。
|
- 已补外部 HTTP 杀毒/内容安全 scanner 接入层、失败关闭和生产 readiness 阻断;继续联调真实扫描服务、动态水印、CDN 刷新和对象生命周期策略。
|
||||||
- `content_assets` 继续作为资源台账,不允许前端绕过台账直接访问私有资源。
|
- `content_assets` 继续作为资源台账,不允许前端绕过台账直接访问私有资源。
|
||||||
|
|
||||||
3. 真实导入 dry-run
|
3. 真实导入 dry-run
|
||||||
@@ -226,5 +226,5 @@
|
|||||||
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
||||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||||
5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
|
5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
|
||||||
6. 并行补真实登录、微信/支付宝官方账单自动下载、异常订单运营台、对象存储真实 AV/内容安全 provider、水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
|
6. 并行补真实登录、微信/支付宝官方账单自动下载、异常订单运营台、对象存储真实 AV/内容安全服务联调、水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
|
||||||
7. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
7. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ npm --workspace @tiku-saas/worker run exports:once
|
|||||||
3. 调用 `PUT /api/tenant-content/assets` 登记资源台账。托管对象默认进入 `status=draft`、`uploadStatus=pending`。
|
3. 调用 `PUT /api/tenant-content/assets` 登记资源台账。托管对象默认进入 `status=draft`、`uploadStatus=pending`。
|
||||||
4. 调用 `POST /api/tenant-content/assets/confirm-upload`,由后端读取对象元数据并比对大小、MIME、SHA-256。
|
4. 调用 `POST /api/tenant-content/assets/confirm-upload`,由后端读取对象元数据并比对大小、MIME、SHA-256。
|
||||||
5. 校验通过后,后端只会置为 `uploadStatus=verified`、`securityScanStatus=pending`,并继续保持 `status=draft`。即使请求里传 `publish=true`,也不会绕过安全扫描直接发布。
|
5. 校验通过后,后端只会置为 `uploadStatus=verified`、`securityScanStatus=pending`,并继续保持 `status=draft`。即使请求里传 `publish=true`,也不会绕过安全扫描直接发布。
|
||||||
6. 运行 assets worker。worker 会先复检对象元数据,再执行内置 `metadata_rules` 安全扫描,检查 object key、MIME allowlist、文件大小、扩展名/MIME 是否匹配等规则。
|
6. 运行 assets worker。worker 会先复检对象元数据,再执行内置 `metadata_rules` 安全扫描,检查 object key、MIME allowlist、文件大小、扩展名/MIME 是否匹配等规则;生产可配置 `WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http`,在内置规则通过后调用外部 HTTP 杀毒/内容安全服务。
|
||||||
7. 扫描通过后,资源变为 `securityScanStatus=passed`,后台再调用 `PUT /api/tenant-content/assets` 将 `status=active` 发布。
|
7. 扫描通过后,资源变为 `securityScanStatus=passed`,后台再调用 `PUT /api/tenant-content/assets` 将 `status=active` 发布。
|
||||||
8. 学生端只能下载或预览 `active + uploadStatus=verified + securityScanStatus=passed` 的托管对象资源;后台管理员下载/预览也执行同一安全扫描门禁。
|
8. 学生端只能下载或预览 `active + uploadStatus=verified + securityScanStatus=passed` 的托管对象资源;后台管理员下载/预览也执行同一安全扫描门禁。
|
||||||
9. 学生端和后台管理员下载/预览都会写入 `content_asset_access_events`,包含 `assetId/userId/accessType/result/expiresInSec/signatureMode/ip/userAgent` 等审计字段。
|
9. 学生端和后台管理员下载/预览都会写入 `content_asset_access_events`,包含 `assetId/userId/accessType/result/expiresInSec/signatureMode/ip/userAgent` 等审计字段。
|
||||||
@@ -168,7 +168,48 @@ npm --workspace @tiku-saas/worker run exports:once
|
|||||||
| `security_scan_provider` | 扫描来源,例如 `metadata_rules`、`trusted_export_worker` |
|
| `security_scan_provider` | 扫描来源,例如 `metadata_rules`、`trusted_export_worker` |
|
||||||
| `security_scan_summary` | 风险等级、问题码、扫描证据和失败原因 |
|
| `security_scan_summary` | 风险等级、问题码、扫描证据和失败原因 |
|
||||||
|
|
||||||
当前内置 provider 是 `metadata_rules`。它不是完整杀毒引擎,但能阻断明显危险或不合规对象:跨租户 key、非法 object key、超限文件、MIME allowlist 外文件、扩展名/MIME 不匹配,以及测试/运营标记的强制失败。商用生产仍需继续接入真实 AV/内容安全 provider,并将结果写入同一张事件表。
|
内置 provider 是 `metadata_rules`。它不是完整杀毒引擎,但能阻断明显危险或不合规对象:跨租户 key、非法 object key、超限文件、MIME allowlist 外文件、扩展名/MIME 不匹配,以及测试/运营标记的强制失败。
|
||||||
|
|
||||||
|
生产环境支持可插拔 HTTP provider:`WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http`。worker 会在 metadata 规则通过后调用外部扫描服务,并把外部扫描结果和内置规则合并为一次最终结果写回 `content_assets`。如果外部服务返回失败、响应无效、超时或不可用,默认 `fail-open=false`,资源会被标记为 `securityScanStatus=failed` 并从 `active` 退回 `draft`。生产 readiness 会阻断没有外部 scanner、HTTP endpoint 非 HTTPS、token 弱或开启 fail-open 的配置。
|
||||||
|
|
||||||
|
HTTP scanner 请求由 worker 发起,前端不会接触扫描服务地址、token 或对象存储密钥。请求示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"assetId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"tenantId": "00000000-0000-0000-0000-000000000001",
|
||||||
|
"assetType": "pdf",
|
||||||
|
"storageProvider": "aliyun_oss",
|
||||||
|
"bucket": "tenant-assets",
|
||||||
|
"objectKey": "tenant-id/assets/file.pdf",
|
||||||
|
"fileName": "file.pdf",
|
||||||
|
"mimeType": "application/pdf",
|
||||||
|
"fileSizeBytes": 4096,
|
||||||
|
"checksumSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
"metadata": {
|
||||||
|
"observed": {},
|
||||||
|
"declared": {}
|
||||||
|
},
|
||||||
|
"requestedAt": "2026-06-29T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP scanner 响应契约:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "passed",
|
||||||
|
"riskLevel": "none",
|
||||||
|
"issueCodes": [],
|
||||||
|
"provider": "clamav",
|
||||||
|
"details": {
|
||||||
|
"engine": "clamav",
|
||||||
|
"signature": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`status` 只允许 `passed` 或 `failed`;`riskLevel` 只允许 `none/low/medium/high/critical`。`details` 会经过脱敏后写入扫描事件,`secret/token/password/privateKey/apiKey/authorization` 等字段会被替换为 `[redacted]`。
|
||||||
|
|
||||||
扫描事件表:
|
扫描事件表:
|
||||||
|
|
||||||
@@ -273,6 +314,11 @@ WORKER_ASSET_BATCH_SIZE=50
|
|||||||
WORKER_ASSET_MIN_AGE_SECONDS=300
|
WORKER_ASSET_MIN_AGE_SECONDS=300
|
||||||
WORKER_ASSET_RECHECK_INTERVAL_SECONDS=86400
|
WORKER_ASSET_RECHECK_INTERVAL_SECONDS=86400
|
||||||
WORKER_ASSET_REQUEST_TIMEOUT_MS=10000
|
WORKER_ASSET_REQUEST_TIMEOUT_MS=10000
|
||||||
|
WORKER_ASSET_SECURITY_SCANNER=metadata_rules
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS=10000
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
|
||||||
```
|
```
|
||||||
|
|
||||||
## 生产建议
|
## 生产建议
|
||||||
@@ -282,7 +328,7 @@ WORKER_ASSET_REQUEST_TIMEOUT_MS=10000
|
|||||||
- 图片、PDF、视频分别设置合理的 CORS,只允许前端域名和小程序业务域名访问。
|
- 图片、PDF、视频分别设置合理的 CORS,只允许前端域名和小程序业务域名访问。
|
||||||
- 开启对象版本控制、生命周期、跨区域复制或定时备份,满足后续容灾要求。
|
- 开启对象版本控制、生命周期、跨区域复制或定时备份,满足后续容灾要求。
|
||||||
- 视频资源已接入 SVIP/播放次数校验、短期签名和播放日志;生产阶段继续补转码、动态水印、CDN 防盗链和播放统计。
|
- 视频资源已接入 SVIP/播放次数校验、短期签名和播放日志;生产阶段继续补转码、动态水印、CDN 防盗链和播放统计。
|
||||||
- 大文件上传已经支持 API 即时确认和 worker 元数据复检;后续继续补杀毒、转码、水印和 CDN 刷新。
|
- 大文件上传已经支持 API 即时确认、worker 元数据复检、内置规则扫描和外部 HTTP 扫描契约;生产阶段必须接入真实扫描服务 endpoint/token,并继续补转码、水印、CDN 刷新和生命周期策略。
|
||||||
|
|
||||||
## 官方依据
|
## 官方依据
|
||||||
|
|
||||||
|
|||||||
@@ -948,7 +948,14 @@ GET /api/tenant-content/assets/security-scan-events?assetId=<assetId>&limit=100
|
|||||||
|
|
||||||
`GET /api/tenant-content/assets` 和学生端 `GET /api/catalog/assets` 都会返回 `securityScanStatus`。前端可以展示状态,但最终能否下载、预览、播放仍以后端签名接口为准。
|
`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 作为长期状态。
|
生产环境会定时运行 assets worker 复检对象存储元数据并执行内置 `metadata_rules` 安全扫描;上线配置应启用 `WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http`,由 worker 调用外部 HTTP 杀毒/内容安全服务。复检发现对象丢失、跨租户 objectKey、大小/MIME/checksum 不一致时,后端会把资源置为 `uploadStatus=failed`、`securityScanStatus=skipped` 并从 `active` 退回 `draft`,同时写入 `securityFlags.assetRecheckFailed=true`。扫描发现 MIME 不允许、扩展名/MIME 不匹配、外部 scanner 判定风险,或外部 scanner 不可用且 fail-closed 时,会把资源置为 `securityScanStatus=failed`,并写入 `securityFlags.assetSecurityScanFailed=true`。
|
||||||
|
|
||||||
|
前端处理规则:
|
||||||
|
|
||||||
|
- 租户后台资源列表应展示 `securityScanStatus=pending/scanning/failed/skipped/passed`,其中 `failed/skipped` 展示异常原因和重新上传入口。
|
||||||
|
- 租户后台不能用前端状态绕过发布;即使 UI 显示处理中,发布/下载/预览仍以后端接口返回为准。
|
||||||
|
- 学生端不要缓存资料列表和签名 URL 作为长期状态;每次预览/下载/播放前重新请求后端签名。
|
||||||
|
- 前端不调用外部扫描服务,也不接触 scanner endpoint/token;扫描证据只通过 `GET /api/tenant-content/assets/security-scan-events` 给后台排查。
|
||||||
|
|
||||||
## 考试倒计时、签到积分和反馈
|
## 考试倒计时、签到积分和反馈
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
|
import http from 'node:http';
|
||||||
|
|
||||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||||
const tenantId = '00000000-0000-0000-0000-000000000001';
|
const tenantId = '00000000-0000-0000-0000-000000000001';
|
||||||
@@ -9,12 +10,15 @@ const ids = {
|
|||||||
okAsset: '20000000-0000-0000-0000-000000000801',
|
okAsset: '20000000-0000-0000-0000-000000000801',
|
||||||
badAsset: '20000000-0000-0000-0000-000000000802',
|
badAsset: '20000000-0000-0000-0000-000000000802',
|
||||||
scanBadAsset: '20000000-0000-0000-0000-000000000803',
|
scanBadAsset: '20000000-0000-0000-0000-000000000803',
|
||||||
|
httpPassAsset: '20000000-0000-0000-0000-000000000804',
|
||||||
|
httpFailAsset: '20000000-0000-0000-0000-000000000805',
|
||||||
|
httpUnavailableAsset: '20000000-0000-0000-0000-000000000806',
|
||||||
};
|
};
|
||||||
|
|
||||||
const checksumA = 'a'.repeat(64);
|
const checksumA = 'a'.repeat(64);
|
||||||
const checksumB = 'b'.repeat(64);
|
const checksumB = 'b'.repeat(64);
|
||||||
|
|
||||||
async function runWorkerOnce() {
|
async function runWorkerOnce(extraEnv = {}) {
|
||||||
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'assets'], {
|
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'assets'], {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
env: {
|
env: {
|
||||||
@@ -22,9 +26,15 @@ async function runWorkerOnce() {
|
|||||||
DATABASE_URL: databaseUrl,
|
DATABASE_URL: databaseUrl,
|
||||||
WORKER_ASSET_BATCH_SIZE: '20',
|
WORKER_ASSET_BATCH_SIZE: '20',
|
||||||
WORKER_ASSET_MIN_AGE_SECONDS: '0',
|
WORKER_ASSET_MIN_AGE_SECONDS: '0',
|
||||||
WORKER_ASSET_RECHECK_INTERVAL_SECONDS: '0',
|
WORKER_ASSET_RECHECK_INTERVAL_SECONDS: '86400',
|
||||||
WORKER_ASSET_REQUEST_TIMEOUT_MS: '5000',
|
WORKER_ASSET_REQUEST_TIMEOUT_MS: '5000',
|
||||||
|
WORKER_ASSET_SECURITY_SCANNER: 'metadata_rules',
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT: '',
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN: '',
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS: '10000',
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN: 'false',
|
||||||
STORAGE_REQUIRE_TENANT_PREFIX: 'true',
|
STORAGE_REQUIRE_TENANT_PREFIX: 'true',
|
||||||
|
...extraEnv,
|
||||||
},
|
},
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
@@ -42,6 +52,54 @@ async function runWorkerOnce() {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startScannerServer() {
|
||||||
|
const requests = [];
|
||||||
|
const server = http.createServer((request, response) => {
|
||||||
|
let body = '';
|
||||||
|
request.on('data', chunk => {
|
||||||
|
body += chunk.toString();
|
||||||
|
});
|
||||||
|
request.on('end', () => {
|
||||||
|
const payload = JSON.parse(body || '{}');
|
||||||
|
requests.push({
|
||||||
|
authorization: request.headers.authorization,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
response.setHeader('content-type', 'application/json');
|
||||||
|
if (payload.assetId === ids.httpFailAsset) {
|
||||||
|
response.end(JSON.stringify({
|
||||||
|
status: 'failed',
|
||||||
|
riskLevel: 'critical',
|
||||||
|
issueCodes: ['virus_detected'],
|
||||||
|
details: {
|
||||||
|
engine: 'fake-av',
|
||||||
|
signature: 'EICAR-Test-File',
|
||||||
|
accessToken: 'must-be-redacted',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.end(JSON.stringify({
|
||||||
|
status: 'passed',
|
||||||
|
riskLevel: 'none',
|
||||||
|
issueCodes: [],
|
||||||
|
details: {
|
||||||
|
engine: 'fake-av',
|
||||||
|
scannedBytes: payload.fileSizeBytes,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const address = server.address();
|
||||||
|
assert.ok(address && typeof address === 'object', 'scanner server should listen on a TCP port');
|
||||||
|
return {
|
||||||
|
endpoint: `http://127.0.0.1:${address.port}/scan`,
|
||||||
|
requests,
|
||||||
|
close: () => new Promise(resolve => server.close(resolve)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function countFromWorkerOutput(output, name) {
|
function countFromWorkerOutput(output, name) {
|
||||||
const match = output.match(new RegExp(`${name}=(\\d+)`));
|
const match = output.match(new RegExp(`${name}=(\\d+)`));
|
||||||
return match ? Number(match[1]) : 0;
|
return match ? Number(match[1]) : 0;
|
||||||
@@ -53,23 +111,23 @@ async function cleanup(pool) {
|
|||||||
delete from public.audit_logs
|
delete from public.audit_logs
|
||||||
where tenant_id = $1
|
where tenant_id = $1
|
||||||
and target_type = 'content_asset'
|
and target_type = 'content_asset'
|
||||||
and target_id in ($2, $3, $4)
|
and target_id = any($2::text[])
|
||||||
`,
|
`,
|
||||||
[tenantId, ids.okAsset, ids.badAsset, ids.scanBadAsset],
|
[tenantId, Object.values(ids)],
|
||||||
);
|
);
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`
|
`
|
||||||
delete from public.content_asset_security_scan_events
|
delete from public.content_asset_security_scan_events
|
||||||
where tenant_id = $1 and asset_id in ($2::uuid, $3::uuid, $4::uuid)
|
where tenant_id = $1 and asset_id = any($2::uuid[])
|
||||||
`,
|
`,
|
||||||
[tenantId, ids.okAsset, ids.badAsset, ids.scanBadAsset],
|
[tenantId, Object.values(ids)],
|
||||||
);
|
);
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`
|
`
|
||||||
delete from public.content_assets
|
delete from public.content_assets
|
||||||
where tenant_id = $1 and id in ($2::uuid, $3::uuid, $4::uuid)
|
where tenant_id = $1 and id = any($2::uuid[])
|
||||||
`,
|
`,
|
||||||
[tenantId, ids.okAsset, ids.badAsset, ids.scanBadAsset],
|
[tenantId, Object.values(ids)],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +170,33 @@ async function seed(pool) {
|
|||||||
'pending', 'metadata_rules', '{"securityScanForceFail":true}'::jsonb,
|
'pending', 'metadata_rules', '{"securityScanForceFail":true}'::jsonb,
|
||||||
'{}'::jsonb, 'integration-test',
|
'{}'::jsonb, 'integration-test',
|
||||||
now() - interval '2 days', now() - interval '2 days'
|
now() - interval '2 days', now() - interval '2 days'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
$10, $2, 'asset-worker-http-pass', '外部扫描正常 PDF', 'pdf', 'local_dev',
|
||||||
|
'tenant-assets', $11, 'http-pass.pdf', 'application/pdf', 4096, $4,
|
||||||
|
'tenant', 'active', 'verified', now() - interval '2 days', 4096,
|
||||||
|
$4, '{"source":"asset-worker-test"}'::jsonb,
|
||||||
|
'pending', 'metadata_rules', '{}'::jsonb,
|
||||||
|
'{}'::jsonb, 'integration-test',
|
||||||
|
now() - interval '2 days', now() - interval '2 days'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
$12, $2, 'asset-worker-http-fail', '外部扫描异常 PDF', 'pdf', 'local_dev',
|
||||||
|
'tenant-assets', $13, 'http-fail.pdf', 'application/pdf', 4096, $4,
|
||||||
|
'tenant', 'active', 'verified', now() - interval '2 days', 4096,
|
||||||
|
$4, '{"source":"asset-worker-test"}'::jsonb,
|
||||||
|
'pending', 'metadata_rules', '{}'::jsonb,
|
||||||
|
'{}'::jsonb, 'integration-test',
|
||||||
|
now() - interval '2 days', now() - interval '2 days'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
$14, $2, 'asset-worker-http-unavailable', '外部扫描不可用 PDF', 'pdf', 'local_dev',
|
||||||
|
'tenant-assets', $15, 'http-unavailable.pdf', 'application/pdf', 4096, $4,
|
||||||
|
'tenant', 'active', 'verified', now() - interval '2 days', 4096,
|
||||||
|
$4, '{"source":"asset-worker-test"}'::jsonb,
|
||||||
|
'pending', 'metadata_rules', '{}'::jsonb,
|
||||||
|
'{}'::jsonb, 'integration-test',
|
||||||
|
now() - interval '2 days', now() - interval '2 days'
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
@@ -124,10 +209,43 @@ async function seed(pool) {
|
|||||||
checksumB,
|
checksumB,
|
||||||
ids.scanBadAsset,
|
ids.scanBadAsset,
|
||||||
`${tenantId}/assets/worker-scan-bad.pdf`,
|
`${tenantId}/assets/worker-scan-bad.pdf`,
|
||||||
|
ids.httpPassAsset,
|
||||||
|
`${tenantId}/assets/worker-http-pass.pdf`,
|
||||||
|
ids.httpFailAsset,
|
||||||
|
`${tenantId}/assets/worker-http-fail.pdf`,
|
||||||
|
ids.httpUnavailableAsset,
|
||||||
|
`${tenantId}/assets/worker-http-unavailable.pdf`,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resetAssetsForHttp(pool) {
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
update public.content_assets
|
||||||
|
set status = 'active',
|
||||||
|
upload_status = 'verified',
|
||||||
|
verified_at = now() - interval '2 days',
|
||||||
|
verification_details = '{"source":"asset-worker-test"}'::jsonb,
|
||||||
|
security_scan_status = 'pending',
|
||||||
|
security_scan_provider = 'metadata_rules',
|
||||||
|
security_scan_summary = '{}'::jsonb,
|
||||||
|
security_flags = '{}'::jsonb,
|
||||||
|
updated_at = now() - interval '2 days'
|
||||||
|
where tenant_id = $1
|
||||||
|
and id = any($2::uuid[])
|
||||||
|
`,
|
||||||
|
[tenantId, [ids.httpPassAsset, ids.httpFailAsset, ids.httpUnavailableAsset]],
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
delete from public.content_asset_security_scan_events
|
||||||
|
where tenant_id = $1 and asset_id = any($2::uuid[])
|
||||||
|
`,
|
||||||
|
[tenantId, [ids.httpPassAsset, ids.httpFailAsset, ids.httpUnavailableAsset]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const pool = new pg.Pool({ connectionString: databaseUrl });
|
const pool = new pg.Pool({ connectionString: databaseUrl });
|
||||||
let seeded = false;
|
let seeded = false;
|
||||||
@@ -139,7 +257,7 @@ async function main() {
|
|||||||
seeded = true;
|
seeded = true;
|
||||||
|
|
||||||
const output = await runWorkerOnce();
|
const output = await runWorkerOnce();
|
||||||
assert.ok(countFromWorkerOutput(output, 'verified') >= 1, 'worker should verify at least one asset');
|
assert.ok(countFromWorkerOutput(output, 'verified') >= 3, 'worker should verify metadata-safe assets');
|
||||||
assert.ok(countFromWorkerOutput(output, 'failed') >= 2, 'worker should fail metadata and security scan assets');
|
assert.ok(countFromWorkerOutput(output, 'failed') >= 2, 'worker should fail metadata and security scan assets');
|
||||||
|
|
||||||
const assets = await pool.query(
|
const assets = await pool.query(
|
||||||
@@ -148,10 +266,10 @@ async function main() {
|
|||||||
security_scan_status, security_scan_provider, security_scan_summary,
|
security_scan_status, security_scan_provider, security_scan_summary,
|
||||||
security_flags
|
security_flags
|
||||||
from public.content_assets
|
from public.content_assets
|
||||||
where tenant_id = $1 and id in ($2::uuid, $3::uuid, $4::uuid)
|
where tenant_id = $1 and id = any($2::uuid[])
|
||||||
order by id
|
order by id
|
||||||
`,
|
`,
|
||||||
[tenantId, ids.okAsset, ids.badAsset, ids.scanBadAsset],
|
[tenantId, [ids.okAsset, ids.badAsset, ids.scanBadAsset, ids.httpPassAsset, ids.httpFailAsset, ids.httpUnavailableAsset]],
|
||||||
);
|
);
|
||||||
const okAsset = assets.rows.find(row => row.id === ids.okAsset);
|
const okAsset = assets.rows.find(row => row.id === ids.okAsset);
|
||||||
const badAsset = assets.rows.find(row => row.id === ids.badAsset);
|
const badAsset = assets.rows.find(row => row.id === ids.badAsset);
|
||||||
@@ -237,6 +355,104 @@ async function main() {
|
|||||||
'worker should write failed security scan audit log',
|
'worker should write failed security scan audit log',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await resetAssetsForHttp(pool);
|
||||||
|
const scanner = await startScannerServer();
|
||||||
|
try {
|
||||||
|
const httpOutput = await runWorkerOnce({
|
||||||
|
WORKER_ASSET_SECURITY_SCANNER: 'metadata_rules,http',
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT: scanner.endpoint,
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN: 'scanner-test-token',
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS: '5000',
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN: 'false',
|
||||||
|
});
|
||||||
|
assert.ok(countFromWorkerOutput(httpOutput, 'verified') >= 1, 'worker should pass externally clean assets');
|
||||||
|
assert.ok(countFromWorkerOutput(httpOutput, 'failed') >= 1, 'worker should fail externally rejected assets');
|
||||||
|
assert.equal(scanner.requests.length, 3, 'external scanner should receive metadata-clean assets only');
|
||||||
|
assert.ok(
|
||||||
|
scanner.requests.every(item => item.authorization === 'Bearer scanner-test-token'),
|
||||||
|
'external scanner should receive bearer token',
|
||||||
|
);
|
||||||
|
|
||||||
|
const httpAssets = await pool.query(
|
||||||
|
`
|
||||||
|
select id, status, security_scan_status, security_scan_provider, security_scan_summary, security_flags
|
||||||
|
from public.content_assets
|
||||||
|
where tenant_id = $1 and id = any($2::uuid[])
|
||||||
|
order by id
|
||||||
|
`,
|
||||||
|
[tenantId, [ids.httpPassAsset, ids.httpFailAsset, ids.httpUnavailableAsset]],
|
||||||
|
);
|
||||||
|
const httpPassAsset = httpAssets.rows.find(row => row.id === ids.httpPassAsset);
|
||||||
|
const httpFailAsset = httpAssets.rows.find(row => row.id === ids.httpFailAsset);
|
||||||
|
const httpUnavailableAsset = httpAssets.rows.find(row => row.id === ids.httpUnavailableAsset);
|
||||||
|
|
||||||
|
assert.equal(httpPassAsset?.status, 'active', 'external scan pass asset should stay active');
|
||||||
|
assert.equal(httpPassAsset?.security_scan_status, 'passed', 'external scan pass asset should pass');
|
||||||
|
assert.equal(
|
||||||
|
httpPassAsset?.security_scan_provider,
|
||||||
|
'metadata_rules+http_security_scan',
|
||||||
|
'external scan pass asset should record merged provider',
|
||||||
|
);
|
||||||
|
assert.equal(httpFailAsset?.status, 'draft', 'external scan failed asset should be unpublished');
|
||||||
|
assert.equal(httpFailAsset?.security_scan_status, 'failed', 'external scan failed asset should fail');
|
||||||
|
assert.ok(
|
||||||
|
httpFailAsset?.security_scan_summary?.issueCodes?.includes('virus_detected'),
|
||||||
|
'external scan failed asset should include provider issue code',
|
||||||
|
);
|
||||||
|
const scannerDetails = httpFailAsset?.security_scan_summary?.details?.results?.find(
|
||||||
|
result => result.provider === 'http_security_scan',
|
||||||
|
)?.details?.scannerDetails;
|
||||||
|
assert.equal(scannerDetails?.accessToken, '[redacted]', 'scanner details should redact secret-like keys');
|
||||||
|
assert.equal(httpUnavailableAsset?.security_scan_status, 'passed', 'available scanner should pass clean third asset');
|
||||||
|
|
||||||
|
const httpScanEvents = await pool.query(
|
||||||
|
`
|
||||||
|
select asset_id, scan_status, provider, issue_codes, details
|
||||||
|
from public.content_asset_security_scan_events
|
||||||
|
where tenant_id = $1 and asset_id = any($2::uuid[])
|
||||||
|
order by created_at asc
|
||||||
|
`,
|
||||||
|
[tenantId, [ids.httpPassAsset, ids.httpFailAsset]],
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
httpScanEvents.rows.some(row => row.asset_id === ids.httpPassAsset && row.provider === 'metadata_rules+http_security_scan' && row.scan_status === 'passed'),
|
||||||
|
'worker should write external pass event',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
httpScanEvents.rows.some(row => row.asset_id === ids.httpFailAsset && row.issue_codes.includes('virus_detected')),
|
||||||
|
'worker should write external failure event',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await scanner.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
await resetAssetsForHttp(pool);
|
||||||
|
const unavailableOutput = await runWorkerOnce({
|
||||||
|
WORKER_ASSET_SECURITY_SCANNER: 'metadata_rules,http',
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT: 'http://127.0.0.1:9/scan',
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN: 'scanner-test-token',
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS: '300',
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN: 'false',
|
||||||
|
});
|
||||||
|
assert.ok(countFromWorkerOutput(unavailableOutput, 'failed') >= 3, 'fail-closed scanner outage should fail assets');
|
||||||
|
const unavailableAssets = await pool.query(
|
||||||
|
`
|
||||||
|
select id, status, security_scan_status, security_scan_summary, security_flags
|
||||||
|
from public.content_assets
|
||||||
|
where tenant_id = $1 and id = any($2::uuid[])
|
||||||
|
`,
|
||||||
|
[tenantId, [ids.httpPassAsset, ids.httpFailAsset, ids.httpUnavailableAsset]],
|
||||||
|
);
|
||||||
|
for (const row of unavailableAssets.rows) {
|
||||||
|
assert.equal(row.status, 'draft', 'fail-closed scanner outage should unpublish active asset');
|
||||||
|
assert.equal(row.security_scan_status, 'failed', 'fail-closed scanner outage should fail scan');
|
||||||
|
assert.ok(
|
||||||
|
row.security_scan_summary?.issueCodes?.includes('external_security_scan_unavailable'),
|
||||||
|
'fail-closed scanner outage should record unavailable issue',
|
||||||
|
);
|
||||||
|
assert.equal(row.security_flags?.assetSecurityScanFailed, true, 'fail-closed scanner outage should record security flag');
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Asset worker integration test complete.');
|
console.log('Asset worker integration test complete.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await pool.query('rollback').catch(() => {});
|
await pool.query('rollback').catch(() => {});
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ ALIYUN_OSS_REGION=cn-hangzhou
|
|||||||
ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
|
ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
|
||||||
ALIYUN_OSS_ACCESS_KEY_ID=LTAI_READINESS_TEST_ONLY
|
ALIYUN_OSS_ACCESS_KEY_ID=LTAI_READINESS_TEST_ONLY
|
||||||
ALIYUN_OSS_ACCESS_KEY_SECRET=aliyun-readiness-secret-placeholder
|
ALIYUN_OSS_ACCESS_KEY_SECRET=aliyun-readiness-secret-placeholder
|
||||||
|
WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.gongxue100.com/api/scan
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuvwx
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS=10000
|
||||||
|
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
|
||||||
WORKER_CRM_ALLOW_INSECURE_LOCALHOST=false
|
WORKER_CRM_ALLOW_INSECURE_LOCALHOST=false
|
||||||
WORKER_CRM_BATCH_SIZE=20
|
WORKER_CRM_BATCH_SIZE=20
|
||||||
WORKER_COMMERCE_BATCH_SIZE=20
|
WORKER_COMMERCE_BATCH_SIZE=20
|
||||||
|
|||||||
@@ -267,6 +267,51 @@ function validateEnv() {
|
|||||||
warn('env.storage_octet_stream', 'application/octet-stream is allowed; consider removing it after import migration is stable');
|
warn('env.storage_octet_stream', 'application/octet-stream is allowed; consider removing it after import migration is stable');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scannerModes = envList('WORKER_ASSET_SECURITY_SCANNER', 'metadata_rules').map(item => item.toLowerCase());
|
||||||
|
const unsupportedScannerModes = scannerModes.filter(mode => mode !== 'metadata_rules' && mode !== 'http');
|
||||||
|
if (scannerModes.length === 0) {
|
||||||
|
block('env.asset_security_scanner.empty', 'WORKER_ASSET_SECURITY_SCANNER must include metadata_rules and a production scanner');
|
||||||
|
} else if (unsupportedScannerModes.length > 0) {
|
||||||
|
block('env.asset_security_scanner.unsupported', 'WORKER_ASSET_SECURITY_SCANNER contains unsupported modes', {
|
||||||
|
modes: unsupportedScannerModes,
|
||||||
|
});
|
||||||
|
} else if (!scannerModes.includes('http')) {
|
||||||
|
block('env.asset_security_scanner.external', 'Production assets require WORKER_ASSET_SECURITY_SCANNER=http or metadata_rules,http');
|
||||||
|
} else {
|
||||||
|
pass('env.asset_security_scanner', 'external asset security scanner is enabled', { modes: scannerModes });
|
||||||
|
}
|
||||||
|
|
||||||
|
const scannerEndpoint = env('WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT', '');
|
||||||
|
if (scannerModes.includes('http')) {
|
||||||
|
const scannerHost = hostFromUrl(scannerEndpoint);
|
||||||
|
if (!scannerEndpoint) {
|
||||||
|
block('env.asset_security_scan_http_endpoint', 'WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT is required when http scanner is enabled');
|
||||||
|
} else if (!scannerEndpoint.startsWith('https://') || isLocalHost(scannerHost)) {
|
||||||
|
block('env.asset_security_scan_http_endpoint.unsafe', 'WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT must be a production HTTPS URL');
|
||||||
|
} else {
|
||||||
|
pass('env.asset_security_scan_http_endpoint', 'asset security scanner endpoint is HTTPS');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUnsafeSecret(env('WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN', ''), '')) {
|
||||||
|
block('env.asset_security_scan_http_token', 'WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN must be a strong shared secret or service token');
|
||||||
|
} else {
|
||||||
|
pass('env.asset_security_scan_http_token', 'asset security scanner token looks production-grade');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scannerTimeout = envNumber('WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS', 10_000);
|
||||||
|
if (scannerTimeout <= 0 || scannerTimeout > 60_000) {
|
||||||
|
block('env.asset_security_scan_http_timeout', 'WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS must be between 1 and 60000');
|
||||||
|
} else {
|
||||||
|
pass('env.asset_security_scan_http_timeout', 'asset security scanner timeout is bounded');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envBool('WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN', false)) {
|
||||||
|
block('env.asset_security_scan_fail_open', 'WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN must be false in production');
|
||||||
|
} else {
|
||||||
|
pass('env.asset_security_scan_fail_open', 'asset security scanning fails closed');
|
||||||
|
}
|
||||||
|
|
||||||
if (envBool('WORKER_CRM_ALLOW_INSECURE_LOCALHOST', false)) {
|
if (envBool('WORKER_CRM_ALLOW_INSECURE_LOCALHOST', false)) {
|
||||||
block('env.worker_crm_insecure_localhost', 'WORKER_CRM_ALLOW_INSECURE_LOCALHOST must be false in production');
|
block('env.worker_crm_insecure_localhost', 'WORKER_CRM_ALLOW_INSECURE_LOCALHOST must be false in production');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user