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

17 KiB
Raw Blame History

对象存储接入说明

更新时间2026-06-29

目标

题库里的图片、PDF、视频、音频、资料包等媒体资源统一走 content_assets 台账和后端签名接口。前端不直接保存或读取云厂商密钥,也不直接拼接私有资源 URL。题目视频播放还需要经过 POST /api/videos/play 校验 SVIP 或视频次数权益后下发短期签名 URL。

生产上线验收请按完整 runbook 执行,包含 API/worker production fail-fast、readiness、云控制台配置、上传确认、安全扫描、短签名、水印、跨租户和导出资源抽样

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_storageSupabase Storage 官方 createSignedUploadUrl / createSignedUrl 语义。
  • external_url:外部公开或厂商托管 URL只允许作为已管理资源的下载地址不支持后端直传签名。

API

后台申请上传签名:

POST /api/tenant-content/assets/sign-upload

后台登记资源草稿:

PUT /api/tenant-content/assets

后台确认上传:

POST /api/tenant-content/assets/confirm-upload

学生端下载:

GET /api/catalog/assets/download?assetId=...

学生端预览:

GET /api/catalog/assets/preview?assetId=...

学生端视频播放:

POST /api/videos/play

后台查看资源访问事件:

GET /api/tenant-content/assets/access-events?assetId=...

后台查看资源安全扫描事件:

GET /api/tenant-content/assets/security-scan-events?assetId=...

后台管理员下载:

POST /api/tenant-content/assets/sign-download

后台管理员预览:

POST /api/tenant-content/assets/sign-preview

后台资源复检 worker

npm --workspace @tiku-saas/worker run assets:once

题库 PDF/Word/每日一练 ZIP 导出 worker

npm --workspace @tiku-saas/worker run exports:once

标准上传流程

后台前端上传 PDF、图片、视频或资料包时必须走下面流程

  1. 调用 POST /api/tenant-content/assets/sign-upload 申请短期上传 URL。
  2. 前端使用返回的 upload.urlupload.headers 直传对象存储。
  3. 调用 PUT /api/tenant-content/assets 登记资源台账。托管对象默认进入 status=draftuploadStatus=pending
  4. 调用 POST /api/tenant-content/assets/confirm-upload由后端读取对象元数据并比对大小、MIME、SHA-256。
  5. 校验通过后,后端只会置为 uploadStatus=verifiedsecurityScanStatus=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/assetsstatus=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=failedsecurityScanStatus=skipped,并将 active 资源退回 draft,同时写入 security_flags.assetRecheckFailed=truecontent_asset_security_scan_eventsaudit_logs

题库导出生成资源

题库导出 PDF/Word/每日一练 ZIP 不走前端上传,而是由后端和 worker 自动进入资源台账:

  1. 租户后台调用 POST /api/tenant-content/exports/questionsformat=pdfformat=docxformat=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_storagealiyun_osstencent_cos
  5. worker 创建 content_assets,设置 uploadStatus=verifiedsecurityScanStatus=passedsecurityScanProvider=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-downloadsign-preview 下载/预览。

注意PDF/Word/ZIP 文件本身不直接存入数据库,数据库只保存 job、资源台账和校验 metadata。daily_practice_zip 资源类型为 package,通常只做签名下载,不做 inline 预览。

确认上传示例:

{
  "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-sha256x-cos-meta-sha256
  • worker 的复检证据写入 verification_details.assetWorker,包含 lastCheckedAtlastResultexpectedobservedissues,便于租户后台定位资源异常。
  • 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=truemetadata.cdnAccessMode='signed_by_provider'。商用建议这类资源优先登记为 objectKey,由 API 生成 OSS/COS/Supabase Storage 私有签名 URL。
  • 每次上传签名、上传确认、学生下载/预览、后台下载/预览都会写入 content_asset_access_events。授权失败也会记录 result=denieddenyCode,用于租户后台排查资源访问问题。
  • 云厂商 AccessKey、SecretKey、Service Role Key 只存在服务端环境变量,不返回前端。
  • content_assets 是资源唯一台账,前端不得绕过台账直接访问私有 bucket。
  • 前端不能把 uploadStatus=failedsecurityScanStatus!=passedstatus=draft 的资源继续展示为可下载;列表仍返回时应展示“资料处理中”“安全扫描中”或“资源异常已下架”,真正下载/预览会被后端拒绝。

动态水印上下文

学生端资料下载、PDF/图片预览、题目视频播放,以及租户后台下载/预览都会返回统一的 watermark 对象:

{
  "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_rulestrusted_export_worker
security_scan_summary 风险等级、问题码、扫描证据和失败原因

内置 provider 是 metadata_rules。它不是完整杀毒引擎,但能阻断明显危险或不合规对象:跨租户 key、非法 object key、超限文件、MIME allowlist 外文件、扩展名/MIME 不匹配,以及测试/运营标记的强制失败。

生产环境支持可插拔 HTTP providerWORKER_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 或对象存储密钥。请求示例:

{
  "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 响应契约:

{
  "status": "passed",
  "riskLevel": "none",
  "issueCodes": [],
  "provider": "clamav",
  "details": {
    "engine": "clamav",
    "signature": ""
  }
}

status 只允许 passedfailedriskLevel 只允许 none/low/medium/high/criticaldetails 会经过脱敏后写入扫描事件,secret/token/password/privateKey/apiKey/authorization 等字段会被替换为 [redacted]

扫描事件表:

public.content_asset_security_scan_events

后台接口:

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、扩展名、错误信息等证据

访问事件字段

资源访问事件表:

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 granteddenied
deny_code 拒绝原因,例如 ASSET_CDN_ACCESS_NOT_ALLOWED
expires_in_sec 下发签名有效期
signature_mode local-placeholdersupabase-storage-signed-urlaliyun-oss-signature-url-v1tencent-cos-signature-url-v5public-or-provider-managed
metadata 签名摘要、对象位置、文件名、水印 traceId 等排查信息;不保存云厂商密钥

后台接口:

GET /api/tenant-content/assets/access-events?assetId=<assetId>&limit=100

前端只用于后台审计和排查,不要把 content_asset_access_events 当成学生端下载列表来源。

环境变量

通用:

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

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

TENCENT_COS_REGION=ap-shanghai
TENCENT_COS_APP_ID=
TENCENT_COS_SECRET_ID=
TENCENT_COS_SECRET_KEY=
TENCENT_COS_SECURITY_TOKEN=

Supabase Storage

SUPABASE_STORAGE_URL=https://your-project.supabase.co/storage/v1
SUPABASE_STORAGE_SERVICE_KEY=

assets worker

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-algorithmq-akq-sign-timeq-key-timeq-header-listq-url-param-listq-signature 等字段;本项目服务端生成签名,不把 SecretKey 下发前端。