forked from wangziqi/gongxue-base
feat: add video playback progress reporting
This commit is contained in:
@@ -199,6 +199,14 @@ function actorLabelSql(alias: string) {
|
||||
return `coalesce(nullif(${alias}.name, ''), nullif(${alias}.username, ''), concat('user:', left(${alias}.id::text, 8)))`;
|
||||
}
|
||||
|
||||
function jsonbIntegerExpr(pathSql: string) {
|
||||
return `case when ${pathSql} ~ '^\\d+$' then (${pathSql})::integer else 0 end`;
|
||||
}
|
||||
|
||||
function jsonbRateExpr(pathSql: string) {
|
||||
return `case when ${pathSql} ~ '^(0(\\.\\d+)?|1(\\.0+)?)$' then (${pathSql})::numeric else null end`;
|
||||
}
|
||||
|
||||
export async function mediaAnalyticsSummaryRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentPermission(ctx, 'content:analytics:read');
|
||||
const range = parseRange(stringParam(ctx, 'timeRange'));
|
||||
@@ -250,6 +258,8 @@ export async function mediaAnalyticsSummaryRoute(ctx: RequestContext) {
|
||||
count(*) filter (where e.access_mode = 'svip')::integer as "svipPlays",
|
||||
count(*) filter (where e.access_mode = 'video_quota')::integer as "quotaPlays",
|
||||
coalesce(sum(e.consumed_quota), 0)::integer as "consumedQuota",
|
||||
coalesce(sum(${jsonbIntegerExpr("e.metadata #>> '{playback,watchedSeconds}'")}), 0)::integer as "watchedSeconds",
|
||||
round(avg(nullif(${jsonbRateExpr("e.metadata #>> '{playback,completionRate}'")}, 0)), 4) as "avgCompletionRate",
|
||||
count(distinct e.user_id)::integer as "uniqueUsers",
|
||||
count(*) filter (where e.metadata #>> '{watermark,traceId}' is not null)::integer as "watermarkEvents"
|
||||
from public.video_play_events e
|
||||
@@ -289,7 +299,10 @@ export async function mediaAnalyticsSummaryRoute(ctx: RequestContext) {
|
||||
count(*)::integer as "totalEvents",
|
||||
count(distinct e.user_id)::integer as "uniqueUsers",
|
||||
coalesce(sum(e.consumed_quota), 0)::integer as "consumedQuota",
|
||||
coalesce(sum(${jsonbIntegerExpr("e.metadata #>> '{playback,watchedSeconds}'")}), 0)::integer as "watchedSeconds",
|
||||
round(avg(nullif(${jsonbRateExpr("e.metadata #>> '{playback,completionRate}'")}, 0)), 4) as "avgCompletionRate",
|
||||
count(*) filter (where e.status = 'issued')::integer as "issuedEvents",
|
||||
count(*) filter (where e.status = 'completed')::integer as "completedEvents",
|
||||
max(e.created_at) as "lastPlayAt"
|
||||
from public.video_play_events e
|
||||
left join public.video_explanations v on v.tenant_id = e.tenant_id and v.id = e.video_id
|
||||
@@ -382,6 +395,8 @@ export async function mediaAnalyticsSummaryRoute(ctx: RequestContext) {
|
||||
svipPlays: intValue(videoTotals?.svipPlays),
|
||||
quotaPlays: intValue(videoTotals?.quotaPlays),
|
||||
consumedQuota: intValue(videoTotals?.consumedQuota),
|
||||
watchedSeconds: intValue(videoTotals?.watchedSeconds),
|
||||
avgCompletionRate: numberValue(videoTotals?.avgCompletionRate),
|
||||
uniqueUsers: intValue(videoTotals?.uniqueUsers),
|
||||
watermarkEvents: intValue(videoTotals?.watermarkEvents),
|
||||
},
|
||||
@@ -452,6 +467,7 @@ export async function mediaVideoPlayEventsRoute(ctx: RequestContext) {
|
||||
e.user_id as "userId", ${actorLabelSql('u')} as "userLabel",
|
||||
e.status, e.access_mode as "accessMode",
|
||||
e.consumed_quota as "consumedQuota",
|
||||
e.metadata -> 'playback' as playback,
|
||||
e.signed_url_expires_at as "signedUrlExpiresAt",
|
||||
e.ip_address as "ipAddress", e.user_agent as "userAgent",
|
||||
e.metadata ->> 'signatureMode' as "signatureMode",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import { questionVideosBatchRoute, questionVideosRoute, videoPlaybackRoute, videoSearchRoute } from './routes.js';
|
||||
import { questionVideosBatchRoute, questionVideosRoute, videoPlaybackRoute, videoProgressRoute, 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],
|
||||
['POST', '/api/videos/progress', videoProgressRoute],
|
||||
];
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
optionalString,
|
||||
optionalStringArray,
|
||||
readJsonBody,
|
||||
requiredString,
|
||||
stringParam,
|
||||
tenantIdFrom,
|
||||
userIdFrom,
|
||||
@@ -77,6 +78,12 @@ interface QuotaAccountRow {
|
||||
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",
|
||||
@@ -110,6 +117,22 @@ 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>(
|
||||
@@ -454,3 +477,92 @@ export async function videoPlaybackRoute(ctx: RequestContext) {
|
||||
...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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user