bdf80e577b
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2009 lines
66 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|