feat: add media access analytics

This commit is contained in:
Codex
2026-06-29 20:30:00 +08:00
parent e0393126a1
commit 38db867d58
11 changed files with 749 additions and 5 deletions

View File

@@ -16,6 +16,10 @@ function hasContentPermission(permissions: Record<string, unknown>) {
return permissions['*'] === true || permissions['content:*'] === true; return permissions['*'] === true || permissions['content:*'] === true;
} }
function hasPermission(permissions: Record<string, unknown>, permissionKey: string) {
return permissions['*'] === true || permissions['content:*'] === true || permissions[permissionKey] === true;
}
export async function requireTenantContentEditor(ctx: RequestContext): Promise<TenantContentAuth> { export async function requireTenantContentEditor(ctx: RequestContext): Promise<TenantContentAuth> {
const tenantId = await tenantIdFrom(ctx); const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx); const userId = await userIdFrom(ctx);
@@ -60,3 +64,56 @@ export async function requireTenantContentEditor(ctx: RequestContext): Promise<T
return { tenantId, userId, role: membership.role, permissions: membership.permissions || {}, templatePermissions: membership.templatePermissions || {} }; return { tenantId, userId, role: membership.role, permissions: membership.permissions || {}, templatePermissions: membership.templatePermissions || {} };
} }
export async function requireTenantContentPermission(
ctx: RequestContext,
permissionKey: string,
defaultRoles: string[] = ['tenant_owner', 'tenant_admin', 'tenant_operator'],
): Promise<TenantContentAuth> {
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const membership = await queryOne<{ role: string; permissions: Record<string, unknown>; templatePermissions: Record<string, unknown> }>(
`
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 };
}

View File

@@ -36,6 +36,11 @@ import {
createQuestionExportRoute, createQuestionExportRoute,
questionExportJobsRoute, questionExportJobsRoute,
} from './exports.js'; } from './exports.js';
import {
mediaAnalyticsSummaryRoute,
mediaAssetAccessEventsRoute,
mediaVideoPlayEventsRoute,
} from './media-analytics.js';
import { import {
contentEntriesAdminRoute, contentEntriesAdminRoute,
contentNodesAdminRoute, contentNodesAdminRoute,
@@ -106,6 +111,9 @@ export const tenantContentRoutes: RouteDefinition[] = [
['GET', '/api/tenant-content/assets', assetsAdminRoute], ['GET', '/api/tenant-content/assets', assetsAdminRoute],
['GET', '/api/tenant-content/assets/access-events', assetAccessEventsAdminRoute], ['GET', '/api/tenant-content/assets/access-events', assetAccessEventsAdminRoute],
['GET', '/api/tenant-content/assets/security-scan-events', assetSecurityScanEventsAdminRoute], ['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], ['PUT', '/api/tenant-content/assets', upsertAssetRoute],
['POST', '/api/tenant-content/assets/sign-upload', signAssetUploadRoute], ['POST', '/api/tenant-content/assets/sign-upload', signAssetUploadRoute],
['POST', '/api/tenant-content/assets/confirm-upload', confirmAssetUploadRoute], ['POST', '/api/tenant-content/assets/confirm-upload', confirmAssetUploadRoute],

View File

@@ -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<string, number> = {
'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<string, unknown>;
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<string>, 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<typeof emptyDailySeries>,
rows: NumericRow[],
mapper: (target: ReturnType<typeof emptyDailySeries>[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<NumericRow>(
`
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<NumericRow>(
`
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<NumericRow>(
`
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<NumericRow>(
`
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<NumericRow>(
`
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<NumericRow>(
`
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 };
}

View File

@@ -273,6 +273,82 @@ export interface ImportTemplateItem {
fields?: Record<string, unknown>[]; fields?: Record<string, unknown>[];
} }
export interface MediaAnalyticsSummary {
scope?: Record<string, unknown>;
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<string, unknown>[];
videoTop?: Record<string, unknown>[];
daily?: Record<string, number | string>[];
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<string, unknown> | 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<string, unknown> | null;
createdAt?: string;
updatedAt?: string;
}
export interface ImportPreviewResult { export interface ImportPreviewResult {
job?: { job?: {
id: string; 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<MediaAnalyticsSummary>('/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<string, unknown>; 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<string, unknown>; items?: MediaVideoPlayEvent[] }>('/api/tenant-content/media-analytics/video-events', {
query: { ...query, limit: query.limit || 100 },
});
}
export async function previewContentImport(importType: ImportType, body: ImportRequestBody) { export async function previewContentImport(importType: ImportType, body: ImportRequestBody) {
return apiRequest<ImportPreviewResult>(`/api/tenant-content/imports/preview/${importType}`, { return apiRequest<ImportPreviewResult>(`/api/tenant-content/imports/preview/${importType}`, {
method: 'POST', method: 'POST',

View File

@@ -82,7 +82,7 @@
| 分数线字段/院校/专业/记录/趋势 | 可联调 | `/api/scoreline/*` | | 分数线字段/院校/专业/记录/趋势 | 可联调 | `/api/scoreline/*` |
| 分数线 JSON 导入 | 可联调 | `/api/tenant-content/imports/preview/scoreline``/api/tenant-content/imports/scoreline`;支持字段、院校、专业、记录、动态字段值、逐行 issue、幂等和审计 | | 分数线 JSON 导入 | 可联调 | `/api/tenant-content/imports/preview/scoreline``/api/tenant-content/imports/scoreline`;支持字段、院校、专业、记录、动态字段值、逐行 issue、幂等和审计 |
| 题目视频/批量预加载/搜索 | 可联调 | `/api/questions/*/videos``/api/videos/search`;付费视频列表不返回可播放 URL | | 题目视频/批量预加载/搜索 | 可联调 | `/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`;支持视频元数据、资源引用、播放模式、题目绑定和题目视频标记 | | 视频 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 | | 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` | | 托管资源 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_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`;视频绑定资源也复用该规则 | | CDN 访问边界 | 可联调 | `members/svip/private` 外部 CDN URL 默认拒绝,必须显式 `metadata.providerManagedAccess=true``cdnAccessMode=signed_by_provider`;视频绑定资源也复用该规则 |
| 深度防盗链/水印/杀毒 | 部分覆盖 | 内置 metadata 规则扫描、外部 HTTP scanner 接入层、失败关闭、生产 readiness 阻断、签名访问动态水印上下文和 traceId 审计已完成;商用上线前继续联调真实 AV/内容安全服务、转码/CDN 级水印、CDN 刷新和对象生命周期策略 | | 深度防盗链/水印/杀毒 | 部分覆盖 | 内置 metadata 规则扫描、外部 HTTP scanner 接入层、失败关闭、生产 readiness 阻断、签名访问动态水印上下文和 traceId 审计已完成;商用上线前继续联调真实 AV/内容安全服务、转码/CDN 级水印、CDN 刷新和对象生命周期策略 |

View File

@@ -259,7 +259,7 @@ GET /api/tenant-admin/audit-logs
- 学生批量导入、批量分班、学生状态、备注和跟进任务都使用独立权限点;教师默认可为范围内学生写备注和跟进任务,但不能批量导入、禁用学生或放大可见班级。 - 学生批量导入、批量分班、学生状态、备注和跟进任务都使用独立权限点;教师默认可为范围内学生写备注和跟进任务,但不能批量导入、禁用学生或放大可见班级。
- 销售/代理客资采用首绑保护:普通扫码/分享事件不会覆盖已有归属,只有具备 `referral:write` 的租户成员可手动强制补绑。 - 销售/代理客资采用首绑保护:普通扫码/分享事件不会覆盖已有归属,只有具备 `referral:write` 的租户成员可手动强制补绑。
- CRM 当前完成配置、密钥入私密表、客资入队、队列查询和 `apps/worker` 消费worker 支持 generic webhook、钉钉、飞书、企业微信机器人消息体/签名、失败重试和日志。 - 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` 管理,练习 session 会保存当次题目 ID 快照。
- 练习访问控制由 `content_entries/content_nodes/question_collections/practice_blueprints``accessRules` 合并决定;普通用户消耗 `practice_daily_usage`,事件写入 `practice_access_events`SVIP/staff 不消耗免费额度。 - 练习访问控制由 `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 待接。 - 批量导入当前支持题目、单词、知识手册、分数线、视频 JSON/CSV/Excel 预览、逐行 issue、job/item 台账、同步执行或 `executionMode=async` 异步执行、幂等跳过,并可落到新内容入口、分类节点、分数线表或题目视频绑定。旧单词模板的 `vocabulary_units_示例数据` / `vocabulary_示例数据`、知识手册的书籍/章节/小节/知识点嵌套结构都由后端规范化。导入后复检、模板下载和字段映射 API 已补齐,前端 UI 待接。

View File

@@ -46,7 +46,7 @@
- 生产鉴权已具备 Supabase JWT API 入口,自定义角色模板基础 API 已可用;仍要做真实云端 Auth/JWKS 回归、RLS 深测和班级/学生范围权限细化,前端不要继续使用 `x-user-id` - 生产鉴权已具备 Supabase JWT API 入口,自定义角色模板基础 API 已可用;仍要做真实云端 Auth/JWKS 回归、RLS 深测和班级/学生范围权限细化,前端不要继续使用 `x-user-id`
- 不要把“Supabase 支持前端 Data API”误解为“本项目所有业务表都由 Taro 直写”订单、支付、权益、租户后台、导入、CRM、私有资源必须走 RPC、`apps/api`、Edge Function 或 worker 这类后端命令层。 - 不要把“Supabase 支持前端 Data API”误解为“本项目所有业务表都由 Taro 直写”订单、支付、权益、租户后台、导入、CRM、私有资源必须走 RPC、`apps/api`、Edge Function 或 worker 这类后端命令层。
- 短信、微信小程序/网页登录、QQ 登录、微信支付、支付宝支付 provider 已有本地 adapter 和测试覆盖;生产账号、回调域名、证书和商户资料仍需正式联调。 - 短信、微信小程序/网页登录、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 验收和更完整的目标入口/集合选择。 - 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入已可联调;大批量导入可传 `executionMode=async` 交给 imports worker模板下载、字段映射 API、导入任务详情和导入后复检已可用。租户内容页已经可以选择文件或粘贴内容、下载模板、执行后端预览、编辑本次字段别名、同步/异步提交导入、轮询异步 job、查看问题行并触发/查看复检;后续还要补真实数据 dry-run 验收和更完整的目标入口/集合选择。
- 数据看板、分佣结算、勋章手动发放和主题模板发布基础 API 已可联调;勋章自动发放、分佣真实打款/导出/凭证、AI 择校、主题素材库/模板市场等仍是后续商用增强项。 - 数据看板、分佣结算、勋章手动发放和主题模板发布基础 API 已可联调;勋章自动发放、分佣真实打款/导出/凭证、AI 择校、主题素材库/模板市场等仍是后续商用增强项。

View File

@@ -30,6 +30,7 @@
- 资金对账和差错工单闭环已完成:`commerce_reconciliation_batches/items``/api/commerce/reconciliation/*` 支持手工/API 导入供应商账单行、预览差异、生成批次统计、查询异常、租户隔离、权限点 `tenant:reconciliation:read/write` 和审计日志;`commerce_reconciliation_issues/events` 支持异常明细创建工单、分配、开始处理、升级、解决、忽略、重开和事件留痕,且不直接修改订单/支付/退款/权益。 - 资金对账和差错工单闭环已完成:`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 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。 - 题库导出 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` 已通过。 - 本地验证:`npm run check:refactor` 已通过。
当前更适合进入前端联调前阅读的总览文档: 当前更适合进入前端联调前阅读的总览文档:
@@ -53,6 +54,7 @@
- 已补内容资源安全扫描状态机:确认上传后进入 `securityScanStatus=pending`,扫描通过后才能发布、下载、预览或视频播放;后台可查 `/api/tenant-content/assets/security-scan-events` - 已补内容资源安全扫描状态机:确认上传后进入 `securityScanStatus=pending`,扫描通过后才能发布、下载、预览或视频播放;后台可查 `/api/tenant-content/assets/security-scan-events`
- 已补资源访问事件 `content_asset_access_events`,覆盖上传签名/确认、学生下载/预览、后台下载/预览的 granted/denied、短 TTL、签名模式、IP、UA 和动态水印 traceId。 - 已补资源访问事件 `content_asset_access_events`,覆盖上传签名/确认、学生下载/预览、后台下载/预览的 granted/denied、短 TTL、签名模式、IP、UA 和动态水印 traceId。
- 已补签名访问动态水印上下文:学生资料下载/预览、租户后台下载/预览和视频播放都会返回 `watermark`,并将 traceId 写入访问事件或播放事件。 - 已补签名访问动态水印上下文:学生资料下载/预览、租户后台下载/预览和视频播放都会返回 `watermark`,并将 traceId 写入访问事件或播放事件。
- 已补租户后台媒体运营报表,支持资料/视频访问汇总、Top 资源/视频和 traceId 回查;大租户后续再把实时查询替换为日/周预聚合。
- 已收紧锁定资源 CDN 边界:`members/svip/private` 外链默认拒绝,必须显式 provider-managed 才允许;视频绑定资源也复用该策略。 - 已收紧锁定资源 CDN 边界:`members/svip/private` 外链默认拒绝,必须显式 provider-managed 才允许;视频绑定资源也复用该策略。
- 已补外部 HTTP 杀毒/内容安全 scanner 接入层、失败关闭、生产 readiness 阻断和动态水印 traceId 审计;继续联调真实扫描服务、转码/CDN 级水印、CDN 刷新和对象生命周期策略。 - 已补外部 HTTP 杀毒/内容安全 scanner 接入层、失败关闭、生产 readiness 阻断和动态水印 traceId 审计;继续联调真实扫描服务、转码/CDN 级水印、CDN 刷新和对象生命周期策略。
- `content_assets` 继续作为资源台账,不允许前端绕过台账直接访问私有资源。 - `content_assets` 继续作为资源台账,不允许前端绕过台账直接访问私有资源。
@@ -101,7 +103,7 @@
6. 视频会员控制 6. 视频会员控制
- 已完成视频 SVIP 权限、播放次数扣减、签名播放和播放日志。 - 已完成视频 SVIP 权限、播放次数扣减、签名播放和播放日志。
- 已补播放签名动态水印上下文traceId 事件;继续补深度防盗链、转码级水印、播放统计。 - 已补播放签名动态水印上下文traceId 事件和租户后台播放事件查询;继续补深度防盗链、转码级水印、播放完成率和观看时长统计。
- 单题视频和通用知识视频混合推荐。 - 单题视频和通用知识视频混合推荐。
7. 学习统计 7. 学习统计

View File

@@ -269,6 +269,7 @@ tenant:<tenantId>:theme
| 租户勋章 | `GET/PUT /api/tenant-admin/badges``GET/POST /api/tenant-admin/badge-grants` | | 租户勋章 | `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` | | 公共题库采纳/同步 | `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` | | 题库导出 | `POST /api/tenant-content/exports/questions``GET /api/tenant-content/exports/jobs` |
| 资料/视频运营审计 | `GET /api/tenant-content/media-analytics/summary``asset-events``video-events` |
## 资料、PDF 和视频资源契约 ## 资料、PDF 和视频资源契约
@@ -329,6 +330,25 @@ GET /api/tenant-content/assets/access-events?assetId=<assetId>&limit=100
访问事件 `metadata.watermark.traceId` 可用于后台按截图上的追踪码回查访问记录。视频播放不走 `content_asset_access_events`,但 `POST /api/videos/play` 返回同样的 `watermark` 对象,后端会把 traceId 写入 `video_play_events.metadata.watermark.traceId` 访问事件 `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=<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` 和当前用户权益决定最终题目快照。 前端不要先拉完整题目列表再自行判断免费额度。用户点击顺序刷题、随机刷题、全真模拟时,统一调用 `POST /api/learning/practice-sessions`,后端会根据 `content_entries.accessRules``content_nodes.accessRules``question_collections.accessRules``practice_blueprints.accessRules` 和当前用户权益决定最终题目快照。

View File

@@ -3709,6 +3709,59 @@ async function testTenantContentAssetsAndImports() {
'admin preview access event should include watermark trace id', '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', { const failedAsset = await request('/api/tenant-content/assets', {
userId: TENANT_ADMIN_USER_ID, userId: TENANT_ADMIN_USER_ID,
method: 'PUT', method: 'PUT',

View File

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