forked from wangziqi/gongxue-base
371 lines
17 KiB
Markdown
371 lines
17 KiB
Markdown
# 对象存储接入说明
|
||
|
||
更新时间:2026-06-29
|
||
|
||
## 目标
|
||
|
||
题库里的图片、PDF、视频、音频、资料包等媒体资源统一走 `content_assets` 台账和后端签名接口。前端不直接保存或读取云厂商密钥,也不直接拼接私有资源 URL。题目视频播放还需要经过 `POST /api/videos/play` 校验 SVIP 或视频次数权益后下发短期签名 URL。
|
||
|
||
生产上线验收请按完整 runbook 执行,包含 API/worker production fail-fast、readiness、云控制台配置、上传确认、安全扫描、短签名、水印、跨租户和导出资源抽样:
|
||
|
||
```text
|
||
docs/refactor/object-storage-production-runbook.md
|
||
```
|
||
|
||
已接入的 provider:
|
||
|
||
- `local_dev`:本地开发占位签名,便于前后端联调。
|
||
- `aliyun_oss`:阿里云 OSS 官方 Node.js SDK `signatureUrl`。
|
||
- `tencent_cos`:腾讯云 COS XML API V5 签名 URL,服务端用 Node `crypto` 实现,避免引入当前 COS Node SDK 的高危依赖。
|
||
- `supabase_storage`:Supabase Storage 官方 `createSignedUploadUrl` / `createSignedUrl` 语义。
|
||
- `external_url`:外部公开或厂商托管 URL,只允许作为已管理资源的下载地址,不支持后端直传签名。
|
||
|
||
## API
|
||
|
||
后台申请上传签名:
|
||
|
||
```text
|
||
POST /api/tenant-content/assets/sign-upload
|
||
```
|
||
|
||
后台登记资源草稿:
|
||
|
||
```text
|
||
PUT /api/tenant-content/assets
|
||
```
|
||
|
||
后台确认上传:
|
||
|
||
```text
|
||
POST /api/tenant-content/assets/confirm-upload
|
||
```
|
||
|
||
学生端下载:
|
||
|
||
```text
|
||
GET /api/catalog/assets/download?assetId=...
|
||
```
|
||
|
||
学生端预览:
|
||
|
||
```text
|
||
GET /api/catalog/assets/preview?assetId=...
|
||
```
|
||
|
||
学生端视频播放:
|
||
|
||
```text
|
||
POST /api/videos/play
|
||
```
|
||
|
||
后台查看资源访问事件:
|
||
|
||
```text
|
||
GET /api/tenant-content/assets/access-events?assetId=...
|
||
```
|
||
|
||
后台查看资源安全扫描事件:
|
||
|
||
```text
|
||
GET /api/tenant-content/assets/security-scan-events?assetId=...
|
||
```
|
||
|
||
后台管理员下载:
|
||
|
||
```text
|
||
POST /api/tenant-content/assets/sign-download
|
||
```
|
||
|
||
后台管理员预览:
|
||
|
||
```text
|
||
POST /api/tenant-content/assets/sign-preview
|
||
```
|
||
|
||
后台资源复检 worker:
|
||
|
||
```bash
|
||
npm --workspace @tiku-saas/worker run assets:once
|
||
```
|
||
|
||
题库 PDF/Word/每日一练 ZIP 导出 worker:
|
||
|
||
```bash
|
||
npm --workspace @tiku-saas/worker run exports:once
|
||
```
|
||
|
||
## 标准上传流程
|
||
|
||
后台前端上传 PDF、图片、视频或资料包时必须走下面流程:
|
||
|
||
1. 调用 `POST /api/tenant-content/assets/sign-upload` 申请短期上传 URL。
|
||
2. 前端使用返回的 `upload.url` 和 `upload.headers` 直传对象存储。
|
||
3. 调用 `PUT /api/tenant-content/assets` 登记资源台账。托管对象默认进入 `status=draft`、`uploadStatus=pending`。
|
||
4. 调用 `POST /api/tenant-content/assets/confirm-upload`,由后端读取对象元数据并比对大小、MIME、SHA-256。
|
||
5. 校验通过后,后端只会置为 `uploadStatus=verified`、`securityScanStatus=pending`,并继续保持 `status=draft`。即使请求里传 `publish=true`,也不会绕过安全扫描直接发布。
|
||
6. 运行 assets worker。worker 会先复检对象元数据,再执行内置 `metadata_rules` 安全扫描,检查 object key、MIME allowlist、文件大小、扩展名/MIME 是否匹配等规则;生产可配置 `WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http`,在内置规则通过后调用外部 HTTP 杀毒/内容安全服务。
|
||
7. 扫描通过后,资源变为 `securityScanStatus=passed`,后台再调用 `PUT /api/tenant-content/assets` 将 `status=active` 发布。
|
||
8. 学生端只能下载或预览 `active + uploadStatus=verified + securityScanStatus=passed` 的托管对象资源;后台管理员下载/预览也执行同一安全扫描门禁。
|
||
9. 学生端和后台管理员下载/预览都会写入 `content_asset_access_events`,包含 `assetId/userId/accessType/result/expiresInSec/signatureMode/ip/userAgent` 和动态水印 `traceId` 等审计字段。
|
||
10. 生产环境定时运行 assets worker,复检 `pending/verified` 托管对象的大小、MIME、SHA-256 等元数据。
|
||
11. 如果复检发现对象丢失、跨租户 objectKey、大小/MIME/checksum 不一致,worker 会把资源置为 `uploadStatus=failed`、`securityScanStatus=skipped`,并将 `active` 资源退回 `draft`,同时写入 `security_flags.assetRecheckFailed=true`、`content_asset_security_scan_events` 和 `audit_logs`。
|
||
|
||
## 题库导出生成资源
|
||
|
||
题库导出 PDF/Word/每日一练 ZIP 不走前端上传,而是由后端和 worker 自动进入资源台账:
|
||
|
||
1. 租户后台调用 `POST /api/tenant-content/exports/questions`,`format=pdf`、`format=docx` 或 `format=daily_practice_zip`。
|
||
2. API 校验租户内容权限、导出范围和答案/解析开关,创建 `content_export_jobs.status=pending`。
|
||
3. `apps/worker --job exports` 抢占 pending job,复用后端导出 payload 规则渲染 PDF/Word,或为每日一练生成 ZIP 素材包;PDF/Word 会应用 `options.watermarkText` 水印,ZIP 包会包含 PNG/SVG 卡片、拼图、manifest 和脱敏 payload。
|
||
4. worker 将二进制文件写入配置的对象存储。`local_dev` 会写入 `EXPORT_LOCAL_STORAGE_ROOT`,生产建议使用 `supabase_storage`、`aliyun_oss` 或 `tencent_cos`。
|
||
5. worker 创建 `content_assets`,设置 `uploadStatus=verified`、`securityScanStatus=passed`、`securityScanProvider=trusted_export_worker`,并把 `assetId`、文件名、大小和 SHA-256 回填到 `content_export_jobs.output_metadata`。
|
||
6. 前端轮询 `GET /api/tenant-content/exports/jobs`,拿到 `assetId` 后通过 `POST /api/tenant-content/assets/sign-download` 或 `sign-preview` 下载/预览。
|
||
|
||
注意:PDF/Word/ZIP 文件本身不直接存入数据库,数据库只保存 job、资源台账和校验 metadata。`daily_practice_zip` 资源类型为 `package`,通常只做签名下载,不做 inline 预览。
|
||
|
||
确认上传示例:
|
||
|
||
```json
|
||
{
|
||
"assetId": "00000000-0000-0000-0000-000000000000",
|
||
"fileSizeBytes": 4096,
|
||
"mimeType": "application/pdf",
|
||
"checksumSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||
"publish": true
|
||
}
|
||
```
|
||
|
||
注意:
|
||
|
||
- `local_dev` 用声明的元数据模拟对象 HEAD,方便本地和集成测试。
|
||
- `aliyun_oss` 使用 OSS 对象 HEAD 元数据。
|
||
- `tencent_cos` 使用 COS HEAD Object 预签名请求读取元数据。
|
||
- `supabase_storage` 使用 Storage list metadata 做最小存在性/大小校验。
|
||
- 如果 provider 无法返回 SHA-256,后端会记录 `checksumUnavailable=true`,生产建议上传时写入对象自定义元数据,例如 `x-oss-meta-sha256` 或 `x-cos-meta-sha256`。
|
||
- worker 的复检证据写入 `verification_details.assetWorker`,包含 `lastCheckedAt`、`lastResult`、`expected`、`observed`、`issues`,便于租户后台定位资源异常。
|
||
- `publish=true` 只作为迁移期兼容参数保留。确认上传成功后仍然会保持草稿,必须等 `securityScanStatus=passed` 后再由后台显式发布。
|
||
- 新上传托管对象常见状态流为:`draft/pending/pending` -> `draft/verified/pending` -> `draft/verified/passed` -> `active/verified/passed`。
|
||
|
||
## 安全规则
|
||
|
||
- 只有租户内容维护权限用户可以申请上传签名。
|
||
- `objectKey` 默认必须以当前 `tenantId/` 开头,防止跨租户覆盖或读取。
|
||
- 禁止 `..`、反斜杠、编码斜杠等危险 object key。
|
||
- 上传会校验 MIME 类型和文件大小。
|
||
- 托管对象资源未确认或安全扫描未通过前不能发布为 `active`,学生端不可下载。
|
||
- 托管对象安全扫描失败会返回 `ASSET_SECURITY_SCAN_FAILED`;仍在等待扫描或扫描被跳过会返回 `ASSET_SECURITY_SCAN_REQUIRED`。
|
||
- PDF/图片预览使用 `inline` 签名,不等同于长期公开 URL。
|
||
- 下载、预览和视频播放必须先经过 API 权限判断,再下发短期签名 URL。
|
||
- 下载、预览和视频播放响应会返回 `watermark` 上下文;前端必须按 `mode=visible_overlay` 渲染可见覆盖水印,不能隐藏 `traceId`。
|
||
- 学生端 `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`、`securityScanStatus!=passed` 或 `status=draft` 的资源继续展示为可下载;列表仍返回时应展示“资料处理中”“安全扫描中”或“资源异常已下架”,真正下载/预览会被后端拒绝。
|
||
|
||
## 动态水印上下文
|
||
|
||
学生端资料下载、PDF/图片预览、题目视频播放,以及租户后台下载/预览都会返回统一的 `watermark` 对象:
|
||
|
||
```json
|
||
{
|
||
"mode": "visible_overlay",
|
||
"required": true,
|
||
"text": "仅限本人学习 账号:AB12CD34 7D2A9C3E1B0F",
|
||
"traceId": "7D2A9C3E1B0F",
|
||
"position": "diagonal",
|
||
"opacity": 0.16,
|
||
"repeat": true,
|
||
"expiresAt": "2026-06-29T10:00:00.000Z",
|
||
"renderHint": "render_visible_overlay_before_opening_signed_url"
|
||
}
|
||
```
|
||
|
||
规则:
|
||
|
||
- `members/svip/private` 资源、视频和资料包强制返回可见水印;metadata 不能关闭。
|
||
- 公开资源可通过安全 metadata 关闭水印,但后端权限仍然是最终判断。
|
||
- 水印文本包含账号哈希短码和 `traceId`,用于截图或录屏外泄后的访问事件回查。
|
||
- `content_asset_access_events.metadata.watermark.traceId` 会记录资料下载/预览水印;视频播放会记录到 `video_play_events.metadata.watermark.traceId`。
|
||
- 当前阶段是前端可见覆盖层水印。生产后续可继续把同一 `traceId` 接入 CDN 鉴权、视频转码水印或服务端 PDF 二次渲染。
|
||
|
||
## 安全扫描字段
|
||
|
||
资源台账 `content_assets` 新增安全扫描状态:
|
||
|
||
| 字段 | 说明 |
|
||
| --- | --- |
|
||
| `security_scan_status` | `not_required/pending/scanning/passed/failed/skipped` |
|
||
| `security_scanned_at` | 最近一次扫描完成时间 |
|
||
| `security_scan_provider` | 扫描来源,例如 `metadata_rules`、`trusted_export_worker` |
|
||
| `security_scan_summary` | 风险等级、问题码、扫描证据和失败原因 |
|
||
|
||
内置 provider 是 `metadata_rules`。它不是完整杀毒引擎,但能阻断明显危险或不合规对象:跨租户 key、非法 object key、超限文件、MIME allowlist 外文件、扩展名/MIME 不匹配,以及测试/运营标记的强制失败。
|
||
|
||
生产环境支持可插拔 HTTP provider:`WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http`。worker 会在 metadata 规则通过后调用外部扫描服务,并把外部扫描结果和内置规则合并为一次最终结果写回 `content_assets`。如果外部服务返回失败、响应无效、超时或不可用,默认 `fail-open=false`,资源会被标记为 `securityScanStatus=failed` 并从 `active` 退回 `draft`。生产 readiness 会阻断没有外部 scanner、HTTP endpoint 非 HTTPS、token 弱或开启 fail-open 的配置。
|
||
|
||
HTTP scanner 请求由 worker 发起,前端不会接触扫描服务地址、token 或对象存储密钥。请求示例:
|
||
|
||
```json
|
||
{
|
||
"assetId": "00000000-0000-0000-0000-000000000000",
|
||
"tenantId": "00000000-0000-0000-0000-000000000001",
|
||
"assetType": "pdf",
|
||
"storageProvider": "aliyun_oss",
|
||
"bucket": "tenant-assets",
|
||
"objectKey": "tenant-id/assets/file.pdf",
|
||
"fileName": "file.pdf",
|
||
"mimeType": "application/pdf",
|
||
"fileSizeBytes": 4096,
|
||
"checksumSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||
"metadata": {
|
||
"observed": {},
|
||
"declared": {}
|
||
},
|
||
"requestedAt": "2026-06-29T00:00:00.000Z"
|
||
}
|
||
```
|
||
|
||
HTTP scanner 响应契约:
|
||
|
||
```json
|
||
{
|
||
"status": "passed",
|
||
"riskLevel": "none",
|
||
"issueCodes": [],
|
||
"provider": "clamav",
|
||
"details": {
|
||
"engine": "clamav",
|
||
"signature": ""
|
||
}
|
||
}
|
||
```
|
||
|
||
`status` 只允许 `passed` 或 `failed`;`riskLevel` 只允许 `none/low/medium/high/critical`。`details` 会经过脱敏后写入扫描事件,`secret/token/password/privateKey/apiKey/authorization` 等字段会被替换为 `[redacted]`。
|
||
|
||
扫描事件表:
|
||
|
||
```text
|
||
public.content_asset_security_scan_events
|
||
```
|
||
|
||
后台接口:
|
||
|
||
```text
|
||
GET /api/tenant-content/assets/security-scan-events?assetId=<assetId>&limit=100
|
||
```
|
||
|
||
事件字段:
|
||
|
||
| 字段 | 说明 |
|
||
| --- | --- |
|
||
| `asset_id` | 资源 ID |
|
||
| `provider` | 扫描 provider |
|
||
| `scan_status` | `pending/scanning/passed/failed/skipped` |
|
||
| `risk_level` | `none/low/medium/high/critical` |
|
||
| `issue_codes` | 问题码数组,例如 `file_extension_mime_mismatch` |
|
||
| `details` | observed/declared metadata、扩展名、错误信息等证据 |
|
||
|
||
## 访问事件字段
|
||
|
||
资源访问事件表:
|
||
|
||
```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` | 签名摘要、对象位置、文件名、水印 traceId 等排查信息;不保存云厂商密钥 |
|
||
|
||
后台接口:
|
||
|
||
```text
|
||
GET /api/tenant-content/assets/access-events?assetId=<assetId>&limit=100
|
||
```
|
||
|
||
前端只用于后台审计和排查,不要把 `content_asset_access_events` 当成学生端下载列表来源。
|
||
|
||
## 环境变量
|
||
|
||
通用:
|
||
|
||
```text
|
||
STORAGE_DEFAULT_PROVIDER=local_dev
|
||
STORAGE_DEFAULT_BUCKET=tenant-assets
|
||
STORAGE_PUBLIC_BASE_URL=
|
||
STORAGE_MAX_UPLOAD_BYTES=524288000
|
||
STORAGE_ALLOWED_MIME_PREFIXES=image/,video/,audio/
|
||
STORAGE_ALLOWED_MIME_TYPES=application/pdf,application/json,application/zip,application/x-zip-compressed,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/octet-stream,text/plain,text/markdown,text/csv
|
||
STORAGE_REQUIRE_TENANT_PREFIX=true
|
||
```
|
||
|
||
阿里云 OSS:
|
||
|
||
```text
|
||
ALIYUN_OSS_REGION=oss-cn-hangzhou
|
||
ALIYUN_OSS_ENDPOINT=
|
||
ALIYUN_OSS_ACCESS_KEY_ID=
|
||
ALIYUN_OSS_ACCESS_KEY_SECRET=
|
||
ALIYUN_OSS_STS_TOKEN=
|
||
ALIYUN_OSS_INTERNAL=false
|
||
```
|
||
|
||
腾讯云 COS:
|
||
|
||
```text
|
||
TENCENT_COS_REGION=ap-shanghai
|
||
TENCENT_COS_APP_ID=
|
||
TENCENT_COS_SECRET_ID=
|
||
TENCENT_COS_SECRET_KEY=
|
||
TENCENT_COS_SECURITY_TOKEN=
|
||
```
|
||
|
||
Supabase Storage:
|
||
|
||
```text
|
||
SUPABASE_STORAGE_URL=https://your-project.supabase.co/storage/v1
|
||
SUPABASE_STORAGE_SERVICE_KEY=
|
||
```
|
||
|
||
assets worker:
|
||
|
||
```text
|
||
WORKER_ASSET_BATCH_SIZE=50
|
||
WORKER_ASSET_MIN_AGE_SECONDS=300
|
||
WORKER_ASSET_RECHECK_INTERVAL_SECONDS=86400
|
||
WORKER_ASSET_REQUEST_TIMEOUT_MS=10000
|
||
WORKER_ASSET_SECURITY_SCANNER=metadata_rules
|
||
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=
|
||
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=
|
||
WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS=10000
|
||
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
|
||
```
|
||
|
||
## 生产建议
|
||
|
||
- 阿里云和腾讯云生产环境优先用 STS/临时密钥或 RAM/CAM 最小权限账号。
|
||
- bucket 默认私有,公开资源也建议先经过 CDN/防盗链策略,不让前端直接持有写权限。
|
||
- 图片、PDF、视频分别设置合理的 CORS,只允许前端域名和小程序业务域名访问。
|
||
- 开启对象版本控制、生命周期、跨区域复制或定时备份,满足后续容灾要求。
|
||
- 视频资源已接入 SVIP/播放次数校验、短期签名、播放日志和动态水印上下文;生产阶段继续补转码级水印、CDN 防盗链和播放统计。
|
||
- 大文件上传已经支持 API 即时确认、worker 元数据复检、内置规则扫描、外部 HTTP 扫描契约和访问水印 traceId;生产阶段必须接入真实扫描服务 endpoint/token,并继续补转码水印、CDN 刷新和生命周期策略。
|
||
|
||
## 官方依据
|
||
|
||
- 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 下发前端。
|