Compare commits

...

4 Commits

Author SHA1 Message Date
xiaoxia 9840d5d778 deploy(#1718): 微信OAuth凭证纳入env模板+CI渲染,修复staging部署覆盖丢失 (#1721)
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 / 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 (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
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 / Build Staging Worker Image (pull_request) 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 / 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 Web Image (pull_request) Successful in 18s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 29s
CI/CD Pipeline / Build Staging API Image (push) Successful in 28s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 29s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m34s
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 / Build Staging Web Image (push) Successful in 43s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 43s
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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m56s
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 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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Integration Tests (push) Successful in 2m22s
CI/CD Pipeline / Validate - Style (push) Successful in 2m40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 50s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m8s
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 4m53s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m31s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m8s
AI Code Review / AI Code Review (pull_request) Successful in 6m28s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m54s
CI/CD Pipeline / Unit Tests (push) Successful in 9m4s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
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 / 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-05 17:39:46 +08:00
xiaoxia c8cfed0add feat(#1718): 微信扫码登录前端——昵称引导页 + 设置页微信绑定/解绑 (#1720)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
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 (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
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 (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m3s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (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 / Integration Tests (push) Successful in 2m26s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m24s
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m6s
CI/CD Pipeline / Validate - Style (push) Successful in 3m10s
CI/CD Pipeline / Build Staging API Image (push) Successful in 29s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 31s
AI Code Review / AI Code Review (pull_request) Failing after 3m49s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m5s
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 / Validate - Security (push) Successful in 5m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 52s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m35s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m54s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-05 17:30:42 +08:00
xiaoxia 8a6d51f6c3 fix(auth): 微信OAuth服务工厂改为真单例,修复回调state必校验失败 (#1718) (#1719)
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 / 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 / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m31s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 55s
CI/CD Pipeline / Build Staging API Image (push) Successful in 58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m57s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 29s
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 2m11s
CI/CD Pipeline / Validate - Style (push) Successful in 2m21s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m48s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m50s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m4s
CI/CD Pipeline / Validate - Security (push) Successful in 5m22s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m24s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m32s
AI Code Review / AI Code Review (pull_request) Failing after 6m21s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m55s
CI/CD Pipeline / Unit Tests (push) Successful in 9m46s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
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 / 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
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 17s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 33s
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 / PR Build Worker Image (pull_request) Successful in 30s
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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m23s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-05 17:17:07 +08:00
xiaoxia 6521be5426 fix(#1714): 素材上传去重防重 + complete幂等传递 + 轮询收敛(前端P0) (#1717)
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 / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
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 / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Frontend Lint (push) 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 / 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 / PR Build Web Image (pull_request) Successful in 31s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 20s
CI/CD Pipeline / Build Staging API Image (push) Successful in 45s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 44s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 42s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker 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 / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) 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 / Retag skipped Staging Worker Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m51s
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 / Validate - Python (mypy + alembic) (push) Successful in 1m58s
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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m13s
CI/CD Pipeline / Validate - Style (push) Successful in 2m45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m4s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m17s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 4m7s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m35s
CI/CD Pipeline / Validate - Security (push) Successful in 5m22s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m20s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m5s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m5s
CI/CD Pipeline / Unit Tests (push) Successful in 8m14s
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 12m54s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-05 16:40:05 +08:00
33 changed files with 1705 additions and 251 deletions
+2
View File
@@ -1187,6 +1187,8 @@ jobs:
COSYVOICE_API_KEY: ${{ secrets.COSYVOICE_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
MEDIAKIT_API_KEY: ${{ secrets.MEDIAKIT_API_KEY }}
WECHAT_APP_ID: ${{ secrets.WECHAT_APP_ID }}
WECHAT_APP_SECRET: ${{ secrets.WECHAT_APP_SECRET }}
run: |
set -eu
echo "Rendering .env from template + secrets..."
+35 -3
View File
@@ -4,6 +4,7 @@
import apiClient from "../client"
import { getOrCreateDefaultProject } from "../projects"
import type { DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types"
import { computeFileHash, makeClientUploadId } from "./uploadDedup"
/** 预签名直传准备 */
export const prepareDirectUpload = async (data: {
@@ -12,8 +13,13 @@ export const prepareDirectUpload = async (data: {
filename: string
content_type: string
file_size: number
/** 前端算好的文件内容哈希(SHA-256 hex),打开后端 file_hash 去重闸门 */
file_hash?: string
/** 前端生成的上传幂等 token,同一次逻辑上传(含重试)保持不变 */
client_upload_id?: string
}): Promise<DirectUploadPrepareResult> => {
const response = await apiClient.post("/upload/direct/prepare", data)
// prepare 单独放宽到 30s(全局 axios 实例只有 10sstaging 抖动时易超时)
const response = await apiClient.post("/upload/direct/prepare", data, { timeout: 30_000 })
return response.data
}
@@ -22,8 +28,14 @@ export const completeDirectUpload = async (data: {
project_id: string
library_id: string
storage_key: string
/** 前端算好的文件内容哈希(与 prepare 一致),后端按 hash 幂等去重 */
file_hash?: string
/** 前端上传幂等 token(与 prepare 一致),同一次上传重发 complete 不重复建记录 */
client_upload_id?: string
}): Promise<DirectUploadCompleteResult> => {
const response = await apiClient.post("/upload/direct/complete", data)
// complete 内含 OSS 存在性检查 + 建库 + 派单,放宽到 60s;
// 超时不代表失败(记录可能已建成),调用方禁止超时后盲目重传整个文件
const response = await apiClient.post("/upload/direct/complete", data, { timeout: 60_000 })
return response.data
}
@@ -109,6 +121,10 @@ export interface DirectUploadHandle {
export const prepareDirectUploadHandle = async (data: {
file: File
library_id: string
/** 前端算好的文件内容哈希(SHA-256 hex),prepare/complete 均携带 */
fileHash?: string
/** 本次逻辑上传的幂等 tokenprepare/complete 一致、重试复用 */
clientUploadId?: string
}): Promise<DirectUploadHandle> => {
const project = await getOrCreateDefaultProject()
@@ -118,6 +134,8 @@ export const prepareDirectUploadHandle = async (data: {
filename: data.file.name,
content_type: data.file.type || "application/octet-stream",
file_size: data.file.size,
file_hash: data.fileHash,
client_upload_id: data.clientUploadId,
})
return {
@@ -128,6 +146,8 @@ export const prepareDirectUploadHandle = async (data: {
project_id: project.id,
library_id: data.library_id,
storage_key: prepared.storage_key,
file_hash: data.fileHash,
client_upload_id: data.clientUploadId,
}),
}
}
@@ -137,8 +157,20 @@ export const uploadAssetDirect = async (data: {
file: File
library_id: string
onProgress?: (percent: number) => void
/** 文件内容哈希;未传时自动补算(配音/封面/克隆等非队列链路统一受益) */
fileHash?: string
/** 幂等 token;未传时自动生成 */
clientUploadId?: string
}): Promise<DirectUploadCompleteResult> => {
const handle = await prepareDirectUploadHandle({ file: data.file, library_id: data.library_id })
// 自动补算哈希与幂等 token:确保 file_hash 去重闸门对所有上传链路生效
const fileHash = data.fileHash ?? (await computeFileHash(data.file))
const clientUploadId = data.clientUploadId ?? makeClientUploadId()
const handle = await prepareDirectUploadHandle({
file: data.file,
library_id: data.library_id,
fileHash,
clientUploadId,
})
await handle.transfer(data.onProgress)
return handle.complete()
}
+143
View File
@@ -0,0 +1,143 @@
/**
* 上传去重 / 幂等工具(Issue #1714
*
* 背景:同一文件被反复入队、complete 超时后盲目重传,导致后端创建大量重复
* PROCESSING 素材记录。本模块提供两类纯函数:
*
* 1. 文件指纹:
* - makeFileFingerprint():文件名+大小+lastModified,入队去重用(同步、零开销)
* - computeFileHash()SHA-256 内容哈希(小文件全量、大文件抽样头尾),
* prepare/complete 时发给后端打开 file_hash 去重闸门
* 2. 队列去重:findDuplicateInQueue() 判断文件是否已在队列中
* 3. 幂等 tokenmakeClientUploadId() 生成上传幂等 ID(每次"一次逻辑上传"一个,
* 重试复用同一 ID,重新入队才生成新 ID)
*/
/** 大文件抽样阈值:超过此大小只哈希头尾片段,避免上传前长时间卡 UI */
export const HASH_FULL_READ_LIMIT = 256 * 1024 * 1024 // 256MB
/** 抽样读取的头尾片段大小(各 8MB) */
export const HASH_SAMPLE_CHUNK = 8 * 1024 * 1024
/** 计算指纹时,文件在队列中已存在的状态(已失败的可以重试,不算重复) */
export type DedupExcludeStatus = "error" | "done"
/**
* 文件入队指纹:同库 + 文件名 + 大小 + 修改时间。
* 同一文件(File 对象由 <input> 重选或拖拽重复触发时三个字段均一致)稳定复现;
* 不同文件极小概率碰撞时可由后端 file_hash 内容去重兜底。
*/
export function makeFileFingerprint(file: Pick<File, "name" | "size" | "lastModified">): string {
return `${file.name}::${file.size}::${file.lastModified}`
}
/**
* 在现有队列项中查找同一文件的在途记录。
* 已失败(error)的项允许重试路径复用、已完成(done)的可跳过;
* 处于 preparing/uploading/ingesting 的在途项一律视为重复,禁止重复入队。
*
* 返回命中的队列项 id(tempId),未命中返回 null。
*/
export function findDuplicateInQueue<T extends { fileKey: string; status: string }>(
queue: T[],
fileKey: string,
excludeStatuses: DedupExcludeStatus[] = [],
): T | null {
const exclude = new Set<string>(excludeStatuses)
return queue.find((it) => it.fileKey === fileKey && !exclude.has(it.status)) ?? null
}
/** 生成上传幂等 token:一次"逻辑上传"一个,重试复用、重新入队换新 */
export function makeClientUploadId(): string {
const rand =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${Math.random()
.toString(36)
.slice(2, 10)}`
return `up_${Date.now().toString(36)}_${rand.replace(/-/g, "").slice(0, 16)}`
}
/** 读取 Blob/File 片段为 ArrayBuffer:优先 Blob.arrayBuffer(),老环境回退 FileReader */
function readAsArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
if (typeof blob.arrayBuffer === "function") {
return blob.arrayBuffer()
}
return new Promise<ArrayBuffer>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as ArrayBuffer)
reader.onerror = () => reject(reader.error ?? new Error("FileReader read failed"))
reader.readAsArrayBuffer(blob)
})
}
/**
* 把 buffer 复制到当前 JS realm 的 Uint8Array 再哈希。
* jsdom/测试环境中 Blob.arrayBuffer() 可能返回另一 realm 的 ArrayBuffer
* Node WebCrypto 的 WebIDL instanceof 校验会拒绝跨 realm 参数。
*/
async function digestSha256(buffer: ArrayBuffer): Promise<ArrayBuffer> {
const subtle =
typeof globalThis !== "undefined" && globalThis.crypto ? globalThis.crypto.subtle : null
if (!subtle) throw new Error("crypto.subtle unavailable")
const local = new Uint8Array(buffer.byteLength)
local.set(new Uint8Array(buffer))
return subtle.digest("SHA-256", local)
}
function toHex(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let hex = ""
for (let i = 0; i < bytes.length; i += 1) {
hex += bytes[i].toString(16).padStart(2, "0")
}
return hex
}
/**
* 计算文件内容 SHA-256hex64 字符,与后端 file_hash 字段长度一致)。
* - ≤256MB:全量哈希,内容一致必然一致
* - >256MB:哈希「头部 8MB + 尾部 8MB + 文件大小」,视频素材体积大、
* 头部含 moov 元数据、尾部含 mdat 结尾,抽样碰撞概率可忽略,
* 且避免上传前对 2GB 文件全量读取造成长时间卡顿
*
* 运行环境不支持 crypto.subtle(非安全上下文/老浏览器)时返回空字符串,
* 调用方据此降级为不传 hash(后端仍有幂等 token + 同文件名兜底去重)。
*/
export async function computeFileHash(file: File): Promise<string> {
try {
const subtle =
typeof globalThis !== "undefined" &&
globalThis.crypto &&
typeof globalThis.crypto.subtle?.digest === "function"
? globalThis.crypto.subtle
: null
if (!subtle) return ""
if (file.size <= HASH_FULL_READ_LIMIT) {
const data = await readAsArrayBuffer(file.slice(0, file.size))
return toHex(await digestSha256(data))
}
// 大文件:头 8MB + 尾 8MB + 大小,拼成一段后哈希
const head = await readAsArrayBuffer(file.slice(0, HASH_SAMPLE_CHUNK))
const tail =
file.size > HASH_SAMPLE_CHUNK
? await readAsArrayBuffer(file.slice(Math.max(0, file.size - HASH_SAMPLE_CHUNK), file.size))
: new ArrayBuffer(0)
const merged = new Uint8Array(head.byteLength + tail.byteLength + 8)
merged.set(new Uint8Array(head), 0)
merged.set(new Uint8Array(tail), head.byteLength)
const sizeView = new DataView(merged.buffer, head.byteLength + tail.byteLength, 8)
// 文件大小以 64 位大端写入(BigInt 最稳;不支持 BigInt64 时手算高低位)
if (typeof sizeView.setBigUint64 === "function") {
sizeView.setBigUint64(0, BigInt(file.size), false)
} else {
sizeView.setUint32(0, Math.floor(file.size / 0x100000000), false)
sizeView.setUint32(4, file.size >>> 0, false)
}
return toHex(await digestSha256(merged.buffer))
} catch (err) {
console.warn("[uploadDedup] 计算文件哈希失败,降级为不传 file_hash:", err)
return ""
}
}
+14 -3
View File
@@ -12,13 +12,18 @@ export type {
UserResponse,
WechatAuthUrlResponse,
WechatCallbackResponse,
WechatBindUrlResponse,
WechatBindCompleteResponse,
WechatUnbindResponse,
UpdateProfileRequest,
UpdateProfileResponse,
SendVerificationCodeRequest,
BindContactRequest,
BindContactResponse,
} from "./types"
// 用户工具函数
export { normalizeUser } from "./user"
export { normalizeUser, updateProfile } from "./user"
// 登录/注册/登出/刷新
export { login, refreshAccessToken, register, logout } from "./login"
@@ -32,8 +37,14 @@ export { requestPasswordReset, resetPassword } from "./password"
// 邮箱验证
export { verifyEmail } from "./email"
// 微信登录
export { getWechatAuthUrl, wechatCallback } from "./wechat"
// 微信登录 / 绑定
export {
getWechatAuthUrl,
wechatCallback,
getWechatBindUrl,
bindWechat,
unbindWechat,
} from "./wechat"
// 联系方式
export { sendVerificationCode, bindContact } from "./contact"
+44
View File
@@ -34,6 +34,17 @@ export interface User {
is_email_verified: boolean
email_verified: boolean
created_at?: string
/** 微信是否已绑定 */
wechat_bound?: boolean
/** 微信昵称(绑定后展示) */
wechat_nickname?: string
/** 头像 URL(微信头像等) */
avatar_url?: string
/** 手机号 */
phone?: string
phone_verified?: boolean
/** 资料是否完善(微信新用户首次登录为 false,需填昵称引导) */
profile_completed?: boolean
}
export interface UserResponse {
@@ -45,6 +56,12 @@ export interface UserResponse {
is_email_verified?: boolean
email_verified?: boolean
created_at?: string
wechat_bound?: boolean
wechat_nickname?: string
avatar_url?: string
phone?: string
phone_verified?: boolean
profile_completed?: boolean
}
export interface WechatAuthUrlResponse {
@@ -80,3 +97,30 @@ export interface BindContactResponse {
success: boolean
user: User
}
/** 更新个人资料请求 */
export interface UpdateProfileRequest {
display_name?: string
}
/** 更新个人资料响应(返回最新用户信息) */
export interface UpdateProfileResponse {
user: UserResponse
}
/** 微信绑定授权链接响应 */
export interface WechatBindUrlResponse {
auth_url: string
state: string
}
/** 微信绑定完成响应 */
export interface WechatBindCompleteResponse {
success: boolean
user: UserResponse
}
/** 微信解绑响应 */
export interface WechatUnbindResponse {
success: boolean
}
+16 -1
View File
@@ -1,4 +1,5 @@
import type { User, UserResponse } from "./types"
import apiClient from "../client"
import type { User, UserResponse, UpdateProfileRequest, UpdateProfileResponse } from "./types"
/**
* 规范化用户数据,兼容不同后端返回格式
@@ -16,5 +17,19 @@ export const normalizeUser = (data: UserResponse): User => {
is_email_verified: emailVerified,
email_verified: emailVerified,
created_at: data.created_at,
wechat_bound: data.wechat_bound,
wechat_nickname: data.wechat_nickname,
avatar_url: data.avatar_url,
phone: data.phone,
phone_verified: data.phone_verified,
profile_completed: data.profile_completed,
}
}
/**
* 更新个人资料(昵称等)
*/
export const updateProfile = async (data: UpdateProfileRequest): Promise<User> => {
const response = await apiClient.patch<UpdateProfileResponse>("/auth/me", data)
return normalizeUser(response.data.user)
}
+35 -2
View File
@@ -1,8 +1,14 @@
import apiClient from "../client"
import type { WechatAuthUrlResponse, WechatCallbackResponse } from "./types"
import type {
WechatAuthUrlResponse,
WechatCallbackResponse,
WechatBindUrlResponse,
WechatBindCompleteResponse,
WechatUnbindResponse,
} from "./types"
/**
* 获取微信授权链接
* 获取微信授权链接(登录场景)
*/
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
const response = await apiClient.get("/auth/wechat/url")
@@ -19,3 +25,30 @@ export const wechatCallback = async (
const response = await apiClient.post("/auth/wechat/callback", { code, state })
return response.data
}
/**
* 获取微信绑定授权链接(已登录用户绑定场景)
*/
export const getWechatBindUrl = async (): Promise<WechatBindUrlResponse> => {
const response = await apiClient.get("/auth/wechat/bind/url")
return response.data
}
/**
* 微信绑定完成(扫码回调后用 code 绑定到当前登录账号)
*/
export const bindWechat = async (
code: string,
state: string,
): Promise<WechatBindCompleteResponse> => {
const response = await apiClient.post("/auth/wechat/bind", { code, state })
return response.data
}
/**
* 解绑微信
*/
export const unbindWechat = async (): Promise<WechatUnbindResponse> => {
const response = await apiClient.delete("/auth/wechat/bind")
return response.data
}
@@ -42,6 +42,7 @@ const AssetLibrary: React.FC = () => {
assetsError,
assetsErrorObj,
refetchAssets,
stalledAssetIds,
searchText,
setSearchText,
filterType,
@@ -77,6 +78,7 @@ const AssetLibrary: React.FC = () => {
removeUpload,
clearFinished,
uploading,
transferActive,
activeCount,
pendingCount,
} = useAssetUpload({ effectiveLibId })
@@ -168,6 +170,7 @@ const AssetLibrary: React.FC = () => {
{/* 上传区域 */}
<AssetUploadZone
uploading={uploading}
transferActive={transferActive}
activeCount={activeCount}
pendingCount={pendingCount}
onUpload={enqueueUploads}
@@ -214,6 +217,7 @@ const AssetLibrary: React.FC = () => {
selectedIds={selectedIds}
diagnosingId={diagnosingId}
uploadProgressMap={uploadProgressMap}
stalledAssetIds={stalledAssetIds}
onRetry={refetchAssets}
onToggleSelect={toggleSelect}
onDiagnose={handleDiagnose}
+22
View File
@@ -1041,3 +1041,25 @@
background: #fef2f2;
color: #dc2626;
}
/* 上传入口禁用态(直传进行中,防重复提交,Issue #1714 */
.xx-asset-upload-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.xx-asset-upload-btn:disabled:hover {
opacity: 0.6;
}
.xx-asset-upload-btn:disabled:active {
transform: none;
}
/* 处理超时遮罩:创建超过 10 分钟仍在处理中(疑似后端卡住),停止转圈并警示 */
.xx-asset-thumb-stalled {
background: rgba(217, 119, 6, 0.28);
color: #fde68a;
backdrop-filter: blur(2px);
}
.xx-asset-thumb-stalled :first-child {
font-size: var(--font-size-2xl);
}
@@ -22,6 +22,8 @@ export interface AssetCardProps {
diagnosing?: boolean
/** 上传中实时进度(仅 uploading 态有值;ingesting 后由后端状态接管) */
uploadProgress?: { progress: number; uploading: boolean }
/** 处理超过 10 分钟仍未就绪(疑似后端卡住):停止转圈并提示处理超时 */
stalled?: boolean
onToggle: () => void
onDiagnose: () => void
onPlay: () => void
@@ -33,6 +35,7 @@ const AssetCard: React.FC<AssetCardProps> = ({
selected,
diagnosing,
uploadProgress,
stalled,
onToggle,
onDiagnose,
onPlay,
@@ -67,11 +70,15 @@ const AssetCard: React.FC<AssetCardProps> = ({
</div>
)}
{/* 转码/处理中遮罩 */}
{/* 转码/处理中遮罩(卡死超过 10 分钟时停止转圈,提示超时) */}
{asset.loading && !isUploading && (
<div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
<LoadingOutlined />
<span></span>
<div
className={`xx-asset-thumb-overlay ${
stalled ? "xx-asset-thumb-stalled" : "xx-asset-thumb-processing"
}`}
>
{stalled ? <CloseCircleOutlined /> : <LoadingOutlined />}
<span>{stalled ? "处理超时,可重试上传" : "转码处理中"}</span>
</div>
)}
@@ -129,7 +136,7 @@ const AssetCard: React.FC<AssetCardProps> = ({
</p>
<div className="xx-asset-meta">
<span className="xx-asset-meta-status">
<StatusPill status={asset.status} label={asset.statusLabel} />
<StatusPill status={asset.status} label={stalled ? "处理超时" : asset.statusLabel} />
</span>
{asset.duration && <span className="xx-asset-meta-duration">{asset.duration}</span>}
</div>
@@ -19,6 +19,8 @@ export interface AssetGridSectionProps {
selectedIds: Set<string>
diagnosingId: string | null
uploadProgressMap?: UploadProgressMap
/** 创建超过 10 分钟仍在处理中的素材 id(疑似后端卡住),卡片提示处理超时 */
stalledAssetIds?: Set<string>
onRetry?: () => void
onToggleSelect: (id: string) => void
onDiagnose: (asset: AssetItem) => void
@@ -34,6 +36,7 @@ export const AssetGridSection: React.FC<AssetGridSectionProps> = ({
selectedIds,
diagnosingId,
uploadProgressMap,
stalledAssetIds,
onRetry,
onToggleSelect,
onDiagnose,
@@ -76,6 +79,7 @@ export const AssetGridSection: React.FC<AssetGridSectionProps> = ({
selected={selectedIds.has(asset.id)}
diagnosing={diagnosingId === asset.id}
uploadProgress={uploadProgressMap?.get(asset.id)}
stalled={stalledAssetIds?.has(asset.id)}
onToggle={() => onToggleSelect(asset.id)}
onDiagnose={() => onDiagnose(asset)}
onPlay={() => onPlay(asset)}
@@ -4,10 +4,13 @@
* - 拖拽文件到内容区任意位置同样触发上传(不再占用大面积虚线框)
*/
import React, { useRef, useState } from "react"
import { message } from "antd"
import { PlusOutlined, CloudUploadOutlined } from "@ant-design/icons"
export interface AssetUploadZoneProps {
uploading: boolean
/** 有文件正在本地指纹/prepare/直传(非服务端转码),此时禁用入口防重复提交 */
transferActive: boolean
activeCount: number
pendingCount: number
onUpload: (files: File[]) => void
@@ -15,6 +18,7 @@ export interface AssetUploadZoneProps {
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
uploading,
transferActive,
activeCount,
pendingCount,
onUpload,
@@ -27,6 +31,11 @@ export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
const pickFiles = (list: FileList | null) => {
if (!list || list.length === 0) return
// 直传进行中拦截重复触发:相同文件仍由入队去重兜底,这里先给明确反馈
if (transferActive) {
message.warning("文件正在上传中,请等待当前上传完成后再添加")
return
}
onUpload(Array.from(list))
}
@@ -58,10 +67,18 @@ export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
<button
type="button"
className="xx-asset-upload-btn"
onClick={() => inputRef.current?.click()}
disabled={transferActive}
title={transferActive ? "文件上传中,暂不能添加新文件" : undefined}
onClick={() => {
if (transferActive) {
message.warning("文件正在上传中,请等待当前上传完成后再添加")
return
}
inputRef.current?.click()
}}
>
<PlusOutlined />
{transferActive ? "上传中…" : "上传素材"}
</button>
<span className="xx-asset-upload-status">
{uploading ? (
@@ -80,6 +80,7 @@ const UploadQueuePanel: React.FC<UploadQueuePanelProps> = ({
) : null}
<div className="xx-upload-queue-status">
{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}` : ""}
</div>
@@ -89,7 +90,9 @@ const UploadQueuePanel: React.FC<UploadQueuePanelProps> = ({
<button
type="button"
className="xx-upload-queue-btn"
title="重试"
title={
it.failedStage === "complete" ? "安全重试(只确认,不重新上传)" : "重试上传"
}
onClick={() => onRetry(it.tempId)}
>
<ReloadOutlined />
+187 -32
View File
@@ -3,33 +3,57 @@ import { useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
import { prepareDirectUploadHandle, type DirectUploadHandle } from "@/api/assets"
import { MAX_FILE_SIZE } from "../constants"
import {
computeFileHash,
findDuplicateInQueue,
makeClientUploadId,
makeFileFingerprint,
} from "@/api/assets/uploadDedup"
/** 单文件上传状态机 */
export type UploadItemStatus = "preparing" | "uploading" | "ingesting" | "done" | "error"
/** 失败发生的阶段:complete 阶段失败时记录可能已在后端建成,禁止盲目重传整个文件 */
export type UploadFailStage = "prepare" | "transfer" | "complete"
export interface UploadItem {
/** 前端临时 idprepare 前无 asset_id 时用) */
/** 前端临时 idprepare 前无 asset_id 时用),同时作为队列项 key */
tempId: string
file: File
fileName: string
/** 进度 0~100(仅直传阶段有真实进度) */
progress: number
status: UploadItemStatus
/** 文件指纹(name+size+lastModified),入队去重用 */
fileKey: string
/** 本次逻辑上传的幂等 token:重试复用、重新入队才换新 */
clientUploadId: string
/** 上传前算好的文件内容哈希(SHA-256),prepare/complete 都带上 */
fileHash?: string
/** 后端 prepare 预建的 asset id(旧后端可能为空) */
assetId?: string
/** 去重命中:complete 返回 duplicated,标记完成但不产生新素材 */
duplicated?: boolean
/** 失败发生的阶段;complete 阶段失败点重试只重发 complete,不重新上传文件 */
failedStage?: UploadFailStage
/** 状态行补充提示(如"正在计算文件指纹…" */
hint?: string
error?: string
}
/** 批量直传最大并发数,避免多文件瓜分上行带宽 */
const MAX_CONCURRENT = 3
/** complete 阶段失败后的错误提示:素材可能已在服务器处理中,重试不会重新上传 */
const COMPLETE_ERROR_HINT =
"确认请求失败,素材可能已在服务器处理中;点重试将安全确认,不会重新上传文件"
/**
* 素材批量上传 Hook
* - prepare 阶段后端预建 status=uploading 的 asset,前端拿到 asset_id 立即刷新列表
* - 入队按文件指纹(name+size+lastModified)去重:同一文件已在队列/上传中/处理中时不重复入队
* - 上传前计算文件 SHA-256prepare/complete 携带 file_hash + 幂等 tokenclientUploadId
* - complete 超时/失败不盲目重传:复用 handle 只重发 complete(幂等),prepare/transfer 失败才全量重跑
* - OSS 直传并发限制为 3,其余排队;每个文件独立进度/状态
* - complete 后素材进入转码(ingesting/processing),由列表轮询反映
* - 失败卡片支持重试/移除
*/
export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
@@ -39,6 +63,13 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
const itemsRef = useRef<UploadItem[]>([])
itemsRef.current = items
/**
* prepare 成功后的 handle 按 tempId 留存:
* complete 阶段失败(超时/网络)时 OSS 文件已存在、后端记录也可能已建成,
* 重试必须复用同一 handle 只重发 complete,绝不能重新 prepare+直传。
*/
const handlesRef = useRef<Map<string, DirectUploadHandle>>(new Map())
const updateItem = useCallback((tempId: string, patch: Partial<UploadItem>) => {
setItems((prev) => prev.map((it) => (it.tempId === tempId ? { ...it, ...patch } : it)))
}, [])
@@ -52,14 +83,57 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
}, [queryClient, effectiveLibId])
/** 执行单个文件的完整上传流程(prepare→transfer→complete */
/**
* 执行单个文件的完整上传流程。
* @param completeOnly complete 阶段失败后的重试:跳过 hash/prepare/transfer,只重发 complete
* (OSS 文件已传完,重发由 file_hash + clientUploadId 保证幂等)
*/
const runUpload = useCallback(
async (item: UploadItem, handle?: DirectUploadHandle) => {
async (item: UploadItem, handle?: DirectUploadHandle, completeOnly = false) => {
let stage: UploadFailStage = "prepare"
try {
// 1. prepare(重试时复用已准备的 handle 也行,但签名可能过期,重新 prepare 最稳)
const h =
handle ??
(await prepareDirectUploadHandle({ file: item.file, library_id: effectiveLibId }))
let h = handle
if (completeOnly && h) {
// ── complete 重试:文件已在 OSS,直接幂等重发确认 ──
stage = "complete"
updateItem(item.tempId, {
status: "ingesting",
progress: 100,
error: undefined,
failedStage: undefined,
hint: undefined,
})
const result = await h.complete()
refreshList()
handlesRef.current.delete(item.tempId)
if (result.duplicated) {
updateItem(item.tempId, { status: "done", duplicated: true, assetId: result.asset_id })
message.info(`"${item.fileName}" 与素材库已有内容相同,已跳过`)
} else {
updateItem(item.tempId, { status: "done", assetId: result.asset_id || item.assetId })
message.success(`"${item.fileName}" 上传完成,正在转码处理`)
}
return
}
// 1. 计算文件内容哈希(失败不阻塞,降级为不传 hash;后端仍有幂等 token 兜底)
updateItem(item.tempId, { hint: "正在计算文件指纹…" })
const fileHash = item.fileHash || (await computeFileHash(item.file))
updateItem(item.tempId, { fileHash, hint: undefined })
// 2. prepare(携带 file_hash + 幂等 token;重试时复用同一 clientUploadId
stage = "prepare"
h =
h ??
(await prepareDirectUploadHandle({
file: item.file,
library_id: effectiveLibId,
fileHash,
clientUploadId: item.clientUploadId,
}))
handlesRef.current.set(item.tempId, h)
if (h.prepared.asset_id) {
updateItem(item.tempId, {
status: "uploading",
@@ -72,26 +146,50 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
updateItem(item.tempId, { status: "uploading", progress: 0 })
}
// 2. OSS 直传(真实进度)
// 3. OSS 直传(真实进度)
stage = "transfer"
await h.transfer((pct) => updateItem(item.tempId, { progress: pct }))
// 3. complete:后端创建 ingest job,素材进入转码
// 4. complete:后端确认入库并创建 ingest jobfile_hash + 幂等 token 已在 handle 闭包中)
stage = "complete"
updateItem(item.tempId, { status: "ingesting", progress: 100 })
const result = await h.complete()
refreshList()
handlesRef.current.delete(item.tempId)
if (result.duplicated) {
updateItem(item.tempId, { status: "done", duplicated: true, assetId: result.asset_id })
message.info(`"${item.fileName}" 与素材库已有内容相同,已跳过`)
} else {
updateItem(item.tempId, { status: "done" })
updateItem(item.tempId, { status: "done", assetId: result.asset_id || item.assetId })
message.success(`"${item.fileName}" 上传完成,正在转码处理`)
}
} catch (err: unknown) {
const detail = err instanceof Error ? err.message : "上传失败"
console.error("[useAssetUpload] 上传失败:", item.fileName, err)
updateItem(item.tempId, { status: "error", error: detail })
message.error(`"${item.fileName}" 上传失败:${detail}`)
console.error("[useAssetUpload] 上传失败:", item.fileName, stage, err)
if (stage === "complete") {
// complete 失败(超时/5xx/网络):后端记录可能已建成,handle 保留供幂等重试;
// 刷新列表让用户看到可能已创建的「处理中」素材,避免误以为没传上去而重复操作
refreshList()
updateItem(item.tempId, {
status: "error",
failedStage: "complete",
error: COMPLETE_ERROR_HINT,
hint: undefined,
})
message.error(`"${item.fileName}" ${COMPLETE_ERROR_HINT}`)
} else {
// prepare / transfer 失败:后端尚无素材记录,可安全全量重跑
handlesRef.current.delete(item.tempId)
updateItem(item.tempId, {
status: "error",
failedStage: stage === "transfer" ? "transfer" : "prepare",
error: detail,
hint: undefined,
})
message.error(`"${item.fileName}" 上传失败:${detail}`)
}
}
},
[effectiveLibId, refreshList, updateItem],
@@ -113,7 +211,9 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
if (!next) return
claimedRef.current.add(next.tempId)
inFlightRef.current += 1
void runUpload(next).finally(() => {
// complete 阶段失败的重试:复用留存的 handle,只重发 complete
const existingHandle = handlesRef.current.get(next.tempId)
void runUpload(next, existingHandle, existingHandle !== undefined).finally(() => {
inFlightRef.current -= 1
claimedRef.current.delete(next.tempId)
// 一个任务结束(成功/失败)后继续拉起排队任务
@@ -126,7 +226,11 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
pumpRef.current()
}, [items])
/** 入队一个或多个文件 */
/**
* 入队一个或多个文件(按文件指纹去重):
* - 同一文件已在队列且 preparing/uploading/ingesting/done → 跳过,不重复入队
* - 同一文件此前失败(error)→ 重新激活原队列项(复用 clientUploadId,保持幂等语义)
*/
const enqueueUploads = useCallback(
(files: File[]) => {
if (!effectiveLibId) {
@@ -143,37 +247,86 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
}
if (valid.length === 0) return
const newItems: UploadItem[] = valid.map((file, idx) => ({
tempId: `${Date.now()}-${idx}-${Math.random().toString(36).slice(2, 8)}`,
file,
fileName: file.name,
progress: 0,
status: "preparing",
}))
setItems((prev) => [...prev, ...newItems])
let skipped = 0
let rearmed = 0
const newItems: UploadItem[] = []
for (const file of valid) {
const fileKey = makeFileFingerprint(file)
// error 项允许重新激活;其余状态(preparing/uploading/ingesting/done)都算重复
const dup = findDuplicateInQueue([...itemsRef.current, ...newItems], fileKey, ["error"])
if (dup) {
skipped += 1
continue
}
// 失败项重新激活:复用 tempId/clientUploadId,由 pump 按留存 handle 决定重试方式
const failed = itemsRef.current.find(
(it) => it.fileKey === fileKey && it.status === "error",
)
if (failed) {
rearmed += 1
updateItem(failed.tempId, {
status: "preparing",
progress: 0,
error: undefined,
failedStage: undefined,
hint: undefined,
})
continue
}
newItems.push({
tempId: `${Date.now()}-${newItems.length}-${Math.random().toString(36).slice(2, 8)}`,
file,
fileName: file.name,
progress: 0,
status: "preparing",
fileKey,
clientUploadId: makeClientUploadId(),
})
}
if (newItems.length > 0) {
setItems((prev) => [...prev, ...newItems])
}
if (skipped > 0) {
message.warning(`已跳过 ${skipped} 个重复文件(已在上传队列、处理中或本页已上传)`)
}
if (rearmed > 0) {
message.info(`已重新加入 ${rearmed} 个此前失败的文件`)
}
},
[effectiveLibId],
[effectiveLibId, updateItem],
)
/** 重试失败任务 */
/**
* 重试失败任务(仅限 status=error):
* - complete 阶段失败:复用留存 handle 只重发 complete(幂等,不重新上传)
* - prepare/transfer 阶段失败:全量重跑(后端尚无记录,安全)
*/
const retryUpload = useCallback(
(tempId: string) => {
const target = itemsRef.current.find((it) => it.tempId === tempId)
if (!target) return
updateItem(tempId, { status: "preparing", progress: 0, error: undefined })
// 状态更新后由 useEffect 触发 pump
if (!target || target.status !== "error") return
updateItem(tempId, { status: "preparing", progress: 0, error: undefined, hint: undefined })
// 状态更新后由 useEffect 触发 pumppump 会按 handlesRef 自动选择 completeOnly / 全量
},
[updateItem],
)
/** 从上传列表移除(已进入转码的由素材网格管理;这里只移除上传面板记录) */
const removeUpload = useCallback((tempId: string) => {
handlesRef.current.delete(tempId)
setItems((prev) => prev.filter((it) => it.tempId !== tempId))
}, [])
/** 清空已完成/去重记录 */
const clearFinished = useCallback(() => {
setItems((prev) => prev.filter((it) => it.status !== "done"))
setItems((prev) => {
for (const it of prev) {
if (it.status === "done") handlesRef.current.delete(it.tempId)
}
return prev.filter((it) => it.status !== "done")
})
}, [])
const activeCount = items.filter(
@@ -188,8 +341,10 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
retryUpload,
removeUpload,
clearFinished,
/** 是否有进行中的上传(用于上传区文案) */
/** 是否有进行中的上传(用于上传区文案/禁用入口 */
uploading: hasActive,
/** 是否有文件正在本地处理或直传(用于禁用上传入口,防重复提交) */
transferActive: activeCount > 0,
activeCount,
pendingCount,
}
@@ -10,6 +10,26 @@ import {
import { getOrCreateDefaultProject } from "@/api/projects"
import { mapLibrary, mapAsset, type AssetItem, type LibraryItem } from "../types"
/** 处理中素材快速轮询(3s)的最大持续时间:超过后停止快轮询,避免孤儿任务永久转圈 */
const PROCESSING_POLL_MAX_MS = 10 * 60 * 1000 // 10 分钟
const isProcessingStatus = (st?: string | null): boolean =>
st === "uploading" || st === "ingesting" || st === "processing" || st === "pending"
/** 判断列表是否存在「创建超过 maxMs 仍在处理中」的卡死素材 */
const hasStalledProcessing = (
list: ApiAssetItem[],
maxMs: number = PROCESSING_POLL_MAX_MS,
): boolean => {
const now = Date.now()
return list.some((a) => {
if (!isProcessingStatus(a.status ?? "")) return false
if (!a.created_at) return false
const created = new Date(a.created_at).getTime()
return Number.isFinite(created) && now - created > maxMs
})
}
/**
* 素材库数据 Hook
* 封装视频库列表、素材列表的数据查询,以及筛选、搜索状态管理
@@ -63,15 +83,15 @@ export function useAssetsData() {
}),
enabled: !!effectiveLibId,
staleTime: 30_000,
// 列表中存在上传中/转码中素材时每 3s 轮询;全部就绪后自动停止
// 列表中存在上传中/转码中素材时每 3s 轮询;全部就绪后自动停止
// 但若处理中素材创建已超过 10 分钟仍未就绪(疑似后端卡住/孤儿任务),
// 停止快轮询避免无限转圈——卡死素材在网格中显示「处理超时」提示。
refetchInterval: (query) => {
const data = query.state.data as { items: ApiAssetItem[] } | undefined
const items = data?.items ?? []
const processing = items.some((a) => {
const st = a.status ?? ""
return st === "uploading" || st === "ingesting" || st === "processing" || st === "pending"
})
return processing ? 3000 : false
const processing = items.some((a) => isProcessingStatus(a.status ?? ""))
if (!processing) return false
return hasStalledProcessing(items) ? false : 3000
},
})
@@ -80,6 +100,21 @@ export function useAssetsData() {
[apiAssets],
)
/** 创建超过 10 分钟仍在处理中的素材(后端可能卡住),网格提示「处理超时」 */
const stalledAssetIds = useMemo(() => {
const list = Array.isArray(apiAssets?.items) ? apiAssets.items : []
const ids = new Set<string>()
const now = Date.now()
for (const a of list) {
if (!isProcessingStatus(a.status ?? "") || !a.created_at) continue
const created = new Date(a.created_at).getTime()
if (Number.isFinite(created) && now - created > PROCESSING_POLL_MAX_MS) {
ids.add(a.id)
}
}
return ids
}, [apiAssets])
/* ── 筛选状态 ── */
const [searchText, setSearchText] = useState("")
const [filterType, setFilterType] = useState<string>("all")
@@ -129,6 +164,8 @@ export function useAssetsData() {
assetsError,
assetsErrorObj,
refetchAssets,
stalledAssetIds,
hasStalledAssets: stalledAssetIds.size > 0,
// 筛选
searchText,
setSearchText,
@@ -0,0 +1,97 @@
/**
* 微信绑定回调页(已登录用户在设置页发起"绑定微信"扫码后回到这里)
* 用 code 调绑定接口把微信关联到当前账号,成功后回设置页
*/
import React, { useEffect, useState } from "react"
import { useSearchParams, useNavigate } from "react-router-dom"
import { Spin } from "antd"
import { bindWechat, normalizeUser } from "@/api/auth"
import { useAuthStore } from "@/store/authStore"
const WechatBindCallback: React.FC = () => {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const setUser = useAuthStore((state) => state.setUser)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const code = searchParams.get("code")
const state = searchParams.get("state")
if (!code || !state) {
setError("无效的回调参数")
return
}
const handleBind = async () => {
// state 校验:绑定场景由设置页生成并落库,前缀 bind:
const savedState = localStorage.getItem("wechat_bind_state")
if (!savedState || savedState !== state) {
setError("安全校验失败,请重新绑定")
return
}
localStorage.removeItem("wechat_bind_state")
try {
const result = await bindWechat(code, state)
setUser(normalizeUser(result.user))
// 用 replace 回设置页,query 携带成功标记由设置页提示
navigate("/app/profile?wechat_bind=success", { replace: true })
} catch {
navigate("/app/profile?wechat_bind=failed", { replace: true })
}
}
handleBind()
}, [searchParams, navigate, setUser])
if (error) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
background: "#f5f5f5",
}}
>
<div style={{ textAlign: "center" }}>
<p style={{ color: "#ef4444", fontSize: 16, marginBottom: 16 }}>{error}</p>
<button
onClick={() => navigate("/app/profile")}
style={{
padding: "8px 24px",
background: "var(--primary-color, #3b82f6)",
color: "white",
border: "none",
borderRadius: 6,
cursor: "pointer",
}}
>
</button>
</div>
</div>
)
}
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
background: "#f5f5f5",
}}
>
<div style={{ textAlign: "center" }}>
<Spin size="large" />
<p style={{ marginTop: 16, color: "#666" }}>...</p>
</div>
</div>
)
}
export default WechatBindCallback
+44 -67
View File
@@ -1,19 +1,20 @@
/**
* 微信登录回调页
* 扫码授权后由微信重定向回来:用 code 换登录态,
* 新用户/资料未完善 → 跳昵称引导页;老用户 → 回来源页/首页
*/
import React, { useEffect, useState } from "react"
import { useSearchParams, useNavigate } from "react-router-dom"
import { Spin, message } from "antd"
import { Spin } from "antd"
import { wechatCallback, getCurrentUser, normalizeUser, type User } from "@/api/auth"
import { useAuthStore } from "@/store/authStore"
import BindContactModal from "@/components/auth/BindContactModal"
import { scheduleProactiveRefresh } from "@/api/auth/tokenRefresh"
const WechatCallback: React.FC = () => {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const setAuth = useAuthStore((state) => state.setAuth)
const [loading, setLoading] = useState(true)
const [showBindModal, setShowBindModal] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
@@ -49,20 +50,21 @@ const WechatCallback: React.FC = () => {
const userData = await getCurrentUser()
const user: User = normalizeUser(userData)
setAuth(user, result.access_token, result.refresh_token)
scheduleProactiveRefresh()
if (result.binding_complete) {
// 已绑定,跳转到登录前页面或首页
message.success("登录成功")
const redirect = localStorage.getItem("login_redirect") || "/"
localStorage.removeItem("login_redirect")
navigate(redirect, { replace: true })
} else {
// 未绑定,显示绑定弹窗
setLoading(false)
setShowBindModal(true)
// 新用户 或 资料未完善(如上次中断没填昵称)→ 强制昵称引导
const needOnboarding = result.is_new_user || user.profile_completed === false
if (needOnboarding) {
navigate("/welcome/wechat", { replace: true })
return
}
} catch (err) {
setError("登录失败,请重试")
// 老用户:回登录前页面或首页
const redirect = localStorage.getItem("login_redirect") || "/"
localStorage.removeItem("login_redirect")
navigate(redirect, { replace: true })
} catch {
setError("微信登录失败,请重试")
setLoading(false)
}
}
@@ -70,21 +72,6 @@ const WechatCallback: React.FC = () => {
handleCallback()
}, [searchParams, navigate, setAuth])
const handleBindSuccess = (user: User) => {
const setUser = useAuthStore.getState().setUser
setUser(user)
setShowBindModal(false)
message.success("绑定成功")
const redirect = localStorage.getItem("login_redirect") || "/"
localStorage.removeItem("login_redirect")
navigate(redirect, { replace: true })
}
const handleBindCancel = () => {
setShowBindModal(false)
navigate("/login")
}
if (loading) {
return (
<div
@@ -98,49 +85,39 @@ const WechatCallback: React.FC = () => {
>
<div style={{ textAlign: "center" }}>
<Spin size="large" />
<p style={{ marginTop: 16, color: "#666" }}>...</p>
</div>
</div>
)
}
if (error) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
background: "#f5f5f5",
}}
>
<div style={{ textAlign: "center" }}>
<p style={{ color: "#ef4444", fontSize: 16, marginBottom: 16 }}>{error}</p>
<button
onClick={() => navigate("/login")}
style={{
padding: "8px 24px",
background: "var(--primary-color, #3b82f6)",
color: "white",
border: "none",
borderRadius: 6,
cursor: "pointer",
}}
>
</button>
<p style={{ marginTop: 16, color: "#666" }}>...</p>
</div>
</div>
)
}
return (
<BindContactModal
open={showBindModal}
onSuccess={handleBindSuccess}
onCancel={handleBindCancel}
/>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
background: "#f5f5f5",
}}
>
<div style={{ textAlign: "center" }}>
<p style={{ color: "#ef4444", fontSize: 16, marginBottom: 16 }}>{error}</p>
<button
onClick={() => navigate("/login")}
style={{
padding: "8px 24px",
background: "var(--primary-color, #3b82f6)",
color: "white",
border: "none",
borderRadius: 6,
cursor: "pointer",
}}
>
</button>
</div>
</div>
)
}
@@ -0,0 +1,102 @@
/**
* 微信新用户昵称引导页
* 新微信用户首次登录后强制填写昵称,完成后才进入主界面
*/
import React 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 { useAuthStore } from "@/store/authStore"
import Button from "@/components/ui/Button"
import "./Login.css"
interface OnboardingFormValues {
display_name: string
}
const WechatOnboarding: React.FC = () => {
const navigate = useNavigate()
const setUser = useAuthStore((state) => state.setUser)
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
const user = useAuthStore((state) => state.user)
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
const [form] = Form.useForm<OnboardingFormValues>()
const saveMutation = useMutation({
mutationFn: (displayName: string) => updateProfile({ display_name: displayName }),
})
// 已登录且资料已完善的用户不该停留在引导页
if (isAuthenticated && hasAccessToken && user?.profile_completed === true) {
return <Navigate to="/app/dashboard" replace />
}
// 未登录(如手动输入 URL)回登录页
if (!isAuthenticated || !hasAccessToken) {
return <Navigate to="/login" replace />
}
const onFinish = async (values: OnboardingFormValues) => {
try {
const updated = await saveMutation.mutateAsync(values.display_name.trim())
// 后端返回的 profile_completed 以最新资料为准,前端同步标记完善
setUser({ ...updated, profile_completed: true })
message.success("欢迎加入小虾智剪!")
const redirect = localStorage.getItem("login_redirect") || "/app/dashboard"
localStorage.removeItem("login_redirect")
navigate(redirect, { replace: true })
} catch {
message.error("保存失败,请重试")
}
}
return (
<div className="xx-auth-page">
<div className="xx-auth-card">
<div className="xx-auth-header">
<div className="xx-auth-brand">
<span className="xx-auth-logo">🦐</span>
<span className="xx-auth-brand-name"></span>
</div>
<p>使</p>
</div>
<Form
form={form}
name="wechat-onboarding"
onFinish={onFinish}
autoComplete="off"
layout="vertical"
initialValues={{ display_name: user?.display_name || "" }}
>
<Form.Item
name="display_name"
label="昵称"
rules={[
{ required: true, message: "请输入昵称" },
{ whitespace: true, message: "昵称不能为空白" },
{ min: 1, max: 20, message: "昵称长度需在 1-20 个字符之间" },
]}
extra="昵称将展示在您的作品和账户中,之后可在个人设置中修改"
>
<Input placeholder="请输入您的昵称" size="large" maxLength={20} showCount />
</Form.Item>
<Form.Item>
<Button
buttonType="primary"
buttonSize="lg"
htmlType="submit"
loading={saveMutation.isPending}
style={{ width: "100%" }}
>
{saveMutation.isPending ? "保存中..." : "进入小虾智剪"}
</Button>
</Form.Item>
</Form>
</div>
</div>
)
}
export default WechatOnboarding
@@ -178,3 +178,35 @@
border-color: var(--border-color);
margin: var(--space-lg) 0;
}
/* 微信账号绑定卡片 */
.xx-settings-wechat {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-lg);
flex-wrap: wrap;
}
.xx-settings-wechat-info {
display: flex;
align-items: center;
gap: var(--space-md);
}
.xx-settings-wechat-info .xx-wechat-icon {
font-size: 28px;
line-height: 1;
}
.xx-settings-wechat-info strong {
display: block;
color: var(--text-primary);
font-size: var(--font-size-md);
}
.xx-settings-wechat-info p {
margin: 2px 0 0;
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
+148 -25
View File
@@ -1,39 +1,111 @@
/**
* 个人设置页面
* P1-2: 添加 PageHead
* P1-3: antd Form/Input/Button/Alert → 自定义 UI 组件
* - 个人资料(昵称)保存
* - 微信账号绑定状态 / 绑定 / 解绑
*/
import React, { useState } from "react"
import React, { useEffect, useRef, useState } from "react"
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 { useAuthStore } from "@/store/authStore"
import PageHead from "@/components/layout/PageHead"
import "./ProfileSettings.css"
const Settings: React.FC = () => {
const user = useAuthStore((state) => state.user)
const setUser = useAuthStore((state) => state.setUser)
const queryClient = useQueryClient()
const [searchParams, setSearchParams] = useSearchParams()
const [displayName, setDisplayName] = useState(user?.display_name || "")
const bindTipShownRef = useRef(false)
const handleSave = () => {
Modal.info({
title: "提示",
content: "个人资料修改接口暂未开放,保存功能即将上线。",
// 拉取最新用户信息(微信绑定状态以后端为准)
const { data: freshUser } = useQuery({
queryKey: ["currentUser"],
queryFn: getCurrentUser,
})
useEffect(() => {
if (freshUser) {
setUser(freshUser)
setDisplayName((prev) => prev || freshUser.display_name || "")
}
}, [freshUser, setUser])
// 绑定回调结果提示(?wechat_bind=success|failed
useEffect(() => {
if (bindTipShownRef.current) return
const result = searchParams.get("wechat_bind")
if (!result) return
bindTipShownRef.current = true
if (result === "success") {
message.success("微信绑定成功")
} else if (result === "failed") {
message.error("微信绑定失败,请重试")
}
searchParams.delete("wechat_bind")
setSearchParams(searchParams, { replace: true })
}, [searchParams, setSearchParams])
const wechatBound = user?.wechat_bound === true
const saveProfileMutation = useMutation({
mutationFn: () => updateProfile({ display_name: displayName.trim() }),
onSuccess: (updated) => {
setUser(updated)
message.success("资料已保存")
},
onError: () => {
message.error("保存失败,请重试")
},
})
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 unbindMutation = useMutation({
mutationFn: unbindWechat,
onSuccess: () => {
message.success("已解绑微信")
queryClient.invalidateQueries({ queryKey: ["currentUser"] })
// 本地立即更新,避免等待刷新
if (user) {
setUser({ ...user, wechat_bound: false, wechat_nickname: "" })
}
},
onError: () => {
message.error("解绑失败,请重试")
},
})
const handleUnbind = () => {
Modal.confirm({
title: "解绑微信",
content: "解绑后将无法使用微信登录该账号,确定要解绑吗?",
okText: "确定解绑",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: () => unbindMutation.mutateAsync(),
})
}
const displayNameDirty = displayName.trim() !== (user?.display_name || "")
return (
<div className="xx-settings-page">
<PageHead title="个人设置" description="管理您的账户信息" />
<div className="xx-settings-card">
<h3></h3>
<div className="xx-settings-notice">
<span className="xx-settings-notice-icon"></span>
<div>
<strong></strong>
<p></p>
</div>
</div>
<div className="xx-settings-form">
<div className="xx-settings-field">
<label className="xx-settings-label"></label>
@@ -42,25 +114,76 @@ const Settings: React.FC = () => {
<div className="xx-settings-field">
<label className="xx-settings-label"></label>
<Input value={user?.email || ""} disabled placeholder="邮箱" />
</div>
<div className="xx-settings-field">
<label className="xx-settings-label"></label>
<Input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="请输入显示名称"
value={user?.email && !user.email.endsWith("@wechat.local") ? user.email : ""}
disabled
placeholder={user?.email?.endsWith("@wechat.local") ? "微信账号暂未绑定邮箱" : "邮箱"}
/>
</div>
<div className="xx-settings-field">
<Button buttonType="primary" buttonSize="md" onClick={handleSave} disabled>
<label className="xx-settings-label"></label>
<Input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="请输入昵称"
maxLength={20}
/>
</div>
<div className="xx-settings-field">
<Button
buttonType="primary"
buttonSize="md"
onClick={() => saveProfileMutation.mutate()}
loading={saveProfileMutation.isPending}
disabled={!displayName.trim() || !displayNameDirty}
>
</Button>
</div>
</div>
</div>
<div className="xx-settings-card">
<h3></h3>
<div className="xx-settings-wechat">
<div className="xx-settings-wechat-info">
<span className="xx-wechat-icon">💬</span>
<div>
{wechatBound ? (
<>
<strong>
{user?.wechat_nickname ? `${user.wechat_nickname}` : ""}
</strong>
<p>使</p>
</>
) : (
<>
<strong></strong>
<p>使</p>
</>
)}
</div>
</div>
<div className="xx-settings-wechat-actions">
{wechatBound ? (
<Button
buttonType="ghost"
buttonSize="md"
onClick={handleUnbind}
loading={unbindMutation.isPending}
>
</Button>
) : (
<Button buttonType="primary" buttonSize="md" onClick={handleBindWechat}>
</Button>
)}
</div>
</div>
</div>
</div>
)
}
+6
View File
@@ -6,10 +6,16 @@ import { useAuthStore } from "@/store/authStore"
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
const profileCompleted = useAuthStore((state) => state.user?.profile_completed !== false)
if (!isAuthenticated || !hasAccessToken) {
return <Navigate to="/login" replace />
}
// 微信新用户未完成昵称引导时,禁止进入主界面
if (!profileCompleted) {
return <Navigate to="/welcome/wechat" replace />
}
return <>{children}</>
}
+10
View File
@@ -5,6 +5,8 @@ import Register from "@/pages/auth/Register"
import ForgotPassword from "@/pages/auth/ForgotPassword"
import ResetPassword from "@/pages/auth/ResetPassword"
import WechatCallback from "@/pages/auth/WechatCallback"
import WechatOnboarding from "@/pages/auth/WechatOnboarding"
import WechatBindCallback from "@/pages/auth/WechatBindCallback"
import { useAuthStore } from "@/store/authStore"
/** 首页路由组件:已登录跳 dashboard,未登录显示落地页 */
@@ -45,4 +47,12 @@ export const publicRoutes: RouteObject[] = [
path: "/auth/wechat/callback",
element: <WechatCallback />,
},
{
path: "/auth/wechat/bind/callback",
element: <WechatBindCallback />,
},
{
path: "/welcome/wechat",
element: <WechatOnboarding />,
},
]
+6
View File
@@ -13,6 +13,12 @@ interface User {
display_name: string
is_email_verified: boolean
email_verified: boolean
wechat_bound?: boolean
wechat_nickname?: string
avatar_url?: string
phone?: string
phone_verified?: boolean
profile_completed?: boolean
}
interface AuthState {
+101
View File
@@ -0,0 +1,101 @@
/**
* 上传去重/幂等工具单测(Issue #1714
*/
import { describe, it, expect } from "vitest"
import {
computeFileHash,
findDuplicateInQueue,
makeClientUploadId,
makeFileFingerprint,
} from "@/api/assets/uploadDedup"
const makeFile = (name: string, size = 100, lastModified = 1_700_000_000_000) =>
new File([new Uint8Array(size)], name, { type: "video/mp4", lastModified })
describe("makeFileFingerprint", () => {
it("同一文件(name+size+lastModified 相同)指纹一致", () => {
const a = makeFile("a.mp4", 1000, 12345)
const b = makeFile("a.mp4", 1000, 12345)
expect(makeFileFingerprint(a)).toBe(makeFileFingerprint(b))
})
it("文件名/大小/修改时间任一不同指纹即不同", () => {
const base = makeFile("a.mp4", 1000, 100)
expect(makeFileFingerprint(base)).not.toBe(makeFileFingerprint(makeFile("b.mp4", 1000, 100)))
expect(makeFileFingerprint(base)).not.toBe(makeFileFingerprint(makeFile("a.mp4", 1001, 100)))
expect(makeFileFingerprint(base)).not.toBe(makeFileFingerprint(makeFile("a.mp4", 1000, 101)))
})
})
describe("findDuplicateInQueue", () => {
const queue = [
{ fileKey: "k1", status: "preparing" },
{ fileKey: "k2", status: "uploading" },
{ fileKey: "k3", status: "ingesting" },
{ fileKey: "k4", status: "done" },
{ fileKey: "k5", status: "error" },
]
it("在途状态(preparing/uploading/ingesting/done)命中重复", () => {
expect(findDuplicateInQueue(queue, "k1")?.status).toBe("preparing")
expect(findDuplicateInQueue(queue, "k2")?.status).toBe("uploading")
expect(findDuplicateInQueue(queue, "k3")?.status).toBe("ingesting")
expect(findDuplicateInQueue(queue, "k4")?.status).toBe("done")
})
it("未命中返回 null", () => {
expect(findDuplicateInQueue(queue, "missing")).toBeNull()
})
it("排除 error 状态后,失败项不算重复(允许重新激活)", () => {
expect(findDuplicateInQueue(queue, "k5", ["error"])).toBeNull()
})
it("同时排除 done 后,已完成项也不算重复", () => {
expect(findDuplicateInQueue(queue, "k4", ["error", "done"])).toBeNull()
// 但在途的仍然命中
expect(findDuplicateInQueue(queue, "k1", ["error", "done"])).not.toBeNull()
})
})
describe("makeClientUploadId", () => {
it("生成带前缀且互不相同的幂等 token", () => {
const ids = new Set(Array.from({ length: 20 }, () => makeClientUploadId()))
expect(ids.size).toBe(20)
for (const id of ids) expect(id.startsWith("up_")).toBe(true)
})
})
describe("computeFileHash", () => {
it("相同内容 hash 一致、不同内容 hash 不同", async () => {
const f1 = makeFile("a.mp4", 4096)
const f2 = makeFile("b.mp4", 4096)
// 两个文件都是 0 填充,内容相同 → hash 一致
expect(await computeFileHash(f1)).toBe(await computeFileHash(f2))
const f3 = new File([new Uint8Array(4096).fill(7)], "c.mp4", { type: "video/mp4" })
expect(await computeFileHash(f1)).not.toBe(await computeFileHash(f3))
})
it("返回 64 位十六进制(SHA-256,与后端 file_hash 长度一致)", async () => {
const hash = await computeFileHash(makeFile("a.mp4", 1024))
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
})
describe("computeFileHash 大文件抽样(>256MB", () => {
it("抽样路径正常返回 64 位 hex,且大小不同则 hash 不同", async () => {
// mock 一个「声称」300MB 的 Fileslice 返回小 buffer 即可,不真分配 300MB
const makeBig = (declaredSize: number, head: number) => {
const f = new File([new Uint8Array([head, 2, 3])], "big.mov", { type: "video/quicktime" })
Object.defineProperty(f, "size", { value: declaredSize, configurable: true })
// slice 仍按真实内容返回小片段(头尾片段内容由底层小 buffer 决定)
return f
}
const h1 = await computeFileHash(makeBig(300 * 1024 * 1024, 1))
const h2 = await computeFileHash(makeBig(301 * 1024 * 1024, 1))
expect(h1).toMatch(/^[0-9a-f]{64}$/)
// 声明大小不同 → 写入的 64 位 size 字段不同 → hash 必须不同(锁定 setBigUint64 路径)
expect(h1).not.toBe(h2)
})
})
+124 -33
View File
@@ -1,8 +1,8 @@
import { describe, expect, it, vi } from "vitest"
import { render, screen } from "@testing-library/react"
import { describe, expect, it, vi, beforeEach } from "vitest"
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
// mock PageHead 简单mock
vi.mock("@/components/layout/PageHead", () => ({
default: ({ title, description }: { title: string; description?: string }) => (
<div data-testid="page-head">
@@ -12,51 +12,142 @@ vi.mock("@/components/layout/PageHead", () => ({
),
}))
const mockSetUser = vi.fn()
const mockInvalidate = vi.fn()
let authState: Record<string, unknown> = {
user: {
id: "1",
user_id: "1",
username: "testuser",
email: "test@example.com",
display_name: "Test User",
wechat_bound: false,
},
isAuthenticated: true,
setUser: mockSetUser,
}
vi.mock("@/store/authStore", () => ({
useAuthStore: (selector: (state: any) => any) =>
selector({
useAuthStore: (selector: (state: unknown) => unknown) => selector(authState),
}))
const getCurrentUserMock = vi.fn(async () => authState.user as Record<string, unknown>)
const updateProfileMock = vi.fn()
const getWechatBindUrlMock = vi.fn(async () => ({
auth_url: "https://wx.example/auth",
state: "s1",
}))
const unbindWechatMock = vi.fn(async () => ({ success: true }))
vi.mock("@/api/auth", () => ({
getCurrentUser: () => getCurrentUserMock(),
updateProfile: (d: unknown) => updateProfileMock(d),
getWechatBindUrl: () => getWechatBindUrlMock(),
unbindWechat: () => unbindWechatMock(),
}))
vi.mock("antd", async () => {
const actual = await vi.importActual("antd")
return { ...actual, message: { success: vi.fn(), error: vi.fn() } }
})
import Settings from "@/pages/profile/Settings"
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
const renderPage = () =>
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Settings />
</MemoryRouter>
</QueryClientProvider>,
)
describe("Settings Page", () => {
beforeEach(() => {
vi.clearAllMocks()
authState = {
user: {
id: "1",
user_id: "1",
username: "testuser",
email: "test@example.com",
display_name: "Test User",
is_email_verified: true,
email_verified: true,
wechat_bound: false,
},
isAuthenticated: true,
}),
}))
import Settings from "@/pages/profile/Settings"
describe("Settings Page", () => {
it("should render without crashing", () => {
render(
<MemoryRouter>
<Settings />
</MemoryRouter>,
)
expect(screen.getByText("个人设置")).toBeTruthy()
setUser: mockSetUser,
}
})
it("should display user info", () => {
render(
<MemoryRouter>
<Settings />
</MemoryRouter>,
)
it("渲染个人设置与用户信息", () => {
renderPage()
expect(screen.getByText("个人设置")).toBeTruthy()
expect(screen.getByDisplayValue("testuser")).toBeTruthy()
expect(screen.getByDisplayValue("test@example.com")).toBeTruthy()
})
it("should show save button is disabled", () => {
render(
<MemoryRouter>
<Settings />
</MemoryRouter>,
it("未绑定时显示绑定微信按钮,点击跳转微信授权", async () => {
renderPage()
expect(screen.getByText("未绑定微信")).toBeTruthy()
const btn = screen.getByText("绑定微信")
fireEvent.click(btn)
await waitFor(() => {
expect(getWechatBindUrlMock).toHaveBeenCalled()
expect(localStorage.getItem("wechat_bind_state")).toBe("s1")
})
})
it("已绑定时显示状态与解绑按钮,确认后调解绑接口", async () => {
authState.user = {
...(authState.user as object),
wechat_bound: true,
wechat_nickname: "微信昵称",
} as never
renderPage()
expect(screen.getByText(/已绑定微信/)).toBeTruthy()
fireEvent.click(
screen.getByText(
(_, el) => el?.tagName === "BUTTON" && (el.textContent ?? "").replace(/\s/g, "") === "解绑",
),
)
const button = screen.getByText("保存暂未开放")
expect(button).toBeTruthy()
// antd Modal.confirm 弹确认框(标题+内容均含"解绑微信",用 role=dialog 内的确认按钮)
await waitFor(() => {
expect(document.querySelector(".ant-modal-confirm")).toBeTruthy()
})
fireEvent.click(
screen.getByText(
(_, el) => el?.tagName === "BUTTON" && (el.textContent ?? "").includes("确定解绑"),
),
)
await waitFor(() => {
expect(unbindWechatMock).toHaveBeenCalled()
})
})
it("修改昵称后保存按钮可用,点击调用更新接口", async () => {
renderPage()
const saveBtn = screen.getByText(
(_, el) => el?.tagName === "BUTTON" && (el.textContent ?? "").replace(/\s/g, "") === "保存",
)
expect(saveBtn.closest("button")?.disabled).toBe(true)
fireEvent.change(screen.getByDisplayValue("Test User"), {
target: { value: "新昵称" },
})
await waitFor(() => {
expect(saveBtn.closest("button")?.disabled).toBe(false)
})
updateProfileMock.mockResolvedValueOnce({
id: "1",
display_name: "新昵称",
wechat_bound: false,
})
fireEvent.click(saveBtn)
await waitFor(() => {
expect(updateProfileMock).toHaveBeenCalledWith({ display_name: "新昵称" })
})
})
})
@@ -31,17 +31,23 @@ interface FakeHandle {
complete: ReturnType<typeof vi.fn>
/** 手动结束传输(transfer 被调用后挂载);finish(true) 以失败结束 */
finish: (fail?: boolean) => void
/** complete 已被调用的次数 */
completeCalls: { resolve: () => void; reject: (err: unknown) => void }[]
}
let activeTransfers = 0
let maxConcurrent = 0
/**
* 创建一个假 handletransfer 返回挂起的 promise
* finish 槽位在 transfer executor 同步执行时挂载,测试中调用 finish() 控制成败
* 创建一个假 handle
* - transfer 返回挂起的 promisefinish()/finish(true) 控制成败
* - complete 每次调用返回独立的挂起 promise,由 completeCalls 记录控制,
* 成功调 resolve(idx) / 失败调 reject(idx)(模拟超时)
*/
const makeFakeHandle = (opts: { id: string; duplicated?: boolean; failTransfer?: boolean }) => {
const h = {
const makeFakeHandle = (opts: {
id: string
duplicated?: boolean
failTransfer?: boolean
completeAuto?: boolean
}) => {
const h: FakeHandle = {
prepared: {
upload_url: "https://oss.example.com/u",
method: "POST",
@@ -52,15 +58,39 @@ const makeFakeHandle = (opts: { id: string; duplicated?: boolean; failTransfer?:
asset_id: opts.id,
},
transfer: vi.fn(),
complete: vi.fn().mockResolvedValue({
storage_key: "uploads/x/y.mp4",
ingest_job_id: opts.duplicated ? "" : "job-1",
url: "https://oss.example.com/u",
duplicated: opts.duplicated,
asset_id: opts.id,
}),
finish: (() => {}) as (fail?: boolean) => void,
complete: vi.fn(),
finish: () => {},
completeCalls: [],
}
h.complete.mockImplementation(
() =>
new Promise<{
storage_key: string
ingest_job_id: string
url: string
duplicated: boolean
asset_id: string
}>((resolve, reject) => {
h.completeCalls.push({
resolve: () =>
resolve({
storage_key: "uploads/x/y.mp4",
ingest_job_id: opts.duplicated ? "" : `job-${opts.id}`,
url: "https://oss.example.com/u",
duplicated: !!opts.duplicated,
asset_id: opts.id,
}),
reject,
})
// 默认立即成功,保持旧用例简单
if (opts.completeAuto !== false) {
const idx = h.completeCalls.length - 1
Promise.resolve().then(() => h.completeCalls[idx]?.resolve())
}
}),
)
h.transfer.mockImplementation(
() =>
new Promise<void>((_resolve, reject) => {
@@ -78,17 +108,24 @@ const makeFakeHandle = (opts: { id: string; duplicated?: boolean; failTransfer?:
type FakeHandleLike = ReturnType<typeof makeFakeHandle>
let activeTransfers = 0
let maxConcurrent = 0
/** prepare mock:调用序号生成稳定 id,立即把 handle(含 finish 槽位)推入数组 */
const installPrepareMock = (
handles: FakeHandleLike[],
optOverrides?: (id: string) => { duplicated?: boolean; failTransfer?: boolean },
optOverrides?: (id: string) => {
duplicated?: boolean
failTransfer?: boolean
completeAuto?: boolean
},
) => {
let callNo = 0
;(prepareDirectUploadHandle as unknown as ReturnType<typeof vi.fn>).mockImplementation(
async () => {
const id = `asset-${callNo++}`
const overrides = optOverrides?.(id) ?? {}
const h = makeFakeHandle({ id, ...overrides })
const h = makeFakeHandle({ id, completeAuto: true, ...overrides })
handles.push(h)
await new Promise((r) => setTimeout(r, 10))
return h
@@ -223,4 +260,96 @@ describe("useAssetUpload", () => {
expect(result.current.uploadItems[0].status).toBe("done")
})
})
it("同一文件多次选择不重复入队(指纹去重)", async () => {
const handles: FakeHandleLike[] = []
installPrepareMock(handles)
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
wrapper: createWrapper(),
})
// 同一文件(name+size+lastModified 完全一致)第一次入队
const sameFile = mp4("same.mp4")
await act(async () => {
result.current.enqueueUploads([sameFile])
})
await waitFor(() => expect(handles.length).toBe(1))
expect(result.current.uploadItems).toHaveLength(1)
// transfer 挂起期间,再次选择同一文件(模拟用户反复点选/拖拽)
await act(async () => {
result.current.enqueueUploads([sameFile])
})
await act(async () => {
result.current.enqueueUploads([sameFile])
})
// 队列表只有 1 项、prepare 只有 1 次
expect(result.current.uploadItems).toHaveLength(1)
expect(handles.length).toBe(1)
// 完成后再次重复选择(已 done):仍然不新增
await act(async () => {
handles[0].finish()
})
await waitFor(() => expect(result.current.uploadItems[0].status).toBe("done"))
await act(async () => {
result.current.enqueueUploads([sameFile])
})
expect(result.current.uploadItems).toHaveLength(1)
expect(handles.length).toBe(1)
// 不同文件正常入队
await act(async () => {
result.current.enqueueUploads([mp4("other.mp4")])
})
await waitFor(() => expect(handles.length).toBe(2))
expect(result.current.uploadItems).toHaveLength(2)
})
it("complete 失败(超时)后重试:只重发 complete,不重新 prepare/直传", async () => {
const handles: FakeHandleLike[] = []
installPrepareMock(handles, () => ({ completeAuto: false }))
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
wrapper: createWrapper(),
})
await act(async () => {
result.current.enqueueUploads([mp4("slow.mp4")])
})
await waitFor(() => expect(handles.length).toBe(1))
await waitFor(() => expect(handles[0].transfer).toHaveBeenCalled())
await act(async () => {
handles[0].finish()
})
// complete 被调用但挂起
await waitFor(() => expect(handles[0].complete).toHaveBeenCalledTimes(1))
// 模拟 complete 超时(后端记录可能已建成)
await act(async () => {
handles[0].completeCalls[0]?.reject(new Error("complete timeout (ECONNABORTED)"))
})
const tempId = result.current.uploadItems[0].tempId
await waitFor(() => {
const it = result.current.uploadItems.find((x) => x.tempId === tempId)
expect(it?.status).toBe("error")
expect(it?.failedStage).toBe("complete")
})
// 点重试:pump 复用 handle,只再调一次 completetransfer/prepare 不重复)
await act(async () => {
result.current.retryUpload(tempId)
})
await waitFor(() => expect(handles[0].complete).toHaveBeenCalledTimes(2))
expect(handles.length).toBe(1) // 没有重新 prepare
expect(handles[0].transfer).toHaveBeenCalledTimes(1) // 没有重新直传
// 第二次 complete 成功
await act(async () => {
handles[0].completeCalls[1]?.resolve()
})
await waitFor(() => {
expect(result.current.uploadItems.find((x) => x.tempId === tempId)?.status).toBe("done")
})
})
})
@@ -1,79 +1,127 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import { render, screen } from "@testing-library/react"
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 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
const mockAuthState = { setAuth: mockSetAuth }
// 文件级 localStorage mock(避免每个用例重复 spy 导致链式污染)
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 mockCallbackResult: Record<string, unknown> = {}
let mockCurrentUser: Record<string, unknown> = {}
let callbackShouldFail = false
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom")
return {
...actual,
useNavigate: () => vi.fn(),
useSearchParams: () => [new URLSearchParams({ code: "test_code", state: "test_state" })],
useNavigate: () => mockNavigate,
useSearchParams: () => mockSearchParams,
}
})
vi.mock("@/api/auth", () => ({
wechatCallback: vi.fn(() => new Promise(() => {})), // pending promise,保持loading
getCurrentUser: vi.fn(),
wechatCallback: vi.fn(async () => {
if (callbackShouldFail) throw new Error("fail")
return mockCallbackResult
}),
getCurrentUser: vi.fn(async () => mockCurrentUser),
normalizeUser: (u: unknown) => u,
}))
vi.mock("@/api/auth/tokenRefresh", () => ({
scheduleProactiveRefresh: vi.fn(),
cancelProactiveRefresh: vi.fn(),
}))
vi.mock("@/store/authStore", () => ({
useAuthStore: () => ({
setAuth: vi.fn(),
}),
useAuthStore: (selector: (state: unknown) => unknown) => selector({ setAuth: mockSetAuth }),
}))
vi.mock("@/components/auth/BindContactModal", () => ({
default: ({ open }: { open: boolean }) => (
<div data-testid="bind-contact-modal" style={{ display: open ? "block" : "none" }}>
BindContactModal
</div>
),
}))
vi.mock("antd", async () => {
const actual = await vi.importActual("antd")
return {
...actual,
message: {
success: vi.fn(),
error: vi.fn(),
},
}
})
const renderPage = () =>
render(
<MemoryRouter>
<WechatCallback />
</MemoryRouter>,
)
describe("WechatCallback Page", () => {
afterEach(() => {
cleanup()
})
beforeEach(() => {
// mock localStorage,设置wechat_state匹配,让校验通过
const store: Record<string, string> = {
wechat_state: "test_state",
vi.clearAllMocks()
callbackShouldFail = false
localStorageStore.wechat_state = "test_state"
mockCallbackResult = {
access_token: "at",
refresh_token: "rt",
is_new_user: false,
}
vi.spyOn(Storage.prototype, "getItem").mockImplementation((key) => store[key] || null)
vi.spyOn(Storage.prototype, "setItem").mockImplementation((key, val) => {
store[key] = val
mockCurrentUser = {
id: "u1",
display_name: "老用户",
profile_completed: true,
}
})
it("老用户登录成功跳转首页/来源页", async () => {
renderPage()
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true })
})
vi.spyOn(Storage.prototype, "removeItem").mockImplementation((key) => {
delete store[key]
expect(mockSetAuth).toHaveBeenCalled()
})
it("新用户(is_new_user)跳转昵称引导页", async () => {
mockCallbackResult = { access_token: "at", refresh_token: "rt", is_new_user: true }
mockCurrentUser = { id: "u2", display_name: "微信用户", profile_completed: false }
renderPage()
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/welcome/wechat", { replace: true })
})
})
it("should render without crashing", () => {
const { container } = render(
<MemoryRouter>
<WechatCallback />
</MemoryRouter>,
)
expect(container).toBeTruthy()
it("is_new_user=false 但 profile_completed=false(上次中断)也跳引导页", async () => {
mockCallbackResult = { access_token: "at", refresh_token: "rt", is_new_user: false }
mockCurrentUser = { id: "u3", display_name: "微信用户", profile_completed: false }
renderPage()
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/welcome/wechat", { replace: true })
})
})
it("should show loading state while processing", () => {
render(
<MemoryRouter>
<WechatCallback />
</MemoryRouter>,
)
// wechatCallback 返回 pending promise,所以应该显示 loading
expect(screen.getByText("正在登录...")).toBeTruthy()
it("state 不匹配显示安全错误", async () => {
localStorageStore.wechat_state = "other_state"
renderPage()
await waitFor(() => {
expect(screen.getByText("安全校验失败,请重新登录")).toBeTruthy()
})
expect(mockNavigate).not.toHaveBeenCalled()
})
it("接口失败显示错误提示", async () => {
callbackShouldFail = true
renderPage()
await waitFor(() => {
expect(screen.getByText("微信登录失败,请重试")).toBeTruthy()
})
})
it("处理中显示 loading", () => {
renderPage()
expect(screen.getByText("微信登录中...")).toBeTruthy()
})
})
@@ -0,0 +1,132 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import WechatOnboarding from "@/pages/auth/WechatOnboarding"
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
const mockNavigate = vi.fn()
const mockSetUser = vi.fn()
let updateProfileMock = vi.fn()
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom")
return { ...actual, useNavigate: () => mockNavigate }
})
let authState: Record<string, unknown> = {}
vi.mock("@/store/authStore", () => ({
useAuthStore: (selector: (state: unknown) => unknown) => selector(authState),
}))
vi.mock("@/api/auth", () => ({
updateProfile: (data: { display_name: string }) => updateProfileMock(data),
}))
vi.mock("antd", async () => {
const actual = await vi.importActual("antd")
return { ...actual, message: { success: vi.fn(), error: vi.fn() } }
})
const renderPage = () =>
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<WechatOnboarding />
</MemoryRouter>
</QueryClientProvider>,
)
describe("WechatOnboarding 昵称引导页", () => {
afterEach(() => {
cleanup()
})
beforeEach(() => {
vi.clearAllMocks()
authState = {
isAuthenticated: true,
user: { id: "u1", display_name: "", profile_completed: false },
setUser: mockSetUser,
}
localStorage.setItem("access_token", "at")
updateProfileMock = vi.fn(async (data: { display_name: string }) => ({
id: "u1",
display_name: data.display_name,
profile_completed: true,
}))
})
it("未登录时跳转登录页", () => {
authState = {
isAuthenticated: false,
user: null,
setUser: mockSetUser,
}
localStorage.removeItem("access_token")
renderPage()
expect(mockNavigate).not.toHaveBeenCalled()
// Navigate 组件渲染即生效;这里断言页面不含昵称表单
expect(screen.queryByText("进入小虾智剪")).toBeNull()
})
it("资料已完善的用户跳 dashboard", () => {
authState = {
isAuthenticated: true,
user: { id: "u1", display_name: "已起名", profile_completed: true },
setUser: mockSetUser,
}
renderPage()
expect(screen.queryByText("进入小虾智剪")).toBeNull()
})
it("新用户可见昵称表单并能提交", async () => {
renderPage()
expect(screen.getByText("欢迎使用微信登录,请先设置您的昵称")).toBeTruthy()
fireEvent.change(screen.getByPlaceholderText("请输入您的昵称"), {
target: { value: "小虾用户" },
})
fireEvent.click(screen.getByText("进入小虾智剪"))
await waitFor(() => {
expect(updateProfileMock).toHaveBeenCalledWith({ display_name: "小虾用户" })
})
await waitFor(() => {
expect(mockSetUser).toHaveBeenCalled()
expect(mockNavigate).toHaveBeenCalledWith("/app/dashboard", { replace: true })
})
})
it("昵称为空时不允许提交(表单校验)", async () => {
renderPage()
fireEvent.change(screen.getByPlaceholderText("请输入您的昵称"), {
target: { value: " " },
})
fireEvent.click(screen.getByText("进入小虾智剪"))
// 等待表单校验
await waitFor(
() => {
expect(updateProfileMock).not.toHaveBeenCalled()
},
{ timeout: 1000 },
)
})
it("提交失败显示错误且不跳转", async () => {
updateProfileMock = vi.fn(async () => {
throw new Error("500")
})
renderPage()
fireEvent.change(screen.getByPlaceholderText("请输入您的昵称"), {
target: { value: "小虾用户" },
})
fireEvent.click(screen.getByText("进入小虾智剪"))
await waitFor(() => {
expect(mockNavigate).not.toHaveBeenCalled()
})
})
})
+7
View File
@@ -214,3 +214,10 @@ MEDIAKIT_TIMEOUT=60
# ==================== 监控(可选)====================
# Sentry DSN(取消注释并填入实际值以启用错误追踪)
# SENTRY_DSN=${SENTRY_DSN}
# ==================== 微信开放平台 OAuth(网页扫码登录)====================
# 回调域名:xiaoxiajianji.com(微信开放平台已配置)
WECHAT_OPEN_APP_ID=${WECHAT_APP_ID}
WECHAT_OPEN_APP_SECRET=${WECHAT_APP_SECRET}
WECHAT_OPEN_REDIRECT_URI=https://saas.xiaoxiajianji.com/auth/wechat/callback
+7
View File
@@ -231,3 +231,10 @@ DASHSCOPE_API_KEY=${DASHSCOPE_API_KEY}
MEDIAKIT_API_KEY=${MEDIAKIT_API_KEY}
MEDIAKIT_BASE_URL=https://mediakit.cn-beijing.volces.com/api/v1
MEDIAKIT_TIMEOUT=60
# ==================== 微信开放平台 OAuth(网页扫码登录)====================
# 回调域名:xiaoxiajianji.com(微信开放平台已配置)
WECHAT_OPEN_APP_ID=${WECHAT_APP_ID}
WECHAT_OPEN_APP_SECRET=${WECHAT_APP_SECRET}
WECHAT_OPEN_REDIRECT_URI=https://staging.xiaoxiajianji.com/auth/wechat/callback
@@ -200,7 +200,15 @@ class WechatOAuthService:
return None, "微信登录处理失败"
# 模块级单例:state 存储必须跨请求共享,否则 /wechat/url 生成的 state
# 与 /wechat/callback 校验时不在同一个 MemoryStateStore,回调必然 400。
# 多实例部署时应替换为 Redis state store(单容器多 worker 也需如此)。
_oauth_service_singleton: WechatOAuthService | None = None
def get_wechat_oauth_service() -> WechatOAuthService:
"""获取微信 OAuth 服务单例"""
# TODO: 可替换为 Redis state store
return WechatOAuthService()
"""获取微信 OAuth 服务单例state store 跨请求共享)"""
global _oauth_service_singleton
if _oauth_service_singleton is None:
_oauth_service_singleton = WechatOAuthService()
return _oauth_service_singleton
+1 -1
View File
@@ -57,7 +57,7 @@ if [ "$TARGET_ENV" = "staging" ]; then
fi
# 共用 secrets 直接导出(如果存在)
SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY"
SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY WECHAT_APP_ID WECHAT_APP_SECRET"
for var in $SHARED_SECRETS; do
value="${!var:-}"
# 已经在环境中了,无需额外操作
+49
View File
@@ -388,3 +388,52 @@ class TestGetWechatOAuthService:
"""返回 WechatOAuthService 实例"""
service = get_wechat_oauth_service()
assert isinstance(service, WechatOAuthService)
def test_singleton_same_instance_across_calls(self, monkeypatch):
"""#1718 回归:工厂必须返回同一实例,否则 state store 不共享"""
import packages.application.auth.wechat_oauth_service as mod
monkeypatch.setattr(mod, "_oauth_service_singleton", None)
s1 = get_wechat_oauth_service()
s2 = get_wechat_oauth_service()
assert s1 is s2
def test_state_survives_across_factory_calls(self, monkeypatch):
"""#1718 回归:/wechat/url 与 /wechat/callback 经工厂拿到同一 state store
模拟两次请求各自调用工厂:第一个实例生成 state,第二个实例(同一单例)
必须能校验通过。修复前工厂每次 new 一个实例,回调必现 400「无效的 state」。
"""
import packages.application.auth.wechat_oauth_service as mod
monkeypatch.setattr(mod, "_oauth_service_singleton", None)
monkeypatch.setenv("WECHAT_OPEN_APP_ID", "wx-test")
monkeypatch.setenv("WECHAT_OPEN_APP_SECRET", "secret-test")
monkeypatch.setenv("WECHAT_OPEN_REDIRECT_URI", "https://example.com/cb")
# 请求1:生成授权链接(state 写入单例 store
_, state = get_wechat_oauth_service().generate_auth_url()
# 请求2:回调校验(应命中同一个 store;微信 API 用 mock 避免外网)
with patch("packages.application.auth.wechat_oauth_service.requests.get") as mock_get:
mock_get.return_value = MagicMock(
json=MagicMock(
return_value={
"access_token": "at",
"openid": "oid",
"unionid": "uid",
"nickname": "n",
"headimgurl": "http://x/a.png",
}
)
)
user_info, error = get_wechat_oauth_service().handle_callback("code-x", state)
assert error is None, f"state 应跨请求共享,实际报错: {error}"
assert user_info is not None
assert user_info.openid == "oid"
# state 一次性消费,重放必须失败
user_info2, error2 = get_wechat_oauth_service().handle_callback("code-y", state)
assert user_info2 is None
assert "state" in error2