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;
}
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> {
const tenantId = await tenantIdFrom(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 || {} };
}
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,
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],

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>[];
}
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 {
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<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) {
return apiRequest<ImportPreviewResult>(`/api/tenant-content/imports/preview/${importType}`, {
method: 'POST',