Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbfb1e2bcc | |||
| e061685982 | |||
| c8b1c4b8ff | |||
| 5ca64898b7 | |||
| 9a0b9c3234 | |||
| 8156bb1e13 | |||
| 5575d98512 | |||
| 6f4499afff |
@@ -84,6 +84,7 @@ class CurrentUserResponse(BaseModel):
|
||||
phone: str = ""
|
||||
phone_verified: bool = False
|
||||
binding_complete: bool = False
|
||||
wechat_bound: bool = False
|
||||
|
||||
|
||||
class PasswordResetRequestModel(BaseModel):
|
||||
@@ -272,6 +273,7 @@ async def get_current_user_info(
|
||||
phone=user.phone or "",
|
||||
phone_verified=user.phone_verified,
|
||||
binding_complete=binding_complete,
|
||||
wechat_bound=bool(user.wechat_openid),
|
||||
)
|
||||
|
||||
|
||||
@@ -492,6 +494,122 @@ async def wechat_callback(
|
||||
)
|
||||
|
||||
|
||||
# ==================== 微信账号绑定/解绑(已登录用户) ====================
|
||||
|
||||
|
||||
class WechatBindUrlResponse(BaseModel):
|
||||
auth_url: str
|
||||
state: str
|
||||
|
||||
|
||||
class WechatBindCompleteRequest(BaseModel):
|
||||
code: str
|
||||
state: str = ""
|
||||
|
||||
|
||||
class WechatBindUserProfile(BaseModel):
|
||||
"""绑定/解绑后返回的用户信息(字段对齐 /auth/me,前端 normalizeUser 直接消费)"""
|
||||
|
||||
user_id: str
|
||||
email: str
|
||||
username: str
|
||||
display_name: str
|
||||
email_verified: bool
|
||||
phone: str = ""
|
||||
phone_verified: bool = False
|
||||
binding_complete: bool = False
|
||||
wechat_bound: bool = False
|
||||
|
||||
|
||||
class WechatBindCompleteResponse(BaseModel):
|
||||
success: bool
|
||||
user: WechatBindUserProfile
|
||||
|
||||
|
||||
class WechatUnbindResponse(BaseModel):
|
||||
success: bool
|
||||
|
||||
|
||||
def _wechat_user_profile(user) -> WechatBindUserProfile:
|
||||
binding_complete = bool(
|
||||
user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
|
||||
)
|
||||
return WechatBindUserProfile(
|
||||
user_id=user.id,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
display_name=user.display_name,
|
||||
email_verified=user.email_verified,
|
||||
phone=user.phone or "",
|
||||
phone_verified=user.phone_verified,
|
||||
binding_complete=binding_complete,
|
||||
wechat_bound=bool(user.wechat_openid),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/wechat/bind/url", response_model=WechatBindUrlResponse)
|
||||
async def get_wechat_bind_url(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> WechatBindUrlResponse:
|
||||
"""获取微信绑定授权链接(已登录用户场景)。state 经 Redis 存储做 CSRF 校验。"""
|
||||
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
|
||||
|
||||
oauth_service = get_wechat_oauth_service()
|
||||
auth_url, state = oauth_service.generate_auth_url()
|
||||
logger.info("[微信绑定] 用户 %s 请求绑定授权链接", current_user.user.id)
|
||||
return WechatBindUrlResponse(auth_url=auth_url, state=state)
|
||||
|
||||
|
||||
@router.post("/wechat/bind", response_model=WechatBindCompleteResponse)
|
||||
async def wechat_bind(
|
||||
request: WechatBindCompleteRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> WechatBindCompleteResponse:
|
||||
"""微信绑定完成:扫码回调后用 code 换 openid,绑定到当前登录账号(不创建新用户)。"""
|
||||
from packages.application.auth.wechat_bind_use_case import WechatBindRequest, WechatBindUseCase
|
||||
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
|
||||
|
||||
oauth_service = get_wechat_oauth_service()
|
||||
wechat_user, err = oauth_service.handle_callback(request.code, request.state)
|
||||
if err:
|
||||
logger.warning("[微信绑定] 用户 %s 换取微信信息失败: %s", current_user.user.id, err)
|
||||
raise HTTPException(status_code=400, detail=err)
|
||||
|
||||
use_case = WechatBindUseCase(user_repository=user_repository)
|
||||
result, error, http_status = use_case.bind(
|
||||
WechatBindRequest(
|
||||
user_id=current_user.user.id,
|
||||
openid=wechat_user.openid,
|
||||
unionid=wechat_user.unionid or "",
|
||||
)
|
||||
)
|
||||
if error:
|
||||
logger.warning("[微信绑定] 用户 %s 绑定失败: %s", current_user.user.id, error)
|
||||
raise HTTPException(status_code=http_status, detail=error)
|
||||
|
||||
logger.info("[微信绑定] 用户 %s 绑定成功 openid=%s", current_user.user.id, wechat_user.openid[:8])
|
||||
return WechatBindCompleteResponse(success=True, user=_wechat_user_profile(result.user))
|
||||
|
||||
|
||||
@router.delete("/wechat/bind", response_model=WechatUnbindResponse)
|
||||
async def wechat_unbind(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> WechatUnbindResponse:
|
||||
"""解绑微信:需账号仍有其他登录方式(密码/手机/真实邮箱),否则拒绝。"""
|
||||
from packages.application.auth.wechat_bind_use_case import WechatUnbindUseCase
|
||||
|
||||
use_case = WechatUnbindUseCase(user_repository=user_repository)
|
||||
result, error, http_status = use_case.unbind(current_user.user.id)
|
||||
if error:
|
||||
logger.warning("[微信解绑] 用户 %s 解绑失败: %s", current_user.user.id, error)
|
||||
raise HTTPException(status_code=http_status, detail=error)
|
||||
|
||||
logger.info("[微信解绑] 用户 %s 解绑成功", current_user.user.id)
|
||||
return WechatUnbindResponse(success=True)
|
||||
|
||||
|
||||
# ==================== 验证码 & 绑定 ====================
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
微信账号绑定/解绑 Use Case(已登录用户场景)
|
||||
|
||||
与 wechat_sync_use_case(登录/注册,系统级)不同:
|
||||
- bind:把微信 openid/unionid 绑定到【当前登录账号】,不创建新用户;
|
||||
微信身份若已绑定其他账号则冲突(409)。
|
||||
- unbind:解除当前账号的微信绑定;若账号没有其他登录方式(手机/邮箱/密码),
|
||||
解绑后将无法登录,因此拒绝解绑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from packages.domain.entities import User
|
||||
|
||||
|
||||
class WechatBindRequest:
|
||||
"""微信绑定请求"""
|
||||
|
||||
def __init__(self, user_id: str, openid: str, unionid: str = ""):
|
||||
self.user_id = user_id
|
||||
self.openid = (openid or "").strip()
|
||||
self.unionid = (unionid or "").strip()
|
||||
|
||||
|
||||
class WechatBindResult:
|
||||
"""微信绑定/解绑结果"""
|
||||
|
||||
def __init__(self, user: User):
|
||||
self.user = user
|
||||
|
||||
|
||||
class WechatBindUseCase:
|
||||
"""已登录用户绑定微信用例"""
|
||||
|
||||
def __init__(self, user_repository):
|
||||
self.user_repository = user_repository
|
||||
|
||||
def bind(self, request: WechatBindRequest) -> tuple[Optional[WechatBindResult], Optional[str], int]:
|
||||
"""
|
||||
绑定微信到当前登录账号。
|
||||
|
||||
Returns:
|
||||
(结果, 错误信息, http状态码) - 成功时错误信息为 None、状态码为 200;
|
||||
冲突返回 409,客户端/服务端错误返回 400/404。
|
||||
"""
|
||||
if not request.openid:
|
||||
return None, "缺少微信 openid", 400
|
||||
|
||||
user = self.user_repository.find_by_id(request.user_id)
|
||||
if user is None:
|
||||
return None, "当前用户不存在", 404
|
||||
|
||||
# 已绑定同一个微信:幂等成功
|
||||
if user.wechat_openid == request.openid:
|
||||
return WechatBindResult(user=user), None, 200
|
||||
|
||||
# 当前账号已绑定其他微信
|
||||
if user.wechat_openid:
|
||||
return None, "当前账号已绑定微信,请先解绑", 409
|
||||
|
||||
# openid 已被其他账号占用
|
||||
existing = self.user_repository.find_by_wechat_openid(request.openid)
|
||||
if existing is not None and existing.id != user.id:
|
||||
return None, "该微信已绑定其他账号,请先在原账号解绑", 409
|
||||
|
||||
# unionid 冲突:同主体微信已绑其他账号
|
||||
if request.unionid:
|
||||
existing_union = self.user_repository.find_by_wechat_unionid(request.unionid)
|
||||
if existing_union is not None and existing_union.id != user.id:
|
||||
return None, "该微信主体已绑定其他账号,请先在原账号解绑", 409
|
||||
|
||||
user.wechat_openid = request.openid
|
||||
if request.unionid and not user.wechat_unionid:
|
||||
user.wechat_unionid = request.unionid
|
||||
self.user_repository.save(user)
|
||||
|
||||
return WechatBindResult(user=user), None, 200
|
||||
|
||||
|
||||
class WechatUnbindUseCase:
|
||||
"""已登录用户解绑微信用例"""
|
||||
|
||||
def __init__(self, user_repository):
|
||||
self.user_repository = user_repository
|
||||
|
||||
def unbind(self, user_id: str) -> tuple[Optional[WechatBindResult], Optional[str], int]:
|
||||
"""
|
||||
解除当前账号的微信绑定。
|
||||
|
||||
解绑前置条件:账号必须还有其他登录方式(密码 / 已验证手机 / 真实邮箱),
|
||||
否则解绑后将永远无法登录。
|
||||
"""
|
||||
user = self.user_repository.find_by_id(user_id)
|
||||
if user is None:
|
||||
return None, "当前用户不存在", 404
|
||||
|
||||
if not user.wechat_openid:
|
||||
return None, "当前账号未绑定微信", 400
|
||||
|
||||
# 守卫:解绑后账号必须仍有可实际使用的登录方式。
|
||||
# 注意:微信注册用户带的是【随机密码】(用户不知道、无法用密码登录,
|
||||
# 且 @wechat.local 占位邮箱收不到重置邮件),故 password_hash 不作为兜底依据,
|
||||
# 口径与 /auth/me 的 binding_complete 一致。
|
||||
has_phone = bool(user.phone and user.phone_verified)
|
||||
has_real_email = bool(user.email and user.email_verified and "@wechat.local" not in user.email)
|
||||
if not (has_phone or has_real_email):
|
||||
return None, "账号需要至少一种其他登录方式(已验证手机或真实邮箱)后才能解绑微信", 400
|
||||
|
||||
user.wechat_openid = None
|
||||
user.wechat_unionid = None
|
||||
self.user_repository.save(user)
|
||||
|
||||
return WechatBindResult(user=user), None, 200
|
||||
@@ -0,0 +1,233 @@
|
||||
"""#1719:微信绑定/解绑路由层测试(直接驱动路由函数)。
|
||||
|
||||
覆盖:
|
||||
- GET /wechat/bind/url:调 oauth 生成链接、记日志
|
||||
- POST /wechat/bind:oauth 失败→400;绑定成功→success+user.wechat_bound=True;
|
||||
use case 返回冲突→对应状态码透传
|
||||
- DELETE /wechat/bind:成功→success=True;use case 报错→状态码透传
|
||||
- /auth/me 返回 wechat_bound 字段
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from app.api.routes import auth as auth_route # noqa: E402
|
||||
from fastapi import HTTPException # noqa: E402
|
||||
|
||||
|
||||
def _auth_user(user_id="u-1", openid=None):
|
||||
user = SimpleNamespace(
|
||||
id=user_id,
|
||||
wechat_openid=openid,
|
||||
email="user@example.com",
|
||||
email_verified=True,
|
||||
username="user",
|
||||
display_name="用户",
|
||||
phone="",
|
||||
phone_verified=False,
|
||||
)
|
||||
return SimpleNamespace(user=user, session_id="s-1", token_type="user_auth")
|
||||
|
||||
|
||||
def _patched_bind(result, error, status):
|
||||
"""构造打了补丁的 wechat_bind_use_case 模块"""
|
||||
mod = SimpleNamespace(
|
||||
WechatBindRequest=lambda **kw: SimpleNamespace(**kw),
|
||||
WechatBindUseCase=MagicMock(),
|
||||
WechatUnbindUseCase=MagicMock(),
|
||||
)
|
||||
fake_bind_uc = MagicMock()
|
||||
fake_bind_uc.bind.return_value = (result, error, status)
|
||||
mod.WechatBindUseCase.return_value = fake_bind_uc
|
||||
return mod
|
||||
|
||||
|
||||
def test_get_bind_url_returns_url_and_state():
|
||||
fake_oauth = MagicMock()
|
||||
fake_oauth.generate_auth_url.return_value = ("https://open.weixin.qq.com/qrconnect?xxx", "state-bind-1")
|
||||
|
||||
import packages.application.auth.wechat_oauth_service as oauth_mod
|
||||
|
||||
orig = oauth_mod.get_wechat_oauth_service
|
||||
oauth_mod.get_wechat_oauth_service = MagicMock(return_value=fake_oauth)
|
||||
try:
|
||||
resp = asyncio.run(auth_route.get_wechat_bind_url(current_user=_auth_user()))
|
||||
finally:
|
||||
oauth_mod.get_wechat_oauth_service = orig
|
||||
|
||||
assert resp.auth_url.startswith("https://open.weixin.qq.com")
|
||||
assert resp.state == "state-bind-1"
|
||||
|
||||
|
||||
def test_bind_oauth_error_returns_400():
|
||||
fake_oauth = MagicMock()
|
||||
fake_oauth.handle_callback.return_value = (None, "无效的 state 参数")
|
||||
|
||||
import packages.application.auth.wechat_oauth_service as oauth_mod
|
||||
|
||||
orig = oauth_mod.get_wechat_oauth_service
|
||||
oauth_mod.get_wechat_oauth_service = MagicMock(return_value=fake_oauth)
|
||||
try:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(
|
||||
auth_route.wechat_bind(
|
||||
SimpleNamespace(code="c-1", state="s-1"),
|
||||
current_user=_auth_user(),
|
||||
user_repository=MagicMock(),
|
||||
)
|
||||
)
|
||||
finally:
|
||||
oauth_mod.get_wechat_oauth_service = orig
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert "state" in exc.value.detail
|
||||
|
||||
|
||||
def test_bind_success_returns_user_with_wechat_bound():
|
||||
fake_oauth = MagicMock()
|
||||
fake_oauth.handle_callback.return_value = (
|
||||
SimpleNamespace(openid="wx-openid-1", unionid="wx-union-1"),
|
||||
None,
|
||||
)
|
||||
|
||||
bound_user = SimpleNamespace(
|
||||
id="u-1",
|
||||
wechat_openid="wx-openid-1",
|
||||
email="user@example.com",
|
||||
email_verified=True,
|
||||
username="user",
|
||||
display_name="用户",
|
||||
phone="",
|
||||
phone_verified=False,
|
||||
)
|
||||
|
||||
import packages.application.auth.wechat_oauth_service as oauth_mod
|
||||
from packages.application.auth import wechat_bind_use_case as bind_mod
|
||||
|
||||
orig_oauth = oauth_mod.get_wechat_oauth_service
|
||||
fake_bind_uc = MagicMock()
|
||||
fake_bind_uc.bind.return_value = (SimpleNamespace(user=bound_user), None, 200)
|
||||
orig_bind = bind_mod.WechatBindUseCase
|
||||
bind_mod.WechatBindUseCase = MagicMock(return_value=fake_bind_uc)
|
||||
oauth_mod.get_wechat_oauth_service = MagicMock(return_value=fake_oauth)
|
||||
try:
|
||||
resp = asyncio.run(
|
||||
auth_route.wechat_bind(
|
||||
SimpleNamespace(code="c-1", state="s-1"),
|
||||
current_user=_auth_user(),
|
||||
user_repository=MagicMock(),
|
||||
)
|
||||
)
|
||||
finally:
|
||||
oauth_mod.get_wechat_oauth_service = orig_oauth
|
||||
bind_mod.WechatBindUseCase = orig_bind
|
||||
|
||||
assert resp.success is True
|
||||
assert resp.user.wechat_bound is True
|
||||
assert resp.user.user_id == "u-1"
|
||||
# 绑定请求应带上当前用户 id 与微信 openid
|
||||
call_kwargs = fake_bind_uc.bind.call_args[0][0]
|
||||
assert call_kwargs.user_id == "u-1"
|
||||
assert call_kwargs.openid == "wx-openid-1"
|
||||
|
||||
|
||||
def test_bind_conflict_propagates_409():
|
||||
fake_oauth = MagicMock()
|
||||
fake_oauth.handle_callback.return_value = (
|
||||
SimpleNamespace(openid="wx-openid-1", unionid=""),
|
||||
None,
|
||||
)
|
||||
|
||||
import packages.application.auth.wechat_oauth_service as oauth_mod
|
||||
from packages.application.auth import wechat_bind_use_case as bind_mod
|
||||
|
||||
orig_oauth = oauth_mod.get_wechat_oauth_service
|
||||
fake_bind_uc = MagicMock()
|
||||
fake_bind_uc.bind.return_value = (None, "该微信已绑定其他账号,请先在原账号解绑", 409)
|
||||
orig_bind = bind_mod.WechatBindUseCase
|
||||
bind_mod.WechatBindUseCase = MagicMock(return_value=fake_bind_uc)
|
||||
oauth_mod.get_wechat_oauth_service = MagicMock(return_value=fake_oauth)
|
||||
try:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(
|
||||
auth_route.wechat_bind(
|
||||
SimpleNamespace(code="c-1", state="s-1"),
|
||||
current_user=_auth_user(),
|
||||
user_repository=MagicMock(),
|
||||
)
|
||||
)
|
||||
finally:
|
||||
oauth_mod.get_wechat_oauth_service = orig_oauth
|
||||
bind_mod.WechatBindUseCase = orig_bind
|
||||
|
||||
assert exc.value.status_code == 409
|
||||
assert "已绑定其他账号" in exc.value.detail
|
||||
|
||||
|
||||
def test_unbind_success_returns_success_true():
|
||||
unbound_user = SimpleNamespace(
|
||||
id="u-1",
|
||||
wechat_openid=None,
|
||||
email="user@example.com",
|
||||
email_verified=True,
|
||||
username="user",
|
||||
display_name="用户",
|
||||
phone="",
|
||||
phone_verified=False,
|
||||
)
|
||||
|
||||
from packages.application.auth import wechat_bind_use_case as bind_mod
|
||||
|
||||
fake_uc = MagicMock()
|
||||
fake_uc.unbind.return_value = (SimpleNamespace(user=unbound_user), None, 200)
|
||||
orig = bind_mod.WechatUnbindUseCase
|
||||
bind_mod.WechatUnbindUseCase = MagicMock(return_value=fake_uc)
|
||||
try:
|
||||
resp = asyncio.run(
|
||||
auth_route.wechat_unbind(current_user=_auth_user(openid="wx-old"), user_repository=MagicMock())
|
||||
)
|
||||
finally:
|
||||
bind_mod.WechatUnbindUseCase = orig
|
||||
|
||||
assert resp.success is True
|
||||
fake_uc.unbind.assert_called_once_with("u-1")
|
||||
|
||||
|
||||
def test_unbind_rejected_no_other_login_propagates_400():
|
||||
from packages.application.auth import wechat_bind_use_case as bind_mod
|
||||
|
||||
fake_uc = MagicMock()
|
||||
fake_uc.unbind.return_value = (None, "账号需要至少一种其他登录方式(已验证手机或真实邮箱)后才能解绑微信", 400)
|
||||
orig = bind_mod.WechatUnbindUseCase
|
||||
bind_mod.WechatUnbindUseCase = MagicMock(return_value=fake_uc)
|
||||
try:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(auth_route.wechat_unbind(current_user=_auth_user(openid="wx-old"), user_repository=MagicMock()))
|
||||
finally:
|
||||
bind_mod.WechatUnbindUseCase = orig
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert "登录方式" in exc.value.detail
|
||||
|
||||
|
||||
def test_me_includes_wechat_bound_flag():
|
||||
# 已绑定用户
|
||||
resp = asyncio.run(auth_route.get_current_user_info(authenticated_user=_auth_user(openid="wx-openid-1")))
|
||||
assert resp.wechat_bound is True
|
||||
|
||||
# 未绑定用户
|
||||
resp2 = asyncio.run(auth_route.get_current_user_info(authenticated_user=_auth_user(openid=None)))
|
||||
assert resp2.wechat_bound is False
|
||||
@@ -0,0 +1,251 @@
|
||||
"""#1719:已登录用户微信绑定/解绑 Use Case 测试。
|
||||
|
||||
覆盖:
|
||||
- bind:幂等重复绑定、未绑定成功、当前账号已绑其他微信、openid/unionid 冲突 409、用户不存在
|
||||
- unbind:成功清 openid+unionid、未绑定拒绝、无其他登录方式拒绝、密码/手机/真实邮箱各兜底放行、用户不存在
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.auth.wechat_bind_use_case import (
|
||||
WechatBindRequest,
|
||||
WechatBindUseCase,
|
||||
WechatUnbindUseCase,
|
||||
)
|
||||
|
||||
|
||||
def _user(
|
||||
user_id="u-1",
|
||||
wechat_openid=None,
|
||||
wechat_unionid=None,
|
||||
password_hash="hashed-pw",
|
||||
phone=None,
|
||||
phone_verified=False,
|
||||
email="user@example.com",
|
||||
email_verified=True,
|
||||
):
|
||||
return SimpleNamespace(
|
||||
id=user_id,
|
||||
wechat_openid=wechat_openid,
|
||||
wechat_unionid=wechat_unionid,
|
||||
password_hash=password_hash,
|
||||
phone=phone,
|
||||
phone_verified=phone_verified,
|
||||
email=email,
|
||||
email_verified=email_verified,
|
||||
)
|
||||
|
||||
|
||||
class _FakeRepo:
|
||||
"""内存仓储:按 id/openid/unionid 建索引,save 原地更新。"""
|
||||
|
||||
def __init__(self, users):
|
||||
self.users = {u.id: u for u in users}
|
||||
self.saved = []
|
||||
|
||||
def find_by_id(self, user_id):
|
||||
return self.users.get(user_id)
|
||||
|
||||
def find_by_wechat_openid(self, openid):
|
||||
for u in self.users.values():
|
||||
if u.wechat_openid == openid:
|
||||
return u
|
||||
return None
|
||||
|
||||
def find_by_wechat_unionid(self, unionid):
|
||||
if not unionid:
|
||||
return None
|
||||
for u in self.users.values():
|
||||
if u.wechat_unionid == unionid:
|
||||
return u
|
||||
return None
|
||||
|
||||
def save(self, user):
|
||||
self.saved.append(user)
|
||||
|
||||
|
||||
# ==================== bind ====================
|
||||
|
||||
|
||||
def test_bind_success_when_not_bound():
|
||||
user = _user()
|
||||
repo = _FakeRepo([user])
|
||||
result, err, status = WechatBindUseCase(repo).bind(
|
||||
WechatBindRequest(user_id="u-1", openid="wx-openid-1", unionid="wx-union-1")
|
||||
)
|
||||
assert err is None
|
||||
assert status == 200
|
||||
assert result.user.wechat_openid == "wx-openid-1"
|
||||
assert result.user.wechat_unionid == "wx-union-1"
|
||||
assert repo.saved == [user]
|
||||
|
||||
|
||||
def test_bind_idempotent_same_openid():
|
||||
user = _user(wechat_openid="wx-openid-1", wechat_unionid="wx-union-1")
|
||||
repo = _FakeRepo([user])
|
||||
result, err, status = WechatBindUseCase(repo).bind(
|
||||
WechatBindRequest(user_id="u-1", openid="wx-openid-1", unionid="wx-union-1")
|
||||
)
|
||||
assert err is None
|
||||
assert status == 200
|
||||
assert result.user is user
|
||||
assert repo.saved == [] # 幂等不写库
|
||||
|
||||
|
||||
def test_bind_conflict_user_already_bound_other_wechat():
|
||||
user = _user(wechat_openid="wx-old")
|
||||
repo = _FakeRepo([user])
|
||||
result, err, status = WechatBindUseCase(repo).bind(WechatBindRequest(user_id="u-1", openid="wx-new"))
|
||||
assert result is None
|
||||
assert status == 409
|
||||
assert "已绑定微信" in err
|
||||
|
||||
|
||||
def test_bind_conflict_openid_used_by_other_user():
|
||||
user = _user(user_id="u-1")
|
||||
other = _user(user_id="u-2", wechat_openid="wx-openid-1")
|
||||
repo = _FakeRepo([user, other])
|
||||
result, err, status = WechatBindUseCase(repo).bind(WechatBindRequest(user_id="u-1", openid="wx-openid-1"))
|
||||
assert result is None
|
||||
assert status == 409
|
||||
assert "已绑定其他账号" in err
|
||||
assert user.wechat_openid is None # 未写库
|
||||
|
||||
|
||||
def test_bind_conflict_unionid_used_by_other_user():
|
||||
user = _user(user_id="u-1")
|
||||
# openid 不同,但 unionid 指向同一微信主体
|
||||
other = _user(user_id="u-2", wechat_openid="wx-other", wechat_unionid="wx-union-x")
|
||||
repo = _FakeRepo([user, other])
|
||||
result, err, status = WechatBindUseCase(repo).bind(
|
||||
WechatBindRequest(user_id="u-1", openid="wx-openid-1", unionid="wx-union-x")
|
||||
)
|
||||
assert result is None
|
||||
assert status == 409
|
||||
assert "微信主体" in err
|
||||
|
||||
|
||||
def test_bind_missing_openid_returns_400():
|
||||
repo = _FakeRepo([_user()])
|
||||
result, err, status = WechatBindUseCase(repo).bind(WechatBindRequest(user_id="u-1", openid=""))
|
||||
assert result is None
|
||||
assert status == 400
|
||||
assert "openid" in err
|
||||
|
||||
|
||||
def test_bind_user_not_found_returns_404():
|
||||
repo = _FakeRepo([])
|
||||
result, err, status = WechatBindUseCase(repo).bind(WechatBindRequest(user_id="ghost", openid="wx-openid-1"))
|
||||
assert result is None
|
||||
assert status == 404
|
||||
|
||||
|
||||
def test_bind_fills_unionid_when_existing_user_has_none():
|
||||
# 用户历史上只绑了 openid(unionid 为空),再次绑定时补齐 unionid 不冲突
|
||||
user = _user(wechat_openid="wx-openid-1", wechat_unionid=None)
|
||||
repo = _FakeRepo([user])
|
||||
result, err, status = WechatBindUseCase(repo).bind(
|
||||
WechatBindRequest(user_id="u-1", openid="wx-openid-1", unionid="wx-union-new")
|
||||
)
|
||||
# openid 相同 → 幂等成功(不覆盖 unionid,保持数据稳定)
|
||||
assert err is None
|
||||
assert status == 200
|
||||
|
||||
|
||||
# ==================== unbind ====================
|
||||
|
||||
|
||||
def test_unbind_success_with_real_verified_email():
|
||||
# 默认 _user 即 real@example.com 且 email_verified=True
|
||||
user = _user(wechat_openid="wx-openid-1", wechat_unionid="wx-union-1")
|
||||
repo = _FakeRepo([user])
|
||||
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
|
||||
assert err is None
|
||||
assert status == 200
|
||||
assert result.user.wechat_openid is None
|
||||
assert result.user.wechat_unionid is None
|
||||
assert repo.saved == [user]
|
||||
|
||||
|
||||
def test_unbind_rejected_when_only_random_password_hash():
|
||||
# 微信注册用户:随机密码 hash 存在、邮箱是 @wechat.local 占位、无手机 → 不允许解绑
|
||||
user = _user(
|
||||
wechat_openid="wx-openid-1",
|
||||
password_hash="random-secret-hash",
|
||||
email="abc@wechat.local",
|
||||
email_verified=True,
|
||||
)
|
||||
repo = _FakeRepo([user])
|
||||
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
|
||||
assert result is None
|
||||
assert status == 400
|
||||
assert "登录方式" in err
|
||||
assert user.wechat_openid == "wx-openid-1" # 未写库
|
||||
|
||||
|
||||
def test_unbind_allowed_with_verified_phone_even_without_password():
|
||||
user = _user(
|
||||
wechat_openid="wx-openid-1",
|
||||
password_hash="",
|
||||
phone="13800000000",
|
||||
phone_verified=True,
|
||||
email="wx@wechat.local",
|
||||
email_verified=True,
|
||||
)
|
||||
repo = _FakeRepo([user])
|
||||
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
|
||||
assert err is None
|
||||
assert status == 200
|
||||
assert result.user.wechat_openid is None
|
||||
|
||||
|
||||
def test_unbind_rejected_when_no_other_login_method():
|
||||
# 无手机、邮箱占位 → 唯一登录方式就是微信,禁止解绑
|
||||
user = _user(
|
||||
wechat_openid="wx-openid-1",
|
||||
password_hash="",
|
||||
email="abc@wechat.local",
|
||||
email_verified=True,
|
||||
)
|
||||
repo = _FakeRepo([user])
|
||||
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
|
||||
assert result is None
|
||||
assert status == 400
|
||||
assert "登录方式" in err
|
||||
assert user.wechat_openid == "wx-openid-1" # 未写库
|
||||
|
||||
|
||||
def test_unbind_not_bound_returns_400():
|
||||
user = _user() # 未绑定
|
||||
repo = _FakeRepo([user])
|
||||
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
|
||||
assert result is None
|
||||
assert status == 400
|
||||
assert "未绑定" in err
|
||||
|
||||
|
||||
def test_unbind_user_not_found_returns_404():
|
||||
repo = _FakeRepo([])
|
||||
result, err, status = WechatUnbindUseCase(repo).unbind("ghost")
|
||||
assert result is None
|
||||
assert status == 404
|
||||
|
||||
|
||||
def test_unbind_unverified_phone_does_not_count():
|
||||
# 手机未验证不算有效登录方式
|
||||
user = _user(
|
||||
wechat_openid="wx-openid-1",
|
||||
password_hash="",
|
||||
phone="13800000000",
|
||||
phone_verified=False,
|
||||
email="abc@wechat.local",
|
||||
email_verified=True,
|
||||
)
|
||||
repo = _FakeRepo([user])
|
||||
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
|
||||
assert result is None
|
||||
assert status == 400
|
||||
Reference in New Issue
Block a user