微信授权逻辑修改

This commit is contained in:
2026-07-06 20:56:25 +08:00
parent b12ee13232
commit 9d221cfcd2
12 changed files with 475 additions and 28 deletions
@@ -149,6 +149,51 @@ export class WechatApiProvider implements IWechatProvider {
};
}
async fetchOAuthUserInfo(
accessToken: string,
openId: string,
actorRef?: WechatActorRef,
): Promise<import('./wechat.interface').WechatOAuthUserInfo> {
const maskedUrl = new URL('https://api.weixin.qq.com/sns/userinfo');
maskedUrl.searchParams.set('access_token', '***');
maskedUrl.searchParams.set('openid', openId);
maskedUrl.searchParams.set('lang', 'zh_CN');
const apiUrl = new URL('https://api.weixin.qq.com/sns/userinfo');
apiUrl.searchParams.set('access_token', accessToken);
apiUrl.searchParams.set('openid', openId);
apiUrl.searchParams.set('lang', 'zh_CN');
const data = await this.fetchJson<{
openid?: string;
nickname?: string;
headimgurl?: string;
unionid?: string;
errcode?: number;
errmsg?: string;
}>(apiUrl.toString());
const ok = !!data.openid;
await logWechatAuth(this.prisma, {
scene: 'USERINFO',
requestUrl: maskedUrl.toString(),
requestBody: { lang: 'zh_CN' },
responseBody: ok
? { openid: data.openid, nickname: data.nickname, unionid: data.unionid }
: { errcode: data.errcode, errmsg: data.errmsg },
externalNo: data.openid ?? openId,
status: ok ? 'SUCCESS' : 'FAILED',
errorMessage: ok ? undefined : data.errmsg || '微信用户信息获取失败',
actorRef,
});
if (!data.openid) {
throw new InternalServerErrorException(data.errmsg || '微信用户信息获取失败');
}
return {
openId: data.openid,
nickname: data.nickname,
headImgUrl: data.headimgurl,
unionId: data.unionid,
};
}
async createJssdkConfig(url: string, actorRef?: WechatActorRef) {
try {
const ticket = await this.getJsapiTicket();
@@ -27,6 +27,10 @@ export class WechatDisabledProvider implements IWechatProvider {
return this.disabled();
}
fetchOAuthUserInfo() {
return this.disabled();
}
createJssdkConfig() {
return this.disabled();
}
@@ -14,6 +14,13 @@ export type WechatOAuthSession = {
refreshToken?: string;
};
export type WechatOAuthUserInfo = {
openId: string;
nickname?: string;
headImgUrl?: string;
unionId?: string;
};
export type WechatPayNotifyResult = {
transactionId: string;
outTradeNo: string;
@@ -36,6 +43,13 @@ export interface IWechatProvider {
/** 公众号 H5 OAuth code 换 openId */
oauth2AccessToken(code: string, actorRef?: { refType: string; refId: bigint }): Promise<WechatOAuthSession>;
/** 公众号 OAuth access_token 拉取用户昵称头像(snsapi_userinfo */
fetchOAuthUserInfo(
accessToken: string,
openId: string,
actorRef?: { refType: string; refId: bigint },
): Promise<WechatOAuthUserInfo>;
/** JSSDK 签名配置 */
createJssdkConfig(url: string, actorRef?: { refType: string; refId: bigint }): Promise<WechatJssdkConfig>;
@@ -3,6 +3,7 @@ import type { Request } from 'express';
import { AuthService } from './auth.service';
import {
BindPhoneDto,
BindWechatDto,
BindWechatPhoneDto,
BootstrapSessionDto,
LoginSmsDto,
@@ -78,6 +79,17 @@ export class UserAuthController {
);
}
@Post('auth/wechat/bind')
@UseGuards(JwtAuthGuard)
bindWechat(@CurrentUser() user: AuthUser, @Body() dto: BindWechatDto) {
return this.authService.bindUserWechat(
user.actorId,
{ code: dto.code, wxSessionKey: dto.wxSessionKey },
ClientApp.USER_H5,
dto.platform ?? 'h5',
);
}
@Get('auth/me')
@UseGuards(JwtAuthGuard)
me(@CurrentUser() user: AuthUser) {
@@ -28,6 +28,7 @@ type WxSessionPayload = {
openId: string;
unionId?: string;
sessionKey?: string;
accessToken?: string;
clientApp: ClientApp;
guestId?: string;
};
@@ -512,6 +513,10 @@ export class AuthService {
},
include: { avatar: true },
});
if (session.accessToken) {
const synced = await this.syncWechatUserProfile(activeUser.id, session.accessToken, session.openId);
if (synced) activeUser = synced as UserRow;
}
this.analyticsService.trackOneSafe(activeUser.id, clientApp, {
eventName: 'wechat_login',
extraJson: { platform },
@@ -523,6 +528,18 @@ export class AuthService {
return this.buildSessionResponse(activeUser, clientApp, activeUser.deviceKey);
}
if (guestId) {
try {
const guest = await this.assertActiveUser(guestId);
if (guest.phoneVerifiedAt && !guest.wxOpenId) {
const activeUser = await this.attachWechatToUser(guest.id, session, clientApp, platform);
return this.buildSessionResponse(activeUser, clientApp, activeUser.deviceKey);
}
} catch (e) {
if (e instanceof BadRequestException) throw e;
}
}
const wxSessionKey = randomUUID();
await this.redis.setJson(
`wx:session:${wxSessionKey}`,
@@ -530,6 +547,7 @@ export class AuthService {
openId: session.openId,
unionId: session.unionId,
sessionKey: 'sessionKey' in session ? session.sessionKey : undefined,
accessToken: session.accessToken,
clientApp,
guestId: guestId?.toString(),
} satisfies WxSessionPayload,
@@ -647,6 +665,9 @@ export class AuthService {
}
if (!targetUserId) throw new BadRequestException('绑定失败');
if (wxSession.accessToken) {
await this.syncWechatUserProfile(targetUserId, wxSession.accessToken, wxSession.openId);
}
const user = await this.assertActiveUser(targetUserId);
await this.redis.del(`wx:session:${wxSessionKey}`);
this.trackSmsUserEvent(user.id, clientApp, 'bind_phone', {
@@ -664,6 +685,53 @@ export class AuthService {
return this.buildSessionResponse(user, clientApp, user.deviceKey);
}
async bindUserWechat(
userId: bigint,
input: { code?: string; wxSessionKey?: string },
clientApp: ClientApp,
platform: 'h5' | 'mini' = 'h5',
) {
this.assertWechatEnabled();
if (!input.code && !input.wxSessionKey) {
throw new BadRequestException('请提供微信授权 code 或会话');
}
let openId: string;
let unionId: string | undefined;
let accessToken: string | undefined;
const actorRef = { refType: 'USER', refId: userId };
if (input.code) {
const session =
platform === 'mini'
? await this.wechatProvider.code2Session(input.code, actorRef)
: await this.wechatProvider.oauth2AccessToken(input.code, actorRef);
openId = session.openId;
unionId = session.unionId;
accessToken = session.accessToken;
} else {
const wxSession = await this.redis.getJson<WxSessionPayload>(`wx:session:${input.wxSessionKey}`);
if (!wxSession) throw new BadRequestException('微信会话已过期,请重新授权');
openId = wxSession.openId;
unionId = wxSession.unionId;
accessToken = wxSession.accessToken;
await this.redis.del(`wx:session:${input.wxSessionKey}`);
}
const user = await this.attachWechatToUser(
userId,
{ openId, unionId, accessToken },
clientApp,
platform,
{ skipLoginEvents: true },
);
this.analyticsService.trackOneSafe(userId, clientApp, {
eventName: 'wechat_bind',
extraJson: { platform },
});
return this.buildSessionResponse(user, clientApp, user.deviceKey);
}
async loginStoreWechat(code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5') {
this.assertWechatEnabled();
const session =
@@ -871,6 +939,113 @@ export class AuthService {
return this.issueToken('USER', user.id, clientApp, phoneVerified, this.formatUserProfile(user), undefined, undefined, deviceKey);
}
private isDefaultNickname(nickname: string | null | undefined) {
if (!nickname || nickname === '访客') return true;
return /^用户\d{4}$/.test(nickname);
}
private async syncWechatUserProfile(
userId: bigint,
accessToken: string,
openId: string,
): Promise<UserRow | null> {
try {
const info = await this.wechatProvider.fetchOAuthUserInfo(accessToken, openId, {
refType: 'USER',
refId: userId,
});
const current = await this.prisma.user.findUnique({
where: { id: userId },
include: { avatar: true },
});
if (!current) return null;
const data: {
nickname?: string;
avatarResourceId?: bigint;
} = {};
if (info.nickname && this.isDefaultNickname(current.nickname)) {
data.nickname = info.nickname;
}
if (info.headImgUrl && !current.avatarResourceId) {
const avatar = await this.prisma.commonResource.create({
data: {
ownerType: 'USER',
ownerId: userId,
bizType: 'AVATAR',
mediaType: 'IMAGE',
ossBucket: 'wechat',
ossKey: `wx-avatar/${openId}`,
url: info.headImgUrl,
status: 'ACTIVE',
},
});
data.avatarResourceId = avatar.id;
}
if (!data.nickname && !data.avatarResourceId) {
return current as UserRow;
}
return this.prisma.user.update({
where: { id: userId },
data,
include: { avatar: true },
});
} catch {
return null;
}
}
private async attachWechatToUser(
userId: bigint,
session: { openId: string; unionId?: string; accessToken?: string },
clientApp: ClientApp,
platform: string,
options?: { skipLoginEvents?: boolean },
): Promise<UserRow> {
const conflict = await this.prisma.user.findFirst({
where: {
wxOpenId: session.openId,
id: { not: userId },
status: 1,
mergedIntoUserId: null,
},
});
if (conflict) {
throw new BadRequestException('该微信已绑定其他账号');
}
let user = (await this.prisma.user.update({
where: { id: userId },
data: {
wxOpenId: session.openId,
wxUnionId: session.unionId ?? undefined,
},
include: { avatar: true },
})) as UserRow;
if (session.accessToken) {
const synced = await this.syncWechatUserProfile(userId, session.accessToken, session.openId);
if (synced) user = synced as UserRow;
}
if (!options?.skipLoginEvents) {
this.analyticsService.trackOneSafe(userId, clientApp, {
eventName: 'wechat_login',
extraJson: { platform },
});
this.analyticsService.trackOneSafe(userId, clientApp, {
eventName: 'login_success',
extraJson: { method: 'wechat', platform },
});
}
return user;
}
private formatUserProfile(user: UserRow) {
return {
id: user.id.toString(),
@@ -68,3 +68,18 @@ export class BindWechatPhoneDto {
@IsNotEmpty()
code: string;
}
export class BindWechatDto {
@IsString()
@IsOptional()
code?: string;
@IsString()
@IsOptional()
wxSessionKey?: string;
@IsString()
@IsIn(['h5', 'mini'])
@IsOptional()
platform?: 'h5' | 'mini';
}