Files
dukang/server/dukang-api/src/modules/iam/auth.service.ts
T
jacy bdf80e577b
CI / verify (pull_request) Has been cancelled
fix(mini-user): address review blockers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 13:03:02 +08:00

2009 lines
66 KiB
TypeScript

import { randomUUID } from 'crypto';
import {
BadRequestException,
ForbiddenException,
forwardRef,
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 { verifyPassword } from '../../common/crypto/password.util';
import { AnalyticsService } from '../analytics/analytics.service';
import { UserAddressService } from './user-address.service';
import { ResourceService } from '../common/resource.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,
@Inject(forwardRef(() => ResourceService)) private readonly resourceService: ResourceService,
) {}
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.STORE_ACCOUNT_OPEN:
return ClientApp.HQ_WEB;
case SmsScene.PARTNER_LOGIN:
case SmsScene.PARTNER_STAFF_ADD:
case SmsScene.PARTNER_STORE_OPEN:
return ClientApp.PARTNER_H5;
case SmsScene.HQ_LOGIN:
return ClientApp.HQ_WEB;
case SmsScene.REDEEM_PHONE_LOOKUP:
case SmsScene.REDEEM_PHONE_CONFIRM:
return ClientApp.SHOP_H5;
case SmsScene.PARTNER_PROXY_ORDER:
return ClientApp.PARTNER_H5;
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_STORE_OPEN: {
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;
}
case SmsScene.REDEEM_PHONE_LOOKUP:
case SmsScene.REDEEM_PHONE_CONFIRM: {
const user = await this.prisma.user.findFirst({
where: { phone, mergedIntoUserId: null, status: 1 },
select: { id: true },
});
return user ? { refType: 'USER', refId: user.id } : undefined;
}
case SmsScene.PARTNER_PROXY_ORDER: {
const user = await this.prisma.user.findFirst({
where: { phone, mergedIntoUserId: null, status: 1 },
select: { id: true },
});
return user ? { refType: 'USER', refId: user.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 trackStoreEvent(
storeAccountId: bigint | undefined,
storeId: bigint | undefined,
clientApp: ClientApp | string,
eventName: string,
extraJson?: Record<string, unknown>,
ref?: { refType?: string; refId?: bigint },
) {
if (storeId == null) return;
this.analyticsService.trackStoreOneSafe(storeAccountId, clientApp, {
storeId,
eventName,
refType: ref?.refType,
refId: ref?.refId,
extraJson,
});
}
private trackPartnerEvent(
actorAccountId: bigint | undefined,
primaryAccountId: bigint,
clientApp: ClientApp | string,
eventName: string,
extraJson?: Record<string, unknown>,
ref?: { refType?: string; refId?: bigint },
) {
this.analyticsService.trackPartnerOneSafe(actorAccountId, clientApp, {
partnerAccountId: primaryAccountId,
eventName,
refType: ref?.refType,
refId: ref?.refId,
extraJson,
});
}
private async resolvePrimaryAccount(accountId: bigint) {
const account = await this.prisma.partnerAccount.findUnique({ where: { id: accountId } });
if (!account) throw new NotFoundException('合伙人账号不存在');
if (account.isPrimary === 1) return account;
if (!account.parentAccountId) {
throw new BadRequestException('子账号缺少主账号');
}
return this.prisma.partnerAccount.findUniqueOrThrow({ where: { id: account.parentAccountId } });
}
private partnerTokenPayload(
account: {
id: bigint;
name: string;
phone: string;
isPrimary: number;
staffRole: string | null;
permissions?: unknown;
wxOpenId?: string | null;
wxNickname?: string | null;
wxAvatarUrl?: string | null;
},
primary: { id: bigint; companyName: string | null },
) {
return {
id: account.id.toString(),
primaryAccountId: primary.id.toString(),
name: account.name,
phone: account.phone,
isPrimary: account.isPrimary === 1,
staffRole: account.staffRole ?? undefined,
companyName: primary.companyName ?? undefined,
permissions: Array.isArray(account.permissions) ? account.permissions : undefined,
hasWechat: !!account.wxOpenId,
wxNickname: account.wxNickname ?? undefined,
wxAvatarUrl: account.wxAvatarUrl ?? undefined,
};
}
/** 公众号 snsapi_userinfo:写入合伙人微信昵称与头像 */
private async syncPartnerWechatProfile(
partnerAccountId: bigint,
accessToken: string | undefined,
openId: string,
) {
if (!accessToken) return null;
try {
const info = await this.wechatProvider.fetchOAuthUserInfo(accessToken, openId, {
refType: 'PARTNER',
refId: partnerAccountId,
});
const data: { wxNickname?: string; wxAvatarUrl?: string } = {};
if (info.nickname?.trim()) data.wxNickname = info.nickname.trim().slice(0, 64);
if (info.headImgUrl?.trim()) data.wxAvatarUrl = info.headImgUrl.trim().slice(0, 512);
if (!data.wxNickname && !data.wxAvatarUrl) return null;
return this.prisma.partnerAccount.update({
where: { id: partnerAccountId },
data,
});
} catch {
return null;
}
}
private async assertPartnerAccountByPhone(phone: string) {
const account = await this.prisma.partnerAccount.findUnique({
where: { phone },
});
if (!account) throw new BadRequestException('未找到合伙人账号');
if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
return account;
}
async checkPartnerPhone(phone: string) {
const normalizedPhone = this.assertMobilePhone(phone);
const account = await this.assertPartnerAccountByPhone(normalizedPhone);
const primary = await this.resolvePrimaryAccount(account.id);
return {
ok: true,
maskedPhone: this.maskPhone(normalizedPhone),
name: account.name,
companyName: primary.companyName,
hasWechat: !!account.wxOpenId,
};
}
private async assertSmsSendAllowed(phone: string, scene: SmsScene) {
if (scene === SmsScene.STORE_LOGIN) {
const account = await this.prisma.storeAccount.findUnique({ where: { phone } });
if (!account) throw new BadRequestException('该手机号未绑定门店');
if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用');
return;
}
if (scene === SmsScene.STORE_ACCOUNT_OPEN) {
const existing = await this.prisma.storeAccount.findUnique({ where: { phone } });
if (existing) throw new BadRequestException('该手机号已绑定门店');
return;
}
if (scene === SmsScene.PARTNER_LOGIN) {
await this.assertPartnerAccountByPhone(phone);
return;
}
if (scene === SmsScene.PARTNER_STAFF_ADD) {
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } });
if (existing) throw new BadRequestException('该手机号已被使用');
return;
}
if (scene === SmsScene.REDEEM_PHONE_LOOKUP || scene === SmsScene.REDEEM_PHONE_CONFIRM) {
const user = await this.prisma.user.findFirst({
where: { phone, mergedIntoUserId: null, status: 1 },
select: { id: true, phoneVerifiedAt: true },
});
if (!user) throw new BadRequestException('该手机号未注册好客用户');
if (!user.phoneVerifiedAt) throw new BadRequestException('用户手机号未验证,无法核销');
return;
}
if (scene === SmsScene.PARTNER_PROXY_ORDER) {
return;
}
if (scene === SmsScene.PARTNER_STORE_OPEN) {
return;
}
}
async verifySmsCode(phone: string, code: string, scene: SmsScene) {
const normalizedPhone = this.assertMobilePhone(phone);
await this.smsProvider.verify(normalizedPhone, code, scene);
}
/** 合伙人代下单:按手机号查找或创建已验证用户 */
async findOrCreateUserByPhone(phone: string) {
const normalizedPhone = this.assertMobilePhone(phone);
let user = await this.prisma.user.findUnique({
where: { phone: normalizedPhone },
include: { avatar: true },
});
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 },
});
}
await this.assertActiveUser(user.id);
}
return user;
}
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('无效的验证码场景');
}
await this.assertSmsSendAllowed(normalizedPhone, scene as SmsScene);
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 ?? '短信发送失败');
}
if (scene === SmsScene.STORE_LOGIN && actorRef?.refType === 'STORE') {
const storeAccount = await this.prisma.storeAccount.findUnique({
where: { id: actorRef.refId },
select: {
id: true,
bindings: { select: { storeId: true }, take: 1 },
},
});
if (storeAccount) {
this.trackStoreEvent(
storeAccount.id,
storeAccount.bindings[0]?.storeId,
clientApp,
'store_sms_send',
{
scene,
phone: this.maskPhone(normalizedPhone),
status: 'success',
},
);
}
}
if (
(scene === SmsScene.PARTNER_LOGIN || scene === SmsScene.PARTNER_STAFF_ADD) &&
actorRef?.refType === 'PARTNER'
) {
const partnerAccount = await this.prisma.partnerAccount.findUnique({
where: { id: actorRef.refId },
select: { id: true, isPrimary: true, parentAccountId: true },
});
if (partnerAccount) {
const primary = await this.resolvePrimaryAccount(partnerAccount.id);
this.trackPartnerEvent(
partnerAccount.id,
primary.id,
clientApp,
'partner_sms_send',
{
scene,
phone: this.maskPhone(normalizedPhone),
status: 'success',
},
);
}
}
} 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) {
throw new UnauthorizedException('Invalid refresh token');
}
if (payload.actorType === 'USER') {
const user = await this.assertActiveUser(BigInt(payload.actorId));
return this.buildSessionResponse(user, clientApp, user.deviceKey);
}
if (payload.actorType === 'STORE' && clientApp === ClientApp.SHOP_H5) {
const storeId =
payload.storeId != null && payload.storeId !== ''
? BigInt(payload.storeId)
: undefined;
return this.buildStoreSessionResponse(BigInt(payload.actorId), clientApp, storeId);
}
if (payload.actorType === 'PARTNER' && clientApp === ClientApp.PARTNER_H5) {
return this.buildPartnerSessionResponse(BigInt(payload.actorId), clientApp);
}
throw new UnauthorizedException('Invalid refresh token');
} catch (err) {
if (err instanceof UnauthorizedException) throw err;
throw new UnauthorizedException('Invalid refresh token');
}
}
private async loadStoreAccountWithBindings(accountId: bigint) {
return this.prisma.storeAccount.findUnique({
where: { id: accountId },
include: {
bindings: {
include: {
store: {
select: {
id: true,
name: true,
status: true,
district: true,
address: true,
},
},
},
orderBy: { createdAt: 'asc' },
},
},
});
}
private mapShopStoreOptions(
bindings: Array<{
store: {
id: bigint;
name: string;
status: string;
district: string;
address: string;
};
}>,
) {
return bindings.map((b) => ({
storeId: b.store.id.toString(),
name: b.store.name,
status: b.store.status,
district: b.store.district,
address: b.store.address,
}));
}
private formatShopAccountMe(account: {
id: bigint;
name: string;
phone: string;
isPrimary: number;
staffRole: string | null;
permissions: unknown;
parentAccountId: bigint | null;
wxOpenId: string | null;
}) {
const permissions = Array.isArray(account.permissions)
? (account.permissions as string[])
: undefined;
return {
id: account.id.toString(),
name: account.name,
phone: account.phone,
isPrimary: account.isPrimary === 1,
staffRole: account.staffRole,
permissions,
primaryAccountId: account.parentAccountId?.toString(),
hasWechat: !!account.wxOpenId,
};
}
private async buildStoreSessionResponse(
accountId: bigint,
clientApp: ClientApp,
preferredStoreId?: bigint,
) {
const account = await this.loadStoreAccountWithBindings(accountId);
if (!account || account.status !== 'ACTIVE') {
throw new UnauthorizedException('Invalid refresh token');
}
return this.issueStoreSession(account, clientApp, preferredStoreId);
}
private async issueStoreSession(
account: NonNullable<Awaited<ReturnType<AuthService['loadStoreAccountWithBindings']>>>,
clientApp: ClientApp,
preferredStoreId?: bigint,
options?: { autoSelectSingle?: boolean },
) {
const stores = this.mapShopStoreOptions(account.bindings);
const autoSelect = options?.autoSelectSingle !== false;
let selectedStoreId = preferredStoreId;
if (selectedStoreId != null) {
const ok = account.bindings.some((b) => b.store.id === selectedStoreId);
if (!ok) throw new ForbiddenException('无权访问该门店');
} else if (autoSelect && stores.length === 1) {
selectedStoreId = BigInt(stores[0].storeId);
}
const selected = selectedStoreId
? account.bindings.find((b) => b.store.id === selectedStoreId)?.store
: undefined;
const accountMe = this.formatShopAccountMe(account);
const storePayload = {
id: account.id.toString(),
storeId: selected?.id.toString() ?? '',
name: account.name,
phone: account.phone,
storeName: selected?.name ?? '',
isPrimary: account.isPrimary === 1,
stores,
};
return this.issueToken(
'STORE',
account.id,
clientApp,
false,
undefined,
storePayload,
undefined,
undefined,
undefined,
selectedStoreId,
{
account: accountMe,
stores,
store: selected
? {
storeId: selected.id.toString(),
name: selected.name,
status: selected.status,
district: selected.district,
address: selected.address,
}
: null,
selectedStoreId: selectedStoreId?.toString(),
},
);
}
async listShopStores(accountId: bigint) {
const account = await this.loadStoreAccountWithBindings(accountId);
if (!account || account.status !== 'ACTIVE') {
throw new UnauthorizedException('门店账号无效');
}
return this.mapShopStoreOptions(account.bindings);
}
async selectShopStore(accountId: bigint, storeId: bigint, clientApp: ClientApp) {
const account = await this.loadStoreAccountWithBindings(accountId);
if (!account || account.status !== 'ACTIVE') {
throw new UnauthorizedException('门店账号无效');
}
const binding = account.bindings.find((b) => b.store.id === storeId);
if (!binding) throw new ForbiddenException('无权访问该门店');
this.trackStoreEvent(account.id, storeId, clientApp, 'store_select');
return this.issueStoreSession(account, clientApp, storeId, { autoSelectSingle: false });
}
async getShopMe(user: { actorId: bigint; storeId?: bigint }) {
const account = await this.loadStoreAccountWithBindings(user.actorId);
if (!account) throw new NotFoundException('门店账号不存在');
const stores = this.mapShopStoreOptions(account.bindings);
const selected = user.storeId
? account.bindings.find((b) => b.store.id === user.storeId)?.store
: undefined;
const accountMe = this.formatShopAccountMe(account);
return {
account: accountMe,
stores,
store: selected
? {
storeId: selected.id.toString(),
name: selected.name,
status: selected.status,
district: selected.district,
address: selected.address,
}
: null,
// legacy flat fields for older clients
id: account.id.toString(),
storeId: selected?.id.toString() ?? '',
name: account.name,
phone: account.phone,
};
}
private async buildPartnerSessionResponse(accountId: bigint, clientApp: ClientApp) {
const account = await this.prisma.partnerAccount.findUnique({
where: { id: accountId },
});
if (!account || account.status !== 'ACTIVE') {
throw new UnauthorizedException('Invalid refresh token');
}
const primary = await this.resolvePrimaryAccount(account.id);
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(account, primary));
}
/** 手机号已验证后建号/登录并签发会话(短信登录与微信手机号快捷登录共用) */
private async issueUserSessionByVerifiedPhone(
normalizedPhone: string,
clientApp: ClientApp,
guestId: bigint | undefined,
method: 'sms' | 'wechat_phone',
) {
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);
this.analyticsService.trackOneSafe(user.id, clientApp, {
eventName: method === 'sms' ? 'sms_login' : 'wechat_phone_login',
extraJson: { method, accountMerged: true },
});
this.analyticsService.trackOneSafe(user.id, clientApp, {
eventName: 'login_success',
extraJson: { method, accountMerged: true },
});
return this.buildSessionResponse(user, clientApp, user.deviceKey, { accountMerged: true });
} else {
await this.assertActiveUser(user.id);
}
}
if (!user) throw new BadRequestException('登录失败');
this.analyticsService.trackOneSafe(user.id, clientApp, {
eventName: method === 'sms' ? 'sms_login' : 'wechat_phone_login',
extraJson: { method },
});
this.analyticsService.trackOneSafe(user.id, clientApp, {
eventName: 'login_success',
extraJson: { method },
});
return this.buildSessionResponse(user, clientApp, user.deviceKey);
}
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,
);
return this.issueUserSessionByVerifiedPhone(normalizedPhone, clientApp, guestId, 'sms');
}
/** 小程序 getPhoneNumber:用微信返回的 phoneCode 登录/注册,可选 loginCode 绑定 openId */
async loginUserWechatPhone(
phoneCode: string,
clientApp: ClientApp,
platform: 'h5' | 'mini' = 'mini',
guestId?: bigint,
loginCode?: string,
) {
this.assertWechatEnabled();
if (platform !== 'mini') {
throw new BadRequestException('仅小程序支持手机号快捷登录');
}
const phone = await this.wechatProvider.getPhoneNumberByCode(phoneCode, platform);
const normalizedPhone = this.assertMobilePhone(phone);
const session = await this.issueUserSessionByVerifiedPhone(
normalizedPhone,
clientApp,
guestId,
'wechat_phone',
);
if (loginCode?.trim() && session.actorId) {
try {
await this.bindUserWechat(
BigInt(session.actorId),
{ code: loginCode.trim() },
clientApp,
'mini',
);
} catch {
/* 绑定 openId 失败不阻断已成功的手机号登录 */
}
}
return session;
}
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;
let accountMerged = false;
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);
accountMerged = true;
if (!targetUser.phoneVerifiedAt) {
targetUser = await this.prisma.user.update({
where: { id: targetUser.id },
data: { phoneVerifiedAt: new Date() },
include: { avatar: true },
});
}
}
}
this.trackSmsUserEvent(targetUser.id, clientApp, 'bind_phone', {
phone: this.maskPhone(normalizedPhone),
});
return this.buildSessionResponse(targetUser, clientApp, targetUser.deviceKey, {
accountMerged,
});
}
async loginStore(phone: string, code: string, clientApp: ClientApp) {
const normalizedPhone = this.assertMobilePhone(phone);
try {
await this.smsProvider.verify(normalizedPhone, code, SmsScene.STORE_LOGIN);
} catch (err) {
const account = await this.prisma.storeAccount.findUnique({
where: { phone: normalizedPhone },
include: { bindings: { select: { storeId: true }, take: 1 } },
});
if (account) {
this.trackStoreEvent(
account.id,
account.bindings[0]?.storeId,
clientApp,
'store_sms_verify_fail',
{
phone: this.maskPhone(normalizedPhone),
reason: err instanceof BadRequestException ? err.message : '验证码错误',
},
);
}
throw err;
}
const found = await this.prisma.storeAccount.findUnique({ where: { phone: normalizedPhone } });
if (!found) throw new BadRequestException('该手机号未绑定门店');
const account = await this.loadStoreAccountWithBindings(found.id);
if (!account) throw new BadRequestException('该手机号未绑定门店');
if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用');
if (!account.bindings.length) throw new BadRequestException('该账号未绑定任何门店');
await this.prisma.storeAccount.update({
where: { id: account.id },
data: { lastLoginAt: new Date() },
});
const firstStoreId = account.bindings[0]?.store.id;
this.trackStoreEvent(account.id, firstStoreId, clientApp, 'store_sms_login', {
phone: this.maskPhone(normalizedPhone),
});
this.trackStoreEvent(account.id, firstStoreId, clientApp, 'store_login_success', {
method: 'sms',
});
return this.issueStoreSession(account, clientApp);
}
async loginPartner(phone: string, code: string, clientApp: ClientApp) {
const normalizedPhone = this.assertMobilePhone(phone);
try {
await this.smsProvider.verify(normalizedPhone, code, SmsScene.PARTNER_LOGIN);
} catch (err) {
const account = await this.prisma.partnerAccount.findUnique({ where: { phone: normalizedPhone } });
if (account) {
const primary = await this.resolvePrimaryAccount(account.id);
this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_sms_verify_fail', {
phone: this.maskPhone(normalizedPhone),
reason: err instanceof BadRequestException ? err.message : '验证码错误',
});
}
throw err;
}
const account = await this.prisma.partnerAccount.findUnique({
where: { phone: normalizedPhone },
});
if (!account) throw new BadRequestException('未找到合伙人账号');
if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
const primary = await this.resolvePrimaryAccount(account.id);
await this.prisma.partnerAccount.update({
where: { id: account.id },
data: { lastLoginAt: new Date() },
});
this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_sms_login', {
phone: this.maskPhone(normalizedPhone),
});
this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_login_success', {
method: 'sms',
});
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(account, primary));
}
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 loginHqPassword(loginName: string, password: string, clientApp: ClientApp) {
const normalizedLogin = loginName.trim();
if (!normalizedLogin) throw new BadRequestException('请输入账号');
const account = await this.prisma.hqAccount.findUnique({
where: { loginName: normalizedLogin },
});
if (!account?.passwordHash) throw new BadRequestException('账号或密码错误');
if (account.status !== 'ACTIVE') throw new BadRequestException('账号已停用');
if (!verifyPassword(password, account.passwordHash)) {
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 loginHqWechat(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.hqAccount.findFirst({
where: { wxOpenId: session.openId },
});
if (!account && this.wechatProvider.isMock()) {
account = await this.prisma.hqAccount.findFirst({
where: { status: 'ACTIVE' },
orderBy: [{ adminRole: 'asc' }, { id: 'asc' }],
});
}
if (!account) {
throw new BadRequestException('首次登录请使用手机验证码,登录后将自动关联微信');
}
if (account.status !== 'ACTIVE') throw new BadRequestException('账号已停用');
account = await this.prisma.hqAccount.update({
where: { id: account.id },
data: {
wxOpenId: session.openId,
wxUnionId: session.unionId ?? account.wxUnionId,
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') {
return this.getShopMe({ actorId });
}
if (actorType === 'PARTNER') {
const account = await this.prisma.partnerAccount.findUnique({
where: { id: actorId },
});
if (!account) return null;
const primary = await this.resolvePrimaryAccount(account.id);
return serializeBigInt({
...account,
primaryAccountId: primary.id,
companyName: primary.companyName,
});
}
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 },
});
// 微信登录不再强制绑定手机号;phoneVerified=false 也可签发会话,下单页仅提示可选绑定
if (user) {
let accountMerged = false;
let activeUser: UserRow;
if (guestId && guestId !== user.id) {
activeUser = await this.mergeUsers(guestId, user.id);
accountMerged = true;
} else {
activeUser = 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, accountMerged },
});
this.analyticsService.trackOneSafe(activeUser.id, clientApp, {
eventName: 'login_success',
extraJson: { method: 'wechat', platform, accountMerged },
});
return this.buildSessionResponse(activeUser, clientApp, activeUser.deviceKey, { accountMerged });
}
// 历史脏数据:openId 仍在已合并访客上 → 跟随主账号并补挂微信身份
const legacyMerged = await this.prisma.user.findFirst({
where: { wxOpenId: session.openId, mergedIntoUserId: { not: null } },
select: { mergedIntoUserId: true },
});
if (legacyMerged?.mergedIntoUserId) {
let primary = await this.assertActiveUser(legacyMerged.mergedIntoUserId);
if (!primary.wxOpenId) {
primary = await this.prisma.user.update({
where: { id: primary.id },
data: {
wxOpenId: session.openId,
wxUnionId: session.unionId ?? primary.wxUnionId,
},
include: { avatar: true },
});
}
await this.prisma.user.updateMany({
where: { wxOpenId: session.openId, id: { not: primary.id } },
data: { wxOpenId: null, wxUnionId: null },
});
let accountMerged = false;
if (guestId && guestId !== primary.id) {
primary = await this.mergeUsers(guestId, primary.id);
accountMerged = true;
}
if (session.accessToken) {
const synced = await this.syncWechatUserProfile(primary.id, session.accessToken, session.openId);
if (synced) primary = synced as UserRow;
}
this.analyticsService.trackOneSafe(primary.id, clientApp, {
eventName: 'wechat_login',
extraJson: { platform, accountMerged, recoveredFromMerge: true },
});
this.analyticsService.trackOneSafe(primary.id, clientApp, {
eventName: 'login_success',
extraJson: { method: 'wechat', platform, accountMerged },
});
return this.buildSessionResponse(primary, clientApp, primary.deviceKey, { accountMerged });
}
if (guestId) {
try {
const guest = await this.assertActiveUser(guestId);
if (!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;
}
}
let created: UserRow = (await this.prisma.user.create({
data: {
userNo: generateUserNo(),
wxOpenId: session.openId,
wxUnionId: session.unionId,
nickname: '微信用户',
cityPreference: {
create: {
selectedCityCode: '410100',
selectedDistrict: '郑州市',
},
},
},
include: { avatar: true },
})) as UserRow;
if (session.accessToken) {
const synced = await this.syncWechatUserProfile(created.id, session.accessToken, session.openId);
if (synced) created = synced as UserRow;
}
this.analyticsService.trackOneSafe(created.id, clientApp, {
eventName: 'wechat_login',
extraJson: { platform },
});
this.analyticsService.trackOneSafe(created.id, clientApp, {
eventName: 'login_success',
extraJson: { method: 'wechat', platform },
});
return this.buildSessionResponse(created, clientApp, created.deviceKey);
}
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;
let accountMerged = false;
if (guestId && guestId !== updated.id) {
targetUserId = (await this.mergeUsers(guestId, updated.id)).id;
accountMerged = true;
}
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', accountMerged },
});
this.analyticsService.trackOneSafe(user.id, clientApp, {
eventName: 'login_success',
extraJson: { method: 'wechat_bind', accountMerged },
});
return this.buildSessionResponse(user, clientApp, user.deviceKey, { accountMerged });
} 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 updateMiniWechatProfile(
userId: bigint,
input: { nickname?: string; avatarUrl?: string; avatarResourceId?: string },
) {
const user = await this.assertActiveUser(userId);
const data: {
nickname?: string;
avatarResourceId?: bigint;
} = {};
const nickname = input.nickname?.trim();
if (nickname) {
data.nickname = nickname.slice(0, 64);
}
const avatarUrl = input.avatarUrl?.trim();
const avatarResourceId = input.avatarResourceId?.trim();
if (avatarResourceId) {
let resourceId: bigint;
try {
resourceId = BigInt(avatarResourceId);
} catch {
throw new BadRequestException('头像资源编号无效');
}
const avatar = await this.resourceService.getOwnedActiveAvatar(resourceId, userId);
data.avatarResourceId = avatar.id;
} else if (avatarUrl) {
// 兼容已发布旧客户端:只接受刚由当前用户上传并登记过的真实资源 URL。
const avatar = await this.resourceService.getOwnedActiveAvatarByUrl(avatarUrl, userId);
data.avatarResourceId = avatar.id;
}
if (!data.nickname && !data.avatarResourceId) {
return this.formatUserProfile(user);
}
const updated = await this.prisma.user.update({
where: { id: userId },
data,
include: { avatar: true },
});
return this.formatUserProfile(updated);
}
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 },
});
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(),
},
});
const full = await this.loadStoreAccountWithBindings(account.id);
if (!full || !full.bindings.length) {
throw new BadRequestException('该账号未绑定任何门店');
}
const firstStoreId = full.bindings[0]?.store.id;
this.trackStoreEvent(account.id, firstStoreId, clientApp, 'store_wechat_login', { platform });
this.trackStoreEvent(account.id, firstStoreId, clientApp, 'store_login_success', {
method: 'wechat',
platform,
});
return this.issueStoreSession(full, clientApp);
}
async bindStoreWechat(
storeAccountId: bigint,
code: string,
clientApp: ClientApp,
platform: 'h5' | 'mini' = 'h5',
currentStoreId?: bigint,
) {
this.assertWechatEnabled();
const session =
platform === 'mini'
? await this.wechatProvider.code2Session(code)
: await this.wechatProvider.oauth2AccessToken(code);
const account = await this.prisma.storeAccount.findUnique({
where: { id: storeAccountId },
});
if (!account) throw new BadRequestException('门店账号不存在');
const conflict = await this.prisma.storeAccount.findFirst({
where: { wxOpenId: session.openId, id: { not: storeAccountId } },
});
if (conflict) {
throw new BadRequestException('该微信已绑定其他门店账号');
}
await this.prisma.storeAccount.update({
where: { id: storeAccountId },
data: {
wxOpenId: session.openId,
wxUnionId: session.unionId ?? account.wxUnionId,
lastLoginAt: new Date(),
},
});
const full = await this.loadStoreAccountWithBindings(storeAccountId);
if (!full) throw new BadRequestException('门店账号不存在');
this.trackStoreEvent(
storeAccountId,
currentStoreId ?? full.bindings[0]?.store.id,
clientApp,
'store_wechat_bind',
{ platform },
);
return this.issueStoreSession(full, clientApp, currentStoreId);
}
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 },
});
if (!account) throw new BadRequestException('合伙人账号不存在');
const conflict = await this.prisma.partnerAccount.findFirst({
where: { wxOpenId: session.openId, id: { not: partnerAccountId } },
});
if (conflict) {
throw new BadRequestException('该微信已绑定其他合伙人账号');
}
let updated = await this.prisma.partnerAccount.update({
where: { id: partnerAccountId },
data: {
wxOpenId: session.openId,
wxUnionId: session.unionId ?? account.wxUnionId,
lastLoginAt: new Date(),
},
});
const synced = await this.syncPartnerWechatProfile(
updated.id,
'accessToken' in session ? session.accessToken : undefined,
session.openId,
);
if (synced) updated = synced;
const primary = await this.resolvePrimaryAccount(updated.id);
this.trackPartnerEvent(updated.id, primary.id, clientApp, 'partner_wechat_bind', { platform });
return this.issueToken('PARTNER', updated.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(updated, primary));
}
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 },
});
if (!account && this.wechatProvider.isMock()) {
// preV1 Mock:无绑定微信时回落到演示主账号,方便一键授权登录
account = await this.prisma.partnerAccount.findFirst({
where: { status: 'ACTIVE' },
orderBy: [{ isPrimary: 'desc' }, { id: 'asc' }],
});
}
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(),
},
});
const synced = await this.syncPartnerWechatProfile(
account.id,
'accessToken' in session ? session.accessToken : undefined,
session.openId,
);
if (synced) account = synced;
const primary = await this.resolvePrimaryAccount(account.id);
this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_wechat_login', { platform });
this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_login_success', {
method: 'wechat',
});
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(account, primary));
}
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('目标账号无效');
}
// 先迁微信身份到主账号,再清空访客,否则下次 OAuth 找不到 openId 又会建无手机号访客
const guestWxOpenId = guest.wxOpenId;
const guestWxUnionId = guest.wxUnionId;
if (guestWxOpenId || guestWxUnionId) {
await tx.user.update({
where: { id: guestId },
data: { wxOpenId: null, wxUnionId: null },
});
if (guestWxOpenId && !primary.wxOpenId) {
await tx.user.update({
where: { id: primaryId },
data: {
wxOpenId: guestWxOpenId,
wxUnionId: guestWxUnionId ?? primary.wxUnionId,
},
});
} else if (guestWxUnionId && !primary.wxUnionId) {
await tx.user.update({
where: { id: primaryId },
data: { wxUnionId: guestWxUnionId },
});
}
}
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 },
});
}
}
if (
guest.sourceType === 'PROMO_CODE' &&
guest.sourceRefId &&
primary.sourceType === 'ORGANIC'
) {
await tx.user.update({
where: { id: primaryId },
data: {
sourceType: 'PROMO_CODE',
sourceRefId: guest.sourceRefId,
sourceLabel: guest.sourceLabel ?? primary.sourceLabel,
},
});
}
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 } });
}
if (!primary.avatarResourceId && guest.avatarResourceId) {
await tx.user.update({
where: { id: primaryId },
data: { avatarResourceId: guest.avatarResourceId },
});
await tx.user.update({
where: { id: guestId },
data: { avatarResourceId: null },
});
}
if ((!primary.nickname || primary.nickname === '访客' || /^用户\d{4}$/.test(primary.nickname))
&& guest.nickname
&& guest.nickname !== '访客') {
await tx.user.update({
where: { id: primaryId },
data: { nickname: guest.nickname },
});
}
await tx.user.update({
where: { id: guestId },
data: {
mergedIntoUserId: primaryId,
status: 0,
deviceKey: null,
wxOpenId: null,
wxUnionId: 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,
extras?: { accountMerged?: boolean },
) {
const phoneVerified = !!user.phoneVerifiedAt;
return {
...this.issueToken(
'USER',
user.id,
clientApp,
phoneVerified,
this.formatUserProfile(user),
undefined,
undefined,
deviceKey,
),
...(extras?.accountMerged ? { accountMerged: true as const } : {}),
};
}
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 ?? 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>,
storeId?: bigint,
shopExtra?: Record<string, unknown>,
) {
const payload = {
sub: actorId.toString(),
actorType,
actorId: actorId.toString(),
clientApp,
phoneVerified,
...(storeId != null ? { storeId: storeId.toString() } : {}),
};
const accessToken = this.jwtService.sign(payload);
const refreshExpiresIn = actorType === 'STORE' || actorType === 'PARTNER' ? '7d' : '30d';
const refreshToken = this.jwtService.sign(payload, { expiresIn: refreshExpiresIn });
return {
accessToken,
refreshToken,
deviceKey: deviceKey ?? undefined,
actorType,
actorId: actorId.toString(),
phoneVerified,
user,
store,
partner,
hq,
...shopExtra,
};
}
}