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

@@ -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,

View File

@@ -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 (