diff --git a/apps/web/src/api/assets/upload.ts b/apps/web/src/api/assets/upload.ts index ab450cd69..3b08b2914 100644 --- a/apps/web/src/api/assets/upload.ts +++ b/apps/web/src/api/assets/upload.ts @@ -126,7 +126,15 @@ export const prepareDirectUploadHandle = async (data: { /** 本次逻辑上传的幂等 token,prepare/complete 一致、重试复用 */ clientUploadId?: string }): Promise => { - const project = await getOrCreateDefaultProject() + // 默认项目初始化失败(项目列表接口异常/自动创建失败)给出独立、明确的提示, + // 不与 prepare 的签名接口错误混在一起 + let project: Awaited> + try { + project = await getOrCreateDefaultProject() + } catch (err) { + const reason = err instanceof Error ? err.message : "网络异常" + throw new Error(`初始化默认项目失败,无法开始上传:${reason}`) + } const prepared = await prepareDirectUpload({ project_id: project.id, diff --git a/apps/web/src/api/assets/uploadDedup.ts b/apps/web/src/api/assets/uploadDedup.ts index 67f31f7de..f30ad6472 100644 --- a/apps/web/src/api/assets/uploadDedup.ts +++ b/apps/web/src/api/assets/uploadDedup.ts @@ -13,10 +13,10 @@ * 重试复用同一 ID,重新入队才生成新 ID) */ -/** 大文件抽样阈值:超过此大小只哈希头尾片段,避免上传前长时间卡 UI */ -export const HASH_FULL_READ_LIMIT = 256 * 1024 * 1024 // 256MB -/** 抽样读取的头尾片段大小(各 8MB) */ -export const HASH_SAMPLE_CHUNK = 8 * 1024 * 1024 +/** 全量哈希阈值:≤64MB 全量读入计算;超过即走头尾抽样,避免 100~256MB 视频被整文件读进内存卡死页面 */ +export const HASH_FULL_READ_LIMIT = 64 * 1024 * 1024 // 64MB +/** 抽样读取的头尾片段大小(各 16MB) */ +export const HASH_SAMPLE_CHUNK = 16 * 1024 * 1024 /** 计算指纹时,文件在队列中已存在的状态(已失败的可以重试,不算重复) */ export type DedupExcludeStatus = "error" | "done" @@ -95,10 +95,10 @@ function toHex(buffer: ArrayBuffer): string { /** * 计算文件内容 SHA-256(hex,64 字符,与后端 file_hash 字段长度一致)。 - * - ≤256MB:全量哈希,内容一致必然一致 - * - >256MB:哈希「头部 8MB + 尾部 8MB + 文件大小」,视频素材体积大、 + * - ≤64MB:全量哈希,内容一致必然一致 + * - >64MB:哈希「头部 16MB + 尾部 16MB + 文件大小」,视频素材体积大、 * 头部含 moov 元数据、尾部含 mdat 结尾,抽样碰撞概率可忽略, - * 且避免上传前对 2GB 文件全量读取造成长时间卡顿 + * 且避免 100~256MB 视频被整文件读进内存导致页面卡死/崩溃 * * 运行环境不支持 crypto.subtle(非安全上下文/老浏览器)时返回空字符串, * 调用方据此降级为不传 hash(后端仍有幂等 token + 同文件名兜底去重)。 diff --git a/apps/web/src/api/errors.ts b/apps/web/src/api/errors.ts new file mode 100644 index 000000000..7eacd4bc9 --- /dev/null +++ b/apps/web/src/api/errors.ts @@ -0,0 +1,132 @@ +/** + * 统一错误信息提取 + * 把 axios 错误(后端 detail / FastAPI 校验错误 / HTTP 状态码)、XHR/OSS 错误、 + * 网络/超时错误、普通 Error 统一转成「可直接展示给用户」的中文信息。 + * + * 与 api/client.ts 响应拦截器的提示口径保持一致;拦截器负责全局 toast, + * 页面/队列卡片用本工具把真实原因展示在持久位置(回调页、失败卡片等)。 + */ +import type { AxiosError } from "axios" + +/** 后端错误响应体可能出现的字段(FastAPI:detail;历史接口:message/msg) */ +interface ErrorBody { + detail?: unknown + message?: unknown + msg?: unknown +} + +/** FastAPI 422 校验错误单项 */ +interface ValidationItem { + loc?: (string | number)[] + msg?: string +} + +/** 从后端响应体提取人类可读信息(detail 可能是字符串、对象、422 数组) */ +function extractBodyMessage(data: unknown): string { + if (!data || typeof data !== "object") return "" + const body = data as ErrorBody + + const walk = (val: unknown): string => { + if (typeof val === "string") return val + if (Array.isArray(val)) { + // FastAPI 422: [{loc, msg, type}, ...] → 取每条 msg 拼接 + const parts = val + .map((item) => { + if (typeof item === "string") return item + if (item && typeof item === "object") { + const v = item as ValidationItem + if (typeof v.msg === "string") { + const field = Array.isArray(v.loc) ? v.loc.filter((x) => x !== "body").join(".") : "" + return field ? `${field}: ${v.msg}` : v.msg + } + return walk(item) + } + return "" + }) + .filter(Boolean) + return parts.join(";") + } + if (val && typeof val === "object") { + const obj = val as Record + if (typeof obj.message === "string") return obj.message + if (typeof obj.msg === "string") return obj.msg + if (typeof obj.detail === "string") return obj.detail + if (obj.message && typeof obj.message === "object") return walk(obj.message) + if (obj.msg && typeof obj.msg === "object") return walk(obj.msg) + try { + return JSON.stringify(val) + } catch { + return "" + } + } + return "" + } + + return walk(body.detail) || walk(body.message) || walk(body.msg) +} + +/** 无响应体时按 HTTP 状态码给出兜底提示(与 client.ts 拦截器口径一致) */ +function statusFallback(status: number): string { + switch (status) { + case 400: + return "请求参数有误(HTTP 400)" + case 401: + return "登录状态已失效,请重新登录(HTTP 401)" + case 403: + return "没有权限执行该操作(HTTP 403)" + case 404: + return "请求的资源不存在(HTTP 404)" + case 409: + return "操作冲突,资源状态已变化(HTTP 409)" + case 413: + return "文件过大,请缩小后重试(HTTP 413)" + case 415: + return "不支持的文件格式(HTTP 415)" + case 429: + return "操作过于频繁,请稍后再试(HTTP 429)" + case 503: + return "服务暂不可用,请稍后再试(HTTP 503)" + default: + if (status >= 500) return `服务器繁忙,请稍后再试(HTTP ${status})` + return `请求失败(HTTP ${status})` + } +} + +/** + * 从任意抛出值提取可展示的错误信息。 + * @param fallback 全部提取失败时的兜底文案 + */ +export function getErrorMessage(err: unknown, fallback = "操作失败,请稍后重试"): string { + if (!err) return fallback + + // axios 错误(后端 JSON 响应 / HTTP 错误状态) + const ax = err as AxiosError + if (ax.isAxiosError || (typeof ax === "object" && "response" in (ax as object))) { + // 超时 + if (ax.code === "ECONNABORTED" || /timeout/i.test(ax.message || "")) { + return "请求超时,请检查网络后重试" + } + const resp = ax.response + if (resp) { + const bodyMsg = extractBodyMessage(resp.data) + if (bodyMsg) return bodyMsg + return statusFallback(resp.status) + } + // 请求已发出但无响应(断网/CORS/DNS) + if (ax.request) return "网络连接异常,请检查网络设置" + return ax.message || fallback + } + + if (err instanceof Error) { + // XHR 直传 OSS 失败等场景自带详细 message(含 HTTP 状态 + OSS Code/Message) + if (err.message) return err.message + } + if (typeof err === "string") return err + + return fallback +} + +/** client.ts 拦截器是否已对该错误弹过全局 toast(__msgShown 标记) */ +export function isErrorMsgShown(err: unknown): boolean { + return Boolean((err as { __msgShown?: boolean } | null)?.__msgShown) +} diff --git a/apps/web/src/pages/assets/assets.css b/apps/web/src/pages/assets/assets.css index 1c143fb79..29043aee7 100644 --- a/apps/web/src/pages/assets/assets.css +++ b/apps/web/src/pages/assets/assets.css @@ -831,6 +831,20 @@ color: #ef4444; } +.xx-upload-queue-error-detail { + margin-top: 4px; + font-size: 12px; + line-height: 1.5; + color: #ef4444; + word-break: break-word; + white-space: normal; +} + +.xx-upload-queue-error-hint { + margin-top: 2px; + color: #b45309; +} + .xx-upload-queue-actions { display: flex; gap: 6px; diff --git a/apps/web/src/pages/assets/components/UploadQueuePanel.tsx b/apps/web/src/pages/assets/components/UploadQueuePanel.tsx index a88f4f9eb..e84823792 100644 --- a/apps/web/src/pages/assets/components/UploadQueuePanel.tsx +++ b/apps/web/src/pages/assets/components/UploadQueuePanel.tsx @@ -12,7 +12,8 @@ import { ReloadOutlined, CloseOutlined, } from "@ant-design/icons" -import type { UploadItem } from "../hooks/useAssetUpload" +import type { UploadItem, UploadFailStage } from "../hooks/useAssetUpload" +import { COMPLETE_RETRY_HINT } from "../hooks/useAssetUpload" export interface UploadQueuePanelProps { items: UploadItem[] @@ -29,6 +30,13 @@ const STATUS_TEXT: Record = { error: "上传失败", } +/** 失败阶段中文名:让用户一眼看到失败发生在哪一步 */ +const FAIL_STAGE_TEXT: Record = { + prepare: "准备上传阶段", + transfer: "文件传输阶段", + complete: "确认入库阶段", +} + const UploadQueuePanel: React.FC = ({ items, onRetry, @@ -82,8 +90,23 @@ const UploadQueuePanel: React.FC = ({ {it.duplicated ? "素材已存在,已跳过" : STATUS_TEXT[it.status]} {it.status === "preparing" && it.hint ? `(${it.hint})` : ""} {it.status === "uploading" ? ` ${it.progress}%` : ""} - {it.status === "error" && it.error ? `:${it.error}` : ""} + {it.status === "error" && it.failedStage + ? `(${FAIL_STAGE_TEXT[it.failedStage]})` + : ""} + {it.status === "error" && it.error ? ( +
+ {it.error.split("\n").map((line, idx) => + line === COMPLETE_RETRY_HINT ? ( +
+ {line} +
+ ) : ( +
{line}
+ ), + )} +
+ ) : null} {it.status === "error" && ( diff --git a/apps/web/src/pages/assets/hooks/useAssetUpload.ts b/apps/web/src/pages/assets/hooks/useAssetUpload.ts index 0d0a588b2..d322e3d5e 100644 --- a/apps/web/src/pages/assets/hooks/useAssetUpload.ts +++ b/apps/web/src/pages/assets/hooks/useAssetUpload.ts @@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect } from "react" import { useQueryClient } from "@tanstack/react-query" import { message } from "antd" import { prepareDirectUploadHandle, type DirectUploadHandle } from "@/api/assets" +import { getErrorMessage, isErrorMsgShown } from "@/api/errors" import { MAX_FILE_SIZE } from "../constants" import { computeFileHash, @@ -44,9 +45,15 @@ export interface UploadItem { /** 批量直传最大并发数,避免多文件瓜分上行带宽 */ const MAX_CONCURRENT = 3 -/** complete 阶段失败后的错误提示:素材可能已在服务器处理中,重试不会重新上传 */ -const COMPLETE_ERROR_HINT = - "确认请求失败,素材可能已在服务器处理中;点重试将安全确认,不会重新上传文件" +/** complete 阶段失败后的安全提示:素材可能已在后端建成,重试只重发 complete 幂等安全 */ +export const COMPLETE_RETRY_HINT = "素材可能已在服务器处理中,点重试将安全确认,不会重新上传文件" + +/** 失败阶段中文名(toast 提示用,明确失败发生在哪一步) */ +const STAGE_LABEL: Record = { + prepare: "准备上传", + transfer: "文件传输", + complete: "确认入库", +} /** * 素材批量上传 Hook @@ -165,20 +172,25 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) { message.success(`"${item.fileName}" 上传完成,正在转码处理`) } } catch (err: unknown) { - const detail = err instanceof Error ? err.message : "上传失败" + // 完整失败原因:HTTP 状态码 / OSS XML 的 Code+Message / 后端 detail, + // 由 getErrorMessage 统一提取(OSS XHR 错误自带「OSS 直传失败: HTTP xxx ...」明细) + const detail = getErrorMessage(err, "未知错误") console.error("[useAssetUpload] 上传失败:", item.fileName, stage, err) if (stage === "complete") { // complete 失败(超时/5xx/网络):后端记录可能已建成,handle 保留供幂等重试; - // 刷新列表让用户看到可能已创建的「处理中」素材,避免误以为没传上去而重复操作 + // 刷新列表让用户看到可能已创建的「处理中」素材,避免误以为没传上去而重复操作。 + // 卡片同时展示真实错误原因 + 安全重试提示(重试只重发 complete,不重新上传) refreshList() updateItem(item.tempId, { status: "error", failedStage: "complete", - error: COMPLETE_ERROR_HINT, + error: `${detail}\n${COMPLETE_RETRY_HINT}`, hint: undefined, }) - message.error(`"${item.fileName}" ${COMPLETE_ERROR_HINT}`) + if (!isErrorMsgShown(err)) { + message.error(`"${item.fileName}" 确认入库失败:${detail}`) + } } else { // prepare / transfer 失败:后端尚无素材记录,可安全全量重跑 handlesRef.current.delete(item.tempId) @@ -188,7 +200,11 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) { error: detail, hint: undefined, }) - message.error(`"${item.fileName}" 上传失败:${detail}`) + // 拦截器已对后端错误弹过 toast(含真实 detail)时不重复弹; + // OSS XHR 直传错误不走 axios,必须在这里弹 + if (!isErrorMsgShown(err)) { + message.error(`"${item.fileName}" ${STAGE_LABEL[stage]}失败:${detail}`) + } } } }, diff --git a/apps/web/src/pages/auth/Login.tsx b/apps/web/src/pages/auth/Login.tsx index 1b92d9eca..90acff2b2 100644 --- a/apps/web/src/pages/auth/Login.tsx +++ b/apps/web/src/pages/auth/Login.tsx @@ -1,11 +1,12 @@ /** * 登录页面 - V21 完全对标 */ -import React, { useState } from "react" +import React, { useRef, useState } from "react" import { Form, Input, Checkbox, message } from "antd" import { Link, useNavigate } from "react-router-dom" import { useLogin } from "@/hooks/useAuth" import { getWechatAuthUrl } from "@/api/auth" +import { getErrorMessage, isErrorMsgShown } from "@/api/errors" import Button from "@/components/ui/Button" import "./Login.css" @@ -20,6 +21,9 @@ const Login: React.FC = () => { const loginMutation = useLogin() const [form] = Form.useForm() const [wechatLoading, setWechatLoading] = useState(false) + // 同步防连点守卫:state 更新有渲染间隙,连点两次会各自请求授权 URL, + // 后一次的 state 覆盖前一次写入 localStorage 的 state,导致回调校验失败 + const wechatStartingRef = useRef(false) const onFinish = async (values: LoginFormValues) => { try { @@ -36,8 +40,10 @@ const Login: React.FC = () => { } const handleWechatLogin = async () => { + if (wechatStartingRef.current) return + wechatStartingRef.current = true + setWechatLoading(true) try { - setWechatLoading(true) const result = await getWechatAuthUrl() // 保存 state 到 localStorage 用于回调时验证 localStorage.setItem("wechat_state", result.state) @@ -51,11 +57,15 @@ const Login: React.FC = () => { // 跳转到微信授权页 window.location.href = result.auth_url } catch (error) { - if (!(error as { __msgShown?: boolean })?.__msgShown) - message.error("微信登录暂不可用,请稍后重试") - } finally { + // 跳走前才可能回到这里;拦截器已弹过后端 detail 时不重复弹, + // 否则透传真实原因(如微信服务未配置、网络异常) + if (!isErrorMsgShown(error)) { + message.error(`微信登录启动失败:${getErrorMessage(error, "请稍后重试")}`) + } + wechatStartingRef.current = false setWechatLoading(false) } + // 成功时 window.location 跳走,不复位 loading(页面即将卸载) } return ( diff --git a/apps/web/src/pages/auth/WechatBindCallback.tsx b/apps/web/src/pages/auth/WechatBindCallback.tsx index ad013cdd8..61ded7e61 100644 --- a/apps/web/src/pages/auth/WechatBindCallback.tsx +++ b/apps/web/src/pages/auth/WechatBindCallback.tsx @@ -6,6 +6,7 @@ import React, { useEffect, useState } from "react" import { useSearchParams, useNavigate } from "react-router-dom" import { Spin } from "antd" import { bindWechat, normalizeUser } from "@/api/auth" +import { getErrorMessage } from "@/api/errors" import { useAuthStore } from "@/store/authStore" const WechatBindCallback: React.FC = () => { @@ -19,17 +20,13 @@ const WechatBindCallback: React.FC = () => { const state = searchParams.get("state") if (!code || !state) { - setError("无效的回调参数") + setError("无效的回调参数,请回到设置页重新扫码绑定") return } const handleBind = async () => { - // state 校验:绑定场景由设置页生成并落库,前缀 bind: - const savedState = localStorage.getItem("wechat_bind_state") - if (!savedState || savedState !== state) { - setError("安全校验失败,请重新绑定") - return - } + // state 校验由后端 state store 一次性消费兜底(前端不再比对 localStorage, + // 微信内打开/跨浏览器场景本地无 state 会误杀);清理绑定前写入的 state localStorage.removeItem("wechat_bind_state") try { @@ -37,8 +34,9 @@ const WechatBindCallback: React.FC = () => { setUser(normalizeUser(result.user)) // 用 replace 回设置页,query 携带成功标记由设置页提示 navigate("/app/profile?wechat_bind=success", { replace: true }) - } catch { - navigate("/app/profile?wechat_bind=failed", { replace: true }) + } catch (err) { + // 绑定失败直接在本页展示真实原因(如微信已被其他账号绑定),不静默跳走 + setError(`微信绑定失败:${getErrorMessage(err, "请回到设置页重试")}`) } } diff --git a/apps/web/src/pages/auth/WechatCallback.tsx b/apps/web/src/pages/auth/WechatCallback.tsx index 4186c3033..754a4ca70 100644 --- a/apps/web/src/pages/auth/WechatCallback.tsx +++ b/apps/web/src/pages/auth/WechatCallback.tsx @@ -7,6 +7,7 @@ import React, { useEffect, useState } from "react" import { useSearchParams, useNavigate } from "react-router-dom" import { Spin } from "antd" import { wechatCallback, getCurrentUser, normalizeUser, type User } from "@/api/auth" +import { getErrorMessage } from "@/api/errors" import { useAuthStore } from "@/store/authStore" import { scheduleProactiveRefresh } from "@/api/auth/tokenRefresh" @@ -21,21 +22,27 @@ const WechatCallback: React.FC = () => { const code = searchParams.get("code") const state = searchParams.get("state") + // 微信重定向出错时(如用户拒绝授权 error=access_denied)直接展示原因 + const wxErrorCode = searchParams.get("error") + const wxErrDesc = searchParams.get("error_description") + if (wxErrorCode || wxErrDesc) { + const reason = [wxErrorCode, wxErrDesc].filter(Boolean).join(":") + setError(`微信授权失败:${reason}`) + setLoading(false) + return + } + if (!code || !state) { - setError("无效的回调参数") + setError("无效的回调参数,请重新扫码登录") setLoading(false) return } const handleCallback = async () => { try { - // 校验 state,防止 CSRF - const savedState = localStorage.getItem("wechat_state") - if (!savedState || savedState !== state) { - setError("安全校验失败,请重新登录") - setLoading(false) - return - } + // state 的 CSRF 校验由后端 state store 一次性消费兜底(前端不再比对 + // localStorage——微信内打开、跨浏览器等场景本地没有 state,会误杀正常回调); + // 清理登录前写入的 state,避免残留 localStorage.removeItem("wechat_state") const result = await wechatCallback(code, state) @@ -63,8 +70,9 @@ const WechatCallback: React.FC = () => { const redirect = localStorage.getItem("login_redirect") || "/" localStorage.removeItem("login_redirect") navigate(redirect, { replace: true }) - } catch { - setError("微信登录失败,请重试") + } catch (err) { + // 透传后端真实错误(如 state 过期、code 已消费、接口异常),禁止吞成通用提示 + setError(`微信登录失败:${getErrorMessage(err, "请重试或更换登录方式")}`) setLoading(false) } } diff --git a/apps/web/src/pages/auth/WechatOnboarding.tsx b/apps/web/src/pages/auth/WechatOnboarding.tsx index a4893b846..0b237243f 100644 --- a/apps/web/src/pages/auth/WechatOnboarding.tsx +++ b/apps/web/src/pages/auth/WechatOnboarding.tsx @@ -67,7 +67,8 @@ const WechatOnboarding: React.FC = () => { onFinish={onFinish} autoComplete="off" layout="vertical" - initialValues={{ display_name: user?.display_name || "" }} + // 不预填:新微信用户必须自己输入昵称(user.display_name 可能是微信昵称/系统占位) + initialValues={{ display_name: "" }} > { }) }) -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() + }) }) diff --git a/apps/web/src/test/pages/assets/useAssetUpload.test.tsx b/apps/web/src/test/pages/assets/useAssetUpload.test.tsx index cda209f5d..4c5004fa0 100644 --- a/apps/web/src/test/pages/assets/useAssetUpload.test.tsx +++ b/apps/web/src/test/pages/assets/useAssetUpload.test.tsx @@ -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).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("签名服务内部错误") + }) }) diff --git a/apps/web/src/test/pages/auth/WechatBindCallback.test.tsx b/apps/web/src/test/pages/auth/WechatBindCallback.test.tsx new file mode 100644 index 000000000..a2592fc2f --- /dev/null +++ b/apps/web/src/test/pages/auth/WechatBindCallback.test.tsx @@ -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 = {} +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( + + + , + ) + +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() + }) + }) +}) diff --git a/apps/web/src/test/pages/auth/WechatCallback.test.tsx b/apps/web/src/test/pages/auth/WechatCallback.test.tsx index 01675db9b..5ef33e4c2 100644 --- a/apps/web/src/test/pages/auth/WechatCallback.test.tsx +++ b/apps/web/src/test/pages/auth/WechatCallback.test.tsx @@ -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 = {} let mockCurrentUser: Record = {} -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() }) }) diff --git a/apps/web/src/test/pages/auth/WechatOnboarding.test.tsx b/apps/web/src/test/pages/auth/WechatOnboarding.test.tsx index b93218448..41c4fcfa9 100644 --- a/apps/web/src/test/pages/auth/WechatOnboarding.test.tsx +++ b/apps/web/src/test/pages/auth/WechatOnboarding.test.tsx @@ -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()