2152 lines
72 KiB
TypeScript
2152 lines
72 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, isHqListColumnKey, type HqListColumnPrefsMap } 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 { HqPermissionsResolver } from '../../common/guards/hq-permission.guard';
|
||
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.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;
|
||
};
|
||
|
||
function parseListColumnPrefs(raw: unknown): HqListColumnPrefsMap {
|
||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||
const out: HqListColumnPrefsMap = {};
|
||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||
if (!isHqListColumnKey(key) || !value || typeof value !== 'object' || Array.isArray(value)) continue;
|
||
const row = value as { order?: unknown; hidden?: unknown };
|
||
out[key] = {
|
||
order: Array.isArray(row.order) ? row.order.filter((v): v is string => typeof v === 'string') : [],
|
||
hidden: Array.isArray(row.hidden) ? row.hidden.filter((v): v is string => typeof v === 'string') : [],
|
||
};
|
||
}
|
||
return out;
|
||
}
|
||
|
||
@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 readonly hqPermissions: HqPermissionsResolver,
|
||
private readonly testWhitelist: TestWhitelistService,
|
||
) {}
|
||
|
||
/** 登录/绑号后按全局白名单同步 isTest */
|
||
private async syncTestFlagByPhone(phone: string) {
|
||
const isTest = await this.testWhitelist.isPhoneInWhitelist(phone);
|
||
await Promise.all([
|
||
this.prisma.user.updateMany({ where: { phone }, data: { isTest } }),
|
||
this.prisma.storeAccount.updateMany({ where: { phone }, data: { isTest } }),
|
||
this.prisma.partnerAccount.updateMany({ where: { phone }, data: { isTest } }),
|
||
this.prisma.store.updateMany({ where: { phone }, data: { isTest } }),
|
||
]);
|
||
return isTest;
|
||
}
|
||
|
||
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:
|
||
case SmsScene.PARTNER_PROXY_CUSTOMER:
|
||
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_CUSTOMER: {
|
||
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 partner = await this.prisma.partnerAccount.findUnique({
|
||
where: { phone },
|
||
select: { id: true },
|
||
});
|
||
return partner ? { refType: 'PARTNER', refId: partner.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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 合伙人账号可登录态校验(统一闸门):
|
||
* 1) 账号自身 status !== ACTIVE(DISABLED,含子账号)→ 拦截
|
||
* 2) 所属主账号 bindingStatus !== ACTIVE(PAUSED,城市合伙人绑定暂停)→ 拦截
|
||
* 子账号的绑定状态以父主账号为准。
|
||
*/
|
||
private async assertPartnerAccountActive(account: {
|
||
id: bigint;
|
||
status: string;
|
||
isPrimary: number;
|
||
bindingStatus?: string | null;
|
||
}): Promise<void> {
|
||
if (account.status !== 'ACTIVE') {
|
||
throw new BadRequestException('该账号已暂停使用,请联系客服人员');
|
||
}
|
||
const primary =
|
||
account.isPrimary === 1 ? account : await this.resolvePrimaryAccount(account.id);
|
||
if (primary.bindingStatus && primary.bindingStatus !== 'ACTIVE') {
|
||
throw new BadRequestException('该合伙人合作已暂停,请联系客服人员');
|
||
}
|
||
}
|
||
|
||
private async assertPartnerAccountByPhone(phone: string) {
|
||
const account = await this.prisma.partnerAccount.findUnique({
|
||
where: { phone },
|
||
});
|
||
if (!account) throw new BadRequestException('未找到合伙人账号');
|
||
await this.assertPartnerAccountActive(account);
|
||
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_CUSTOMER) {
|
||
return;
|
||
}
|
||
if (scene === SmsScene.PARTNER_PROXY_ORDER) {
|
||
await this.assertPartnerAccountByPhone(phone);
|
||
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,
|
||
source?: {
|
||
sourceType?: 'PARTNER_PROXY';
|
||
sourceRefId?: bigint;
|
||
sourceLabel?: string;
|
||
},
|
||
) {
|
||
const normalizedPhone = this.assertMobilePhone(phone);
|
||
let user = await this.prisma.user.findUnique({
|
||
where: { phone: normalizedPhone },
|
||
include: { avatar: true },
|
||
});
|
||
|
||
if (!user) {
|
||
const isTest = await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
|
||
user = await this.prisma.user.create({
|
||
data: {
|
||
phone: normalizedPhone,
|
||
phoneVerifiedAt: new Date(),
|
||
userNo: generateUserNo(),
|
||
nickname: `用户${normalizedPhone.slice(-4)}`,
|
||
isTest,
|
||
sourceType: source?.sourceType ?? 'ORGANIC',
|
||
sourceRefId: source?.sourceRefId,
|
||
sourceLabel: source?.sourceLabel,
|
||
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.syncTestFlagByPhone(normalizedPhone);
|
||
user = await this.prisma.user.findUniqueOrThrow({
|
||
where: { id: user.id },
|
||
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) {
|
||
const isTest = await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
|
||
user = await this.prisma.user.create({
|
||
data: {
|
||
phone: normalizedPhone,
|
||
phoneVerifiedAt: new Date(),
|
||
userNo: generateUserNo(),
|
||
nickname: `用户${normalizedPhone.slice(-4)}`,
|
||
isTest,
|
||
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('登录失败');
|
||
|
||
await this.syncTestFlagByPhone(normalizedPhone);
|
||
user = await this.prisma.user.findUniqueOrThrow({
|
||
where: { id: user.id },
|
||
include: { avatar: true },
|
||
});
|
||
|
||
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.syncTestFlagByPhone(normalizedPhone);
|
||
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('未找到合伙人账号');
|
||
await this.assertPartnerAccountActive(account);
|
||
await this.syncTestFlagByPhone(normalizedPhone);
|
||
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 } });
|
||
if (!account) return null;
|
||
const permissionKeys = await this.hqPermissions.resolveEffectiveKeys(actorId);
|
||
const cityScope = await this.hqPermissions.resolveCityScope(actorId);
|
||
const { passwordHash: _passwordHash, ...safeAccount } = account;
|
||
return serializeBigInt({
|
||
...safeAccount,
|
||
listColumnPrefs: parseListColumnPrefs(account.listColumnPrefs),
|
||
permissionKeys,
|
||
cityIds: (cityScope ?? []).map((id) => id.toString()),
|
||
cityScoped: cityScope !== null,
|
||
});
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async updateMyListColumnPrefs(
|
||
actorType: string,
|
||
actorId: bigint,
|
||
listKey: string,
|
||
dto: { reset?: boolean; order?: string[]; hidden?: string[] },
|
||
) {
|
||
if (actorType !== 'HQ') throw new ForbiddenException('仅总部账号可保存列表列设置');
|
||
if (!isHqListColumnKey(listKey)) throw new BadRequestException('未知列表');
|
||
const account = await this.prisma.hqAccount.findUnique({
|
||
where: { id: actorId },
|
||
select: { id: true, listColumnPrefs: true },
|
||
});
|
||
if (!account) throw new NotFoundException('账号不存在');
|
||
const current = parseListColumnPrefs(account.listColumnPrefs);
|
||
if (dto.reset) {
|
||
delete current[listKey];
|
||
} else {
|
||
current[listKey] = {
|
||
order: (dto.order ?? []).filter((k) => typeof k === 'string' && k.trim()),
|
||
hidden: (dto.hidden ?? []).filter((k) => typeof k === 'string' && k.trim()),
|
||
};
|
||
}
|
||
const updated = await this.prisma.hqAccount.update({
|
||
where: { id: actorId },
|
||
data: { listColumnPrefs: current },
|
||
});
|
||
return { listColumnPrefs: parseListColumnPrefs(updated.listColumnPrefs) };
|
||
}
|
||
|
||
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 },
|
||
});
|
||
this.analyticsService.trackOneSafe(userId, 'USER_MINI', {
|
||
eventName: 'profile_update',
|
||
refType: 'USER',
|
||
refId: userId,
|
||
extraJson: {
|
||
fields: [
|
||
...(data.nickname ? ['nickname'] : []),
|
||
...(data.avatarResourceId ? ['avatar'] : []),
|
||
],
|
||
},
|
||
});
|
||
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('首次登录请使用手机验证码,登录后将自动关联微信');
|
||
}
|
||
|
||
await this.assertPartnerAccountActive(account);
|
||
|
||
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,
|
||
};
|
||
}
|
||
}
|