forked from wangziqi/gongxue-base
feat: protect video playback access
This commit is contained in:
@@ -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],
|
||||
];
|
||||
|
||||
@@ -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<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) {
|
||||
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<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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ apps/api/src/
|
||||
learning/ 组卷 session、答题、错题、收藏、练习进度
|
||||
commerce/ 订单、支付确认、激活码、权益
|
||||
referral/ 销售/代理客资追踪、首绑保护、团队关系、CRM 队列
|
||||
storage/ 对象存储签名 provider
|
||||
video/ 题目视频列表、搜索、SVIP/次数校验和签名播放
|
||||
platform-admin/ 平台方 SaaS 租户、订阅、账单、使用量
|
||||
tenant-admin/ 租户品牌、域名、公开设置、登录/商户配置、成员权限、活动/兑换码运营
|
||||
tenant-content/ 租户后台内容维护:入口、分类树、集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、批量导入
|
||||
|
||||
@@ -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/视频次数校验、签名播放、次数扣减、播放日志;深度防盗链和动态水印继续补 |
|
||||
|
||||
## 资料与对象存储
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. 题库导出、试卷生成、每日一练运营工具。
|
||||
|
||||
|
||||
@@ -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. 学习统计
|
||||
|
||||
@@ -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`。
|
||||
|
||||
## 官方依据
|
||||
|
||||
@@ -139,7 +139,7 @@ tenant:<tenantId>: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:<tenantId>: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 写入本地持久缓存。
|
||||
|
||||
## 题库新模型接入方式
|
||||
|
||||
旧项目常按“地区 -> 科目 -> 章节/试卷”固定层级处理。新项目不要写死层级,按下面模型渲染:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 $$;
|
||||
Reference in New Issue
Block a user