This commit is contained in:
2026-04-10 15:37:02 +08:00
parent 161cd32277
commit f19e482c8f
233 changed files with 43987 additions and 5127 deletions

View File

@@ -0,0 +1,182 @@
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;
}): string;
resolveCallbackProfile(params: {
code?: string | null;
mockCode?: string | null;
}): Promise<WechatIdentityProfile>;
};
class MockWechatAuthService implements WechatAuthService {
constructor(private readonly config: AppConfig['wechatAuth']) {}
buildAuthorizationUrl(params: {
callbackUrl: string;
state: string;
}) {
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;
}) {
const url = new URL(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', '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);
}