From 38db867d58e40da6257b67710711a677e036701a Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 20:30:00 +0800 Subject: [PATCH] feat: add media access analytics --- apps/api/src/features/tenant-content/auth.ts | 57 +++ apps/api/src/features/tenant-content/index.ts | 8 + .../tenant-content/media-analytics.ts | 473 ++++++++++++++++++ apps/taro/src/services/tenantAdmin.ts | 115 +++++ docs/refactor/backend-capability-status.md | 4 +- docs/refactor/backend-progress.md | 2 +- docs/refactor/frontend-handoff-index.md | 2 +- docs/refactor/next-development-todo.md | 4 +- docs/refactor/taro-frontend-integration.md | 20 + scripts/api-integration-test.js | 53 ++ ...6290025_media_access_analytics_indexes.sql | 16 + 11 files changed, 749 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/features/tenant-content/media-analytics.ts create mode 100644 supabase/migrations/202606290025_media_access_analytics_indexes.sql diff --git a/apps/api/src/features/tenant-content/auth.ts b/apps/api/src/features/tenant-content/auth.ts index 7981da6a..eb32b173 100644 --- a/apps/api/src/features/tenant-content/auth.ts +++ b/apps/api/src/features/tenant-content/auth.ts @@ -16,6 +16,10 @@ function hasContentPermission(permissions: Record) { return permissions['*'] === true || permissions['content:*'] === true; } +function hasPermission(permissions: Record, permissionKey: string) { + return permissions['*'] === true || permissions['content:*'] === true || permissions[permissionKey] === true; +} + export async function requireTenantContentEditor(ctx: RequestContext): Promise { const tenantId = await tenantIdFrom(ctx); const userId = await userIdFrom(ctx); @@ -60,3 +64,56 @@ export async function requireTenantContentEditor(ctx: RequestContext): Promise { + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); + + const membership = await queryOne<{ role: string; permissions: Record; templatePermissions: Record }>( + ` + select tm.role, tm.permissions, + coalesce(rt.permissions, '{}'::jsonb) as "templatePermissions" + from public.tenant_memberships tm + left join public.tenant_role_templates rt + on rt.id = tm.role_template_id + and rt.tenant_id = tm.tenant_id + and rt.status = 'active' + where tm.tenant_id = $1 + and tm.user_id = $2 + and tm.status = 'active' + and ( + tm.role = any($3::text[]) + or coalesce(rt.permissions, '{}'::jsonb) ? $4 + or coalesce(rt.permissions, '{}'::jsonb) ? 'content:*' + or coalesce(rt.permissions, '{}'::jsonb) ? '*' + or tm.permissions ? $4 + or tm.permissions ? 'content:*' + or tm.permissions ? '*' + ) + order by case tm.role + when 'tenant_owner' then 1 + when 'tenant_admin' then 2 + when 'tenant_operator' then 3 + when 'teacher' then 4 + else 9 + end + limit 1 + `, + [tenantId, userId, defaultRoles, permissionKey], + ); + + if (!membership) { + throw new HttpError(403, 'Tenant content permission is required', 'TENANT_CONTENT_PERMISSION_REQUIRED'); + } + const permissions = membership.permissions || {}; + const templatePermissions = membership.templatePermissions || {}; + if (!defaultRoles.includes(membership.role) && !hasPermission(permissions, permissionKey) && !hasPermission(templatePermissions, permissionKey)) { + throw new HttpError(403, 'Tenant content permission is required', 'TENANT_CONTENT_PERMISSION_REQUIRED'); + } + + return { tenantId, userId, role: membership.role, permissions, templatePermissions }; +} diff --git a/apps/api/src/features/tenant-content/index.ts b/apps/api/src/features/tenant-content/index.ts index b80cf15a..ad03acdd 100644 --- a/apps/api/src/features/tenant-content/index.ts +++ b/apps/api/src/features/tenant-content/index.ts @@ -36,6 +36,11 @@ import { createQuestionExportRoute, questionExportJobsRoute, } from './exports.js'; +import { + mediaAnalyticsSummaryRoute, + mediaAssetAccessEventsRoute, + mediaVideoPlayEventsRoute, +} from './media-analytics.js'; import { contentEntriesAdminRoute, contentNodesAdminRoute, @@ -106,6 +111,9 @@ export const tenantContentRoutes: RouteDefinition[] = [ ['GET', '/api/tenant-content/assets', assetsAdminRoute], ['GET', '/api/tenant-content/assets/access-events', assetAccessEventsAdminRoute], ['GET', '/api/tenant-content/assets/security-scan-events', assetSecurityScanEventsAdminRoute], + ['GET', '/api/tenant-content/media-analytics/summary', mediaAnalyticsSummaryRoute], + ['GET', '/api/tenant-content/media-analytics/asset-events', mediaAssetAccessEventsRoute], + ['GET', '/api/tenant-content/media-analytics/video-events', mediaVideoPlayEventsRoute], ['PUT', '/api/tenant-content/assets', upsertAssetRoute], ['POST', '/api/tenant-content/assets/sign-upload', signAssetUploadRoute], ['POST', '/api/tenant-content/assets/confirm-upload', confirmAssetUploadRoute], diff --git a/apps/api/src/features/tenant-content/media-analytics.ts b/apps/api/src/features/tenant-content/media-analytics.ts new file mode 100644 index 00000000..a1c205b1 --- /dev/null +++ b/apps/api/src/features/tenant-content/media-analytics.ts @@ -0,0 +1,473 @@ +import { HttpError, type RequestContext } from '../../core/http.js'; +import { intParam, stringParam } from '../../core/request.js'; +import { query, queryOne } from '../../core/db.js'; +import { requireTenantContentPermission } from './auth.js'; + +const ANALYTICS_RANGES: Record = { + '7d': 7, + '30d': 30, + '90d': 90, +}; + +const ASSET_ACCESS_TYPES = new Set(['download', 'preview', 'admin_download', 'admin_preview', 'upload_sign', 'upload_confirm']); +const ASSET_RESULTS = new Set(['granted', 'denied']); +const VIDEO_STATUSES = new Set(['issued', 'started', 'completed', 'expired', 'revoked']); +const VIDEO_ACCESS_MODES = new Set(['free', 'svip', 'video_quota']); +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const TRACE_RE = /^[a-z0-9_-]{6,64}$/i; + +const shanghaiDateFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', +}); + +type NumericRow = Record; + +function numberValue(value: unknown) { + const parsed = Number(value ?? 0); + return Number.isFinite(parsed) ? parsed : 0; +} + +function intValue(value: unknown) { + return Math.trunc(numberValue(value)); +} + +function optionalUuid(value: string, label: string) { + if (!value) return null; + if (!UUID_RE.test(value)) throw new HttpError(400, `${label} must be a valid UUID`, 'INVALID_UUID'); + return value; +} + +function optionalTraceId(value: string) { + if (!value) return null; + if (!TRACE_RE.test(value)) throw new HttpError(400, 'traceId must be a safe trace token', 'INVALID_TRACE_ID'); + return value.toUpperCase(); +} + +function choiceParam(value: string, allowed: Set, label: string) { + if (!value) return null; + if (!allowed.has(value)) throw new HttpError(400, `Invalid ${label}: ${value}`, 'INVALID_FIELD_VALUE'); + return value; +} + +function shanghaiDateKey(date: Date) { + const parts = Object.fromEntries( + shanghaiDateFormatter.formatToParts(date).map(part => [part.type, part.value]), + ); + return `${parts.year}-${parts.month}-${parts.day}`; +} + +function addDaysKey(dateKey: string, days: number) { + const date = new Date(`${dateKey}T00:00:00+08:00`); + date.setUTCDate(date.getUTCDate() + days); + return shanghaiDateKey(date); +} + +function shanghaiDayStartIso(dateKey: string) { + return new Date(`${dateKey}T00:00:00+08:00`).toISOString(); +} + +function parseRange(value: string) { + const timeRange = value || '30d'; + const days = ANALYTICS_RANGES[timeRange]; + if (!days) throw new HttpError(400, 'Unsupported analytics time range', 'INVALID_ANALYTICS_RANGE'); + + const endDate = shanghaiDateKey(new Date()); + const startDate = addDaysKey(endDate, -(days - 1)); + const endExclusiveDate = addDaysKey(endDate, 1); + return { + timeRange, + days, + startDate, + endDate, + startAt: shanghaiDayStartIso(startDate), + endAt: shanghaiDayStartIso(endExclusiveDate), + }; +} + +function emptyDailySeries(startDate: string, days: number) { + return Array.from({ length: days }, (_, index) => ({ + date: addDaysKey(startDate, index), + assetEvents: 0, + assetDenied: 0, + videoPlays: 0, + videoQuotaConsumed: 0, + })); +} + +function applyDailyRows( + series: ReturnType, + rows: NumericRow[], + mapper: (target: ReturnType[number], row: NumericRow) => void, +) { + const map = new Map(series.map(item => [item.date, item])); + for (const row of rows) { + const target = map.get(String(row.date || '')); + if (target) mapper(target, row); + } +} + +function buildAssetFilters(input: { + tenantId: string; + startAt?: string; + endAt?: string; + assetId?: string | null; + userId?: string | null; + accessType?: string | null; + result?: string | null; + traceId?: string | null; +}) { + const params: unknown[] = [input.tenantId]; + const filters = ['e.tenant_id = $1']; + if (input.startAt) { + params.push(input.startAt); + filters.push(`e.created_at >= $${params.length}::timestamptz`); + } + if (input.endAt) { + params.push(input.endAt); + filters.push(`e.created_at < $${params.length}::timestamptz`); + } + if (input.assetId) { + params.push(input.assetId); + filters.push(`e.asset_id = $${params.length}::uuid`); + } + if (input.userId) { + params.push(input.userId); + filters.push(`e.user_id = $${params.length}::uuid`); + } + if (input.accessType) { + params.push(input.accessType); + filters.push(`e.access_type = $${params.length}`); + } + if (input.result) { + params.push(input.result); + filters.push(`e.result = $${params.length}`); + } + if (input.traceId) { + params.push(input.traceId); + filters.push(`upper(e.metadata #>> '{watermark,traceId}') = $${params.length}`); + } + return { params, whereSql: filters.join(' and ') }; +} + +function buildVideoFilters(input: { + tenantId: string; + startAt?: string; + endAt?: string; + videoId?: string | null; + userId?: string | null; + accessMode?: string | null; + status?: string | null; + traceId?: string | null; +}) { + const params: unknown[] = [input.tenantId]; + const filters = ['e.tenant_id = $1']; + if (input.startAt) { + params.push(input.startAt); + filters.push(`e.created_at >= $${params.length}::timestamptz`); + } + if (input.endAt) { + params.push(input.endAt); + filters.push(`e.created_at < $${params.length}::timestamptz`); + } + if (input.videoId) { + params.push(input.videoId); + filters.push(`e.video_id = $${params.length}::uuid`); + } + if (input.userId) { + params.push(input.userId); + filters.push(`e.user_id = $${params.length}::uuid`); + } + if (input.accessMode) { + params.push(input.accessMode); + filters.push(`e.access_mode = $${params.length}`); + } + if (input.status) { + params.push(input.status); + filters.push(`e.status = $${params.length}`); + } + if (input.traceId) { + params.push(input.traceId); + filters.push(`upper(e.metadata #>> '{watermark,traceId}') = $${params.length}`); + } + return { params, whereSql: filters.join(' and ') }; +} + +function actorLabelSql(alias: string) { + return `coalesce(nullif(${alias}.name, ''), nullif(${alias}.username, ''), concat('user:', left(${alias}.id::text, 8)))`; +} + +export async function mediaAnalyticsSummaryRoute(ctx: RequestContext) { + const auth = await requireTenantContentPermission(ctx, 'content:analytics:read'); + const range = parseRange(stringParam(ctx, 'timeRange')); + const limit = intParam(ctx, 'limit', 10, 50); + const assetId = optionalUuid(stringParam(ctx, 'assetId'), 'assetId'); + const videoId = optionalUuid(stringParam(ctx, 'videoId'), 'videoId'); + const userId = optionalUuid(stringParam(ctx, 'userId'), 'userId'); + const traceId = optionalTraceId(stringParam(ctx, 'traceId')); + + const assetFilter = buildAssetFilters({ tenantId: auth.tenantId, startAt: range.startAt, endAt: range.endAt, assetId, userId, traceId }); + const videoFilter = buildVideoFilters({ tenantId: auth.tenantId, startAt: range.startAt, endAt: range.endAt, videoId, userId, traceId }); + const daily = emptyDailySeries(range.startDate, range.days); + + const [ + assetTotals, + videoTotals, + assetTop, + videoTop, + assetDailyRows, + videoDailyRows, + traceAssetCount, + traceVideoCount, + ] = await Promise.all([ + queryOne( + ` + select + count(*)::integer as "totalEvents", + count(*) filter (where e.result = 'granted')::integer as "grantedEvents", + count(*) filter (where e.result = 'denied')::integer as "deniedEvents", + count(*) filter (where e.access_type in ('download', 'admin_download') and e.result = 'granted')::integer as downloads, + count(*) filter (where e.access_type in ('preview', 'admin_preview') and e.result = 'granted')::integer as previews, + count(*) filter (where e.access_type = 'upload_sign')::integer as "uploadSigns", + count(*) filter (where e.access_type = 'upload_confirm')::integer as "uploadConfirms", + count(distinct e.user_id) filter (where e.user_id is not null)::integer as "uniqueUsers", + count(*) filter (where e.metadata #>> '{watermark,traceId}' is not null)::integer as "watermarkEvents" + from public.content_asset_access_events e + where ${assetFilter.whereSql} + `, + assetFilter.params, + ), + queryOne( + ` + select + count(*)::integer as "totalEvents", + count(*) filter (where e.status = 'issued')::integer as "issuedEvents", + count(*) filter (where e.status = 'started')::integer as "startedEvents", + count(*) filter (where e.status = 'completed')::integer as "completedEvents", + count(*) filter (where e.access_mode = 'free')::integer as "freePlays", + 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", + 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 + where ${videoFilter.whereSql} + `, + videoFilter.params, + ), + query( + ` + select e.asset_id as "assetId", + coalesce(a.title, '已删除资源') as title, + coalesce(a.asset_type, e.asset_type) as "assetType", + coalesce(a.visibility, e.visibility) as visibility, + count(*)::integer as "totalEvents", + count(*) filter (where e.result = 'granted')::integer as "grantedEvents", + count(*) filter (where e.result = 'denied')::integer as "deniedEvents", + count(*) filter (where e.access_type in ('download', 'admin_download') and e.result = 'granted')::integer as downloads, + count(*) filter (where e.access_type in ('preview', 'admin_preview') and e.result = 'granted')::integer as previews, + count(distinct e.user_id) filter (where e.user_id is not null)::integer as "uniqueUsers", + max(e.created_at) as "lastAccessAt" + from public.content_asset_access_events e + left join public.content_assets a on a.tenant_id = e.tenant_id and a.id = e.asset_id + where ${assetFilter.whereSql} + and e.asset_id is not null + group by e.asset_id, a.title, a.asset_type, a.visibility, e.asset_type, e.visibility + order by "totalEvents" desc, "lastAccessAt" desc + limit $${assetFilter.params.length + 1} + `, + [...assetFilter.params, limit], + ), + query( + ` + select e.video_id as "videoId", + coalesce(v.title, '已删除视频') as title, + coalesce(v.access_mode, e.access_mode) as "accessMode", + v.asset_id as "assetId", + count(*)::integer as "totalEvents", + count(distinct e.user_id)::integer as "uniqueUsers", + coalesce(sum(e.consumed_quota), 0)::integer as "consumedQuota", + count(*) filter (where e.status = 'issued')::integer as "issuedEvents", + 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 + where ${videoFilter.whereSql} + group by e.video_id, v.title, v.access_mode, e.access_mode, v.asset_id + order by "totalEvents" desc, "lastPlayAt" desc + limit $${videoFilter.params.length + 1} + `, + [...videoFilter.params, limit], + ), + query( + ` + select to_char(e.created_at at time zone 'Asia/Shanghai', 'YYYY-MM-DD') as date, + count(*)::integer as events, + count(*) filter (where e.result = 'denied')::integer as denied + from public.content_asset_access_events e + where ${assetFilter.whereSql} + group by date + order by date asc + `, + assetFilter.params, + ), + query( + ` + select to_char(e.created_at at time zone 'Asia/Shanghai', 'YYYY-MM-DD') as date, + count(*)::integer as events, + coalesce(sum(e.consumed_quota), 0)::integer as quota + from public.video_play_events e + where ${videoFilter.whereSql} + group by date + order by date asc + `, + videoFilter.params, + ), + traceId ? queryOne( + ` + select count(*)::integer as count + from public.content_asset_access_events e + where e.tenant_id = $1 and upper(e.metadata #>> '{watermark,traceId}') = $2 + `, + [auth.tenantId, traceId], + ) : Promise.resolve(null), + traceId ? queryOne( + ` + select count(*)::integer as count + from public.video_play_events e + where e.tenant_id = $1 and upper(e.metadata #>> '{watermark,traceId}') = $2 + `, + [auth.tenantId, traceId], + ) : Promise.resolve(null), + ]); + + applyDailyRows(daily, assetDailyRows, (target, row) => { + target.assetEvents = intValue(row.events); + target.assetDenied = intValue(row.denied); + }); + applyDailyRows(daily, videoDailyRows, (target, row) => { + target.videoPlays = intValue(row.events); + target.videoQuotaConsumed = intValue(row.quota); + }); + + return { + scope: { + timeRange: range.timeRange, + startAt: range.startAt, + endAt: range.endAt, + assetId, + videoId, + userId, + traceId, + limit, + }, + assetAccess: { + totalEvents: intValue(assetTotals?.totalEvents), + grantedEvents: intValue(assetTotals?.grantedEvents), + deniedEvents: intValue(assetTotals?.deniedEvents), + downloads: intValue(assetTotals?.downloads), + previews: intValue(assetTotals?.previews), + uploadSigns: intValue(assetTotals?.uploadSigns), + uploadConfirms: intValue(assetTotals?.uploadConfirms), + uniqueUsers: intValue(assetTotals?.uniqueUsers), + watermarkEvents: intValue(assetTotals?.watermarkEvents), + }, + videoPlay: { + totalEvents: intValue(videoTotals?.totalEvents), + issuedEvents: intValue(videoTotals?.issuedEvents), + startedEvents: intValue(videoTotals?.startedEvents), + completedEvents: intValue(videoTotals?.completedEvents), + freePlays: intValue(videoTotals?.freePlays), + svipPlays: intValue(videoTotals?.svipPlays), + quotaPlays: intValue(videoTotals?.quotaPlays), + consumedQuota: intValue(videoTotals?.consumedQuota), + uniqueUsers: intValue(videoTotals?.uniqueUsers), + watermarkEvents: intValue(videoTotals?.watermarkEvents), + }, + assetTop, + videoTop, + daily, + traceMatches: traceId + ? { assets: intValue(traceAssetCount?.count), videos: intValue(traceVideoCount?.count) } + : null, + }; +} + +export async function mediaAssetAccessEventsRoute(ctx: RequestContext) { + const auth = await requireTenantContentPermission(ctx, 'content:analytics:read'); + const range = parseRange(stringParam(ctx, 'timeRange')); + const limit = intParam(ctx, 'limit', 100, 500); + const assetId = optionalUuid(stringParam(ctx, 'assetId'), 'assetId'); + const userId = optionalUuid(stringParam(ctx, 'userId'), 'userId'); + const accessType = choiceParam(stringParam(ctx, 'accessType'), ASSET_ACCESS_TYPES, 'accessType'); + const result = choiceParam(stringParam(ctx, 'result'), ASSET_RESULTS, 'result'); + const traceId = optionalTraceId(stringParam(ctx, 'traceId')); + const filters = buildAssetFilters({ tenantId: auth.tenantId, startAt: range.startAt, endAt: range.endAt, assetId, userId, accessType, result, traceId }); + + const items = await query( + ` + select e.id, e.asset_id as "assetId", + coalesce(a.title, e.metadata ->> 'title', '已删除资源') as "assetTitle", + coalesce(a.asset_type, e.asset_type) as "assetType", + e.user_id as "userId", + case when e.user_id is null then null else ${actorLabelSql('u')} end as "userLabel", + e.actor_role as "actorRole", e.access_type as "accessType", + e.visibility, e.storage_provider as "storageProvider", + e.disposition, e.expires_in_sec as "expiresInSec", + e.signature_mode as "signatureMode", e.result, + e.deny_code as "denyCode", e.ip_address as "ipAddress", + e.user_agent as "userAgent", + e.metadata #>> '{watermark,traceId}' as "watermarkTraceId", + e.metadata -> 'watermark' as watermark, + e.created_at as "createdAt" + from public.content_asset_access_events e + left join public.content_assets a on a.tenant_id = e.tenant_id and a.id = e.asset_id + left join public.platform_users u on u.id = e.user_id + where ${filters.whereSql} + order by e.created_at desc + limit $${filters.params.length + 1} + `, + [...filters.params, limit], + ); + + return { scope: { timeRange: range.timeRange, startAt: range.startAt, endAt: range.endAt, limit, assetId, userId, accessType, result, traceId }, items }; +} + +export async function mediaVideoPlayEventsRoute(ctx: RequestContext) { + const auth = await requireTenantContentPermission(ctx, 'content:analytics:read'); + const range = parseRange(stringParam(ctx, 'timeRange')); + const limit = intParam(ctx, 'limit', 100, 500); + const videoId = optionalUuid(stringParam(ctx, 'videoId'), 'videoId'); + const userId = optionalUuid(stringParam(ctx, 'userId'), 'userId'); + const accessMode = choiceParam(stringParam(ctx, 'accessMode'), VIDEO_ACCESS_MODES, 'accessMode'); + const status = choiceParam(stringParam(ctx, 'status'), VIDEO_STATUSES, 'status'); + const traceId = optionalTraceId(stringParam(ctx, 'traceId')); + const filters = buildVideoFilters({ tenantId: auth.tenantId, startAt: range.startAt, endAt: range.endAt, videoId, userId, accessMode, status, traceId }); + + const items = await query( + ` + select e.id, e.video_id as "videoId", coalesce(v.title, '已删除视频') as "videoTitle", + v.asset_id as "assetId", e.question_id as "questionId", + e.user_id as "userId", ${actorLabelSql('u')} as "userLabel", + e.status, e.access_mode as "accessMode", + e.consumed_quota as "consumedQuota", + e.signed_url_expires_at as "signedUrlExpiresAt", + e.ip_address as "ipAddress", e.user_agent as "userAgent", + e.metadata ->> 'signatureMode' as "signatureMode", + e.metadata ->> 'provider' as provider, + e.metadata #>> '{watermark,traceId}' as "watermarkTraceId", + e.metadata -> 'watermark' as watermark, + e.created_at as "createdAt", e.updated_at as "updatedAt" + 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 + join public.platform_users u on u.id = e.user_id + where ${filters.whereSql} + order by e.created_at desc + limit $${filters.params.length + 1} + `, + [...filters.params, limit], + ); + + return { scope: { timeRange: range.timeRange, startAt: range.startAt, endAt: range.endAt, limit, videoId, userId, accessMode, status, traceId }, items }; +} diff --git a/apps/taro/src/services/tenantAdmin.ts b/apps/taro/src/services/tenantAdmin.ts index 29232d06..a2f4ab96 100644 --- a/apps/taro/src/services/tenantAdmin.ts +++ b/apps/taro/src/services/tenantAdmin.ts @@ -273,6 +273,82 @@ export interface ImportTemplateItem { fields?: Record[]; } +export interface MediaAnalyticsSummary { + scope?: Record; + assetAccess?: { + totalEvents?: number; + grantedEvents?: number; + deniedEvents?: number; + downloads?: number; + previews?: number; + uploadSigns?: number; + uploadConfirms?: number; + uniqueUsers?: number; + watermarkEvents?: number; + }; + videoPlay?: { + totalEvents?: number; + issuedEvents?: number; + startedEvents?: number; + completedEvents?: number; + freePlays?: number; + svipPlays?: number; + quotaPlays?: number; + consumedQuota?: number; + uniqueUsers?: number; + watermarkEvents?: number; + }; + assetTop?: Record[]; + videoTop?: Record[]; + daily?: Record[]; + traceMatches?: { assets?: number; videos?: number } | null; +} + +export interface MediaAssetAccessEvent { + id: string; + assetId?: string | null; + assetTitle?: string | null; + assetType?: string | null; + userId?: string | null; + userLabel?: string | null; + actorRole?: string; + accessType?: string; + visibility?: string | null; + storageProvider?: string | null; + disposition?: string | null; + expiresInSec?: number | null; + signatureMode?: string | null; + result?: string; + denyCode?: string | null; + ipAddress?: string | null; + userAgent?: string | null; + watermarkTraceId?: string | null; + watermark?: Record | null; + createdAt?: string; +} + +export interface MediaVideoPlayEvent { + id: string; + videoId?: string; + videoTitle?: string | null; + assetId?: string | null; + questionId?: string | null; + userId?: string; + userLabel?: string | null; + status?: string; + accessMode?: string; + consumedQuota?: number; + signedUrlExpiresAt?: string | null; + ipAddress?: string | null; + userAgent?: string | null; + signatureMode?: string | null; + provider?: string | null; + watermarkTraceId?: string | null; + watermark?: Record | null; + createdAt?: string; + updatedAt?: string; +} + export interface ImportPreviewResult { job?: { id: string; @@ -811,6 +887,45 @@ export async function loadImportTemplate(importType: string, format: 'json' | 'c }); } +export async function loadMediaAnalyticsSummary(query: { + timeRange?: '7d' | '30d' | '90d'; + assetId?: string; + videoId?: string; + userId?: string; + traceId?: string; + limit?: number; +} = {}) { + return apiRequest('/api/tenant-content/media-analytics/summary', { query }); +} + +export async function loadMediaAssetAccessEvents(query: { + timeRange?: '7d' | '30d' | '90d'; + assetId?: string; + userId?: string; + accessType?: string; + result?: 'granted' | 'denied'; + traceId?: string; + limit?: number; +} = {}) { + return apiRequest<{ scope?: Record; items?: MediaAssetAccessEvent[] }>('/api/tenant-content/media-analytics/asset-events', { + query: { ...query, limit: query.limit || 100 }, + }); +} + +export async function loadMediaVideoPlayEvents(query: { + timeRange?: '7d' | '30d' | '90d'; + videoId?: string; + userId?: string; + accessMode?: 'free' | 'svip' | 'video_quota'; + status?: 'issued' | 'started' | 'completed' | 'expired' | 'revoked'; + traceId?: string; + limit?: number; +} = {}) { + return apiRequest<{ scope?: Record; items?: MediaVideoPlayEvent[] }>('/api/tenant-content/media-analytics/video-events', { + query: { ...query, limit: query.limit || 100 }, + }); +} + export async function previewContentImport(importType: ImportType, body: ImportRequestBody) { return apiRequest(`/api/tenant-content/imports/preview/${importType}`, { method: 'POST', diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index a3e5f6a8..634c1977 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/视频次数校验、签名播放、次数扣减、播放日志和动态水印上下文;深度防盗链、转码级水印和播放统计继续补 | +| 视频会员播放次数 | 可联调 | `POST /api/videos/play` 支持 SVIP/视频次数校验、签名播放、次数扣减、播放日志和动态水印上下文;租户后台媒体报表已可按视频、用户、traceId 查询播放事件和汇总;深度防盗链、转码级水印和更细播放完成率统计继续补 | | 视频 JSON 导入和批量绑定 | 可联调 | `/api/tenant-content/imports/preview/videos`、`/api/tenant-content/imports/videos`;支持视频元数据、资源引用、播放模式、题目绑定和题目视频标记 | ## 资料与对象存储 @@ -99,7 +99,7 @@ | PDF/图片预览签名 | 可联调 | `/api/catalog/assets/preview`、`/api/tenant-content/assets/sign-preview`;使用 inline 短期签名,学生预览默认短 TTL | | 托管资源 worker 复检 | 可联调 | `apps/worker --job assets` 定期复检 pending/verified 对象元数据;异常资源会标记 failed 并从 active 退回 draft,写入审计和 `security_flags` | | 内容资源安全扫描 | 可联调 | `content_assets.security_scan_status` + `content_asset_security_scan_events`;托管对象确认上传后进入 `pending/scanning`,内置 `metadata_rules` 和可选外部 HTTP scanner 通过后才可发布、下载、预览或视频播放;失败或外部 scanner 不可用默认 fail-closed,会下架并写审计 | -| 资源访问审计 | 可联调 | `content_asset_access_events` + `GET /api/tenant-content/assets/access-events`;记录上传签名/确认、学生下载/预览、后台下载/预览的 granted/denied、TTL、签名模式、IP、UA 和水印 traceId | +| 资源访问审计 | 可联调 | `content_asset_access_events` + `GET /api/tenant-content/assets/access-events` + `/api/tenant-content/media-analytics/*`;记录上传签名/确认、学生下载/预览、后台下载/预览的 granted/denied、TTL、签名模式、IP、UA 和水印 traceId,并支持租户后台汇总、Top 资源和 traceId 回查 | | CDN 访问边界 | 可联调 | `members/svip/private` 外部 CDN URL 默认拒绝,必须显式 `metadata.providerManagedAccess=true` 或 `cdnAccessMode=signed_by_provider`;视频绑定资源也复用该规则 | | 深度防盗链/水印/杀毒 | 部分覆盖 | 内置 metadata 规则扫描、外部 HTTP scanner 接入层、失败关闭、生产 readiness 阻断、签名访问动态水印上下文和 traceId 审计已完成;商用上线前继续联调真实 AV/内容安全服务、转码/CDN 级水印、CDN 刷新和对象生命周期策略 | diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index df51d657..3c8c29a4 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -259,7 +259,7 @@ GET /api/tenant-admin/audit-logs - 学生批量导入、批量分班、学生状态、备注和跟进任务都使用独立权限点;教师默认可为范围内学生写备注和跟进任务,但不能批量导入、禁用学生或放大可见班级。 - 销售/代理客资采用首绑保护:普通扫码/分享事件不会覆盖已有归属,只有具备 `referral:write` 的租户成员可手动强制补绑。 - CRM 当前完成配置、密钥入私密表、客资入队、队列查询和 `apps/worker` 消费;worker 支持 generic webhook、钉钉、飞书、企业微信机器人消息体/签名、失败重试和日志。 -- 内容资源当前完成台账、租户后台维护、学生端 SVIP 下载权限,以及 `local_dev`、阿里云 OSS、腾讯 COS、Supabase Storage 的上传/下载签名 provider;上传确认、访问审计、动态水印上下文、assets worker 对象元数据校验/复检、内置 `metadata_rules` 安全扫描和外部 HTTP scanner 接入层已完成。PDF 深度预览体验、防盗链、转码/CDN 级水印和真实 AV/内容安全服务联调仍需继续补。 +- 内容资源当前完成台账、租户后台维护、学生端 SVIP 下载权限,以及 `local_dev`、阿里云 OSS、腾讯 COS、Supabase Storage 的上传/下载签名 provider;上传确认、访问审计、动态水印上下文、租户后台媒体运营报表、assets worker 对象元数据校验/复检、内置 `metadata_rules` 安全扫描和外部 HTTP scanner 接入层已完成。PDF 深度预览体验、防盗链、转码/CDN 级水印和真实 AV/内容安全服务联调仍需继续补。 - 题库内容导航当前以 `content_entries/content_nodes` 为主模型,可表达“入口 -> 多级分类 -> 院校/专业/学科/销售意向标记”;题目集合和练习方式由 `question_collections/practice_blueprints` 管理,练习 session 会保存当次题目 ID 快照。 - 练习访问控制由 `content_entries/content_nodes/question_collections/practice_blueprints` 的 `accessRules` 合并决定;普通用户消耗 `practice_daily_usage`,事件写入 `practice_access_events`,SVIP/staff 不消耗免费额度。 - 批量导入当前支持题目、单词、知识手册、分数线、视频 JSON/CSV/Excel 预览、逐行 issue、job/item 台账、同步执行或 `executionMode=async` 异步执行、幂等跳过,并可落到新内容入口、分类节点、分数线表或题目视频绑定。旧单词模板的 `vocabulary_units_示例数据` / `vocabulary_示例数据`、知识手册的书籍/章节/小节/知识点嵌套结构都由后端规范化。导入后复检、模板下载和字段映射 API 已补齐,前端 UI 待接。 diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index 347db9cc..42d74ecf 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -46,7 +46,7 @@ - 生产鉴权已具备 Supabase JWT API 入口,自定义角色模板基础 API 已可用;仍要做真实云端 Auth/JWKS 回归、RLS 深测和班级/学生范围权限细化,前端不要继续使用 `x-user-id`。 - 不要把“Supabase 支持前端 Data API”误解为“本项目所有业务表都由 Taro 直写”;订单、支付、权益、租户后台、导入、CRM、私有资源必须走 RPC、`apps/api`、Edge Function 或 worker 这类后端命令层。 - 短信、微信小程序/网页登录、QQ 登录、微信支付、支付宝支付 provider 已有本地 adapter 和测试覆盖;生产账号、回调域名、证书和商户资料仍需正式联调。 -- 对象存储已完成签名 provider、上传后校验、PDF/图片预览、动态水印上下文、资源复检 worker、内置安全扫描和外部 HTTP scanner 接入层;CDN 防盗链、转码级视频水印和真实 AV/内容安全服务联调还要补。 +- 对象存储已完成签名 provider、上传后校验、PDF/图片预览、动态水印上下文、资源复检 worker、内置安全扫描、外部 HTTP scanner 接入层和租户后台媒体运营报表;CDN 防盗链、转码级视频水印和真实 AV/内容安全服务联调还要补。 - 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入已可联调;大批量导入可传 `executionMode=async` 交给 imports worker;模板下载、字段映射 API、导入任务详情和导入后复检已可用。租户内容页已经可以选择文件或粘贴内容、下载模板、执行后端预览、编辑本次字段别名、同步/异步提交导入、轮询异步 job、查看问题行并触发/查看复检;后续还要补真实数据 dry-run 验收和更完整的目标入口/集合选择。 - 数据看板、分佣结算、勋章手动发放和主题模板发布基础 API 已可联调;勋章自动发放、分佣真实打款/导出/凭证、AI 择校、主题素材库/模板市场等仍是后续商用增强项。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 900e2887..ec818956 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -30,6 +30,7 @@ - 资金对账和差错工单闭环已完成:`commerce_reconciliation_batches/items` 和 `/api/commerce/reconciliation/*` 支持手工/API 导入供应商账单行、预览差异、生成批次统计、查询异常、租户隔离、权限点 `tenant:reconciliation:read/write` 和审计日志;`commerce_reconciliation_issues/events` 支持异常明细创建工单、分配、开始处理、升级、解决、忽略、重开和事件留痕,且不直接修改订单/支付/退款/权益。 - 内容资源复检与安全扫描 worker 已完成:`apps/worker --job assets` 可复检 `content_assets` 中的托管对象元数据,并执行内置 `metadata_rules` 和可选外部 HTTP scanner;正常资源写回复检/扫描证据,异常资源自动置为 `failed/skipped + draft` 或 `security_scan_status=failed`,外部 scanner 不可用默认 fail-closed,并写入审计、扫描事件和安全标记。 - 题库导出 worker 已完成:`apps/worker --job exports` 可抢占 `pdf/docx/daily_practice_zip` 导出任务,渲染 PDF/Word、水印或每日一练图片素材包,写入对象存储或本地开发存储,创建 `content_assets` 并回填 `assetId/hash/size`;`daily_practice` 已支持每日一练九宫格 metadata、PDF/Word 基础版式、9 张 PNG/SVG 卡片和拼图 ZIP。 +- 租户后台媒体运营报表已完成:`/api/tenant-content/media-analytics/summary`、`asset-events`、`video-events` 可按 7/30/90 天、资源、视频、用户和水印 traceId 查询资料访问、视频播放、拒绝访问、Top 资源/视频和日趋势;仅开放给 owner/admin/operator 或 `content:analytics:read` 权限角色,前端不会拿到签名 URL 或播放 token。 - 本地验证:`npm run check:refactor` 已通过。 当前更适合进入前端联调前阅读的总览文档: @@ -53,6 +54,7 @@ - 已补内容资源安全扫描状态机:确认上传后进入 `securityScanStatus=pending`,扫描通过后才能发布、下载、预览或视频播放;后台可查 `/api/tenant-content/assets/security-scan-events`。 - 已补资源访问事件 `content_asset_access_events`,覆盖上传签名/确认、学生下载/预览、后台下载/预览的 granted/denied、短 TTL、签名模式、IP、UA 和动态水印 traceId。 - 已补签名访问动态水印上下文:学生资料下载/预览、租户后台下载/预览和视频播放都会返回 `watermark`,并将 traceId 写入访问事件或播放事件。 + - 已补租户后台媒体运营报表,支持资料/视频访问汇总、Top 资源/视频和 traceId 回查;大租户后续再把实时查询替换为日/周预聚合。 - 已收紧锁定资源 CDN 边界:`members/svip/private` 外链默认拒绝,必须显式 provider-managed 才允许;视频绑定资源也复用该策略。 - 已补外部 HTTP 杀毒/内容安全 scanner 接入层、失败关闭、生产 readiness 阻断和动态水印 traceId 审计;继续联调真实扫描服务、转码/CDN 级水印、CDN 刷新和对象生命周期策略。 - `content_assets` 继续作为资源台账,不允许前端绕过台账直接访问私有资源。 @@ -101,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 24fd93a5..a42e3028 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -269,6 +269,7 @@ tenant::theme | 租户勋章 | `GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants` | | 公共题库采纳/同步 | `GET /api/tenant-content/public-question-banks`、`POST /api/tenant-content/public-question-banks/adopt`、`POST /api/tenant-content/public-question-banks/sync`、`GET /api/tenant-content/public-question-banks/conflicts?adoptionId=...`、`POST /api/tenant-content/public-question-banks/conflicts/resolve`、`POST /api/tenant-content/public-question-banks/conflicts/resolve-batch` | | 题库导出 | `POST /api/tenant-content/exports/questions`、`GET /api/tenant-content/exports/jobs` | +| 资料/视频运营审计 | `GET /api/tenant-content/media-analytics/summary`、`asset-events`、`video-events` | ## 资料、PDF 和视频资源契约 @@ -329,6 +330,25 @@ GET /api/tenant-content/assets/access-events?assetId=&limit=100 访问事件 `metadata.watermark.traceId` 可用于后台按截图上的追踪码回查访问记录。视频播放不走 `content_asset_access_events`,但 `POST /api/videos/play` 返回同样的 `watermark` 对象,后端会把 traceId 写入 `video_play_events.metadata.watermark.traceId`。 +租户后台运营报表: + +```text +GET /api/tenant-content/media-analytics/summary?timeRange=30d&limit=10 +GET /api/tenant-content/media-analytics/asset-events?traceId=7D2A9C3E1B0F&limit=100 +GET /api/tenant-content/media-analytics/video-events?videoId=&limit=100 +``` + +这些接口用于租户后台资料/视频运营面板,权限为 `content:analytics:read`,租户 owner/admin/operator 默认可访问。普通教师默认不可见,除非绑定了带该权限的角色模板。 + +前端展示建议: + +- `summary.assetAccess` 展示下载、预览、拒绝访问、水印事件数。 +- `summary.videoPlay` 展示视频播放、SVIP 播放、次数播放、消耗次数。 +- `assetTop` / `videoTop` 做热门资料和热门视频排行。 +- `daily` 做资料访问和视频播放趋势。 +- 搜索框支持输入截图上的 `traceId`,同时请求 `asset-events` 和 `video-events` 回查用户、时间、IP、UA、资源或视频。 +- 这些接口不会返回签名 URL、播放 token、云厂商密钥或支付密钥;前端不要把它们当成下载/播放接口。 + ## 练习访问控制契约 前端不要先拉完整题目列表再自行判断免费额度。用户点击顺序刷题、随机刷题、全真模拟时,统一调用 `POST /api/learning/practice-sessions`,后端会根据 `content_entries.accessRules`、`content_nodes.accessRules`、`question_collections.accessRules`、`practice_blueprints.accessRules` 和当前用户权益决定最终题目快照。 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 85c9bebc..09f2d1cb 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -3709,6 +3709,59 @@ async function testTenantContentAssetsAndImports() { 'admin preview access event should include watermark trace id', ); + const mediaSummary = await request('/api/tenant-content/media-analytics/summary', { + userId: TENANT_ADMIN_USER_ID, + query: { timeRange: '30d', assetId: localAsset.item.id, traceId: localPreview.watermark.traceId }, + }); + assert.equal(mediaSummary.assetAccess?.previews >= 1, true, 'media analytics summary should count asset previews'); + assert.equal(mediaSummary.assetAccess?.watermarkEvents >= 1, true, 'media analytics summary should count watermark asset events'); + assert.equal(mediaSummary.traceMatches?.assets >= 1, true, 'media analytics summary should support trace id asset lookup'); + assert.ok(mediaSummary.daily?.some(item => item.assetEvents >= 1), 'media analytics daily series should include asset events'); + + const mediaAssetEvents = await request('/api/tenant-content/media-analytics/asset-events', { + userId: TENANT_ADMIN_USER_ID, + query: { timeRange: '30d', traceId: localPreview.watermark.traceId, limit: 5 }, + }); + assert.ok( + mediaAssetEvents.items?.some(item => item.assetId === localAsset.item.id && item.watermarkTraceId === localPreview.watermark.traceId), + 'media analytics asset event list should search by watermark trace id', + ); + assert.ok(!JSON.stringify(mediaAssetEvents).includes('signature='), 'media analytics asset events should not leak signed URLs'); + + const mediaVideoSummary = await request('/api/tenant-content/media-analytics/summary', { + userId: TENANT_ADMIN_USER_ID, + query: { timeRange: '30d', videoId: ids.video }, + }); + 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 mediaVideoEventsByVideo = await request('/api/tenant-content/media-analytics/video-events', { + userId: TENANT_ADMIN_USER_ID, + query: { timeRange: '30d', videoId: ids.video, limit: 5 }, + }); + const videoTraceId = mediaVideoEventsByVideo.items?.find(item => item.videoId === ids.video)?.watermarkTraceId; + assert.ok(videoTraceId, 'media analytics video event list should include watermark trace id'); + + const mediaVideoEvents = await request('/api/tenant-content/media-analytics/video-events', { + userId: TENANT_ADMIN_USER_ID, + query: { timeRange: '30d', traceId: videoTraceId, limit: 5 }, + }); + assert.ok( + mediaVideoEvents.items?.some(item => item.videoId === ids.video && item.watermarkTraceId === videoTraceId), + 'media analytics video event list should search by watermark trace id', + ); + assert.ok(!JSON.stringify(mediaVideoEvents).includes('vp_'), 'media analytics video events should not leak play tokens'); + + const teacherAnalyticsDenied = await request('/api/tenant-content/media-analytics/summary', { + userId: TENANT_TEACHER_USER_ID, + expectStatus: 403, + }); + assert.equal( + teacherAnalyticsDenied.code, + 'TENANT_CONTENT_PERMISSION_REQUIRED', + 'teacher without analytics permission should not access media audit analytics', + ); + const failedAsset = await request('/api/tenant-content/assets', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', diff --git a/supabase/migrations/202606290025_media_access_analytics_indexes.sql b/supabase/migrations/202606290025_media_access_analytics_indexes.sql new file mode 100644 index 00000000..5fe28451 --- /dev/null +++ b/supabase/migrations/202606290025_media_access_analytics_indexes.sql @@ -0,0 +1,16 @@ +create index if not exists idx_asset_access_events_tenant_created + on public.content_asset_access_events(tenant_id, created_at desc); + +create index if not exists idx_asset_access_events_watermark_trace + on public.content_asset_access_events(tenant_id, (upper(metadata #>> '{watermark,traceId}')), created_at desc) + where metadata #>> '{watermark,traceId}' is not null; + +create index if not exists idx_video_play_events_tenant_created + on public.video_play_events(tenant_id, created_at desc); + +create index if not exists idx_video_play_events_mode_status + on public.video_play_events(tenant_id, access_mode, status, created_at desc); + +create index if not exists idx_video_play_events_watermark_trace + on public.video_play_events(tenant_id, (upper(metadata #>> '{watermark,traceId}')), created_at desc) + where metadata #>> '{watermark,traceId}' is not null;