微信SDK接通

This commit is contained in:
2026-07-01 19:56:44 +08:00
parent a1cbfc7241
commit aea1513836
38 changed files with 1610 additions and 92 deletions
@@ -3,8 +3,10 @@ import type { Request } from 'express';
import { AuthService } from './auth.service';
import {
BindPhoneDto,
BindWechatPhoneDto,
BootstrapSessionDto,
LoginSmsDto,
LoginWechatDto,
RefreshTokenDto,
SendSmsDto,
} from './dto/auth.dto';
@@ -48,13 +50,26 @@ export class UserAuthController {
}
@Post('auth/login/wechat')
wechatLogin() {
return this.authService.wechatDisabled();
@UseGuards(OptionalJwtAuthGuard)
wechatLogin(@Req() req: Request, @Body() dto: LoginWechatDto) {
const guest = (req as Request & { user?: AuthUser }).user;
const guestId = guest?.actorType === 'USER' ? guest.actorId : undefined;
return this.authService.loginUserWechat(
dto.code,
ClientApp.USER_H5,
dto.platform ?? 'h5',
guestId,
);
}
@Post('auth/wechat/bind-phone')
bindPhoneLegacy() {
return this.authService.wechatDisabled();
bindPhoneLegacy(@Body() dto: BindWechatPhoneDto) {
return this.authService.bindWechatPhone(
dto.wxSessionKey,
dto.phone,
dto.code,
ClientApp.USER_H5,
);
}
@Get('auth/me')
@@ -79,8 +94,8 @@ export class ShopAuthController {
}
@Post('login/wechat')
wechatLogin() {
return this.authService.wechatDisabled();
wechatLogin(@Body() dto: LoginWechatDto) {
return this.authService.loginStoreWechat(dto.code, ClientApp.SHOP_H5, dto.platform ?? 'h5');
}
}
@@ -99,8 +114,8 @@ export class PartnerAuthController {
}
@Post('login/wechat')
wechatLogin() {
return this.authService.wechatDisabled();
wechatLogin(@Body() dto: LoginWechatDto) {
return this.authService.loginPartnerWechat(dto.code, ClientApp.PARTNER_H5, dto.platform ?? 'h5');
}
}
@@ -12,13 +12,23 @@ import { JwtService } from '@nestjs/jwt';
import { ClientApp, SmsScene } from '@dukang/shared-types';
import { generateUserNo } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { SMS_PROVIDER } from '../../integrations/integrations.constants';
import { RedisService } from '../../common/redis/redis.service';
import { SMS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants';
import { ISmsProvider } from '../../integrations/sms/sms.interface';
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { Prisma } from '@prisma/client';
import type { User } from '@prisma/client';
type WxSessionPayload = {
openId: string;
unionId?: string;
sessionKey?: string;
clientApp: ClientApp;
guestId?: string;
};
type UserRow = Pick<
User,
| 'id'
@@ -29,6 +39,7 @@ type UserRow = Pick<
| 'nickname'
| 'avatarResourceId'
| 'wxOpenId'
| 'wxUnionId'
| 'mergedIntoUserId'
| 'status'
> & {
@@ -40,7 +51,9 @@ export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly redis: RedisService,
@Inject(SMS_PROVIDER) private readonly smsProvider: ISmsProvider,
@Inject(WECHAT_PROVIDER) private readonly wechatProvider: IWechatProvider,
) {}
async sendSms(phone: string, scene: string) {
@@ -286,6 +299,242 @@ export class AuthService {
throw new NotImplementedException('FEATURE_DISABLED');
}
assertWechatEnabled() {
if (!this.wechatProvider.isEnabled()) {
this.wechatDisabled();
}
}
async loginUserWechat(
code: string,
clientApp: ClientApp,
platform: 'h5' | 'mini' = 'h5',
guestId?: bigint,
) {
this.assertWechatEnabled();
const session =
platform === 'mini'
? await this.wechatProvider.code2Session(code)
: await this.wechatProvider.oauth2AccessToken(code);
let user = await this.prisma.user.findFirst({
where: { wxOpenId: session.openId, status: 1, mergedIntoUserId: null },
include: { avatar: true },
});
if (user?.phone && user.phoneVerifiedAt) {
let activeUser: UserRow =
guestId && guestId !== user.id ? await this.mergeUsers(guestId, user.id) : (user as UserRow);
activeUser = await this.prisma.user.update({
where: { id: activeUser.id },
data: {
wxUnionId: session.unionId ?? activeUser.wxUnionId,
},
include: { avatar: true },
});
return this.buildSessionResponse(activeUser, clientApp, activeUser.deviceKey);
}
const wxSessionKey = randomUUID();
await this.redis.setJson(
`wx:session:${wxSessionKey}`,
{
openId: session.openId,
unionId: session.unionId,
sessionKey: 'sessionKey' in session ? session.sessionKey : undefined,
clientApp,
guestId: guestId?.toString(),
} satisfies WxSessionPayload,
1800,
);
if (user && !user.phoneVerifiedAt) {
return {
needBindPhone: true,
wxSessionKey,
actorType: 'USER',
actorId: user.id.toString(),
phoneVerified: false,
user: this.formatUserProfile(user),
};
}
return {
needBindPhone: true,
wxSessionKey,
phoneVerified: false,
};
}
async bindWechatPhone(
wxSessionKey: string,
phone: string,
code: string,
clientApp: ClientApp,
) {
this.assertWechatEnabled();
await this.smsProvider.verify(phone, code, SmsScene.BIND_PHONE);
const wxSession = await this.redis.getJson<WxSessionPayload>(`wx:session:${wxSessionKey}`);
if (!wxSession) throw new BadRequestException('微信会话已过期,请重新授权');
const guestId = wxSession.guestId ? BigInt(wxSession.guestId) : undefined;
const wxUser = await this.prisma.user.findFirst({
where: { wxOpenId: wxSession.openId, status: 1, mergedIntoUserId: null },
include: { avatar: true },
});
const existingPhone = await this.prisma.user.findUnique({
where: { phone },
include: { avatar: true },
});
let targetUserId: bigint | null = wxUser?.id ?? null;
if (!wxUser && !existingPhone) {
if (guestId) {
try {
const guest = await this.assertActiveUser(guestId);
const updated = await this.prisma.user.update({
where: { id: guest.id },
data: {
phone,
phoneVerifiedAt: new Date(),
wxOpenId: wxSession.openId,
wxUnionId: wxSession.unionId,
nickname: guest.nickname === '访客' ? `用户${phone.slice(-4)}` : guest.nickname,
},
});
targetUserId = updated.id;
} catch {
/* fall through */
}
}
if (!targetUserId) {
const created = await this.prisma.user.create({
data: {
phone,
phoneVerifiedAt: new Date(),
wxOpenId: wxSession.openId,
wxUnionId: wxSession.unionId,
userNo: generateUserNo(),
nickname: `用户${phone.slice(-4)}`,
cityPreference: {
create: {
selectedCityCode: '410100',
selectedDistrict: '郑州市',
},
},
},
});
targetUserId = created.id;
}
} else if (existingPhone) {
await this.assertActiveUser(existingPhone.id);
const updated = await this.prisma.user.update({
where: { id: existingPhone.id },
data: {
wxOpenId: wxSession.openId,
wxUnionId: wxSession.unionId,
phoneVerifiedAt: existingPhone.phoneVerifiedAt ?? new Date(),
},
});
targetUserId = updated.id;
if (guestId && guestId !== updated.id) {
targetUserId = (await this.mergeUsers(guestId, updated.id)).id;
}
} else if (wxUser) {
if (wxUser.phone && wxUser.phone !== phone) {
throw new BadRequestException('手机号已被其他账号占用');
}
const updated = await this.prisma.user.update({
where: { id: wxUser.id },
data: {
phone,
phoneVerifiedAt: new Date(),
wxUnionId: wxSession.unionId ?? wxUser.wxUnionId,
},
});
targetUserId = updated.id;
}
if (!targetUserId) throw new BadRequestException('绑定失败');
const user = await this.assertActiveUser(targetUserId);
await this.redis.del(`wx:session:${wxSessionKey}`);
return this.buildSessionResponse(user, clientApp, user.deviceKey);
}
async loginStoreWechat(code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5') {
this.assertWechatEnabled();
const session =
platform === 'mini'
? await this.wechatProvider.code2Session(code)
: await this.wechatProvider.oauth2AccessToken(code);
let account = await this.prisma.storeAccount.findFirst({
where: { wxOpenId: session.openId },
include: { store: true },
});
if (!account) {
throw new BadRequestException('首次登录请使用手机验证码,登录后将自动关联微信');
}
account = await this.prisma.storeAccount.update({
where: { id: account.id },
data: {
wxOpenId: session.openId,
wxUnionId: session.unionId ?? account.wxUnionId,
lastLoginAt: new Date(),
},
include: { store: true },
});
return this.issueToken('STORE', account.id, clientApp, false, undefined, {
id: account.id.toString(),
storeId: account.storeId.toString(),
name: account.name,
phone: account.phone,
storeName: account.store.name,
});
}
async loginPartnerWechat(code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5') {
this.assertWechatEnabled();
const session =
platform === 'mini'
? await this.wechatProvider.code2Session(code)
: await this.wechatProvider.oauth2AccessToken(code);
let account = await this.prisma.partnerAccount.findFirst({
where: { wxOpenId: session.openId },
include: { partner: true },
});
if (!account) {
throw new BadRequestException('首次登录请使用手机验证码,登录后将自动关联微信');
}
account = await this.prisma.partnerAccount.update({
where: { id: account.id },
data: {
wxOpenId: session.openId,
wxUnionId: session.unionId ?? account.wxUnionId,
lastLoginAt: new Date(),
},
include: { partner: true },
});
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, {
id: account.id.toString(),
partnerId: account.partnerId.toString(),
name: account.name,
phone: account.phone,
isPrimary: account.isPrimary === 1,
companyName: account.partner.companyName,
});
}
private async mergeUsers(guestId: bigint, primaryId: bigint): Promise<UserRow> {
if (guestId === primaryId) {
return this.assertActiveUser(primaryId);
@@ -1,4 +1,4 @@
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class SendSmsDto {
@IsString()
@@ -41,3 +41,28 @@ export class BindPhoneDto {
@IsNotEmpty()
code: string;
}
export class LoginWechatDto {
@IsString()
@IsNotEmpty()
code: string;
@IsString()
@IsIn(['h5', 'mini'])
@IsOptional()
platform?: 'h5' | 'mini';
}
export class BindWechatPhoneDto {
@IsString()
@IsNotEmpty()
wxSessionKey: string;
@IsString()
@IsNotEmpty()
phone: string;
@IsString()
@IsNotEmpty()
code: string;
}