fix(#1718/#1714): 微信回调state误杀修复+错误透传防连点、上传失败完整可观测、哈希阈值降至64MB、昵称不预填 (#1723)
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 (pull_request) Successful in 27s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
CI/CD Pipeline / Production Browser E2E (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
AI Code Review / AI Code Review (pull_request) Failing after 6m15s
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 / 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 / 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 / Frontend Unit Tests (push) Successful in 5m14s
CI/CD Pipeline / Unit Tests (push) Successful in 8m4s
CI/CD Pipeline / Validate - Security (push) Successful in 16m53s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Failing after 255h51m59s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 255h52m3s
CI/CD Pipeline / Build Production Web Image (push) Failing after 255h52m3s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 256h8m32s
CI/CD Pipeline / Deploy Production (push) Failing after 255h51m59s
CI/CD Pipeline / Frontend Lint (push) Failing after 256h8m58s
CI/CD Pipeline / Build Production API Image (push) Failing after 255h52m3s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 256h6m56s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 256h6m56s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 256h27m48s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 256h29m25s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 256h29m30s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 256h29m31s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 256h29m25s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 256h29m34s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 256h29m35s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 256h29m36s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 256h29m35s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 256h29m37s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 256h31m30s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 256h31m30s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 256h31m30s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 256h31m33s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 256h31m34s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 256h31m34s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 256h31m35s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 256h8m58s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 256h31m35s
CI/CD Pipeline / PR Build API Image (push) Failing after 256h8m58s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 256h31m37s
CI/CD Pipeline / CI Gate (push) Failing after 256h26m38s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 256h43m7s
CI/CD Pipeline / PR Build Web Image (push) Failing after 256h43m32s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 256h43m36s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 256h41m30s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 256h43m6s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 257h4m4s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 257h4m8s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 257h4m10s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 257h6m5s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 257h6m8s

This commit was merged in pull request #1723.
This commit is contained in:
2026-09-05 22:52:49 +08:00
parent c8b1c4b8ff
commit cdcb032e45
15 changed files with 468 additions and 55 deletions
+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 的 File:slice 返回小 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()
})
})
@@ -224,6 +224,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")
// 重试:重新 prepare(handles[1] 成功)
const tempId = result.current.uploadItems[0].tempId
await act(async () => {
@@ -334,6 +339,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,只再调一次 complete(transfer/prepare 不重复)
@@ -352,4 +360,24 @@ 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("签名服务内部错误")
})
})
@@ -0,0 +1,105 @@
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 }),
}))
const renderPage = () =>
render(
<MemoryRouter>
<WechatBindCallback />
</MemoryRouter>,
)
describe("WechatBindCallback Page", () => {
afterEach(() => {
cleanup()
})
beforeEach(() => {
vi.clearAllMocks()
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()
})
})
})
@@ -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),
@@ -63,7 +67,11 @@ describe("WechatCallback Page", () => {
beforeEach(() => {
vi.clearAllMocks()
callbackShouldFail = 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 +111,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()
})
})
@@ -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()