forked from wangziqi/gongxue-base
feat: harden content asset access
This commit is contained in:
@@ -29,7 +29,7 @@
|
||||
|
||||
- Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。
|
||||
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;完整资金流水对账和真实生产账号联调还没接完。
|
||||
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览和资源复检 worker 已完成,CDN 防盗链、杀毒扫描和视频动态水印还没完成。
|
||||
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览、资源访问事件、锁定资源 CDN 边界和资源复检 worker 已完成,杀毒扫描、视频动态水印、CDN 刷新和对象生命周期策略还没完成。
|
||||
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
|
||||
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
|
||||
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。
|
||||
@@ -304,7 +304,7 @@ API 身份上下文:
|
||||
|
||||
- 租户公开配置和主题配置不能存放密钥;主题 token 只能是后端允许的颜色、半径、安全 CSS 变量、图标 token 和公开素材引用。
|
||||
- 商户密钥、短信密钥、OAuth app secret 等必须进入 `app_private.tenant_secrets`,或后续生产 KMS/Vault。
|
||||
- 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL;生产环境应定时运行 assets worker 复检对象元数据,异常资源会被标记 failed 并退回 draft。
|
||||
- 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL;学生端预览、锁定资料和视频会使用短 TTL。`members/svip/private` 外部 CDN URL 默认拒绝,除非显式登记 provider-managed 访问;所有上传签名、上传确认、下载/预览 granted/denied 都写入 `content_asset_access_events`。生产环境应定时运行 assets worker 复检对象元数据,异常资源会被标记 failed 并退回 draft。
|
||||
- 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。
|
||||
- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册、分数线和视频 JSON/CSV/Excel 导入已走这套后台校验管线;大批量任务可提交 `executionMode=async`,由 imports worker 消费,前端只轮询 job 状态和展示 issues。
|
||||
- 题库导出必须由后端按权限生成,不允许前端直接读取数据库拼导出文件;不开启答案/解析时,顶层题目和复合题子题都必须脱敏;PDF/Word/每日一练 ZIP 只通过 exports worker 写入 `content_assets` 后再签名下载/预览。
|
||||
@@ -318,14 +318,14 @@ API 身份上下文:
|
||||
npx supabase db reset
|
||||
npm run check:api
|
||||
npm run check:worker
|
||||
npm run test:worker:assets
|
||||
npm run test:api
|
||||
npm run test:worker:exports
|
||||
npm run check:refactor
|
||||
npm run audit:runtime
|
||||
git diff --check
|
||||
```
|
||||
|
||||
结果:通过。`npm run test:worker:exports` 已覆盖 PDF、Word、每日一练 PDF 和 `daily_practice_zip` 图片素材包生成、资源台账写入、ZIP 解包和答案/解析脱敏。`npm run audit:runtime` 无 high/critical 漏洞;当前运行时依赖树仍有 `exceljs -> uuid` 的 moderate 级提示,修复需要破坏性降级 `exceljs`,后续应在导入 Excel 回归充分后单独处理。
|
||||
结果:通过。`npm run test:api` 已覆盖资源访问事件、锁定 CDN 资源拒绝、provider-managed CDN 显式放行、学生短 TTL 下载/预览和访问记录查询。`npm run test:worker:assets` 已覆盖托管资源复检和异常资源自动下架。`npm run audit:runtime` 无 high/critical 漏洞;当前运行时依赖树仍有 `exceljs -> uuid` 的 moderate 级提示,修复需要破坏性降级 `exceljs`,后续应在导入 Excel 回归充分后单独处理。
|
||||
|
||||
注意:`apps/taro` 是静态构建工程,线上发布 `apps/taro/dist/**`,不发布 `node_modules`。Taro 4.2.0 当前构建工具链仍会触发 `npm run audit:taro:toolchain` 的上游 high/critical 提示,不能用 `npm audit fix --force` 降级到 Taro 3 破坏构建;上线验收时以 `audit:runtime`、构建产物、前端密钥检查和静态服务器配置为准,并持续跟进 Taro 官方修复。
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, optionalUserIdFrom, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
import { query, queryOne } from '../../core/db.js';
|
||||
import { signStorageDownload, type StorageProviderName } from '../storage/service.js';
|
||||
import {
|
||||
assetAccessTtl,
|
||||
assertCdnAccessAllowed,
|
||||
recordAssetAccessEvent,
|
||||
signedAssetFingerprint,
|
||||
} from '../storage/asset-access.js';
|
||||
|
||||
interface CatalogAssetRow {
|
||||
id: string;
|
||||
@@ -20,6 +26,8 @@ interface CatalogAssetRow {
|
||||
subjectId: string | null;
|
||||
uploadStatus: string;
|
||||
previewStatus: string;
|
||||
accessRules: Record<string, unknown>;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function hasActiveMembership(tenantId: string, userId: string) {
|
||||
@@ -83,6 +91,28 @@ async function assertAssetAccess(ctx: RequestContext, asset: CatalogAssetRow) {
|
||||
return { userId, svip: true };
|
||||
}
|
||||
|
||||
async function recordDeniedAssetAccess(ctx: RequestContext, tenantId: string, asset: CatalogAssetRow, accessType: 'download' | 'preview', error: unknown) {
|
||||
const userId = await optionalUserIdFrom(ctx).catch(() => '');
|
||||
const code = error instanceof HttpError ? error.code : 'ASSET_ACCESS_DENIED';
|
||||
await recordAssetAccessEvent({
|
||||
ctx,
|
||||
tenantId,
|
||||
assetId: asset.id,
|
||||
userId: userId || null,
|
||||
actorRole: userId ? 'student' : 'anonymous',
|
||||
accessType,
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
storageProvider: asset.storageProvider,
|
||||
result: 'denied',
|
||||
denyCode: code,
|
||||
metadata: {
|
||||
title: asset.title,
|
||||
fileName: asset.fileName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function assetPreviewable(asset: CatalogAssetRow) {
|
||||
const mimeType = asset.mimeType?.split(';')[0]?.trim().toLowerCase() || '';
|
||||
return asset.assetType === 'pdf' || mimeType === 'application/pdf' || asset.assetType === 'image' || mimeType.startsWith('image/');
|
||||
@@ -174,7 +204,8 @@ export async function assetDownloadRoute(ctx: RequestContext) {
|
||||
cdn_url as "cdnUrl", preview_url as "previewUrl",
|
||||
preview_object_key as "previewObjectKey", mime_type as "mimeType",
|
||||
visibility, region_id as "regionId", subject_id as "subjectId",
|
||||
upload_status as "uploadStatus", preview_status as "previewStatus"
|
||||
upload_status as "uploadStatus", preview_status as "previewStatus",
|
||||
access_rules as "accessRules", metadata
|
||||
from public.content_assets
|
||||
where tenant_id = $1 and id = $2 and status = 'active'
|
||||
limit 1
|
||||
@@ -186,12 +217,63 @@ export async function assetDownloadRoute(ctx: RequestContext) {
|
||||
throw new HttpError(404, 'Asset not found', 'ASSET_NOT_FOUND');
|
||||
}
|
||||
|
||||
const access = await assertAssetAccess(ctx, asset);
|
||||
assertPublishedAsset(asset);
|
||||
let access: { userId: string; svip: boolean };
|
||||
try {
|
||||
access = await assertAssetAccess(ctx, asset);
|
||||
assertPublishedAsset(asset);
|
||||
assertCdnAccessAllowed({
|
||||
assetId: asset.id,
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
storageProvider: asset.storageProvider as StorageProviderName,
|
||||
objectKey: asset.objectKey,
|
||||
cdnUrl: asset.cdnUrl,
|
||||
metadata: asset.metadata,
|
||||
accessRules: asset.accessRules,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordDeniedAssetAccess(ctx, tenantId, asset, 'download', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const expiresInSec = assetAccessTtl({
|
||||
actorRole: access.userId ? 'student' : 'anonymous',
|
||||
accessType: 'download',
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
disposition: 'attachment',
|
||||
requestedExpiresInSec: 900,
|
||||
});
|
||||
const download = await signStorageDownload({
|
||||
tenantId,
|
||||
provider: asset.storageProvider as StorageProviderName,
|
||||
bucket: asset.bucket,
|
||||
objectKey: asset.objectKey,
|
||||
cdnUrl: asset.cdnUrl,
|
||||
fileName: asset.fileName,
|
||||
expiresInSec,
|
||||
disposition: 'attachment',
|
||||
});
|
||||
await query(
|
||||
'update public.content_assets set download_count = download_count + 1, updated_at = now() where tenant_id = $1 and id = $2',
|
||||
[tenantId, assetId],
|
||||
);
|
||||
await recordAssetAccessEvent({
|
||||
ctx,
|
||||
tenantId,
|
||||
assetId: asset.id,
|
||||
userId: access.userId || null,
|
||||
actorRole: access.userId ? 'student' : 'anonymous',
|
||||
accessType: 'download',
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
storageProvider: asset.storageProvider,
|
||||
disposition: 'attachment',
|
||||
expiresInSec: download.expiresInSec,
|
||||
signatureMode: download.signatureMode,
|
||||
result: 'granted',
|
||||
metadata: { signature: signedAssetFingerprint(download) },
|
||||
});
|
||||
|
||||
return {
|
||||
item: {
|
||||
@@ -205,16 +287,7 @@ export async function assetDownloadRoute(ctx: RequestContext) {
|
||||
visibility: asset.visibility,
|
||||
},
|
||||
access,
|
||||
download: await signStorageDownload({
|
||||
tenantId,
|
||||
provider: asset.storageProvider as StorageProviderName,
|
||||
bucket: asset.bucket,
|
||||
objectKey: asset.objectKey,
|
||||
cdnUrl: asset.cdnUrl,
|
||||
fileName: asset.fileName,
|
||||
expiresInSec: 900,
|
||||
disposition: 'attachment',
|
||||
}),
|
||||
download,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -232,7 +305,8 @@ export async function assetPreviewRoute(ctx: RequestContext) {
|
||||
cdn_url as "cdnUrl", preview_url as "previewUrl",
|
||||
preview_object_key as "previewObjectKey", mime_type as "mimeType",
|
||||
visibility, region_id as "regionId", subject_id as "subjectId",
|
||||
upload_status as "uploadStatus", preview_status as "previewStatus"
|
||||
upload_status as "uploadStatus", preview_status as "previewStatus",
|
||||
access_rules as "accessRules", metadata
|
||||
from public.content_assets
|
||||
where tenant_id = $1 and id = $2 and status = 'active'
|
||||
limit 1
|
||||
@@ -247,8 +321,58 @@ export async function assetPreviewRoute(ctx: RequestContext) {
|
||||
throw new HttpError(400, 'Asset type does not support inline preview', 'ASSET_PREVIEW_NOT_SUPPORTED');
|
||||
}
|
||||
|
||||
const access = await assertAssetAccess(ctx, asset);
|
||||
assertPublishedAsset(asset);
|
||||
let access: { userId: string; svip: boolean };
|
||||
try {
|
||||
access = await assertAssetAccess(ctx, asset);
|
||||
assertPublishedAsset(asset);
|
||||
assertCdnAccessAllowed({
|
||||
assetId: asset.id,
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
storageProvider: asset.storageProvider as StorageProviderName,
|
||||
objectKey: asset.previewObjectKey || asset.objectKey,
|
||||
cdnUrl: asset.previewUrl || asset.cdnUrl,
|
||||
metadata: asset.metadata,
|
||||
accessRules: asset.accessRules,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordDeniedAssetAccess(ctx, tenantId, asset, 'preview', error);
|
||||
throw error;
|
||||
}
|
||||
const expiresInSec = assetAccessTtl({
|
||||
actorRole: access.userId ? 'student' : 'anonymous',
|
||||
accessType: 'preview',
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
disposition: 'inline',
|
||||
requestedExpiresInSec: 900,
|
||||
});
|
||||
const preview = await signStorageDownload({
|
||||
tenantId,
|
||||
provider: asset.storageProvider as StorageProviderName,
|
||||
bucket: asset.bucket,
|
||||
objectKey: asset.previewObjectKey || asset.objectKey,
|
||||
cdnUrl: asset.previewUrl || asset.cdnUrl,
|
||||
fileName: asset.fileName || asset.title || 'preview.pdf',
|
||||
expiresInSec,
|
||||
disposition: 'inline',
|
||||
});
|
||||
await recordAssetAccessEvent({
|
||||
ctx,
|
||||
tenantId,
|
||||
assetId: asset.id,
|
||||
userId: access.userId || null,
|
||||
actorRole: access.userId ? 'student' : 'anonymous',
|
||||
accessType: 'preview',
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
storageProvider: asset.storageProvider,
|
||||
disposition: 'inline',
|
||||
expiresInSec: preview.expiresInSec,
|
||||
signatureMode: preview.signatureMode,
|
||||
result: 'granted',
|
||||
metadata: { signature: signedAssetFingerprint(preview) },
|
||||
});
|
||||
|
||||
return {
|
||||
item: {
|
||||
@@ -261,15 +385,6 @@ export async function assetPreviewRoute(ctx: RequestContext) {
|
||||
visibility: asset.visibility,
|
||||
},
|
||||
access,
|
||||
preview: await signStorageDownload({
|
||||
tenantId,
|
||||
provider: asset.storageProvider as StorageProviderName,
|
||||
bucket: asset.bucket,
|
||||
objectKey: asset.previewObjectKey || asset.objectKey,
|
||||
cdnUrl: asset.previewUrl || asset.cdnUrl,
|
||||
fileName: asset.fileName || asset.title || 'preview.pdf',
|
||||
expiresInSec: 900,
|
||||
disposition: 'inline',
|
||||
}),
|
||||
preview,
|
||||
};
|
||||
}
|
||||
|
||||
153
apps/api/src/features/storage/asset-access.ts
Normal file
153
apps/api/src/features/storage/asset-access.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { query } from '../../core/db.js';
|
||||
import type {
|
||||
SignedStorageUrl,
|
||||
StorageContentDisposition,
|
||||
StorageProviderName,
|
||||
} from './service.js';
|
||||
|
||||
export type AssetAccessType = 'download' | 'preview' | 'admin_download' | 'admin_preview' | 'upload_sign' | 'upload_confirm';
|
||||
export type AssetActorRole = 'anonymous' | 'student' | 'tenant_admin' | 'tenant_content_editor' | 'system';
|
||||
export type AssetAccessResult = 'granted' | 'denied';
|
||||
|
||||
export interface AssetAccessRecordInput {
|
||||
ctx: RequestContext;
|
||||
tenantId: string;
|
||||
assetId?: string | null;
|
||||
userId?: string | null;
|
||||
actorRole: AssetActorRole;
|
||||
accessType: AssetAccessType;
|
||||
visibility?: string | null;
|
||||
assetType?: string | null;
|
||||
storageProvider?: string | null;
|
||||
disposition?: StorageContentDisposition | null;
|
||||
expiresInSec?: number | null;
|
||||
signatureMode?: string | null;
|
||||
result: AssetAccessResult;
|
||||
denyCode?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AssetAccessPolicyInput {
|
||||
actorRole: AssetActorRole;
|
||||
accessType: AssetAccessType;
|
||||
visibility: string;
|
||||
assetType: string;
|
||||
disposition: StorageContentDisposition;
|
||||
requestedExpiresInSec?: number | null;
|
||||
}
|
||||
|
||||
export interface AssetCdnPolicyInput {
|
||||
assetId: string;
|
||||
visibility: string;
|
||||
assetType: string;
|
||||
storageProvider: StorageProviderName;
|
||||
objectKey?: string | null;
|
||||
cdnUrl?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
accessRules?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const LOCKED_VISIBILITIES = new Set(['members', 'svip', 'private']);
|
||||
const SHORT_LIVED_ASSET_TYPES = new Set(['video', 'package']);
|
||||
|
||||
function clientIpFrom(ctx: RequestContext) {
|
||||
return (getHeader(ctx.req, 'x-forwarded-for').split(',')[0] || getHeader(ctx.req, 'x-real-ip') || ctx.req.socket.remoteAddress || '').trim();
|
||||
}
|
||||
|
||||
function userAgentFrom(ctx: RequestContext) {
|
||||
return getHeader(ctx.req, 'user-agent');
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function boolFlag(...values: unknown[]) {
|
||||
return values.some(value => value === true || value === 'true');
|
||||
}
|
||||
|
||||
export function assetAccessTtl(input: AssetAccessPolicyInput) {
|
||||
const requested = input.requestedExpiresInSec ?? 900;
|
||||
const minTtl = 60;
|
||||
let maxTtl = 900;
|
||||
|
||||
if (input.actorRole === 'tenant_admin' || input.actorRole === 'tenant_content_editor') {
|
||||
maxTtl = input.accessType === 'admin_preview' ? 3600 : 86_400;
|
||||
} else if (input.disposition === 'inline') {
|
||||
maxTtl = 300;
|
||||
} else if (LOCKED_VISIBILITIES.has(input.visibility) || SHORT_LIVED_ASSET_TYPES.has(input.assetType)) {
|
||||
maxTtl = 300;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(requested, minTtl), maxTtl);
|
||||
}
|
||||
|
||||
export function assertCdnAccessAllowed(input: AssetCdnPolicyInput) {
|
||||
if (!input.cdnUrl) return;
|
||||
|
||||
const metadata = objectValue(input.metadata);
|
||||
const accessRules = objectValue(input.accessRules);
|
||||
const providerManagedAccess = boolFlag(
|
||||
metadata.providerManagedAccess,
|
||||
metadata.providerManagedCdn,
|
||||
metadata.cdnAccessMode === 'signed_by_provider',
|
||||
accessRules.providerManagedAccess,
|
||||
accessRules.providerManagedCdn,
|
||||
accessRules.cdnAccessMode === 'signed_by_provider',
|
||||
);
|
||||
|
||||
if (LOCKED_VISIBILITIES.has(input.visibility) && !providerManagedAccess) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
'Locked asset requires managed object signing or explicit provider-managed CDN access',
|
||||
'ASSET_CDN_ACCESS_NOT_ALLOWED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function signedAssetFingerprint(signed: SignedStorageUrl) {
|
||||
return {
|
||||
provider: signed.provider,
|
||||
bucket: signed.bucket,
|
||||
objectKey: signed.objectKey,
|
||||
method: signed.method,
|
||||
expiresAt: signed.expiresAt,
|
||||
expiresInSec: signed.expiresInSec,
|
||||
signatureMode: signed.signatureMode,
|
||||
};
|
||||
}
|
||||
|
||||
export async function recordAssetAccessEvent(input: AssetAccessRecordInput) {
|
||||
await query(
|
||||
`
|
||||
insert into public.content_asset_access_events (
|
||||
tenant_id, asset_id, user_id, actor_role, access_type, visibility,
|
||||
asset_type, storage_provider, disposition, expires_in_sec,
|
||||
signature_mode, result, deny_code, ip_address, user_agent, metadata
|
||||
)
|
||||
values (
|
||||
$1, $2::uuid, $3::uuid, $4, $5, $6, $7, $8, $9, $10,
|
||||
$11, $12, $13, $14, $15, $16::jsonb
|
||||
)
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.assetId || null,
|
||||
input.userId || null,
|
||||
input.actorRole,
|
||||
input.accessType,
|
||||
input.visibility || null,
|
||||
input.assetType || null,
|
||||
input.storageProvider || null,
|
||||
input.disposition || null,
|
||||
input.expiresInSec || null,
|
||||
input.signatureMode || null,
|
||||
input.result,
|
||||
input.denyCode || null,
|
||||
clientIpFrom(input.ctx) || null,
|
||||
userAgentFrom(input.ctx) || null,
|
||||
JSON.stringify(input.metadata || {}),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,12 @@ import {
|
||||
validateObjectKey,
|
||||
type StorageProviderName,
|
||||
} from '../storage/service.js';
|
||||
import {
|
||||
assetAccessTtl,
|
||||
assertCdnAccessAllowed,
|
||||
recordAssetAccessEvent,
|
||||
signedAssetFingerprint,
|
||||
} from '../storage/asset-access.js';
|
||||
|
||||
const ASSET_TYPES = ['pdf', 'video', 'image', 'audio', 'document', 'package', 'link', 'other'];
|
||||
const STORAGE_PROVIDERS = ['external_url', 'supabase_storage', 'aliyun_oss', 'tencent_cos', 'qiniu_kodo', 'local_dev'];
|
||||
@@ -47,6 +53,8 @@ interface AssetRow {
|
||||
verifiedSizeBytes: number | null;
|
||||
verifiedChecksumSha256: string | null;
|
||||
previewStatus: string;
|
||||
accessRules?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function choice(value: unknown, allowed: string[], fallback: string, label: string) {
|
||||
@@ -61,6 +69,12 @@ function nullableUuid(value: unknown) {
|
||||
return nullableString(value);
|
||||
}
|
||||
|
||||
function assertUuidParam(value: string, label: string) {
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
|
||||
throw new HttpError(400, `${label} must be a valid UUID`, 'INVALID_UUID');
|
||||
}
|
||||
}
|
||||
|
||||
function safeFileName(fileName: string) {
|
||||
return fileName
|
||||
.trim()
|
||||
@@ -173,6 +187,27 @@ async function recordAssetAudit(auth: TenantContentAuth, action: string, targetI
|
||||
);
|
||||
}
|
||||
|
||||
async function recordDeniedAdminAssetAccess(ctx: RequestContext, auth: TenantContentAuth, asset: AssetRow, accessType: 'admin_download' | 'admin_preview', error: unknown) {
|
||||
const denyCode = error instanceof HttpError ? error.code : 'ASSET_ACCESS_DENIED';
|
||||
await recordAssetAccessEvent({
|
||||
ctx,
|
||||
tenantId: auth.tenantId,
|
||||
assetId: asset.id,
|
||||
userId: auth.userId,
|
||||
actorRole: 'tenant_content_editor',
|
||||
accessType,
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
storageProvider: asset.storageProvider,
|
||||
result: 'denied',
|
||||
denyCode,
|
||||
metadata: {
|
||||
title: asset.title,
|
||||
fileName: asset.fileName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function assetsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
@@ -253,6 +288,40 @@ export async function assetsAdminRoute(ctx: RequestContext) {
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function assetAccessEventsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const assetId = stringParam(ctx, 'assetId');
|
||||
|
||||
const params: unknown[] = [auth.tenantId];
|
||||
const filters = ['tenant_id = $1'];
|
||||
if (assetId) {
|
||||
assertUuidParam(assetId, 'assetId');
|
||||
params.push(assetId);
|
||||
filters.push(`asset_id = $${params.length}::uuid`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, asset_id as "assetId", user_id as "userId",
|
||||
actor_role as "actorRole", access_type as "accessType",
|
||||
visibility, asset_type as "assetType",
|
||||
storage_provider as "storageProvider", disposition,
|
||||
expires_in_sec as "expiresInSec", signature_mode as "signatureMode",
|
||||
result, deny_code as "denyCode", ip_address as "ipAddress",
|
||||
user_agent as "userAgent", metadata, created_at as "createdAt"
|
||||
from public.content_asset_access_events
|
||||
where ${filters.join(' and ')}
|
||||
order by created_at desc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
@@ -562,6 +631,25 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
|
||||
`,
|
||||
[auth.tenantId, assetId, JSON.stringify(verificationDetails), auth.userId],
|
||||
);
|
||||
await recordAssetAccessEvent({
|
||||
ctx,
|
||||
tenantId: auth.tenantId,
|
||||
assetId,
|
||||
userId: auth.userId,
|
||||
actorRole: 'tenant_content_editor',
|
||||
accessType: 'upload_confirm',
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
storageProvider: asset.storageProvider,
|
||||
result: 'denied',
|
||||
denyCode: 'UPLOAD_VERIFICATION_FAILED',
|
||||
metadata: {
|
||||
provider,
|
||||
bucket: asset.bucket,
|
||||
objectKey: asset.objectKey,
|
||||
issues,
|
||||
},
|
||||
});
|
||||
throw new HttpError(409, `Upload verification failed: ${issues.join(', ')}`, 'UPLOAD_VERIFICATION_FAILED');
|
||||
}
|
||||
|
||||
@@ -611,6 +699,26 @@ export async function confirmAssetUploadRoute(ctx: RequestContext) {
|
||||
checksumVerified: verificationDetails.checksumVerified,
|
||||
checksumUnavailable: verificationDetails.checksumUnavailable,
|
||||
});
|
||||
await recordAssetAccessEvent({
|
||||
ctx,
|
||||
tenantId: auth.tenantId,
|
||||
assetId,
|
||||
userId: auth.userId,
|
||||
actorRole: 'tenant_content_editor',
|
||||
accessType: 'upload_confirm',
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
storageProvider: asset.storageProvider,
|
||||
result: 'granted',
|
||||
metadata: {
|
||||
provider,
|
||||
bucket: asset.bucket,
|
||||
objectKey: asset.objectKey,
|
||||
publish,
|
||||
checksumVerified: verificationDetails.checksumVerified,
|
||||
checksumUnavailable: verificationDetails.checksumUnavailable,
|
||||
},
|
||||
});
|
||||
|
||||
return { item, metadata, verification: verificationDetails };
|
||||
}
|
||||
@@ -651,6 +759,27 @@ export async function signAssetUploadRoute(ctx: RequestContext) {
|
||||
mimeType,
|
||||
fileSizeBytes,
|
||||
});
|
||||
await recordAssetAccessEvent({
|
||||
ctx,
|
||||
tenantId: auth.tenantId,
|
||||
userId: auth.userId,
|
||||
actorRole: 'tenant_content_editor',
|
||||
accessType: 'upload_sign',
|
||||
assetType,
|
||||
storageProvider,
|
||||
disposition: 'attachment',
|
||||
expiresInSec: upload.expiresInSec,
|
||||
signatureMode: upload.signatureMode,
|
||||
result: 'granted',
|
||||
metadata: {
|
||||
bucket,
|
||||
objectKey: cleanObjectKey,
|
||||
fileName,
|
||||
mimeType,
|
||||
fileSizeBytes,
|
||||
signature: signedAssetFingerprint(upload),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
upload,
|
||||
@@ -671,7 +800,7 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const assetId = requiredString(body, 'assetId');
|
||||
const expiresInSec = Math.min(Math.max(intValue(body.expiresInSec, 900), 60), 86_400);
|
||||
const requestedExpiresInSec = intValue(body.expiresInSec, 900);
|
||||
|
||||
const asset = await queryOne<AssetRow>(
|
||||
`
|
||||
@@ -684,7 +813,8 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
|
||||
visibility, status, upload_status as "uploadStatus",
|
||||
verified_size_bytes as "verifiedSizeBytes",
|
||||
verified_checksum_sha256 as "verifiedChecksumSha256",
|
||||
preview_status as "previewStatus"
|
||||
preview_status as "previewStatus",
|
||||
access_rules as "accessRules", metadata
|
||||
from public.content_assets
|
||||
where tenant_id = $1 and id = $2
|
||||
limit 1
|
||||
@@ -695,15 +825,36 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
|
||||
if (!asset) {
|
||||
throw new HttpError(404, 'Asset not found', 'ASSET_NOT_FOUND');
|
||||
}
|
||||
const expiresInSec = assetAccessTtl({
|
||||
actorRole: 'tenant_content_editor',
|
||||
accessType: 'admin_download',
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
disposition: 'attachment',
|
||||
requestedExpiresInSec,
|
||||
});
|
||||
try {
|
||||
assertCdnAccessAllowed({
|
||||
assetId,
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
storageProvider: asset.storageProvider as StorageProviderName,
|
||||
objectKey: asset.objectKey,
|
||||
cdnUrl: asset.cdnUrl,
|
||||
metadata: asset.metadata,
|
||||
accessRules: asset.accessRules,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordDeniedAdminAssetAccess(ctx, auth, asset, 'admin_download', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await query(
|
||||
'update public.content_assets set download_count = download_count + 1, updated_at = now() where tenant_id = $1 and id = $2',
|
||||
[auth.tenantId, assetId],
|
||||
);
|
||||
|
||||
return {
|
||||
item: asset,
|
||||
download: await signStorageDownload({
|
||||
const download = await signStorageDownload({
|
||||
tenantId: auth.tenantId,
|
||||
provider: asset.storageProvider as StorageProviderName,
|
||||
bucket: asset.bucket,
|
||||
@@ -712,15 +863,32 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
|
||||
fileName: asset.fileName,
|
||||
expiresInSec,
|
||||
disposition: 'attachment',
|
||||
}),
|
||||
};
|
||||
});
|
||||
await recordAssetAccessEvent({
|
||||
ctx,
|
||||
tenantId: auth.tenantId,
|
||||
assetId,
|
||||
userId: auth.userId,
|
||||
actorRole: 'tenant_content_editor',
|
||||
accessType: 'admin_download',
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
storageProvider: asset.storageProvider,
|
||||
disposition: 'attachment',
|
||||
expiresInSec: download.expiresInSec,
|
||||
signatureMode: download.signatureMode,
|
||||
result: 'granted',
|
||||
metadata: { signature: signedAssetFingerprint(download) },
|
||||
});
|
||||
|
||||
return { item: asset, download };
|
||||
}
|
||||
|
||||
export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const assetId = requiredString(body, 'assetId');
|
||||
const expiresInSec = Math.min(Math.max(intValue(body.expiresInSec, 900), 60), 3600);
|
||||
const requestedExpiresInSec = intValue(body.expiresInSec, 900);
|
||||
|
||||
const asset = await queryOne<AssetRow>(
|
||||
`
|
||||
@@ -732,7 +900,8 @@ export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
|
||||
visibility, status, upload_status as "uploadStatus",
|
||||
verified_size_bytes as "verifiedSizeBytes",
|
||||
verified_checksum_sha256 as "verifiedChecksumSha256",
|
||||
preview_status as "previewStatus"
|
||||
preview_status as "previewStatus",
|
||||
access_rules as "accessRules", metadata
|
||||
from public.content_assets
|
||||
where tenant_id = $1 and id = $2
|
||||
limit 1
|
||||
@@ -748,6 +917,56 @@ export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
|
||||
const objectKey = asset.previewObjectKey || asset.objectKey;
|
||||
const cdnUrl = asset.previewUrl || asset.cdnUrl;
|
||||
const fileName = asset.fileName || asset.title || 'preview.pdf';
|
||||
const expiresInSec = assetAccessTtl({
|
||||
actorRole: 'tenant_content_editor',
|
||||
accessType: 'admin_preview',
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
disposition: 'inline',
|
||||
requestedExpiresInSec,
|
||||
});
|
||||
try {
|
||||
assertCdnAccessAllowed({
|
||||
assetId,
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
storageProvider: asset.storageProvider as StorageProviderName,
|
||||
objectKey,
|
||||
cdnUrl,
|
||||
metadata: asset.metadata,
|
||||
accessRules: asset.accessRules,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordDeniedAdminAssetAccess(ctx, auth, asset, 'admin_preview', error);
|
||||
throw error;
|
||||
}
|
||||
const preview = await signStorageDownload({
|
||||
tenantId: auth.tenantId,
|
||||
provider: asset.storageProvider as StorageProviderName,
|
||||
bucket: asset.bucket,
|
||||
objectKey,
|
||||
cdnUrl,
|
||||
fileName,
|
||||
expiresInSec,
|
||||
disposition: 'inline',
|
||||
});
|
||||
|
||||
await recordAssetAccessEvent({
|
||||
ctx,
|
||||
tenantId: auth.tenantId,
|
||||
assetId,
|
||||
userId: auth.userId,
|
||||
actorRole: 'tenant_content_editor',
|
||||
accessType: 'admin_preview',
|
||||
visibility: asset.visibility,
|
||||
assetType: asset.assetType,
|
||||
storageProvider: asset.storageProvider,
|
||||
disposition: 'inline',
|
||||
expiresInSec: preview.expiresInSec,
|
||||
signatureMode: preview.signatureMode,
|
||||
result: 'granted',
|
||||
metadata: { signature: signedAssetFingerprint(preview) },
|
||||
});
|
||||
|
||||
return {
|
||||
item: {
|
||||
@@ -757,15 +976,6 @@ export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
|
||||
fileName: asset.fileName,
|
||||
previewStatus: asset.previewStatus,
|
||||
},
|
||||
preview: await signStorageDownload({
|
||||
tenantId: auth.tenantId,
|
||||
provider: asset.storageProvider as StorageProviderName,
|
||||
bucket: asset.bucket,
|
||||
objectKey,
|
||||
cdnUrl,
|
||||
fileName,
|
||||
expiresInSec,
|
||||
disposition: 'inline',
|
||||
}),
|
||||
preview,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import {
|
||||
assetAccessEventsAdminRoute,
|
||||
assetsAdminRoute,
|
||||
confirmAssetUploadRoute,
|
||||
signAssetDownloadAdminRoute,
|
||||
@@ -102,6 +103,7 @@ export const tenantContentRoutes: RouteDefinition[] = [
|
||||
['POST', '/api/tenant-content/questions', createQuestionRoute],
|
||||
['PATCH', '/api/tenant-content/questions', updateQuestionRoute],
|
||||
['GET', '/api/tenant-content/assets', assetsAdminRoute],
|
||||
['GET', '/api/tenant-content/assets/access-events', assetAccessEventsAdminRoute],
|
||||
['PUT', '/api/tenant-content/assets', upsertAssetRoute],
|
||||
['POST', '/api/tenant-content/assets/sign-upload', signAssetUploadRoute],
|
||||
['POST', '/api/tenant-content/assets/confirm-upload', confirmAssetUploadRoute],
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
} from '../../core/request.js';
|
||||
import { query, queryOne, transaction } from '../../core/db.js';
|
||||
import { signStorageDownload, type StorageProviderName } from '../storage/service.js';
|
||||
import {
|
||||
assetAccessTtl,
|
||||
assertCdnAccessAllowed,
|
||||
signedAssetFingerprint,
|
||||
} from '../storage/asset-access.js';
|
||||
|
||||
interface QuestionVideoRow {
|
||||
questionId: string;
|
||||
@@ -52,6 +57,11 @@ interface VideoPlaybackRow {
|
||||
fileName: string | null;
|
||||
assetTitle: string | null;
|
||||
assetStatus: string | null;
|
||||
assetType: string | null;
|
||||
assetVisibility: string | null;
|
||||
assetUploadStatus: string | null;
|
||||
assetAccessRules: Record<string, unknown> | null;
|
||||
assetMetadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface EntitlementAccessRow {
|
||||
@@ -150,6 +160,19 @@ async function signVideoPlayback(tenantId: string, video: VideoPlaybackRow) {
|
||||
if (video.assetStatus !== 'active') {
|
||||
throw new HttpError(404, 'Video asset is not active', 'VIDEO_ASSET_NOT_FOUND');
|
||||
}
|
||||
if (video.objectKey && video.assetUploadStatus !== 'verified') {
|
||||
throw new HttpError(409, 'Video asset upload has not been verified', 'VIDEO_ASSET_UPLOAD_NOT_VERIFIED');
|
||||
}
|
||||
assertCdnAccessAllowed({
|
||||
assetId: video.assetId,
|
||||
visibility: video.assetVisibility || 'svip',
|
||||
assetType: video.assetType || 'video',
|
||||
storageProvider: video.storageProvider as StorageProviderName,
|
||||
objectKey: video.objectKey,
|
||||
cdnUrl: video.cdnUrl,
|
||||
metadata: video.assetMetadata,
|
||||
accessRules: video.assetAccessRules,
|
||||
});
|
||||
return signStorageDownload({
|
||||
tenantId,
|
||||
provider: video.storageProvider as StorageProviderName,
|
||||
@@ -157,7 +180,14 @@ async function signVideoPlayback(tenantId: string, video: VideoPlaybackRow) {
|
||||
objectKey: video.objectKey,
|
||||
cdnUrl: video.cdnUrl,
|
||||
fileName: video.fileName || video.assetTitle || video.title,
|
||||
expiresInSec: 600,
|
||||
expiresInSec: assetAccessTtl({
|
||||
actorRole: 'student',
|
||||
accessType: 'download',
|
||||
visibility: video.assetVisibility || 'svip',
|
||||
assetType: video.assetType || 'video',
|
||||
disposition: 'attachment',
|
||||
requestedExpiresInSec: 600,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (isExternalPublicVideo(video)) {
|
||||
@@ -274,7 +304,10 @@ export async function videoPlaybackRoute(ctx: RequestContext) {
|
||||
v.asset_id as "assetId",
|
||||
a.storage_provider as "storageProvider", a.bucket, a.object_key as "objectKey",
|
||||
a.cdn_url as "cdnUrl", a.file_name as "fileName",
|
||||
a.title as "assetTitle", a.status as "assetStatus"
|
||||
a.title as "assetTitle", a.status as "assetStatus",
|
||||
a.asset_type as "assetType", a.visibility as "assetVisibility",
|
||||
a.upload_status as "assetUploadStatus",
|
||||
a.access_rules as "assetAccessRules", a.metadata as "assetMetadata"
|
||||
from public.video_explanations v
|
||||
left join public.content_assets a on a.tenant_id = v.tenant_id and a.id = v.asset_id
|
||||
left join public.subjects vs on vs.tenant_id = v.tenant_id and vs.id = v.subject_id
|
||||
@@ -359,7 +392,9 @@ export async function videoPlaybackRoute(ctx: RequestContext) {
|
||||
provider: signed.provider,
|
||||
bucket: signed.bucket,
|
||||
objectKey: signed.objectKey,
|
||||
assetId: video.assetId,
|
||||
freePreviewSeconds: video.freePreviewSeconds,
|
||||
signature: signedAssetFingerprint(signed),
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -91,14 +91,16 @@
|
||||
| --- | --- | --- |
|
||||
| 内容资源台账 | 可联调 | `content_assets` |
|
||||
| 租户后台资源维护 | 可联调 | `/api/tenant-content/assets` |
|
||||
| 学生端资源列表/下载签名 | 可联调 | `/api/catalog/assets`、`/api/catalog/assets/download` |
|
||||
| 学生端资源列表/下载签名 | 可联调 | `/api/catalog/assets`、`/api/catalog/assets/download`;学生端锁定资源使用短 TTL,访问事件写入 `content_asset_access_events` |
|
||||
| 阿里云 OSS 签名 | 可联调 | `aliyun_oss` provider |
|
||||
| 腾讯 COS 签名 | 可联调 | `tencent_cos` provider |
|
||||
| Supabase Storage 签名 | 可联调 | `supabase_storage` provider |
|
||||
| 上传后对象校验 | 可联调 | `/api/tenant-content/assets/confirm-upload`;托管对象必须 verified 后才能发布/下载 |
|
||||
| PDF/图片预览签名 | 可联调 | `/api/catalog/assets/preview`、`/api/tenant-content/assets/sign-preview`;使用 inline 短期签名 |
|
||||
| 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` |
|
||||
| 深度防盗链/水印/杀毒 | 待补齐 | 商用上线前继续补 CDN 防盗链、动态水印、安全扫描和对象生命周期策略 |
|
||||
| 资源访问审计 | 可联调 | `content_asset_access_events` + `GET /api/tenant-content/assets/access-events`;记录上传签名/确认、学生下载/预览、后台下载/预览的 granted/denied、TTL、签名模式、IP 和 UA |
|
||||
| CDN 访问边界 | 可联调 | `members/svip/private` 外部 CDN URL 默认拒绝,必须显式 `metadata.providerManagedAccess=true` 或 `cdnAccessMode=signed_by_provider`;视频绑定资源也复用该规则 |
|
||||
| 深度防盗链/水印/杀毒 | 待补齐 | 商用上线前继续补动态水印、安全扫描、CDN 刷新和对象生命周期策略 |
|
||||
|
||||
## 订单、会员、营销
|
||||
|
||||
|
||||
@@ -49,7 +49,9 @@
|
||||
- 已接阿里云 OSS、腾讯云 COS、Supabase Storage 的上传/下载签名 provider。
|
||||
- 已补上传后对象确认接口、托管对象发布前 verified 校验、PDF/图片 inline 预览签名。
|
||||
- 已补 assets worker 复检,异常托管对象会自动下架并记录审计。
|
||||
- 继续补视频深度防盗链、动态水印、杀毒扫描、CDN 刷新和对象生命周期策略。
|
||||
- 已补资源访问事件 `content_asset_access_events`,覆盖上传签名/确认、学生下载/预览、后台下载/预览的 granted/denied、短 TTL、签名模式、IP 和 UA。
|
||||
- 已收紧锁定资源 CDN 边界:`members/svip/private` 外链默认拒绝,必须显式 provider-managed 才允许;视频绑定资源也复用该策略。
|
||||
- 继续补动态水印、杀毒扫描、CDN 刷新和对象生命周期策略。
|
||||
- `content_assets` 继续作为资源台账,不允许前端绕过台账直接访问私有资源。
|
||||
|
||||
3. 真实导入 dry-run
|
||||
@@ -222,5 +224,5 @@
|
||||
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||
5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
|
||||
6. 并行补对象存储、真实登录、完整资金流水对账、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
|
||||
6. 并行补真实登录、完整资金流水对账、对象存储杀毒/水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
|
||||
7. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
||||
|
||||
@@ -52,6 +52,12 @@ GET /api/catalog/assets/preview?assetId=...
|
||||
POST /api/videos/play
|
||||
```
|
||||
|
||||
后台查看资源访问事件:
|
||||
|
||||
```text
|
||||
GET /api/tenant-content/assets/access-events?assetId=...
|
||||
```
|
||||
|
||||
后台管理员下载:
|
||||
|
||||
```text
|
||||
@@ -86,8 +92,9 @@ npm --workspace @tiku-saas/worker run exports:once
|
||||
4. 调用 `POST /api/tenant-content/assets/confirm-upload`,由后端读取对象元数据并比对大小、MIME、SHA-256。
|
||||
5. 校验通过且 `publish=true` 时,后端将资源置为 `status=active`、`uploadStatus=verified`。
|
||||
6. 学生端只能下载或预览 `active + verified` 的托管对象资源。
|
||||
7. 生产环境定时运行 assets worker,复检 `pending/verified` 托管对象的大小、MIME、SHA-256 等元数据。
|
||||
8. 如果复检发现对象丢失、跨租户 objectKey、大小/MIME/checksum 不一致,worker 会把资源置为 `uploadStatus=failed`,并将 `active` 资源退回 `draft`,同时写入 `security_flags.assetRecheckFailed=true` 和 `audit_logs`。
|
||||
7. 学生端和后台管理员下载/预览都会写入 `content_asset_access_events`,包含 `assetId/userId/accessType/result/expiresInSec/signatureMode/ip/userAgent` 等审计字段。
|
||||
8. 生产环境定时运行 assets worker,复检 `pending/verified` 托管对象的大小、MIME、SHA-256 等元数据。
|
||||
9. 如果复检发现对象丢失、跨租户 objectKey、大小/MIME/checksum 不一致,worker 会把资源置为 `uploadStatus=failed`,并将 `active` 资源退回 `draft`,同时写入 `security_flags.assetRecheckFailed=true` 和 `audit_logs`。
|
||||
|
||||
## 题库导出生成资源
|
||||
|
||||
@@ -131,11 +138,45 @@ npm --workspace @tiku-saas/worker run exports:once
|
||||
- 上传会校验 MIME 类型和文件大小。
|
||||
- 托管对象资源未确认前不能发布为 `active`,学生端不可下载。
|
||||
- PDF/图片预览使用 `inline` 签名,不等同于长期公开 URL。
|
||||
- 下载和视频播放必须先经过 API 权限判断,再下发短期签名 URL。
|
||||
- 下载、预览和视频播放必须先经过 API 权限判断,再下发短期签名 URL。
|
||||
- 学生端 `preview`、锁定资料下载、视频和资料包默认使用更短 TTL。当前策略是学生 inline 预览、`members/svip/private` 资料、`video/package` 资源最多 300 秒;租户后台预览最多 3600 秒,后台下载最多 86400 秒。
|
||||
- `visibility in ('members','svip','private')` 的外部 CDN/直链资源默认会被拒绝,除非资源 `metadata.providerManagedAccess=true` 或 `metadata.cdnAccessMode='signed_by_provider'`。商用建议这类资源优先登记为 `objectKey`,由 API 生成 OSS/COS/Supabase Storage 私有签名 URL。
|
||||
- 每次上传签名、上传确认、学生下载/预览、后台下载/预览都会写入 `content_asset_access_events`。授权失败也会记录 `result=denied` 和 `denyCode`,用于租户后台排查资源访问问题。
|
||||
- 云厂商 AccessKey、SecretKey、Service Role Key 只存在服务端环境变量,不返回前端。
|
||||
- `content_assets` 是资源唯一台账,前端不得绕过台账直接访问私有 bucket。
|
||||
- 前端不能把 `uploadStatus=failed` 或 `status=draft` 的资源继续展示为可下载;列表仍返回时应展示“资料处理中”或“资源异常已下架”,真正下载/预览会被后端拒绝。
|
||||
|
||||
## 访问事件字段
|
||||
|
||||
资源访问事件表:
|
||||
|
||||
```text
|
||||
public.content_asset_access_events
|
||||
```
|
||||
|
||||
关键字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `tenant_id` | 租户隔离维度 |
|
||||
| `asset_id` | 资源 ID,上传签名阶段可能为空 |
|
||||
| `user_id` | 学生或后台操作者 |
|
||||
| `actor_role` | `anonymous/student/tenant_content_editor/tenant_admin/system` |
|
||||
| `access_type` | `download/preview/admin_download/admin_preview/upload_sign/upload_confirm` |
|
||||
| `result` | `granted` 或 `denied` |
|
||||
| `deny_code` | 拒绝原因,例如 `ASSET_CDN_ACCESS_NOT_ALLOWED` |
|
||||
| `expires_in_sec` | 下发签名有效期 |
|
||||
| `signature_mode` | `local-placeholder`、`supabase-storage-signed-url`、`aliyun-oss-signature-url-v1`、`tencent-cos-signature-url-v5`、`public-or-provider-managed` |
|
||||
| `metadata` | 签名摘要、对象位置、文件名等排查信息;不保存云厂商密钥 |
|
||||
|
||||
后台接口:
|
||||
|
||||
```text
|
||||
GET /api/tenant-content/assets/access-events?assetId=<assetId>&limit=100
|
||||
```
|
||||
|
||||
前端只用于后台审计和排查,不要把 `content_asset_access_events` 当成学生端下载列表来源。
|
||||
|
||||
## 环境变量
|
||||
|
||||
通用:
|
||||
@@ -198,6 +239,6 @@ WORKER_ASSET_REQUEST_TIMEOUT_MS=10000
|
||||
|
||||
## 官方依据
|
||||
|
||||
- 阿里云 OSS Node.js SDK 支持通过 `signatureUrl` 为上传/下载生成带过期时间的签名 URL,并可通过对象 HEAD 读取元数据。
|
||||
- 腾讯云 COS XML API V5 签名由 `q-sign-algorithm`、`q-ak`、`q-sign-time`、`q-key-time`、`q-header-list`、`q-url-param-list`、`q-signature` 等字段组成,可用于预签名 URL 和 HEAD Object。
|
||||
- Supabase Storage 提供 `createSignedUploadUrl` 和 `createSignedUrl`,分别用于签名上传和签名下载;私有 bucket 仍应配合 RLS、服务端权限控制和资源台账。
|
||||
- Supabase Storage JavaScript `createSignedUrl` 用于为文件创建固定有效期的签名 URL,并要求对象具备 `select` 权限;本项目由服务端集中处理权限与签名。
|
||||
- 阿里云 OSS 官方文档建议服务端生成 signed URL 后让客户端直传对象,也说明私有对象可通过 presigned URL 在有效期内授权下载或预览。
|
||||
- 腾讯云 COS 文档给出的签名 URL 格式包含 `q-sign-algorithm`、`q-ak`、`q-sign-time`、`q-key-time`、`q-header-list`、`q-url-param-list`、`q-signature` 等字段;本项目服务端生成签名,不把 SecretKey 下发前端。
|
||||
|
||||
@@ -270,6 +270,37 @@ tenant:<tenantId>:theme
|
||||
| 公共题库采纳/同步 | `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` |
|
||||
|
||||
## 资料、PDF 和视频资源契约
|
||||
|
||||
前端必须把 `content_assets` 当成资源唯一台账。学生端资料、PDF 预览和题目视频播放都不能直接拼接私有 OSS/COS/Supabase Storage URL,也不能把后台配置的 `cdnUrl` 持久缓存成长期可访问地址。
|
||||
|
||||
学生端资料流程:
|
||||
|
||||
1. 列表页调用 `GET /api/catalog/assets`,只展示后端返回的 active 资源。
|
||||
2. 预览 PDF/图片时调用 `GET /api/catalog/assets/preview?assetId=...`。
|
||||
3. 下载资料时调用 `GET /api/catalog/assets/download?assetId=...`。
|
||||
4. 使用响应里的 `preview.url` 或 `download.url` 立即打开;不要写入本地长期缓存。
|
||||
|
||||
签名有效期规则:
|
||||
|
||||
- 学生 inline 预览、SVIP/会员资料、视频和资料包通常只有 300 秒左右有效期。
|
||||
- 后台预览有效期也不是永久 URL,租户后台应在用户点击时重新请求签名。
|
||||
- 响应里的 `expiresInSec/expiresAt/signatureMode` 只用于 UI 提示和排查,不要自行延长有效期。
|
||||
|
||||
锁定资源 CDN 规则:
|
||||
|
||||
- `visibility=members/svip/private` 的外部 `cdnUrl` 默认会被后端拒绝,返回 `ASSET_CDN_ACCESS_NOT_ALLOWED`。
|
||||
- 只有后台明确登记 `metadata.providerManagedAccess=true` 或 `metadata.cdnAccessMode='signed_by_provider'`,后端才允许把外部 URL 作为 provider-managed 资源返回。
|
||||
- 商用环境更推荐把锁定资料登记为 `objectKey`,由后端生成 OSS/COS/Supabase Storage 私有签名 URL。
|
||||
|
||||
租户后台排查:
|
||||
|
||||
```text
|
||||
GET /api/tenant-content/assets/access-events?assetId=<assetId>&limit=100
|
||||
```
|
||||
|
||||
该接口返回资源访问事件,包括学生下载、学生预览、后台下载、后台预览、上传签名、上传确认以及 denied 原因。租户后台可以在资源详情页增加“访问记录/异常记录”面板。
|
||||
|
||||
## 练习访问控制契约
|
||||
|
||||
前端不要先拉完整题目列表再自行判断免费额度。用户点击顺序刷题、随机刷题、全真模拟时,统一调用 `POST /api/learning/practice-sessions`,后端会根据 `content_entries.accessRules`、`content_nodes.accessRules`、`question_collections.accessRules`、`practice_blueprints.accessRules` 和当前用户权益决定最终题目快照。
|
||||
|
||||
@@ -3148,11 +3148,29 @@ async function testTenantContentAssetsAndImports() {
|
||||
regionId: ids.region,
|
||||
subjectId: ids.subject,
|
||||
categoryId: ids.category,
|
||||
metadata: { source: 'api-integration' },
|
||||
metadata: { source: 'api-integration', providerManagedAccess: true },
|
||||
},
|
||||
});
|
||||
assert.equal(asset.item?.visibility, 'svip', 'tenant admin should create svip asset');
|
||||
|
||||
const lockedCdnAsset = await request('/api/tenant-content/assets', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
title: '集成测试未授权 CDN SVIP 资料',
|
||||
assetType: 'pdf',
|
||||
storageProvider: 'external_url',
|
||||
cdnUrl: 'https://example.test/resources/locked-without-provider-managed.pdf',
|
||||
fileName: 'locked-without-provider-managed.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
visibility: 'svip',
|
||||
regionId: ids.region,
|
||||
subjectId: ids.subject,
|
||||
categoryId: ids.category,
|
||||
metadata: { source: 'api-integration' },
|
||||
},
|
||||
});
|
||||
|
||||
const adminAssets = await request('/api/tenant-content/assets', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { assetType: 'pdf', regionId: ids.region },
|
||||
@@ -3178,6 +3196,31 @@ async function testTenantContentAssetsAndImports() {
|
||||
assert.equal(download.access?.svip, true, 'asset download should report svip access');
|
||||
assert.equal(download.download?.url, 'https://example.test/resources/integration.pdf', 'external asset download should use cdn url');
|
||||
assert.equal(download.download?.signatureMode, 'public-or-provider-managed', 'external asset should be marked provider managed');
|
||||
assert.equal(download.download?.expiresInSec, 300, 'locked student asset downloads should use short TTL');
|
||||
|
||||
const deniedLockedCdn = await request('/api/catalog/assets/download', {
|
||||
query: { assetId: lockedCdnAsset.item.id },
|
||||
expectStatus: 409,
|
||||
});
|
||||
assert.equal(deniedLockedCdn.code, 'ASSET_CDN_ACCESS_NOT_ALLOWED', 'locked cdn asset should require explicit provider-managed access');
|
||||
|
||||
const accessEventsAfterLockedDeny = await request('/api/tenant-content/assets/access-events', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { assetId: lockedCdnAsset.item.id },
|
||||
});
|
||||
assert.ok(
|
||||
accessEventsAfterLockedDeny.items?.some(item => item.result === 'denied' && item.denyCode === 'ASSET_CDN_ACCESS_NOT_ALLOWED'),
|
||||
'locked cdn denial should be recorded as an asset access event',
|
||||
);
|
||||
|
||||
const accessEventsAfterDownload = await request('/api/tenant-content/assets/access-events', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { assetId: asset.item.id },
|
||||
});
|
||||
assert.ok(
|
||||
accessEventsAfterDownload.items?.some(item => item.accessType === 'download' && item.result === 'granted'),
|
||||
'successful student asset download should be recorded as an access event',
|
||||
);
|
||||
|
||||
const localAsset = await request('/api/tenant-content/assets', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
@@ -3233,6 +3276,7 @@ async function testTenantContentAssetsAndImports() {
|
||||
assert.equal(localPreview.preview?.method, 'GET', 'asset preview should sign GET');
|
||||
assert.equal(localPreview.preview?.signatureMode, 'local-placeholder', 'local asset preview should use local placeholder signer');
|
||||
assert.ok(localPreview.preview?.url?.includes('disposition=inline'), 'asset preview should request inline disposition');
|
||||
assert.equal(localPreview.preview?.expiresInSec, 300, 'student inline previews should use short TTL');
|
||||
|
||||
const adminPreview = await request('/api/tenant-content/assets/sign-preview', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
@@ -3241,6 +3285,15 @@ async function testTenantContentAssetsAndImports() {
|
||||
});
|
||||
assert.ok(adminPreview.preview?.url?.includes('disposition=inline'), 'admin preview should request inline disposition');
|
||||
|
||||
const localAccessEvents = await request('/api/tenant-content/assets/access-events', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { assetId: localAsset.item.id },
|
||||
});
|
||||
assert.ok(
|
||||
localAccessEvents.items?.some(item => item.accessType === 'preview' && item.expiresInSec === 300),
|
||||
'student preview access event should include short TTL',
|
||||
);
|
||||
|
||||
const failedAsset = await request('/api/tenant-content/assets', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'PUT',
|
||||
|
||||
@@ -1354,22 +1354,30 @@ async function main() {
|
||||
`
|
||||
insert into public.content_assets (
|
||||
id, tenant_id, asset_key, title, asset_type, storage_provider,
|
||||
bucket, object_key, file_name, mime_type, visibility, status, source
|
||||
bucket, object_key, file_name, mime_type, file_size_bytes,
|
||||
checksum_sha256, visibility, status, upload_status, verified_at,
|
||||
verified_size_bytes, verified_checksum_sha256, source
|
||||
)
|
||||
values (
|
||||
$1, $2, 'smoke-video-asset', '烟测视频对象', 'video', 'local_dev',
|
||||
'tenant-assets', $3, 'smoke.mp4', 'video/mp4',
|
||||
'svip', 'active', 'smoke-seed'
|
||||
'tenant-assets', $3, 'smoke.mp4', 'video/mp4', 8192,
|
||||
$4, 'svip', 'active', 'verified', now(), 8192, $4, 'smoke-seed'
|
||||
)
|
||||
on conflict (id)
|
||||
do update set storage_provider = excluded.storage_provider,
|
||||
bucket = excluded.bucket,
|
||||
object_key = excluded.object_key,
|
||||
file_size_bytes = excluded.file_size_bytes,
|
||||
checksum_sha256 = excluded.checksum_sha256,
|
||||
visibility = excluded.visibility,
|
||||
status = 'active',
|
||||
upload_status = 'verified',
|
||||
verified_at = coalesce(public.content_assets.verified_at, now()),
|
||||
verified_size_bytes = excluded.verified_size_bytes,
|
||||
verified_checksum_sha256 = excluded.verified_checksum_sha256,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.videoAsset, tenantId, `${tenantId}/videos/smoke.mp4`],
|
||||
[ids.videoAsset, tenantId, `${tenantId}/videos/smoke.mp4`, 'c'.repeat(64)],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
create table if not exists public.content_asset_access_events (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
asset_id uuid references public.content_assets(id) on delete set null,
|
||||
user_id uuid references public.platform_users(id) on delete set null,
|
||||
actor_role text not null default 'anonymous'
|
||||
check (actor_role in ('anonymous', 'student', 'tenant_admin', 'tenant_content_editor', 'system')),
|
||||
access_type text not null
|
||||
check (access_type in ('download', 'preview', 'admin_download', 'admin_preview', 'upload_sign', 'upload_confirm')),
|
||||
visibility text,
|
||||
asset_type text,
|
||||
storage_provider text,
|
||||
disposition text check (disposition is null or disposition in ('attachment', 'inline')),
|
||||
expires_in_sec integer check (expires_in_sec is null or expires_in_sec > 0),
|
||||
signature_mode text,
|
||||
result text not null check (result in ('granted', 'denied')),
|
||||
deny_code text,
|
||||
ip_address text,
|
||||
user_agent text,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
comment on table public.content_asset_access_events is
|
||||
'短期资源签名与资源访问授权事件。用于租户隔离审计、资料下载追踪、异常 CDN/私有资源排查。';
|
||||
|
||||
create index if not exists idx_asset_access_events_asset
|
||||
on public.content_asset_access_events(tenant_id, asset_id, created_at desc);
|
||||
|
||||
create index if not exists idx_asset_access_events_user
|
||||
on public.content_asset_access_events(tenant_id, user_id, created_at desc)
|
||||
where user_id is not null;
|
||||
|
||||
create index if not exists idx_asset_access_events_type
|
||||
on public.content_asset_access_events(tenant_id, access_type, result, created_at desc);
|
||||
|
||||
alter table public.content_asset_access_events enable row level security;
|
||||
|
||||
drop policy if exists tenant_isolation on public.content_asset_access_events;
|
||||
create policy tenant_isolation on public.content_asset_access_events
|
||||
for all
|
||||
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
|
||||
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
|
||||
Reference in New Issue
Block a user