Files
BYW-VoiceHub/server/api/auth/wechat/login.post.ts
T

214 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { db } from '~/drizzle/db'
import { JWTEnhanced } from '~~/server/utils/jwt-enhanced'
import { createAuthSession } from '~~/server/utils/create-auth-session'
import { generateBindingToken } from '~~/server/utils/oauth-token'
import { isUserBlocked, getUserBlockRemainingTime } from '~~/server/services/securityService'
/**
* 微信小程序登录
*
* 与网页端的聚合 OAuth(provider = 'wx')区分开:小程序拿到的 openid
* 是按小程序 AppID 维度生成的,与公众号网页授权的 openid 不通用,
* 因此单独使用 provider = 'wechat-mp' 存储身份。
*
* 请求体:{ code } —— code 来自小程序端 wx.login()
* 响应:
* - 已绑定:{ success: true, needBind: false, token, user }
* - 未绑定:{ success: false, needBind: true, bindToken }
* 客户端接着调用 /api/auth/bind(把 bindToken 放进请求体)完成绑定
*/
const PROVIDER = 'wechat-mp'
interface Jscode2SessionResult {
openid?: string
session_key?: string
unionid?: string
errcode?: number
errmsg?: string
}
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const code = body?.code
if (!code || typeof code !== 'string') {
throw createError({ statusCode: 400, message: '缺少 code 参数' })
}
const appid = process.env.WECHAT_MINI_APPID
const secret = process.env.WECHAT_MINI_SECRET
if (!appid || !secret) {
console.error('[WeChatMP] WECHAT_MINI_APPID / WECHAT_MINI_SECRET 未配置,无法登录')
throw createError({ statusCode: 500, message: '服务器未配置小程序凭据' })
}
// 1. 用 code 换取 openid
let session: any
try {
session = await $fetch<any>('https://api.weixin.qq.com/sns/jscode2session', {
query: { appid, secret, js_code: code, grant_type: 'authorization_code' },
timeout: 8000,
/*
* ★★★ 关键修复(线上「微信登录一直 401」的真正根因)★★★
*
* ofetch 按响应的 **content-type** 决定要不要解析 JSON。
* 微信的 jscode2session 返回的 content-type 不是 application/json,
* 于是 ofetch **原样返回字符串**,`session` 就成了 string 而不是对象 ——
* 结果 session.openid / errcode / errmsg **全是 undefined**,
* 代码走到 `if (!session.openid)` 抛 401,日志打印「undefined undefined」,
* 用户看到「微信登录失败:未知错误(undefined)」。
*
* 实测(容器内直接跑 ofetch):
* GOT type=string "{\"errcode\":40029,\"errmsg\":\"invalid code\"}"
*
* 显式声明 responseType: 'json' 后才会解析成对象。
*/
responseType: 'json'
})
} catch (e: any) {
console.error('[WeChatMP] jscode2session 请求失败:', e?.message)
throw createError({ statusCode: 502, message: '微信登录服务暂时不可用,请稍后重试' })
}
// 兜底:万一某天 responseType 仍没生效(或上游改了 content-type),手动解析一次,
// 不要再让「字符串被当对象用」这种问题静默成 undefined。
if (typeof session === 'string') {
try {
session = JSON.parse(session)
} catch {
console.error('[WeChatMP] 响应无法解析为 JSON:', String(session).slice(0, 300))
session = null
}
}
/*
* ⚠️ 诊断日志(这次是踩坑加的)。
*
* 线上曾出现 `[WeChatMP] jscode2session 返回异常: undefined undefined` ——
* errcode / errmsg **双 undefined**。这不是「appid 错了」:
* 微信出错时会返回 { errcode, errmsg },两个都取不到说明**响应体根本不是微信的 JSON**。
* 最可能是 $fetch 拿回了一段非 JSON 文本(被代理/防火墙拦截返回的 HTML、
* 或重定向页),此时 ofetch 解析失败会把原始字符串返回,于是所有字段都是 undefined。
*
* 所以这里把**响应的真实类型和内容**打出来,否则下次还是只能看到 undefined。
* appid 打前 6 位后 4 位,用于和微信公众平台里的小程序 AppID 核对(两者必须完全一致,
* 且**不能**填公众号的 AppID —— 那套凭据不通用)。
*/
const appidMasked =
appid.length > 10 ? appid.slice(0, 6) + '****' + appid.slice(-4) : appid.slice(0, 3) + '****'
console.error(
'[WeChatMP] 诊断: appid=' + appidMasked +
' secretLen=' + String(secret).length +
' 响应类型=' + typeof session +
' 是否数组=' + Array.isArray(session) +
' 顶层键=' + (session && typeof session === 'object' ? Object.keys(session).join(',') : '(非对象)')
)
console.error(
'[WeChatMP] 诊断: 原始响应=' +
(typeof session === 'string'
? session.slice(0, 500)
: JSON.stringify(session)?.slice(0, 500))
)
// 微信接口失败时仍返回 HTTP 200,必须检查 openid
if (!session || typeof session !== 'object' || !session.openid) {
// 区分「微信明确报错」和「响应体不是微信的 JSON」——两者排查方向完全不同
const notJson = !session || typeof session !== 'object'
const errcode = session && session.errcode
const errmsg = session && session.errmsg
console.error('[WeChatMP] jscode2session 返回异常:', errcode, errmsg, notJson ? '(响应非 JSON)' : '')
const message = notJson
? '微信登录失败:服务器访问微信接口时未收到合法响应(可能被网络策略拦截),请查看服务端日志'
: `微信登录失败:${errmsg || '未知错误(' + (errcode ?? '无错误码') + ')'}`
throw createError({ statusCode: 401, message })
}
const openid = session.openid
// 2. 查询该 openid 是否已绑定到某个账号
const existingIdentity = await db.query.userIdentities.findFirst({
where: (t, { eq, and }) => and(eq(t.provider, PROVIDER), eq(t.providerUserId, openid)),
with: { user: true }
})
const isSecure =
getRequestURL(event).protocol === 'https:' ||
getRequestHeader(event, 'x-forwarded-proto') === 'https'
// 3a. 已绑定 —— 直接登录
if (existingIdentity && existingIdentity.user) {
const user = existingIdentity.user
if (user.status === 'withdrawn') {
throw createError({ statusCode: 403, message: '该账号已注销' })
}
if (user.status === 'graduate') {
throw createError({ statusCode: 403, message: '该账号已毕业,限制访问' })
}
if (isUserBlocked(user.id)) {
const remaining = getUserBlockRemainingTime(user.id)
throw createError({
statusCode: 423,
message: `账户处于风险控制期,请在 ${remaining} 分钟后重试`
})
}
const { token, jti } = JWTEnhanced.generateTokenWithJti(user.id, user.role)
await createAuthSession(event, {
userId: user.id,
jti,
loginMethod: 'oauth',
oauthProvider: PROVIDER
})
setCookie(event, 'auth-token', token, {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7天
path: '/'
})
return {
success: true,
needBind: false,
token,
user: {
id: user.id,
username: user.username,
name: user.name,
grade: user.grade,
class: user.class,
role: user.role,
needsPasswordChange: !user.passwordChangedAt
}
}
}
// 3b. 未绑定 —— 签发绑定令牌,由客户端带着学号密码调用 /api/auth/bind
const bindingToken = generateBindingToken({
provider: PROVIDER,
providerUserId: openid,
providerUsername: '微信用户'
})
// 同时写入 cookie,保证网页端流程也能复用同一套绑定逻辑
setCookie(event, 'binding-token', bindingToken, {
httpOnly: true,
secure: isSecure,
sameSite: 'lax',
maxAge: 60 * 10, // 10分钟
path: '/'
})
return {
success: false,
needBind: true,
provider: PROVIDER,
bindToken: bindingToken
}
})