diff --git a/README.md b/README.md index 2f5d1e3f..6a335a6a 100644 --- a/README.md +++ b/README.md @@ -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/短信/支付生产账号联调、微信/支付宝官方账单自动下载、异常订单运营台、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。 diff --git a/apps/api/src/features/catalog/assets.ts b/apps/api/src/features/catalog/assets.ts index 1a196fae..9fd88491 100644 --- a/apps/api/src/features/catalog/assets.ts +++ b/apps/api/src/features/catalog/assets.ts @@ -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, }; } diff --git a/apps/api/src/features/storage/asset-access.ts b/apps/api/src/features/storage/asset-access.ts index e8c609c6..b7cd81cc 100644 --- a/apps/api/src/features/storage/asset-access.ts +++ b/apps/api/src/features/storage/asset-access.ts @@ -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 | 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) { + 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) { + 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; diff --git a/apps/api/src/features/tenant-content/assets.ts b/apps/api/src/features/tenant-content/assets.ts index e43c76ff..a0548f1b 100644 --- a/apps/api/src/features/tenant-content/assets.ts +++ b/apps/api/src/features/tenant-content/assets.ts @@ -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, }; } diff --git a/apps/api/src/features/video/routes.ts b/apps/api/src/features/video/routes.ts index e349b377..f112461d 100644 --- a/apps/api/src/features/video/routes.ts +++ b/apps/api/src/features/video/routes.ts @@ -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, diff --git a/apps/taro/src/services/catalog.ts b/apps/taro/src/services/catalog.ts index 963e138a..e0876957 100644 --- a/apps/taro/src/services/catalog.ts +++ b/apps/taro/src/services/catalog.ts @@ -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 } }); } diff --git a/apps/taro/src/services/video.ts b/apps/taro/src/services/video.ts index 927655a8..db3b02ec 100644 --- a/apps/taro/src/services/video.ts +++ b/apps/taro/src/services/video.ts @@ -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; } diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 7df2db2d..a3e5f6a8 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -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 刷新和对象生命周期策略 | ## 订单、会员、营销 diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index 0a0bc148..93545430 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -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:企业级体验和增长闭环 diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index d2db659e..df51d657 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -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. 补微信/支付宝官方账单自动下载、异常订单运营台和优惠券核销报表;支付/退款补偿、退款查询确认、退款通知、资金对账导入比对和差错工单主链路已完成。 diff --git a/docs/refactor/blueprint-coverage.md b/docs/refactor/blueprint-coverage.md index f5e8d4df..fef46527 100644 --- a/docs/refactor/blueprint-coverage.md +++ b/docs/refactor/blueprint-coverage.md @@ -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 基础已落地,继续补分配策略和告警。 diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index bae3a526..347db9cc 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -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 择校、主题素材库/模板市场等仍是后续商用增强项。 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index 0c4cca52..19bfec8b 100644 --- a/docs/refactor/implementation-status.md +++ b/docs/refactor/implementation-status.md @@ -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-closed,exports 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 资料下载、安全扫描门禁、水印 traceId,assets worker 覆盖内置规则与外部 scanner 通过/失败/不可用 fail-closed,exports 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。 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 9738de6f..328ab546 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -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. 公共题库生产定时调度、失败告警、租户确认/跳过策略和更完整运营消息。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 36254436..900e2887 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -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. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。 diff --git a/docs/refactor/object-storage.md b/docs/refactor/object-storage.md index c0e51bad..63c8b2a8 100644 --- a/docs/refactor/object-storage.md +++ b/docs/refactor/object-storage.md @@ -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 刷新和生命周期策略。 ## 官方依据 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 9eeda119..24fd93a5 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -279,7 +279,7 @@ tenant::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::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=&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` 和当前用户权益决定最终题目快照。 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index a2eb888d..85c9bebc 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -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', {