Files
Genarrative/server-node/src/services/wechatAuthService.ts
高物 0981d6ee1b
Some checks failed
CI / verify (push) Has been cancelled
1
2026-04-11 15:43:32 +08:00

221 lines
6.6 KiB
TypeScript

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<string, unknown> | null;
};
export type WechatAuthService = {
buildAuthorizationUrl(params: {
callbackUrl: string;
state: string;
userAgent?: string | null;
}): string;
resolveCallbackProfile(params: {
code?: string | null;
mockCode?: string | null;
}): Promise<WechatIdentityProfile>;
};
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<string, unknown>;
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<string, unknown>;
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);
}