Files
dukang/server/dukang-api/src/modules/iam/auth.service.ts
T
2026-07-06 20:56:25 +08:00

1095 lines
35 KiB
TypeScript

import { randomUUID } from 'crypto';
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
NotFoundException,
NotImplementedException,
UnauthorizedException,
} from '@nestjs/common';
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 { RedisService } from '../../common/redis/redis.service';
import { SMS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants';
import { ISmsProvider } from '../../integrations/sms/sms.interface';
import type { SmsActorRef } from '../../integrations/sms/sms.interface';
import { SmsCodeStore } from '../../integrations/sms/sms-code.store';
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AnalyticsService } from '../analytics/analytics.service';
import { UserAddressService } from './user-address.service';
import type { User } from '@prisma/client';
type WxSessionPayload = {
openId: string;
unionId?: string;
sessionKey?: string;
accessToken?: string;
clientApp: ClientApp;
guestId?: string;
};
type UserRow = Pick<
User,
| 'id'
| 'userNo'
| 'deviceKey'
| 'phone'
| 'phoneVerifiedAt'
| 'nickname'
| 'avatarResourceId'
| 'wxOpenId'
| 'wxUnionId'
| 'mergedIntoUserId'
| 'status'
> & {
avatar?: { url: string } | null;
};
@Injectable()
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,
private readonly analyticsService: AnalyticsService,
private readonly smsCodeStore: SmsCodeStore,
private readonly userAddressService: UserAddressService,
) {}
private assertMobilePhone(phone: string) {
const trimmed = phone.trim();
if (!/^1[3-9]\d{9}$/.test(trimmed)) {
throw new BadRequestException('请输入正确的手机号码');
}
return trimmed;
}
private maskPhone(phone: string) {
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
}
private clientAppForScene(scene: string): ClientApp {
switch (scene) {
case SmsScene.STORE_LOGIN:
return ClientApp.SHOP_H5;
case SmsScene.PARTNER_LOGIN:
case SmsScene.PARTNER_STAFF_ADD:
return ClientApp.PARTNER_H5;
case SmsScene.HQ_LOGIN:
return ClientApp.HQ_WEB;
default:
return ClientApp.USER_H5;
}
}
private async resolveSmsActorRef(
phone: string,
scene: string,
guestUserId?: bigint,
): Promise<SmsActorRef | undefined> {
switch (scene) {
case SmsScene.USER_LOGIN:
case SmsScene.BIND_PHONE: {
if (guestUserId) return { refType: 'USER', refId: guestUserId };
const user = await this.prisma.user.findUnique({
where: { phone },
select: { id: true },
});
return user ? { refType: 'USER', refId: user.id } : undefined;
}
case SmsScene.STORE_LOGIN: {
const account = await this.prisma.storeAccount.findUnique({
where: { phone },
select: { id: true },
});
return account ? { refType: 'STORE', refId: account.id } : undefined;
}
case SmsScene.PARTNER_LOGIN:
case SmsScene.PARTNER_STAFF_ADD: {
const account = await this.prisma.partnerAccount.findUnique({
where: { phone },
select: { id: true },
});
return account ? { refType: 'PARTNER', refId: account.id } : undefined;
}
case SmsScene.HQ_LOGIN: {
const account = await this.prisma.hqAccount.findUnique({
where: { phone },
select: { id: true },
});
return account ? { refType: 'HQ', refId: account.id } : undefined;
}
default:
return undefined;
}
}
private trackSmsUserEvent(
userId: bigint | undefined,
clientApp: ClientApp | string,
eventName: string,
extraJson: Record<string, unknown>,
thirdPartyLogId?: bigint,
) {
if (!userId) return;
this.analyticsService.trackOneSafe(userId, clientApp, {
eventName,
refType: thirdPartyLogId ? 'THIRD_PARTY_LOG' : undefined,
refId: thirdPartyLogId,
extraJson,
});
}
private async verifySmsForUser(
phone: string,
code: string,
scene: SmsScene.USER_LOGIN | SmsScene.BIND_PHONE,
clientApp: ClientApp,
userId?: bigint,
) {
try {
await this.smsProvider.verify(phone, code, scene);
} catch (err) {
if (err instanceof BadRequestException) {
this.trackSmsUserEvent(userId, clientApp, 'sms_verify_fail', {
scene,
phone: this.maskPhone(phone),
reason: err.message,
});
}
throw err;
}
}
async sendSms(
phone: string,
scene: string,
opts?: { guestUserId?: bigint; clientApp?: ClientApp },
) {
const normalizedPhone = this.assertMobilePhone(phone);
if (!Object.values(SmsScene).includes(scene as SmsScene)) {
throw new BadRequestException('无效的验证码场景');
}
const clientApp = opts?.clientApp ?? this.clientAppForScene(scene);
const actorRef = await this.resolveSmsActorRef(normalizedPhone, scene, opts?.guestUserId);
const userId = actorRef?.refType === 'USER' ? actorRef.refId : opts?.guestUserId;
await this.smsCodeStore.assertSendCooldown(normalizedPhone);
try {
const result = await this.smsProvider.send(normalizedPhone, scene, actorRef);
await this.smsCodeStore.setSendCooldown(normalizedPhone);
this.trackSmsUserEvent(
userId,
clientApp,
'sms_send',
{
scene,
phone: this.maskPhone(normalizedPhone),
status: result.ok ? 'success' : 'failed',
...(result.errorMessage ? { message: result.errorMessage } : {}),
},
result.logId,
);
if (!result.ok) {
throw new BadRequestException(result.errorMessage ?? '短信发送失败');
}
} catch (err) {
if (err instanceof BadRequestException) throw err;
const message = err instanceof Error ? err.message : '短信发送失败';
this.trackSmsUserEvent(userId, clientApp, 'sms_send', {
scene,
phone: this.maskPhone(normalizedPhone),
status: 'failed',
message,
});
throw new BadRequestException(message);
}
return { sent: true };
}
async bootstrapSession(deviceKey: string | undefined, clientApp: ClientApp) {
let user: UserRow | null = null;
let resolvedDeviceKey = deviceKey?.trim() || null;
if (resolvedDeviceKey) {
user = await this.prisma.user.findFirst({
where: {
deviceKey: resolvedDeviceKey,
status: 1,
mergedIntoUserId: null,
},
include: { avatar: true },
});
}
if (!user) {
resolvedDeviceKey = randomUUID();
user = await this.prisma.user.create({
data: {
userNo: generateUserNo(),
deviceKey: resolvedDeviceKey,
nickname: '访客',
cityPreference: {
create: {
selectedCityCode: '410100',
selectedDistrict: '郑州市',
},
},
},
include: { avatar: true },
});
}
return this.buildSessionResponse(user, clientApp, resolvedDeviceKey);
}
async refreshAccessToken(refreshToken: string, clientApp: ClientApp) {
try {
const payload = this.jwtService.verify(refreshToken);
if (payload.clientApp !== clientApp || payload.actorType !== 'USER') {
throw new UnauthorizedException('Invalid refresh token');
}
const user = await this.assertActiveUser(BigInt(payload.actorId));
return this.buildSessionResponse(user, clientApp, user.deviceKey);
} catch (err) {
if (err instanceof UnauthorizedException) throw err;
throw new UnauthorizedException('Invalid refresh token');
}
}
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
const normalizedPhone = this.assertMobilePhone(phone);
const existingUser = await this.prisma.user.findUnique({
where: { phone: normalizedPhone },
select: { id: true },
});
await this.verifySmsForUser(
normalizedPhone,
code,
SmsScene.USER_LOGIN,
clientApp,
guestId ?? existingUser?.id,
);
let user: UserRow | null = await this.prisma.user.findUnique({
where: { phone: normalizedPhone },
include: { avatar: true },
});
if (!user) {
if (guestId) {
try {
const guest = await this.assertActiveUser(guestId);
if (!guest.phone) {
user = await this.prisma.user.update({
where: { id: guestId },
data: {
phone: normalizedPhone,
phoneVerifiedAt: new Date(),
nickname: guest.nickname === '访客' ? `用户${normalizedPhone.slice(-4)}` : guest.nickname,
},
include: { avatar: true },
});
}
} catch {
/* guest invalid, fall through to create */
}
}
if (!user) {
user = await this.prisma.user.create({
data: {
phone: normalizedPhone,
phoneVerifiedAt: new Date(),
userNo: generateUserNo(),
nickname: `用户${normalizedPhone.slice(-4)}`,
cityPreference: {
create: {
selectedCityCode: '410100',
selectedDistrict: '郑州市',
},
},
},
include: { avatar: true },
});
}
} else {
if (!user.phoneVerifiedAt) {
user = await this.prisma.user.update({
where: { id: user.id },
data: { phoneVerifiedAt: new Date() },
include: { avatar: true },
});
}
if (guestId && guestId !== user.id) {
user = await this.mergeUsers(guestId, user.id);
} else {
await this.assertActiveUser(user.id);
}
}
if (!user) throw new BadRequestException('登录失败');
this.analyticsService.trackOneSafe(user.id, clientApp, {
eventName: 'sms_login',
extraJson: { method: 'sms' },
});
this.analyticsService.trackOneSafe(user.id, clientApp, {
eventName: 'login_success',
extraJson: { method: 'sms' },
});
return this.buildSessionResponse(user, clientApp, user.deviceKey);
}
async bindPhone(actorId: bigint, phone: string, code: string, clientApp: ClientApp) {
const normalizedPhone = this.assertMobilePhone(phone);
await this.verifySmsForUser(normalizedPhone, code, SmsScene.BIND_PHONE, clientApp, actorId);
const guest = await this.assertActiveUser(actorId);
if (guest.phone && guest.phoneVerifiedAt) {
if (guest.phone === normalizedPhone) {
return this.buildSessionResponse(guest, clientApp, guest.deviceKey);
}
throw new BadRequestException('当前账号已绑定其他手机号');
}
const existing = await this.prisma.user.findUnique({ where: { phone: normalizedPhone } });
let targetUser: UserRow;
if (!existing) {
targetUser = await this.prisma.user.update({
where: { id: guest.id },
data: {
phone: normalizedPhone,
phoneVerifiedAt: new Date(),
nickname: guest.nickname === '访客' ? `用户${normalizedPhone.slice(-4)}` : guest.nickname,
},
include: { avatar: true },
});
} else {
await this.assertActiveUser(existing.id);
if (existing.id === guest.id) {
targetUser = existing;
} else {
targetUser = await this.mergeUsers(guest.id, existing.id);
}
}
this.trackSmsUserEvent(targetUser.id, clientApp, 'bind_phone', {
phone: this.maskPhone(normalizedPhone),
});
return this.buildSessionResponse(targetUser, clientApp, targetUser.deviceKey);
}
async loginStore(phone: string, code: string, clientApp: ClientApp) {
const normalizedPhone = this.assertMobilePhone(phone);
await this.smsProvider.verify(normalizedPhone, code, SmsScene.STORE_LOGIN);
const account = await this.prisma.storeAccount.findUnique({
where: { phone: normalizedPhone },
include: { store: true },
});
if (!account) throw new BadRequestException('门店账号不存在');
await this.prisma.storeAccount.update({
where: { id: account.id },
data: { lastLoginAt: new Date() },
});
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 loginPartner(phone: string, code: string, clientApp: ClientApp) {
const normalizedPhone = this.assertMobilePhone(phone);
await this.smsProvider.verify(normalizedPhone, code, SmsScene.PARTNER_LOGIN);
const account = await this.prisma.partnerAccount.findUnique({
where: { phone: normalizedPhone },
include: { partner: true },
});
if (!account) throw new BadRequestException('合伙人账号不存在');
await this.prisma.partnerAccount.update({
where: { id: account.id },
data: { lastLoginAt: new Date() },
});
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,
});
}
async loginHq(phone: string, code: string, clientApp: ClientApp) {
const normalizedPhone = this.assertMobilePhone(phone);
await this.smsProvider.verify(normalizedPhone, code, SmsScene.HQ_LOGIN);
const account = await this.prisma.hqAccount.findUnique({ where: { phone: normalizedPhone } });
if (!account) throw new BadRequestException('HQ账号不存在');
if (account.status !== 'ACTIVE') throw new BadRequestException('账号已停用');
await this.prisma.hqAccount.update({
where: { id: account.id },
data: { lastLoginAt: new Date() },
});
return this.issueToken('HQ', account.id, clientApp, false, undefined, undefined, undefined, undefined, {
id: account.id.toString(),
phone: account.phone,
name: account.name,
adminRole: account.adminRole,
status: account.status,
});
}
async getMe(actorType: string, actorId: bigint) {
if (actorType === 'USER') {
const user = await this.assertActiveUser(actorId);
return this.formatUserProfile(user);
}
if (actorType === 'STORE') {
const account = await this.prisma.storeAccount.findUnique({
where: { id: actorId },
include: { store: true },
});
return serializeBigInt(account);
}
if (actorType === 'PARTNER') {
const account = await this.prisma.partnerAccount.findUnique({
where: { id: actorId },
include: { partner: true },
});
return serializeBigInt(account);
}
if (actorType === 'HQ') {
const account = await this.prisma.hqAccount.findUnique({ where: { id: actorId } });
return serializeBigInt(account);
}
return null;
}
wechatDisabled() {
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);
const 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 },
});
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 },
});
this.analyticsService.trackOneSafe(activeUser.id, clientApp, {
eventName: 'login_success',
extraJson: { method: 'wechat', platform },
});
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}`,
{
openId: session.openId,
unionId: session.unionId,
sessionKey: 'sessionKey' in session ? session.sessionKey : undefined,
accessToken: session.accessToken,
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();
const wxSession = await this.redis.getJson<WxSessionPayload>(`wx:session:${wxSessionKey}`);
if (!wxSession) throw new BadRequestException('微信会话已过期,请重新授权');
const guestId = wxSession.guestId ? BigInt(wxSession.guestId) : undefined;
await this.verifySmsForUser(phone, code, SmsScene.BIND_PHONE, clientApp, guestId);
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('绑定失败');
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', {
phone: this.maskPhone(phone),
method: 'wechat',
});
this.analyticsService.trackOneSafe(user.id, clientApp, {
eventName: 'wechat_phone',
extraJson: { method: 'bind_phone' },
});
this.analyticsService.trackOneSafe(user.id, clientApp, {
eventName: 'login_success',
extraJson: { method: 'wechat_bind' },
});
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 =
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 bindPartnerWechat(
partnerAccountId: bigint,
code: string,
clientApp: ClientApp,
platform: 'h5' | 'mini' = 'h5',
) {
this.assertWechatEnabled();
const session =
platform === 'mini'
? await this.wechatProvider.code2Session(code)
: await this.wechatProvider.oauth2AccessToken(code);
const account = await this.prisma.partnerAccount.findUnique({
where: { id: partnerAccountId },
include: { partner: true },
});
if (!account) throw new BadRequestException('合伙人账号不存在');
const conflict = await this.prisma.partnerAccount.findFirst({
where: { wxOpenId: session.openId, id: { not: partnerAccountId } },
});
if (conflict) {
throw new BadRequestException('该微信已绑定其他合伙人账号');
}
const updated = await this.prisma.partnerAccount.update({
where: { id: partnerAccountId },
data: {
wxOpenId: session.openId,
wxUnionId: session.unionId ?? account.wxUnionId,
lastLoginAt: new Date(),
},
include: { partner: true },
});
return this.issueToken('PARTNER', updated.id, clientApp, false, undefined, undefined, {
id: updated.id.toString(),
partnerId: updated.partnerId.toString(),
name: updated.name,
phone: updated.phone,
isPrimary: updated.isPrimary === 1,
companyName: updated.partner.companyName,
});
}
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);
}
await this.prisma.$transaction(async (tx) => {
const guest = await tx.user.findUnique({ where: { id: guestId } });
const primary = await tx.user.findUnique({ where: { id: primaryId } });
if (!guest || guest.mergedIntoUserId || guest.status !== 1) {
throw new BadRequestException('访客账号无效');
}
if (!primary || primary.mergedIntoUserId || primary.status !== 1) {
throw new BadRequestException('目标账号无效');
}
await tx.order.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
await tx.userAddress.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
await tx.benefitCoupon.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
await tx.commonEvent.updateMany({
where: { actorType: 'USER', actorId: guestId },
data: { actorId: primaryId },
});
await tx.redeemRecord.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
await tx.logUserAnalytics.updateMany({ where: { userId: guestId }, data: { userId: primaryId } });
const primaryPref = await tx.userCityPreference.findUnique({ where: { userId: primaryId } });
const guestPref = await tx.userCityPreference.findUnique({ where: { userId: guestId } });
if (!primaryPref && guestPref) {
await tx.userCityPreference.update({
where: { userId: guestId },
data: { userId: primaryId },
});
} else if (guestPref) {
await tx.userCityPreference.delete({ where: { userId: guestId } });
}
const guestPromo = await tx.userPromoAttribution.findUnique({ where: { userId: guestId } });
if (guestPromo) {
const primaryPromo = await tx.userPromoAttribution.findUnique({ where: { userId: primaryId } });
if (primaryPromo) {
await tx.userPromoAttribution.delete({ where: { userId: guestId } });
} else {
await tx.userPromoAttribution.update({
where: { userId: guestId },
data: { userId: primaryId },
});
}
}
const deviceKeyToTransfer =
guest.deviceKey && !primary.deviceKey ? guest.deviceKey : null;
if (deviceKeyToTransfer) {
// 先清空访客 deviceKey,避免 uk_user_user_device_key 冲突
await tx.user.update({ where: { id: guestId }, data: { deviceKey: null } });
await tx.user.update({ where: { id: primaryId }, data: { deviceKey: deviceKeyToTransfer } });
}
await tx.user.update({
where: { id: guestId },
data: {
mergedIntoUserId: primaryId,
status: 0,
deviceKey: null,
},
});
});
await this.userAddressService.normalizeDefaultAddress(primaryId);
return this.assertActiveUser(primaryId);
}
private async assertActiveUser(userId: bigint): Promise<UserRow> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: { avatar: true },
});
if (!user) throw new NotFoundException('用户不存在');
if (user.mergedIntoUserId) {
throw new UnauthorizedException('账号已合并,请重新进入');
}
if (user.status !== 1) {
throw new ForbiddenException('账号已停用');
}
return user;
}
private buildSessionResponse(user: UserRow, clientApp: ClientApp, deviceKey: string | null) {
const phoneVerified = !!user.phoneVerifiedAt;
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(),
userNo: user.userNo,
phone: user.phone ? user.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : null,
phoneVerified: !!user.phoneVerifiedAt,
nickname: user.nickname,
avatarUrl: user.avatar?.url ?? null,
hasWechat: !!user.wxOpenId,
};
}
private issueToken(
actorType: string,
actorId: bigint,
clientApp: ClientApp,
phoneVerified: boolean,
user?: Record<string, unknown>,
store?: Record<string, unknown>,
partner?: Record<string, unknown>,
deviceKey?: string | null,
hq?: Record<string, unknown>,
) {
const payload = {
sub: actorId.toString(),
actorType,
actorId: actorId.toString(),
clientApp,
phoneVerified,
};
const accessToken = this.jwtService.sign(payload);
const refreshToken = this.jwtService.sign(payload, { expiresIn: '30d' });
return {
accessToken,
refreshToken,
deviceKey: deviceKey ?? undefined,
actorType,
actorId: actorId.toString(),
phoneVerified,
user,
store,
partner,
hq,
};
}
}