forked from wangziqi/gongxue-base
569 lines
20 KiB
TypeScript
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 };
|
|
}
|