门店账户多账号
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { BadRequestException, Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
@@ -122,6 +122,7 @@ export class ShopAuthController {
|
||||
dto.code,
|
||||
ClientApp.SHOP_H5,
|
||||
dto.platform ?? 'h5',
|
||||
user.storeId,
|
||||
);
|
||||
}
|
||||
return this.authService.loginStoreWechat(dto.code, ClientApp.SHOP_H5, dto.platform ?? 'h5');
|
||||
@@ -132,10 +133,23 @@ export class ShopAuthController {
|
||||
return this.authService.refreshAccessToken(dto.refreshToken, ClientApp.SHOP_H5);
|
||||
}
|
||||
|
||||
@Get('stores')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
stores(@CurrentUser() user: AuthUser) {
|
||||
return this.authService.listShopStores(user.actorId);
|
||||
}
|
||||
|
||||
@Post('select-store')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
selectStore(@CurrentUser() user: AuthUser, @Body() body: { storeId: string }) {
|
||||
if (!body?.storeId) throw new BadRequestException('请选择门店');
|
||||
return this.authService.selectShopStore(user.actorId, BigInt(body.storeId), ClientApp.SHOP_H5);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@CurrentUser() user: AuthUser) {
|
||||
return this.authService.getMe(user.actorType, user.actorId);
|
||||
return this.authService.getShopMe(user);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { AccountStatus, StoreStaffRole } from '@dukang/shared-types';
|
||||
|
||||
export class CreateStoreStaffDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
storeIds: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(Object.values(StoreStaffRole))
|
||||
staffRole?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export class UpdateStoreStaffDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(Object.values(StoreStaffRole))
|
||||
@IsOptional()
|
||||
staffRole?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
permissions?: string[];
|
||||
|
||||
@IsString()
|
||||
@IsIn(Object.values(AccountStatus))
|
||||
@IsOptional()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
storeIds?: string[];
|
||||
}
|
||||
@@ -13,12 +13,16 @@ import { UserAddressController } from './user-address.controller';
|
||||
import { UserAddressService } from './user-address.service';
|
||||
import { PartnerStaffController } from './partner-staff.controller';
|
||||
import { PartnerStaffService } from './partner-staff.service';
|
||||
import { StoreStaffController } from './store-staff.controller';
|
||||
import { StoreStaffService } from './store-staff.service';
|
||||
import { AdminAuthController } from './admin-auth.controller';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard';
|
||||
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
|
||||
import { StoreMembershipService } from '../../common/guards/store-membership.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -34,11 +38,37 @@ import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||
ShopAuthController,
|
||||
PartnerAuthController,
|
||||
PartnerStaffController,
|
||||
StoreStaffController,
|
||||
UserProfileController,
|
||||
UserAddressController,
|
||||
AdminAuthController,
|
||||
],
|
||||
providers: [AuthService, UserAddressService, PartnerStaffService, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard, PartnerPrimaryGuard],
|
||||
exports: [AuthService, UserAddressService, PartnerStaffService, JwtModule, JwtAuthGuard, PhoneVerifiedGuard, OptionalJwtAuthGuard, HqAuthGuard, PartnerPrimaryGuard],
|
||||
providers: [
|
||||
AuthService,
|
||||
UserAddressService,
|
||||
PartnerStaffService,
|
||||
StoreStaffService,
|
||||
StoreMembershipService,
|
||||
JwtAuthGuard,
|
||||
PhoneVerifiedGuard,
|
||||
OptionalJwtAuthGuard,
|
||||
HqAuthGuard,
|
||||
PartnerPrimaryGuard,
|
||||
ShopStoreGuard,
|
||||
],
|
||||
exports: [
|
||||
AuthService,
|
||||
UserAddressService,
|
||||
PartnerStaffService,
|
||||
StoreStaffService,
|
||||
StoreMembershipService,
|
||||
JwtModule,
|
||||
JwtAuthGuard,
|
||||
PhoneVerifiedGuard,
|
||||
OptionalJwtAuthGuard,
|
||||
HqAuthGuard,
|
||||
PartnerPrimaryGuard,
|
||||
ShopStoreGuard,
|
||||
],
|
||||
})
|
||||
export class IamModule {}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { StoreStaffService } from './store-staff.service';
|
||||
import { CreateStoreStaffDto, UpdateStoreStaffDto } from './dto/store-staff.dto';
|
||||
|
||||
@Controller('shop/staff')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class StoreStaffController {
|
||||
constructor(private readonly staffService: StoreStaffService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthUser) {
|
||||
return this.staffService.listStaff(user.actorId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateStoreStaffDto) {
|
||||
return this.staffService.createStaff(user, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateStoreStaffDto,
|
||||
) {
|
||||
return this.staffService.updateStaff(user, BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.staffService.deleteStaff(user, BigInt(id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
STORE_STAFF_DEFAULT_PERMISSIONS,
|
||||
StoreStaffRole,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { AnalyticsService } from '../analytics/analytics.service';
|
||||
import { CreateStoreStaffDto, UpdateStoreStaffDto } from './dto/store-staff.dto';
|
||||
|
||||
@Injectable()
|
||||
export class StoreStaffService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly analytics: AnalyticsService,
|
||||
) {}
|
||||
|
||||
async listStaff(parentAccountId: bigint) {
|
||||
const parent = await this.assertPrimary(parentAccountId);
|
||||
const rows = await this.prisma.storeAccount.findMany({
|
||||
where: { parentAccountId: parent.id },
|
||||
include: {
|
||||
bindings: {
|
||||
include: {
|
||||
store: {
|
||||
select: { id: true, name: true, status: true, district: true, address: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return rows.map((row) => this.toStaffItem(row));
|
||||
}
|
||||
|
||||
async createStaff(actor: AuthUser, dto: CreateStoreStaffDto) {
|
||||
const parent = await this.assertPrimary(actor.actorId);
|
||||
const phone = dto.phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
throw new BadRequestException('请输入正确的手机号码');
|
||||
}
|
||||
const existing = await this.prisma.storeAccount.findUnique({ where: { phone } });
|
||||
if (existing) throw new BadRequestException('该手机号已被使用');
|
||||
|
||||
const name = dto.name.trim();
|
||||
if (!name) throw new BadRequestException('请填写真实姓名');
|
||||
|
||||
const storeIds = await this.resolveOwnedStoreIds(parent.id, dto.storeIds);
|
||||
if (!storeIds.length) throw new BadRequestException('请至少绑定一家门店');
|
||||
|
||||
const staffRole = (dto.staffRole as StoreStaffRole | undefined) ?? StoreStaffRole.CASHIER;
|
||||
const permissions = dto.permissions?.length
|
||||
? dto.permissions
|
||||
: [...STORE_STAFF_DEFAULT_PERMISSIONS];
|
||||
|
||||
const account = await this.prisma.storeAccount.create({
|
||||
data: {
|
||||
phone,
|
||||
name,
|
||||
isPrimary: 0,
|
||||
parentAccountId: parent.id,
|
||||
staffRole,
|
||||
permissions,
|
||||
status: 'ACTIVE',
|
||||
bindings: {
|
||||
create: storeIds.map((storeId) => ({ storeId })),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
bindings: {
|
||||
include: {
|
||||
store: {
|
||||
select: { id: true, name: true, status: true, district: true, address: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.trackStaffEvent(actor, parent.id, 'store_staff_create', account.id, {
|
||||
name,
|
||||
phone: this.maskPhone(phone),
|
||||
staffRole,
|
||||
storeIds: storeIds.map(String),
|
||||
});
|
||||
|
||||
return this.toStaffItem(account);
|
||||
}
|
||||
|
||||
async updateStaff(actor: AuthUser, staffId: bigint, dto: UpdateStoreStaffDto) {
|
||||
const parent = await this.assertPrimary(actor.actorId);
|
||||
const staff = await this.assertStaffOwned(parent.id, staffId);
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (dto.name !== undefined) {
|
||||
const name = dto.name.trim();
|
||||
if (!name) throw new BadRequestException('请填写真实姓名');
|
||||
data.name = name;
|
||||
}
|
||||
if (dto.staffRole !== undefined) data.staffRole = dto.staffRole;
|
||||
if (dto.permissions !== undefined) data.permissions = dto.permissions;
|
||||
if (dto.status !== undefined) data.status = dto.status;
|
||||
|
||||
if (dto.storeIds !== undefined) {
|
||||
const storeIds = await this.resolveOwnedStoreIds(parent.id, dto.storeIds);
|
||||
if (!storeIds.length) throw new BadRequestException('请至少绑定一家门店');
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.storeAccountStore.deleteMany({ where: { storeAccountId: staff.id } }),
|
||||
this.prisma.storeAccountStore.createMany({
|
||||
data: storeIds.map((storeId) => ({ storeAccountId: staff.id, storeId })),
|
||||
}),
|
||||
this.prisma.storeAccount.update({ where: { id: staff.id }, data }),
|
||||
]);
|
||||
} else if (Object.keys(data).length) {
|
||||
await this.prisma.storeAccount.update({ where: { id: staff.id }, data });
|
||||
}
|
||||
|
||||
const updated = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: staff.id },
|
||||
include: {
|
||||
bindings: {
|
||||
include: {
|
||||
store: {
|
||||
select: { id: true, name: true, status: true, district: true, address: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.trackStaffEvent(actor, parent.id, 'store_staff_update', staff.id, {
|
||||
name: updated.name,
|
||||
status: updated.status,
|
||||
staffRole: updated.staffRole,
|
||||
});
|
||||
|
||||
return this.toStaffItem(updated);
|
||||
}
|
||||
|
||||
async deleteStaff(actor: AuthUser, staffId: bigint) {
|
||||
const parent = await this.assertPrimary(actor.actorId);
|
||||
const staff = await this.assertStaffOwned(parent.id, staffId);
|
||||
this.trackStaffEvent(actor, parent.id, 'store_staff_delete', staff.id, {
|
||||
name: staff.name,
|
||||
phone: this.maskPhone(staff.phone),
|
||||
});
|
||||
await this.prisma.storeAccount.delete({ where: { id: staff.id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async assertPrimary(accountId: bigint) {
|
||||
const account = await this.prisma.storeAccount.findUnique({ where: { id: accountId } });
|
||||
if (!account) throw new NotFoundException('门店账号不存在');
|
||||
if (account.isPrimary !== 1) {
|
||||
throw new ForbiddenException('仅主账号可管理子账号');
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
private async assertStaffOwned(parentAccountId: bigint, staffId: bigint) {
|
||||
const staff = await this.prisma.storeAccount.findFirst({
|
||||
where: { id: staffId, parentAccountId },
|
||||
});
|
||||
if (!staff) throw new NotFoundException('子账号不存在');
|
||||
return staff;
|
||||
}
|
||||
|
||||
/** Staff may only bind stores that the primary account itself is bound to. */
|
||||
private async resolveOwnedStoreIds(primaryAccountId: bigint, storeIds: string[]) {
|
||||
const unique = [...new Set(storeIds.map((id) => id.trim()).filter(Boolean))];
|
||||
const ids = unique.map((id) => BigInt(id));
|
||||
const owned = await this.prisma.storeAccountStore.findMany({
|
||||
where: { storeAccountId: primaryAccountId, storeId: { in: ids } },
|
||||
select: { storeId: true },
|
||||
});
|
||||
if (owned.length !== ids.length) {
|
||||
throw new BadRequestException('只能绑定主账号已管理的门店');
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private trackStaffEvent(
|
||||
actor: AuthUser,
|
||||
primaryAccountId: bigint,
|
||||
eventName: string,
|
||||
refId: bigint,
|
||||
extraJson?: Record<string, unknown>,
|
||||
) {
|
||||
this.analytics.trackStoreOneSafe(actor.actorId, actor.clientApp, {
|
||||
storeId: actor.storeId,
|
||||
eventName,
|
||||
refType: 'STORE_ACCOUNT',
|
||||
refId,
|
||||
extraJson: { primaryAccountId: primaryAccountId.toString(), ...extraJson },
|
||||
});
|
||||
}
|
||||
|
||||
private toStaffItem(row: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
phone: string;
|
||||
staffRole: string | null;
|
||||
permissions?: unknown;
|
||||
status: string;
|
||||
lastLoginAt: Date | null;
|
||||
bindings: Array<{
|
||||
store: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
status: string;
|
||||
district: string;
|
||||
address: string;
|
||||
};
|
||||
}>;
|
||||
}) {
|
||||
return serializeBigInt({
|
||||
id: row.id.toString(),
|
||||
name: row.name,
|
||||
phone: this.maskPhone(row.phone),
|
||||
staffRole: row.staffRole ?? StoreStaffRole.CASHIER,
|
||||
permissions: Array.isArray(row.permissions) ? row.permissions : undefined,
|
||||
status: row.status,
|
||||
storeIds: row.bindings.map((b) => b.store.id.toString()),
|
||||
stores: row.bindings.map((b) => ({
|
||||
storeId: b.store.id.toString(),
|
||||
name: b.store.name,
|
||||
status: b.store.status,
|
||||
district: b.store.district,
|
||||
address: b.store.address,
|
||||
})),
|
||||
lastLoginAt: row.lastLoginAt?.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
private maskPhone(phone: string): string {
|
||||
if (phone.length !== 11) return phone;
|
||||
return `${phone.slice(0, 3)} **** ${phone.slice(7)}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user