forked from wangziqi/gongxue-base
feat: add media access analytics
This commit is contained in:
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
473
apps/api/src/features/tenant-content/media-analytics.ts
Normal file
473
apps/api/src/features/tenant-content/media-analytics.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 刷新和对象生命周期策略 |
|
||||||
|
|
||||||
|
|||||||
@@ -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 待接。
|
||||||
|
|||||||
@@ -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 择校、主题素材库/模板市场等仍是后续商用增强项。
|
||||||
|
|
||||||
|
|||||||
@@ -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. 学习统计
|
||||||
|
|||||||
@@ -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` 和当前用户权益决定最终题目快照。
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user