Commit b98979af by chunhong.mu

fix(*): 修正 mini 登录接口名并统一 SDK 错误提取

mini 的验证码登录与密码登录均无法登录:接口名写错,后端返回
HTTP 200 + rescode 9999 + "No signature of method"(不是 404),
在 Network 面板里看起来是成功的,极易误判。

问题来源:从 uniapp-mp-template 移植时带进来一份手写的
packages/mini/src/api/auth.ts,接口名为 snake_case,后端没有这些方法。
而 @common/sdk 里的 createLoginService/createVerifyService 才是正确实现
(admin/web 一直在用)。login.vue 恰好横跨两者——图形验证码与发短信走
SDK(正常),登录走手写实现(失败)。

已用测试后端逐个验证:
- loginWithPassword     返回"用户名或密码错误"(login_with_password  方法不存在)
- loginWithVerifyCode   返回"验证码不正确"(login_with_mobile  方法不存在)
- sendVerifyCode        返回"请输入正确的计算结果"(send_verifycode  方法不存在)
- bind_phone            在 anonymous/system.wechat 下
- 图形验证码不是接口,是同步拼 URL(get_image_code  方法不存在)

改动:
- mini 的 api/auth.ts 改为纯委托 @common/sdk 服务,删除手写实现;
  微信登录接 WechatLoginService,utils/sdk.ts 补 getWechatLoginService()
- 新增 @common/sdk 的 resolveSdkError():SDK 业务错误 reject 的是纯字符串
  (拦截器 Promise.reject(errorMsg)),网络错误才是对象,原先各包写的
  err?.message || "失败" 会把后端真实原因吞成兜底文案。admin/web/mini
  三个包的 auth-service 全部换上
- login.vue 不再直连 SDK:发短信走 services、验证码走 api,符合分层约定;
  图形验证码校验失败时自动换一张
- CLAUDE.md 加两条硬规则,ARCHITECTURE.md 补「登录/验证码接口一律走 SDK
  服务」对照表与「SDK 错误 reject 是字符串」说明

校验:pnpm lint 全绿,4 包 type-check 与 build:test 通过,
mini 产物中确认为 camelCase 接口名

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
parent 527c65d0
......@@ -212,9 +212,46 @@ common/sdk/src/
| `uniAdapter` / `webAdapter` | HTTP 适配器 |
| `TokenManager` | Token 管理(`hasToken` / `saveToken2Storage` / `clearToken` / `setupStorage`) |
| `createLoginService` / `createVerifyService` | 登录与验证码服务工厂 |
| `resolveSdkError` | 从 SDK reject 值中提取错误文案(见下方「错误处理」) |
| `buildImage` / `buildFilePath` | 图片/文件 URL 构建 |
| `webMonitor` | 监控上报(需先 `setupEndpoint` 配置地址,未配置则不上报) |
#### 登录/验证码接口:一律走 SDK 服务,不要自己拼
平台的通行证接口名是 **camelCase**,且都挂在 `uniplat_base` + `anonymous/system.user` 下:
| 功能 | 正确接口名 | 常见误写 |
|------|-----------|---------|
| 密码登录 | `loginWithPassword` | ~~`login_with_password`~~ |
| 验证码登录 | `loginWithVerifyCode` | ~~`login_with_mobile`~~ |
| 发送短信验证码 | `sendVerifyCode` | ~~`send_verifycode`~~ |
| 微信绑定手机号登录 | `bind_phone`(在 `anonymous/system.wechat` 下) | — |
| 图形验证码 | **不是接口**`generateImage()` 同步拼图片 URL | ~~`get_image_code`~~ |
⚠️ 接口名写错时,后端返回的是 **HTTP 200 + `rescode: 9999` + `No signature of method: ...`**,不是 404。很容易被误判成参数问题或鉴权问题,排查时先确认接口名。
因此**不要在业务包里手写 `domainServicePost("login_with_password", ...)` 这类调用**,统一用 `createLoginService` / `createVerifyService` / `WechatLoginService`,接口名收敛在 `@common/sdk` 内部的枚举里。
#### 错误处理:SDK 的业务错误 reject 出来是字符串
uniplat-sdk 的响应拦截器:
- **业务错误**(HTTP 200 但 `rescode !== 0`)→ `Promise.reject(errorMsg)`,reject 值是**纯字符串**
- **网络/HTTP 错误**`Promise.reject(error.response.data || error)`,reject 值是**对象**
所以业务代码里写 `err?.message || "失败"` 会在最常见的业务错误分支上拿不到内容,把后端返回的真实原因(如「用户名或密码错误」)吞成兜底文案。统一用:
```typescript
import { resolveSdkError } from "@common/sdk"
try {
await passwordLogin(username, password)
}
catch (err) {
Toast.error(resolveSdkError(err, "登录失败"))
}
```
**导入约定**(重要):
```typescript
......
......@@ -84,6 +84,8 @@ types/ 类型定义
- 业务代码不要直接用 `import.meta.env`,一律从 `config`
- `api/` 里不操作 store、不跳路由,那是 `services/` 的事
- 登录成功必须同时做三件事:持久化 Token(`TokenManager.saveToken2Storage`)、同步 SDK 登录态(`sdk.core.loginByToken`)、写 store。已封装在各包 `services/auth-service.ts` 里,不要绕过
- **登录/验证码类接口不要自己拼 `domainService`**,一律走 `@common/sdk``createLoginService` / `createVerifyService` / `WechatLoginService`。平台接口名是 **camelCase**`loginWithPassword` / `loginWithVerifyCode` / `sendVerifyCode`),写成 snake_case 后端会返回 `rescode: 9999` + `No signature of method`,而不是 404,很容易误判成参数问题
- **SDK 的业务错误 reject 出来的是纯字符串**(拦截器里 `Promise.reject(errorMsg)`),网络错误才是对象。统一用 `resolveSdkError(err, "兜底文案")` 提取,不要写 `err?.message || "失败"`——那样会把后端真实原因吞掉
- `@common/sdk` 根入口不导出小程序模块,小程序专属能力从 `@common/sdk/mini-program` 引入(避免 Web 包引入 `uni.*` 类型)
- `@common/sdk` 里不要写死任何域名/appid/client id,需要环境相关值就加到 `SdkConfig`
......
/**
* SDK 错误信息提取
*
* uniplat-sdk 的响应拦截器对「业务错误」(HTTP 200 但 rescode !== 0)是
* `Promise.reject(errorMsg)` —— reject 出来的是**纯字符串**,不是 Error;
* 对「网络/HTTP 错误」则是 `Promise.reject(error.response.data || error)`,
* 此时是对象(可能带 msg / message)。
*
* 因此业务代码里常见的 `err?.message || "失败"` 会在最常见的业务错误分支上
* 拿不到内容,把后端返回的真实原因(如「用户名或密码错误」)吞掉。统一用本
* 方法提取。
*/
export function resolveSdkError(err: unknown, fallback = "请求失败"): string {
if (typeof err === "string") {
return err || fallback
}
if (err && typeof err === "object") {
const e = err as Record<string, any>
return e.msg || e.message || e.error || fallback
}
return fallback
}
// 错误信息提取
export { resolveSdkError } from "./error"
// 图片/文件 URL 构建
export { buildFilePath, buildImage } from "./image-builder"
......
......@@ -3,6 +3,9 @@ export { uniAdapter } from "./adapters/uni-adapter"
export { webAdapter } from "./adapters/web-adapter"
// 错误信息提取
export { resolveSdkError } from "./core/error"
// 图片/文件 URL 构建
export { buildFilePath, buildImage } from "./core/image-builder"
......
import { resolveSdkError } from '@common/sdk'
import { passwordLogin, sendSmsCode, smsLogin } from '@/api/auth'
import { sdk } from '@/api/request'
import { useAuthStore } from '@/stores/auth'
......@@ -27,7 +28,7 @@ export async function handleSendSmsCode(
return true
}
catch (e: any) {
Toast.error(e?.message || '发送失败')
Toast.error(resolveSdkError(e, '发送失败'))
return false
}
}
......@@ -44,7 +45,7 @@ export async function handleSmsLogin(
return true
}
catch (e: any) {
Toast.error(e?.message || '登录失败')
Toast.error(resolveSdkError(e, '登录失败'))
return false
}
}
......@@ -61,7 +62,7 @@ export async function handlePasswordLogin(
return true
}
catch (e: any) {
Toast.error(e?.message || '登录失败')
Toast.error(resolveSdkError(e, '登录失败'))
return false
}
}
import { config } from "@/config"
import { getSdk } from "@/utils/sdk"
import type {
LoginRegisterResult,
RegisterChannel,
VerifyImageResult,
} from "@common/sdk"
import {
getLoginService,
getVerifyService,
getWechatLoginService,
} from "@/utils/sdk"
export interface LoginResult {
jwt: string
user_id?: string
mobile?: string
export type { LoginRegisterResult, RegisterChannel, VerifyImageResult }
/**
* 获取图形验证码
*
* 注意:这是同步方法,SDK 只负责拼出图片 URL,实际加载由 `<image>` 完成。
* 因此拿不到验证码时不会 reject,需要判断 `img` 是否为空 + 监听 `<image>`
* 的 error 事件。
*/
export function getVerifyImage(): VerifyImageResult {
return getVerifyService().generateImage()
}
export interface RegisterChannel {
app?: string
scene?: string
channel?: string
app_store?: string
[key: string]: any
/**
* 发送短信验证码
*
* @param mobile 手机号
* @param seed 图形验证码的 seed(来自 getVerifyImage)
* @param imgCode 用户填写的图形验证码
*/
export function sendSmsCode(mobile: string, seed: string, imgCode: string) {
return getVerifyService().sendVerifyCode(mobile, seed, imgCode)
}
/**
* 获取微信登录 code
* 短信验证码登录
*/
export function smsLogin(
mobile: string,
verifycode: string,
registerChannel?: RegisterChannel,
) {
return getLoginService().verifyCodeLogin(mobile, verifycode, registerChannel)
}
/**
* 密码登录
*/
export function passwordLogin(username: string, password: string) {
return getLoginService().login(username, password)
}
/**
* 获取微信登录 code(uni API,非 HTTP 请求)
*/
export function getWxLoginCode(): Promise<string> {
return new Promise((resolve, reject) => {
......@@ -32,112 +68,17 @@ export function getWxLoginCode(): Promise<string> {
* 微信小程序绑定手机号登录
*/
export function miniProgramBindPhone(
appid: string,
code: string,
encryptedData: string,
iv: string,
registerChannel: RegisterChannel = {},
): Promise<LoginResult> {
return getSdk().domainServicePost<any, any, LoginResult>(
"bind_phone",
{
data: {
appid: config.mpAppid,
) {
return getWechatLoginService().miniProgramBindPhone(
appid,
code,
client_id: config.clientId,
encryptedData,
iv,
...registerChannel,
},
},
true, // 匿名接口
"uniplat_base",
"system.wechat",
config,
)
}
/**
* 发送短信验证码
*/
export function sendSmsCode(mobile: string, imgCode: string): Promise<void> {
return getSdk().domainServicePost<any, any, void>(
"send_verifycode",
{
data: {
mobile,
img_code: imgCode,
client_id: config.clientId,
},
},
true,
"uniplat_base",
"anonymous/system.user",
config,
)
}
/**
* 短信验证码登录
*/
export function smsLogin(
mobile: string,
verifycode: string,
registerChannel: RegisterChannel = {},
): Promise<LoginResult> {
return getSdk().domainServicePost<any, any, LoginResult>(
"login_with_mobile",
{
data: {
mobile,
verifycode,
client_id: config.clientId,
...registerChannel,
},
},
true,
"uniplat_base",
"anonymous/system.user",
config,
)
}
/**
* 密码登录
*/
export function passwordLogin(
username: string,
password: string,
registerChannel: RegisterChannel = {},
): Promise<LoginResult> {
return getSdk().domainServicePost<any, any, LoginResult>(
"login_with_password",
{
data: {
username,
password,
client_id: config.clientId,
...registerChannel,
},
},
true,
"uniplat_base",
"anonymous/system.user",
config,
)
}
/**
* 获取图形验证码
*/
export function getImageCode(): Promise<string> {
return getSdk()
.domainServiceGet<any, { img_code: string }>(
"get_image_code",
{},
true,
"uniplat_base",
"system.user",
config,
registerChannel,
)
.then(res => res.img_code)
}
export {
getImageCode,
getVerifyImage,
getWxLoginCode,
miniProgramBindPhone,
passwordLogin,
......@@ -7,7 +7,11 @@ export {
smsLogin,
} from "./auth"
export type { LoginResult, RegisterChannel } from "./auth"
export type {
LoginRegisterResult,
RegisterChannel,
VerifyImageResult,
} from "./auth"
export { fetchHomeData } from "./home"
......
......@@ -154,11 +154,16 @@ import { SMS_COUNTDOWN } from "@common/utils/constants"
import { isPhone } from "@common/utils/regex"
import { onLoad } from "@dcloudio/uni-app"
import { computed, reactive, ref } from "vue"
import { getVerifyImage } from "@/api"
import { useToast } from "@/composables"
import { handlePasswordLogin, handleSmsLogin } from "@/services"
import { getVerifyService } from "@/utils/sdk"
import {
handlePasswordLogin,
handleSendSmsCode,
handleSmsLogin,
} from "@/services"
const { error: toastError, success: toastSuccess } = useToast()
// 成功提示统一由 services 层发出,这里只处理页面级校验提示
const { error: toastError } = useToast()
const activeTab = ref<"sms" | "password">("sms")
......@@ -194,7 +199,7 @@ const imageCodeFailed = ref(false)
// 同步拼 URL,加载失败由 <image> error 事件兜住
function getVerifyCodeImg() {
imageCodeFailed.value = false
imageCodeModel.value = getVerifyService().generateImage()
imageCodeModel.value = getVerifyImage()
if (!imageCodeModel.value.img) {
imageCodeFailed.value = true
toastError("获取图形验证码失败,请检查 VITE_APP_UNIPLAT 配置")
......@@ -243,17 +248,21 @@ async function getCode() {
}
uni.showLoading({ title: "正在获取验证码", mask: true })
try {
await getVerifyService().sendVerifyCode(
const ok = await handleSendSmsCode(
smsModel.mobile,
imageCodeModel.value?.seed || "",
smsModel.imgCode,
)
uni.hideLoading()
toastSuccess("验证码已发送")
if (ok) {
startCodeCountdown()
} catch (err: any) {
}
else {
// 图形验证码错误时刷新一张新的
getVerifyCodeImg()
}
}
finally {
uni.hideLoading()
toastError(err?.msg || err?.message || "发送失败")
}
}
......
import { TokenManager } from "@common/sdk"
import type { LoginRegisterResult, RegisterChannel } from "@common/sdk"
import { resolveSdkError, TokenManager } from "@common/sdk"
import {
getWxLoginCode,
type LoginResult,
miniProgramBindPhone,
passwordLogin,
type RegisterChannel,
sendSmsCode,
smsLogin,
} from "@/api"
import { useToast } from "@/composables"
......@@ -14,6 +14,28 @@ import { getSdk } from "@/utils/sdk"
const { success, error } = useToast()
/**
* 发送短信验证码
*
* @param mobile 手机号
* @param seed 图形验证码的 seed
* @param imgCode 用户填写的图形验证码
*/
export async function handleSendSmsCode(
mobile: string,
seed: string,
imgCode: string,
): Promise<boolean> {
try {
await sendSmsCode(mobile, seed, imgCode)
success("验证码已发送")
return true
} catch (err: any) {
error(resolveSdkError(err, "发送失败"))
return false
}
}
/** 短信验证码登录 */
export async function handleSmsLogin(
mobile: string,
......@@ -25,8 +47,8 @@ export async function handleSmsLogin(
saveLoginResult(result)
success("登录成功")
return true
} catch {
error("登录失败,请检查手机号与验证码")
} catch (err: any) {
error(resolveSdkError(err, "登录失败"))
return false
}
}
......@@ -35,15 +57,14 @@ export async function handleSmsLogin(
export async function handlePasswordLogin(
username: string,
password: string,
registerChannel: RegisterChannel = config.registerConfig || {},
): Promise<boolean> {
try {
const result = await passwordLogin(username, password, registerChannel)
const result = await passwordLogin(username, password)
saveLoginResult(result)
success("登录成功")
return true
} catch {
error("登录失败,请检查账号密码")
} catch (err: any) {
error(resolveSdkError(err, "登录失败"))
return false
}
}
......@@ -57,6 +78,7 @@ export async function handleWxLogin(
try {
const code = await getWxLoginCode()
const result = await miniProgramBindPhone(
config.mpAppid || "",
code,
encryptedData,
iv,
......@@ -65,8 +87,8 @@ export async function handleWxLogin(
saveLoginResult(result)
success("登录成功")
return true
} catch {
error("微信登录失败")
} catch (err: any) {
error(resolveSdkError(err, "微信登录失败"))
return false
}
}
......@@ -77,7 +99,7 @@ export function handleLogout() {
useUserInfo().clearUserInfo()
}
function saveLoginResult(result: LoginResult) {
function saveLoginResult(result: LoginRegisterResult) {
// 持久化 Token,并同步到 SDK 的登录态
TokenManager.saveToken2Storage(result.jwt)
getSdk().core.loginByToken({ token: result.jwt })
......
export {
handleLogout,
handlePasswordLogin,
handleSendSmsCode,
handleSmsLogin,
handleWxLogin,
} from "./auth-service"
import { Sdk as SdkBase, TokenManager, uniAdapter } from "@common/sdk"
import { WechatLoginService } from "@common/sdk/mini-program"
import { createLoginService } from "@common/sdk/services/login-service"
import { createVerifyService } from "@common/sdk/services/verify-service"
import { config } from "@/config"
......@@ -47,6 +48,7 @@ class Sdk extends SdkBase {
let sdkInstance: Sdk | null = null
let loginServiceInstance: ReturnType<typeof createLoginService> | null = null
let verifyServiceInstance: ReturnType<typeof createVerifyService> | null = null
let wechatLoginServiceInstance: WechatLoginService | null = null
/**
* 初始化 SDK(在 main.ts 的 createApp 中调用,幂等)
......@@ -58,6 +60,7 @@ export function initSdk() {
sdkInstance = new Sdk()
loginServiceInstance = createLoginService(sdkInstance.core, sdkInstance.clientId)
verifyServiceInstance = createVerifyService(sdkInstance.core, sdkInstance.clientId)
wechatLoginServiceInstance = new WechatLoginService(sdkInstance)
}
export function getSdk() {
......@@ -80,3 +83,10 @@ export function getVerifyService() {
}
return verifyServiceInstance!
}
export function getWechatLoginService() {
if (!wechatLoginServiceInstance) {
initSdk()
}
return wechatLoginServiceInstance!
}
import { resolveSdkError } from '@common/sdk'
import { passwordLogin, sendSmsCode, smsLogin } from '@/api/auth'
import { sdk } from '@/api/request'
import { useAuthStore } from '@/stores/auth'
......@@ -21,7 +22,7 @@ export async function handleSendSmsCode(
return true
}
catch (e: any) {
Toast.error(e?.message || '发送失败')
Toast.error(resolveSdkError(e, '发送失败'))
return false
}
}
......@@ -38,7 +39,7 @@ export async function handleSmsLogin(
return true
}
catch (e: any) {
Toast.error(e?.message || '登录失败')
Toast.error(resolveSdkError(e, '登录失败'))
return false
}
}
......@@ -55,7 +56,7 @@ export async function handlePasswordLogin(
return true
}
catch (e: any) {
Toast.error(e?.message || '登录失败')
Toast.error(resolveSdkError(e, '登录失败'))
return false
}
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment