feat: add video playback progress reporting

This commit is contained in:
Codex
2026-06-29 20:38:12 +08:00
parent 38db867d58
commit f6710ace8b
9 changed files with 223 additions and 3 deletions

View File

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

View File

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

View File

@@ -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 };
}

View File

@@ -295,6 +295,8 @@ export interface MediaAnalyticsSummary {
svipPlays?: number;
quotaPlays?: number;
consumedQuota?: number;
watchedSeconds?: number;
avgCompletionRate?: number;
uniqueUsers?: number;
watermarkEvents?: number;
};
@@ -338,6 +340,7 @@ export interface MediaVideoPlayEvent {
status?: string;
accessMode?: string;
consumedQuota?: number;
playback?: Record<string, unknown> | null;
signedUrlExpiresAt?: string | null;
ipAddress?: string | null;
userAgent?: string | null;

View File

@@ -26,6 +26,28 @@ export interface VideoPlayback {
access?: Record<string, unknown>;
}
export interface VideoProgressReport {
item?: {
id?: string;
videoId?: string;
questionId?: string | null;
status?: 'issued' | 'started' | 'completed' | 'expired' | 'revoked';
accessMode?: string;
consumedQuota?: number;
playback?: {
startedAt?: string;
completedAt?: string | null;
lastEventType?: 'start' | 'heartbeat' | 'complete';
lastProgressSeconds?: number;
durationSeconds?: number | null;
watchedSeconds?: number;
completionRate?: number | null;
updatedAt?: string;
};
updatedAt?: string;
};
}
export async function loadQuestionVideos(questionId: string) {
return apiRequest<{ videos?: QuestionVideoItem[]; total?: number }>('/api/questions/videos', {
query: { questionId },
@@ -38,3 +60,16 @@ export async function playVideo(input: { videoId: string; questionId?: string })
body: input,
});
}
export async function reportVideoProgress(input: {
playToken: string;
eventType: 'start' | 'heartbeat' | 'complete';
progressSeconds?: number;
watchedSeconds?: number;
durationSeconds?: number;
}) {
return apiRequest<VideoProgressReport>('/api/videos/progress', {
method: 'POST',
body: input,
});
}

View File

@@ -82,7 +82,7 @@
| 分数线字段/院校/专业/记录/趋势 | 可联调 | `/api/scoreline/*` |
| 分数线 JSON 导入 | 可联调 | `/api/tenant-content/imports/preview/scoreline``/api/tenant-content/imports/scoreline`;支持字段、院校、专业、记录、动态字段值、逐行 issue、幂等和审计 |
| 题目视频/批量预加载/搜索 | 可联调 | `/api/questions/*/videos``/api/videos/search`;付费视频列表不返回可播放 URL |
| 视频会员播放次数 | 可联调 | `POST /api/videos/play` 支持 SVIP/视频次数校验、签名播放、次数扣减、播放日志和动态水印上下文租户后台媒体报表已可按视频、用户、traceId 查询播放事件和汇总;深度防盗链转码级水印和更细播放完成率统计继续补 |
| 视频会员播放次数 | 可联调 | `POST /api/videos/play` 支持 SVIP/视频次数校验、签名播放、次数扣减、播放日志和动态水印上下文;`POST /api/videos/progress` 支持播放开始、心跳、完成上报;租户后台媒体报表已可按视频、用户、traceId 查询播放事件、观看秒数和完成率;深度防盗链转码级水印继续补 |
| 视频 JSON 导入和批量绑定 | 可联调 | `/api/tenant-content/imports/preview/videos``/api/tenant-content/imports/videos`;支持视频元数据、资源引用、播放模式、题目绑定和题目视频标记 |
## 资料与对象存储

View File

@@ -103,7 +103,7 @@
6. 视频会员控制
- 已完成视频 SVIP 权限、播放次数扣减、签名播放和播放日志。
- 已补播放签名动态水印上下文、traceId 事件和租户后台播放事件查询;继续补深度防盗链、转码级水印、播放完成率和观看时长统计
- 已补播放签名动态水印上下文、traceId 事件、播放进度上报和租户后台播放事件查询;继续补深度防盗链、转码级水印和更细观看行为分析
- 单题视频和通用知识视频混合推荐。
7. 学习统计

View File

@@ -879,6 +879,7 @@ GET /api/learning/vocabulary/review-plan?unitId=<unitId>&reviewLimit=30&newLimit
3. 后端校验当前 session 用户、租户、题目绑定关系、SVIP 权益或视频次数权益。
4. 后端返回短期签名 URL、播放 token、权益来源和过期时间。
5. 前端播放器只使用本次返回的 `playback.url`,不要缓存为长期资源地址。
6. 播放器开始、周期心跳和播放完成时调用 `POST /api/videos/progress` 上报进度。
请求示例:
@@ -921,6 +922,22 @@ GET /api/learning/vocabulary/review-plan?unitId=<unitId>&reviewLimit=30&newLimit
- `VIDEO_ASSET_REQUIRED`:展示“视频暂不可播放”,同时上报前端日志。
- 签名 URL 过期后必须重新调用 `/api/videos/play`,不要重试旧 URL。
- 小程序/H5 不保存对象存储真实 key不把播放 URL 写入本地持久缓存。
- `playToken` 只用于当前播放会话进度上报,不写入长期缓存,不暴露到页面 URL。
- H5 `video` 组件建议在 `play` 上报 `eventType=start`,每 15-30 秒或进度变化明显时上报 `heartbeat``ended` 或观看进度超过 90% 时上报 `complete`
进度上报示例:
```json
{
"playToken": "vp_...",
"eventType": "heartbeat",
"progressSeconds": 45,
"watchedSeconds": 48,
"durationSeconds": 90
}
```
后端会校验 `playToken` 必须属于当前登录用户和当前租户,其他用户不能拿 token 改播放状态。返回的 `item.playback` 会包含 `watchedSeconds``completionRate``startedAt``completedAt`,租户后台媒体运营报表会读取这些字段计算完成率和观看时长。
## 资料上传、预览和下载契约

View File

@@ -1694,6 +1694,35 @@ async function testVideos() {
quotaPlayback.watermark?.traceId,
'quota video play event should record watermark trace id',
);
const progressStarted = await request('/api/videos/progress', {
method: 'POST',
body: { playToken: quotaPlayback.playToken, eventType: 'start', progressSeconds: 3, durationSeconds: 90 },
});
assert.equal(progressStarted.item?.status, 'started', 'video progress start should mark event started');
assert.equal(progressStarted.item?.playback?.watchedSeconds, 3, 'video progress start should record watched seconds');
const progressHeartbeat = await request('/api/videos/progress', {
method: 'POST',
body: { playToken: quotaPlayback.playToken, eventType: 'heartbeat', progressSeconds: 45, watchedSeconds: 48, durationSeconds: 90 },
});
assert.equal(progressHeartbeat.item?.status, 'started', 'video heartbeat should keep event started before completion threshold');
assert.equal(progressHeartbeat.item?.playback?.watchedSeconds, 48, 'video heartbeat should keep max watched seconds');
const progressCompleted = await request('/api/videos/progress', {
method: 'POST',
body: { playToken: quotaPlayback.playToken, eventType: 'complete', progressSeconds: 90, durationSeconds: 90 },
});
assert.equal(progressCompleted.item?.status, 'completed', 'video completion should mark event completed');
assert.equal(progressCompleted.item?.playback?.completionRate, 1, 'video completion should record completion rate');
const secondUserProgressDenied = await request('/api/videos/progress', {
userId: SECOND_STUDENT_USER_ID,
method: 'POST',
body: { playToken: quotaPlayback.playToken, eventType: 'heartbeat', progressSeconds: 10 },
expectStatus: 404,
});
assert.equal(secondUserProgressDenied.code, 'VIDEO_PLAY_EVENT_NOT_FOUND', 'another user must not update a play token');
}
async function testVocabulary() {
@@ -3735,6 +3764,13 @@ async function testTenantContentAssetsAndImports() {
assert.equal(mediaVideoSummary.videoPlay?.svipPlays >= 1, true, 'media analytics summary should count SVIP video plays');
assert.ok(mediaVideoSummary.videoTop?.some(item => item.videoId === ids.video), 'media analytics summary should include top videos');
const mediaQuotaVideoSummary = await request('/api/tenant-content/media-analytics/summary', {
userId: TENANT_ADMIN_USER_ID,
query: { timeRange: '30d', videoId: ids.quotaVideo },
});
assert.equal(mediaQuotaVideoSummary.videoPlay?.completedEvents >= 1, true, 'media analytics summary should count completed video events');
assert.equal(mediaQuotaVideoSummary.videoPlay?.watchedSeconds >= 90, true, 'media analytics summary should count watched seconds');
const mediaVideoEventsByVideo = await request('/api/tenant-content/media-analytics/video-events', {
userId: TENANT_ADMIN_USER_ID,
query: { timeRange: '30d', videoId: ids.video, limit: 5 },