fix(#1718/#1714): 微信回调纠错 + 上传失败可观测性 + 哈希性能 + 昵称不预填 #1723
@@ -126,7 +126,15 @@ export const prepareDirectUploadHandle = async (data: {
|
||||
/** 本次逻辑上传的幂等 token,prepare/complete 一致、重试复用 */
|
||||
clientUploadId?: string
|
||||
}): Promise<DirectUploadHandle> => {
|
||||
const project = await getOrCreateDefaultProject()
|
||||
// 默认项目初始化失败(项目列表接口异常/自动创建失败)给出独立、明确的提示,
|
||||
// 不与 prepare 的签名接口错误混在一起
|
||||
let project: Awaited<ReturnType<typeof getOrCreateDefaultProject>>
|
||||
try {
|
||||
project = await getOrCreateDefaultProject()
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : "网络异常"
|
||||
throw new Error(`初始化默认项目失败,无法开始上传:${reason}`)
|
||||
}
|
||||
|
||||
const prepared = await prepareDirectUpload({
|
||||
project_id: project.id,
|
||||
|
||||
@@ -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 + 同文件名兜底去重)。
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 统一错误信息提取
|
||||
* 把 axios 错误(后端 detail / FastAPI 校验错误 / HTTP 状态码)、XHR/OSS 错误、
|
||||
* 网络/超时错误、普通 Error 统一转成「可直接展示给用户」的中文信息。
|
||||
*
|
||||
* 与 api/client.ts 响应拦截器的提示口径保持一致;拦截器负责全局 toast,
|
||||
* 页面/队列卡片用本工具把真实原因展示在持久位置(回调页、失败卡片等)。
|
||||
*/
|
||||
import type { AxiosError } from "axios"
|
||||
|
||||
/** 后端错误响应体可能出现的字段(FastAPI:detail;历史接口:message/msg) */
|
||||
interface ErrorBody {
|
||||
detail?: unknown
|
||||
message?: unknown
|
||||
msg?: unknown
|
||||
}
|
||||
|
||||
/** FastAPI 422 校验错误单项 */
|
||||
interface ValidationItem {
|
||||
loc?: (string | number)[]
|
||||
msg?: string
|
||||
}
|
||||
|
||||
/** 从后端响应体提取人类可读信息(detail 可能是字符串、对象、422 数组) */
|
||||
function extractBodyMessage(data: unknown): string {
|
||||
if (!data || typeof data !== "object") return ""
|
||||
const body = data as ErrorBody
|
||||
|
||||
const walk = (val: unknown): string => {
|
||||
if (typeof val === "string") return val
|
||||
if (Array.isArray(val)) {
|
||||
// FastAPI 422: [{loc, msg, type}, ...] → 取每条 msg 拼接
|
||||
const parts = val
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item
|
||||
if (item && typeof item === "object") {
|
||||
const v = item as ValidationItem
|
||||
if (typeof v.msg === "string") {
|
||||
const field = Array.isArray(v.loc) ? v.loc.filter((x) => x !== "body").join(".") : ""
|
||||
return field ? `${field}: ${v.msg}` : v.msg
|
||||
}
|
||||
return walk(item)
|
||||
}
|
||||
return ""
|
||||
})
|
||||
.filter(Boolean)
|
||||
return parts.join(";")
|
||||
}
|
||||
if (val && typeof val === "object") {
|
||||
const obj = val as Record<string, unknown>
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.msg === "string") return obj.msg
|
||||
if (typeof obj.detail === "string") return obj.detail
|
||||
if (obj.message && typeof obj.message === "object") return walk(obj.message)
|
||||
if (obj.msg && typeof obj.msg === "object") return walk(obj.msg)
|
||||
try {
|
||||
return JSON.stringify(val)
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
return walk(body.detail) || walk(body.message) || walk(body.msg)
|
||||
}
|
||||
|
||||
/** 无响应体时按 HTTP 状态码给出兜底提示(与 client.ts 拦截器口径一致) */
|
||||
function statusFallback(status: number): string {
|
||||
switch (status) {
|
||||
case 400:
|
||||
return "请求参数有误(HTTP 400)"
|
||||
case 401:
|
||||
return "登录状态已失效,请重新登录(HTTP 401)"
|
||||
case 403:
|
||||
return "没有权限执行该操作(HTTP 403)"
|
||||
case 404:
|
||||
return "请求的资源不存在(HTTP 404)"
|
||||
case 409:
|
||||
return "操作冲突,资源状态已变化(HTTP 409)"
|
||||
case 413:
|
||||
return "文件过大,请缩小后重试(HTTP 413)"
|
||||
case 415:
|
||||
return "不支持的文件格式(HTTP 415)"
|
||||
case 429:
|
||||
return "操作过于频繁,请稍后再试(HTTP 429)"
|
||||
case 503:
|
||||
return "服务暂不可用,请稍后再试(HTTP 503)"
|
||||
default:
|
||||
if (status >= 500) return `服务器繁忙,请稍后再试(HTTP ${status})`
|
||||
return `请求失败(HTTP ${status})`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从任意抛出值提取可展示的错误信息。
|
||||
* @param fallback 全部提取失败时的兜底文案
|
||||
*/
|
||||
export function getErrorMessage(err: unknown, fallback = "操作失败,请稍后重试"): string {
|
||||
if (!err) return fallback
|
||||
|
||||
// axios 错误(后端 JSON 响应 / HTTP 错误状态)
|
||||
const ax = err as AxiosError<ErrorBody>
|
||||
if (ax.isAxiosError || (typeof ax === "object" && "response" in (ax as object))) {
|
||||
// 超时
|
||||
if (ax.code === "ECONNABORTED" || /timeout/i.test(ax.message || "")) {
|
||||
return "请求超时,请检查网络后重试"
|
||||
}
|
||||
const resp = ax.response
|
||||
if (resp) {
|
||||
const bodyMsg = extractBodyMessage(resp.data)
|
||||
if (bodyMsg) return bodyMsg
|
||||
return statusFallback(resp.status)
|
||||
}
|
||||
// 请求已发出但无响应(断网/CORS/DNS)
|
||||
if (ax.request) return "网络连接异常,请检查网络设置"
|
||||
return ax.message || fallback
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
// XHR 直传 OSS 失败等场景自带详细 message(含 HTTP 状态 + OSS Code/Message)
|
||||
if (err.message) return err.message
|
||||
}
|
||||
if (typeof err === "string") return err
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** client.ts 拦截器是否已对该错误弹过全局 toast(__msgShown 标记) */
|
||||
export function isErrorMsgShown(err: unknown): boolean {
|
||||
return Boolean((err as { __msgShown?: boolean } | null)?.__msgShown)
|
||||
}
|
||||
@@ -831,6 +831,20 @@
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.xx-upload-queue-error-detail {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #ef4444;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.xx-upload-queue-error-hint {
|
||||
margin-top: 2px;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.xx-upload-queue-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
ReloadOutlined,
|
||||
CloseOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { UploadItem } from "../hooks/useAssetUpload"
|
||||
import type { UploadItem, UploadFailStage } from "../hooks/useAssetUpload"
|
||||
import { COMPLETE_RETRY_HINT } from "../hooks/useAssetUpload"
|
||||
|
||||
export interface UploadQueuePanelProps {
|
||||
items: UploadItem[]
|
||||
@@ -29,6 +30,13 @@ const STATUS_TEXT: Record<UploadItem["status"], string> = {
|
||||
error: "上传失败",
|
||||
}
|
||||
|
||||
/** 失败阶段中文名:让用户一眼看到失败发生在哪一步 */
|
||||
const FAIL_STAGE_TEXT: Record<UploadFailStage, string> = {
|
||||
prepare: "准备上传阶段",
|
||||
transfer: "文件传输阶段",
|
||||
complete: "确认入库阶段",
|
||||
}
|
||||
|
||||
const UploadQueuePanel: React.FC<UploadQueuePanelProps> = ({
|
||||
items,
|
||||
onRetry,
|
||||
@@ -82,8 +90,23 @@ const UploadQueuePanel: React.FC<UploadQueuePanelProps> = ({
|
||||
{it.duplicated ? "素材已存在,已跳过" : STATUS_TEXT[it.status]}
|
||||
{it.status === "preparing" && it.hint ? `(${it.hint})` : ""}
|
||||
{it.status === "uploading" ? ` ${it.progress}%` : ""}
|
||||
{it.status === "error" && it.error ? `:${it.error}` : ""}
|
||||
{it.status === "error" && it.failedStage
|
||||
? `(${FAIL_STAGE_TEXT[it.failedStage]})`
|
||||
: ""}
|
||||
</div>
|
||||
{it.status === "error" && it.error ? (
|
||||
<div className="xx-upload-queue-error-detail" title={it.error}>
|
||||
{it.error.split("\n").map((line, idx) =>
|
||||
line === COMPLETE_RETRY_HINT ? (
|
||||
<div key={idx} className="xx-upload-queue-error-hint">
|
||||
{line}
|
||||
</div>
|
||||
) : (
|
||||
<div key={idx}>{line}</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="xx-upload-queue-actions">
|
||||
{it.status === "error" && (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { prepareDirectUploadHandle, type DirectUploadHandle } from "@/api/assets"
|
||||
import { getErrorMessage, isErrorMsgShown } from "@/api/errors"
|
||||
import { MAX_FILE_SIZE } from "../constants"
|
||||
import {
|
||||
computeFileHash,
|
||||
@@ -44,9 +45,15 @@ export interface UploadItem {
|
||||
/** 批量直传最大并发数,避免多文件瓜分上行带宽 */
|
||||
const MAX_CONCURRENT = 3
|
||||
|
||||
/** complete 阶段失败后的错误提示:素材可能已在服务器处理中,重试不会重新上传 */
|
||||
const COMPLETE_ERROR_HINT =
|
||||
"确认请求失败,素材可能已在服务器处理中;点重试将安全确认,不会重新上传文件"
|
||||
/** complete 阶段失败后的安全提示:素材可能已在后端建成,重试只重发 complete 幂等安全 */
|
||||
export const COMPLETE_RETRY_HINT = "素材可能已在服务器处理中,点重试将安全确认,不会重新上传文件"
|
||||
|
||||
/** 失败阶段中文名(toast 提示用,明确失败发生在哪一步) */
|
||||
const STAGE_LABEL: Record<UploadFailStage, string> = {
|
||||
prepare: "准备上传",
|
||||
transfer: "文件传输",
|
||||
complete: "确认入库",
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材批量上传 Hook
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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, "请回到设置页重试")}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: "" }}
|
||||
>
|
||||
<Form.Item
|
||||
name="display_name"
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user