Compare commits

..

11 Commits

Author SHA1 Message Date
xiaoxia aea012fb74 fix: storage不可用时用URL参数作降级标记,防无痕模式刷新死循环
AI Review 二次意见:Safari 无痕/禁用 Cookie 环境 sessionStorage 读写都抛异常,
标记写不进去,刷新后 getChunkReloadedAt 仍返回 null,会陷入
'检测到chunk失效→刷新→再检测→再刷新'死循环。

- getChunkReloadedAt 双重判定:先查 URL ?chunkreload=1 参数(刷新后仍在),
  再查 sessionStorage 标记
- reloadForChunkError 优先用 location.replace 带上 URL 标记跳转,
  URL 构造失败再退回 window.location.reload
- goHomeRecover 跳首页天然清掉 URL 参数
- 补 2 例:URL 参数判定为已刷新;storage 全不可用时走 URL 标记不崩溃
2026-09-06 13:38:06 +08:00
xiaoxia 320aa89751 fix: 按 AI Review 意见加固——非chunk错误跳首页防死循环 + storage异常防护
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m43s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m48s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m3s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m4s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 2m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m14s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m31s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 4m58s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 5m59s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m7s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 18s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 36s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 10m13s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 10m15s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 10m21s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
- 手动兜底由 hardReload(刷新当前URL) 改为 goHomeRecover(跳首页整页导航):
  chunk 失效时脱离旧 chunk 引用;业务崩溃时绕开报错路由,避免刷新-再崩死循环
- chunkLoadError 的 sessionStorage 访问全部包 try-catch(Safari 无痕/禁用
  Cookie 时访问存储会抛异常,错误边界本身不能因此崩溃),降级为无标记
- lazyRoute 增加 mod.default 校验,缺导出时抛明确错误
2026-09-06 13:28:59 +08:00
xiaoxia c894163446 fix: 发版后懒加载 chunk 失效白屏——ErrorBoundary 自动刷新 + lazy 重试
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m43s
AI Code Review / AI Code Review (pull_request) Failing after 1m49s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m46s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m58s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m1s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m7s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m8s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m27s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 4m23s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
根因:所有页面 React Router lazy 动态 import,发版后旧 chunk 文件名删除,
停留旧标签页的用户点菜单时 import 404(Failed to fetch dynamically
imported module / ChunkLoadError),无兜底导致整页白屏。

改动:
- utils/chunkLoadError.ts:识别 chunk 加载错误(Vite/webpack/字符串多形态);
  sessionStorage 标记最近 10min 内是否已为 chunk 失效自动刷新过(防死循环)
- components/common/ChunkErrorBoundary.tsx:全局错误边界包裹 RouterProvider;
  chunk 错误首次捕获自动整页刷新(刷新后 no-cache 的 index.html 拿到新 chunk
  引用自愈),已刷新过仍失败则显示「系统已更新,请点击刷新」兜底按钮;
  其他错误显示通用异常页,不再整页白屏
- router/lazyRoute.ts:路由 lazy 包一层自动重试(最多 2 次,300/800ms),
  网络抖动无感恢复;chunk 404 重试耗尽抛出交给 Boundary;非 chunk 错误立即抛
- appRoutes.tsx:28 个路由 lazy 统一替换为 lazyRoute
- nginx 缓存策略经核实已满足要求:index.html no-cache、/assets/* immutable 1y

单测 16 例:chunk 错误识别多形态、reload 标记生命周期、Boundary 首次自动刷新/
二次手动兜底/非 chunk 通用页、lazyRoute 重试成功/耗尽抛出/非 chunk 不重试。
2026-09-06 13:21:19 +08:00
xiaoxia 9bca7e53e3 feat(#1714): complete 请求携带 file_size,修复同名兜底误杀新视频 (#1731)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 4s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 29s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 30s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 43s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 11s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 43s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 42s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m43s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m53s
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Validate - Style (push) Successful in 2m44s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m10s
AI Code Review / AI Code Review (pull_request) Failing after 3m38s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m23s
CI/CD Pipeline / Validate - Security (push) Successful in 5m39s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m3s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m4s
CI/CD Pipeline / Unit Tests (push) Successful in 8m16s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-06 12:44:05 +08:00
xiaoxia 70dde8cbfb feat(#1714): prepare_direct_upload 去重 + 预建 PROCESSING asset 占位 (#1730)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 27s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 27s
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m2s
AI Code Review / AI Code Review (pull_request) Failing after 1m43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 2m7s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 31s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m15s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m47s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m45s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m18s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m42s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 51s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m47s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (push) Successful in 4m42s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m54s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m15s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m12s
CI/CD Pipeline / Unit Tests (push) Successful in 10m53s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-06 12:31:38 +08:00
xiaoxia 528f56254d feat(#1718): PATCH /auth/me 资料更新接口 + profile_completed 字段 (#1728)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 20s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 21s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 23s
CI/CD Pipeline / Build Staging API Image (push) Successful in 17s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 33s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m0s
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Validate - Style (push) Successful in 2m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m8s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m6s
AI Code Review / AI Code Review (pull_request) Successful in 3m27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m38s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m30s
CI/CD Pipeline / Validate - Security (push) Successful in 4m54s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m17s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m11s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m10s
CI/CD Pipeline / Unit Tests (push) Successful in 8m18s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 11m48s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-06 12:06:58 +08:00
xiaoxia ff60fdf956 feat(#1714): 前端 prepare 短路(后端 skip_transfer 命中时跳过 OSS 直传) (#1729)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 26s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
CI/CD Pipeline / Build Staging API Image (push) Successful in 41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 39s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 38s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m54s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m16s
CI/CD Pipeline / Integration Tests (push) Successful in 2m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m16s
CI/CD Pipeline / Validate - Style (push) Successful in 2m51s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m10s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m31s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m32s
CI/CD Pipeline / Validate - Security (push) Successful in 5m18s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m41s
AI Code Review / AI Code Review (pull_request) Successful in 6m20s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m32s
CI/CD Pipeline / Unit Tests (push) Successful in 8m24s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-06 11:43:59 +08:00
xiaoxia 06b0bacce1 fix(#1718): 昵称页提交防连点 + 补 /vite.svg favicon (#1727)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 19s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 20s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 24s
CI/CD Pipeline / Build Staging API Image (push) Successful in 26s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m46s
CI/CD Pipeline / Validate - Style (push) Successful in 2m16s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m18s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m41s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m2s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m0s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 51s
AI Code Review / AI Code Review (pull_request) Failing after 4m18s
CI/CD Pipeline / Validate - Security (push) Successful in 5m9s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m38s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m1s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m39s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m17s
CI/CD Pipeline / Unit Tests (push) Successful in 8m29s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-06 11:24:48 +08:00
xiaoxia a83ed58864 feat(#1718): 微信登录/绑定改为弹窗内嵌二维码,不再整页跳转 (#1726)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 28s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 30s
CI/CD Pipeline / Build Staging API Image (push) Successful in 23s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 42s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m59s
AI Code Review / AI Code Review (pull_request) Failing after 2m5s
CI/CD Pipeline / Validate - Style (push) Successful in 2m21s
CI/CD Pipeline / Integration Tests (push) Successful in 2m19s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m14s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m47s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 55s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m22s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m32s
CI/CD Pipeline / Validate - Security (push) Successful in 5m20s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m34s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m18s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m7s
CI/CD Pipeline / Unit Tests (push) Successful in 8m26s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 12m1s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-06 00:31:48 +08:00
xiaoxia cdcb032e45 fix(#1718/#1714): 微信回调state误杀修复+错误透传防连点、上传失败完整可观测、哈希阈值降至64MB、昵称不预填 (#1723)
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 27s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m28s
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m39s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m12s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 6m15s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / Check push changed paths (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 21s
CI/CD Pipeline / Build Staging API Image (push) Successful in 21s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 22s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m44s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m30s
CI/CD Pipeline / Validate - Style (push) Successful in 2m20s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m19s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m27s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m13s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m58s
CI/CD Pipeline / Unit Tests (push) Successful in 8m4s
CI/CD Pipeline / Validate - Security (push) Successful in 16m53s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-09-05 22:52:49 +08:00
xiaoxia c8b1c4b8ff feat(#1719): 微信账号绑定/解绑三接口 + /me 返回 wechat_bound (#1725)
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m37s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m36s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 6m16s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 6s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 6s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 8s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / Check push changed paths (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 18s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 19s
CI/CD Pipeline / Build Staging API Image (push) Successful in 20s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m37s
CI/CD Pipeline / Validate - Style (push) Successful in 2m12s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m52s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m31s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m3s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m3s
CI/CD Pipeline / Unit Tests (push) Successful in 7m36s
CI/CD Pipeline / Validate - Security (push) Successful in 24m19s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
2026-09-05 21:50:54 +08:00
44 changed files with 3087 additions and 235 deletions
@@ -0,0 +1,26 @@
"""add profile_completed to users
Issue #1718:微信新用户首次登录需设置昵称(PATCH /auth/me)。
- users.profile_completed:资料是否已完善;存量行默认 True(不触发引导),
微信新建用户在应用层置 False。
"""
import sqlalchemy as sa
from alembic import op
revision = "068_user_profile_completed"
down_revision = "067_celery_task_id"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("profile_completed", sa.Boolean(), nullable=False, server_default=sa.text("true")),
)
def downgrade() -> None:
op.drop_column("users", "profile_completed")
+65 -19
View File
@@ -1,4 +1,5 @@
"""
from __future__ import annotations
Canonical authentication API routes.
The route layer is intentionally thin: repository construction lives in
@@ -15,7 +16,7 @@ from app.config import settings
from app.dependencies import get_auth_email_service, get_auth_session_store, get_user_repository
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, EmailStr, field_validator
from packages.adapters.redis import NoopSessionStore
from packages.adapters.smtp import NoopEmailService
@@ -85,6 +86,22 @@ class CurrentUserResponse(BaseModel):
phone_verified: bool = False
binding_complete: bool = False
wechat_bound: bool = False
profile_completed: bool = True
class UserProfileResponse(BaseModel):
"""用户资料负载(PATCH /me、绑定/解绑接口复用;字段与 GET /auth/me 一致,前端 normalizeUser 直接消费)"""
user_id: str
email: str
username: str
display_name: str
email_verified: bool
phone: str = ""
phone_verified: bool = False
binding_complete: bool = False
wechat_bound: bool = False
profile_completed: bool = True
class PasswordResetRequestModel(BaseModel):
@@ -274,9 +291,51 @@ async def get_current_user_info(
phone_verified=user.phone_verified,
binding_complete=binding_complete,
wechat_bound=bool(user.wechat_openid),
profile_completed=user.profile_completed,
)
class UpdateProfileRequest(BaseModel):
"""更新个人资料请求(当前仅支持昵称)"""
display_name: str
@field_validator("display_name")
@classmethod
def _validate_display_name(cls, v: str) -> str:
name = (v or "").strip()
if not name:
raise ValueError("昵称不能为空白")
if len(name) > 20:
raise ValueError("昵称长度需在 1-20 个字符之间")
return name
class UpdateProfileResponse(BaseModel):
"""更新资料响应:前端 normalizeUser(response.user) 直接消费"""
user: UserProfileResponse
@router.patch("/me", response_model=UpdateProfileResponse)
async def update_current_user_profile(
request: UpdateProfileRequest,
current_user: AuthenticatedUser = Depends(get_current_user),
user_repository: UserRepository = Depends(get_user_repository),
) -> UpdateProfileResponse:
"""更新当前登录用户昵称(微信新用户首次设置昵称后置 profile_completed=True)。"""
user = current_user.user
user.display_name = request.display_name # 已 stripvalidator
if not user.profile_completed:
user.profile_completed = True
user_repository.save(user)
logger.info("[资料更新] 用户 %s 更新昵称,profile_completed=%s", user.id, user.profile_completed)
# 重新读取,确保返回的是持久化后的最新状态
fresh = user_repository.find_by_id(user.id) or user
return UpdateProfileResponse(user=_user_profile(fresh))
class _NoopSessionStore(NoopSessionStore):
pass
@@ -507,34 +566,20 @@ class WechatBindCompleteRequest(BaseModel):
state: str = ""
class WechatBindUserProfile(BaseModel):
"""绑定/解绑后返回的用户信息(字段对齐 /auth/me,前端 normalizeUser 直接消费)"""
user_id: str
email: str
username: str
display_name: str
email_verified: bool
phone: str = ""
phone_verified: bool = False
binding_complete: bool = False
wechat_bound: bool = False
class WechatBindCompleteResponse(BaseModel):
success: bool
user: WechatBindUserProfile
user: UserProfileResponse
class WechatUnbindResponse(BaseModel):
success: bool
def _wechat_user_profile(user) -> WechatBindUserProfile:
def _user_profile(user) -> UserProfileResponse:
binding_complete = bool(
user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
)
return WechatBindUserProfile(
return UserProfileResponse(
user_id=user.id,
email=user.email,
username=user.username,
@@ -544,6 +589,7 @@ def _wechat_user_profile(user) -> WechatBindUserProfile:
phone_verified=user.phone_verified,
binding_complete=binding_complete,
wechat_bound=bool(user.wechat_openid),
profile_completed=user.profile_completed,
)
@@ -589,7 +635,7 @@ async def wechat_bind(
raise HTTPException(status_code=http_status, detail=error)
logger.info("[微信绑定] 用户 %s 绑定成功 openid=%s", current_user.user.id, wechat_user.openid[:8])
return WechatBindCompleteResponse(success=True, user=_wechat_user_profile(result.user))
return WechatBindCompleteResponse(success=True, user=_user_profile(result.user))
@router.delete("/wechat/bind", response_model=WechatUnbindResponse)
+139 -12
View File
@@ -165,15 +165,44 @@ def _find_duplicate_asset(
within_minutes=FALLBACK_DEDUP_WINDOW_MINUTES,
file_size=file_size or 0,
)
if existing is not None and getattr(existing, "status", None) in ACTIVE_ASSET_STATUSES:
logger.info(
"素材幂等兜底命中(近期活动同名记录): library=%s name=%s asset=%s status=%s",
library_id,
filename,
getattr(existing, "id", "?"),
getattr(existing, "status", "?"),
)
return existing
# 兜底去重:按状态区分处理
# - READY/ERROR:稳定素材,总命中(避免重复创建)
# - PROCESSING/UPLOADING:预建或 complete 占位,仅当 hash 一致才命中
# - 占位无 hash(旧客户端 complete 建的)→ 命中
# - 占位有 hash 且与当前请求 hash 一致 → 命中
# - 占位有 hash 且与当前请求 hash 不同 → 跳过(内容不同)
if existing is not None:
status = getattr(existing, "status", None)
existing_hash = getattr(existing, "file_hash", "") or ""
if status in (AssetStatus.READY, AssetStatus.ERROR):
logger.info(
"素材幂等兜底命中(近期同名稳定记录): library=%s name=%s asset=%s status=%s",
library_id,
filename,
getattr(existing, "id", "?"),
status,
)
return existing
elif status in ACTIVE_ASSET_STATUSES:
if existing_hash and file_hash and existing_hash != file_hash:
logger.debug(
"素材兜底去重跳过(占位 hash 不同): library=%s name=%s asset=%s hash=%s req_hash=%s",
library_id,
filename,
getattr(existing, "id", "?"),
existing_hash,
file_hash,
)
existing = None
else:
logger.info(
"素材幂等兜底命中(近期同名活动记录): library=%s name=%s asset=%s status=%s",
library_id,
filename,
getattr(existing, "id", "?"),
status,
)
return existing
return None
@@ -187,8 +216,44 @@ def _create_pending_asset(
user_id,
file_hash="",
client_upload_id="",
file_size: int = 0,
):
"""立即创建一条 PROCESSING 状态的 Asset 记录,使前端能马上看到新素材。"""
"""立即创建或复用一条 PROCESSING 状态的 Asset 记录
find-or-createprepare 阶段已按 file_hash/client_upload_id 预建的占位记录
会被 find_by_library_and_file_hash/find_by_library_and_client_upload_id 命中,
直接复用并补齐字段(避免 pre-create + complete 重复建两条)。
"""
# 1. 按 client_upload_id / file_hash 查找现有记录
existing = None
if client_upload_id:
find_by_cuid = getattr(asset_repository, "find_by_library_and_client_upload_id", None)
if callable(find_by_cuid):
existing = find_by_cuid(library_id=library_id, client_upload_id=client_upload_id)
if existing is None and file_hash:
existing = asset_repository.find_by_library_and_file_hash(library_id=library_id, file_hash=file_hash)
if existing is not None:
# 补齐字段(幂等:避免重复建记录,前端已拿到 asset_id)
changed = False
if file_hash and not existing.file_hash:
existing.file_hash = file_hash
changed = True
if client_upload_id and not existing.client_upload_id:
existing.client_upload_id = client_upload_id
changed = True
if file_size and not existing.file_size:
existing.file_size = file_size
changed = True
if existing.status not in (AssetStatus.PROCESSING, AssetStatus.UPLOADING):
existing.status = AssetStatus.PROCESSING
changed = True
if changed:
try:
asset_repository.update(existing)
except Exception: # noqa: BLE001 — 字段补齐失败不阻塞主流程
pass
return existing
asset = Asset.create(
project_id=project_id,
library_id=library_id,
@@ -199,6 +264,7 @@ def _create_pending_asset(
uploaded_by_user_id=user_id,
file_hash=file_hash,
client_upload_id=client_upload_id,
file_size=file_size,
)
return asset_repository.create(asset)
@@ -243,9 +309,15 @@ async def prepare_direct_upload(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> DirectUploadPrepareResponse:
"""创建浏览器直传 OSS 的短期表单签名"""
"""创建浏览器直传 OSS 的短期表单签名,并在签名前按 file_hash/client_upload_id 去重。
命中去重:直接返回 duplicated=True + skip_transfer=True(前端跳过 OSS 直传),
未命中:正常签名 OSS 并立即预建一条 PROCESSING 状态的 asset 记录占住
file_hash 闸门,响应带 asset_id 供前端/后续 complete 关联。
"""
settings = get_settings()
max_size_bytes = settings.OSS_DIRECT_UPLOAD_MAX_MB * 1024 * 1024
if request.file_size > max_size_bytes:
@@ -264,8 +336,39 @@ async def prepare_direct_upload(
asset_library_repository,
)
file_id = uuid4().hex[:8]
safe_filename = request.filename.replace("/", "_").replace("\\", "_")
# ── prepare 阶段去重:OSS 签名之前先查已存在素材 ──
if request.file_hash or request.client_upload_id:
existing = _find_duplicate_asset(
asset_repository,
library_id=request.library_id,
file_hash=request.file_hash,
client_upload_id=request.client_upload_id,
filename=request.filename,
file_size=request.file_size,
)
if existing is not None:
logger.info(
"prepare 命中去重: library=%s hash=%s cuid=%s existing_asset=%s",
request.library_id,
request.file_hash,
request.client_upload_id,
existing.id,
)
return DirectUploadPrepareResponse(
upload_url="",
method="",
storage_key=existing.storage_key,
expires_at="",
fields={},
max_size_bytes=0,
duplicated=True,
skip_transfer=True,
asset_id=existing.id,
)
file_id = uuid4().hex[:8]
storage_key = f"uploads/{file_id}/{safe_filename}"
try:
payload = storage_service.create_direct_upload_post(
@@ -284,6 +387,27 @@ async def prepare_direct_upload(
detail=f"Failed to prepare upload: {type(error).__name__}",
) from error
# ── 预建 asset 占位:占住 file_hash/client_upload_id 闸门,避免并发重复上传 ──
pending_asset_id = ""
if request.file_hash or request.client_upload_id:
try:
pending = _create_pending_asset(
asset_repository=asset_repository,
project_id=request.project_id,
library_id=request.library_id,
storage_key=storage_key,
filename=safe_filename,
mime_type=validated_content_type,
user_id=authenticated_user.user.id,
file_hash=request.file_hash,
client_upload_id=request.client_upload_id,
file_size=request.file_size,
)
pending_asset_id = pending.id
except Exception as error:
# 预建失败不阻塞签名:complete 仍可按 OSS 文件 + hash 兜底去重
logger.warning("预建 asset 占位失败,降级走 old flow: %s", error)
return DirectUploadPrepareResponse(
upload_url=str(payload["url"]),
method=str(payload["method"]),
@@ -291,6 +415,9 @@ async def prepare_direct_upload(
expires_at=str(payload["expires_at"]),
fields={str(key): str(value) for key, value in dict(payload["fields"]).items()},
max_size_bytes=max_size_bytes,
duplicated=False,
skip_transfer=False,
asset_id=pending_asset_id,
)
+4
View File
@@ -16,6 +16,7 @@ class DirectUploadPrepareRequest(BaseModel):
content_type: str = Field(default="application/octet-stream", min_length=1, max_length=100)
file_size: int = Field(..., gt=0)
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
client_upload_id: str = Field(default="", max_length=64, description="客户端幂等 token(同一次上传的重试保持一致)")
class DirectUploadPrepareResponse(BaseModel):
@@ -25,6 +26,9 @@ class DirectUploadPrepareResponse(BaseModel):
expires_at: str
fields: dict[str, str]
max_size_bytes: int
duplicated: bool = False
skip_transfer: bool = False
asset_id: str = ""
class DirectUploadCompleteRequest(BaseModel):
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#3b82f6"/>
<text x="32" y="44" font-size="34" text-anchor="middle">🦐</text>
</svg>

After

Width:  |  Height:  |  Size: 194 B

+11
View File
@@ -139,6 +139,17 @@ export interface DirectUploadPrepareResult {
* 旧后端不返回该字段,前端降级为无预建卡片的原有行为。
*/
asset_id?: string
/**
* 后端 file_hash 命中素材库已有相同文件时为 true,前端应跳过 transfer + complete 阶段
* 直接按「去重命中」处理(不调 transfer、不调 complete、立即刷新素材列表)。
* 旧后端不返回该字段,前端降级为走老流程。
*/
duplicated?: boolean
/**
* 与 duplicated 语义一致:true 表示跳过传输,前端据此短路。
* 两个字段是同一语义的别名(后端可能只返回其一),前端任意为 true 即视为命中去重。
*/
skip_transfer?: boolean
}
/** 直传完成确认返回 */
+23 -1
View File
@@ -32,6 +32,8 @@ export const completeDirectUpload = async (data: {
file_hash?: string
/** 前端上传幂等 token(与 prepare 一致),同一次上传重发 complete 不重复建记录 */
client_upload_id?: string
/** 文件字节数;后端同名兜底去重需用它做大小校验,缺失(=0)时同名记录一律不判重 */
file_size?: number
}): Promise<DirectUploadCompleteResult> => {
// complete 内含 OSS 存在性检查 + 建库 + 派单,放宽到 60s;
// 超时不代表失败(记录可能已建成),调用方禁止超时后盲目重传整个文件
@@ -126,7 +128,15 @@ export const prepareDirectUploadHandle = async (data: {
/** 本次逻辑上传的幂等 tokenprepare/complete 一致、重试复用 */
clientUploadId?: string
}): Promise<DirectUploadHandle> => {
const project = await getOrCreateDefaultProject()
// 默认项目初始化失败(项目列表接口异常/自动创建失败)给出独立、明确的提示,
// 不与 prepare 的签名接口错误混在一起
let project: Awaited<ReturnType<typeof getOrCreateDefaultProject>>
try {
project = await getOrCreateDefaultProject()
} catch (err) {
const reason = err instanceof Error ? err.message : "网络异常"
throw new Error(`初始化默认项目失败,无法开始上传:${reason}`)
}
const prepared = await prepareDirectUpload({
project_id: project.id,
@@ -148,6 +158,8 @@ export const prepareDirectUploadHandle = async (data: {
storage_key: prepared.storage_key,
file_hash: data.fileHash,
client_upload_id: data.clientUploadId,
// 透传文件字节数:后端同名兜底去重依赖大小校验,缺省会导致同名新视频被误判重复
file_size: data.file.size,
}),
}
}
@@ -171,6 +183,16 @@ export const uploadAssetDirect = async (data: {
fileHash,
clientUploadId,
})
// prepare 阶段后端 file_hash 命中素材库已有相同文件:跳过 transfer + complete
if (handle.prepared.skip_transfer || handle.prepared.duplicated) {
return {
storage_key: handle.prepared.storage_key,
ingest_job_id: "",
url: "",
duplicated: true,
asset_id: handle.prepared.asset_id,
}
}
await handle.transfer(data.onProgress)
return handle.complete()
}
+7 -7
View File
@@ -13,10 +13,10 @@
* 重试复用同一 ID,重新入队才生成新 ID)
*/
/** 大文件抽样阈值:超过此大小只哈希头尾片段,避免上传前长时间卡 UI */
export const HASH_FULL_READ_LIMIT = 256 * 1024 * 1024 // 256MB
/** 抽样读取的头尾片段大小(各 8MB */
export const HASH_SAMPLE_CHUNK = 8 * 1024 * 1024
/** 全量哈希阈值:≤64MB 全量读入计算;超过即走头尾抽样,避免 100~256MB 视频被整文件读进内存卡死页面 */
export const HASH_FULL_READ_LIMIT = 64 * 1024 * 1024 // 64MB
/** 抽样读取的头尾片段大小(各 16MB */
export const HASH_SAMPLE_CHUNK = 16 * 1024 * 1024
/** 计算指纹时,文件在队列中已存在的状态(已失败的可以重试,不算重复) */
export type DedupExcludeStatus = "error" | "done"
@@ -95,10 +95,10 @@ function toHex(buffer: ArrayBuffer): string {
/**
* 计算文件内容 SHA-256hex64 字符,与后端 file_hash 字段长度一致)。
* - ≤256MB:全量哈希,内容一致必然一致
* - >256MB:哈希「头部 8MB + 尾部 8MB + 文件大小」,视频素材体积大、
* - ≤64MB:全量哈希,内容一致必然一致
* - >64MB:哈希「头部 16MB + 尾部 16MB + 文件大小」,视频素材体积大、
* 头部含 moov 元数据、尾部含 mdat 结尾,抽样碰撞概率可忽略,
* 且避免上传前对 2GB 文件全量读取造成长时间卡顿
* 且避免 100~256MB 视频被整文件读进内存导致页面卡死/崩溃
*
* 运行环境不支持 crypto.subtle(非安全上下文/老浏览器)时返回空字符串,
* 调用方据此降级为不传 hash(后端仍有幂等 token + 同文件名兜底去重)。
+112
View File
@@ -0,0 +1,112 @@
/**
* 微信扫码登录 WxLogin JS-SDK 动态加载与授权参数解析
*
* 微信官网嵌入式二维码方案:页面引入 https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js
* 后挂载全局 window.WxLoginnew WxLogin({...}) 会在指定容器内渲染二维码 iframe。
* 本模块负责:动态加载该脚本(带超时/失败检测)、从后端返回的 auth_url 中解析
* WxLogin 所需的 appid / redirect_uri / state。
*/
const WX_LOGIN_SRC = "https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js"
/** 脚本加载超时(毫秒):超时视为加载失败,调用方回退整页跳转 */
const WX_LOGIN_LOAD_TIMEOUT = 8000
/** WxLogin 构造参数(微信官方字段,保持原名) */
export interface WxLoginOptions {
/** 是否内嵌二维码(回调在 iframe 内完成) */
self_redirect: boolean
/** 二维码容器元素 id */
id: string
/** 微信开放平台 AppID */
appid: string
/** 应用授权作用域,网站应用固定 snsapi_login */
scope: "snsapi_login"
/** 回调地址(需与微信开放平台配置一致,WxLogin 内部会 encodeURIComponent */
redirect_uri: string
/** 防 CSRF 随机串,由后端 state store 生成并在回调时一次性消费 */
state: string
/** 二维码样式:black / white */
style?: "black" | "white"
/** 自定义样式链接(可选) */
href?: string
}
/** 微信脚本挂载到 window 上的全局构造函数类型 */
export interface WxLoginConstructor {
new (options: WxLoginOptions): unknown
}
declare global {
interface Window {
WxLogin?: WxLoginConstructor
}
}
let loadPromise: Promise<WxLoginConstructor> | null = null
/**
* 动态加载微信 WxLogin JS(单例:并发调用复用同一个 promise)。
* 加载失败或超时会 reject,调用方应回退到整页跳转授权方式。
*/
export function loadWxLoginScript(): Promise<WxLoginConstructor> {
if (window.WxLogin) return Promise.resolve(window.WxLogin)
if (loadPromise) return loadPromise
loadPromise = new Promise<WxLoginConstructor>((resolve, reject) => {
const script = document.createElement("script")
script.src = WX_LOGIN_SRC
script.async = true
script.onload = () => {
if (window.WxLogin) {
resolve(window.WxLogin)
} else {
loadPromise = null
reject(new Error("微信登录脚本加载完成但 WxLogin 未挂载"))
}
}
script.onerror = () => {
loadPromise = null
script.remove()
reject(new Error("微信登录脚本加载失败"))
}
document.head.appendChild(script)
// 超时兜底:部分网络环境下脚本既不 onload 也不 onerror
window.setTimeout(() => {
if (window.WxLogin) {
resolve(window.WxLogin)
return
}
loadPromise = null
script.remove()
reject(new Error("微信登录脚本加载超时"))
}, WX_LOGIN_LOAD_TIMEOUT)
})
return loadPromise
}
/** 从微信授权链接 query 中解析出的 WxLogin 所需参数 */
export interface ParsedWxAuthParams {
appid: string
/** 已 URL 解码的回调地址(传给 WxLogin 时由其内部再次编码) */
redirect_uri: string
state: string
}
/**
* 从后端返回的微信授权链接(https://open.weixin.qq.com/connect/qrconnect?appid=...&redirect_uri=...&state=...
* 中解析 appid / redirect_uri / state。解析失败时返回 null,由调用方回退整页跳转。
*/
export function parseWxAuthUrl(authUrl: string, stateFallback?: string): ParsedWxAuthParams | null {
try {
const url = new URL(authUrl)
const appid = url.searchParams.get("appid")
const redirectUri = url.searchParams.get("redirect_uri")
const state = url.searchParams.get("state") || stateFallback || ""
if (!appid || !redirectUri || !state) return null
return { appid, redirect_uri: redirectUri, state }
} catch {
return null
}
}
+132
View File
@@ -0,0 +1,132 @@
/**
* 统一错误信息提取
* 把 axios 错误(后端 detail / FastAPI 校验错误 / HTTP 状态码)、XHR/OSS 错误、
* 网络/超时错误、普通 Error 统一转成「可直接展示给用户」的中文信息。
*
* 与 api/client.ts 响应拦截器的提示口径保持一致;拦截器负责全局 toast,
* 页面/队列卡片用本工具把真实原因展示在持久位置(回调页、失败卡片等)。
*/
import type { AxiosError } from "axios"
/** 后端错误响应体可能出现的字段(FastAPI:detail;历史接口:message/msg */
interface ErrorBody {
detail?: unknown
message?: unknown
msg?: unknown
}
/** FastAPI 422 校验错误单项 */
interface ValidationItem {
loc?: (string | number)[]
msg?: string
}
/** 从后端响应体提取人类可读信息(detail 可能是字符串、对象、422 数组) */
function extractBodyMessage(data: unknown): string {
if (!data || typeof data !== "object") return ""
const body = data as ErrorBody
const walk = (val: unknown): string => {
if (typeof val === "string") return val
if (Array.isArray(val)) {
// FastAPI 422: [{loc, msg, type}, ...] → 取每条 msg 拼接
const parts = val
.map((item) => {
if (typeof item === "string") return item
if (item && typeof item === "object") {
const v = item as ValidationItem
if (typeof v.msg === "string") {
const field = Array.isArray(v.loc) ? v.loc.filter((x) => x !== "body").join(".") : ""
return field ? `${field}: ${v.msg}` : v.msg
}
return walk(item)
}
return ""
})
.filter(Boolean)
return parts.join("")
}
if (val && typeof val === "object") {
const obj = val as Record<string, unknown>
if (typeof obj.message === "string") return obj.message
if (typeof obj.msg === "string") return obj.msg
if (typeof obj.detail === "string") return obj.detail
if (obj.message && typeof obj.message === "object") return walk(obj.message)
if (obj.msg && typeof obj.msg === "object") return walk(obj.msg)
try {
return JSON.stringify(val)
} catch {
return ""
}
}
return ""
}
return walk(body.detail) || walk(body.message) || walk(body.msg)
}
/** 无响应体时按 HTTP 状态码给出兜底提示(与 client.ts 拦截器口径一致) */
function statusFallback(status: number): string {
switch (status) {
case 400:
return "请求参数有误(HTTP 400"
case 401:
return "登录状态已失效,请重新登录(HTTP 401)"
case 403:
return "没有权限执行该操作(HTTP 403"
case 404:
return "请求的资源不存在(HTTP 404"
case 409:
return "操作冲突,资源状态已变化(HTTP 409)"
case 413:
return "文件过大,请缩小后重试(HTTP 413)"
case 415:
return "不支持的文件格式(HTTP 415"
case 429:
return "操作过于频繁,请稍后再试(HTTP 429)"
case 503:
return "服务暂不可用,请稍后再试(HTTP 503)"
default:
if (status >= 500) return `服务器繁忙,请稍后再试(HTTP ${status}`
return `请求失败(HTTP ${status}`
}
}
/**
* 从任意抛出值提取可展示的错误信息。
* @param fallback 全部提取失败时的兜底文案
*/
export function getErrorMessage(err: unknown, fallback = "操作失败,请稍后重试"): string {
if (!err) return fallback
// axios 错误(后端 JSON 响应 / HTTP 错误状态)
const ax = err as AxiosError<ErrorBody>
if (ax.isAxiosError || (typeof ax === "object" && "response" in (ax as object))) {
// 超时
if (ax.code === "ECONNABORTED" || /timeout/i.test(ax.message || "")) {
return "请求超时,请检查网络后重试"
}
const resp = ax.response
if (resp) {
const bodyMsg = extractBodyMessage(resp.data)
if (bodyMsg) return bodyMsg
return statusFallback(resp.status)
}
// 请求已发出但无响应(断网/CORS/DNS)
if (ax.request) return "网络连接异常,请检查网络设置"
return ax.message || fallback
}
if (err instanceof Error) {
// XHR 直传 OSS 失败等场景自带详细 message(含 HTTP 状态 + OSS Code/Message
if (err.message) return err.message
}
if (typeof err === "string") return err
return fallback
}
/** client.ts 拦截器是否已对该错误弹过全局 toast(__msgShown 标记) */
export function isErrorMsgShown(err: unknown): boolean {
return Boolean((err as { __msgShown?: boolean } | null)?.__msgShown)
}
@@ -0,0 +1,74 @@
.xx-wechat-qr-modal {
position: relative;
padding: 8px 0 4px;
min-height: 320px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* 常驻二维码容器(WxLogin 渲染目标) */
.xx-wechat-qr-container {
display: flex;
justify-content: center;
min-height: 260px;
}
/* loading / error 遮罩层,覆盖在二维码容器之上 */
.xx-wechat-qr-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #fff;
text-align: center;
color: #666;
}
.xx-wechat-qr-overlay p {
margin-top: 16px;
margin-bottom: 0;
}
.xx-wechat-qr-container iframe {
border: none;
}
.xx-wechat-qr-tip {
margin: 12px 0 0;
color: #666;
font-size: 14px;
}
.xx-wechat-qr-error {
text-align: center;
width: 100%;
}
.xx-wechat-qr-error-msg {
color: #ef4444;
font-size: 14px;
line-height: 1.6;
margin: 0 0 16px;
word-break: break-word;
}
.xx-wechat-qr-error-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.xx-wechat-qr-fallback {
background: none;
border: none;
color: var(--primary-color, #3b82f6);
cursor: pointer;
font-size: 13px;
padding: 0;
text-decoration: underline;
}
@@ -0,0 +1,265 @@
/**
* 微信扫码二维码弹窗(登录 / 绑定复用)
*
* 微信官方嵌入式二维码方案:弹窗内用 new WxLogin({ self_redirect: true }) 渲染二维码,
* 扫码后微信重定向到本站回调页(在二维码 iframe 内加载),回调页通过 postMessage
* 把成功/失败结果通知本弹窗(消息协议见 ./messages)。
*
* 兜底:获取授权链接成功但 WxLogin JS 加载失败/超时时,自动回退整页跳转授权
* (与旧流程一致);获取授权链接本身失败时在弹窗内展示错误并提供重试。
*/
import React, { useEffect, useRef, useState } from "react"
import { Spin } from "antd"
import Modal from "@/components/ui/Modal"
import Button from "@/components/ui/Button"
import {
getWechatAuthUrl,
getWechatBindUrl,
getCurrentUser,
normalizeUser,
type User,
} from "@/api/auth"
import { useAuthStore } from "@/store/authStore"
import { scheduleProactiveRefresh } from "@/api/auth/tokenRefresh"
import { getErrorMessage } from "@/api/errors"
import { loadWxLoginScript, parseWxAuthUrl } from "@/api/auth/wxLogin"
import { isWechatQrMessage, type WechatQrScene } from "./messages"
import "./WechatQrModal.css"
export interface WechatQrModalProps {
open: boolean
scene: WechatQrScene
onClose: () => void
/** 登录场景成功回调(needOnboarding=true 时调用方应跳昵称引导页) */
onLoginSuccess?: (needOnboarding: boolean) => void
/** 绑定场景成功回调(调用方刷新用户信息/提示) */
onBindSuccess?: () => void
}
type QrStatus = "loading" | "qrcode" | "error"
const CONTAINER_ID: Record<WechatQrScene, string> = {
login: "wechat-qr-login-container",
bind: "wechat-qr-bind-container",
}
const STATE_STORAGE_KEY: Record<WechatQrScene, string> = {
login: "wechat_state",
bind: "wechat_bind_state",
}
/**
* 等待二维码容器挂载到 DOM。antd Modal 内容通过 portal 渲染且带进场动画,
* 父组件 effect 首次执行时容器可能尚未出现在 document 中。
*/
function waitForContainer(id: string, timeoutMs = 3000): Promise<HTMLElement | null> {
return new Promise((resolve) => {
const start = Date.now()
const check = () => {
const el = document.getElementById(id)
if (el) {
resolve(el)
return
}
if (Date.now() - start > timeoutMs) {
resolve(null)
return
}
setTimeout(check, 50)
}
check()
})
}
const WechatQrModal: React.FC<WechatQrModalProps> = ({
open,
scene,
onClose,
onLoginSuccess,
onBindSuccess,
}) => {
const setAuth = useAuthStore((state) => state.setAuth)
const setUser = useAuthStore((state) => state.setUser)
const [status, setStatus] = useState<QrStatus>("loading")
const [errorMsg, setErrorMsg] = useState("")
/** 刷新二维码计数:变化时重新请求授权链接并重渲染 */
const [renderSeq, setRenderSeq] = useState(0)
/** 最新授权链接,用于"整页打开"兜底 */
const authUrlRef = useRef<string | null>(null)
const isLogin = scene === "login"
// 初始化:获取授权链接 → 加载 WxLogin JS → 内嵌渲染二维码
useEffect(() => {
if (!open) return
let cancelled = false
authUrlRef.current = null
setStatus("loading")
setErrorMsg("")
const init = async () => {
try {
const fetchUrl = isLogin ? getWechatAuthUrl : getWechatBindUrl
const result = await fetchUrl()
if (cancelled) return
// 写 state(整页跳转兜底路径的回调页也会清理它)
localStorage.setItem(STATE_STORAGE_KEY[scene], result.state)
authUrlRef.current = result.auth_url
const params = parseWxAuthUrl(result.auth_url, result.state)
if (!params) {
// 授权链接格式异常:直接整页跳转,由微信侧/回调页兜底
window.location.href = result.auth_url
return
}
const WxLogin = await loadWxLoginScript()
if (cancelled) return
// 等 Modal portal 中的容器挂载完成
const container = await waitForContainer(CONTAINER_ID[scene])
if (cancelled) return
if (!container) {
window.location.href = result.auth_url
return
}
container.innerHTML = ""
new WxLogin({
self_redirect: true,
id: CONTAINER_ID[scene],
appid: params.appid,
scope: "snsapi_login",
redirect_uri: params.redirect_uri,
state: params.state,
style: "black",
})
if (!cancelled) setStatus("qrcode")
} catch (err) {
if (cancelled) return
if (authUrlRef.current) {
// 授权链接已拿到但二维码脚本加载失败/超时:回退整页跳转
window.location.href = authUrlRef.current
return
}
// 授权链接接口本身失败:弹窗内展示真实原因,允许重试
setErrorMsg(getErrorMessage(err, "微信服务暂不可用,请稍后重试"))
setStatus("error")
}
}
init()
return () => {
cancelled = true
}
}, [open, scene, isLogin, renderSeq])
// 监听 iframe 内回调页 postMessage 回来的扫码结果
useEffect(() => {
if (!open) return
const handleMessage = async (event: MessageEvent) => {
// 只接受同源消息
if (event.origin !== window.location.origin) return
if (!isWechatQrMessage(event.data, scene)) return
const msg = event.data
if (msg.success) {
if (isLogin) {
// iframe 内回调页已把 token 写入 localStorage(同源共享),
// 父窗口同步内存登录态后交给调用方跳转
try {
const userData = await getCurrentUser()
const user = normalizeUser(userData) as User
setAuth(
user,
localStorage.getItem("access_token") || "",
localStorage.getItem("refresh_token"),
)
scheduleProactiveRefresh()
} catch {
// token 已持久化,即使这里失败路由守卫/刷新也能恢复登录态
}
onLoginSuccess?.(msg.payload?.needOnboarding ?? false)
} else {
try {
const userData = await getCurrentUser()
setUser(normalizeUser(userData) as User)
} catch {
// 绑定结果以后端为准,调用方 invalidateQueries 会兜底刷新
}
onBindSuccess?.()
}
return
}
// 失败:弹窗内展示回调页透传的真实原因,提供刷新/整页跳转
setErrorMsg(msg.detail || "微信授权失败,请重试")
setStatus("error")
}
window.addEventListener("message", handleMessage)
return () => window.removeEventListener("message", handleMessage)
}, [open, scene, isLogin, onLoginSuccess, onBindSuccess, setAuth, setUser])
const handleRefresh = () => setRenderSeq((seq) => seq + 1)
const handleFullPageRedirect = () => {
if (authUrlRef.current) {
window.location.href = authUrlRef.current
}
}
return (
<Modal
title={isLogin ? "微信扫码登录" : "绑定微信"}
open={open}
onCancel={onClose}
footer={null}
width={380}
maskClosable={false}
destroyOnHidden
>
<div className="xx-wechat-qr-modal">
{/* 二维码容器常驻:WxLogin 在 loading 阶段就会把 iframe 渲染进来,
不能按 status 条件渲染,否则 effect 里永远找不到容器 */}
<div
id={CONTAINER_ID[scene]}
className="xx-wechat-qr-container"
style={{ visibility: status === "qrcode" ? "visible" : "hidden" }}
/>
{status === "loading" && (
<div className="xx-wechat-qr-overlay">
<Spin size="large" />
<p>...</p>
</div>
)}
{status === "qrcode" && (
<p className="xx-wechat-qr-tip">使{isLogin ? "登录" : "绑定账号"}</p>
)}
{status === "error" && (
<div className="xx-wechat-qr-overlay xx-wechat-qr-error">
<p className="xx-wechat-qr-error-msg">{errorMsg}</p>
<div className="xx-wechat-qr-error-actions">
<Button buttonType="primary" buttonSize="md" onClick={handleRefresh}>
</Button>
{authUrlRef.current && (
<button
type="button"
className="xx-wechat-qr-fallback"
onClick={handleFullPageRedirect}
>
使
</button>
)}
</div>
</div>
)}
</div>
</Modal>
)
}
export default WechatQrModal
@@ -0,0 +1,71 @@
/**
* 微信扫码弹窗与 iframe 内回调页之间的 postMessage 消息协议
*
* 流程:弹窗内 WxLogin(self_redirect:true) 渲染的二维码 iframe 扫码后,
* 微信重定向到本站回调页(同源,在 iframe 内加载);回调页完成换 token/绑定后,
* 通过 window.parent.postMessage 把结果通知弹窗,弹窗负责关闭/展示错误/同步登录态。
*/
/** 扫码场景:登录 / 绑定 */
export type WechatQrScene = "login" | "bind"
export interface WechatQrSuccessPayload {
/** 登录场景:是否需要昵称引导(新用户或资料未完善) */
needOnboarding?: boolean
}
export interface WechatQrMessageData {
/** 固定协议标识,父窗口只认该 source */
source: "xiaoxia-wechat-qr"
/** 场景,需与弹窗发起时一致(login/bind),父窗口据此过滤 */
scene: WechatQrScene
/** 成功 / 失败 */
success: boolean
/** 失败时的真实原因(已在回调页拼好,含后端 detail) */
detail?: string
payload?: WechatQrSuccessPayload
}
export const WECHAT_QR_MESSAGE_SOURCE = "xiaoxia-wechat-qr"
/** 判断收到的 message 是否为本协议消息(且场景匹配) */
export function isWechatQrMessage(
data: unknown,
scene: WechatQrScene,
): data is WechatQrMessageData {
if (!data || typeof data !== "object") return false
const msg = data as Partial<WechatQrMessageData>
return msg.source === WECHAT_QR_MESSAGE_SOURCE && msg.scene === scene
}
/** 当前页面是否运行在 iframe(弹窗内嵌二维码)中 */
export function isInIframe(): boolean {
try {
return window.parent !== window
} catch {
// 跨域访问 window.parent 可能抛异常,按非 iframe 处理
return false
}
}
/**
* iframe 内回调页向父窗口上报扫码结果。同源回调页加载,targetOrigin 限定本站 origin。
*/
export function postWechatQrResult(
scene: WechatQrScene,
success: boolean,
options?: { detail?: string; needOnboarding?: boolean },
): void {
if (!isInIframe()) return
const data: WechatQrMessageData = {
source: WECHAT_QR_MESSAGE_SOURCE,
scene,
success,
detail: options?.detail,
payload:
success && options?.needOnboarding !== undefined
? { needOnboarding: options.needOnboarding }
: undefined,
}
window.parent.postMessage(data, window.location.origin)
}
@@ -0,0 +1,85 @@
/**
* 全局错误边界:专门兜底"发版后旧标签页懒加载 chunk 失效"导致的白屏,
* 同时兜住页面级渲染崩溃,避免任何未捕获错误导致整页白屏无反馈。
*
* 捕获到 ChunkLoadError / Failed to fetch dynamically imported module
* 1. 首次:自动整页刷新一次(sessionStorage 标记,刷新后 index.html 重新拉取,
* 拿到新 chunk 引用,白屏自愈)
* 2. 刷新后仍失败(标记未过期):不再自动刷新,显示"系统已更新,请点击刷新"
* 兜底界面,由用户手动点击
*
* 其他非 chunk 错误:显示通用错误页 + "返回首页"按钮(跳首页而非刷新当前 URL,
* 避免刷新后再次命中同一路由崩溃形成死循环)。
*/
import React from "react"
import { Button, Result } from "antd"
import {
getChunkReloadedAt,
goHomeRecover,
isChunkLoadError,
reloadForChunkError,
} from "@/utils/chunkLoadError"
interface Props {
children: React.ReactNode
}
interface State {
error: Error | null
isChunkError: boolean
/** 捕获错误时是否已经自动刷新过(决定显示自动刷新中还是手动兜底) */
alreadyReloaded: boolean
}
class ChunkErrorBoundary extends React.Component<Props, State> {
state: State = { error: null, isChunkError: false, alreadyReloaded: false }
static getDerivedStateFromError(error: Error): State {
const chunk = isChunkLoadError(error)
return {
error,
isChunkError: chunk,
alreadyReloaded: chunk ? getChunkReloadedAt() !== null : false,
}
}
componentDidCatch(error: Error): void {
// 仅 chunk 错误且本次会话没自动刷新过 → 打标记并整页刷新(自愈)
if (isChunkLoadError(error) && getChunkReloadedAt() === null) {
reloadForChunkError()
}
}
render(): React.ReactNode {
const { error, isChunkError, alreadyReloaded } = this.state
if (!error) return this.props.children
if (isChunkError && !alreadyReloaded) {
// 已打标记、componentDidCatch 里已触发 reload;极短瞬间展示加载中
return (
<Result status="info" title="系统正在更新" subTitle="检测到新版本,正在自动刷新页面…" />
)
}
// 手动兜底统一跳首页(整页导航):chunk 失效时脱离旧 chunk 引用;
// 业务崩溃时绕开当前报错路由,避免刷新-再崩死循环
return (
<Result
status="warning"
title={isChunkError ? "系统已更新" : "页面出现异常"}
subTitle={
isChunkError
? "检测到新版本,请点击下方按钮回到首页加载最新内容。"
: "页面加载遇到问题,点击返回首页通常可以恢复,未保存的内容可能丢失。"
}
extra={
<Button type="primary" onClick={goHomeRecover}>
{isChunkError ? "刷新并返回首页" : "返回首页"}
</Button>
}
/>
)
}
}
export default ChunkErrorBoundary
+4 -1
View File
@@ -9,6 +9,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ConfigProvider, App as AntApp } from "antd"
import zhCN from "antd/locale/zh_CN"
import router from "./router"
import ChunkErrorBoundary from "./components/common/ChunkErrorBoundary"
import { scheduleProactiveRefresh } from "./api/auth/tokenRefresh"
// 应用启动时,如果用户已登录,立即调度主动 token 刷新
@@ -99,7 +100,9 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={queryClient}>
<ConfigProvider locale={zhCN} theme={theme}>
<AntApp>
<RouterProvider router={router} />
<ChunkErrorBoundary>
<RouterProvider router={router} />
</ChunkErrorBoundary>
</AntApp>
</ConfigProvider>
</QueryClientProvider>
+14
View File
@@ -831,6 +831,20 @@
color: #ef4444;
}
.xx-upload-queue-error-detail {
margin-top: 4px;
font-size: 12px;
line-height: 1.5;
color: #ef4444;
word-break: break-word;
white-space: normal;
}
.xx-upload-queue-error-hint {
margin-top: 2px;
color: #b45309;
}
.xx-upload-queue-actions {
display: flex;
gap: 6px;
@@ -12,7 +12,8 @@ import {
ReloadOutlined,
CloseOutlined,
} from "@ant-design/icons"
import type { UploadItem } from "../hooks/useAssetUpload"
import type { UploadItem, UploadFailStage } from "../hooks/useAssetUpload"
import { COMPLETE_RETRY_HINT } from "../hooks/useAssetUpload"
export interface UploadQueuePanelProps {
items: UploadItem[]
@@ -29,6 +30,13 @@ const STATUS_TEXT: Record<UploadItem["status"], string> = {
error: "上传失败",
}
/** 失败阶段中文名:让用户一眼看到失败发生在哪一步 */
const FAIL_STAGE_TEXT: Record<UploadFailStage, string> = {
prepare: "准备上传阶段",
transfer: "文件传输阶段",
complete: "确认入库阶段",
}
const UploadQueuePanel: React.FC<UploadQueuePanelProps> = ({
items,
onRetry,
@@ -82,8 +90,23 @@ const UploadQueuePanel: React.FC<UploadQueuePanelProps> = ({
{it.duplicated ? "素材已存在,已跳过" : STATUS_TEXT[it.status]}
{it.status === "preparing" && it.hint ? `${it.hint}` : ""}
{it.status === "uploading" ? ` ${it.progress}%` : ""}
{it.status === "error" && it.error ? `${it.error}` : ""}
{it.status === "error" && it.failedStage
? `${FAIL_STAGE_TEXT[it.failedStage]}`
: ""}
</div>
{it.status === "error" && it.error ? (
<div className="xx-upload-queue-error-detail" title={it.error}>
{it.error.split("\n").map((line, idx) =>
line === COMPLETE_RETRY_HINT ? (
<div key={idx} className="xx-upload-queue-error-hint">
{line}
</div>
) : (
<div key={idx}>{line}</div>
),
)}
</div>
) : null}
</div>
<span className="xx-upload-queue-actions">
{it.status === "error" && (
@@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { prepareDirectUploadHandle, type DirectUploadHandle } from "@/api/assets"
import { getErrorMessage, isErrorMsgShown } from "@/api/errors"
import { MAX_FILE_SIZE } from "../constants"
import {
computeFileHash,
@@ -44,9 +45,15 @@ export interface UploadItem {
/** 批量直传最大并发数,避免多文件瓜分上行带宽 */
const MAX_CONCURRENT = 3
/** complete 阶段失败后的错误提示:素材可能已在服务器处理中,重试不会重新上传 */
const COMPLETE_ERROR_HINT =
"确认请求失败,素材可能已在服务器处理中;点重试将安全确认,不会重新上传文件"
/** complete 阶段失败后的安全提示:素材可能已在后端建成,重试只重发 complete 幂等安全 */
export const COMPLETE_RETRY_HINT = "素材可能已在服务器处理中,点重试将安全确认,不会重新上传文件"
/** 失败阶段中文名(toast 提示用,明确失败发生在哪一步) */
const STAGE_LABEL: Record<UploadFailStage, string> = {
prepare: "准备上传",
transfer: "文件传输",
complete: "确认入库",
}
/**
* 素材批量上传 Hook
@@ -134,6 +141,20 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
}))
handlesRef.current.set(item.tempId, h)
// prepare 阶段后端 file_hash 命中素材库已有相同文件(skip_transfer / duplicated):
// 立即标记 done、调一次 refreshList 让已存在素材立即显示,跳过 transfer + complete
if (h.prepared.skip_transfer || h.prepared.duplicated) {
updateItem(item.tempId, {
status: "done",
duplicated: true,
assetId: h.prepared.asset_id,
})
handlesRef.current.delete(item.tempId)
refreshList()
message.info(`"${item.fileName}" 与素材库已有内容相同,已跳过`)
return
}
if (h.prepared.asset_id) {
updateItem(item.tempId, {
status: "uploading",
@@ -165,20 +186,25 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
message.success(`"${item.fileName}" 上传完成,正在转码处理`)
}
} catch (err: unknown) {
const detail = err instanceof Error ? err.message : "上传失败"
// 完整失败原因:HTTP 状态码 / OSS XML 的 Code+Message / 后端 detail
// 由 getErrorMessage 统一提取(OSS XHR 错误自带「OSS 直传失败: HTTP xxx ...」明细)
const detail = getErrorMessage(err, "未知错误")
console.error("[useAssetUpload] 上传失败:", item.fileName, stage, err)
if (stage === "complete") {
// complete 失败(超时/5xx/网络):后端记录可能已建成,handle 保留供幂等重试;
// 刷新列表让用户看到可能已创建的「处理中」素材,避免误以为没传上去而重复操作
// 刷新列表让用户看到可能已创建的「处理中」素材,避免误以为没传上去而重复操作
// 卡片同时展示真实错误原因 + 安全重试提示(重试只重发 complete,不重新上传)
refreshList()
updateItem(item.tempId, {
status: "error",
failedStage: "complete",
error: COMPLETE_ERROR_HINT,
error: `${detail}\n${COMPLETE_RETRY_HINT}`,
hint: undefined,
})
message.error(`"${item.fileName}" ${COMPLETE_ERROR_HINT}`)
if (!isErrorMsgShown(err)) {
message.error(`"${item.fileName}" 确认入库失败:${detail}`)
}
} else {
// prepare / transfer 失败:后端尚无素材记录,可安全全量重跑
handlesRef.current.delete(item.tempId)
@@ -188,7 +214,11 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
error: detail,
hint: undefined,
})
message.error(`"${item.fileName}" 上传失败:${detail}`)
// 拦截器已对后端错误弹过 toast(含真实 detail)时不重复弹;
// OSS XHR 直传错误不走 axios,必须在这里弹
if (!isErrorMsgShown(err)) {
message.error(`"${item.fileName}" ${STAGE_LABEL[stage]}失败:${detail}`)
}
}
}
},
+32 -24
View File
@@ -5,8 +5,8 @@ import React, { useState } from "react"
import { Form, Input, Checkbox, message } from "antd"
import { Link, useNavigate } from "react-router-dom"
import { useLogin } from "@/hooks/useAuth"
import { getWechatAuthUrl } from "@/api/auth"
import Button from "@/components/ui/Button"
import WechatQrModal from "@/components/auth/WechatQrModal"
import "./Login.css"
interface LoginFormValues {
@@ -19,7 +19,7 @@ const Login: React.FC = () => {
const navigate = useNavigate()
const loginMutation = useLogin()
const [form] = Form.useForm()
const [wechatLoading, setWechatLoading] = useState(false)
const [wechatQrOpen, setWechatQrOpen] = useState(false)
const onFinish = async (values: LoginFormValues) => {
try {
@@ -35,27 +35,28 @@ const Login: React.FC = () => {
}
}
const handleWechatLogin = async () => {
try {
setWechatLoading(true)
const result = await getWechatAuthUrl()
// 保存 state 到 localStorage 用于回调时验证
localStorage.setItem("wechat_state", result.state)
// 记录登录前的来源页,登录成功后跳回
const from = window.location.pathname + window.location.search
if (from !== "/login" && from !== "/register") {
localStorage.setItem("login_redirect", from)
} else {
localStorage.removeItem("login_redirect")
}
// 跳转到微信授权页
window.location.href = result.auth_url
} catch (error) {
if (!(error as { __msgShown?: boolean })?.__msgShown)
message.error("微信登录暂不可用,请稍后重试")
} finally {
setWechatLoading(false)
const handleWechatLogin = () => {
// 记录登录前的来源页,登录成功后(弹窗回调)跳回
const from = window.location.pathname + window.location.search
if (from !== "/login" && from !== "/register") {
localStorage.setItem("login_redirect", from)
} else {
localStorage.removeItem("login_redirect")
}
setWechatQrOpen(true)
// 弹窗打开期间按钮 disabled;WxLogin 脚本加载失败/超时时弹窗内会自动回退整页跳转
}
// 弹窗扫码登录成功:登录态已由弹窗同步,按用户类型跳转
const handleWechatQrSuccess = (needOnboarding: boolean) => {
setWechatQrOpen(false)
if (needOnboarding) {
navigate("/welcome/wechat", { replace: true })
return
}
const redirect = localStorage.getItem("login_redirect") || "/"
localStorage.removeItem("login_redirect")
navigate(redirect, { replace: true })
}
return (
@@ -126,10 +127,10 @@ const Login: React.FC = () => {
type="button"
className="xx-btn-wechat"
onClick={handleWechatLogin}
disabled={wechatLoading}
disabled={wechatQrOpen}
>
<span className="xx-wechat-icon">💬</span>
{wechatLoading ? "加载中..." : "微信登录"}
</button>
</div>
@@ -137,6 +138,13 @@ const Login: React.FC = () => {
<Link to="/register"></Link>
</div>
</div>
<WechatQrModal
open={wechatQrOpen}
scene="login"
onClose={() => setWechatQrOpen(false)}
onLoginSuccess={handleWechatQrSuccess}
/>
</div>
)
}
+31 -10
View File
@@ -1,49 +1,70 @@
/**
* 微信绑定回调页(已登录用户在设置页发起"绑定微信"扫码后回到这里)
* 用 code 调绑定接口把微信关联到当前账号,成功后回设置页
*
* 两种运行环境:
* - 整页跳转授权(旧流程/兜底):本页整页加载,成功/失败后 navigate 回设置页
* - 弹窗内嵌二维码(WxLogin self_redirect):本页在同源 iframe 内加载,
* 结果通过 postMessage 通知父窗口弹窗,不做页面导航
*/
import React, { useEffect, useState } from "react"
import { useSearchParams, useNavigate } from "react-router-dom"
import { Spin } from "antd"
import { bindWechat, normalizeUser } from "@/api/auth"
import { getErrorMessage } from "@/api/errors"
import { useAuthStore } from "@/store/authStore"
import { isInIframe, postWechatQrResult } from "@/components/auth/WechatQrModal/messages"
const WechatBindCallback: React.FC = () => {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const setUser = useAuthStore((state) => state.setUser)
const [error, setError] = useState<string | null>(null)
const inIframe = isInIframe()
useEffect(() => {
const code = searchParams.get("code")
const state = searchParams.get("state")
const fail = (message: string) => {
if (inIframe) {
// 弹窗模式:把真实原因上报父窗口在 Modal 内展示
postWechatQrResult("bind", false, { detail: message })
return
}
setError(message)
}
if (!code || !state) {
setError("无效的回调参数")
fail("无效的回调参数,请回到设置页重新扫码绑定")
return
}
const handleBind = async () => {
// state 校验:绑定场景由设置页生成并落库,前缀 bind:
const savedState = localStorage.getItem("wechat_bind_state")
if (!savedState || savedState !== state) {
setError("安全校验失败,请重新绑定")
return
}
// state 校验由后端 state store 一次性消费兜底(前端不再比对 localStorage
// 微信内打开/跨浏览器场景本地无 state 会误杀);清理绑定前写入的 state
localStorage.removeItem("wechat_bind_state")
try {
const result = await bindWechat(code, state)
setUser(normalizeUser(result.user))
if (inIframe) {
// 弹窗模式:通知父窗口关闭弹窗并刷新绑定状态
postWechatQrResult("bind", true)
return
}
// 用 replace 回设置页,query 携带成功标记由设置页提示
navigate("/app/profile?wechat_bind=success", { replace: true })
} catch {
navigate("/app/profile?wechat_bind=failed", { replace: true })
} catch (err) {
// 绑定失败直接在本页展示/上报真实原因(如微信已被其他账号绑定),不静默跳走
fail(`微信绑定失败:${getErrorMessage(err, "请回到设置页重试")}`)
}
}
handleBind()
}, [searchParams, navigate, setUser])
}, [searchParams, navigate, setUser, inIframe])
if (error) {
return (
+42 -13
View File
@@ -2,13 +2,20 @@
* 微信登录回调页
* 扫码授权后由微信重定向回来:用 code 换登录态,
* 新用户/资料未完善 → 跳昵称引导页;老用户 → 回来源页/首页
*
* 两种运行环境:
* - 整页跳转授权(旧流程/兜底):本页整页加载,按上述逻辑导航
* - 弹窗内嵌二维码(WxLogin self_redirect):本页在同源 iframe 内加载,
* 成功/失败均通过 postMessage 通知父窗口弹窗,不做页面导航
*/
import React, { useEffect, useState } from "react"
import { useSearchParams, useNavigate } from "react-router-dom"
import { Spin } from "antd"
import { wechatCallback, getCurrentUser, normalizeUser, type User } from "@/api/auth"
import { getErrorMessage } from "@/api/errors"
import { useAuthStore } from "@/store/authStore"
import { scheduleProactiveRefresh } from "@/api/auth/tokenRefresh"
import { isInIframe, postWechatQrResult } from "@/components/auth/WechatQrModal/messages"
const WechatCallback: React.FC = () => {
const [searchParams] = useSearchParams()
@@ -16,26 +23,41 @@ const WechatCallback: React.FC = () => {
const setAuth = useAuthStore((state) => state.setAuth)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const inIframe = isInIframe()
useEffect(() => {
const code = searchParams.get("code")
const state = searchParams.get("state")
if (!code || !state) {
setError("无效的回调参数")
const fail = (message: string) => {
if (inIframe) {
// 弹窗模式:把真实原因上报父窗口在 Modal 内展示,本页保持"处理中"即可
postWechatQrResult("login", false, { detail: message })
return
}
setError(message)
setLoading(false)
}
// 微信重定向出错时(如用户拒绝授权 error=access_denied)直接展示/上报原因
const wxErrorCode = searchParams.get("error")
const wxErrDesc = searchParams.get("error_description")
if (wxErrorCode || wxErrDesc) {
const reason = [wxErrorCode, wxErrDesc].filter(Boolean).join("")
fail(`微信授权失败:${reason}`)
return
}
if (!code || !state) {
fail("无效的回调参数,请重新扫码登录")
return
}
const handleCallback = async () => {
try {
// 校验 state,防止 CSRF
const savedState = localStorage.getItem("wechat_state")
if (!savedState || savedState !== state) {
setError("安全校验失败,请重新登录")
setLoading(false)
return
}
// state CSRF 校验由后端 state store 一次性消费兜底(前端不再比对
// localStorage——微信内打开、跨浏览器等场景本地没有 state,会误杀正常回调);
// 清理登录前写入的 state,避免残留
localStorage.removeItem("wechat_state")
const result = await wechatCallback(code, state)
@@ -54,6 +76,13 @@ const WechatCallback: React.FC = () => {
// 新用户 或 资料未完善(如上次中断没填昵称)→ 强制昵称引导
const needOnboarding = result.is_new_user || user.profile_completed === false
if (inIframe) {
// 弹窗模式:token 已写入同源 localStorage,通知父窗口同步登录态并跳转
postWechatQrResult("login", true, { needOnboarding })
return
}
if (needOnboarding) {
navigate("/welcome/wechat", { replace: true })
return
@@ -63,14 +92,14 @@ const WechatCallback: React.FC = () => {
const redirect = localStorage.getItem("login_redirect") || "/"
localStorage.removeItem("login_redirect")
navigate(redirect, { replace: true })
} catch {
setError("微信登录失败,请重试")
setLoading(false)
} catch (err) {
// 透传后端真实错误(如 state 过期、code 已消费、接口异常),禁止吞成通用提示
fail(`微信登录失败:${getErrorMessage(err, "请重试或更换登录方式")}`)
}
}
handleCallback()
}, [searchParams, navigate, setAuth])
}, [searchParams, navigate, setAuth, inIframe])
if (loading) {
return (
+18 -4
View File
@@ -2,11 +2,12 @@
* 微信新用户昵称引导页
* 新微信用户首次登录后强制填写昵称,完成后才进入主界面
*/
import React from "react"
import React, { useRef } from "react"
import { Form, Input, message } from "antd"
import { Navigate, useNavigate } from "react-router-dom"
import { useMutation } from "@tanstack/react-query"
import { updateProfile } from "@/api/auth"
import { getErrorMessage, isErrorMsgShown } from "@/api/errors"
import { useAuthStore } from "@/store/authStore"
import Button from "@/components/ui/Button"
import "./Login.css"
@@ -22,6 +23,10 @@ const WechatOnboarding: React.FC = () => {
const user = useAuthStore((state) => state.user)
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
const [form] = Form.useForm<OnboardingFormValues>()
// 同步防连点守卫:antd loading 要等 React 重渲染后才禁用按钮,
// 连点两次时第一次的 mutation 刚触发、重渲染未发生,第二次 click 仍会进来
// (截图里 PATCH /me 405 出现两次就是连点导致的重复提交)
const submittingRef = useRef(false)
const saveMutation = useMutation({
mutationFn: (displayName: string) => updateProfile({ display_name: displayName }),
@@ -37,6 +42,8 @@ const WechatOnboarding: React.FC = () => {
}
const onFinish = async (values: OnboardingFormValues) => {
if (submittingRef.current) return
submittingRef.current = true
try {
const updated = await saveMutation.mutateAsync(values.display_name.trim())
// 后端返回的 profile_completed 以最新资料为准,前端同步标记完善
@@ -45,9 +52,14 @@ const WechatOnboarding: React.FC = () => {
const redirect = localStorage.getItem("login_redirect") || "/app/dashboard"
localStorage.removeItem("login_redirect")
navigate(redirect, { replace: true })
} catch {
message.error("保存失败,请重试")
} catch (err) {
// 透传后端真实原因(如接口异常/校验失败);拦截器已弹过的不重复弹
if (!isErrorMsgShown(err)) {
message.error(`昵称保存失败:${getErrorMessage(err, "请稍后重试")}`)
}
submittingRef.current = false
}
// 成功时页面跳走,不复位
}
return (
@@ -67,7 +79,8 @@ const WechatOnboarding: React.FC = () => {
onFinish={onFinish}
autoComplete="off"
layout="vertical"
initialValues={{ display_name: user?.display_name || "" }}
// 不预填:新微信用户必须自己输入昵称(user.display_name 可能是微信昵称/系统占位)
initialValues={{ display_name: "" }}
>
<Form.Item
name="display_name"
@@ -88,6 +101,7 @@ const WechatOnboarding: React.FC = () => {
buttonSize="lg"
htmlType="submit"
loading={saveMutation.isPending}
disabled={saveMutation.isPending}
style={{ width: "100%" }}
>
{saveMutation.isPending ? "保存中..." : "进入小虾智剪"}
+16 -10
View File
@@ -8,9 +8,10 @@ import { useSearchParams } from "react-router-dom"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { Button, Input, Modal } from "@/components/ui"
import { getCurrentUser, updateProfile, getWechatBindUrl, unbindWechat } from "@/api/auth"
import { getCurrentUser, updateProfile, unbindWechat } from "@/api/auth"
import { useAuthStore } from "@/store/authStore"
import PageHead from "@/components/layout/PageHead"
import WechatQrModal from "@/components/auth/WechatQrModal"
import "./ProfileSettings.css"
const Settings: React.FC = () => {
@@ -19,6 +20,7 @@ const Settings: React.FC = () => {
const queryClient = useQueryClient()
const [searchParams, setSearchParams] = useSearchParams()
const [displayName, setDisplayName] = useState(user?.display_name || "")
const [wechatBindOpen, setWechatBindOpen] = useState(false)
const bindTipShownRef = useRef(false)
// 拉取最新用户信息(微信绑定状态以后端为准)
@@ -62,14 +64,11 @@ const Settings: React.FC = () => {
},
})
const handleBindWechat = async () => {
try {
const result = await getWechatBindUrl()
localStorage.setItem("wechat_bind_state", result.state)
window.location.href = result.auth_url
} catch {
message.error("微信绑定暂不可用,请稍后重试")
}
// 弹窗扫码绑定成功:关闭弹窗,刷新用户信息并提示
const handleBindSuccess = () => {
setWechatBindOpen(false)
queryClient.invalidateQueries({ queryKey: ["currentUser"] })
message.success("微信绑定成功")
}
const unbindMutation = useMutation({
@@ -177,13 +176,20 @@ const Settings: React.FC = () => {
</Button>
) : (
<Button buttonType="primary" buttonSize="md" onClick={handleBindWechat}>
<Button buttonType="primary" buttonSize="md" onClick={() => setWechatBindOpen(true)}>
</Button>
)}
</div>
</div>
</div>
<WechatQrModal
open={wechatBindOpen}
scene="bind"
onClose={() => setWechatBindOpen(false)}
onBindSuccess={handleBindSuccess}
/>
</div>
)
}
+29 -112
View File
@@ -1,6 +1,7 @@
import { Navigate, type RouteObject } from "react-router-dom"
import MainLayout from "@/components/layout/MainLayout"
import { ProtectedRoute } from "./ProtectedRoute"
import { lazyRoute } from "./lazyRoute"
/**
* 受保护的 /app 子路由
@@ -13,202 +14,118 @@ const appChildren: RouteObject[] = [
},
{
path: "dashboard",
lazy: () =>
import("@/pages/dashboard/Dashboard").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/dashboard/Dashboard")),
},
{
path: "assets",
lazy: () =>
import("@/pages/assets/AssetLibrary").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/assets/AssetLibrary")),
},
{
path: "titles",
lazy: () =>
import("@/pages/titles/TitleLibrary").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/titles/TitleLibrary")),
},
{
path: "voices",
lazy: () =>
import("@/pages/voices/VoiceLibrary").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/voices/VoiceLibrary")),
},
{
path: "templates",
lazy: () =>
import("@/pages/templates/TemplateLibrary").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/templates/TemplateLibrary")),
},
{
path: "generate",
lazy: () =>
import("@/pages/generate/GeneratePage").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/generate/GeneratePage")),
},
{
path: "history",
lazy: () =>
import("@/pages/history/TaskHistory").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/history/TaskHistory")),
},
{
path: "products",
lazy: () =>
import("@/pages/products/ProductLibrary").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/products/ProductLibrary")),
},
{
path: "products/:id",
lazy: () =>
import("@/pages/products/ProductDetail").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/products/ProductDetail")),
},
{
path: "tasks",
lazy: () =>
import("@/pages/tasks/TaskCenter").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/tasks/TaskCenter")),
},
{
path: "editing-planner",
lazy: () =>
import("@/pages/editing-planner/EditingPlanner").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/editing-planner/EditingPlanner")),
},
{
path: "my-templates",
lazy: () =>
import("@/pages/my-templates/MyTemplates").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/my-templates/MyTemplates")),
},
{
path: "voice-clone",
lazy: () =>
import("@/pages/voice-clone/VoiceClone").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/voice-clone/VoiceClone")),
},
{
path: "voice-materials",
lazy: () =>
import("@/pages/voice-materials/VoiceMaterialLibrary").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/voice-materials/VoiceMaterialLibrary")),
},
{
path: "my-voices",
lazy: () =>
import("@/pages/my-voices/MyVoices").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/my-voices/MyVoices")),
},
{
path: "accounts",
lazy: () =>
import("@/pages/accounts/Accounts").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/accounts/Accounts")),
},
{
path: "duplication",
lazy: () =>
import("@/pages/duplication/DuplicationUpload").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/duplication/DuplicationUpload")),
},
{
path: "duplication/results",
lazy: () =>
import("@/pages/duplication/DuplicationResults").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/duplication/DuplicationResults")),
},
{
path: "duplication/:id",
lazy: () =>
import("@/pages/duplication/DuplicationDetail").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/duplication/DuplicationDetail")),
},
{
path: "subscription",
lazy: () =>
import("@/pages/subscription/Plans").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/subscription/Plans")),
},
{
path: "subscription/upgrade",
lazy: () =>
import("@/pages/subscription/UpgradeSubscription").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/subscription/UpgradeSubscription")),
},
{
path: "subscription/billing",
lazy: () =>
import("@/pages/subscription/Billing").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/subscription/Billing")),
},
{
path: "profile",
lazy: () =>
import("@/pages/profile/Settings").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/profile/Settings")),
},
{
path: "admin",
children: [
{
index: true,
lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
},
{
path: "users",
lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
},
{
path: "analytics",
lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
},
{
path: "monitor",
lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
},
{
path: "logs",
lazy: () =>
import("@/pages/admin/AdminComingSoon").then((m) => ({
Component: m.default,
})),
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
},
],
},
+40
View File
@@ -0,0 +1,40 @@
import type { LazyRouteFunction, RouteObject } from "react-router-dom"
import { isChunkLoadError } from "@/utils/chunkLoadError"
/**
* 给 React Router data router 的路由懒加载包一层自动重试:
*
* - 网络抖动 / 瞬态失败:自动重试最多 2 次(间隔 300ms / 800ms),用户无感恢复
* - 发版后旧 chunk 404(chunk 文件名已不存在):重试也拿不到旧文件名,
* 重试耗尽后抛出,由全局 ChunkErrorBoundary 捕获并引导整页刷新
* (刷新后 index.html 是 no-cache 的,会拿到新 chunk 引用)
*/
const RETRY_DELAYS_MS = [300, 800]
const RETRY_COUNT = RETRY_DELAYS_MS.length
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
export const lazyRoute = (
factory: () => Promise<{ default: React.ComponentType }>,
): LazyRouteFunction<RouteObject> => {
return async () => {
let lastError: unknown
for (let attempt = 0; attempt <= RETRY_COUNT; attempt++) {
try {
const mod = await factory()
if (!mod.default) {
throw new Error("lazyRoute: 目标模块缺少 default 导出")
}
return { Component: mod.default }
} catch (err) {
lastError = err
// 非 chunk 加载错误(代码 bug 等)立即抛出,不浪费重试
if (!isChunkLoadError(err)) throw err
if (attempt < RETRY_COUNT) {
await sleep(RETRY_DELAYS_MS[attempt])
}
}
}
throw lastError
}
}
+122
View File
@@ -242,6 +242,20 @@ describe("assets API", () => {
await expect(completeDirectUpload({ name: "test-item" })).resolves.not.toThrow()
})
it("请求体携带 file_size(后端同名兜底去重的大小校验依赖它)", async () => {
await completeDirectUpload({
project_id: "p-1",
library_id: "l-1",
storage_key: "uploads/k.mp4",
file_size: 12345,
} as never)
const completeCalls = mockPost.mock.calls.filter(
([u]: [string]) => u === "/upload/direct/complete",
)
expect(completeCalls).toHaveLength(1)
expect(completeCalls[0][1]).toMatchObject({ file_size: 12345 })
})
it("should reject on API error", async () => {
mockGet.mockRejectedValue(new Error("Network error"))
mockPost.mockRejectedValue(new Error("Network error"))
@@ -259,6 +273,114 @@ describe("assets API", () => {
})
})
describe("uploadAssetDirect skip_transfer 短路", () => {
it("prepare 返回 skip_transfer=true → 直接返回 duplicated,不调 transfer/complete", async () => {
mockPost.mockImplementation((url: string) => {
if (url === "/upload/direct/prepare") {
return Promise.resolve({
data: {
upload_url: "https://oss/x",
method: "POST",
storage_key: "uploads/skip/y.mp4",
expires_at: "2099",
fields: {},
max_size_bytes: 1e9,
asset_id: "existing-asset",
skip_transfer: true,
duplicated: true,
},
})
}
if (url === "/upload/direct/complete") {
throw new Error("complete 不应被调用")
}
throw new Error("unexpected url " + url)
})
const putSpy = vi.spyOn(globalThis, "XMLHttpRequest")
const file = new File(["x"], "x.mp4", { type: "video/mp4" })
const result = await uploadAssetDirect({ file, library_id: "lib-1" })
expect(result.duplicated).toBe(true)
expect(result.asset_id).toBe("existing-asset")
// complete 未被调用(mockPost 只记录 preparecomplete 若调用会抛 "不应被调用"
const completeCalls = mockPost.mock.calls.filter(
([u]: [string]) => u === "/upload/direct/complete",
)
expect(completeCalls).toHaveLength(0)
putSpy.mockRestore()
})
it("prepare 返回 skip_transfer=false → 走老流程(complete 被调用)", async () => {
mockPost.mockImplementation((url: string) => {
if (url === "/upload/direct/prepare") {
return Promise.resolve({
data: {
upload_url: "https://oss/x",
method: "POST",
storage_key: "uploads/normal/y.mp4",
expires_at: "2099",
fields: {},
max_size_bytes: 1e9,
asset_id: "new-asset",
},
})
}
if (url === "/upload/direct/complete") {
return Promise.resolve({
data: {
storage_key: "uploads/normal/y.mp4",
ingest_job_id: "job-1",
url: "https://oss/y.mp4",
duplicated: false,
asset_id: "new-asset",
},
})
}
throw new Error("unexpected url " + url)
})
// mock XMLHttpRequestsend 之后下一 tick 触发 onload 让 transfer 立即成功
const origOpen = XMLHttpRequest.prototype.open
const origSend = XMLHttpRequest.prototype.send
const origSetReadyState = Object.getOwnPropertyDescriptor(
XMLHttpRequest.prototype,
"readyState",
) as PropertyDescriptor | undefined
const origStatus = Object.getOwnPropertyDescriptor(XMLHttpRequest.prototype, "status")
Object.defineProperty(XMLHttpRequest.prototype, "readyState", {
configurable: true,
writable: true,
value: 4,
})
Object.defineProperty(XMLHttpRequest.prototype, "status", {
configurable: true,
writable: true,
value: 200,
})
XMLHttpRequest.prototype.open = vi.fn() as unknown as typeof origOpen
XMLHttpRequest.prototype.send = vi.fn(function (this: XMLHttpRequest) {
// 下一 tick 触发 onload(模拟 XHR 异步完成)
setTimeout(() => this.onload?.(new ProgressEvent("load")), 0)
}) as unknown as typeof origSend
const file = new File(["x"], "x.mp4", { type: "video/mp4" })
const result = await uploadAssetDirect({ file, library_id: "lib-1" })
expect(result.duplicated).toBeFalsy()
expect(result.asset_id).toBe("new-asset")
const completeCalls = mockPost.mock.calls.filter(
([u]: [string]) => u === "/upload/direct/complete",
)
expect(completeCalls).toHaveLength(1)
// complete 请求必须带上 file_size,否则后端同名兜底会误杀同名新视频
expect(completeCalls[0][1]).toMatchObject({ file_size: file.size })
XMLHttpRequest.prototype.open = origOpen
XMLHttpRequest.prototype.send = origSend
if (origSetReadyState) {
Object.defineProperty(XMLHttpRequest.prototype, "readyState", origSetReadyState)
}
if (origStatus) {
Object.defineProperty(XMLHttpRequest.prototype, "status", origStatus)
}
})
})
describe("getIngestJob", () => {
it("should resolve successfully", async () => {
await expect(getIngestJob("test-jobId")).resolves.not.toThrow()
+31 -2
View File
@@ -1,10 +1,12 @@
/**
* 上传去重/幂等工具单测(Issue #1714
*/
import { describe, it, expect } from "vitest"
import { describe, it, expect, vi } from "vitest"
import {
computeFileHash,
findDuplicateInQueue,
HASH_FULL_READ_LIMIT,
HASH_SAMPLE_CHUNK,
makeClientUploadId,
makeFileFingerprint,
} from "@/api/assets/uploadDedup"
@@ -83,7 +85,7 @@ describe("computeFileHash", () => {
})
})
describe("computeFileHash 大文件抽样(>256MB", () => {
describe("computeFileHash 大文件抽样(>64MB", () => {
it("抽样路径正常返回 64 位 hex,且大小不同则 hash 不同", async () => {
// mock 一个「声称」300MB 的 Fileslice 返回小 buffer 即可,不真分配 300MB
const makeBig = (declaredSize: number, head: number) => {
@@ -98,4 +100,31 @@ describe("computeFileHash 大文件抽样(>256MB", () => {
// 声明大小不同 → 写入的 64 位 size 字段不同 → hash 必须不同(锁定 setBigUint64 路径)
expect(h1).not.toBe(h2)
})
it("≤64MB 走全量读取(slice 一次覆盖整个文件)", async () => {
const f = new File([new Uint8Array(1024).fill(9)], "full.mp4", { type: "video/mp4" })
Object.defineProperty(f, "size", { value: HASH_FULL_READ_LIMIT, configurable: true })
const sliceSpy = vi.spyOn(f, "slice")
await computeFileHash(f)
// 全量路径:唯一一次 slice 为 (0, size)
expect(sliceSpy).toHaveBeenCalledTimes(1)
expect(sliceSpy).toHaveBeenCalledWith(0, HASH_FULL_READ_LIMIT)
sliceSpy.mockRestore()
})
it(">64MB 只读取头尾各 16MB 抽样,绝不整文件读入内存", async () => {
const f = new File([new Uint8Array(1024).fill(9)], "big.mp4", { type: "video/mp4" })
Object.defineProperty(f, "size", { value: HASH_FULL_READ_LIMIT + 1, configurable: true })
const sliceSpy = vi.spyOn(f, "slice")
await computeFileHash(f)
// 抽样路径:两次 slice —— 头部 (0, 16MB) 与尾部 (size-16MB, size)
expect(sliceSpy).toHaveBeenCalledTimes(2)
expect(sliceSpy).toHaveBeenNthCalledWith(1, 0, HASH_SAMPLE_CHUNK)
expect(sliceSpy).toHaveBeenNthCalledWith(
2,
HASH_FULL_READ_LIMIT + 1 - HASH_SAMPLE_CHUNK,
HASH_FULL_READ_LIMIT + 1,
)
sliceSpy.mockRestore()
})
})
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
describe("wxLogin 工具", () => {
describe("parseWxAuthUrl", () => {
it("从微信授权链接解析出 appid/redirect_uri/stateredirect_uri 解码)", async () => {
const { parseWxAuthUrl } = await import("@/api/auth/wxLogin")
const authUrl =
"https://open.weixin.qq.com/connect/qrconnect?appid=wxb7ae80b48e53980d" +
"&redirect_uri=https%3A%2F%2Fstaging.xiaoxiajianji.com%2Fauth%2Fwechat%2Fcallback" +
"&response_type=code&scope=snsapi_login&state=abc123#wechat_redirect"
const params = parseWxAuthUrl(authUrl)
expect(params).not.toBeNull()
expect(params?.appid).toBe("wxb7ae80b48e53980d")
expect(params?.redirect_uri).toBe("https://staging.xiaoxiajianji.com/auth/wechat/callback")
expect(params?.state).toBe("abc123")
})
it("链接里缺 state 时回退使用 stateFallback", async () => {
const { parseWxAuthUrl } = await import("@/api/auth/wxLogin")
const authUrl =
"https://open.weixin.qq.com/connect/qrconnect?appid=wx123" +
"&redirect_uri=https%3A%2F%2Fexample.com%2Fcb"
const params = parseWxAuthUrl(authUrl, "fallback-state")
expect(params?.state).toBe("fallback-state")
})
it("缺 appid 或 redirect_uri 时返回 null(调用方应回退整页跳转)", async () => {
const { parseWxAuthUrl } = await import("@/api/auth/wxLogin")
expect(parseWxAuthUrl("https://open.weixin.qq.com/connect/qrconnect?appid=wx123")).toBeNull()
expect(parseWxAuthUrl("not a url")).toBeNull()
})
})
describe("loadWxLoginScript", () => {
beforeEach(() => {
vi.resetModules()
document.head.querySelectorAll("script[src*='wxLogin']").forEach((el) => el.remove())
delete (window as unknown as { WxLogin?: unknown }).WxLogin
})
afterEach(() => {
vi.restoreAllMocks()
})
it("window.WxLogin 已存在时直接复用,不重复插入 script", async () => {
const fakeCtor = vi.fn()
;(window as unknown as { WxLogin: unknown }).WxLogin = fakeCtor
const { loadWxLoginScript } = await import("@/api/auth/wxLogin")
const ctor = await loadWxLoginScript()
expect(ctor).toBe(fakeCtor)
expect(document.head.querySelector("script[src*='wxLogin']")).toBeNull()
})
it("脚本 onerror 时 reject(调用方据此回退整页跳转)", async () => {
const { loadWxLoginScript } = await import("@/api/auth/wxLogin")
const promise = loadWxLoginScript()
const script = document.head.querySelector(
"script[src*='wxLogin']",
) as HTMLScriptElement | null
expect(script).not.toBeNull()
script?.dispatchEvent(new Event("error"))
await expect(promise).rejects.toThrow(/加载失败/)
})
})
})
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
import { render, screen, fireEvent } from "@testing-library/react"
import { Button } from "antd"
import { useState } from "react"
import ChunkErrorBoundary from "@/components/common/ChunkErrorBoundary"
import * as chunkUtils from "@/utils/chunkLoadError"
// reload 函数 mock 掉(jsdom 不支持真实 window.location.reload
vi.mock("@/utils/chunkLoadError", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/utils/chunkLoadError")>()
return {
...actual,
reloadForChunkError: vi.fn(),
goHomeRecover: vi.fn(),
}
})
const { reloadForChunkError, goHomeRecover } = vi.mocked(chunkUtils)
/** 渲染时直接抛错的子组件 */
const Boom: React.FC<{ error: Error }> = ({ error }) => {
throw error
}
/** 点击按钮后才抛 chunk 错误的子组件 */
const ChunkBoomButton: React.FC = () => {
const [boom, setBoom] = useState(false)
if (boom) {
throw new TypeError("Failed to fetch dynamically imported module: /assets/x.js")
}
return <Button onClick={() => setBoom(true)}>boom</Button>
}
const renderBoundary = (ui: React.ReactNode) =>
render(<ChunkErrorBoundary>{ui}</ChunkErrorBoundary>)
beforeEach(() => {
sessionStorage.clear()
vi.clearAllMocks()
// error boundary 捕获后 React 会打 error log,静默掉
vi.spyOn(console, "error").mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
sessionStorage.clear()
})
describe("ChunkErrorBoundary", () => {
it("正常渲染 children", () => {
renderBoundary(<div>hello-child</div>)
expect(screen.getByText("hello-child")).toBeInTheDocument()
})
it("首次捕获 chunk 错误 → 自动刷新(reloadForChunkError)并显示自动刷新提示", () => {
renderBoundary(<ChunkBoomButton />)
fireEvent.click(screen.getByText("boom"))
expect(reloadForChunkError).toHaveBeenCalledTimes(1)
expect(screen.getByText(/正在自动刷新/)).toBeInTheDocument()
})
it("已刷新过仍失败 → 不再自动刷新,显示手动兜底按钮", () => {
// 模拟"本会话已经自动刷新过一次"
sessionStorage.setItem("chunk_error_reloaded_at", String(Date.now()))
renderBoundary(
<Boom error={new TypeError("Failed to fetch dynamically imported module: /assets/y.js")} />,
)
expect(reloadForChunkError).not.toHaveBeenCalled()
expect(screen.getByText("系统已更新")).toBeInTheDocument()
// 点击兜底按钮 → goHomeRecover(跳首页,不刷新当前 URL)
fireEvent.click(screen.getByText("刷新并返回首页"))
expect(goHomeRecover).toHaveBeenCalledTimes(1)
})
it("非 chunk 错误 → 显示通用错误页,不触发 chunk 自动刷新", () => {
renderBoundary(<Boom error={new Error("普通业务报错")} />)
expect(reloadForChunkError).not.toHaveBeenCalled()
expect(screen.getByText("页面出现异常")).toBeInTheDocument()
})
})
@@ -0,0 +1,165 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react"
import WechatQrModal from "@/components/auth/WechatQrModal"
const { mockWxLoginCtor, mockGetAuthUrl, mockGetBindUrl, mockGetCurrentUser } = vi.hoisted(() => ({
mockWxLoginCtor: vi.fn(),
mockGetAuthUrl: vi.fn(),
mockGetBindUrl: vi.fn(),
mockGetCurrentUser: vi.fn(),
}))
vi.mock("@/api/auth", () => ({
getWechatAuthUrl: (...args: unknown[]) => mockGetAuthUrl(...args),
getWechatBindUrl: (...args: unknown[]) => mockGetBindUrl(...args),
getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args),
normalizeUser: (u: unknown) => u,
}))
vi.mock("@/api/auth/wxLogin", () => ({
loadWxLoginScript: vi.fn(async () => mockWxLoginCtor),
parseWxAuthUrl: vi.fn(() => ({
appid: "wxb7ae80b48e53980d",
redirect_uri: "https://staging.xiaoxiajianji.com/auth/wechat/callback",
state: "state-from-url",
})),
}))
vi.mock("@/api/auth/tokenRefresh", () => ({
scheduleProactiveRefresh: vi.fn(),
cancelProactiveRefresh: vi.fn(),
}))
const { mockSetAuth, mockSetUser } = vi.hoisted(() => ({
mockSetAuth: vi.fn(),
mockSetUser: vi.fn(),
}))
vi.mock("@/store/authStore", () => ({
useAuthStore: (selector: (s: unknown) => unknown) =>
selector({ setAuth: mockSetAuth, setUser: mockSetUser }),
}))
const AUTH_URL =
"https://open.weixin.qq.com/connect/qrconnect?appid=wxb7ae80b48e53980d" +
"&redirect_uri=https%3A%2F%2Fstaging.xiaoxiajianji.com%2Fauth%2Fwechat%2Fcallback&state=st123"
const postMessage = (data: Record<string, unknown>) =>
window.dispatchEvent(new MessageEvent("message", { data, origin: window.location.origin }))
beforeEach(() => {
vi.clearAllMocks()
mockGetAuthUrl.mockResolvedValue({ auth_url: AUTH_URL, state: "st123" })
mockGetBindUrl.mockResolvedValue({ auth_url: AUTH_URL, state: "st123" })
mockGetCurrentUser.mockResolvedValue({ id: 1, display_name: "测试用户" })
localStorage.clear()
})
afterEach(() => cleanup())
describe("WechatQrModal", () => {
it("open=false 时不渲染弹窗内容", () => {
render(<WechatQrModal open={false} scene="login" onClose={vi.fn()} />)
expect(screen.queryByText("微信扫码登录")).toBeNull()
})
it("登录场景:open 后请求授权链接、写入 state、用 WxLogin 渲染二维码", async () => {
render(<WechatQrModal open scene="login" onClose={vi.fn()} />)
await waitFor(() => expect(mockGetAuthUrl).toHaveBeenCalledTimes(1))
expect(localStorage.getItem("wechat_state")).toBe("st123")
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
expect(mockWxLoginCtor).toHaveBeenCalledWith(
expect.objectContaining({
self_redirect: true,
appid: "wxb7ae80b48e53980d",
scope: "snsapi_login",
state: "state-from-url",
redirect_uri: "https://staging.xiaoxiajianji.com/auth/wechat/callback",
}),
)
expect(screen.getByText(/请使用微信扫描二维码登录/)).toBeTruthy()
})
it("绑定场景:请求 bind/url 且写入 wechat_bind_state", async () => {
render(<WechatQrModal open scene="bind" onClose={vi.fn()} />)
await waitFor(() => expect(mockGetBindUrl).toHaveBeenCalledTimes(1))
expect(mockGetAuthUrl).not.toHaveBeenCalled()
expect(localStorage.getItem("wechat_bind_state")).toBe("st123")
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
})
it("获取授权链接失败时弹窗内展示错误并提供刷新", async () => {
mockGetAuthUrl.mockRejectedValueOnce({
response: { status: 500, data: { detail: "微信服务内部错误" } },
})
render(<WechatQrModal open scene="login" onClose={vi.fn()} />)
expect(await screen.findByText(/微信服务内部错误/)).toBeTruthy()
expect(screen.getByText("刷新二维码")).toBeTruthy()
// 点刷新后重新请求
fireEvent.click(screen.getByText("刷新二维码"))
await waitFor(() => expect(mockGetAuthUrl).toHaveBeenCalledTimes(2))
})
it("登录成功消息:同步登录态并回调 onLoginSuccess(needOnboarding)", async () => {
const onSuccess = vi.fn()
localStorage.setItem("access_token", "tok-123")
render(<WechatQrModal open scene="login" onClose={vi.fn()} onLoginSuccess={onSuccess} />)
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
postMessage({
source: "xiaoxia-wechat-qr",
scene: "login",
success: true,
payload: { needOnboarding: true },
})
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(true))
expect(mockGetCurrentUser).toHaveBeenCalled()
expect(mockSetAuth).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }), "tok-123", null)
})
it("登录失败消息:弹窗内展示回调页透传的真实原因", async () => {
render(<WechatQrModal open scene="login" onClose={vi.fn()} />)
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
postMessage({
source: "xiaoxia-wechat-qr",
scene: "login",
success: false,
detail: "微信登录失败:state 已过期或已被使用",
})
expect(await screen.findByText(/state 已过期或已被使用/)).toBeTruthy()
})
it("绑定成功消息:刷新用户并回调 onBindSuccess", async () => {
const onBindSuccess = vi.fn()
render(<WechatQrModal open scene="bind" onClose={vi.fn()} onBindSuccess={onBindSuccess} />)
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
postMessage({ source: "xiaoxia-wechat-qr", scene: "bind", success: true })
await waitFor(() => expect(onBindSuccess).toHaveBeenCalledTimes(1))
expect(mockSetUser).toHaveBeenCalled()
})
it("忽略跨源消息和其他场景的消息", async () => {
const onSuccess = vi.fn()
render(<WechatQrModal open scene="login" onClose={vi.fn()} onLoginSuccess={onSuccess} />)
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
// 跨源
window.dispatchEvent(
new MessageEvent("message", {
data: { source: "xiaoxia-wechat-qr", scene: "login", success: true },
origin: "https://evil.example.com",
}),
)
// 场景不符(bind 消息发给 login 弹窗)
postMessage({ source: "xiaoxia-wechat-qr", scene: "bind", success: true })
// 无协议标识
postMessage({ foo: "bar" })
await new Promise((r) => setTimeout(r, 50))
expect(onSuccess).not.toHaveBeenCalled()
})
})
@@ -26,6 +26,8 @@ interface FakeHandle {
fields: Record<string, string>
max_size_bytes: number
asset_id: string
duplicated?: boolean
skip_transfer?: boolean
}
transfer: ReturnType<typeof vi.fn>
complete: ReturnType<typeof vi.fn>
@@ -46,6 +48,8 @@ const makeFakeHandle = (opts: {
duplicated?: boolean
failTransfer?: boolean
completeAuto?: boolean
/** prepare 阶段就命中去重:prepare 响应 skip_transfer/duplicated=true */
prepareDedup?: boolean
}) => {
const h: FakeHandle = {
prepared: {
@@ -56,6 +60,8 @@ const makeFakeHandle = (opts: {
fields: {},
max_size_bytes: 2_000_000_000,
asset_id: opts.id,
duplicated: opts.prepareDedup ? true : undefined,
skip_transfer: opts.prepareDedup ? true : undefined,
},
transfer: vi.fn(),
complete: vi.fn(),
@@ -224,6 +230,11 @@ describe("useAssetUpload", () => {
})
await waitFor(() => expect(result.current.uploadItems[0].status).toBe("error"))
// 失败卡片记录失败阶段与完整错误原因(不再只显示"上传失败")
const failed = result.current.uploadItems[0]
expect(failed.failedStage).toBe("transfer")
expect(failed.error).toContain("OSS boom")
// 重试:重新 preparehandles[1] 成功)
const tempId = result.current.uploadItems[0].tempId
await act(async () => {
@@ -334,6 +345,9 @@ describe("useAssetUpload", () => {
const it = result.current.uploadItems.find((x) => x.tempId === tempId)
expect(it?.status).toBe("error")
expect(it?.failedStage).toBe("complete")
// 卡片同时展示真实失败原因与"重试不会重新上传"提示
expect(it?.error).toContain("complete timeout")
expect(it?.error).toContain("不会重新上传文件")
})
// 点重试:pump 复用 handle,只再调一次 completetransfer/prepare 不重复)
@@ -352,4 +366,47 @@ describe("useAssetUpload", () => {
expect(result.current.uploadItems.find((x) => x.tempId === tempId)?.status).toBe("done")
})
})
it("prepare 阶段失败:标记 prepare 阶段并保留后端错误明细", async () => {
;(prepareDirectUploadHandle as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce({
isAxiosError: true,
response: { status: 500, data: { detail: "签名服务内部错误" } },
message: "Request failed with status code 500",
})
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
wrapper: createWrapper(),
})
await act(async () => {
result.current.enqueueUploads([mp4("prep-fail.mp4")])
})
await waitFor(() => expect(result.current.uploadItems[0]?.status).toBe("error"))
const it = result.current.uploadItems[0]
expect(it.failedStage).toBe("prepare")
expect(it.error).toContain("签名服务内部错误")
})
it("prepare 返回 skip_transfer=true 时立即跳过 transfer+complete,标记 done+duplicated", async () => {
const h = makeFakeHandle({ id: "a-skip", prepareDedup: true })
;(prepareDirectUploadHandle as unknown as ReturnType<typeof vi.fn>).mockImplementation(
async () => h,
)
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
wrapper: createWrapper(),
})
await act(async () => {
result.current.enqueueUploads([mp4("skip-transfer.mp4")])
})
await waitFor(() => {
expect(h.transfer).not.toHaveBeenCalled()
expect(h.complete).not.toHaveBeenCalled()
const it = result.current.uploadItems[0]
expect(it?.status).toBe("done")
expect(it?.duplicated).toBe(true)
expect(it?.assetId).toBe("a-skip")
})
})
})
@@ -0,0 +1,144 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
import { render, screen, waitFor, cleanup } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import WechatBindCallback from "@/pages/auth/WechatBindCallback"
const mockNavigate = vi.fn()
const mockSetUser = vi.fn()
const mockParams = new URLSearchParams({ code: "bind_code", state: "bind_state" })
const mockSearchParams = [mockParams] as const
const localStorageStore: Record<string, string> = {}
vi.spyOn(Storage.prototype, "getItem").mockImplementation((key) => localStorageStore[key] || null)
vi.spyOn(Storage.prototype, "setItem").mockImplementation((key, val) => {
localStorageStore[key] = val
})
vi.spyOn(Storage.prototype, "removeItem").mockImplementation((key) => {
delete localStorageStore[key]
})
let bindError: unknown = null
const mockBindResult = { user: { id: "u1", wechat_bound: true } }
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom")
return {
...actual,
useNavigate: () => mockNavigate,
useSearchParams: () => mockSearchParams,
}
})
vi.mock("@/api/auth", () => ({
bindWechat: vi.fn(async () => {
if (bindError) throw bindError
return mockBindResult
}),
normalizeUser: (u: unknown) => u,
}))
vi.mock("@/store/authStore", () => ({
useAuthStore: (selector: (state: unknown) => unknown) => selector({ setUser: mockSetUser }),
}))
// iframe 场景:默认非 iframe;用例可 mockReturnValue(true)
const { mockIsInIframe, mockPostResult } = vi.hoisted(() => ({
mockIsInIframe: vi.fn(() => false),
mockPostResult: vi.fn(),
}))
vi.mock("@/components/auth/WechatQrModal/messages", () => ({
isInIframe: () => mockIsInIframe(),
postWechatQrResult: (...args: unknown[]) => mockPostResult(...args),
}))
const renderPage = () =>
render(
<MemoryRouter>
<WechatBindCallback />
</MemoryRouter>,
)
describe("WechatBindCallback Page", () => {
afterEach(() => {
cleanup()
})
beforeEach(() => {
vi.clearAllMocks()
mockIsInIframe.mockReturnValue(false)
bindError = null
Array.from(mockParams.keys()).forEach((k) => mockParams.delete(k))
mockParams.set("code", "bind_code")
mockParams.set("state", "bind_state")
localStorageStore.wechat_bind_state = "bind_state"
})
it("绑定成功跳转设置页并携带 success 标记", async () => {
renderPage()
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/app/profile?wechat_bind=success", {
replace: true,
})
})
expect(mockSetUser).toHaveBeenCalled()
})
it("本地无 wechat_bind_state(微信内/跨浏览器)不再误杀,绑定正常完成", async () => {
delete localStorageStore.wechat_bind_state
renderPage()
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/app/profile?wechat_bind=success", {
replace: true,
})
})
})
it("后端报错(微信已被其他账号绑定)时页面透传真实原因,不静默跳走", async () => {
bindError = {
isAxiosError: true,
response: { status: 409, data: { detail: "该微信已绑定其他账号" } },
message: "Request failed with status code 409",
}
renderPage()
await waitFor(() => {
expect(screen.getByText(/该微信已绑定其他账号/)).toBeTruthy()
})
expect(mockNavigate).not.toHaveBeenCalled()
})
it("缺少 code/state 时提示无效回调", async () => {
mockParams.delete("code")
renderPage()
await waitFor(() => {
expect(screen.getByText(/无效的回调参数/)).toBeTruthy()
})
})
describe("iframe(弹窗内嵌二维码)场景", () => {
it("绑定成功时 postMessage 通知父窗口,不做 navigate", async () => {
mockIsInIframe.mockReturnValue(true)
renderPage()
await waitFor(() => {
expect(mockPostResult).toHaveBeenCalledWith("bind", true)
})
expect(mockSetUser).toHaveBeenCalled()
expect(mockNavigate).not.toHaveBeenCalled()
})
it("绑定失败时把真实原因 postMessage 给父窗口", async () => {
mockIsInIframe.mockReturnValue(true)
bindError = {
isAxiosError: true,
response: { status: 409, data: { detail: "该微信已绑定其他账号" } },
}
renderPage()
await waitFor(() => {
expect(mockPostResult).toHaveBeenCalledWith("bind", false, {
detail: expect.stringContaining("该微信已绑定其他账号"),
})
})
expect(screen.queryByText(/返回设置/)).toBeNull()
expect(mockNavigate).not.toHaveBeenCalled()
})
})
})
@@ -5,7 +5,11 @@ import WechatCallback from "@/pages/auth/WechatCallback"
const mockNavigate = vi.fn()
const mockSetAuth = vi.fn()
const mockSearchParams = [new URLSearchParams({ code: "test_code", state: "test_state" })] as const
// useSearchParams 返回模块级稳定引用(数组元素同一 URLSearchParams 实例),
// 避免每次 render 返回新数组/新实例导致 useEffect 依赖变化重跑
const mockParams = new URLSearchParams({ code: "test_code", state: "test_state" })
const mockSearchParams = [mockParams] as const
const mockAuthState = { setAuth: mockSetAuth }
// 文件级 localStorage mock(避免每个用例重复 spy 导致链式污染)
@@ -20,7 +24,7 @@ vi.spyOn(Storage.prototype, "removeItem").mockImplementation((key) => {
let mockCallbackResult: Record<string, unknown> = {}
let mockCurrentUser: Record<string, unknown> = {}
let callbackShouldFail = false
let callbackError: unknown = null
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom")
@@ -33,7 +37,7 @@ vi.mock("react-router-dom", async () => {
vi.mock("@/api/auth", () => ({
wechatCallback: vi.fn(async () => {
if (callbackShouldFail) throw new Error("fail")
if (callbackError) throw callbackError
return mockCallbackResult
}),
getCurrentUser: vi.fn(async () => mockCurrentUser),
@@ -49,6 +53,16 @@ vi.mock("@/store/authStore", () => ({
useAuthStore: (selector: (state: unknown) => unknown) => selector({ setAuth: mockSetAuth }),
}))
// iframe 场景:默认非 iframe;用例可 mockReturnValue(true)
const { mockIsInIframe, mockPostResult } = vi.hoisted(() => ({
mockIsInIframe: vi.fn(() => false),
mockPostResult: vi.fn(),
}))
vi.mock("@/components/auth/WechatQrModal/messages", () => ({
isInIframe: () => mockIsInIframe(),
postWechatQrResult: (...args: unknown[]) => mockPostResult(...args),
}))
const renderPage = () =>
render(
<MemoryRouter>
@@ -63,7 +77,12 @@ describe("WechatCallback Page", () => {
beforeEach(() => {
vi.clearAllMocks()
callbackShouldFail = false
mockIsInIframe.mockReturnValue(false)
callbackError = null
// 默认正常回调参数;用例可改写 mockParams 模拟 error 重定向
Array.from(mockParams.keys()).forEach((k) => mockParams.delete(k))
mockParams.set("code", "test_code")
mockParams.set("state", "test_state")
localStorageStore.wechat_state = "test_state"
mockCallbackResult = {
access_token: "at",
@@ -103,20 +122,47 @@ describe("WechatCallback Page", () => {
})
})
it("state 不匹配显示安全错误", async () => {
localStorageStore.wechat_state = "other_state"
it("本地无 wechat_state(微信内打开/跨浏览器场景)不再误杀,正常完成登录", async () => {
delete localStorageStore.wechat_state
renderPage()
await waitFor(() => {
expect(screen.getByText("安全校验失败,请重新登录")).toBeTruthy()
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true })
})
// state 已被清理
expect(localStorageStore.wechat_state).toBeUndefined()
})
it("后端返回 detail 错误时,页面透传真实原因(不再吞成通用提示)", async () => {
callbackError = {
isAxiosError: true,
response: { status: 400, data: { detail: "微信授权码已过期,请重新扫码" } },
message: "Request failed with status code 400",
}
renderPage()
await waitFor(() => {
expect(screen.getByText(/微信授权码已过期,请重新扫码/)).toBeTruthy()
})
expect(screen.queryByText(/^微信登录失败,请重试$/)).toBeNull()
expect(mockNavigate).not.toHaveBeenCalled()
})
it("微信重定向带 error(用户拒绝授权)时展示授权失败原因", async () => {
for (const k of Array.from(mockParams.keys())) mockParams.delete(k)
mockParams.set("error", "access_denied")
mockParams.set("error_description", "The+user+denied+the+request")
renderPage()
await waitFor(() => {
expect(screen.getByText(/微信授权失败/)).toBeTruthy()
expect(screen.getByText(/access_denied/)).toBeTruthy()
})
expect(mockNavigate).not.toHaveBeenCalled()
})
it("接口失败显示错误提示", async () => {
callbackShouldFail = true
it("缺少 code/state 参数时提示无效回调", async () => {
mockParams.delete("code")
renderPage()
await waitFor(() => {
expect(screen.getByText("微信登录失败,请重试")).toBeTruthy()
expect(screen.getByText(/无效的回调参数/)).toBeTruthy()
})
})
@@ -124,4 +170,54 @@ describe("WechatCallback Page", () => {
renderPage()
expect(screen.getByText("微信登录中...")).toBeTruthy()
})
describe("iframe(弹窗内嵌二维码)场景", () => {
it("登录成功时 postMessage 通知父窗口(needOnboarding=false),不做 navigate", async () => {
mockIsInIframe.mockReturnValue(true)
renderPage()
await waitFor(() => {
expect(mockPostResult).toHaveBeenCalledWith("login", true, { needOnboarding: false })
})
expect(mockSetAuth).toHaveBeenCalled()
expect(mockNavigate).not.toHaveBeenCalled()
})
it("新用户成功时上报 needOnboarding=true", async () => {
mockIsInIframe.mockReturnValue(true)
mockCallbackResult = { access_token: "at", refresh_token: "rt", is_new_user: true }
renderPage()
await waitFor(() => {
expect(mockPostResult).toHaveBeenCalledWith("login", true, { needOnboarding: true })
})
expect(mockNavigate).not.toHaveBeenCalled()
})
it("后端报错时把真实原因 postMessage 给父窗口,页面不渲染错误/按钮", async () => {
mockIsInIframe.mockReturnValue(true)
callbackError = {
isAxiosError: true,
response: { status: 400, data: { detail: "state 已过期或已被使用" } },
}
renderPage()
await waitFor(() => {
expect(mockPostResult).toHaveBeenCalledWith("login", false, {
detail: expect.stringContaining("state 已过期或已被使用"),
})
})
expect(screen.queryByText(/返回登录/)).toBeNull()
expect(mockNavigate).not.toHaveBeenCalled()
})
it("微信重定向 error(拒绝授权)在 iframe 内也上报父窗口", async () => {
mockIsInIframe.mockReturnValue(true)
for (const k of Array.from(mockParams.keys())) mockParams.delete(k)
mockParams.set("error", "access_denied")
renderPage()
await waitFor(() => {
expect(mockPostResult).toHaveBeenCalledWith("login", false, {
detail: expect.stringContaining("access_denied"),
})
})
})
})
})
@@ -83,6 +83,12 @@ describe("WechatOnboarding 昵称引导页", () => {
expect(screen.queryByText("进入小虾智剪")).toBeNull()
})
it("昵称输入框不预填,必须用户自己输入", () => {
renderPage()
expect(screen.getByText("欢迎使用微信登录,请先设置您的昵称")).toBeTruthy()
expect((screen.getByPlaceholderText("请输入您的昵称") as HTMLInputElement).value).toBe("")
})
it("新用户可见昵称表单并能提交", async () => {
renderPage()
expect(screen.getByText("欢迎使用微信登录,请先设置您的昵称")).toBeTruthy()
@@ -116,6 +122,54 @@ describe("WechatOnboarding 昵称引导页", () => {
)
})
it("连点提交按钮只触发一次请求(防重复提交)", async () => {
// mutation 挂起不立即完成,模拟慢网络下连续双击
let resolveSubmit: (v: unknown) => void = () => {}
updateProfileMock = vi.fn(
() =>
new Promise((resolve) => {
resolveSubmit = resolve
}),
)
renderPage()
fireEvent.change(screen.getByPlaceholderText("请输入您的昵称"), {
target: { value: "小虾用户" },
})
const btn = screen.getByText("进入小虾智剪")
fireEvent.click(btn)
// 第一次点击后立即再点(此时重渲染/loading 可能还没生效)
fireEvent.click(btn)
fireEvent.click(btn)
await waitFor(() => {
expect(updateProfileMock).toHaveBeenCalledTimes(1)
})
// 释放挂起的 Promise,避免泄漏
resolveSubmit({ id: "u1", display_name: "小虾用户", profile_completed: true })
})
it("提交失败后守卫复位,允许再次提交", async () => {
updateProfileMock = vi
.fn()
.mockRejectedValueOnce({
isAxiosError: true,
response: { status: 500, data: { detail: "服务内部错误" } },
})
.mockResolvedValueOnce({ id: "u1", display_name: "小虾用户", profile_completed: true })
renderPage()
fireEvent.change(screen.getByPlaceholderText("请输入您的昵称"), {
target: { value: "小虾用户" },
})
fireEvent.click(screen.getByText("进入小虾智剪"))
await waitFor(() => {
expect(updateProfileMock).toHaveBeenCalledTimes(1)
})
// 失败后再点一次,应能重新提交
fireEvent.click(screen.getByText("进入小虾智剪"))
await waitFor(() => {
expect(updateProfileMock).toHaveBeenCalledTimes(2)
})
})
it("提交失败显示错误且不跳转", async () => {
updateProfileMock = vi.fn(async () => {
throw new Error("500")
@@ -0,0 +1,44 @@
import { describe, it, expect, vi, afterEach } from "vitest"
import { lazyRoute } from "@/router/lazyRoute"
const chunkErr = () => new TypeError("Failed to fetch dynamically imported module: /assets/x.js")
/** fake 模块 */
const Comp = function Comp() {}
const factoryOk = vi.fn(async () => ({ default: Comp }))
afterEach(() => {
vi.clearAllMocks()
})
describe("lazyRoute", () => {
it("首次成功直接返回 Component", async () => {
const result = await lazyRoute(factoryOk)()
expect(result).toEqual({ Component: Comp })
expect(factoryOk).toHaveBeenCalledTimes(1)
})
it("chunk 失败重试:前两次失败、第三次成功 → 不抛出", async () => {
const f = vi
.fn()
.mockRejectedValueOnce(chunkErr())
.mockRejectedValueOnce(chunkErr())
.mockResolvedValueOnce({ default: Comp })
const result = await lazyRoute(f as never)()
expect(result).toEqual({ Component: Comp })
expect(f).toHaveBeenCalledTimes(3)
})
it("chunk 失败重试 2 次仍失败 → 抛出", async () => {
const f = vi.fn().mockRejectedValue(chunkErr())
await expect(lazyRoute(f as never)()).rejects.toThrow(/dynamically imported/)
expect(f).toHaveBeenCalledTimes(3)
})
it("非 chunk 错误立即抛出,不重试", async () => {
const f = vi.fn().mockRejectedValue(new Error("业务模块内部报错"))
await expect(lazyRoute(f as never)()).rejects.toThrow("业务模块内部报错")
expect(f).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,101 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
import {
getChunkReloadedAt,
goHomeRecover,
isChunkLoadError,
reloadForChunkError,
} from "@/utils/chunkLoadError"
describe("isChunkLoadError", () => {
it("识别 Vite 动态 import 失败", () => {
const err = new TypeError(
"Failed to fetch dynamically imported module: https://x/assets/AssetLibrary-abc.js",
)
expect(isChunkLoadError(err)).toBe(true)
})
it("识别 Webpack 风格 ChunkLoadError", () => {
const err = new Error("Loading chunk 12 failed.")
err.name = "ChunkLoadError"
expect(isChunkLoadError(err)).toBe(true)
})
it("识别字符串形式错误", () => {
expect(isChunkLoadError("Error loading dynamically imported module")).toBe(true)
})
it("普通错误不命中", () => {
expect(isChunkLoadError(new Error("Cannot read properties of undefined"))).toBe(false)
expect(isChunkLoadError(null)).toBe(false)
expect(isChunkLoadError(undefined)).toBe(false)
expect(isChunkLoadError({ status: 500 })).toBe(false)
})
})
describe("reload 标记", () => {
beforeEach(() => {
sessionStorage.clear()
// jsdom 未实现真实导航,reload 仅打 "not implemented" 警告,静默掉
vi.spyOn(console, "error").mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
sessionStorage.clear()
})
it("无标记返回 null", () => {
expect(getChunkReloadedAt()).toBeNull()
})
it("reloadForChunkError 写入刷新标记", () => {
expect(() => reloadForChunkError()).not.toThrow()
expect(getChunkReloadedAt()).not.toBeNull()
})
it("标记过期(>10min)返回 null", () => {
sessionStorage.setItem("chunk_error_reloaded_at", String(Date.now() - 11 * 60 * 1000))
expect(getChunkReloadedAt()).toBeNull()
})
it("goHomeRecover 清掉标记", () => {
reloadForChunkError()
expect(getChunkReloadedAt()).not.toBeNull()
expect(() => goHomeRecover()).not.toThrow()
expect(sessionStorage.getItem("chunk_error_reloaded_at")).toBeNull()
})
it("sessionStorage 抛异常(无痕模式)时降级不崩溃", () => {
const spy = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("Storage disabled")
})
const setSpy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("Storage disabled")
})
expect(getChunkReloadedAt()).toBeNull()
expect(() => reloadForChunkError()).not.toThrow()
expect(() => goHomeRecover()).not.toThrow()
spy.mockRestore()
setSpy.mockRestore()
})
it("URL 带 chunkreload 参数时视为已刷新过(storage 不可用的降级标记)", () => {
window.history.replaceState({}, "", "/app/assets?chunkreload=1")
expect(getChunkReloadedAt()).not.toBeNull()
window.history.replaceState({}, "", "/")
})
it("storage 完全不可用时,reloadForChunkError 走 URL 标记跳转(防无痕死循环)", () => {
const setSpy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("Storage disabled")
})
const getSpy = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("Storage disabled")
})
window.history.replaceState({}, "", "/app/assets")
// reloadForChunkError 应走 location.replace 带上 ?chunkreload=1jsdom 不会真导航)
expect(() => reloadForChunkError()).not.toThrow()
setSpy.mockRestore()
getSpy.mockRestore()
window.history.replaceState({}, "", "/")
})
})
+111
View File
@@ -0,0 +1,111 @@
/**
* 发版后旧标签页懒加载 chunk 失效(白屏)的识别与恢复工具。
*
* 背景:页面 React Router 的 lazy 动态 import,发版后旧 chunk 文件名被删除,
* 停留在旧标签页的用户点菜单时 import 404,抛出
* "Failed to fetch dynamically imported module"Vite/ ChunkLoadError
* 不捕获就是整页白屏。
*/
/** sessionStorage 标记:最近已经为 chunk 失效自动刷新过一次(带时间戳,10min 有效) */
const RELOAD_FLAG_KEY = "chunk_error_reloaded_at"
/** 标记有效期:超过后允许再次自动刷新,避免用户手动正常刷新后标记永久残留 */
const RELOAD_FLAG_TTL_MS = 10 * 60 * 1000
/**
* Storage 在 Safari 无痕模式 / 禁用 Cookie 的浏览器 / 严格 iframe 策略下
* 访问可能抛异常;此处统一容错,拿不到存储就降级为"无标记",绝不能让
* 错误边界本身因读存储而崩溃。
*/
const safeStorage = {
getItem: (key: string): string | null => {
try {
return sessionStorage.getItem(key)
} catch {
return null
}
},
setItem: (key: string, value: string): void => {
try {
sessionStorage.setItem(key, value)
} catch {
/* 存储不可用时静默降级:仅丢失"已刷新"标记,不影响恢复动作 */
}
},
removeItem: (key: string): void => {
try {
sessionStorage.removeItem(key)
} catch {
/* ignore */
}
},
}
/** 判断错误是否为懒加载 chunk 加载失败(发版 404 / 网络中断 / 动态 import 失败) */
export const isChunkLoadError = (error: unknown): boolean => {
if (!error) return false
// Vite: Failed to fetch dynamically imported module: /assets/xxx-yyy.js
// Webpack: ChunkLoadError: Loading chunk xxx failed.
const needle =
error instanceof Error
? `${error.name} ${error.message}`
: typeof error === "string"
? error
: ""
return /failed to fetch dynamically imported module|chunkloaderror|loading chunk \d+ failed|error loading dynamically imported module|importing a module script failed/i.test(
needle,
)
}
/** URL 降级标记参数:storage 不可用(无痕/禁 Cookie)时靠它在刷新后保留"已刷新"状态 */
const RELOAD_QUERY_KEY = "chunkreload"
/** 判断当前 URL 是否已带"为 chunk 失效刷新过"标记 */
const urlHasReloadFlag = (): boolean => {
try {
return new URLSearchParams(window.location.search).has(RELOAD_QUERY_KEY)
} catch {
return false
}
}
/**
* 读取"已自动刷新过"状态:storage 与 URL 参数双重判定。
* storage 在 Safari 无痕等环境可能完全不可用,此时刷新后标记丢失会陷入
* "检测到 chunk 失效 → 刷新 → 再检测 → 再刷新"死循环;URL 参数刷新后仍在,
* 作为降级标记兜住这种环境。
*/
export const getChunkReloadedAt = (): number | null => {
if (urlHasReloadFlag()) return Date.now()
const raw = safeStorage.getItem(RELOAD_FLAG_KEY)
if (!raw) return null
const ts = Number(raw)
if (!Number.isFinite(ts)) return null
if (Date.now() - ts > RELOAD_FLAG_TTL_MS) return null
return ts
}
/** 标记"已为 chunk 失效自动刷新过",然后刷新页面 */
export const reloadForChunkError = (): void => {
safeStorage.setItem(RELOAD_FLAG_KEY, String(Date.now()))
// 同时在 URL 上带标记:storage 写不进(无痕模式)时刷新后仍能识别已刷新过
try {
const url = new URL(window.location.href)
url.searchParams.set(RELOAD_QUERY_KEY, "1")
window.location.replace(url.toString())
return
} catch {
/* URL 构造失败则退回普通刷新 */
}
window.location.reload()
}
/**
* 硬恢复:清掉标记后回到首页(整页导航,不是当前 URL 刷新)。
* - chunk 失效兜底:回到首页会拉取最新 index.html,彻底脱离旧 chunk 引用
* - 非 chunk 的页面级崩溃:跳首页能绕开当前报错路由,避免"刷新-再崩"死循环
*/
export const goHomeRecover = (): void => {
safeStorage.removeItem(RELOAD_FLAG_KEY)
window.location.href = "/"
}
@@ -38,6 +38,7 @@ class UserModel(Base):
phone = Column(String(32), nullable=True, unique=True, index=True)
phone_verified = Column(Boolean, nullable=False, default=False)
binding_completed_at = Column(DateTime, nullable=True)
profile_completed = Column(Boolean, nullable=False, default=True, server_default="true")
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
@@ -38,6 +38,7 @@ class SQLAlchemyUserRepository(UserRepository):
model.phone = user.phone
model.phone_verified = user.phone_verified
model.binding_completed_at = user.binding_completed_at
model.profile_completed = user.profile_completed
model.created_at = user.created_at
self.session.commit()
@@ -113,5 +114,6 @@ class SQLAlchemyUserRepository(UserRepository):
phone=model.phone,
phone_verified=model.phone_verified or False,
binding_completed_at=model.binding_completed_at,
profile_completed=model.profile_completed if model.profile_completed is not None else True,
created_at=model.created_at,
)
@@ -200,6 +200,8 @@ class WechatSyncUseCase:
email_verified=True, # 微信登录视为已验证
wechat_openid=request.openid,
wechat_unionid=request.unionid or None,
# 微信新建用户首次登录需引导设置昵称
profile_completed=False,
)
self.user_repository.save(user)
+2
View File
@@ -57,6 +57,8 @@ class User:
phone: str | None = None
phone_verified: bool = False
binding_completed_at: datetime | None = None
# 资料是否已完善(微信新用户首次设置昵称后置 True;邮箱注册默认 True)
profile_completed: bool = True
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+222
View File
@@ -0,0 +1,222 @@
"""#1718PATCH /auth/me 资料更新接口测试。
覆盖:
- 正常更新昵称并落库
- strip 生效(前后空白去除)
- 纯空白/超长 -> 422pydantic 校验)
- 首次设置昵称 profile_completed False->True
- 已完成用户重复提交幂等(仍 True)
- 未登录由 get_current_user 依赖保证 401(框架行为,这里验证路由声明了该依赖)
- 响应结构 {user: {...}} 含 wechat_bound/profile_completed 全字段
- 微信新建用户 profile_completed 默认 Falsewechat_sync _create_wechat_user
"""
from __future__ import annotations
import asyncio
import os
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from pydantic import ValidationError
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from app.api.routes import auth as auth_route # noqa: E402
from packages.adapters.in_memory.user_repository import InMemoryUserRepository # noqa: E402
from packages.domain.entities import User # noqa: E402
def _auth_user(user):
return SimpleNamespace(user=user, session_id="s-1", token_type="user_auth")
def _make_user(**kw):
defaults = dict(
id="u-1",
email="user@example.com",
username="user",
display_name="微信用户",
password_hash="x",
email_verified=True,
profile_completed=False,
)
defaults.update(kw)
return User(**defaults)
# ---------- 请求体校验 ----------
def test_display_name_strips_whitespace():
req = auth_route.UpdateProfileRequest(display_name=" ying123 ")
assert req.display_name == "ying123"
def test_display_name_blank_rejected():
with pytest.raises(ValidationError) as exc:
auth_route.UpdateProfileRequest(display_name=" ")
assert "空白" in str(exc.value)
def test_display_name_empty_rejected():
with pytest.raises(ValidationError):
auth_route.UpdateProfileRequest(display_name="")
def test_display_name_too_long_rejected():
with pytest.raises(ValidationError) as exc:
auth_route.UpdateProfileRequest(display_name="" * 21)
assert "1-20" in str(exc.value)
def test_display_name_max_length_accepted():
req = auth_route.UpdateProfileRequest(display_name="" * 20)
assert req.display_name == "" * 20
# ---------- 路由逻辑 ----------
def test_patch_me_updates_display_name_and_persists():
user = _make_user()
repo = InMemoryUserRepository()
repo.save(user)
resp = asyncio.run(
auth_route.update_current_user_profile(
auth_route.UpdateProfileRequest(display_name=" ying123 "),
current_user=_auth_user(user),
user_repository=repo,
)
)
assert resp.user.display_name == "ying123"
assert resp.user.profile_completed is True
assert resp.user.wechat_bound is False
# 落库验证
fresh = repo.find_by_id("u-1")
assert fresh.display_name == "ying123"
assert fresh.profile_completed is True
def test_patch_me_first_time_sets_profile_completed_true():
user = _make_user(profile_completed=False)
repo = InMemoryUserRepository()
repo.save(user)
assert repo.find_by_id("u-1").profile_completed is False
asyncio.run(
auth_route.update_current_user_profile(
auth_route.UpdateProfileRequest(display_name="小虾"),
current_user=_auth_user(user),
user_repository=repo,
)
)
assert repo.find_by_id("u-1").profile_completed is True
def test_patch_me_idempotent_for_completed_user():
user = _make_user(display_name="老名字", profile_completed=True)
repo = InMemoryUserRepository()
repo.save(user)
resp = asyncio.run(
auth_route.update_current_user_profile(
auth_route.UpdateProfileRequest(display_name="新名字"),
current_user=_auth_user(user),
user_repository=repo,
)
)
assert resp.user.profile_completed is True
assert resp.user.display_name == "新名字"
# 再提交一次同样内容,不报错、状态稳定
resp2 = asyncio.run(
auth_route.update_current_user_profile(
auth_route.UpdateProfileRequest(display_name="新名字"),
current_user=_auth_user(repo.find_by_id("u-1")),
user_repository=repo,
)
)
assert resp2.user.profile_completed is True
def test_patch_me_response_contains_all_me_fields():
user = _make_user(wechat_openid="wx-1", phone="13800000000", phone_verified=True)
repo = InMemoryUserRepository()
repo.save(user)
resp = asyncio.run(
auth_route.update_current_user_profile(
auth_route.UpdateProfileRequest(display_name="昵称"),
current_user=_auth_user(user),
user_repository=repo,
)
)
payload = resp.user.model_dump()
for field in (
"user_id",
"email",
"username",
"display_name",
"email_verified",
"phone",
"phone_verified",
"binding_complete",
"wechat_bound",
"profile_completed",
):
assert field in payload, f"missing field {field}"
assert payload["wechat_bound"] is True
assert payload["phone"] == "13800000000"
def test_get_me_includes_profile_completed_flag():
# 未完成
u = _make_user(profile_completed=False)
resp = asyncio.run(auth_route.get_current_user_info(authenticated_user=_auth_user(u)))
assert resp.profile_completed is False
assert resp.wechat_bound is False
# 已完成 + 已绑微信
u2 = _make_user(profile_completed=True, wechat_openid="wx-9")
resp2 = asyncio.run(auth_route.get_current_user_info(authenticated_user=_auth_user(u2)))
assert resp2.profile_completed is True
assert resp2.wechat_bound is True
def test_patch_me_requires_auth_dependency():
# 路由签名必须依赖 get_current_user,未携带 token 时框架返回 401
params = (
auth_route.update_current_user_profile.__wrapped__
if hasattr(auth_route.update_current_user_profile, "__wrapped__")
else auth_route.update_current_user_profile
)
import inspect
sig = inspect.signature(params)
dep = sig.parameters.get("current_user")
assert dep is not None
assert dep.default is not None and getattr(dep.default, "dependency", None) is auth_route.get_current_user
def test_wechat_new_user_created_with_profile_completed_false():
# 微信同步建号:新用户 profile_completed=False(需引导设置昵称)
from packages.application.auth.wechat_sync_use_case import (
WechatSyncRequest,
WechatSyncUseCase,
)
repo = InMemoryUserRepository()
# session_store 用 mock,不依赖 redis
use_case = WechatSyncUseCase(user_repository=repo, session_store=MagicMock(), jwt_secret_key="test-secret")
resp, err = use_case.execute(WechatSyncRequest(openid="wx-new-openid", nickname="微信测试", source="web"))
assert err is None
user = repo.find_by_id(resp.user_id)
assert user.profile_completed is False
+471
View File
@@ -0,0 +1,471 @@
"""#1714 prepare_direct_upload 去重 + 预建 asset 测试。
覆盖 4 类用例:
- 第一次上传:prepare 返回 duplicated=false + asset_id 非空
- 第二次同 hashprepare 返回 duplicated=true, skip_transfer=true
- 同 client_upload_id 重试:prepare 也直接跳过
- file_hash 空:走老逻辑,duplicated=false,无 asset_id
以及:
- pre-create 的 PROCESSING 占位不被"文件名兜底去重"误命中
- _create_pending_asset find-or-create 复用现有记录
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from apps.api.app.api.routes import upload as upload_route # noqa: E402
from packages.domain.entities import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, Project # noqa: E402
# ---------------------------------------------------------------------------
# Fake repository
# ---------------------------------------------------------------------------
class _FakeAssetRepo:
"""内存 asset 仓储:实现 prepare/complete 去重需要的所有方法。"""
def __init__(self):
self.assets = {} # id -> Asset
self.saved = 0
self.updated = 0
def create(self, asset):
self.assets[asset.id] = asset
self.saved += 1
return asset
def update(self, asset):
self.assets[asset.id] = asset
self.updated += 1
return asset
def find_by_id(self, asset_id):
return self.assets.get(asset_id)
def find_by_library_and_file_hash(self, library_id, file_hash):
if not file_hash:
return None
for a in self.assets.values():
if a.library_id == library_id and a.file_hash == file_hash:
return a
return None
def find_by_library_and_client_upload_id(self, library_id, client_upload_id):
if not client_upload_id:
return None
for a in self.assets.values():
if a.library_id == library_id and a.client_upload_id == client_upload_id:
return a
return None
def find_recent_active_by_library_and_name(self, library_id, name, within_minutes=30, file_size=0):
return None
def _make_asset(**kw):
defaults = dict(
project_id="p-1",
library_id="lib-1",
name="existing.mp4",
storage_key="uploads/old/existing.mp4",
mime_type="video/mp4",
status=AssetStatus.READY,
file_hash="existinghash",
)
defaults.update(kw)
return Asset(id=defaults.pop("id", "existing-asset"), **defaults)
def _make_pending(**kw):
defaults = dict(
project_id="p-1",
library_id="lib-1",
name="test.mp4",
storage_key="uploads/abc/test.mp4",
mime_type="video/mp4",
status=AssetStatus.PROCESSING,
file_hash="abc123",
)
defaults.update(kw)
return Asset(id=defaults.pop("id", "pending-asset"), **defaults)
def _user():
return SimpleNamespace(user=SimpleNamespace(id="user-1"), session_id="s", token_type="t")
class _StubProjectRepo:
def __init__(self, project):
self._p = project
def get(self, pid):
return self._p if self._p.id == pid else None
def find_by_id(self, pid):
return self._p if self._p.id == pid else None
class _StubLibraryRepo:
def __init__(self, lib):
self._lib = lib
def find_by_project(self, pid, kind=None):
if self._lib.project_id == pid:
return [self._lib]
return []
_FIXTURE_PROJECT = Project(id="p-1", owner_user_id="user-1", name="proj", description="")
_FIXTURE_LIBRARY = AssetLibrary(
id="lib-1", project_id="p-1", name="videos", kind=AssetLibraryKind.VIDEO, asset_count=0, total_size=0
)
def _storage():
s = MagicMock()
s.create_direct_upload_post.return_value = {
"url": "https://bucket.oss.example.com",
"method": "POST",
"storage_key": "uploads/abc/test.mp4",
"expires_at": "2026-01-01T00:00:00Z",
"fields": {"key": "uploads/abc/test.mp4"},
}
return s
# ---------------------------------------------------------------------------
# 场景 1:第一次上传(无 file_hash
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_prepare_first_upload_no_hash_returns_no_dedup():
repo = _FakeAssetRepo()
req = SimpleNamespace(
project_id="p-1",
library_id="lib-1",
filename="test.mp4",
content_type="video/mp4",
file_size=1024,
file_hash="",
client_upload_id="",
)
resp = await upload_route.prepare_direct_upload(
request=req,
authenticated_user=_user(),
project_repository=_StubProjectRepo(_FIXTURE_PROJECT),
asset_library_repository=_StubLibraryRepo(_FIXTURE_LIBRARY),
asset_repository=repo,
storage_service=_storage(),
)
assert resp.duplicated is False
assert resp.skip_transfer is False
assert resp.asset_id == "" # file_hash 空,不预建
assert repo.saved == 0
# ---------------------------------------------------------------------------
# 场景 2:第一次上传带 file_hash → duplicated=false + asset_id 非空
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_prepare_first_upload_with_hash_creates_pending():
repo = _FakeAssetRepo()
req = SimpleNamespace(
project_id="p-1",
library_id="lib-1",
filename="test.mp4",
content_type="video/mp4",
file_size=1024,
file_hash="abc123",
client_upload_id="",
)
resp = await upload_route.prepare_direct_upload(
request=req,
authenticated_user=_user(),
project_repository=_StubProjectRepo(_FIXTURE_PROJECT),
asset_library_repository=_StubLibraryRepo(_FIXTURE_LIBRARY),
asset_repository=repo,
storage_service=_storage(),
)
assert resp.duplicated is False
assert resp.skip_transfer is False
assert resp.asset_id != ""
# 预建记录确实落库
assert repo.saved == 1
pending = repo.find_by_id(resp.asset_id)
assert pending is not None
assert pending.file_hash == "abc123"
assert pending.status == AssetStatus.PROCESSING
# ---------------------------------------------------------------------------
# 场景 3:第二次同 hash → duplicated=true, skip_transfer=true
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_prepare_second_upload_same_hash_returns_duplicated():
repo = _FakeAssetRepo()
repo.create(_make_pending(file_hash="abc123", id="existing-asset"))
req = SimpleNamespace(
project_id="p-1",
library_id="lib-1",
filename="test.mp4",
content_type="video/mp4",
file_size=1024,
file_hash="abc123",
client_upload_id="",
)
resp = await upload_route.prepare_direct_upload(
request=req,
authenticated_user=_user(),
project_repository=_StubProjectRepo(_FIXTURE_PROJECT),
asset_library_repository=_StubLibraryRepo(_FIXTURE_LIBRARY),
asset_repository=repo,
storage_service=_storage(),
)
assert resp.duplicated is True
assert resp.skip_transfer is True
assert resp.asset_id == "existing-asset"
assert resp.upload_url == "" # 未签名 OSS
# 未新增记录
assert repo.saved == 1 # 只有初始那条
# ---------------------------------------------------------------------------
# 场景 4:同 client_upload_id 重试 → 直接跳过
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_prepare_retry_same_client_upload_id_skips():
repo = _FakeAssetRepo()
repo.create(
_make_pending(
file_hash="abc123",
client_upload_id="cuid-xyz",
id="existing-asset",
)
)
# 即使 file_hash 不同(理论上不会),client_upload_id 命中也直接跳过
req = SimpleNamespace(
project_id="p-1",
library_id="lib-1",
filename="test.mp4",
content_type="video/mp4",
file_size=1024,
file_hash="different-hash",
client_upload_id="cuid-xyz",
)
resp = await upload_route.prepare_direct_upload(
request=req,
authenticated_user=_user(),
project_repository=_StubProjectRepo(_FIXTURE_PROJECT),
asset_library_repository=_StubLibraryRepo(_FIXTURE_LIBRARY),
asset_repository=repo,
storage_service=_storage(),
)
assert resp.duplicated is True
assert resp.skip_transfer is True
assert resp.asset_id == "existing-asset"
# ---------------------------------------------------------------------------
# 兜底:文件名兜底去重不误命中 PROCESSING 占位
# ---------------------------------------------------------------------------
def test_filename_fallback_does_not_match_processing_pending():
"""_find_duplicate_asset 按文件名兜底时,不能命中 pre-create 的 PROCESSING 记录。"""
repo = _FakeAssetRepo()
repo.create(_make_pending(id="p1"))
result = upload_route._find_duplicate_asset(
repo,
library_id="lib-1",
file_hash="", # 无 hash
client_upload_id="", # 无 cuid
filename="test.mp4", # 同名
file_size=1024,
)
assert result is None # PROCESSING 占位不被兜底命中
def test_filename_fallback_matches_stable_ready_record():
"""READY 状态的已存在记录能被文件名兜底命中。"""
repo = _FakeAssetRepo()
repo.create(_make_asset(status=AssetStatus.READY, id="ready-asset"))
# 伪造 find_recent_active_by_library_and_name 返回 READY 记录
repo.find_recent_active_by_library_and_name = lambda **kw: repo.assets["ready-asset"]
result = upload_route._find_duplicate_asset(
repo,
library_id="lib-1",
file_hash="",
client_upload_id="",
filename="existing.mp4",
file_size=1024,
)
assert result is not None
assert result.id == "ready-asset"
# ---------------------------------------------------------------------------
# _create_pending_asset find-or-create
# ---------------------------------------------------------------------------
def test_create_pending_asset_reuses_existing_by_hash():
"""_create_pending_assetfile_hash 命中现有 PROCESSING 记录则复用,不新建。"""
repo = _FakeAssetRepo()
repo.create(_make_pending(file_hash="abc123", client_upload_id="", id="p1"))
# 复用
result = upload_route._create_pending_asset(
asset_repository=repo,
project_id="p-1",
library_id="lib-1",
storage_key="uploads/new/test.mp4",
filename="test.mp4",
mime_type="video/mp4",
user_id="user-1",
file_hash="abc123",
client_upload_id="cuid-new",
)
assert result.id == "p1"
assert repo.saved == 1 # 没新增
assert repo.updated >= 1 # 字段补齐触发 update
assert result.client_upload_id == "cuid-new"
def test_create_pending_asset_creates_when_no_match():
"""无匹配时正常新建。"""
repo = _FakeAssetRepo()
result = upload_route._create_pending_asset(
asset_repository=repo,
project_id="p-1",
library_id="lib-1",
storage_key="uploads/new/test.mp4",
filename="test.mp4",
mime_type="video/mp4",
user_id="user-1",
file_hash="newhash",
client_upload_id="newcuid",
)
assert result.id != ""
assert result.file_hash == "newhash"
assert result.client_upload_id == "newcuid"
assert repo.saved == 1
# ---------------------------------------------------------------------------
# 兜底去重:PROCESSING 占位 hash 不同时跳过
# ---------------------------------------------------------------------------
def test_filename_fallback_skips_processing_with_different_hash():
"""PROCESSING/UPLOADING 占位记录仅当 hash 一致(或占位无 hash)才命中;hash 不同跳过。"""
repo = _FakeAssetRepo()
repo.create(_make_pending(id="p1", file_hash="oldhash"))
repo.find_recent_active_by_library_and_name = lambda **kw: repo.assets["p1"]
result = upload_route._find_duplicate_asset(
repo,
library_id="lib-1",
file_hash="differenthash", # 新上传内容不同
client_upload_id="",
filename="test.mp4",
file_size=1024,
)
assert result is None
def test_filename_fallback_matches_processing_with_same_hash():
"""PROCESSING 占位 hash 与请求一致时命中(重试场景)。"""
repo = _FakeAssetRepo()
repo.create(_make_pending(id="p1", file_hash="samehash"))
repo.find_recent_active_by_library_and_name = lambda **kw: repo.assets["p1"]
result = upload_route._find_duplicate_asset(
repo,
library_id="lib-1",
file_hash="samehash",
client_upload_id="",
filename="test.mp4",
file_size=1024,
)
assert result is not None
assert result.id == "p1"
# ---------------------------------------------------------------------------
# prepare 预建失败降级:不阻塞签名
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_prepare_pending_asset_create_failure_degrades_gracefully():
"""预建 asset 抛异常时,prepare 仍正常返回签名(duplicated=False, asset_id 空)。"""
class _BrokenRepo(_FakeAssetRepo):
def create(self, asset):
raise RuntimeError("db down")
repo = _BrokenRepo()
req = SimpleNamespace(
project_id="p-1",
library_id="lib-1",
filename="test.mp4",
content_type="video/mp4",
file_size=1024,
file_hash="abc123",
client_upload_id="cuid-1",
)
resp = await upload_route.prepare_direct_upload(
request=req,
authenticated_user=_user(),
project_repository=_StubProjectRepo(_FIXTURE_PROJECT),
asset_library_repository=_StubLibraryRepo(_FIXTURE_LIBRARY),
asset_repository=repo,
storage_service=_storage(),
)
assert resp.duplicated is False
assert resp.skip_transfer is False
assert resp.asset_id == "" # 预建失败,降级无 asset_id
assert resp.upload_url != "" # 签名仍正常返回
def test_create_pending_asset_update_failure_swallowed():
"""复用占位记录时字段补齐 update 抛异常被吞掉,不阻塞返回。"""
class _UpdateBrokenRepo(_FakeAssetRepo):
def update(self, asset):
raise RuntimeError("db down")
repo = _UpdateBrokenRepo()
repo.create(_make_pending(file_hash="abc123", client_upload_id="", id="p1"))
result = upload_route._create_pending_asset(
asset_repository=repo,
project_id="p-1",
library_id="lib-1",
storage_key="uploads/new/test.mp4",
filename="test.mp4",
mime_type="video/mp4",
user_id="user-1",
file_hash="abc123",
client_upload_id="cuid-new",
file_size=1024,
)
assert result.id == "p1" # 仍复用,不抛异常
assert repo.saved == 1
@@ -38,6 +38,7 @@ def _auth_user(user_id="u-1", openid=None):
display_name="用户",
phone="",
phone_verified=False,
profile_completed=True,
)
return SimpleNamespace(user=user, session_id="s-1", token_type="user_auth")
@@ -112,6 +113,7 @@ def test_bind_success_returns_user_with_wechat_bound():
display_name="用户",
phone="",
phone_verified=False,
profile_completed=True,
)
import packages.application.auth.wechat_oauth_service as oauth_mod