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: Jscode2SessionResult try { session = await $fetch( 'https://api.weixin.qq.com/sns/jscode2session', { query: { appid, secret, js_code: code, grant_type: 'authorization_code' }, timeout: 8000 } ) } catch (e: any) { console.error('[WeChatMP] jscode2session 请求失败:', e?.message) throw createError({ statusCode: 502, message: '微信登录服务暂时不可用,请稍后重试' }) } // 微信接口失败时仍返回 HTTP 200,必须检查 openid if (!session.openid) { console.error('[WeChatMP] jscode2session 返回异常:', session.errcode, session.errmsg) throw createError({ statusCode: 401, message: `微信登录失败:${session.errmsg || '未知错误(' + session.errcode + ')'}` }) } 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 } })