@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { isMobilePhone, isStoreContactPhone, STORE_CONTACT_PHONE_HINT, validateBusinessHours } from '@dukang/domain';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
UpdateStoreStatusDto,
|
||||
} from './dto/admin-mutate.dto';
|
||||
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
|
||||
import { HqPermissionsResolver, hqStoreCityWhere, mergeHqStoreCityWhere, type HqCityScope } from '../../common/guards/hq-permission.guard';
|
||||
|
||||
/** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */
|
||||
function normalizeStoreOptionalText(value: unknown): string | null {
|
||||
@@ -54,21 +55,54 @@ export class AdminStoresService {
|
||||
private readonly storeCategoryService: StoreCategoryService,
|
||||
private readonly analyticsService: AnalyticsService,
|
||||
private readonly testWhitelist: TestWhitelistService,
|
||||
private readonly hqPermissions: HqPermissionsResolver,
|
||||
) {}
|
||||
|
||||
async listStores(query: AdminStoresQueryDto) {
|
||||
private async storeIdsInScope(scope: HqCityScope): Promise<bigint[] | null> {
|
||||
const where = hqStoreCityWhere(scope);
|
||||
if (!where) return null;
|
||||
const rows = await this.prisma.store.findMany({ where, select: { id: true } });
|
||||
return rows.length ? rows.map((r) => r.id) : [BigInt(0)];
|
||||
}
|
||||
|
||||
private async assertStoreAccountInScope(actorId: bigint, accountId: bigint) {
|
||||
const account = await this.prisma.storeAccount.findUnique({
|
||||
where: { id: accountId },
|
||||
include: { bindings: { include: { store: { select: { cityId: true } } } } },
|
||||
});
|
||||
if (!account) throw new NotFoundException('门店账号不存在');
|
||||
const scope = await this.hqPermissions.resolveCityScope(actorId);
|
||||
if (scope === null) return account;
|
||||
const ok = account.bindings.some((b) => scope.some((id) => id === b.store.cityId));
|
||||
if (!ok) throw new ForbiddenException('无权访问该城市的门店');
|
||||
return account;
|
||||
}
|
||||
|
||||
private async assertMediaInScope(actorId: bigint, mediaId: bigint) {
|
||||
const media = await this.prisma.commonResource.findUnique({
|
||||
where: { id: mediaId },
|
||||
select: { ownerType: true, ownerId: true },
|
||||
});
|
||||
if (!media) throw new NotFoundException('资源不存在');
|
||||
if (media.ownerType === 'STORE') {
|
||||
await this.hqPermissions.assertStoreIdInScope(actorId, media.ownerId);
|
||||
}
|
||||
}
|
||||
|
||||
async listStores(query: AdminStoresQueryDto, actorId: bigint) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.StoreWhereInput = {};
|
||||
const scope = await this.hqPermissions.resolveCityScope(actorId);
|
||||
let where: Prisma.StoreWhereInput = {};
|
||||
if (query.name) where.name = { contains: query.name };
|
||||
if (query.status) where.status = query.status as Prisma.EnumStoreStatusFilter['equals'];
|
||||
if (query.auditStatus) {
|
||||
where.auditStatus = query.auditStatus as Prisma.EnumStoreAuditStatusFilter['equals'];
|
||||
}
|
||||
if (query.cityId) where.cityId = BigInt(query.cityId);
|
||||
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
|
||||
if (query.phone) where.phone = { contains: query.phone };
|
||||
if (query.excludeTest) where.isTest = false;
|
||||
where = mergeHqStoreCityWhere(where, scope, query.cityId);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.store.findMany({
|
||||
@@ -98,9 +132,10 @@ export class AdminStoresService {
|
||||
const pendingByStore = new Map<string, string>();
|
||||
// 每家店是否有待审核信息变更,供总部列表「审核信息 / 对比」快捷入口使用
|
||||
const pendingInfoByStore = new Map<string, string>();
|
||||
const redeemedByStore = new Map<string, number>();
|
||||
if (items.length) {
|
||||
const storeIds = items.map((s) => s.id);
|
||||
const [pendingReqs, pendingInfoReqs] = await Promise.all([
|
||||
const [pendingReqs, pendingInfoReqs, redeemSums] = await Promise.all([
|
||||
this.prisma.storePackageChangeRequest.findMany({
|
||||
where: { storeId: { in: storeIds }, status: 'PENDING' },
|
||||
select: { id: true, storeId: true },
|
||||
@@ -109,9 +144,17 @@ export class AdminStoresService {
|
||||
where: { storeId: { in: storeIds }, status: 'PENDING' },
|
||||
select: { id: true, storeId: true },
|
||||
}),
|
||||
this.prisma.redeemRecord.groupBy({
|
||||
by: ['storeId'],
|
||||
where: { storeId: { in: storeIds } },
|
||||
_sum: { amount: true },
|
||||
}),
|
||||
]);
|
||||
for (const r of pendingReqs) pendingByStore.set(r.storeId.toString(), r.id.toString());
|
||||
for (const r of pendingInfoReqs) pendingInfoByStore.set(r.storeId.toString(), r.id.toString());
|
||||
for (const r of redeemSums) {
|
||||
redeemedByStore.set(r.storeId.toString(), r._sum.amount != null ? Number(r._sum.amount) : 0);
|
||||
}
|
||||
}
|
||||
|
||||
return serializeBigInt({
|
||||
@@ -124,6 +167,7 @@ export class AdminStoresService {
|
||||
// 透传:mapStoreCompat 为 { ...store } 展开,新字段不会被丢弃
|
||||
pendingPackageAuditId: pendingByStore.get(s.id.toString()) ?? null,
|
||||
pendingInfoChangeId: pendingInfoByStore.get(s.id.toString()) ?? null,
|
||||
totalRedeemedBenefitAmount: redeemedByStore.get(s.id.toString()) ?? 0,
|
||||
partner: s.partnerAccount,
|
||||
account: s.bindings[0]?.storeAccount ?? null,
|
||||
bindings: undefined,
|
||||
@@ -135,7 +179,8 @@ export class AdminStoresService {
|
||||
});
|
||||
}
|
||||
|
||||
async detailStore(id: bigint) {
|
||||
async detailStore(id: bigint, actorId: bigint) {
|
||||
await this.hqPermissions.assertStoreIdInScope(actorId, id);
|
||||
const store = await this.prisma.store.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
@@ -184,7 +229,8 @@ export class AdminStoresService {
|
||||
}));
|
||||
}
|
||||
|
||||
async updateStoreStatus(id: bigint, dto: UpdateStoreStatusDto) {
|
||||
async updateStoreStatus(id: bigint, dto: UpdateStoreStatusDto, actorId: bigint) {
|
||||
await this.hqPermissions.assertStoreIdInScope(actorId, id);
|
||||
const store = await this.prisma.store.update({
|
||||
where: { id },
|
||||
data: { status: dto.status as 'OPEN' | 'PAUSED' | 'CLOSED' },
|
||||
@@ -192,7 +238,8 @@ export class AdminStoresService {
|
||||
return serializeBigInt(store);
|
||||
}
|
||||
|
||||
async auditStore(id: bigint, dto: { approved: boolean; remark?: string }) {
|
||||
async auditStore(id: bigint, dto: { approved: boolean; remark?: string }, actorId: bigint) {
|
||||
await this.hqPermissions.assertStoreIdInScope(actorId, id);
|
||||
const store = await this.prisma.store.findUnique({ where: { id } });
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
if (store.auditStatus !== 'PENDING' && store.auditStatus !== 'REJECTED') {
|
||||
@@ -258,7 +305,8 @@ export class AdminStoresService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateStore(id: bigint, dto: UpdateStoreDto) {
|
||||
async updateStore(id: bigint, dto: UpdateStoreDto, actorId: bigint) {
|
||||
await this.hqPermissions.assertStoreIdInScope(actorId, id);
|
||||
const current = await this.prisma.store.findUnique({ where: { id } });
|
||||
if (!current) throw new NotFoundException('门店不存在');
|
||||
|
||||
@@ -524,10 +572,11 @@ export class AdminStoresService {
|
||||
}
|
||||
});
|
||||
|
||||
return this.detailStore(id);
|
||||
return this.detailStore(id, actorId);
|
||||
}
|
||||
|
||||
async createStore(dto: CreateStoreDto) {
|
||||
async createStore(dto: CreateStoreDto, actorId: bigint) {
|
||||
await this.hqPermissions.assertStoreCityInScope(actorId, BigInt(dto.cityId));
|
||||
const normalizedPhone = dto.phone.trim();
|
||||
if (!isMobilePhone(normalizedPhone)) {
|
||||
throw new BadRequestException('请输入正确的手机号码');
|
||||
@@ -727,10 +776,11 @@ export class AdminStoresService {
|
||||
});
|
||||
}
|
||||
|
||||
return this.detailStore(store.id);
|
||||
return this.detailStore(store.id, actorId);
|
||||
}
|
||||
|
||||
async createStoreAccount(dto: CreateStoreAccountDto) {
|
||||
async createStoreAccount(dto: CreateStoreAccountDto, actorId: bigint) {
|
||||
await this.hqPermissions.assertStoreIdInScope(actorId, BigInt(dto.storeId));
|
||||
const store = await this.prisma.store.findUnique({
|
||||
where: { id: BigInt(dto.storeId) },
|
||||
include: { bindings: true },
|
||||
@@ -768,15 +818,24 @@ export class AdminStoresService {
|
||||
return serializeBigInt(account);
|
||||
}
|
||||
|
||||
async listStoreMedia(query: AdminStoreMediaQueryDto) {
|
||||
async listStoreMedia(query: AdminStoreMediaQueryDto, actorId: bigint) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const scope = await this.hqPermissions.resolveCityScope(actorId);
|
||||
if (query.storeId) {
|
||||
await this.hqPermissions.assertStoreIdInScope(actorId, BigInt(query.storeId));
|
||||
}
|
||||
const where: Prisma.CommonResourceWhereInput = {
|
||||
ownerType: 'STORE',
|
||||
status: 'ACTIVE',
|
||||
};
|
||||
if (query.storeId) where.ownerId = BigInt(query.storeId);
|
||||
if (query.mediaType) where.mediaType = query.mediaType as Prisma.EnumResourceMediaTypeFilter['equals'];
|
||||
const storeScope = hqStoreCityWhere(scope);
|
||||
if (storeScope && !query.storeId) {
|
||||
const ids = await this.storeIdsInScope(scope);
|
||||
if (ids) where.ownerId = { in: ids };
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.commonResource.findMany({
|
||||
@@ -790,7 +849,8 @@ export class AdminStoresService {
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async createStoreMedia(dto: CreateStoreMediaDto) {
|
||||
async createStoreMedia(dto: CreateStoreMediaDto, actorId: bigint) {
|
||||
await this.hqPermissions.assertStoreIdInScope(actorId, BigInt(dto.storeId));
|
||||
const store = await this.prisma.store.findUnique({ where: { id: BigInt(dto.storeId) } });
|
||||
if (!store) throw new BadRequestException('门店不存在');
|
||||
const media = await this.prisma.commonResource.create({
|
||||
@@ -808,7 +868,8 @@ export class AdminStoresService {
|
||||
return serializeBigInt(media);
|
||||
}
|
||||
|
||||
async updateStoreMedia(id: bigint, dto: UpdateStoreMediaDto) {
|
||||
async updateStoreMedia(id: bigint, dto: UpdateStoreMediaDto, actorId: bigint) {
|
||||
await this.assertMediaInScope(actorId, id);
|
||||
const media = await this.prisma.commonResource.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -820,7 +881,8 @@ export class AdminStoresService {
|
||||
return serializeBigInt(media);
|
||||
}
|
||||
|
||||
async deleteStoreMedia(id: bigint) {
|
||||
async deleteStoreMedia(id: bigint, actorId: bigint) {
|
||||
await this.assertMediaInScope(actorId, id);
|
||||
await this.prisma.commonResource.update({
|
||||
where: { id },
|
||||
data: { status: 'DELETED' },
|
||||
@@ -828,13 +890,20 @@ export class AdminStoresService {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async listStoreAccounts(query: AdminStoreAccountsQueryDto) {
|
||||
async listStoreAccounts(query: AdminStoreAccountsQueryDto, actorId: bigint) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
if (query.storeId) {
|
||||
await this.hqPermissions.assertStoreIdInScope(actorId, BigInt(query.storeId));
|
||||
}
|
||||
const scope = await this.hqPermissions.resolveCityScope(actorId);
|
||||
const where: Prisma.StoreAccountWhereInput = { isPrimary: 1 };
|
||||
if (query.phone) where.phone = { contains: query.phone };
|
||||
if (query.storeId) {
|
||||
where.bindings = { some: { storeId: BigInt(query.storeId) } };
|
||||
} else {
|
||||
const storeWhere = hqStoreCityWhere(scope);
|
||||
if (storeWhere) where.bindings = { some: { store: storeWhere } };
|
||||
}
|
||||
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
|
||||
if (query.excludeTest) where.isTest = false;
|
||||
@@ -866,7 +935,8 @@ export class AdminStoresService {
|
||||
return serializeBigInt({ items: mapped, total, page, pageSize });
|
||||
}
|
||||
|
||||
async detailStoreAccount(id: bigint) {
|
||||
async detailStoreAccount(id: bigint, actorId: bigint) {
|
||||
await this.assertStoreAccountInScope(actorId, id);
|
||||
const account = await this.prisma.storeAccount.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
@@ -895,7 +965,8 @@ export class AdminStoresService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateStoreAccount(id: bigint, dto: UpdateStoreAccountDto) {
|
||||
async updateStoreAccount(id: bigint, dto: UpdateStoreAccountDto, actorId: bigint) {
|
||||
await this.assertStoreAccountInScope(actorId, id);
|
||||
const account = await this.prisma.storeAccount.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -908,7 +979,8 @@ export class AdminStoresService {
|
||||
}
|
||||
|
||||
/** HQ 删除门店子账号(非主账号) */
|
||||
async deleteStoreStaff(parentAccountId: bigint, staffId: bigint) {
|
||||
async deleteStoreStaff(parentAccountId: bigint, staffId: bigint, actorId: bigint) {
|
||||
await this.assertStoreAccountInScope(actorId, parentAccountId);
|
||||
const parent = await this.prisma.storeAccount.findUnique({ where: { id: parentAccountId } });
|
||||
if (!parent || parent.isPrimary !== 1) {
|
||||
throw new BadRequestException('主账号不存在');
|
||||
|
||||
Reference in New Issue
Block a user