门店账户多账号

This commit is contained in:
2026-07-12 12:24:34 +08:00
parent 06b1cb22e0
commit 54a15d6da7
39 changed files with 1962 additions and 311 deletions
+245 -60
View File
@@ -172,12 +172,13 @@ export class AuthService {
private trackStoreEvent(
storeAccountId: bigint | undefined,
storeId: bigint,
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,
@@ -393,14 +394,23 @@ export class AuthService {
if (scene === SmsScene.STORE_LOGIN && actorRef?.refType === 'STORE') {
const storeAccount = await this.prisma.storeAccount.findUnique({
where: { id: actorRef.refId },
select: { id: true, storeId: true },
select: {
id: true,
bindings: { select: { storeId: true }, take: 1 },
},
});
if (storeAccount) {
this.trackStoreEvent(storeAccount.id, storeAccount.storeId, clientApp, 'store_sms_send', {
scene,
phone: this.maskPhone(normalizedPhone),
status: 'success',
});
this.trackStoreEvent(
storeAccount.id,
storeAccount.bindings[0]?.storeId,
clientApp,
'store_sms_send',
{
scene,
phone: this.maskPhone(normalizedPhone),
status: 'success',
},
);
}
}
if (
@@ -487,7 +497,11 @@ export class AuthService {
return this.buildSessionResponse(user, clientApp, user.deviceKey);
}
if (payload.actorType === 'STORE' && clientApp === ClientApp.SHOP_H5) {
return this.buildStoreSessionResponse(BigInt(payload.actorId), clientApp);
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);
@@ -499,21 +513,188 @@ export class AuthService {
}
}
private async buildStoreSessionResponse(accountId: bigint, clientApp: ClientApp) {
const account = await this.prisma.storeAccount.findUnique({
private async loadStoreAccountWithBindings(accountId: bigint) {
return this.prisma.storeAccount.findUnique({
where: { id: accountId },
include: { store: true },
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.issueToken('STORE', account.id, clientApp, false, undefined, {
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: account.storeId.toString(),
storeId: selected?.id.toString() ?? '',
name: account.name,
phone: account.phone,
storeName: account.store.name,
});
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) {
@@ -656,38 +837,42 @@ export class AuthService {
try {
await this.smsProvider.verify(normalizedPhone, code, SmsScene.STORE_LOGIN);
} catch (err) {
const account = await this.prisma.storeAccount.findUnique({ where: { phone: normalizedPhone } });
const account = await this.prisma.storeAccount.findUnique({
where: { phone: normalizedPhone },
include: { bindings: { select: { storeId: true }, take: 1 } },
});
if (account) {
this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_sms_verify_fail', {
phone: this.maskPhone(normalizedPhone),
reason: err instanceof BadRequestException ? err.message : '验证码错误',
});
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 account = await this.prisma.storeAccount.findUnique({
where: { phone: normalizedPhone },
include: { store: true },
});
const found = await this.prisma.storeAccount.findUnique({ where: { phone: normalizedPhone } });
if (!found) throw new BadRequestException('该手机号未绑定门店');
const account = await this.loadStoreAccountWithBindings(found.id);
if (!account) throw new BadRequestException('该手机号未绑定门店');
if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用');
if (!account.bindings.length) throw new BadRequestException('该账号未绑定任何门店');
await this.prisma.storeAccount.update({
where: { id: account.id },
data: { lastLoginAt: new Date() },
});
this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_sms_login', {
const firstStoreId = account.bindings[0]?.store.id;
this.trackStoreEvent(account.id, firstStoreId, clientApp, 'store_sms_login', {
phone: this.maskPhone(normalizedPhone),
});
this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_login_success', {
this.trackStoreEvent(account.id, firstStoreId, clientApp, 'store_login_success', {
method: 'sms',
});
return this.issueToken('STORE', account.id, clientApp, false, undefined, {
id: account.id.toString(),
storeId: account.storeId.toString(),
name: account.name,
phone: account.phone,
storeName: account.store.name,
});
return this.issueStoreSession(account, clientApp);
}
async loginPartner(phone: string, code: string, clientApp: ClientApp) {
@@ -814,11 +999,7 @@ export class AuthService {
return this.formatUserProfile(user);
}
if (actorType === 'STORE') {
const account = await this.prisma.storeAccount.findUnique({
where: { id: actorId },
include: { store: true },
});
return serializeBigInt(account);
return this.getShopMe({ actorId });
}
if (actorType === 'PARTNER') {
const account = await this.prisma.partnerAccount.findUnique({
@@ -1104,7 +1285,6 @@ export class AuthService {
let account = await this.prisma.storeAccount.findFirst({
where: { wxOpenId: session.openId },
include: { store: true },
});
if (!account) {
@@ -1118,22 +1298,21 @@ export class AuthService {
wxUnionId: session.unionId ?? account.wxUnionId,
lastLoginAt: new Date(),
},
include: { store: true },
});
this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_wechat_login', { platform });
this.trackStoreEvent(account.id, account.storeId, clientApp, 'store_login_success', {
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.issueToken('STORE', account.id, clientApp, false, undefined, {
id: account.id.toString(),
storeId: account.storeId.toString(),
name: account.name,
phone: account.phone,
storeName: account.store.name,
});
return this.issueStoreSession(full, clientApp);
}
async bindStoreWechat(
@@ -1141,6 +1320,7 @@ export class AuthService {
code: string,
clientApp: ClientApp,
platform: 'h5' | 'mini' = 'h5',
currentStoreId?: bigint,
) {
this.assertWechatEnabled();
const session =
@@ -1150,7 +1330,6 @@ export class AuthService {
const account = await this.prisma.storeAccount.findUnique({
where: { id: storeAccountId },
include: { store: true },
});
if (!account) throw new BadRequestException('门店账号不存在');
@@ -1161,25 +1340,27 @@ export class AuthService {
throw new BadRequestException('该微信已绑定其他门店账号');
}
const updated = await this.prisma.storeAccount.update({
await this.prisma.storeAccount.update({
where: { id: storeAccountId },
data: {
wxOpenId: session.openId,
wxUnionId: session.unionId ?? account.wxUnionId,
lastLoginAt: new Date(),
},
include: { store: true },
});
this.trackStoreEvent(updated.id, updated.storeId, clientApp, 'store_wechat_bind', { platform });
const full = await this.loadStoreAccountWithBindings(storeAccountId);
if (!full) throw new BadRequestException('门店账号不存在');
return this.issueToken('STORE', updated.id, clientApp, false, undefined, {
id: updated.id.toString(),
storeId: updated.storeId.toString(),
name: updated.name,
phone: updated.phone,
storeName: updated.store.name,
});
this.trackStoreEvent(
storeAccountId,
currentStoreId ?? full.bindings[0]?.store.id,
clientApp,
'store_wechat_bind',
{ platform },
);
return this.issueStoreSession(full, clientApp, currentStoreId);
}
async bindPartnerWechat(
@@ -1497,6 +1678,8 @@ export class AuthService {
partner?: Record<string, unknown>,
deviceKey?: string | null,
hq?: Record<string, unknown>,
storeId?: bigint,
shopExtra?: Record<string, unknown>,
) {
const payload = {
sub: actorId.toString(),
@@ -1504,6 +1687,7 @@ export class AuthService {
actorId: actorId.toString(),
clientApp,
phoneVerified,
...(storeId != null ? { storeId: storeId.toString() } : {}),
};
const accessToken = this.jwtService.sign(payload);
const refreshExpiresIn = actorType === 'STORE' || actorType === 'PARTNER' ? '7d' : '30d';
@@ -1519,6 +1703,7 @@ export class AuthService {
store,
partner,
hq,
...shopExtra,
};
}
}