Files
gongxue-base/docs/refactor/object-storage.md
2026-06-30 01:05:34 +08:00

371 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 对象存储接入说明
更新时间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 下发前端。