Files
dukang/server/dukang-api/src/modules/iam/auth.service.ts
T
jacy 5935024ea8
CI / verify (pull_request) Waiting to run
v3.5.8和v3.5.9版本更新
2026-08-25 09:20:32 +08:00

2152 lines
72 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 !== ACTIVEDISABLED,含子账号)→ 拦截
* 2) 所属主账号 bindingStatus !== ACTIVEPAUSED,城市合伙人绑定暂停)→ 拦截
* 子账号的绑定状态以父主账号为准。
*/
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,
};
}
}