feat: add asset watermark access context

This commit is contained in:
Codex
2026-06-29 20:18:27 +08:00
parent ce538a57c7
commit e0393126a1
18 changed files with 361 additions and 44 deletions

View File

@@ -30,7 +30,7 @@
- Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;资金对账已支持手工/API 账单导入比对和差错工单处理,微信/支付宝官方账单自动下载、异常订单运营台和真实生产账号联调还没接完。
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入上传后校验、PDF/图片预览、资源访问事件、锁定资源 CDN 边界、资源复检 worker、内置 `metadata_rules` 安全扫描和外部 HTTP 杀毒/内容安全 scanner 接入层已完成。生产还要配置真实扫描服务 endpoint/token并继续补视频动态水印、CDN 刷新和对象生命周期策略。
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入上传后校验、PDF/图片预览、资源访问事件、动态水印上下文、锁定资源 CDN 边界、资源复检 worker、内置 `metadata_rules` 安全扫描和外部 HTTP 杀毒/内容安全 scanner 接入层已完成。生产还要配置真实扫描服务 endpoint/token并继续补转码/CDN 级水印、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`。托管对象必须 `uploadStatus=verified``securityScanStatus=passed` 后才能发布、下载、预览或播放;生产环境应定时运行 assets worker 复检对象元数据,执行 `metadata_rules` 和外部 HTTP scanner异常资源会被标记 failed/skipped 并退回 draft。
- 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL学生端预览、锁定资料和视频会使用短 TTL,并返回带 `traceId``watermark` 上下文供前端渲染可见水印`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_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册、分数线和视频 JSON/CSV/Excel 导入已走这套后台校验管线;大批量任务可提交 `executionMode=async`,由 imports worker 消费,前端只轮询 job 状态和展示 issues。
- 题库导出必须由后端按权限生成,不允许前端直接读取数据库拼导出文件;不开启答案/解析时顶层题目和复合题子题都必须脱敏PDF/Word/每日一练 ZIP 只通过 exports worker 写入 `content_assets` 后再签名下载/预览。
@@ -337,6 +337,6 @@ git diff --check
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
2. 继续补 Taro 前端:学生端视频/反馈/模考报告/订单收银台,租户后台写入表单/导入操作台/公共题库同步/角色模板 UI平台后台租户详情/审计/自动计费增强,小程序兼容验证。
3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、视频动态水印和生命周期策略。
3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。
4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
5. 真实 OAuth/短信/支付生产账号联调、微信/支付宝官方账单自动下载、异常订单运营台、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。

View File

@@ -8,6 +8,7 @@ import {
import {
assetAccessTtl,
assertCdnAccessAllowed,
buildAssetWatermarkContext,
recordAssetAccessEvent,
signedAssetFingerprint,
} from '../storage/asset-access.js';
@@ -266,6 +267,19 @@ export async function assetDownloadRoute(ctx: RequestContext) {
expiresInSec,
disposition: 'attachment',
});
const watermark = buildAssetWatermarkContext({
tenantId,
assetId: asset.id,
userId: access.userId || null,
actorRole: access.userId ? 'student' : 'anonymous',
accessType: 'download',
visibility: asset.visibility,
assetType: asset.assetType,
fileName: asset.fileName,
title: asset.title,
expiresAt: download.expiresAt,
metadata: asset.metadata,
});
await query(
'update public.content_assets set download_count = download_count + 1, updated_at = now() where tenant_id = $1 and id = $2',
[tenantId, assetId],
@@ -284,7 +298,7 @@ export async function assetDownloadRoute(ctx: RequestContext) {
expiresInSec: download.expiresInSec,
signatureMode: download.signatureMode,
result: 'granted',
metadata: { signature: signedAssetFingerprint(download) },
metadata: { signature: signedAssetFingerprint(download), watermark },
});
return {
@@ -301,6 +315,7 @@ export async function assetDownloadRoute(ctx: RequestContext) {
},
access,
download,
watermark,
};
}
@@ -372,6 +387,19 @@ export async function assetPreviewRoute(ctx: RequestContext) {
expiresInSec,
disposition: 'inline',
});
const watermark = buildAssetWatermarkContext({
tenantId,
assetId: asset.id,
userId: access.userId || null,
actorRole: access.userId ? 'student' : 'anonymous',
accessType: 'preview',
visibility: asset.visibility,
assetType: asset.assetType,
fileName: asset.fileName,
title: asset.title,
expiresAt: preview.expiresAt,
metadata: asset.metadata,
});
await recordAssetAccessEvent({
ctx,
tenantId,
@@ -386,7 +414,7 @@ export async function assetPreviewRoute(ctx: RequestContext) {
expiresInSec: preview.expiresInSec,
signatureMode: preview.signatureMode,
result: 'granted',
metadata: { signature: signedAssetFingerprint(preview) },
metadata: { signature: signedAssetFingerprint(preview), watermark },
});
return {
@@ -402,5 +430,6 @@ export async function assetPreviewRoute(ctx: RequestContext) {
},
access,
preview,
watermark,
};
}

View File

@@ -1,3 +1,4 @@
import crypto from 'node:crypto';
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
import { query } from '../../core/db.js';
import type {
@@ -9,6 +10,33 @@ import type {
export type AssetAccessType = 'download' | 'preview' | 'admin_download' | 'admin_preview' | 'upload_sign' | 'upload_confirm';
export type AssetActorRole = 'anonymous' | 'student' | 'tenant_admin' | 'tenant_content_editor' | 'system';
export type AssetAccessResult = 'granted' | 'denied';
export type AssetWatermarkMode = 'none' | 'visible_overlay';
export interface AssetWatermarkContextInput {
tenantId: string;
assetId?: string | null;
userId?: string | null;
actorRole: AssetActorRole;
accessType: AssetAccessType | 'video_play';
visibility?: string | null;
assetType?: string | null;
fileName?: string | null;
title?: string | null;
expiresAt?: string | null;
metadata?: Record<string, unknown> | null;
}
export interface AssetWatermarkContext {
mode: AssetWatermarkMode;
required: boolean;
text: string;
traceId: string;
position: 'diagonal' | 'bottom-right' | 'center';
opacity: number;
repeat: boolean;
expiresAt: string | null;
renderHint: string;
}
export interface AssetAccessRecordInput {
ctx: RequestContext;
@@ -50,6 +78,7 @@ export interface AssetCdnPolicyInput {
const LOCKED_VISIBILITIES = new Set(['members', 'svip', 'private']);
const SHORT_LIVED_ASSET_TYPES = new Set(['video', 'package']);
const WATERMARKED_ACCESS_TYPES = new Set(['preview', 'download', 'admin_preview', 'admin_download', 'video_play']);
function clientIpFrom(ctx: RequestContext) {
return (getHeader(ctx.req, 'x-forwarded-for').split(',')[0] || getHeader(ctx.req, 'x-real-ip') || ctx.req.socket.remoteAddress || '').trim();
@@ -67,6 +96,92 @@ function boolFlag(...values: unknown[]) {
return values.some(value => value === true || value === 'true');
}
function stringValue(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function safeWatermarkText(value: string, fallback: string) {
const normalized = value.replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim();
return (normalized || fallback).slice(0, 48);
}
function numberValue(value: unknown, fallback: number, min: number, max: number) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(Math.max(parsed, min), max);
}
function watermarkTraceId(input: Pick<AssetWatermarkContextInput, 'tenantId' | 'assetId' | 'userId' | 'accessType' | 'expiresAt'>) {
const seed = [
input.tenantId,
input.assetId || 'no-asset',
input.userId || 'anonymous',
input.accessType,
input.expiresAt || new Date().toISOString(),
].join(':');
return cryptoHash(seed).slice(0, 12).toUpperCase();
}
function cryptoHash(value: string) {
return crypto.createHash('sha256').update(value).digest('hex');
}
function watermarkEnabledByMetadata(metadata: Record<string, unknown>) {
if (metadata.watermark === false || metadata.watermark === 'false') return false;
if (metadata.disableWatermark === true || metadata.disableWatermark === 'true') return false;
return true;
}
export function buildAssetWatermarkContext(input: AssetWatermarkContextInput): AssetWatermarkContext {
const metadata = objectValue(input.metadata);
const watermarkConfig = objectValue(metadata.watermarkConfig);
const requiredByVisibility = LOCKED_VISIBILITIES.has(input.visibility || '');
const requiredByAssetType = SHORT_LIVED_ASSET_TYPES.has(input.assetType || '');
const requiredByAccessType = WATERMARKED_ACCESS_TYPES.has(input.accessType);
const canDisableByMetadata = !requiredByVisibility && !requiredByAssetType;
const metadataEnabled = watermarkEnabledByMetadata(metadata) || !canDisableByMetadata;
const enabled = metadataEnabled && requiredByAccessType && (requiredByVisibility || requiredByAssetType || input.actorRole !== 'anonymous');
const traceId = watermarkTraceId(input);
const defaultText = input.actorRole === 'tenant_content_editor' || input.actorRole === 'tenant_admin' ? '内部资料' : '仅限本人学习';
const baseText = safeWatermarkText(
stringValue(watermarkConfig.text) || stringValue(metadata.watermarkText),
defaultText,
);
const identity = input.userId ? `账号:${cryptoHash(input.userId).slice(0, 8).toUpperCase()}` : '访客';
const text = `${baseText} ${identity} ${traceId}`;
if (!enabled) {
return {
mode: 'none',
required: false,
text: '',
traceId,
position: 'diagonal',
opacity: 0,
repeat: false,
expiresAt: input.expiresAt || null,
renderHint: 'none',
};
}
const position = ['diagonal', 'bottom-right', 'center'].includes(stringValue(watermarkConfig.position))
? stringValue(watermarkConfig.position) as AssetWatermarkContext['position']
: 'diagonal';
return {
mode: 'visible_overlay',
required: true,
text,
traceId,
position,
opacity: numberValue(watermarkConfig.opacity, 0.16, 0.05, 0.35),
repeat: watermarkConfig.repeat === false || watermarkConfig.repeat === 'false' ? false : true,
expiresAt: input.expiresAt || null,
renderHint: 'render_visible_overlay_before_opening_signed_url',
};
}
export function assetAccessTtl(input: AssetAccessPolicyInput) {
const requested = input.requestedExpiresInSec ?? 900;
const minTtl = 60;

View File

@@ -21,6 +21,7 @@ import {
import {
assetAccessTtl,
assertCdnAccessAllowed,
buildAssetWatermarkContext,
recordAssetAccessEvent,
signedAssetFingerprint,
} from '../storage/asset-access.js';
@@ -1041,6 +1042,19 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
expiresInSec,
disposition: 'attachment',
});
const watermark = buildAssetWatermarkContext({
tenantId: auth.tenantId,
assetId,
userId: auth.userId,
actorRole: 'tenant_content_editor',
accessType: 'admin_download',
visibility: asset.visibility,
assetType: asset.assetType,
fileName: asset.fileName,
title: asset.title,
expiresAt: download.expiresAt,
metadata: asset.metadata,
});
await recordAssetAccessEvent({
ctx,
tenantId: auth.tenantId,
@@ -1055,10 +1069,10 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
expiresInSec: download.expiresInSec,
signatureMode: download.signatureMode,
result: 'granted',
metadata: { signature: signedAssetFingerprint(download) },
metadata: { signature: signedAssetFingerprint(download), watermark },
});
return { item: asset, download };
return { item: asset, download, watermark };
}
export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
@@ -1135,6 +1149,19 @@ export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
expiresInSec,
disposition: 'inline',
});
const watermark = buildAssetWatermarkContext({
tenantId: auth.tenantId,
assetId,
userId: auth.userId,
actorRole: 'tenant_content_editor',
accessType: 'admin_preview',
visibility: asset.visibility,
assetType: asset.assetType,
fileName,
title: asset.title,
expiresAt: preview.expiresAt,
metadata: asset.metadata,
});
await recordAssetAccessEvent({
ctx,
@@ -1150,7 +1177,7 @@ export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
expiresInSec: preview.expiresInSec,
signatureMode: preview.signatureMode,
result: 'granted',
metadata: { signature: signedAssetFingerprint(preview) },
metadata: { signature: signedAssetFingerprint(preview), watermark },
});
return {
@@ -1162,5 +1189,6 @@ export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
previewStatus: asset.previewStatus,
},
preview,
watermark,
};
}

View File

@@ -15,6 +15,7 @@ import { signStorageDownload, type StorageProviderName } from '../storage/servic
import {
assetAccessTtl,
assertCdnAccessAllowed,
buildAssetWatermarkContext,
signedAssetFingerprint,
} from '../storage/asset-access.js';
import { assertAssetSecurityScanPassed } from '../tenant-content/assets.js';
@@ -368,6 +369,19 @@ export async function videoPlaybackRoute(ctx: RequestContext) {
}
const signed = await signVideoPlayback(tenantId, video);
const watermark = buildAssetWatermarkContext({
tenantId,
assetId: video.assetId,
userId,
actorRole: 'student',
accessType: 'video_play',
visibility: video.assetVisibility || 'svip',
assetType: video.assetType || 'video',
fileName: video.fileName,
title: video.assetTitle || video.title,
expiresAt: signed.expiresAt,
metadata: video.assetMetadata,
});
const token = createPlayToken();
const tokenHash = hashPlayToken(token);
@@ -403,6 +417,7 @@ export async function videoPlaybackRoute(ctx: RequestContext) {
assetId: video.assetId,
freePreviewSeconds: video.freePreviewSeconds,
signature: signedAssetFingerprint(signed),
watermark,
}),
],
);
@@ -416,6 +431,7 @@ export async function videoPlaybackRoute(ctx: RequestContext) {
playEvent: event.rows[0],
playToken: token,
playback: signed,
watermark,
access: {
mode: accessMode,
entitlementId,

View File

@@ -125,6 +125,18 @@ export interface ContentAsset {
previewStatus?: string;
}
export interface AssetWatermarkContext {
mode: 'none' | 'visible_overlay';
required: boolean;
text: string;
traceId: string;
position: 'diagonal' | 'bottom-right' | 'center';
opacity: number;
repeat: boolean;
expiresAt?: string | null;
renderHint: string;
}
export async function loadRegions() {
return apiRequest<{ items?: RegionItem[] }>('/api/catalog/regions');
}
@@ -192,9 +204,9 @@ export async function loadContentAssets(query: { entryId?: string; contentNodeId
}
export async function signAssetPreview(assetId: string) {
return apiRequest<{ preview?: { url?: string }; item?: ContentAsset }>('/api/catalog/assets/preview', { query: { assetId } });
return apiRequest<{ preview?: { url?: string }; item?: ContentAsset; watermark?: AssetWatermarkContext }>('/api/catalog/assets/preview', { query: { assetId } });
}
export async function signAssetDownload(assetId: string) {
return apiRequest<{ download?: { url?: string }; item?: ContentAsset }>('/api/catalog/assets/download', { query: { assetId } });
return apiRequest<{ download?: { url?: string }; item?: ContentAsset; watermark?: AssetWatermarkContext }>('/api/catalog/assets/download', { query: { assetId } });
}

View File

@@ -1,4 +1,5 @@
import { apiRequest } from './api';
import type { AssetWatermarkContext } from './catalog';
export interface QuestionVideoItem {
id: string;
@@ -21,6 +22,7 @@ export interface VideoPlayback {
expiresInSec?: number;
signatureMode?: string;
};
watermark?: AssetWatermarkContext;
access?: Record<string, unknown>;
}

View File

@@ -82,7 +82,7 @@
| 分数线字段/院校/专业/记录/趋势 | 可联调 | `/api/scoreline/*` |
| 分数线 JSON 导入 | 可联调 | `/api/tenant-content/imports/preview/scoreline``/api/tenant-content/imports/scoreline`;支持字段、院校、专业、记录、动态字段值、逐行 issue、幂等和审计 |
| 题目视频/批量预加载/搜索 | 可联调 | `/api/questions/*/videos``/api/videos/search`;付费视频列表不返回可播放 URL |
| 视频会员播放次数 | 可联调 | `POST /api/videos/play` 支持 SVIP/视频次数校验、签名播放、次数扣减、播放日志;深度防盗链和动态水印继续补 |
| 视频会员播放次数 | 可联调 | `POST /api/videos/play` 支持 SVIP/视频次数校验、签名播放、次数扣减、播放日志和动态水印上下文;深度防盗链、转码级水印和播放统计继续补 |
| 视频 JSON 导入和批量绑定 | 可联调 | `/api/tenant-content/imports/preview/videos``/api/tenant-content/imports/videos`;支持视频元数据、资源引用、播放模式、题目绑定和题目视频标记 |
## 资料与对象存储
@@ -99,9 +99,9 @@
| 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` 和可选外部 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 和水印 traceId |
| CDN 访问边界 | 可联调 | `members/svip/private` 外部 CDN URL 默认拒绝,必须显式 `metadata.providerManagedAccess=true``cdnAccessMode=signed_by_provider`;视频绑定资源也复用该规则 |
| 深度防盗链/水印/杀毒 | 部分覆盖 | 内置 metadata 规则扫描、外部 HTTP scanner 接入层、失败关闭生产 readiness 阻断已完成;商用上线前继续联调真实 AV/内容安全服务、动态水印、CDN 刷新和对象生命周期策略 |
| 深度防盗链/水印/杀毒 | 部分覆盖 | 内置 metadata 规则扫描、外部 HTTP scanner 接入层、失败关闭生产 readiness 阻断、签名访问动态水印上下文和 traceId 审计已完成;商用上线前继续联调真实 AV/内容安全服务、转码/CDN 级水印、CDN 刷新和对象生命周期策略 |
## 订单、会员、营销

View File

@@ -27,8 +27,8 @@
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 |
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入 | 复杂筛选、AI 择校上下文 |
| 视频解析 | 可联调 | 单题视频、批量查询、后台视频绑定、JSON/CSV/Excel 导入、会员播放权限、播放次数扣减、签名 URL播放日志 | 深度防盗链、动态水印、播放统计 |
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、访问审计、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层、题库导出 PDF/Word/每日一练 ZIP 可生成可信 `content_assets` 并走签名下载/预览 | CDN 防盗链、真实 AV/内容安全服务联调、视频水印、资料前端操作体验 |
| 视频解析 | 可联调 | 单题视频、批量查询、后台视频绑定、JSON/CSV/Excel 导入、会员播放权限、播放次数扣减、签名 URL播放日志和动态水印上下文 | 深度防盗链、转码级水印、播放统计 |
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、访问审计、动态水印上下文、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层、题库导出 PDF/Word/每日一练 ZIP 可生成可信 `content_assets` 并走签名下载/预览 | CDN 防盗链、真实 AV/内容安全服务联调、转码级水印、资料前端操作体验 |
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 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 复检、内置安全扫描、外部 HTTP scanner 接入层和题库导出 PDF/Word/每日一练 ZIP worker 已完成,继续补视频播放防盗链、真实 AV/内容安全服务联调和动态水印。
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage上传确认、PDF/图片预览签名、动态水印上下文、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层和题库导出 PDF/Word/每日一练 ZIP worker 已完成,继续补视频播放防盗链、真实 AV/内容安全服务联调和转码/CDN 级水印。
- 真实数据 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、平台后台审计增强和小程序兼容验证。
@@ -88,7 +88,7 @@
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录真实账号联调。
- 公共题库/地区题库自动同步 worker 已具备单批执行能力,租户后台已有同步通知、单条/批量冲突采纳平台或保留本地操作;继续补生产定时调度、失败告警,以及租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。
- 导入模板、字段映射、导入任务详情和复检 API 已可用Taro 租户内容页已接模板下载、字段别名覆盖、导入执行、异步 job 轮询和复检结果面板第一版。前端继续补真实导入目标选择体验和大数据量导入验收。
- 视频深度防盗链、动态水印和播放统计。
- 视频深度防盗链、转码级水印和播放统计。
- 数据看板 API收益、注册趋势、答题次数、收入趋势、题型分布、题目总量、套餐销量、24h 活跃。
### P2企业级体验和增长闭环

View File

@@ -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 对象元数据校验/复检、内置 `metadata_rules` 安全扫描和外部 HTTP scanner 接入层已完成。PDF 深度预览体验、防盗链、水印和真实 AV/内容安全服务联调仍需继续补。
- 内容资源当前完成台账、租户后台维护、学生端 SVIP 下载权限,以及 `local_dev`、阿里云 OSS、腾讯 COS、Supabase Storage 的上传/下载签名 provider上传确认、访问审计、动态水印上下文、assets worker 对象元数据校验/复检、内置 `metadata_rules` 安全扫描和外部 HTTP scanner 接入层已完成。PDF 深度预览体验、防盗链、转码/CDN 级水印和真实 AV/内容安全服务联调仍需继续补。
- 题库内容导航当前以 `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 预览渲染、防盗链、真实 AV/内容安全服务联调和视频水印。
1. 完善内容导入和文件上传:字段映射 UI、真实数据 dry-run、PDF 预览渲染、防盗链、真实 AV/内容安全服务联调和转码/CDN 级视频水印。
2. 完成真实短信 provider 联调:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
3. 完成真实 OAuth provider 联调微信网页、微信小程序、QQ确认回调域名、开放平台账号和旧 PocketBase 身份映射策略。
4. 补微信/支付宝官方账单自动下载、异常订单运营台和优惠券核销报表;支付/退款补偿、退款查询确认、退款通知、资金对账导入比对和差错工单主链路已完成。

View File

@@ -23,8 +23,8 @@
| 背单词 | 基础完成 | 单词单元、单词、进度、收藏、统计、每日复习计划、旧模板/新模板 JSON/CSV/Excel 预览导入、内容导航绑定、排行榜 | 更细复习参数 |
| 知识手册 | 基础完成 | 科目、章节、条目只读与后台维护、书籍/章节/小节/知识点嵌套 JSON 预览导入、内容导航绑定 | 富文本资源、版本管理、附件/PDF 关联、Excel/Markdown 批量解析 |
| 分数线 | 可联调 | 字段、院校、专业、记录、趋势、年份、JSON 批量导入 | 复杂动态筛选、AI 择校数据上下文 |
| 视频解析会员 | 可联调 | 题目视频、批量查询、后台绑定、视频 JSON 导入、SVIP 权限、播放次数扣减、签名 URL、播放日志 | 深度防盗链、动态水印、播放统计 |
| 资料下载/PDF | 可联调 | `content_assets` 资源台账、后台资源管理、OSS/COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、学生端列表、SVIP 下载权限、assets worker 复检、内置安全扫描和外部 HTTP scanner 接入层 | CDN 防盗链、真实 AV/内容安全服务联调、资料前端管理页 |
| 视频解析会员 | 可联调 | 题目视频、批量查询、后台绑定、视频 JSON 导入、SVIP 权限、播放次数扣减、签名 URL、播放日志、动态水印上下文 | 深度防盗链、转码级水印、播放统计 |
| 资料下载/PDF | 可联调 | `content_assets` 资源台账、后台资源管理、OSS/COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、学生端列表、SVIP 下载权限、动态水印上下文、assets worker 复检、内置安全扫描和外部 HTTP scanner 接入层 | CDN 防盗链、真实 AV/内容安全服务联调、资料前端管理页 |
| 营销中心 | 基础完成 | SVIP 套餐、激活码批次、激活码生成、优惠券、Banner/FAQ/公告、勋章管理和手动发放 | 勋章自动发放、复杂活动规则、核销报表 |
| 销售/代理客资 | 可联调 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、团队关系、手动补绑、分佣比例、归因、结算单、审核和打款状态 | 真实微信小程序码、真实打款、结算导出、销售团队看板 |
| CRM 系统 | 可联调 | CRM 配置、密钥私密存储、客资入队、队列查询、generic/钉钉/飞书/企微 worker、签名、重试和日志 | 定向/轮询分配、富卡片模板、失败告警、死信运营台 |
@@ -36,9 +36,9 @@
## 接下来优先级
1. 完善内容导入和对象存储:字段映射 UI、真实数据 dry-run、CDN 防盗链、真实 AV/内容安全服务联调和视频水印。
1. 完善内容导入和对象存储:字段映射 UI、真实数据 dry-run、CDN 防盗链、真实 AV/内容安全服务联调和转码级视频水印。
2. 公共题库/地区题库授权:已完成披露、采纳快照、手动同步、自动同步 worker、同步通知、冲突查询和租户自改保护继续补生产失败告警、冲突操作台增强和按 SaaS 套餐限制地区。
3. 学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
4. 视频会员控制:深度防盗链、水印和播放统计。
4. 视频会员控制:深度防盗链、转码级水印和播放统计。
5. 数据看板预聚合:把实时聚合升级为大租户可承载的日/周/月预聚合。
6. 真实 provider短信、微信/QQ 登录、微信支付/支付宝生产账号联调CRM worker 基础已落地,继续补分配策略和告警。

View File

@@ -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、内置安全扫描和外部 HTTP scanner 接入层CDN 防盗链、视频水印和真实 AV/内容安全服务联调还要补。
- 对象存储已完成签名 provider、上传后校验、PDF/图片预览、动态水印上下文、资源复检 worker、内置安全扫描和外部 HTTP scanner 接入层CDN 防盗链、转码级视频水印和真实 AV/内容安全服务联调还要补。
- 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入已可联调;大批量导入可传 `executionMode=async` 交给 imports worker模板下载、字段映射 API、导入任务详情和导入后复检已可用。租户内容页已经可以选择文件或粘贴内容、下载模板、执行后端预览、编辑本次字段别名、同步/异步提交导入、轮询异步 job、查看问题行并触发/查看复检;后续还要补真实数据 dry-run 验收和更完整的目标入口/集合选择。
- 数据看板、分佣结算、勋章手动发放和主题模板发布基础 API 已可联调;勋章自动发放、分佣真实打款/导出/凭证、AI 择校、主题素材库/模板市场等仍是后续商用增强项。

View File

@@ -31,8 +31,8 @@
| 背单词 | 已建单词单元、单词、进度、收藏表,并可绑定 `content_entries/content_nodes` | 已支持内容和部分用户状态映射 | 单元/单词只读、进度、收藏、统计、每日复习计划、租户后台单词维护 API、旧模板/新模板 JSON 预览导入、排行榜已实现 | 核心 API 集成测试含导入和排行榜断言 | 学生端学习状态、后台维护、批量 JSON 导入和基础排行榜已实现,更细复习参数和后台统计待完善 |
| 知识手册 | 已建手册科目、章节、条目,并可绑定 `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/图片预览签名、资源访问审计、内置安全扫描、外部 HTTP scanner 接入层、题库导出 PDF/Word/每日一练 ZIP 自动发布可信资源、学生端资料列表/下载权限已实现 | 核心 API 集成测试含 SVIP 资料下载、安全扫描门禁assets worker 覆盖内置规则与外部 scanner 通过/失败/不可用 fail-closedexports worker 测试 | 资料资源基础闭环可跑,真实 AV/内容安全服务联调、CDN 防盗链、动态水印和资料下载前端待补 |
| 题目视频讲解 | 已建 `video_explanations``question_videos` | 已支持导入映射 | 单题视频、批量预加载、通用视频搜索、播放签名、视频次数扣减、播放水印上下文、租户后台视频创建绑定 API、JSON 预览导入已实现 | 核心 API 集成测试含播放、水印 traceId 和导入断言 | 播放、权益、后台绑定、动态水印上下文和批量 JSON 导入链路已实现,深度防盗链、转码级水印和播放统计待补 |
| 资料下载/PDF | 已扩展 `content_assets`,新增资源台账和导入任务表 | 旧 `app_assets/images` 兼容导入 | 租户后台资源管理、OSS/COS/Supabase Storage 上传/下载签名、上传确认、PDF/图片预览签名、资源访问审计、动态水印上下文、内置安全扫描、外部 HTTP scanner 接入层、题库导出 PDF/Word/每日一练 ZIP 自动发布可信资源、学生端资料列表/下载权限已实现 | 核心 API 集成测试含 SVIP 资料下载、安全扫描门禁、水印 traceIdassets worker 覆盖内置规则与外部 scanner 通过/失败/不可用 fail-closedexports worker 测试 | 资料资源基础闭环可跑,真实 AV/内容安全服务联调、CDN 防盗链、转码/CDN 级水印和资料下载前端渲染待补 |
| 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录、`badges/user_badges` | 已支持部分用户资料和勋章导入 | 个人资料、目标院校/专业、手机号绑定/换绑、会员状态、最近练习、统计聚合、签到积分、题目反馈、考试倒计时、勋章 API 已实现 | API 集成测试 | 学生端基础个人中心已实现,学习报告可视化和更细任务系统待补 |
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章等基础表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、勋章维护和手动发放已实现 | 核心 API 集成测试 | 基础运营后台可用,勋章自动发放、复杂活动规则、营销自动化、核销报表待补 |
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 |
@@ -298,6 +298,6 @@ platform-admin:
1. 完善内容导入和文件上传:字段映射 UI、真实数据 dry-run 执行验收、CDN 防盗链、真实 AV/内容安全服务联调。
2. 补公共题库生产定时调度/失败告警、租户套餐地区/科目/题库范围限制、主题模板系统。
3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
4. 补视频商用控制:深度防盗链、动态水印和播放统计。
4. 补视频商用控制:深度防盗链、转码级水印和播放统计。
5. 补 AI 择校推荐报告、排行榜防刷/预聚合、勋章自动发放。
6. 接真实短信/OAuth 生产账号、微信/支付宝官方账单下载和异常订单运营台,并继续推进 Taro scaffold。

View File

@@ -26,13 +26,13 @@
| 全真模拟 | `components/AdminMockexam``MockExamConfigModal.tsx` | 部分覆盖 | blueprint、session 快照、倒计时、交卷评分、分段统计、错题解析汇总和复合题子题部分得分已覆盖;后续补排行榜/排名、完整复盘体验 |
| 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 错题列表、移出错题、复习计划和 `wrong_review` 后端组卷已覆盖;后续补更细的间隔复习算法 |
| 收藏夹 | `WordFavoritesPage.tsx`、题目收藏 | 已覆盖 | 题目和单词收藏已有 |
| 题目视频 | `VideoPlayer.tsx` | 部分覆盖 | 题目视频查询、播放签名、SVIP/次数扣减、播放日志已有;缺深度防盗链、动态水印、播放统计报表 |
| 题目视频 | `VideoPlayer.tsx` | 部分覆盖 | 题目视频查询、播放签名、SVIP/次数扣减、播放日志和动态水印上下文已有;缺深度防盗链、转码级水印、播放统计报表 |
| 背单词 | `VocabularyPage.tsx``VocabularyQuiz.tsx` | 部分覆盖 | 单词列表、进度、收藏、统计、每日计划和后端复习调度已覆盖;后续补收藏练习体验、发音/音频策略、排行榜和更精细的间隔算法参数 |
| 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 |
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势、后台维护和 JSON 批量导入已有;后续补复杂筛选优化和 AI 择校数据上下文 |
| 商城/SVIP | `Store.tsx``SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker、全额退款权益撤销、资金对账手工/API 导入比对、异常查询、差错工单和事件轨迹已有;缺微信/支付宝官方账单自动下载、异常订单运营台和前端收银台/售后体验 |
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、手机号绑定/换绑、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时、趋势和勋章展示 API 已有;缺学习报告可视化 |
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载、PDF/图片预览、worker 复检、内置安全扫描和外部 HTTP scanner 接入层已有;缺动态水印、深度防盗链、真实 AV/内容安全服务联调和生命周期策略 |
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载、PDF/图片预览、动态水印上下文、worker 复检、内置安全扫描和外部 HTTP scanner 接入层已有;缺前端水印渲染、深度防盗链、真实 AV/内容安全服务联调和生命周期策略 |
| 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、上传签名、上传确认、预览下载签名、资源访问审计、内置安全扫描和外部 HTTP scanner 接入层已有租户级存储策略、CDN/水印/真实 AV 服务联调待补 |
| 对象存储配置 | 部分覆盖 | 系统 env provider、上传签名、上传确认、预览下载签名、资源访问审计、水印 traceId、内置安全扫描和外部 HTTP scanner 接入层已有租户级存储策略、CDN/转码级水印/真实 AV 服务联调待补 |
## 平台 SaaS 后台功能
@@ -117,7 +117,7 @@
### P1商用主链路
1. 微信/支付宝支付和 webhook 幂等。
2. 对象存储真实 AV/内容安全服务联调、视频深度防盗链、动态水印。
2. 对象存储真实 AV/内容安全服务联调、视频深度防盗链、转码级水印。
3. 真实数据 dry-run 执行验收、导入抽样校验和性能压测。
4. 数据看板预聚合 worker、销售/代理转化看板和分佣结算。
5. 公共题库生产定时调度、失败告警、租户确认/跳过策略和更完整运营消息。

View File

@@ -51,9 +51,10 @@
- 已补上传后对象确认接口、托管对象发布前 verified 校验、PDF/图片 inline 预览签名。
- 已补 assets worker 复检,异常托管对象会自动下架并记录审计。
- 已补内容资源安全扫描状态机:确认上传后进入 `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 和动态水印 traceId
- 已补签名访问动态水印上下文:学生资料下载/预览、租户后台下载/预览和视频播放都会返回 `watermark`,并将 traceId 写入访问事件或播放事件。
- 已收紧锁定资源 CDN 边界:`members/svip/private` 外链默认拒绝,必须显式 provider-managed 才允许;视频绑定资源也复用该策略。
- 已补外部 HTTP 杀毒/内容安全 scanner 接入层、失败关闭生产 readiness 阻断;继续联调真实扫描服务、动态水印、CDN 刷新和对象生命周期策略。
- 已补外部 HTTP 杀毒/内容安全 scanner 接入层、失败关闭生产 readiness 阻断和动态水印 traceId 审计;继续联调真实扫描服务、转码/CDN 级水印、CDN 刷新和对象生命周期策略。
- `content_assets` 继续作为资源台账,不允许前端绕过台账直接访问私有资源。
3. 真实导入 dry-run
@@ -100,7 +101,7 @@
6. 视频会员控制
- 已完成视频 SVIP 权限、播放次数扣减、签名播放和播放日志。
- 继续补深度防盗链、动态水印、播放统计。
- 已补播放签名动态水印上下文和 traceId 事件;继续补深度防盗链、转码级水印、播放统计。
- 单题视频和通用知识视频混合推荐。
7. 学习统计
@@ -226,5 +227,5 @@
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
4. 云服务器部署 Supabase/PostgreSQL 和 API配置对象存储生产环境变量`check:refactor` 的远程等价测试。
5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
6. 并行补真实登录、微信/支付宝官方账单自动下载、异常订单运营台、对象存储真实 AV/内容安全服务联调、水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
6. 并行补真实登录、微信/支付宝官方账单自动下载、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
7. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。

View File

@@ -100,7 +100,7 @@ npm --workspace @tiku-saas/worker run exports:once
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` 发布。
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` 和动态水印 `traceId` 等审计字段。
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`
@@ -150,6 +150,7 @@ npm --workspace @tiku-saas/worker run exports:once
- 托管对象安全扫描失败会返回 `ASSET_SECURITY_SCAN_FAILED`;仍在等待扫描或扫描被跳过会返回 `ASSET_SECURITY_SCAN_REQUIRED`
- PDF/图片预览使用 `inline` 签名,不等同于长期公开 URL。
- 下载、预览和视频播放必须先经过 API 权限判断,再下发短期签名 URL。
- 下载、预览和视频播放响应会返回 `watermark` 上下文;前端必须按 `mode=visible_overlay` 渲染可见覆盖水印,不能隐藏 `traceId`
- 学生端 `preview`、锁定资料下载、视频和资料包默认使用更短 TTL。当前策略是学生 inline 预览、`members/svip/private` 资料、`video/package` 资源最多 300 秒;租户后台预览最多 3600 秒,后台下载最多 86400 秒。
- `visibility in ('members','svip','private')` 的外部 CDN/直链资源默认会被拒绝,除非资源 `metadata.providerManagedAccess=true``metadata.cdnAccessMode='signed_by_provider'`。商用建议这类资源优先登记为 `objectKey`,由 API 生成 OSS/COS/Supabase Storage 私有签名 URL。
- 每次上传签名、上传确认、学生下载/预览、后台下载/预览都会写入 `content_asset_access_events`。授权失败也会记录 `result=denied``denyCode`,用于租户后台排查资源访问问题。
@@ -157,6 +158,32 @@ npm --workspace @tiku-saas/worker run exports:once
- `content_assets` 是资源唯一台账,前端不得绕过台账直接访问私有 bucket。
- 前端不能把 `uploadStatus=failed``securityScanStatus!=passed``status=draft` 的资源继续展示为可下载;列表仍返回时应展示“资料处理中”“安全扫描中”或“资源异常已下架”,真正下载/预览会被后端拒绝。
## 动态水印上下文
学生端资料下载、PDF/图片预览、题目视频播放,以及租户后台下载/预览都会返回统一的 `watermark` 对象:
```json
{
"mode": "visible_overlay",
"required": true,
"text": "仅限本人学习 账号:AB12CD34 7D2A9C3E1B0F",
"traceId": "7D2A9C3E1B0F",
"position": "diagonal",
"opacity": 0.16,
"repeat": true,
"expiresAt": "2026-06-29T10:00:00.000Z",
"renderHint": "render_visible_overlay_before_opening_signed_url"
}
```
规则:
- `members/svip/private` 资源、视频和资料包强制返回可见水印metadata 不能关闭。
- 公开资源可通过安全 metadata 关闭水印,但后端权限仍然是最终判断。
- 水印文本包含账号哈希短码和 `traceId`,用于截图或录屏外泄后的访问事件回查。
- `content_asset_access_events.metadata.watermark.traceId` 会记录资料下载/预览水印;视频播放会记录到 `video_play_events.metadata.watermark.traceId`
- 当前阶段是前端可见覆盖层水印。生产后续可继续把同一 `traceId` 接入 CDN 鉴权、视频转码水印或服务端 PDF 二次渲染。
## 安全扫描字段
资源台账 `content_assets` 新增安全扫描状态:
@@ -255,7 +282,7 @@ public.content_asset_access_events
| `deny_code` | 拒绝原因,例如 `ASSET_CDN_ACCESS_NOT_ALLOWED` |
| `expires_in_sec` | 下发签名有效期 |
| `signature_mode` | `local-placeholder``supabase-storage-signed-url``aliyun-oss-signature-url-v1``tencent-cos-signature-url-v5``public-or-provider-managed` |
| `metadata` | 签名摘要、对象位置、文件名等排查信息;不保存云厂商密钥 |
| `metadata` | 签名摘要、对象位置、文件名、水印 traceId 等排查信息;不保存云厂商密钥 |
后台接口:
@@ -327,8 +354,8 @@ WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
- bucket 默认私有,公开资源也建议先经过 CDN/防盗链策略,不让前端直接持有写权限。
- 图片、PDF、视频分别设置合理的 CORS只允许前端域名和小程序业务域名访问。
- 开启对象版本控制、生命周期、跨区域复制或定时备份,满足后续容灾要求。
- 视频资源已接入 SVIP/播放次数校验、短期签名播放日志;生产阶段继续补转码、动态水印、CDN 防盗链和播放统计。
- 大文件上传已经支持 API 即时确认、worker 元数据复检、内置规则扫描外部 HTTP 扫描契约;生产阶段必须接入真实扫描服务 endpoint/token并继续补转码水印、CDN 刷新和生命周期策略。
- 视频资源已接入 SVIP/播放次数校验、短期签名播放日志和动态水印上下文;生产阶段继续补转码水印、CDN 防盗链和播放统计。
- 大文件上传已经支持 API 即时确认、worker 元数据复检、内置规则扫描外部 HTTP 扫描契约和访问水印 traceId;生产阶段必须接入真实扫描服务 endpoint/token并继续补转码水印、CDN 刷新和生命周期策略。
## 官方依据

View File

@@ -279,7 +279,7 @@ tenant:<tenantId>:theme
1. 列表页调用 `GET /api/catalog/assets`,只展示后端返回的 active 资源。
2. 预览 PDF/图片时调用 `GET /api/catalog/assets/preview?assetId=...`
3. 下载资料时调用 `GET /api/catalog/assets/download?assetId=...`
4. 使用响应里的 `preview.url``download.url` 立即打开;不要写入本地长期缓存。
4. 使用响应里的 `preview.url``download.url` 立即打开;不要写入本地长期缓存。若响应包含 `watermark.required=true`,必须先渲染可见水印覆盖层,再打开或展示签名资源。
签名有效期规则:
@@ -287,6 +287,32 @@ tenant:<tenantId>:theme
- 后台预览有效期也不是永久 URL租户后台应在用户点击时重新请求签名。
- 响应里的 `expiresInSec/expiresAt/signatureMode` 只用于 UI 提示和排查,不要自行延长有效期。
动态水印响应:
```json
{
"watermark": {
"mode": "visible_overlay",
"required": true,
"text": "仅限本人学习 账号:AB12CD34 7D2A9C3E1B0F",
"traceId": "7D2A9C3E1B0F",
"position": "diagonal",
"opacity": 0.16,
"repeat": true,
"expiresAt": "2026-06-29T10:00:00.000Z",
"renderHint": "render_visible_overlay_before_opening_signed_url"
}
}
```
前端处理规则:
- `mode=visible_overlay`PDF/图片预览、H5 视频播放器和资料打开页都要显示覆盖水印。
- 水印必须包含 `text``traceId`,不能只显示品牌名。
- `repeat=true` 建议做斜向重复水印;`position=bottom-right``center` 可作为单水印模式。
- 不要把 `traceId` 当隐私信息隐藏;它是外泄追踪码,会同步写入后端访问事件。
- 小程序端如果原生 PDF/video 组件覆盖层能力受限,应使用自定义容器包裹组件,至少在可视区域显示固定水印和 traceId。
锁定资源 CDN 规则:
- `visibility=members/svip/private` 的外部 `cdnUrl` 默认会被后端拒绝,返回 `ASSET_CDN_ACCESS_NOT_ALLOWED`
@@ -301,6 +327,8 @@ GET /api/tenant-content/assets/access-events?assetId=<assetId>&limit=100
该接口返回资源访问事件,包括学生下载、学生预览、后台下载、后台预览、上传签名、上传确认以及 denied 原因。租户后台可以在资源详情页增加“访问记录/异常记录”面板。
访问事件 `metadata.watermark.traceId` 可用于后台按截图上的追踪码回查访问记录。视频播放不走 `content_asset_access_events`,但 `POST /api/videos/play` 返回同样的 `watermark` 对象,后端会把 traceId 写入 `video_play_events.metadata.watermark.traceId`
## 练习访问控制契约
前端不要先拉完整题目列表再自行判断免费额度。用户点击顺序刷题、随机刷题、全真模拟时,统一调用 `POST /api/learning/practice-sessions`,后端会根据 `content_entries.accessRules``content_nodes.accessRules``question_collections.accessRules``practice_blueprints.accessRules` 和当前用户权益决定最终题目快照。

View File

@@ -181,6 +181,26 @@ async function markAssetSecurityScanPassed(assetId, provider = 'api_integration_
}
}
async function latestVideoPlayEventByToken(playToken) {
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
try {
const tokenHash = crypto.createHash('sha256').update(playToken).digest('hex');
const result = await pool.query(
`
select id, metadata
from public.video_play_events
where tenant_id = $1 and play_token_hash = $2
order by created_at desc
limit 1
`,
[MAIN_TENANT_ID, tokenHash],
);
return result.rows[0] || null;
} finally {
await pool.end();
}
}
function getFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
@@ -1666,6 +1686,14 @@ async function testVideos() {
assert.equal(quotaPlayback.access?.mode, 'video_quota', 'quota video playback should use quota mode');
assert.equal(quotaPlayback.access?.consumedQuota, 1, 'quota video playback should consume one quota');
assert.ok(quotaPlayback.playToken?.startsWith('vp_'), 'video playback should return a play token');
assert.equal(quotaPlayback.watermark?.mode, 'visible_overlay', 'quota video playback should include watermark context');
assert.equal(quotaPlayback.watermark?.required, true, 'quota video playback watermark should be required');
const quotaPlayEvent = await latestVideoPlayEventByToken(quotaPlayback.playToken);
assert.equal(
quotaPlayEvent?.metadata?.watermark?.traceId,
quotaPlayback.watermark?.traceId,
'quota video play event should record watermark trace id',
);
}
async function testVocabulary() {
@@ -1768,6 +1796,14 @@ async function testCommerce() {
assert.ok(svipPlayback.playback?.url, 'SVIP video playback should return signed URL after entitlement is active');
assert.equal(svipPlayback.access?.mode, 'svip', 'SVIP video playback should use svip mode');
assert.equal(svipPlayback.access?.consumedQuota, 0, 'SVIP video playback should not consume quota');
assert.equal(svipPlayback.watermark?.mode, 'visible_overlay', 'SVIP video playback should include watermark context');
assert.ok(svipPlayback.watermark?.text?.includes('仅限本人学习'), 'SVIP video watermark should use student wording');
const svipPlayEvent = await latestVideoPlayEventByToken(svipPlayback.playToken);
assert.equal(
svipPlayEvent?.metadata?.watermark?.traceId,
svipPlayback.watermark?.traceId,
'SVIP video play event should record watermark trace id',
);
const couponOnlyDenied = await request('/api/commerce/orders', {
method: 'POST',
@@ -3508,6 +3544,9 @@ async function testTenantContentAssetsAndImports() {
assert.equal(download.download?.url, 'https://example.test/resources/integration.pdf', 'external asset download should use cdn url');
assert.equal(download.download?.signatureMode, 'public-or-provider-managed', 'external asset should be marked provider managed');
assert.equal(download.download?.expiresInSec, 300, 'locked student asset downloads should use short TTL');
assert.equal(download.watermark?.mode, 'visible_overlay', 'locked student asset downloads should include a visible watermark context');
assert.equal(download.watermark?.required, true, 'locked student asset watermark should be required');
assert.ok(download.watermark?.traceId, 'locked student asset watermark should include a trace id');
const deniedLockedCdn = await request('/api/catalog/assets/download', {
query: { assetId: lockedCdnAsset.item.id },
@@ -3529,8 +3568,12 @@ async function testTenantContentAssetsAndImports() {
query: { assetId: asset.item.id },
});
assert.ok(
accessEventsAfterDownload.items?.some(item => item.accessType === 'download' && item.result === 'granted'),
'successful student asset download should be recorded as an access event',
accessEventsAfterDownload.items?.some(item => (
item.accessType === 'download' &&
item.result === 'granted' &&
item.metadata?.watermark?.traceId === download.watermark?.traceId
)),
'successful student asset download should record the watermark trace id',
);
const localAsset = await request('/api/tenant-content/assets', {
@@ -3625,6 +3668,7 @@ async function testTenantContentAssetsAndImports() {
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');
assert.equal(scannedLocalDownload.watermark?.mode, 'visible_overlay', 'student object asset download should include watermark context');
const localPreview = await request('/api/catalog/assets/preview', {
query: { assetId: localAsset.item.id },
@@ -3633,6 +3677,8 @@ async function testTenantContentAssetsAndImports() {
assert.equal(localPreview.preview?.signatureMode, 'local-placeholder', 'local asset preview should use local placeholder signer');
assert.ok(localPreview.preview?.url?.includes('disposition=inline'), 'asset preview should request inline disposition');
assert.equal(localPreview.preview?.expiresInSec, 300, 'student inline previews should use short TTL');
assert.equal(localPreview.watermark?.mode, 'visible_overlay', 'student inline previews should include watermark context');
assert.equal(localPreview.watermark?.traceId?.length, 12, 'student inline preview watermark should include short trace id');
const adminPreview = await request('/api/tenant-content/assets/sign-preview', {
userId: TENANT_ADMIN_USER_ID,
@@ -3640,14 +3686,27 @@ async function testTenantContentAssetsAndImports() {
body: { assetId: localAsset.item.id },
});
assert.ok(adminPreview.preview?.url?.includes('disposition=inline'), 'admin preview should request inline disposition');
assert.equal(adminPreview.watermark?.mode, 'visible_overlay', 'admin preview should include internal watermark context');
assert.ok(adminPreview.watermark?.text?.includes('内部资料'), 'admin preview watermark should use internal wording');
const localAccessEvents = await request('/api/tenant-content/assets/access-events', {
userId: TENANT_ADMIN_USER_ID,
query: { assetId: localAsset.item.id },
});
assert.ok(
localAccessEvents.items?.some(item => item.accessType === 'preview' && item.expiresInSec === 300),
'student preview access event should include short TTL',
localAccessEvents.items?.some(item => (
item.accessType === 'preview' &&
item.expiresInSec === 300 &&
item.metadata?.watermark?.traceId === localPreview.watermark?.traceId
)),
'student preview access event should include short TTL and watermark trace id',
);
assert.ok(
localAccessEvents.items?.some(item => (
item.accessType === 'admin_preview' &&
item.metadata?.watermark?.traceId === adminPreview.watermark?.traceId
)),
'admin preview access event should include watermark trace id',
);
const failedAsset = await request('/api/tenant-content/assets', {