forked from wangziqi/gongxue-base
feat: protect video playback access
This commit is contained in:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user