Files
gongxue-base/apps/api/src/features/video/routes.ts
2026-06-29 20:38:12 +08:00

569 lines
20 KiB
TypeScript

import crypto from 'node:crypto';
import type pg from 'pg';
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
import {
intParam,
optionalString,
optionalStringArray,
readJsonBody,
requiredString,
stringParam,
tenantIdFrom,
userIdFrom,
} from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
import { signStorageDownload, type StorageProviderName } from '../storage/service.js';
import {
assetAccessTtl,
assertCdnAccessAllowed,
buildAssetWatermarkContext,
signedAssetFingerprint,
} from '../storage/asset-access.js';
import { assertAssetSecurityScanPassed } from '../tenant-content/assets.js';
interface QuestionVideoRow {
questionId: string;
videoType: string;
order: number;
id: string;
legacyId: string | null;
title: string;
description: string | null;
videoUrl: string | null;
thumbnailUrl: string | null;
duration: number | null;
knowledgeTags: unknown[];
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;
assetType: string | null;
assetVisibility: string | null;
assetUploadStatus: string | null;
assetSecurityScanStatus: string | null;
assetAccessRules: Record<string, unknown> | null;
assetMetadata: Record<string, unknown> | null;
}
interface EntitlementAccessRow {
id: string;
}
interface QuotaAccountRow {
id: string;
total_quota: number;
used_quota: number;
}
interface VideoPlayEventRow {
id: string;
status: 'issued' | 'started' | 'completed' | 'expired' | 'revoked';
metadata: Record<string, unknown>;
}
function videoSelectSql() {
return `
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,
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.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');
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function boundedInt(value: unknown, fallback: number, min: number, max: number) {
const parsed = Number(value ?? fallback);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(Math.max(Math.trunc(parsed), min), max);
}
function progressEventType(value: unknown) {
const eventType = typeof value === 'string' ? value.trim() : '';
if (eventType === 'start' || eventType === 'heartbeat' || eventType === 'complete') return eventType;
throw new HttpError(400, 'eventType must be start, heartbeat, or complete', 'INVALID_VIDEO_PROGRESS_EVENT');
}
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');
}
if (video.objectKey && video.assetUploadStatus !== 'verified') {
throw new HttpError(409, 'Video asset upload has not been verified', 'VIDEO_ASSET_UPLOAD_NOT_VERIFIED');
}
assertAssetSecurityScanPassed({
provider: video.storageProvider as StorageProviderName,
objectKey: video.objectKey,
securityScanStatus: video.assetSecurityScanStatus,
});
assertCdnAccessAllowed({
assetId: video.assetId,
visibility: video.assetVisibility || 'svip',
assetType: video.assetType || 'video',
storageProvider: video.storageProvider as StorageProviderName,
objectKey: video.objectKey,
cdnUrl: video.cdnUrl,
metadata: video.assetMetadata,
accessRules: video.assetAccessRules,
});
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: assetAccessTtl({
actorRole: 'student',
accessType: 'download',
visibility: video.assetVisibility || 'svip',
assetType: video.assetType || 'video',
disposition: 'attachment',
requestedExpiresInSec: 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');
if (!questionId) {
throw new HttpError(400, 'questionId is required', 'QUESTION_ID_REQUIRED');
}
const videos = await query<QuestionVideoRow>(
`
${videoSelectSql()}
where qv.tenant_id = $1 and qv.question_id = $2 and v.is_active = true
order by qv.sort_order asc, v.sort_order asc, v.created_at asc
`,
[tenantId, questionId],
);
return { videos, total: videos.length };
}
export async function questionVideosBatchRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = await tenantIdFrom(ctx);
const questionIds = optionalStringArray(body, 'questionIds').slice(0, 50);
if (!questionIds.length) {
throw new HttpError(400, 'questionIds is required', 'QUESTION_IDS_REQUIRED');
}
const rows = await query<QuestionVideoRow>(
`
${videoSelectSql()}
where qv.tenant_id = $1 and qv.question_id = any($2::uuid[]) and v.is_active = true
order by qv.question_id, qv.sort_order asc, v.sort_order asc
`,
[tenantId, questionIds],
);
const data: Record<string, { hasVideo: boolean; videos: QuestionVideoRow[] }> = {};
for (const row of rows) {
data[row.questionId] ||= { hasVideo: true, videos: [] };
data[row.questionId].videos.push(row);
}
return { data };
}
export async function videoSearchRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const subjectId = stringParam(ctx, 'subjectId');
const tags = stringParam(ctx, 'tags')
.split(',')
.map(tag => tag.trim())
.filter(Boolean);
const limit = intParam(ctx, 'limit', 50, 200);
const videos = await query(
`
select id, legacy_id as "legacyId", title, description,
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, 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
and is_general = true
and ($2::uuid is null or subject_id = $2::uuid)
and ($3::text[] = '{}'::text[] or knowledge_tags ?| $3::text[])
order by sort_order asc, created_at desc
limit $4
`,
[tenantId, subjectId || null, tags, limit],
);
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",
a.asset_type as "assetType", a.visibility as "assetVisibility",
a.upload_status as "assetUploadStatus",
a.security_scan_status as "assetSecurityScanStatus",
a.access_rules as "assetAccessRules", a.metadata as "assetMetadata"
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 watermark = buildAssetWatermarkContext({
tenantId,
assetId: video.assetId,
userId,
actorRole: 'student',
accessType: 'video_play',
visibility: video.assetVisibility || 'svip',
assetType: video.assetType || 'video',
fileName: video.fileName,
title: video.assetTitle || video.title,
expiresAt: signed.expiresAt,
metadata: video.assetMetadata,
});
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,
assetId: video.assetId,
freePreviewSeconds: video.freePreviewSeconds,
signature: signedAssetFingerprint(signed),
watermark,
}),
],
);
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,
watermark,
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,
};
}
export async function videoProgressRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
const userId = await userIdFrom(ctx);
const playToken = requiredString(body, 'playToken');
if (!playToken.startsWith('vp_') || playToken.length < 24 || playToken.length > 160) {
throw new HttpError(400, 'Invalid play token', 'INVALID_PLAY_TOKEN');
}
const eventType = progressEventType(body.eventType);
const progressSeconds = boundedInt(body.progressSeconds, 0, 0, 86_400);
const durationSeconds = boundedInt(body.durationSeconds, 0, 0, 86_400);
const watchedSecondsInput = boundedInt(body.watchedSeconds ?? progressSeconds, progressSeconds, 0, 86_400);
const completedByProgress = durationSeconds > 0 && progressSeconds >= Math.floor(durationSeconds * 0.9);
const nextStatus = eventType === 'complete' || completedByProgress ? 'completed' : 'started';
const tokenHash = hashPlayToken(playToken);
const item = await transaction(async client => {
const currentResult = await client.query<VideoPlayEventRow>(
`
select id, status, metadata
from public.video_play_events
where tenant_id = $1
and user_id = $2
and play_token_hash = $3
limit 1
for update
`,
[tenantId, userId, tokenHash],
);
const current = currentResult.rows[0];
if (!current) throw new HttpError(404, 'Video play event not found', 'VIDEO_PLAY_EVENT_NOT_FOUND');
if (current.status === 'revoked') {
throw new HttpError(409, 'Video play event has been revoked', 'VIDEO_PLAY_EVENT_REVOKED');
}
if (current.status === 'expired') {
throw new HttpError(409, 'Video play event has expired', 'VIDEO_PLAY_EVENT_EXPIRED');
}
const nowIso = new Date().toISOString();
const metadata = objectValue(current.metadata);
const previousPlayback = objectValue(metadata.playback);
const previousWatched = boundedInt(previousPlayback.watchedSeconds, 0, 0, 86_400);
const watchedSeconds = Math.max(previousWatched, watchedSecondsInput, progressSeconds);
const completedAt = nextStatus === 'completed'
? String(previousPlayback.completedAt || nowIso)
: (typeof previousPlayback.completedAt === 'string' ? previousPlayback.completedAt : null);
const startedAt = typeof previousPlayback.startedAt === 'string' ? previousPlayback.startedAt : nowIso;
const finalStatus = current.status === 'completed' ? 'completed' : nextStatus;
const completionRate = durationSeconds > 0 ? Number(Math.min(progressSeconds / durationSeconds, 1).toFixed(4)) : null;
const nextMetadata = {
...metadata,
playback: {
...previousPlayback,
startedAt,
completedAt,
lastEventType: eventType,
lastProgressSeconds: progressSeconds,
durationSeconds: durationSeconds || previousPlayback.durationSeconds || null,
watchedSeconds,
completionRate,
updatedAt: nowIso,
},
};
const updated = await client.query(
`
update public.video_play_events
set status = $4,
metadata = $5::jsonb,
updated_at = now()
where tenant_id = $1
and user_id = $2
and play_token_hash = $3
returning id, video_id as "videoId", question_id as "questionId",
status, access_mode as "accessMode",
consumed_quota as "consumedQuota",
metadata -> 'playback' as playback,
updated_at as "updatedAt"
`,
[tenantId, userId, tokenHash, finalStatus, JSON.stringify(nextMetadata)],
);
return updated.rows[0];
});
return { item };
}