v3.5.8和v3.5.9版本更新
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-08-25 09:20:32 +08:00
parent 7304c7a8e1
commit 5935024ea8
101 changed files with 7640 additions and 5364 deletions
@@ -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('主账号不存在');