From 3647c2bc2d6eb8d34a1957f68d7d8b5ff7d0c4ff Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 28 Jun 2026 23:00:05 +0800 Subject: [PATCH] feat: protect video playback access --- apps/api/src/features/video/index.ts | 3 +- apps/api/src/features/video/routes.ts | 300 +++++++++++++++++- docs/refactor/api-structure.md | 2 + docs/refactor/backend-capability-status.md | 4 +- docs/refactor/backend-progress.md | 1 + docs/refactor/legacy-feature-gap-matrix.md | 5 +- docs/refactor/next-development-todo.md | 8 +- docs/refactor/object-storage.md | 12 +- docs/refactor/taro-frontend-integration.md | 56 +++- scripts/api-integration-test.js | 35 ++ scripts/smoke-seed.js | 87 ++++- ...2606210010_video_playback_entitlements.sql | 92 ++++++ 12 files changed, 581 insertions(+), 24 deletions(-) create mode 100644 supabase/migrations/202606210010_video_playback_entitlements.sql diff --git a/apps/api/src/features/video/index.ts b/apps/api/src/features/video/index.ts index 59b38aa1..1fe31016 100644 --- a/apps/api/src/features/video/index.ts +++ b/apps/api/src/features/video/index.ts @@ -1,8 +1,9 @@ import type { RouteDefinition } from '../../core/router.js'; -import { questionVideosBatchRoute, questionVideosRoute, videoSearchRoute } from './routes.js'; +import { questionVideosBatchRoute, questionVideosRoute, videoPlaybackRoute, videoSearchRoute } from './routes.js'; export const videoRoutes: RouteDefinition[] = [ ['GET', '/api/questions/videos', questionVideosRoute], ['POST', '/api/questions/videos/batch', questionVideosBatchRoute], ['GET', '/api/videos/search', videoSearchRoute], + ['POST', '/api/videos/play', videoPlaybackRoute], ]; diff --git a/apps/api/src/features/video/routes.ts b/apps/api/src/features/video/routes.ts index 8123d150..e1725f17 100644 --- a/apps/api/src/features/video/routes.ts +++ b/apps/api/src/features/video/routes.ts @@ -1,6 +1,17 @@ -import { HttpError, type RequestContext } from '../../core/http.js'; -import { intParam, optionalStringArray, readJsonBody, stringParam, tenantIdFrom } from '../../core/request.js'; -import { query } from '../../core/db.js'; +import crypto from 'node:crypto'; +import type pg from 'pg'; +import { getHeader, HttpError, type RequestContext } from '../../core/http.js'; +import { + intParam, + optionalString, + optionalStringArray, + readJsonBody, + stringParam, + tenantIdFrom, + userIdFrom, +} from '../../core/request.js'; +import { query, queryOne, transaction } from '../../core/db.js'; +import { signStorageDownload, type StorageProviderName } from '../storage/service.js'; interface QuestionVideoRow { questionId: string; @@ -17,6 +28,40 @@ interface QuestionVideoRow { isGeneral: boolean; subjectId: string | null; difficulty: number | null; + accessMode: string; + freePreviewSeconds: number; + playable: boolean; +} + +interface VideoPlaybackRow { + id: string; + title: string; + description: string | null; + videoUrl: string | null; + thumbnailUrl: string | null; + duration: number | null; + subjectId: string | null; + regionId: string | null; + accessMode: string; + freePreviewSeconds: number; + assetId: string | null; + storageProvider: string | null; + bucket: string | null; + objectKey: string | null; + cdnUrl: string | null; + fileName: string | null; + assetTitle: string | null; + assetStatus: string | null; +} + +interface EntitlementAccessRow { + id: string; +} + +interface QuotaAccountRow { + id: string; + total_quota: number; + used_quota: number; } function videoSelectSql() { @@ -24,15 +69,111 @@ function videoSelectSql() { select qv.question_id as "questionId", qv.video_type as "videoType", qv.sort_order as "order", v.id, v.legacy_id as "legacyId", v.title, v.description, - v.video_url as "videoUrl", v.thumbnail_url as "thumbnailUrl", + case when v.access_mode = 'free' then v.video_url else null end as "videoUrl", + v.thumbnail_url as "thumbnailUrl", v.duration_seconds as "duration", v.knowledge_tags as "knowledgeTags", v.is_general as "isGeneral", v.subject_id as "subjectId", - v.difficulty + v.difficulty, v.access_mode as "accessMode", + v.free_preview_seconds as "freePreviewSeconds", + (v.access_mode = 'free') as playable from public.question_videos qv join public.video_explanations v on v.id = qv.video_id and v.tenant_id = qv.tenant_id `; } +function clientIpFrom(ctx: RequestContext) { + return (getHeader(ctx.req, 'x-forwarded-for').split(',')[0] || getHeader(ctx.req, 'x-real-ip') || ctx.req.socket.remoteAddress || '').trim(); +} + +function userAgentFrom(ctx: RequestContext) { + return getHeader(ctx.req, 'user-agent'); +} + +function createPlayToken() { + return `vp_${crypto.randomBytes(32).toString('base64url')}`; +} + +function hashPlayToken(token: string) { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +async function activeSvipEntitlement(tenantId: string, userId: string, video: VideoPlaybackRow) { + const now = new Date().toISOString(); + return queryOne( + ` + select id + from public.entitlements + where tenant_id = $1 + and user_id = $2 + and entitlement_type = 'svip' + and status = 'active' + and starts_at <= $3::timestamptz + and (expires_at is null or expires_at > $3::timestamptz) + and ( + scope_type = 'tenant' + or ($4::uuid is not null and scope_type = 'region' and scope_id = $4::uuid) + or ($5::uuid is not null and scope_type = 'subject' and scope_id = $5::uuid) + ) + order by case when scope_type = 'subject' then 0 when scope_type = 'region' then 1 else 2 end, + expires_at desc nulls first + limit 1 + `, + [tenantId, userId, now, video.regionId, video.subjectId], + ); +} + +async function availableVideoQuotaAccount(client: pg.PoolClient, tenantId: string, userId: string) { + const result = await client.query( + ` + select id, total_quota, used_quota + from public.video_play_quota_accounts + where tenant_id = $1 + and user_id = $2 + and quota_type = 'video_play' + and total_quota > used_quota + and (expires_at is null or expires_at > now()) + order by expires_at asc nulls last, created_at asc + limit 1 + for update + `, + [tenantId, userId], + ); + return result.rows[0] || null; +} + +function isExternalPublicVideo(video: VideoPlaybackRow) { + return video.videoUrl && /^https?:\/\//i.test(video.videoUrl); +} + +async function signVideoPlayback(tenantId: string, video: VideoPlaybackRow) { + if (video.assetId) { + if (video.assetStatus !== 'active') { + throw new HttpError(404, 'Video asset is not active', 'VIDEO_ASSET_NOT_FOUND'); + } + return signStorageDownload({ + tenantId, + provider: video.storageProvider as StorageProviderName, + bucket: video.bucket, + objectKey: video.objectKey, + cdnUrl: video.cdnUrl, + fileName: video.fileName || video.assetTitle || video.title, + expiresInSec: 600, + }); + } + if (isExternalPublicVideo(video)) { + return signStorageDownload({ + tenantId, + provider: 'external_url', + bucket: null, + objectKey: null, + cdnUrl: video.videoUrl, + fileName: video.title, + expiresInSec: 600, + }); + } + throw new HttpError(409, 'Video does not have a playable asset', 'VIDEO_ASSET_REQUIRED'); +} + export async function questionVideosRoute(ctx: RequestContext) { const tenantId = await tenantIdFrom(ctx); const questionId = stringParam(ctx, 'questionId'); @@ -91,10 +232,14 @@ export async function videoSearchRoute(ctx: RequestContext) { const videos = await query( ` select id, legacy_id as "legacyId", title, description, - video_url as "videoUrl", thumbnail_url as "thumbnailUrl", + case when access_mode = 'free' then video_url else null end as "videoUrl", + thumbnail_url as "thumbnailUrl", duration_seconds as "duration", knowledge_tags as "knowledgeTags", is_general as "isGeneral", subject_id as "subjectId", - difficulty, sort_order as "order", created_at as "createdAt" + difficulty, access_mode as "accessMode", + free_preview_seconds as "freePreviewSeconds", + (access_mode = 'free') as playable, + sort_order as "order", created_at as "createdAt" from public.video_explanations where tenant_id = $1 and is_active = true @@ -109,3 +254,144 @@ export async function videoSearchRoute(ctx: RequestContext) { return { videos, total: videos.length }; } + +export async function videoPlaybackRoute(ctx: RequestContext) { + const tenantId = await tenantIdFrom(ctx); + const body = await readJsonBody(ctx); + const userId = await userIdFrom(ctx, body); + const videoId = optionalString(body, 'videoId') || stringParam(ctx, 'videoId'); + const questionId = optionalString(body, 'questionId') || null; + if (!videoId) throw new HttpError(400, 'videoId is required', 'VIDEO_ID_REQUIRED'); + + const video = await queryOne( + ` + select v.id, v.title, v.description, v.video_url as "videoUrl", + v.thumbnail_url as "thumbnailUrl", v.duration_seconds as "duration", + v.subject_id as "subjectId", + coalesce(qb.region_id, qs.region_id, vs.region_id) as "regionId", + v.access_mode as "accessMode", + v.free_preview_seconds as "freePreviewSeconds", + v.asset_id as "assetId", + a.storage_provider as "storageProvider", a.bucket, a.object_key as "objectKey", + a.cdn_url as "cdnUrl", a.file_name as "fileName", + a.title as "assetTitle", a.status as "assetStatus" + from public.video_explanations v + left join public.content_assets a on a.tenant_id = v.tenant_id and a.id = v.asset_id + left join public.subjects vs on vs.tenant_id = v.tenant_id and vs.id = v.subject_id + left join public.questions q on q.tenant_id = v.tenant_id and q.id = $3::uuid + left join public.subjects qs on qs.tenant_id = q.tenant_id and qs.id = q.subject_id + left join public.question_banks qb on qb.tenant_id = q.tenant_id and qb.id = q.question_bank_id + where v.tenant_id = $1 + and v.id = $2 + and v.is_active = true + and ( + $3::uuid is null + or exists ( + select 1 + from public.question_videos qv + where qv.tenant_id = v.tenant_id + and qv.video_id = v.id + and qv.question_id = $3::uuid + ) + ) + limit 1 + `, + [tenantId, videoId, questionId], + ); + + if (!video) throw new HttpError(404, 'Video not found', 'VIDEO_NOT_FOUND'); + + const playback = await transaction(async client => { + let entitlementId: string | null = null; + let quotaAccountId: string | null = null; + let consumedQuota = 0; + const accessMode = video.accessMode || 'svip'; + + if (accessMode === 'svip') { + const entitlement = await activeSvipEntitlement(tenantId, userId, video); + if (!entitlement) throw new HttpError(403, 'SVIP entitlement is required for this video', 'VIDEO_SVIP_REQUIRED'); + entitlementId = entitlement.id; + } else if (accessMode === 'video_quota') { + const account = await availableVideoQuotaAccount(client, tenantId, userId); + if (!account) throw new HttpError(403, 'Video play quota is required', 'VIDEO_QUOTA_REQUIRED'); + await client.query( + ` + update public.video_play_quota_accounts + set used_quota = used_quota + 1, updated_at = now() + where tenant_id = $1 and id = $2 + `, + [tenantId, account.id], + ); + quotaAccountId = account.id; + consumedQuota = 1; + } + + const signed = await signVideoPlayback(tenantId, video); + const token = createPlayToken(); + const tokenHash = hashPlayToken(token); + + const event = await client.query( + ` + insert into public.video_play_events ( + tenant_id, user_id, video_id, question_id, entitlement_id, quota_account_id, + play_token_hash, status, access_mode, consumed_quota, + signed_url_expires_at, ip_address, user_agent, metadata + ) + values ($1, $2, $3, $4::uuid, $5::uuid, $6::uuid, $7, 'issued', $8, $9, + $10::timestamptz, $11, $12, $13::jsonb) + returning id, created_at as "createdAt" + `, + [ + tenantId, + userId, + video.id, + questionId, + entitlementId, + quotaAccountId, + tokenHash, + accessMode, + consumedQuota, + signed.expiresAt, + clientIpFrom(ctx) || null, + userAgentFrom(ctx) || null, + JSON.stringify({ + signatureMode: signed.signatureMode, + provider: signed.provider, + bucket: signed.bucket, + objectKey: signed.objectKey, + freePreviewSeconds: video.freePreviewSeconds, + }), + ], + ); + + await client.query( + 'update public.video_explanations set play_count = play_count + 1, updated_at = now() where tenant_id = $1 and id = $2', + [tenantId, video.id], + ); + + return { + playEvent: event.rows[0], + playToken: token, + playback: signed, + access: { + mode: accessMode, + entitlementId, + quotaAccountId, + consumedQuota, + }, + }; + }); + + return { + item: { + id: video.id, + title: video.title, + description: video.description, + thumbnailUrl: video.thumbnailUrl, + duration: video.duration, + accessMode: video.accessMode, + freePreviewSeconds: video.freePreviewSeconds, + }, + ...playback, + }; +} diff --git a/docs/refactor/api-structure.md b/docs/refactor/api-structure.md index a90bd0b5..a5ce05d9 100644 --- a/docs/refactor/api-structure.md +++ b/docs/refactor/api-structure.md @@ -20,6 +20,8 @@ apps/api/src/ learning/ 组卷 session、答题、错题、收藏、练习进度 commerce/ 订单、支付确认、激活码、权益 referral/ 销售/代理客资追踪、首绑保护、团队关系、CRM 队列 + storage/ 对象存储签名 provider + video/ 题目视频列表、搜索、SVIP/次数校验和签名播放 platform-admin/ 平台方 SaaS 租户、订阅、账单、使用量 tenant-admin/ 租户品牌、域名、公开设置、登录/商户配置、成员权限、活动/兑换码运营 tenant-content/ 租户后台内容维护:入口、分类树、集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、批量导入 diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index bec178d6..18d812cf 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -69,8 +69,8 @@ | 知识手册目录/内容 | 可联调 | `/api/catalog/handbook-*` | | 知识手册 JSON 导入 | 可联调 | `/api/tenant-content/imports/*/handbook` | | 分数线字段/院校/专业/记录/趋势 | 可联调 | `/api/scoreline/*` | -| 题目视频/批量预加载/搜索 | 可联调 | `/api/questions/*/videos`、`/api/videos/search` | -| 视频会员播放次数 | 待补齐 | 缺播放次数扣减、播放日志、防盗链、水印 | +| 题目视频/批量预加载/搜索 | 可联调 | `/api/questions/*/videos`、`/api/videos/search`;付费视频列表不返回可播放 URL | +| 视频会员播放次数 | 可联调 | `POST /api/videos/play` 支持 SVIP/视频次数校验、签名播放、次数扣减、播放日志;深度防盗链和动态水印继续补 | ## 资料与对象存储 diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index 1bdc3de8..b5d75f67 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -76,6 +76,7 @@ GET /api/scoreline/years GET /api/questions/{questionId}/videos POST /api/questions/videos/batch GET /api/videos/search +POST /api/videos/play GET /api/tenant-content/content-entries PUT /api/tenant-content/content-entries GET /api/tenant-content/content-nodes diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index be5f9433..b22d01f2 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -25,7 +25,7 @@ | 全真模拟 | `components/AdminMockexam`、`MockExamConfigModal.tsx` | 部分覆盖 | 后端有 blueprint 基础;缺完整交卷报告、排名、复盘 | | 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 后续补错题复习计划 | | 收藏夹 | `WordFavoritesPage.tsx`、题目收藏 | 已覆盖 | 题目和单词收藏已有 | -| 题目视频 | `VideoPlayer.tsx` | 部分覆盖 | 题目视频查询已有;缺播放签名、次数扣减、水印、防下载 | +| 题目视频 | `VideoPlayer.tsx` | 部分覆盖 | 题目视频查询、播放签名、SVIP/次数扣减、播放日志已有;缺深度防盗链、动态水印、播放统计报表 | | 背单词 | `VocabularyPage.tsx`、`VocabularyQuiz.tsx` | 部分覆盖 | 单词列表/进度/收藏/统计已有;缺完整艾宾浩斯算法、每日计划、收藏练习细节 | | 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 | | 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势已有;缺批量导入和复杂筛选优化 | @@ -97,7 +97,7 @@ ### P1:商用主链路 1. 微信/支付宝支付和 webhook 幂等。 -2. 对象存储 PDF 预览、视频播放签名、防盗链、水印。 +2. 对象存储 PDF 预览、视频深度防盗链、动态水印。 3. Excel/CSV、分数线、视频批量导入。 4. 数据看板和销售/代理分佣结算。 5. 公共题库授权、租户采纳和版本同步。 @@ -108,4 +108,3 @@ 2. 班级、教师、学生分组和学习督导。 3. AI 择校推荐和 PDF 报告。 4. 题库导出、试卷生成、每日一练运营工具。 - diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 9f58dd37..dc789ed2 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -8,7 +8,7 @@ - Supabase/PostgreSQL 多租户 schema、RLS、索引、触发器。 - Node.js API 分层:`core/features`。 -- 学生端核心 API:题库、练习、答题、错题、收藏、背单词、知识手册、分数线、视频、资料、订单、权益、个人中心。 +- 学生端核心 API:题库、练习、答题、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单、权益、个人中心。 - 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、激活码、优惠券、成员权限、审计、内容管理。 - 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。 - 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 队列。 @@ -33,7 +33,7 @@ 2. 对象存储 - 已接阿里云 OSS、腾讯云 COS、Supabase Storage 的上传/下载签名 provider。 - - 继续补上传后对象存在性校验、PDF 预览地址、视频播放签名、防盗链、水印和 worker 校验。 + - 继续补上传后对象存在性校验、PDF 预览地址、视频深度防盗链、动态水印和 worker 校验。 - `content_assets` 继续作为资源台账,不允许前端绕过台账直接访问私有资源。 3. 真实导入 dry-run @@ -71,8 +71,8 @@ - 租户采纳、复制、授权、版本同步策略。 5. 视频会员控制 - - 视频 SVIP 权限、播放次数扣减。 - - 防盗链、水印、播放日志、播放统计。 + - 已完成视频 SVIP 权限、播放次数扣减、签名播放和播放日志。 + - 继续补深度防盗链、动态水印、播放统计。 - 单题视频和通用知识视频混合推荐。 6. 学习统计 diff --git a/docs/refactor/object-storage.md b/docs/refactor/object-storage.md index 93180e33..7a2fd3d5 100644 --- a/docs/refactor/object-storage.md +++ b/docs/refactor/object-storage.md @@ -4,7 +4,7 @@ ## 目标 -题库里的图片、PDF、视频、音频、资料包等媒体资源统一走 `content_assets` 台账和后端签名接口。前端不直接保存或读取云厂商密钥,也不直接拼接私有资源 URL。 +题库里的图片、PDF、视频、音频、资料包等媒体资源统一走 `content_assets` 台账和后端签名接口。前端不直接保存或读取云厂商密钥,也不直接拼接私有资源 URL。题目视频播放还需要经过 `POST /api/videos/play` 校验 SVIP 或视频次数权益后下发短期签名 URL。 已接入的 provider: @@ -34,6 +34,12 @@ PUT /api/tenant-content/assets GET /api/catalog/assets/download?assetId=... ``` +学生端视频播放: + +```text +POST /api/videos/play +``` + 后台管理员下载: ```text @@ -46,7 +52,7 @@ POST /api/tenant-content/assets/sign-download - `objectKey` 默认必须以当前 `tenantId/` 开头,防止跨租户覆盖或读取。 - 禁止 `..`、反斜杠、编码斜杠等危险 object key。 - 上传会校验 MIME 类型和文件大小。 -- 下载必须先经过 API 权限判断,再下发短期签名 URL。 +- 下载和视频播放必须先经过 API 权限判断,再下发短期签名 URL。 - 云厂商 AccessKey、SecretKey、Service Role Key 只存在服务端环境变量,不返回前端。 - `content_assets` 是资源唯一台账,前端不得绕过台账直接访问私有 bucket。 @@ -98,7 +104,7 @@ SUPABASE_STORAGE_SERVICE_KEY= - bucket 默认私有,公开资源也建议先经过 CDN/防盗链策略,不让前端直接持有写权限。 - 图片、PDF、视频分别设置合理的 CORS,只允许前端域名和小程序业务域名访问。 - 开启对象版本控制、生命周期、跨区域复制或定时备份,满足后续容灾要求。 -- 视频资源建议后续接入转码、水印、防盗链、播放日志和播放次数扣减。 +- 视频资源已接入 SVIP/播放次数校验、短期签名和播放日志;生产阶段继续补转码、动态水印、CDN 防盗链和播放统计。 - 大文件上传后应由 worker 校验对象是否真实存在、大小/hash 是否匹配,再把资源状态从 `draft` 发布为 `active`。 ## 官方依据 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 86142345..6f947359 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -139,7 +139,7 @@ tenant::theme | 提交答案 | `POST /api/learning/answers` | | 错题本 | `GET /api/learning/wrong-questions`、`POST /api/learning/wrong-questions/resolve` | | 收藏夹 | `GET/POST /api/learning/favorites/questions` | -| 题目视频 | `GET /api/questions/{questionId}/videos`、`POST /api/questions/videos/batch` | +| 题目视频 | `GET /api/questions/{questionId}/videos`、`POST /api/questions/videos/batch`、`POST /api/videos/play` | | 背单词 | `/api/catalog/vocabulary-units`、`/api/catalog/vocabulary-words` | | 单词进度 | `/api/learning/vocabulary/progress`、`/api/learning/vocabulary/stats` | | 单词收藏 | `/api/learning/vocabulary/favorites` | @@ -152,6 +152,60 @@ tenant::theme | 个人中心 | `GET/PATCH /api/profile/me` | | 销售分享 | `/api/referral/resolve`、`track-event`、`bind` | +## 视频播放契约 + +题目视频分为 `free`、`svip`、`video_quota` 三种访问模式。列表接口只用于展示标题、封面、时长、访问模式和试看秒数;除免费公开视频外,列表和搜索接口不会返回可播放 URL。 + +播放步骤: + +1. 进入题目页后调用 `GET /api/questions/{questionId}/videos` 或批量预加载 `POST /api/questions/videos/batch`。 +2. 用户点击播放时调用 `POST /api/videos/play`。 +3. 后端校验当前 session 用户、租户、题目绑定关系、SVIP 权益或视频次数权益。 +4. 后端返回短期签名 URL、播放 token、权益来源和过期时间。 +5. 前端播放器只使用本次返回的 `playback.url`,不要缓存为长期资源地址。 + +请求示例: + +```json +{ + "videoId": "00000000-0000-0000-0000-000000000821", + "questionId": "00000000-0000-0000-0000-000000000401" +} +``` + +响应关键字段: + +```json +{ + "item": { + "id": "...", + "title": "...", + "accessMode": "svip", + "freePreviewSeconds": 15 + }, + "playToken": "vp_...", + "playback": { + "url": "https://...", + "expiresAt": "2026-06-28T12:00:00.000Z", + "signatureMode": "signed" + }, + "access": { + "mode": "svip", + "entitlementId": "...", + "quotaAccountId": null, + "consumedQuota": 0 + } +} +``` + +前端处理规则: + +- `VIDEO_SVIP_REQUIRED`:弹出开通或升级会员。 +- `VIDEO_QUOTA_REQUIRED`:提示购买视频次数包或套餐。 +- `VIDEO_ASSET_REQUIRED`:展示“视频暂不可播放”,同时上报前端日志。 +- 签名 URL 过期后必须重新调用 `/api/videos/play`,不要重试旧 URL。 +- 小程序/H5 不保存对象存储真实 key,不把播放 URL 写入本地持久缓存。 + ## 题库新模型接入方式 旧项目常按“地区 -> 科目 -> 章节/试卷”固定层级处理。新项目不要写死层级,按下面模型渲染: diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 73ade05b..1617dcf1 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -30,6 +30,8 @@ const ids = { question: '00000000-0000-0000-0000-000000000401', vocabularyUnit: '00000000-0000-0000-0000-000000000811', vocabularyWord: '00000000-0000-0000-0000-000000000812', + video: '00000000-0000-0000-0000-000000000821', + quotaVideo: '00000000-0000-0000-0000-000000000824', scorelineSchool: '00000000-0000-0000-0000-000000000831', }; @@ -646,6 +648,11 @@ async function testScoreline() { async function testVideos() { const single = await request(`/api/questions/${ids.question}/videos`); assert.ok(single.total >= 1, 'question should have videos'); + const svipVideo = single.videos?.find(item => item.id === ids.video); + const quotaVideo = single.videos?.find(item => item.id === ids.quotaVideo); + assert.equal(svipVideo?.accessMode, 'svip', 'SVIP video should expose access mode'); + assert.equal(svipVideo?.videoUrl, null, 'SVIP video list must not expose playable URL'); + assert.equal(quotaVideo?.accessMode, 'video_quota', 'quota video should expose access mode'); const batch = await request('/api/questions/videos/batch', { method: 'POST', @@ -655,6 +662,26 @@ async function testVideos() { const search = await request('/api/videos/search', { query: { tags: '烟测' } }); assert.ok(search.videos?.some(item => item.title === '烟测题目视频讲解'), 'general video search should find smoke video'); + assert.ok(!JSON.stringify(search).includes('https://example.test/videos/smoke.mp4'), 'video search must not expose paid playback URL'); + + const noSvipLogin = await loginBySms('13800000007'); + const svipDenied = await request('/api/videos/play', { + userId: false, + headers: { authorization: `Bearer ${noSvipLogin.session.token}` }, + method: 'POST', + body: { videoId: ids.video, questionId: ids.question }, + expectStatus: 403, + }); + assert.equal(svipDenied.code, 'VIDEO_SVIP_REQUIRED', 'SVIP video playback should require entitlement before commerce grants it'); + + const quotaPlayback = await request('/api/videos/play', { + method: 'POST', + body: { videoId: ids.quotaVideo, questionId: ids.question }, + }); + assert.ok(quotaPlayback.playback?.url, 'quota video playback should return signed URL'); + 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'); } async function testVocabulary() { @@ -692,6 +719,14 @@ async function testCommerce() { assert.ok(entitlements.summary && typeof entitlements.summary.isSvip === 'boolean', 'entitlements should include summary'); assert.equal(entitlements.summary.isSvip, true, 'redeemed activation code should make smoke user SVIP'); + const svipPlayback = await request('/api/videos/play', { + method: 'POST', + body: { videoId: ids.video, questionId: ids.question }, + }); + 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'); + const fakeWechatPay = await startFakeWechatPayServer(); const wechatAccount = await request('/api/tenant-admin/payment-accounts', { userId: TENANT_ADMIN_USER_ID, diff --git a/scripts/smoke-seed.js b/scripts/smoke-seed.js index b1ff9d54..484cb301 100644 --- a/scripts/smoke-seed.js +++ b/scripts/smoke-seed.js @@ -33,6 +33,10 @@ const ids = { vocabularyWord: '00000000-0000-0000-0000-000000000812', video: '00000000-0000-0000-0000-000000000821', questionVideo: '00000000-0000-0000-0000-000000000822', + videoAsset: '00000000-0000-0000-0000-000000000823', + quotaVideo: '00000000-0000-0000-0000-000000000824', + quotaQuestionVideo: '00000000-0000-0000-0000-000000000825', + videoQuotaAccount: '00000000-0000-0000-0000-000000000826', scorelineSchool: '00000000-0000-0000-0000-000000000831', scorelineMajor: '00000000-0000-0000-0000-000000000832', scorelineField: '00000000-0000-0000-0000-000000000833', @@ -508,26 +512,73 @@ async function main() { [tenantId, ids.question, ids.questionVersion], ); + await client.query( + ` + insert into public.content_assets ( + id, tenant_id, asset_key, title, asset_type, storage_provider, + bucket, object_key, file_name, mime_type, visibility, status, source + ) + values ( + $1, $2, 'smoke-video-asset', '烟测视频对象', 'video', 'local_dev', + 'tenant-assets', $3, 'smoke.mp4', 'video/mp4', + 'svip', 'active', 'smoke-seed' + ) + on conflict (id) + do update set storage_provider = excluded.storage_provider, + bucket = excluded.bucket, + object_key = excluded.object_key, + visibility = excluded.visibility, + status = 'active', + updated_at = now() + `, + [ids.videoAsset, tenantId, `${tenantId}/videos/smoke.mp4`], + ); + await client.query( ` insert into public.video_explanations ( id, tenant_id, legacy_id, title, description, video_url, thumbnail_url, duration_seconds, knowledge_tags, is_general, subject_id, difficulty, - sort_order, is_active + sort_order, is_active, asset_id, access_mode, free_preview_seconds ) values ( $1, $2, 'smoke-video', '烟测题目视频讲解', '用于验证题目视频 API', 'https://example.test/videos/smoke.mp4', 'https://example.test/videos/smoke.jpg', - 180, '["基础加法","烟测"]'::jsonb, true, $3, 1, 1, true + 180, '["基础加法","烟测"]'::jsonb, true, $3, 1, 1, true, $4, 'svip', 15 ) on conflict (id) do update set title = excluded.title, video_url = excluded.video_url, subject_id = excluded.subject_id, + asset_id = excluded.asset_id, + access_mode = excluded.access_mode, is_active = true, updated_at = now() `, - [ids.video, tenantId, ids.subject], + [ids.video, tenantId, ids.subject, ids.videoAsset], + ); + + await client.query( + ` + insert into public.video_explanations ( + id, tenant_id, legacy_id, title, description, video_url, thumbnail_url, + duration_seconds, knowledge_tags, is_general, subject_id, difficulty, + sort_order, is_active, asset_id, access_mode, free_preview_seconds + ) + values ( + $1, $2, 'smoke-quota-video', '烟测次数视频讲解', '用于验证视频播放次数扣减', + 'https://example.test/videos/quota.mp4', 'https://example.test/videos/quota.jpg', + 90, '["次数权益","烟测"]'::jsonb, false, $3, 1, 2, true, $4, 'video_quota', 0 + ) + on conflict (id) + do update set title = excluded.title, + subject_id = excluded.subject_id, + asset_id = excluded.asset_id, + access_mode = excluded.access_mode, + is_active = true, + updated_at = now() + `, + [ids.quotaVideo, tenantId, ids.subject, ids.videoAsset], ); await client.query( @@ -544,6 +595,36 @@ async function main() { [ids.questionVideo, tenantId, ids.question, ids.video], ); + await client.query( + ` + insert into public.question_videos ( + id, tenant_id, question_id, video_id, legacy_id, video_type, sort_order + ) + values ($1, $2, $3, $4, 'smoke-quota-question-video', 'quota', 2) + on conflict (id) + do update set question_id = excluded.question_id, + video_id = excluded.video_id, + video_type = excluded.video_type, + updated_at = now() + `, + [ids.quotaQuestionVideo, tenantId, ids.question, ids.quotaVideo], + ); + + await client.query( + ` + insert into public.video_play_quota_accounts ( + id, tenant_id, user_id, quota_type, total_quota, used_quota, expires_at, source_type, source_id, metadata + ) + values ($1, $2, $3, 'video_play', 3, 0, now() + interval '30 days', 'smoke-seed', null, '{"source":"smoke-seed"}'::jsonb) + on conflict (id) + do update set total_quota = 3, + used_quota = 0, + expires_at = now() + interval '30 days', + updated_at = now() + `, + [ids.videoQuotaAccount, tenantId, ids.user], + ); + await client.query( ` insert into public.svip_plans ( diff --git a/supabase/migrations/202606210010_video_playback_entitlements.sql b/supabase/migrations/202606210010_video_playback_entitlements.sql new file mode 100644 index 00000000..90950fa0 --- /dev/null +++ b/supabase/migrations/202606210010_video_playback_entitlements.sql @@ -0,0 +1,92 @@ +alter table public.video_explanations + add column if not exists asset_id uuid references public.content_assets(id) on delete set null, + add column if not exists access_mode text not null default 'svip', + add column if not exists free_preview_seconds integer not null default 0, + add column if not exists play_count integer not null default 0; + +do $$ +begin + if not exists (select 1 from pg_constraint where conname = 'video_explanations_access_mode_check') then + alter table public.video_explanations + add constraint video_explanations_access_mode_check + check (access_mode in ('free', 'svip', 'video_quota')); + end if; + + if not exists (select 1 from pg_constraint where conname = 'video_explanations_free_preview_check') then + alter table public.video_explanations + add constraint video_explanations_free_preview_check + check (free_preview_seconds >= 0); + end if; + + if not exists (select 1 from pg_constraint where conname = 'video_explanations_play_count_check') then + alter table public.video_explanations + add constraint video_explanations_play_count_check + check (play_count >= 0); + end if; +end $$; + +create table if not exists public.video_play_quota_accounts ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + user_id uuid not null references public.platform_users(id) on delete cascade, + quota_type text not null default 'video_play', + total_quota integer not null default 0, + used_quota integer not null default 0, + expires_at timestamptz, + source_type text, + source_id uuid, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (tenant_id, user_id, quota_type, source_type, source_id) +); + +create table if not exists public.video_play_events ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + user_id uuid not null references public.platform_users(id) on delete cascade, + video_id uuid not null references public.video_explanations(id) on delete cascade, + question_id uuid references public.questions(id) on delete set null, + entitlement_id uuid references public.entitlements(id) on delete set null, + quota_account_id uuid references public.video_play_quota_accounts(id) on delete set null, + play_token_hash text not null unique, + status text not null default 'issued' check (status in ('issued', 'started', 'completed', 'expired', 'revoked')), + access_mode text not null default 'svip' check (access_mode in ('free', 'svip', 'video_quota')), + consumed_quota integer not null default 0 check (consumed_quota >= 0), + signed_url_expires_at timestamptz, + ip_address text, + user_agent text, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_video_explanations_asset + on public.video_explanations(tenant_id, asset_id) + where asset_id is not null; + +create index if not exists idx_video_quota_user + on public.video_play_quota_accounts(tenant_id, user_id, quota_type, expires_at); + +create index if not exists idx_video_play_events_user + on public.video_play_events(tenant_id, user_id, created_at desc); + +create index if not exists idx_video_play_events_video + on public.video_play_events(tenant_id, video_id, created_at desc); + +do $$ +declare + table_name text; +begin + foreach table_name in array array['video_play_quota_accounts', 'video_play_events'] + loop + execute format('alter table public.%I enable row level security', table_name); + execute format('drop policy if exists tenant_isolation on public.%I', table_name); + execute format( + 'create policy tenant_isolation on public.%I for all using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) with check (tenant_id = app.current_tenant_id() or app.is_platform_admin())', + table_name + ); + execute format('drop trigger if exists set_updated_at on public.%I', table_name); + execute format('create trigger set_updated_at before update on public.%I for each row execute function app.touch_updated_at()', table_name); + end loop; +end $$;