import type { Logger } from 'pino'; import type { AppConfig } from '../config.js'; import { badRequest, upstreamError } from '../errors.js'; export type WechatIdentityProfile = { providerUid: string; providerUnionId: string | null; displayName: string | null; avatarUrl: string | null; metaJson: Record | null; }; export type WechatAuthService = { buildAuthorizationUrl(params: { callbackUrl: string; state: string; userAgent?: string | null; }): string; resolveCallbackProfile(params: { code?: string | null; mockCode?: string | null; }): Promise; }; type WechatAuthorizationScene = 'desktop' | 'wechat_in_app'; const WECHAT_IN_APP_AUTHORIZE_ENDPOINT = 'https://open.weixin.qq.com/connect/oauth2/authorize'; function isWechatBrowser(userAgent?: string | null) { return /MicroMessenger/iu.test(userAgent ?? ''); } function isMobileBrowser(userAgent?: string | null) { return /Android|iPhone|iPad|iPod|Mobile/iu.test(userAgent ?? ''); } function resolveWechatAuthorizationScene( userAgent?: string | null, ): WechatAuthorizationScene { if (isWechatBrowser(userAgent)) { return 'wechat_in_app'; } if (isMobileBrowser(userAgent)) { throw badRequest('当前浏览器请使用手机号登录,或在微信内打开后再使用微信登录'); } return 'desktop'; } class MockWechatAuthService implements WechatAuthService { constructor(private readonly config: AppConfig['wechatAuth']) {} buildAuthorizationUrl(params: { callbackUrl: string; state: string; userAgent?: string | null; }) { const callbackUrl = new URL(params.callbackUrl); callbackUrl.searchParams.set('mock_code', this.config.mockUserId); callbackUrl.searchParams.set('state', params.state); return callbackUrl.toString(); } async resolveCallbackProfile(params: { mockCode?: string | null; }) { const mockCode = params.mockCode?.trim() || this.config.mockUserId; return { providerUid: mockCode, providerUnionId: this.config.mockUnionId || null, displayName: this.config.mockDisplayName || '微信旅人', avatarUrl: this.config.mockAvatarUrl || null, metaJson: { mockCode, }, } satisfies WechatIdentityProfile; } } class RealWechatAuthService implements WechatAuthService { constructor( private readonly config: AppConfig['wechatAuth'], private readonly logger: Logger, ) { if (!config.appId || !config.appSecret) { throw new Error('WECHAT_APP_ID 或 WECHAT_APP_SECRET 未配置'); } } buildAuthorizationUrl(params: { callbackUrl: string; state: string; userAgent?: string | null; }) { const scene = resolveWechatAuthorizationScene(params.userAgent); const url = new URL( scene === 'wechat_in_app' ? WECHAT_IN_APP_AUTHORIZE_ENDPOINT : this.config.authorizeEndpoint, ); url.searchParams.set('appid', this.config.appId); url.searchParams.set('redirect_uri', params.callbackUrl); url.searchParams.set('response_type', 'code'); url.searchParams.set( 'scope', scene === 'wechat_in_app' ? 'snsapi_userinfo' : 'snsapi_login', ); url.searchParams.set('state', params.state); return `${url.toString()}#wechat_redirect`; } async resolveCallbackProfile(params: { code?: string | null; }) { const code = params.code?.trim(); if (!code) { throw badRequest('缺少微信授权 code'); } try { const accessTokenUrl = new URL(this.config.accessTokenEndpoint); accessTokenUrl.searchParams.set('appid', this.config.appId); accessTokenUrl.searchParams.set('secret', this.config.appSecret); accessTokenUrl.searchParams.set('code', code); accessTokenUrl.searchParams.set('grant_type', 'authorization_code'); const accessTokenResponse = await fetch(accessTokenUrl.toString()); const accessTokenPayload = (await accessTokenResponse.json()) as Record; if (!accessTokenResponse.ok || typeof accessTokenPayload.openid !== 'string') { throw new Error( typeof accessTokenPayload.errmsg === 'string' ? accessTokenPayload.errmsg : 'failed to exchange code', ); } const accessToken = typeof accessTokenPayload.access_token === 'string' ? accessTokenPayload.access_token : ''; const openId = accessTokenPayload.openid; const fallbackUnionId = typeof accessTokenPayload.unionid === 'string' ? accessTokenPayload.unionid : null; if (!accessToken) { throw new Error('missing access_token'); } const userInfoUrl = new URL(this.config.userInfoEndpoint); userInfoUrl.searchParams.set('access_token', accessToken); userInfoUrl.searchParams.set('openid', openId); userInfoUrl.searchParams.set('lang', 'zh_CN'); const userInfoResponse = await fetch(userInfoUrl.toString()); const userInfoPayload = (await userInfoResponse.json()) as Record; if (!userInfoResponse.ok || typeof userInfoPayload.openid !== 'string') { throw new Error( typeof userInfoPayload.errmsg === 'string' ? userInfoPayload.errmsg : 'failed to fetch user info', ); } return { providerUid: userInfoPayload.openid, providerUnionId: typeof userInfoPayload.unionid === 'string' ? userInfoPayload.unionid : fallbackUnionId, displayName: typeof userInfoPayload.nickname === 'string' ? userInfoPayload.nickname : null, avatarUrl: typeof userInfoPayload.headimgurl === 'string' ? userInfoPayload.headimgurl : null, metaJson: userInfoPayload, } satisfies WechatIdentityProfile; } catch (error) { this.logger.error({ err: error }, 'wechat auth callback failed'); throw upstreamError( error instanceof Error ? `微信登录失败:${error.message}` : '微信登录失败', ); } } } export function createWechatAuthService( config: AppConfig, logger: Logger, ): WechatAuthService { if (!config.wechatAuth.enabled) { return { buildAuthorizationUrl() { throw badRequest('微信登录暂未启用'); }, async resolveCallbackProfile() { throw badRequest('微信登录暂未启用'); }, }; } if (config.wechatAuth.provider === 'mock') { return new MockWechatAuthService(config.wechatAuth); } return new RealWechatAuthService(config.wechatAuth, logger); }