From f6710ace8b8e3a1ddb36afb46d0a44299a9d1640 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 20:38:12 +0800 Subject: [PATCH] feat: add video playback progress reporting --- .../tenant-content/media-analytics.ts | 16 +++ apps/api/src/features/video/index.ts | 3 +- apps/api/src/features/video/routes.ts | 112 ++++++++++++++++++ apps/taro/src/services/tenantAdmin.ts | 3 + apps/taro/src/services/video.ts | 35 ++++++ docs/refactor/backend-capability-status.md | 2 +- docs/refactor/next-development-todo.md | 2 +- docs/refactor/taro-frontend-integration.md | 17 +++ scripts/api-integration-test.js | 36 ++++++ 9 files changed, 223 insertions(+), 3 deletions(-) diff --git a/apps/api/src/features/tenant-content/media-analytics.ts b/apps/api/src/features/tenant-content/media-analytics.ts index a1c205b1..d410278b 100644 --- a/apps/api/src/features/tenant-content/media-analytics.ts +++ b/apps/api/src/features/tenant-content/media-analytics.ts @@ -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", diff --git a/apps/api/src/features/video/index.ts b/apps/api/src/features/video/index.ts index 1fe31016..d48f03c9 100644 --- a/apps/api/src/features/video/index.ts +++ b/apps/api/src/features/video/index.ts @@ -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], ]; diff --git a/apps/api/src/features/video/routes.ts b/apps/api/src/features/video/routes.ts index f112461d..943251f6 100644 --- a/apps/api/src/features/video/routes.ts +++ b/apps/api/src/features/video/routes.ts @@ -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; +} + 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 { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +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( @@ -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( + ` + 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 }; +} diff --git a/apps/taro/src/services/tenantAdmin.ts b/apps/taro/src/services/tenantAdmin.ts index a2f4ab96..383a9797 100644 --- a/apps/taro/src/services/tenantAdmin.ts +++ b/apps/taro/src/services/tenantAdmin.ts @@ -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 | null; signedUrlExpiresAt?: string | null; ipAddress?: string | null; userAgent?: string | null; diff --git a/apps/taro/src/services/video.ts b/apps/taro/src/services/video.ts index db3b02ec..0ff56e80 100644 --- a/apps/taro/src/services/video.ts +++ b/apps/taro/src/services/video.ts @@ -26,6 +26,28 @@ export interface VideoPlayback { access?: Record; } +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('/api/videos/progress', { + method: 'POST', + body: input, + }); +} diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 634c1977..fa04d034 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -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`;支持视频元数据、资源引用、播放模式、题目绑定和题目视频标记 | ## 资料与对象存储 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index ec818956..8c280daa 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -103,7 +103,7 @@ 6. 视频会员控制 - 已完成视频 SVIP 权限、播放次数扣减、签名播放和播放日志。 - - 已补播放签名动态水印上下文、traceId 事件和租户后台播放事件查询;继续补深度防盗链、转码级水印、播放完成率和观看时长统计。 + - 已补播放签名动态水印上下文、traceId 事件、播放进度上报和租户后台播放事件查询;继续补深度防盗链、转码级水印和更细观看行为分析。 - 单题视频和通用知识视频混合推荐。 7. 学习统计 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index a42e3028..af4374ed 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -879,6 +879,7 @@ GET /api/learning/vocabulary/review-plan?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=&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`,租户后台媒体运营报表会读取这些字段计算完成率和观看时长。 ## 资料上传、预览和下载契约 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 09f2d1cb..cab3eaf2 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -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 },