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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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`;支持视频元数据、资源引用、播放模式、题目绑定和题目视频标记 |
|
||||
|
||||
## 资料与对象存储
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
|
||||
6. 视频会员控制
|
||||
- 已完成视频 SVIP 权限、播放次数扣减、签名播放和播放日志。
|
||||
- 已补播放签名动态水印上下文、traceId 事件和租户后台播放事件查询;继续补深度防盗链、转码级水印、播放完成率和观看时长统计。
|
||||
- 已补播放签名动态水印上下文、traceId 事件、播放进度上报和租户后台播放事件查询;继续补深度防盗链、转码级水印和更细观看行为分析。
|
||||
- 单题视频和通用知识视频混合推荐。
|
||||
|
||||
7. 学习统计
|
||||
|
||||
@@ -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`,租户后台媒体运营报表会读取这些字段计算完成率和观看时长。
|
||||
|
||||
## 资料上传、预览和下载契约
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user