微信授权逻辑修改
This commit is contained in:
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user