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('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 —— 那套凭据不通用)。 * * ★★★ 安全修复(第 97 轮)★★★ * 原来这里把**整个响应 JSON 前 500 字**打进日志: * * console.error('[WeChatMP] 诊断: 原始响应=' + JSON.stringify(session)?.slice(0, 500)) * * 而 jscode2session 的成功响应形如: * { session_key: '...', openid: '...', unionid: '...' } * * **`session_key` 是敏感凭据** —— 有了它可以解密用户数据、配合其他接口换取手机号。 * 它出现在服务端日志里(日志会被备份、被运维翻看、可能进日志平台), * 等于把用户凭据长期落盘。这**不是性能问题,是安全问题**,必须去掉。 * * 现在只打印**故障定位真正需要**的东西: * · 顶层键名(判断"是不是微信的 JSON"足够了,不含值) * · errcode / errmsg(微信的报错码) * · openid / session_key / unionid 的**存在性与长度**(不打印值) * · 响应非对象时,才打印截断后的原始文本(此时它不是合法凭据) */ const appidMasked = appid.length > 10 ? appid.slice(0, 6) + '****' + appid.slice(-4) : appid.slice(0, 3) + '****' const maskLen = (v: unknown) => (typeof v === 'string' && v ? '有(len=' + v.length + ')' : '无') 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] 诊断: openid=' + maskLen(session?.openid) + ' session_key=' + maskLen(session?.session_key) + ' unionid=' + maskLen(session?.unionid) + ' errcode=' + (session?.errcode ?? '(无)') + ' errmsg=' + (session?.errmsg ?? '(无)') ) // 只有"响应根本不是对象"时才打印原文 —— 那种情况下它不可能是合法凭据, // 而且是排查"被代理拦成 HTML"唯一有用的信息。 if (!session || typeof session !== 'object') { console.error( '[WeChatMP] 诊断: 响应非对象,原文前 300 字=' + (typeof session === 'string' ? session.slice(0, 300) : String(session)) ) } // 微信接口失败时仍返回 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 } })