feat: protect video playback access

This commit is contained in:
Codex
2026-06-28 23:00:05 +08:00
parent db65295ddc
commit 3647c2bc2d
12 changed files with 581 additions and 24 deletions

View File

@@ -1,8 +1,9 @@
import type { RouteDefinition } from '../../core/router.js'; 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[] = [ export const videoRoutes: RouteDefinition[] = [
['GET', '/api/questions/videos', questionVideosRoute], ['GET', '/api/questions/videos', questionVideosRoute],
['POST', '/api/questions/videos/batch', questionVideosBatchRoute], ['POST', '/api/questions/videos/batch', questionVideosBatchRoute],
['GET', '/api/videos/search', videoSearchRoute], ['GET', '/api/videos/search', videoSearchRoute],
['POST', '/api/videos/play', videoPlaybackRoute],
]; ];

View File

@@ -1,6 +1,17 @@
import { HttpError, type RequestContext } from '../../core/http.js'; import crypto from 'node:crypto';
import { intParam, optionalStringArray, readJsonBody, stringParam, tenantIdFrom } from '../../core/request.js'; import type pg from 'pg';
import { query } from '../../core/db.js'; 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 { interface QuestionVideoRow {
questionId: string; questionId: string;
@@ -17,6 +28,40 @@ interface QuestionVideoRow {
isGeneral: boolean; isGeneral: boolean;
subjectId: string | null; subjectId: string | null;
difficulty: number | 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() { function videoSelectSql() {
@@ -24,15 +69,111 @@ function videoSelectSql() {
select qv.question_id as "questionId", qv.video_type as "videoType", select qv.question_id as "questionId", qv.video_type as "videoType",
qv.sort_order as "order", qv.sort_order as "order",
v.id, v.legacy_id as "legacyId", v.title, v.description, 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.duration_seconds as "duration", v.knowledge_tags as "knowledgeTags",
v.is_general as "isGeneral", v.subject_id as "subjectId", 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 from public.question_videos qv
join public.video_explanations v on v.id = qv.video_id and v.tenant_id = qv.tenant_id 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<EntitlementAccessRow>(
`
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<QuotaAccountRow>(
`
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) { export async function questionVideosRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx); const tenantId = await tenantIdFrom(ctx);
const questionId = stringParam(ctx, 'questionId'); const questionId = stringParam(ctx, 'questionId');
@@ -91,10 +232,14 @@ export async function videoSearchRoute(ctx: RequestContext) {
const videos = await query( const videos = await query(
` `
select id, legacy_id as "legacyId", title, description, 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", duration_seconds as "duration", knowledge_tags as "knowledgeTags",
is_general as "isGeneral", subject_id as "subjectId", 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 from public.video_explanations
where tenant_id = $1 where tenant_id = $1
and is_active = true and is_active = true
@@ -109,3 +254,144 @@ export async function videoSearchRoute(ctx: RequestContext) {
return { videos, total: videos.length }; 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<VideoPlaybackRow>(
`
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,
};
}

View File

@@ -20,6 +20,8 @@ apps/api/src/
learning/ 组卷 session、答题、错题、收藏、练习进度 learning/ 组卷 session、答题、错题、收藏、练习进度
commerce/ 订单、支付确认、激活码、权益 commerce/ 订单、支付确认、激活码、权益
referral/ 销售/代理客资追踪、首绑保护、团队关系、CRM 队列 referral/ 销售/代理客资追踪、首绑保护、团队关系、CRM 队列
storage/ 对象存储签名 provider
video/ 题目视频列表、搜索、SVIP/次数校验和签名播放
platform-admin/ 平台方 SaaS 租户、订阅、账单、使用量 platform-admin/ 平台方 SaaS 租户、订阅、账单、使用量
tenant-admin/ 租户品牌、域名、公开设置、登录/商户配置、成员权限、活动/兑换码运营 tenant-admin/ 租户品牌、域名、公开设置、登录/商户配置、成员权限、活动/兑换码运营
tenant-content/ 租户后台内容维护:入口、分类树、集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、批量导入 tenant-content/ 租户后台内容维护:入口、分类树、集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、批量导入

View File

@@ -69,8 +69,8 @@
| 知识手册目录/内容 | 可联调 | `/api/catalog/handbook-*` | | 知识手册目录/内容 | 可联调 | `/api/catalog/handbook-*` |
| 知识手册 JSON 导入 | 可联调 | `/api/tenant-content/imports/*/handbook` | | 知识手册 JSON 导入 | 可联调 | `/api/tenant-content/imports/*/handbook` |
| 分数线字段/院校/专业/记录/趋势 | 可联调 | `/api/scoreline/*` | | 分数线字段/院校/专业/记录/趋势 | 可联调 | `/api/scoreline/*` |
| 题目视频/批量预加载/搜索 | 可联调 | `/api/questions/*/videos``/api/videos/search` | | 题目视频/批量预加载/搜索 | 可联调 | `/api/questions/*/videos``/api/videos/search`;付费视频列表不返回可播放 URL |
| 视频会员播放次数 | 待补齐 | 缺播放次数扣减、播放日志、防盗链、水印 | | 视频会员播放次数 | 可联调 | `POST /api/videos/play` 支持 SVIP/视频次数校验、签名播放次数扣减、播放日志;深度防盗链和动态水印继续补 |
## 资料与对象存储 ## 资料与对象存储

View File

@@ -76,6 +76,7 @@ GET /api/scoreline/years
GET /api/questions/{questionId}/videos GET /api/questions/{questionId}/videos
POST /api/questions/videos/batch POST /api/questions/videos/batch
GET /api/videos/search GET /api/videos/search
POST /api/videos/play
GET /api/tenant-content/content-entries GET /api/tenant-content/content-entries
PUT /api/tenant-content/content-entries PUT /api/tenant-content/content-entries
GET /api/tenant-content/content-nodes GET /api/tenant-content/content-nodes

View File

@@ -25,7 +25,7 @@
| 全真模拟 | `components/AdminMockexam``MockExamConfigModal.tsx` | 部分覆盖 | 后端有 blueprint 基础;缺完整交卷报告、排名、复盘 | | 全真模拟 | `components/AdminMockexam``MockExamConfigModal.tsx` | 部分覆盖 | 后端有 blueprint 基础;缺完整交卷报告、排名、复盘 |
| 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 后续补错题复习计划 | | 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 后续补错题复习计划 |
| 收藏夹 | `WordFavoritesPage.tsx`、题目收藏 | 已覆盖 | 题目和单词收藏已有 | | 收藏夹 | `WordFavoritesPage.tsx`、题目收藏 | 已覆盖 | 题目和单词收藏已有 |
| 题目视频 | `VideoPlayer.tsx` | 部分覆盖 | 题目视频查询已有;缺播放签名、次数扣减、水印、防下载 | | 题目视频 | `VideoPlayer.tsx` | 部分覆盖 | 题目视频查询播放签名、SVIP/次数扣减、播放日志已有;缺深度防盗链、动态水印、播放统计报表 |
| 背单词 | `VocabularyPage.tsx``VocabularyQuiz.tsx` | 部分覆盖 | 单词列表/进度/收藏/统计已有;缺完整艾宾浩斯算法、每日计划、收藏练习细节 | | 背单词 | `VocabularyPage.tsx``VocabularyQuiz.tsx` | 部分覆盖 | 单词列表/进度/收藏/统计已有;缺完整艾宾浩斯算法、每日计划、收藏练习细节 |
| 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 | | 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 |
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势已有;缺批量导入和复杂筛选优化 | | 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势已有;缺批量导入和复杂筛选优化 |
@@ -97,7 +97,7 @@
### P1商用主链路 ### P1商用主链路
1. 微信/支付宝支付和 webhook 幂等。 1. 微信/支付宝支付和 webhook 幂等。
2. 对象存储 PDF 预览、视频播放签名、防盗链、水印。 2. 对象存储 PDF 预览、视频深度防盗链、动态水印。
3. Excel/CSV、分数线、视频批量导入。 3. Excel/CSV、分数线、视频批量导入。
4. 数据看板和销售/代理分佣结算。 4. 数据看板和销售/代理分佣结算。
5. 公共题库授权、租户采纳和版本同步。 5. 公共题库授权、租户采纳和版本同步。
@@ -108,4 +108,3 @@
2. 班级、教师、学生分组和学习督导。 2. 班级、教师、学生分组和学习督导。
3. AI 择校推荐和 PDF 报告。 3. AI 择校推荐和 PDF 报告。
4. 题库导出、试卷生成、每日一练运营工具。 4. 题库导出、试卷生成、每日一练运营工具。

View File

@@ -8,7 +8,7 @@
- Supabase/PostgreSQL 多租户 schema、RLS、索引、触发器。 - Supabase/PostgreSQL 多租户 schema、RLS、索引、触发器。
- Node.js API 分层:`core/features` - Node.js API 分层:`core/features`
- 学生端核心 API题库、练习、答题、错题、收藏、背单词、知识手册、分数线、视频、资料、订单、权益、个人中心。 - 学生端核心 API题库、练习、答题、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单、权益、个人中心。
- 租户后台 API品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、激活码、优惠券、成员权限、审计、内容管理。 - 租户后台 API品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、激活码、优惠券、成员权限、审计、内容管理。
- 平台后台 API租户、SaaS 套餐、订阅、账单、服务费收款、用量。 - 平台后台 API租户、SaaS 套餐、订阅、账单、服务费收款、用量。
- 销售/代理/CRM 增长链路邀请码、扫码事件、首绑保护、团队、统计、CRM 队列。 - 销售/代理/CRM 增长链路邀请码、扫码事件、首绑保护、团队、统计、CRM 队列。
@@ -33,7 +33,7 @@
2. 对象存储 2. 对象存储
- 已接阿里云 OSS、腾讯云 COS、Supabase Storage 的上传/下载签名 provider。 - 已接阿里云 OSS、腾讯云 COS、Supabase Storage 的上传/下载签名 provider。
- 继续补上传后对象存在性校验、PDF 预览地址、视频播放签名、防盗链、水印和 worker 校验。 - 继续补上传后对象存在性校验、PDF 预览地址、视频深度防盗链、动态水印和 worker 校验。
- `content_assets` 继续作为资源台账,不允许前端绕过台账直接访问私有资源。 - `content_assets` 继续作为资源台账,不允许前端绕过台账直接访问私有资源。
3. 真实导入 dry-run 3. 真实导入 dry-run
@@ -71,8 +71,8 @@
- 租户采纳、复制、授权、版本同步策略。 - 租户采纳、复制、授权、版本同步策略。
5. 视频会员控制 5. 视频会员控制
- 视频 SVIP 权限、播放次数扣减。 - 已完成视频 SVIP 权限、播放次数扣减、签名播放和播放日志
- 防盗链、水印、播放日志、播放统计。 - 继续补深度防盗链、动态水印、播放统计。
- 单题视频和通用知识视频混合推荐。 - 单题视频和通用知识视频混合推荐。
6. 学习统计 6. 学习统计

View File

@@ -4,7 +4,7 @@
## 目标 ## 目标
题库里的图片、PDF、视频、音频、资料包等媒体资源统一走 `content_assets` 台账和后端签名接口。前端不直接保存或读取云厂商密钥,也不直接拼接私有资源 URL。 题库里的图片、PDF、视频、音频、资料包等媒体资源统一走 `content_assets` 台账和后端签名接口。前端不直接保存或读取云厂商密钥,也不直接拼接私有资源 URL。题目视频播放还需要经过 `POST /api/videos/play` 校验 SVIP 或视频次数权益后下发短期签名 URL。
已接入的 provider 已接入的 provider
@@ -34,6 +34,12 @@ PUT /api/tenant-content/assets
GET /api/catalog/assets/download?assetId=... GET /api/catalog/assets/download?assetId=...
``` ```
学生端视频播放:
```text
POST /api/videos/play
```
后台管理员下载: 后台管理员下载:
```text ```text
@@ -46,7 +52,7 @@ POST /api/tenant-content/assets/sign-download
- `objectKey` 默认必须以当前 `tenantId/` 开头,防止跨租户覆盖或读取。 - `objectKey` 默认必须以当前 `tenantId/` 开头,防止跨租户覆盖或读取。
- 禁止 `..`、反斜杠、编码斜杠等危险 object key。 - 禁止 `..`、反斜杠、编码斜杠等危险 object key。
- 上传会校验 MIME 类型和文件大小。 - 上传会校验 MIME 类型和文件大小。
- 下载必须先经过 API 权限判断,再下发短期签名 URL。 - 下载和视频播放必须先经过 API 权限判断,再下发短期签名 URL。
- 云厂商 AccessKey、SecretKey、Service Role Key 只存在服务端环境变量,不返回前端。 - 云厂商 AccessKey、SecretKey、Service Role Key 只存在服务端环境变量,不返回前端。
- `content_assets` 是资源唯一台账,前端不得绕过台账直接访问私有 bucket。 - `content_assets` 是资源唯一台账,前端不得绕过台账直接访问私有 bucket。
@@ -98,7 +104,7 @@ SUPABASE_STORAGE_SERVICE_KEY=
- bucket 默认私有,公开资源也建议先经过 CDN/防盗链策略,不让前端直接持有写权限。 - bucket 默认私有,公开资源也建议先经过 CDN/防盗链策略,不让前端直接持有写权限。
- 图片、PDF、视频分别设置合理的 CORS只允许前端域名和小程序业务域名访问。 - 图片、PDF、视频分别设置合理的 CORS只允许前端域名和小程序业务域名访问。
- 开启对象版本控制、生命周期、跨区域复制或定时备份,满足后续容灾要求。 - 开启对象版本控制、生命周期、跨区域复制或定时备份,满足后续容灾要求。
- 视频资源建议后续接入转码、水印、防盗链、播放日志和播放次数扣减 - 视频资源已接入 SVIP/播放次数校验、短期签名和播放日志;生产阶段继续补转码、动态水印、CDN 防盗链和播放统计
- 大文件上传后应由 worker 校验对象是否真实存在、大小/hash 是否匹配,再把资源状态从 `draft` 发布为 `active` - 大文件上传后应由 worker 校验对象是否真实存在、大小/hash 是否匹配,再把资源状态从 `draft` 发布为 `active`
## 官方依据 ## 官方依据

View File

@@ -139,7 +139,7 @@ tenant:<tenantId>:theme
| 提交答案 | `POST /api/learning/answers` | | 提交答案 | `POST /api/learning/answers` |
| 错题本 | `GET /api/learning/wrong-questions``POST /api/learning/wrong-questions/resolve` | | 错题本 | `GET /api/learning/wrong-questions``POST /api/learning/wrong-questions/resolve` |
| 收藏夹 | `GET/POST /api/learning/favorites/questions` | | 收藏夹 | `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/catalog/vocabulary-units``/api/catalog/vocabulary-words` |
| 单词进度 | `/api/learning/vocabulary/progress``/api/learning/vocabulary/stats` | | 单词进度 | `/api/learning/vocabulary/progress``/api/learning/vocabulary/stats` |
| 单词收藏 | `/api/learning/vocabulary/favorites` | | 单词收藏 | `/api/learning/vocabulary/favorites` |
@@ -152,6 +152,60 @@ tenant:<tenantId>:theme
| 个人中心 | `GET/PATCH /api/profile/me` | | 个人中心 | `GET/PATCH /api/profile/me` |
| 销售分享 | `/api/referral/resolve``track-event``bind` | | 销售分享 | `/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 写入本地持久缓存。
## 题库新模型接入方式 ## 题库新模型接入方式
旧项目常按“地区 -> 科目 -> 章节/试卷”固定层级处理。新项目不要写死层级,按下面模型渲染: 旧项目常按“地区 -> 科目 -> 章节/试卷”固定层级处理。新项目不要写死层级,按下面模型渲染:

View File

@@ -30,6 +30,8 @@ const ids = {
question: '00000000-0000-0000-0000-000000000401', question: '00000000-0000-0000-0000-000000000401',
vocabularyUnit: '00000000-0000-0000-0000-000000000811', vocabularyUnit: '00000000-0000-0000-0000-000000000811',
vocabularyWord: '00000000-0000-0000-0000-000000000812', vocabularyWord: '00000000-0000-0000-0000-000000000812',
video: '00000000-0000-0000-0000-000000000821',
quotaVideo: '00000000-0000-0000-0000-000000000824',
scorelineSchool: '00000000-0000-0000-0000-000000000831', scorelineSchool: '00000000-0000-0000-0000-000000000831',
}; };
@@ -646,6 +648,11 @@ async function testScoreline() {
async function testVideos() { async function testVideos() {
const single = await request(`/api/questions/${ids.question}/videos`); const single = await request(`/api/questions/${ids.question}/videos`);
assert.ok(single.total >= 1, 'question should have 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', { const batch = await request('/api/questions/videos/batch', {
method: 'POST', method: 'POST',
@@ -655,6 +662,26 @@ async function testVideos() {
const search = await request('/api/videos/search', { query: { tags: '烟测' } }); 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(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() { async function testVocabulary() {
@@ -692,6 +719,14 @@ async function testCommerce() {
assert.ok(entitlements.summary && typeof entitlements.summary.isSvip === 'boolean', 'entitlements should include summary'); 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'); 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 fakeWechatPay = await startFakeWechatPayServer();
const wechatAccount = await request('/api/tenant-admin/payment-accounts', { const wechatAccount = await request('/api/tenant-admin/payment-accounts', {
userId: TENANT_ADMIN_USER_ID, userId: TENANT_ADMIN_USER_ID,

View File

@@ -33,6 +33,10 @@ const ids = {
vocabularyWord: '00000000-0000-0000-0000-000000000812', vocabularyWord: '00000000-0000-0000-0000-000000000812',
video: '00000000-0000-0000-0000-000000000821', video: '00000000-0000-0000-0000-000000000821',
questionVideo: '00000000-0000-0000-0000-000000000822', 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', scorelineSchool: '00000000-0000-0000-0000-000000000831',
scorelineMajor: '00000000-0000-0000-0000-000000000832', scorelineMajor: '00000000-0000-0000-0000-000000000832',
scorelineField: '00000000-0000-0000-0000-000000000833', scorelineField: '00000000-0000-0000-0000-000000000833',
@@ -508,26 +512,73 @@ async function main() {
[tenantId, ids.question, ids.questionVersion], [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( await client.query(
` `
insert into public.video_explanations ( insert into public.video_explanations (
id, tenant_id, legacy_id, title, description, video_url, thumbnail_url, id, tenant_id, legacy_id, title, description, video_url, thumbnail_url,
duration_seconds, knowledge_tags, is_general, subject_id, difficulty, 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 ( values (
$1, $2, 'smoke-video', '烟测题目视频讲解', '用于验证题目视频 API', $1, $2, 'smoke-video', '烟测题目视频讲解', '用于验证题目视频 API',
'https://example.test/videos/smoke.mp4', 'https://example.test/videos/smoke.jpg', '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) on conflict (id)
do update set title = excluded.title, do update set title = excluded.title,
video_url = excluded.video_url, video_url = excluded.video_url,
subject_id = excluded.subject_id, subject_id = excluded.subject_id,
asset_id = excluded.asset_id,
access_mode = excluded.access_mode,
is_active = true, is_active = true,
updated_at = now() 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( await client.query(
@@ -544,6 +595,36 @@ async function main() {
[ids.questionVideo, tenantId, ids.question, ids.video], [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( await client.query(
` `
insert into public.svip_plans ( insert into public.svip_plans (

View File

@@ -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 $$;