门店账户多账号

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
+40 -15
View File
@@ -147,6 +147,11 @@ enum PartnerStaffRole {
PROMOTER
}
enum StoreStaffRole {
MANAGER
CASHIER
}
enum AccountStatus {
ACTIVE
DISABLED
@@ -697,9 +702,6 @@ model Store {
status StoreStatus @default(PAUSED)
openTime String? @map("open_time") @db.VarChar(8)
closeTime String? @map("close_time") @db.VarChar(8)
bankAccountName String? @map("bank_account_name") @db.VarChar(64)
bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
bankBranch String? @map("bank_branch") @db.VarChar(128)
settlementRate Decimal @default(0.60) @map("settlement_rate") @db.Decimal(5, 4)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -708,7 +710,7 @@ model Store {
partnerAccount PartnerAccount @relation(fields: [partnerAccountId], references: [id], onDelete: Restrict)
category CommonStoreCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
coverResource CommonResource? @relation("StoreCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
account StoreAccount?
bindings StoreAccountStore[]
redeemRecords RedeemRecord[]
redeemPendingRecords RedeemPendingRecord[]
ratings StoreRating[]
@@ -720,23 +722,46 @@ model Store {
}
model StoreAccount {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
storeId BigInt @unique @map("store_id") @db.UnsignedBigInt
phone String @unique @db.VarChar(20)
name String @db.VarChar(64)
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
status AccountStatus @default(ACTIVE)
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
phone String @unique @db.VarChar(20)
name String @db.VarChar(64)
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
isPrimary Int @default(1) @map("is_primary") @db.TinyInt
parentAccountId BigInt? @map("parent_account_id") @db.UnsignedBigInt
staffRole StoreStaffRole? @map("staff_role")
permissions Json?
bankAccountName String? @map("bank_account_name") @db.VarChar(64)
bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
bankBranch String? @map("bank_branch") @db.VarChar(128)
status AccountStatus @default(ACTIVE)
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
parentAccount StoreAccount? @relation("StoreAccountHierarchy", fields: [parentAccountId], references: [id], onDelete: Restrict)
childAccounts StoreAccount[] @relation("StoreAccountHierarchy")
bindings StoreAccountStore[]
redeemPendingRecords RedeemPendingRecord[]
@@index([parentAccountId])
@@map("store_account")
}
model StoreAccountStore {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
storeAccountId BigInt @map("store_account_id") @db.UnsignedBigInt
storeId BigInt @map("store_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
storeAccount StoreAccount @relation(fields: [storeAccountId], references: [id], onDelete: Cascade)
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
@@unique([storeAccountId, storeId])
@@index([storeId])
@@map("store_account_store")
}
// ─── ORDER ────────────────────────────────────────────
model Order {
+43 -22
View File
@@ -429,12 +429,6 @@ async function main() {
closeTime: '22:00',
bankAccountName: def.name,
bankAccountNo: '6222029876543210',
bankBranch: '建设银行郑州分行',
},
});
@@ -443,20 +437,41 @@ async function main() {
await prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
if (def.withAccount) {
await prisma.storeAccount.create({
data: { storeId: store.id, phone: def.phone, name: def.name },
});
}
createdStores.push({ id: store.id, name: def.name });
}
// 一号两店:主账号 13910000001 绑定老城店 + 美食城店,便于测选店
const multiStorePrimary = await prisma.storeAccount.create({
data: {
phone: '13910000001',
name: '郑州老城店主',
isPrimary: 1,
bankAccountName: '郑州老城店主',
bankAccountNo: '6222029876543210',
bankBranch: '建设银行郑州分行',
bindings: {
create: createdStores.map((s) => ({ storeId: s.id })),
},
},
});
// 子账号样例:仅绑定第一家店
await prisma.storeAccount.create({
data: {
phone: '13910000011',
name: '老城店收银员',
isPrimary: 0,
parentAccountId: multiStorePrimary.id,
staffRole: 'CASHIER',
permissions: ['redeem', 'records'],
status: 'ACTIVE',
bindings: {
create: [{ storeId: createdStores[0].id }],
},
},
});
const weekStart = (() => {
@@ -511,12 +526,6 @@ async function main() {
closeTime: '22:00',
bankAccountName: '本周新签体验店',
bankAccountNo: '6222029876543211',
bankBranch: '农业银行郑州分行',
createdAt: new Date(weekStart.getTime() + 2 * 24 * 60 * 60 * 1000),
},
@@ -525,6 +534,18 @@ async function main() {
createdStores.push({ id: newWeekStore.id, name: newWeekStore.name });
await prisma.storeAccount.create({
data: {
phone: '13910000003',
name: '本周新签体验店',
isPrimary: 1,
bankAccountName: '本周新签体验店',
bankAccountNo: '6222029876543211',
bankBranch: '农业银行郑州分行',
bindings: { create: [{ storeId: newWeekStore.id }] },
},
});
await prisma.user.create({
@@ -13,6 +13,8 @@ export interface AuthUser {
clientApp: ClientApp;
sub: string;
phoneVerified: boolean;
/** Selected store after POST /shop/auth/select-store */
storeId?: bigint;
}
@Injectable()
@@ -41,6 +43,9 @@ export class JwtAuthGuard implements CanActivate {
clientApp,
sub: payload.sub,
phoneVerified: !!payload.phoneVerified,
...(payload.storeId != null && payload.storeId !== ''
? { storeId: BigInt(payload.storeId) }
: {}),
} satisfies AuthUser;
return true;
} catch (err) {
@@ -29,6 +29,9 @@ export class OptionalJwtAuthGuard implements CanActivate {
clientApp,
sub: payload.sub,
phoneVerified: !!payload.phoneVerified,
...(payload.storeId != null && payload.storeId !== ''
? { storeId: BigInt(payload.storeId) }
: {}),
} satisfies AuthUser;
} catch {
/* ignore invalid token */
@@ -0,0 +1,15 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import type { AuthUser } from './jwt-auth.guard';
/** Shop business APIs require JWT claim storeId (after select-store). */
@Injectable()
export class ShopStoreGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const user = req.user as AuthUser | undefined;
if (!user?.storeId) {
throw new ForbiddenException('请先选择门店');
}
return true;
}
}
@@ -0,0 +1,27 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.module';
import type { AuthUser } from '../guards/jwt-auth.guard';
@Injectable()
export class StoreMembershipService {
constructor(private readonly prisma: PrismaService) {}
async assertStoreMembership(accountId: bigint, storeId: bigint) {
const binding = await this.prisma.storeAccountStore.findUnique({
where: {
storeAccountId_storeId: { storeAccountId: accountId, storeId },
},
});
if (!binding) {
throw new ForbiddenException('无权访问该门店');
}
return binding;
}
requireShopStoreId(user: AuthUser): bigint {
if (!user.storeId) {
throw new ForbiddenException('请先选择门店');
}
return user.storeId;
}
}
@@ -13,7 +13,7 @@ export type TrackEventInput = {
export type TrackStoreEventInput = TrackEventInput & {
storeAccountId?: bigint;
storeId: bigint;
storeId?: bigint;
};
export type TrackPartnerEventInput = TrackEventInput & {
@@ -54,12 +54,14 @@ export class AnalyticsService {
}
async trackStoreOne(storeAccountId: bigint | undefined, clientApp: ClientApp | string, event: TrackStoreEventInput) {
if (event.storeId == null) return;
await this.prisma.logStoreAnalytics.create({
data: this.toStoreRow(storeAccountId, clientApp, event),
});
}
trackStoreOneSafe(storeAccountId: bigint | undefined, clientApp: ClientApp | string, event: TrackStoreEventInput) {
if (event.storeId == null) return;
void this.trackStoreOne(storeAccountId, clientApp, event).catch(() => {});
}
@@ -100,7 +102,7 @@ export class AnalyticsService {
) {
return {
storeAccountId,
storeId: event.storeId,
storeId: event.storeId!,
eventName: event.eventName,
clientApp: clientApp as ClientApp,
refType: event.refType,
@@ -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);
}
}
+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,
};
}
}
@@ -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)}`;
}
}
@@ -54,16 +54,20 @@ export class AdminRedeemDebugService {
throw new NotFoundException('用户不存在,请检查 ID、用户编号或手机号');
}
private async resolveStoreAccountId(storeId: string): Promise<bigint> {
const account = await this.prisma.storeAccount.findFirst({
where: { storeId: this.parseStoreId(storeId), status: 'ACTIVE' },
private async resolveStoreAccountId(storeId: string): Promise<{ accountId: bigint; storeId: bigint }> {
const sid = this.parseStoreId(storeId);
const binding = await this.prisma.storeAccountStore.findFirst({
where: {
storeId: sid,
storeAccount: { status: 'ACTIVE', isPrimary: 1 },
},
orderBy: { id: 'asc' },
select: { id: true, store: { select: { name: true } } },
select: { storeAccountId: true, storeId: true },
});
if (!account) {
if (!binding) {
throw new NotFoundException('该门店无可用账户,请先创建门店账户');
}
return account.id;
return { accountId: binding.storeAccountId, storeId: binding.storeId };
}
async createToken(dto: AdminRedeemDebugCreateTokenDto) {
@@ -76,32 +80,32 @@ export class AdminRedeemDebugService {
}
async preview(dto: AdminRedeemDebugStoreTokenDto) {
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.previewRedeem(storeAccountId, dto.token);
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.previewRedeem(accountId, storeId, dto.token);
}
async confirm(dto: AdminRedeemDebugStoreTokenDto) {
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.confirmRedeem(storeAccountId, { token: dto.token });
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.confirmRedeem(accountId, storeId, { token: dto.token });
}
async sendPhoneLookupSms(dto: AdminRedeemDebugPhoneStoreDto) {
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.sendPhoneLookupSms(storeAccountId, dto.phone);
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.sendPhoneLookupSms(accountId, storeId, dto.phone);
}
async phoneBalance(dto: AdminRedeemDebugPhoneBalanceDto) {
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.verifyPhoneAndGetBalance(storeAccountId, dto.phone, dto.code);
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.verifyPhoneAndGetBalance(accountId, storeId, dto.phone, dto.code);
}
async phonePrepare(dto: AdminRedeemDebugPhonePrepareDto) {
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.preparePhoneRedeem(storeAccountId, dto.sessionId, dto.amount);
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.preparePhoneRedeem(accountId, storeId, dto.sessionId, dto.amount);
}
async phoneConfirm(dto: AdminRedeemDebugPhoneConfirmDto) {
const storeAccountId = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.confirmPhoneRedeem(storeAccountId, dto.sessionId, dto.code);
const { accountId, storeId } = await this.resolveStoreAccountId(dto.storeId);
return this.redeemService.confirmPhoneRedeem(accountId, storeId, dto.sessionId, dto.code);
}
}
@@ -131,11 +131,13 @@ export class AdminStoreLogsService {
if (query.phone) accountWhere.phone = { contains: query.phone };
const accounts = await this.prisma.storeAccount.findMany({
where: accountWhere,
select: { storeId: true },
select: {
bindings: { select: { storeId: true } },
},
take: 100,
});
if (accounts.length === 0) return [];
const ids = [...new Set(accounts.map((a) => a.storeId))];
const ids = [...new Set(accounts.flatMap((a) => a.bindings.map((b) => b.storeId)))];
if (storeWhere.name) {
const stores = await this.prisma.store.findMany({
where: { id: { in: ids }, ...storeWhere },
@@ -41,14 +41,26 @@ export class AdminStoresService {
include: {
cityRef: { select: { id: true, name: true, code: true } },
partnerAccount: { select: { id: true, companyName: true } },
account: { select: { id: true, phone: true, name: true, status: true } },
bindings: {
where: { storeAccount: { isPrimary: 1 } },
take: 1,
include: {
storeAccount: { select: { id: true, phone: true, name: true, status: true } },
},
},
coverResource: { select: { id: true, url: true } },
},
}),
this.prisma.store.count({ where }),
]);
return serializeBigInt({
items: items.map((s) => mapStoreCompat(s)),
items: items.map((s) =>
mapStoreCompat({
...s,
account: s.bindings[0]?.storeAccount ?? null,
bindings: undefined,
}),
),
total,
page,
pageSize,
@@ -62,7 +74,11 @@ export class AdminStoresService {
cityRef: true,
partnerAccount: true,
category: true,
account: true,
bindings: {
where: { storeAccount: { isPrimary: 1 } },
take: 1,
include: { storeAccount: true },
},
coverResource: true,
_count: { select: { redeemRecords: true, ratings: true } },
},
@@ -81,6 +97,8 @@ export class AdminStoresService {
]);
return serializeBigInt(mapStoreCompat({
...store,
account: store.bindings[0]?.storeAccount ?? null,
bindings: undefined,
media,
audits,
redeemCount: store._count.redeemRecords,
@@ -165,7 +183,12 @@ export class AdminStoresService {
const existingAccount = await this.prisma.storeAccount.findUnique({
where: { phone: normalizedPhone },
});
if (existingAccount) throw new BadRequestException('该手机号已绑定门店');
if (existingAccount && existingAccount.isPrimary !== 1) {
throw new BadRequestException('该手机号已是门店子账号');
}
if (existingAccount && existingAccount.status !== 'ACTIVE') {
throw new BadRequestException('该手机号对应门店账号已停用');
}
const partnerAccountId = BigInt(dto.partnerAccountId);
const partnerAccount = await this.prisma.partnerAccount.findUnique({
@@ -191,9 +214,6 @@ export class AdminStoresService {
district: dto.district ?? '',
address: dto.address,
intro: dto.intro ?? null,
bankAccountName: dto.bankAccountName ?? null,
bankAccountNo: dto.bankAccountNo ?? null,
bankBranch: dto.bankBranch ?? null,
openTime: '10:00',
closeTime: '22:00',
status: 'OPEN',
@@ -258,13 +278,37 @@ export class AdminStoresService {
},
});
await this.prisma.storeAccount.create({
data: {
storeId: store.id,
phone: normalizedPhone,
name: dto.accountName ?? dto.name,
},
});
const bankAccountName = dto.bankAccountName ?? null;
const bankAccountNo = dto.bankAccountNo ?? null;
const bankBranch = dto.bankBranch ?? null;
if (existingAccount) {
await this.prisma.storeAccountStore.create({
data: { storeAccountId: existingAccount.id, storeId: store.id },
});
if (bankAccountName || bankAccountNo || bankBranch) {
await this.prisma.storeAccount.update({
where: { id: existingAccount.id },
data: {
...(bankAccountName != null ? { bankAccountName } : {}),
...(bankAccountNo != null ? { bankAccountNo } : {}),
...(bankBranch != null ? { bankBranch } : {}),
},
});
}
} else {
await this.prisma.storeAccount.create({
data: {
phone: normalizedPhone,
name: dto.accountName ?? dto.name,
isPrimary: 1,
bankAccountName,
bankAccountNo,
bankBranch,
bindings: { create: [{ storeId: store.id }] },
},
});
}
return this.detailStore(store.id);
}
@@ -272,12 +316,37 @@ export class AdminStoresService {
async createStoreAccount(dto: CreateStoreAccountDto) {
const store = await this.prisma.store.findUnique({
where: { id: BigInt(dto.storeId) },
include: { account: true },
include: { bindings: true },
});
if (!store) throw new BadRequestException('门店不存在');
if (store.account) throw new BadRequestException('门店已有账户');
const primaryBound = store.bindings.length > 0
? await this.prisma.storeAccount.findFirst({
where: {
isPrimary: 1,
bindings: { some: { storeId: store.id } },
},
})
: null;
if (primaryBound) throw new BadRequestException('门店已有主账号绑定');
const existing = await this.prisma.storeAccount.findUnique({ where: { phone: dto.phone } });
if (existing) {
if (existing.isPrimary !== 1) {
throw new BadRequestException('该手机号已是门店子账号');
}
await this.prisma.storeAccountStore.create({
data: { storeAccountId: existing.id, storeId: store.id },
});
return serializeBigInt(existing);
}
const account = await this.prisma.storeAccount.create({
data: { storeId: store.id, phone: dto.phone, name: dto.name },
data: {
phone: dto.phone,
name: dto.name,
isPrimary: 1,
bindings: { create: [{ storeId: store.id }] },
},
});
return serializeBigInt(account);
}
@@ -345,9 +414,11 @@ export class AdminStoresService {
async listStoreAccounts(query: AdminStoreAccountsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.StoreAccountWhereInput = {};
const where: Prisma.StoreAccountWhereInput = { isPrimary: 1 };
if (query.phone) where.phone = { contains: query.phone };
if (query.storeId) where.storeId = BigInt(query.storeId);
if (query.storeId) {
where.bindings = { some: { storeId: BigInt(query.storeId) } };
}
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
const [items, total] = await Promise.all([
@@ -357,21 +428,53 @@ export class AdminStoresService {
skip: (page - 1) * pageSize,
take: pageSize,
include: {
store: { select: { id: true, name: true, status: true, cityName: true } },
bindings: {
include: {
store: { select: { id: true, name: true, status: true, cityName: true } },
},
},
_count: { select: { childAccounts: true, bindings: true } },
},
}),
this.prisma.storeAccount.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
const mapped = items.map((row) => ({
...row,
storeCount: row._count.bindings,
staffCount: row._count.childAccounts,
stores: row.bindings.map((b) => b.store),
store: row.bindings[0]?.store ?? null,
}));
return serializeBigInt({ items: mapped, total, page, pageSize });
}
async detailStoreAccount(id: bigint) {
const account = await this.prisma.storeAccount.findUnique({
where: { id },
include: { store: { include: { cityRef: true, partnerAccount: true } } },
include: {
bindings: {
include: {
store: { include: { cityRef: true, partnerAccount: true } },
},
},
childAccounts: {
include: {
bindings: {
include: {
store: { select: { id: true, name: true, status: true } },
},
},
},
},
},
});
if (!account) throw new NotFoundException('门店账号不存在');
return serializeBigInt(account);
return serializeBigInt({
...account,
stores: account.bindings.map((b) => b.store),
store: account.bindings[0]?.store ?? null,
staff: account.childAccounts,
});
}
async updateStoreAccount(id: bigint, dto: UpdateStoreAccountDto) {
@@ -185,11 +185,17 @@ export class AdminWechatBindingsService {
const accounts = await this.prisma.storeAccount.findMany({
where,
include: { store: { select: { id: true, name: true } } },
include: {
bindings: {
take: 1,
include: { store: { select: { id: true, name: true } } },
},
},
});
for (const a of accounts) {
if (!a.wxOpenId) continue;
const firstStore = a.bindings[0]?.store;
rows.push({
actorType: 'STORE',
actorId: a.id,
@@ -197,8 +203,8 @@ export class AdminWechatBindingsService {
name: a.name,
wxOpenId: a.wxOpenId,
wxUnionId: a.wxUnionId,
refId: a.storeId,
refLabel: a.store.name,
refId: firstStore?.id,
refLabel: firstStore?.name ?? a.name,
lastLoginAt: a.lastLoginAt,
status: a.status,
});
@@ -1,6 +1,7 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { RedeemService } from './redeem.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import {
RedeemPhoneBalanceDto,
@@ -37,18 +38,18 @@ export class UserRedeemController {
}
@Controller('shop/redeem')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, ShopStoreGuard)
export class ShopRedeemController {
constructor(private readonly redeemService: RedeemService) {}
@Post('preview')
preview(@CurrentUser() user: AuthUser, @Body() body: { token: string }) {
return this.redeemService.previewRedeem(user.actorId, body.token);
return this.redeemService.previewRedeem(user.actorId, user.storeId!, body.token);
}
@Post('confirm')
confirm(@CurrentUser() user: AuthUser, @Body() body: { token: string }) {
return this.redeemService.confirmRedeem(user.actorId, body);
return this.redeemService.confirmRedeem(user.actorId, user.storeId!, body);
}
@Post('failures')
@@ -56,7 +57,7 @@ export class ShopRedeemController {
@CurrentUser() user: AuthUser,
@Body() body: RedeemFailureReportDto,
) {
return this.redeemService.reportNetworkFailure(user.actorId, body);
return this.redeemService.reportNetworkFailure(user.actorId, user.storeId!, body);
}
@Post('pending')
@@ -64,7 +65,7 @@ export class ShopRedeemController {
@CurrentUser() user: AuthUser,
@Body() body: RedeemPendingSubmitDto,
) {
return this.redeemService.submitPendingRedeem(user.actorId, body);
return this.redeemService.submitPendingRedeem(user.actorId, user.storeId!, body);
}
@Get('records')
@@ -73,26 +74,46 @@ export class ShopRedeemController {
@Query('page') page = '1',
@Query('pageSize') pageSize = '20',
) {
return this.redeemService.listShopRecords(user.actorId, Number(page), Number(pageSize));
return this.redeemService.listShopRecords(
user.actorId,
user.storeId!,
Number(page),
Number(pageSize),
);
}
@Post('phone/send-lookup-sms')
sendPhoneLookupSms(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneSendLookupSmsDto) {
return this.redeemService.sendPhoneLookupSms(user.actorId, body.phone);
return this.redeemService.sendPhoneLookupSms(user.actorId, user.storeId!, body.phone);
}
@Post('phone/balance')
phoneBalance(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneBalanceDto) {
return this.redeemService.verifyPhoneAndGetBalance(user.actorId, body.phone, body.code);
return this.redeemService.verifyPhoneAndGetBalance(
user.actorId,
user.storeId!,
body.phone,
body.code,
);
}
@Post('phone/prepare')
phonePrepare(@CurrentUser() user: AuthUser, @Body() body: RedeemPhonePrepareDto) {
return this.redeemService.preparePhoneRedeem(user.actorId, body.sessionId, body.amount);
return this.redeemService.preparePhoneRedeem(
user.actorId,
user.storeId!,
body.sessionId,
body.amount,
);
}
@Post('phone/confirm')
phoneConfirm(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneConfirmDto) {
return this.redeemService.confirmPhoneRedeem(user.actorId, body.sessionId, body.code);
return this.redeemService.confirmPhoneRedeem(
user.actorId,
user.storeId!,
body.sessionId,
body.code,
);
}
}
@@ -89,15 +89,28 @@ export class RedeemService {
return `redeem:phone-session:${sessionId}`;
}
private async loadOpenStoreAccount(storeAccountId: bigint) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
include: { store: true },
private async loadOpenStoreAccount(storeAccountId: bigint, storeId: bigint) {
const binding = await this.prisma.storeAccountStore.findUnique({
where: {
storeAccountId_storeId: { storeAccountId, storeId },
},
include: {
storeAccount: true,
store: true,
},
});
if (account.store.status !== 'OPEN') {
if (!binding) throw new BadRequestException('无权访问该门店');
if (binding.storeAccount.status !== 'ACTIVE') {
throw new BadRequestException('门店账号已停用');
}
if (binding.store.status !== 'OPEN') {
throw new BadRequestException('门店未营业');
}
return account;
return {
...binding.storeAccount,
storeId: binding.store.id,
store: binding.store,
};
}
private async resolveUserByPhone(phone: string) {
@@ -228,8 +241,8 @@ export class RedeemService {
return record;
}
async sendPhoneLookupSms(storeAccountId: bigint, phone: string) {
const account = await this.loadOpenStoreAccount(storeAccountId);
async sendPhoneLookupSms(storeAccountId: bigint, storeId: bigint, phone: string) {
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
const normalizedPhone = this.normalizeMobilePhone(phone);
await this.resolveUserByPhone(normalizedPhone);
await this.authService.sendSms(normalizedPhone, SmsScene.REDEEM_PHONE_LOOKUP, {
@@ -243,8 +256,8 @@ export class RedeemService {
return { ok: true, maskedPhone: this.maskPhoneForStore(normalizedPhone) };
}
async verifyPhoneAndGetBalance(storeAccountId: bigint, phone: string, code: string) {
const account = await this.loadOpenStoreAccount(storeAccountId);
async verifyPhoneAndGetBalance(storeAccountId: bigint, storeId: bigint, phone: string, code: string) {
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
const normalizedPhone = this.normalizeMobilePhone(phone);
const user = await this.resolveUserByPhone(normalizedPhone);
await this.authService.verifySmsCode(normalizedPhone, code, SmsScene.REDEEM_PHONE_LOOKUP);
@@ -298,8 +311,8 @@ export class RedeemService {
return session;
}
async preparePhoneRedeem(storeAccountId: bigint, sessionId: string, amount: number) {
const account = await this.loadOpenStoreAccount(storeAccountId);
async preparePhoneRedeem(storeAccountId: bigint, storeId: bigint, sessionId: string, amount: number) {
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
const session = await this.loadPhoneSession(sessionId, storeAccountId);
const userId = BigInt(session.userId);
const { allocations } = await this.computeDirectAllocations(userId, amount);
@@ -337,8 +350,8 @@ export class RedeemService {
};
}
async confirmPhoneRedeem(storeAccountId: bigint, sessionId: string, code: string) {
const account = await this.loadOpenStoreAccount(storeAccountId);
async confirmPhoneRedeem(storeAccountId: bigint, storeId: bigint, sessionId: string, code: string) {
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
const session = await this.loadPhoneSession(sessionId, storeAccountId);
if (!session.confirmPrepared || session.amount == null || !session.allocations?.length) {
throw new BadRequestException('请先选择核销金额并发送确认验证码');
@@ -462,8 +475,8 @@ export class RedeemService {
return { status: 'EXPIRED' as const };
}
async previewRedeem(storeAccountId: bigint, token: string) {
const account = await this.loadOpenStoreAccount(storeAccountId);
async previewRedeem(storeAccountId: bigint, storeId: bigint, token: string) {
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${token}`);
if (!cached) throw new BadRequestException('核销码无效或已过期');
@@ -516,8 +529,8 @@ export class RedeemService {
});
}
async confirmRedeem(storeAccountId: bigint, body: { token: string }) {
const account = await this.loadOpenStoreAccount(storeAccountId);
async confirmRedeem(storeAccountId: bigint, storeId: bigint, body: { token: string }) {
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
const token = body.token?.trim();
if (!token) throw new BadRequestException('请提供核销码');
@@ -594,6 +607,7 @@ export class RedeemService {
async reportNetworkFailure(
storeAccountId: bigint,
storeId: bigint,
body: {
token: string;
errorClass: 'NETWORK' | 'BUSINESS';
@@ -601,9 +615,7 @@ export class RedeemService {
step: 'preview' | 'confirm';
},
) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
});
await this.loadOpenStoreAccount(storeAccountId, storeId);
const token = body.token.trim();
if (!token) throw new BadRequestException('请提供核销码');
@@ -618,7 +630,7 @@ export class RedeemService {
const thresholdReached = failCount >= REDEEM_WEAKNET_FAIL_THRESHOLD;
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
storeId: account.storeId,
storeId,
eventName: 'store_redeem_confirm_fail',
extraJson: {
token,
@@ -632,7 +644,7 @@ export class RedeemService {
if (thresholdReached && body.errorClass === 'NETWORK') {
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
storeId: account.storeId,
storeId,
eventName: 'store_redeem_weaknet_threshold',
extraJson: {
token,
@@ -670,9 +682,10 @@ export class RedeemService {
async submitPendingRedeem(
storeAccountId: bigint,
storeId: bigint,
body: { token: string; photoResourceId: string; failCount?: number; remark?: string },
) {
const account = await this.loadOpenStoreAccount(storeAccountId);
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
const token = body.token.trim();
if (!token) throw new BadRequestException('请提供核销码');
@@ -831,13 +844,7 @@ export class RedeemService {
throw new BadRequestException('待处理单状态不可补核销');
}
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: pending.storeAccountId },
include: { store: true },
});
if (account.store.status !== 'OPEN') {
throw new BadRequestException('门店未营业,无法补核销');
}
const account = await this.loadOpenStoreAccount(pending.storeAccountId, pending.storeId);
const allocationsRaw = pending.allocationsJson as Array<{ couponId: string; amount: number }>;
const normalizedAllocations = allocationsRaw.map((item) => ({
@@ -941,42 +948,42 @@ export class RedeemService {
return serializeBigInt(updated);
}
async listShopRecords(storeAccountId: bigint, page = 1, pageSize = 20) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
async listShopRecords(storeAccountId: bigint, storeId: bigint, page = 1, pageSize = 20) {
await this.prisma.storeAccountStore.findUniqueOrThrow({
where: { storeAccountId_storeId: { storeAccountId, storeId } },
});
const [list, total] = await Promise.all([
this.prisma.redeemRecord.findMany({
where: { storeId: account.storeId },
where: { storeId },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: { payout: true },
}),
this.prisma.redeemRecord.count({ where: { storeId: account.storeId } }),
this.prisma.redeemRecord.count({ where: { storeId } }),
]);
return { list: serializeBigInt(list), total, page, pageSize };
}
async getShopDashboard(storeAccountId: bigint) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
async getShopDashboard(storeAccountId: bigint, storeId: bigint) {
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
where: { storeAccountId_storeId: { storeAccountId, storeId } },
include: { store: true },
});
const start = new Date();
start.setHours(0, 0, 0, 0);
const records = await this.prisma.redeemRecord.findMany({
where: { storeId: account.storeId, createdAt: { gte: start } },
where: { storeId, createdAt: { gte: start } },
});
const todayCount = records.length;
const todayAmount = records.reduce((sum, r) => sum + Number(r.amount), 0);
const recent = await this.prisma.redeemRecord.findMany({
where: { storeId: account.storeId },
where: { storeId },
orderBy: { createdAt: 'desc' },
take: 3,
});
return serializeBigInt({
store: account.store,
store: binding.store,
todayCount,
todayAmount,
recentRecords: recent,
@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/co
import { SettlementService } from './settlement.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
@@ -20,7 +21,7 @@ export class SettlementController {
}
@Controller('shop/payouts')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, ShopStoreGuard)
export class ShopPayoutController {
constructor(private readonly settlementService: SettlementService) {}
@@ -30,7 +31,12 @@ export class ShopPayoutController {
@Query('page') page = '1',
@Query('pageSize') pageSize = '20',
) {
return this.settlementService.listShopPayouts(user.actorId, Number(page), Number(pageSize));
return this.settlementService.listShopPayouts(
user.actorId,
user.storeId!,
Number(page),
Number(pageSize),
);
}
}
@@ -54,11 +54,11 @@ export class SettlementService {
return serializeBigInt(payout);
}
async listShopPayouts(storeAccountId: bigint, page = 1, pageSize = 20) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
async listShopPayouts(storeAccountId: bigint, storeId: bigint, page = 1, pageSize = 20) {
await this.prisma.storeAccountStore.findUniqueOrThrow({
where: { storeAccountId_storeId: { storeAccountId, storeId } },
});
const where = { storeId: account.storeId };
const where = { storeId };
const [items, total] = await Promise.all([
this.prisma.storePayout.findMany({
where,
@@ -3,6 +3,7 @@ import { StoreService } from './store.service';
import { RedeemService } from '../redeem/redeem.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@Controller('stores')
@@ -105,28 +106,28 @@ export class PartnerReportController {
}
@Controller('shop/store')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, ShopStoreGuard)
export class ShopStoreController {
constructor(private readonly storeService: StoreService) {}
@Get()
info(@CurrentUser() user: AuthUser) {
return this.storeService.getShopStore(user.actorId);
return this.storeService.getShopStore(user.actorId, user.storeId!);
}
@Put('status')
status(@CurrentUser() user: AuthUser, @Body() body: { status: 'OPEN' | 'PAUSED' }) {
return this.storeService.updateShopStatus(user.actorId, body.status);
return this.storeService.updateShopStatus(user.actorId, user.storeId!, body.status);
}
}
@Controller('shop/dashboard')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, ShopStoreGuard)
export class ShopDashboardController {
constructor(private readonly redeemService: RedeemService) {}
@Get()
async dashboard(@CurrentUser() user: AuthUser) {
return this.redeemService.getShopDashboard(user.actorId);
return this.redeemService.getShopDashboard(user.actorId, user.storeId!);
}
}
@@ -116,17 +116,34 @@ export class StoreService {
}
const existingAccount = await this.prisma.storeAccount.findUnique({
where: { phone: normalizedPhone },
include: { _count: { select: { bindings: true } } },
});
if (existingAccount) {
return { available: false, message: '该手机号已绑定门店' };
if (!existingAccount) {
return { available: true, existingStoreCount: 0, needConfirm: false };
}
return { available: true };
if (existingAccount.isPrimary !== 1) {
return { available: false, message: '该手机号已是门店子账号,不可作为负责人' };
}
if (existingAccount.status !== 'ACTIVE') {
return { available: false, message: '该手机号对应门店账号已停用' };
}
const existingStoreCount = existingAccount._count.bindings;
return {
available: true,
existingStoreCount,
needConfirm: existingStoreCount > 0,
message:
existingStoreCount > 0
? `该手机号已是门店主账号(已绑 ${existingStoreCount} 家店),确认后将追加绑定新店`
: undefined,
};
}
async createStore(partnerAccountId: bigint, body: Record<string, unknown>) {
const { primaryId } = await this.resolvePartnerScope(partnerAccountId);
const normalizedPhone = String(body.phone).trim();
await this.assertStorePhoneAvailable(normalizedPhone);
const confirmBindExisting = body.confirmBindExisting === true || body.confirmBindExisting === 'true';
await this.assertStorePhoneAvailable(normalizedPhone, confirmBindExisting);
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
@@ -139,6 +156,10 @@ export class StoreService {
if (envPhotoUrls.length < 3) throw new BadRequestException('请上传至少 3 张环境照片');
if (!contractUrl) throw new BadRequestException('请上传签约合同');
const bankAccountName = body.bankAccountName ? String(body.bankAccountName) : null;
const bankAccountNo = body.bankAccountNo ? String(body.bankAccountNo) : null;
const bankBranch = body.bankBranch ? String(body.bankBranch) : null;
const store = await this.prisma.store.create({
data: {
cityId: city.id,
@@ -151,9 +172,6 @@ export class StoreService {
district: String(body.district ?? ''),
address: String(body.address),
intro: body.intro ? String(body.intro) : null,
bankAccountName: body.bankAccountName ? String(body.bankAccountName) : null,
bankAccountNo: body.bankAccountNo ? String(body.bankAccountNo) : null,
bankBranch: body.bankBranch ? String(body.bankBranch) : null,
openTime: body.openTime ? String(body.openTime) : '10:00',
closeTime: body.closeTime ? String(body.closeTime) : '22:00',
status: this.config.autoApproveStore ? 'OPEN' : 'PAUSED',
@@ -219,14 +237,38 @@ export class StoreService {
extraJson: body as never,
},
});
void audit;
await this.prisma.storeAccount.create({
data: {
storeId: store.id,
phone: normalizedPhone,
name: String(body.name),
},
const existingPrimary = await this.prisma.storeAccount.findUnique({
where: { phone: normalizedPhone },
});
if (existingPrimary && existingPrimary.isPrimary === 1) {
await this.prisma.storeAccountStore.create({
data: { storeAccountId: existingPrimary.id, storeId: store.id },
});
if (bankAccountName || bankAccountNo || bankBranch) {
await this.prisma.storeAccount.update({
where: { id: existingPrimary.id },
data: {
...(bankAccountName != null ? { bankAccountName } : {}),
...(bankAccountNo != null ? { bankAccountNo } : {}),
...(bankBranch != null ? { bankBranch } : {}),
},
});
}
} else {
await this.prisma.storeAccount.create({
data: {
phone: normalizedPhone,
name: String(body.name),
isPrimary: 1,
bankAccountName,
bankAccountNo,
bankBranch,
bindings: { create: [{ storeId: store.id }] },
},
});
}
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerAccountId: primaryId,
@@ -321,26 +363,26 @@ export class StoreService {
return this.partnerGetStore(partnerAccountId, storeId);
}
async getShopStore(storeAccountId: bigint) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
async getShopStore(storeAccountId: bigint, storeId: bigint) {
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
where: { storeAccountId_storeId: { storeAccountId, storeId } },
include: { store: { include: { category: true, coverResource: true } } },
});
return serializeBigInt(mapStoreCompat(account.store));
return serializeBigInt(mapStoreCompat(binding.store));
}
async updateShopStatus(storeAccountId: bigint, status: 'OPEN' | 'PAUSED') {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
async updateShopStatus(storeAccountId: bigint, storeId: bigint, status: 'OPEN' | 'PAUSED') {
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
where: { storeAccountId_storeId: { storeAccountId, storeId } },
include: { store: true },
});
const previousStatus = account.store.status;
const previousStatus = binding.store.status;
const store = await this.prisma.store.update({
where: { id: account.storeId },
where: { id: storeId },
data: { status },
});
this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', {
storeId: account.storeId,
storeId,
eventName: 'store_status_change',
extraJson: {
status,
@@ -721,10 +763,13 @@ export class StoreService {
}
}
private async assertStorePhoneAvailable(phone: string) {
private async assertStorePhoneAvailable(phone: string, confirmBindExisting = false) {
const result = await this.partnerCheckStorePhone(phone);
if (!result.available) {
throw new BadRequestException(result.message ?? '该手机号已绑定门店');
throw new BadRequestException(result.message ?? '该手机号不可用');
}
if (result.needConfirm && !confirmBindExisting) {
throw new BadRequestException(result.message ?? '该手机号已绑定门店,请确认后重试');
}
}