feat(ops): add global test whitelist and exclude test accounts from settlement

Unify product/store visibility on HQ whitelist, mark isTest snapshots, and fix SUPER_ADMIN access for the new module.
This commit is contained in:
2026-08-07 15:46:23 +08:00
parent 10fa361983
commit b626db5d84
47 changed files with 1823 additions and 308 deletions
@@ -7,11 +7,10 @@ import {
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import {
HQ_DANGEROUS_PERMISSION_KEYS,
HQ_PERMISSION_CATALOG,
HQ_ROLE_DEFAULT_PERMISSIONS,
expandHqPermissionKeys,
hasAnySystemSettingsPermission,
hqBasePermissionKeys,
type HqPermissionKey,
} from '@dukang/shared-types';
import { PrismaService } from '../prisma/prisma.module';
@@ -28,7 +27,7 @@ export const RequireAnySystemSettings = () =>
export class HqPermissionsResolver {
constructor(private readonly prisma: PrismaService) {}
async resolveEffectiveKeys(actorId: bigint): Promise<HqPermissionKey[]> {
private async loadActiveAccount(actorId: bigint) {
const account = await this.prisma.hqAccount.findUnique({
where: { id: actorId },
select: { adminRole: true, status: true },
@@ -36,6 +35,14 @@ export class HqPermissionsResolver {
if (!account || account.status !== 'ACTIVE') {
throw new ForbiddenException('HQ 账号不可用');
}
return account;
}
async resolveAccess(actorId: bigint): Promise<{
keys: HqPermissionKey[];
isSuperAdmin: boolean;
}> {
const account = await this.loadActiveAccount(actorId);
const userRows = await this.prisma.hqAccountPermission.findMany({
where: { hqAccountId: actorId },
@@ -44,12 +51,14 @@ export class HqPermissionsResolver {
const userKeys = userRows.map((r) => r.permissionKey);
if (account.adminRole === 'SUPER_ADMIN') {
// 超管含危险操作(删用户/订单/城市);其他角色仍需在权限分配中显式勾选
return expandHqPermissionKeys([
...hqBasePermissionKeys(),
...HQ_DANGEROUS_PERMISSION_KEYS,
...userKeys,
]);
// 超管拥有权限目录内全部项(含后续新增),另含危险操作与用户级附加项
return {
isSuperAdmin: true,
keys: expandHqPermissionKeys([
...HQ_PERMISSION_CATALOG.map((p) => p.key),
...userKeys,
]),
};
}
const roleRows = await this.prisma.hqRolePermission.findMany({
@@ -62,7 +71,15 @@ export class HqPermissionsResolver {
? roleRows.map((r) => r.permissionKey)
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[account.adminRole] ?? [])];
return expandHqPermissionKeys([...roleKeys, ...userKeys]);
return {
isSuperAdmin: false,
keys: expandHqPermissionKeys([...roleKeys, ...userKeys]),
};
}
async resolveEffectiveKeys(actorId: bigint): Promise<HqPermissionKey[]> {
const { keys } = await this.resolveAccess(actorId);
return keys;
}
}
@@ -79,7 +96,7 @@ export class HqPermissionGuard implements CanActivate {
if (!user || user.actorType !== 'HQ') {
throw new ForbiddenException('需要 HQ 权限');
}
const keys = await this.resolver.resolveEffectiveKeys(user.actorId);
const { keys, isSuperAdmin } = await this.resolver.resolveAccess(user.actorId);
req.hqPermissionKeys = keys;
const required =
@@ -89,6 +106,7 @@ export class HqPermissionGuard implements CanActivate {
]) ?? [];
if (!required.length) return true;
if (isSuperAdmin) return true;
if (required.includes('__any_system_settings__')) {
if (!hasAnySystemSettingsPermission(keys)) {
throw new ForbiddenException('无系统设置权限');
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { TestWhitelistService } from './test-whitelist.service';
@Global()
@Module({
providers: [TestWhitelistService],
exports: [TestWhitelistService],
})
export class TestWhitelistModule {}
@@ -0,0 +1,335 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.module';
import { serializeBigInt } from '../decorators/current-user.decorator';
export function normalizeTestPhone(phone: string | null | undefined): string {
return (phone || '').replace(/\D/g, '').trim();
}
export function assertMobilePhone(phone: string): string {
const p = normalizeTestPhone(phone);
if (!/^1\d{10}$/.test(p)) {
throw new BadRequestException(`手机号格式无效:${phone}`);
}
return p;
}
@Injectable()
export class TestWhitelistService {
constructor(private readonly prisma: PrismaService) {}
async isPhoneInWhitelist(phone: string | null | undefined): Promise<boolean> {
const p = normalizeTestPhone(phone);
if (!p) return false;
const row = await this.prisma.commonTestWhitelistPhone.findUnique({
where: { phone: p },
select: { id: true },
});
return !!row;
}
async assertGlobalWhitelistNotEmpty() {
const count = await this.prisma.commonTestWhitelistPhone.count();
if (count === 0) {
throw new BadRequestException('全局测试白名单为空,请先在「白名单管理」添加手机号');
}
}
async listPhones(query: { phone?: string; page?: number; pageSize?: number }) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CommonTestWhitelistPhoneWhereInput = {};
if (query.phone) {
where.phone = { contains: normalizeTestPhone(query.phone) || query.phone };
}
const [items, total] = await Promise.all([
this.prisma.commonTestWhitelistPhone.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.commonTestWhitelistPhone.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async addPhone(input: { phone: string; note?: string; createdByHqId?: bigint }) {
const phone = assertMobilePhone(input.phone);
const existing = await this.prisma.commonTestWhitelistPhone.findUnique({ where: { phone } });
if (existing) {
throw new BadRequestException('该手机号已在白名单中');
}
const row = await this.prisma.commonTestWhitelistPhone.create({
data: {
phone,
note: input.note?.trim() || null,
createdByHqId: input.createdByHqId ?? null,
},
});
await this.syncTestFlagsForPhone(phone, true);
return serializeBigInt(row);
}
async updatePhone(id: bigint, input: { note?: string | null }) {
const row = await this.prisma.commonTestWhitelistPhone.findUnique({ where: { id } });
if (!row) throw new NotFoundException('白名单记录不存在');
const updated = await this.prisma.commonTestWhitelistPhone.update({
where: { id },
data: { note: input.note === undefined ? undefined : input.note?.trim() || null },
});
return serializeBigInt(updated);
}
async removePhone(id: bigint) {
const row = await this.prisma.commonTestWhitelistPhone.findUnique({ where: { id } });
if (!row) throw new NotFoundException('白名单记录不存在');
await this.prisma.commonTestWhitelistPhone.delete({ where: { id } });
await this.syncTestFlagsForPhone(row.phone, false);
return { ok: true };
}
/** 同步账号/门店 isTest,并回填订单/核销快照 */
async syncTestFlagsForPhone(phone: string, isTest: boolean) {
const p = normalizeTestPhone(phone);
if (!p) return;
await this.prisma.user.updateMany({ where: { phone: p }, data: { isTest } });
await this.prisma.storeAccount.updateMany({ where: { phone: p }, data: { isTest } });
await this.prisma.partnerAccount.updateMany({ where: { phone: p }, data: { isTest } });
if (isTest) {
await this.prisma.store.updateMany({ where: { phone: p }, data: { isTest: true } });
} else {
// 仅清除「联系电话命中且当前不在白名单」的自动标;手动标的门店若电话已不在名单则保持 isTest(运营可再关)
// 简化:电话命中且移出名单时置 false;手动标的非该电话门店不受影响
await this.prisma.store.updateMany({ where: { phone: p }, data: { isTest: false } });
}
const users = await this.prisma.user.findMany({
where: { phone: p },
select: { id: true },
});
const userIds = users.map((u) => u.id);
if (userIds.length) {
await this.prisma.order.updateMany({
where: { userId: { in: userIds } },
data: { isTest },
});
}
const stores = await this.prisma.store.findMany({
where: { phone: p },
select: { id: true },
});
const storeIds = stores.map((s) => s.id);
if (userIds.length || storeIds.length) {
const or: Prisma.RedeemRecordWhereInput[] = [];
if (userIds.length) or.push({ userId: { in: userIds } });
if (storeIds.length) or.push({ storeId: { in: storeIds } });
await this.prisma.redeemRecord.updateMany({
where: { OR: or },
data: { isTest },
});
}
}
async listAccounts(query: {
type: 'user' | 'store_account' | 'partner' | 'store' | 'order';
phone?: string;
page?: number;
pageSize?: number;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const phone = query.phone ? normalizeTestPhone(query.phone) : '';
if (query.type === 'user') {
const where: Prisma.UserWhereInput = { isTest: true };
if (phone) where.phone = { contains: phone };
const [items, total] = await Promise.all([
this.prisma.user.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
userNo: true,
phone: true,
nickname: true,
status: true,
createdAt: true,
isTest: true,
},
}),
this.prisma.user.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize, type: query.type });
}
if (query.type === 'store_account') {
const where: Prisma.StoreAccountWhereInput = { isTest: true };
if (phone) where.phone = { contains: phone };
const [items, total] = await Promise.all([
this.prisma.storeAccount.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
phone: true,
name: true,
status: true,
createdAt: true,
isTest: true,
},
}),
this.prisma.storeAccount.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize, type: query.type });
}
if (query.type === 'partner') {
const where: Prisma.PartnerAccountWhereInput = { isTest: true };
if (phone) where.phone = { contains: phone };
const [items, total] = await Promise.all([
this.prisma.partnerAccount.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
phone: true,
name: true,
companyName: true,
status: true,
createdAt: true,
isTest: true,
},
}),
this.prisma.partnerAccount.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize, type: query.type });
}
if (query.type === 'store') {
const where: Prisma.StoreWhereInput = { isTest: true };
if (phone) where.phone = { contains: phone };
const [items, total] = await Promise.all([
this.prisma.store.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
name: true,
phone: true,
status: true,
cityName: true,
createdAt: true,
isTest: true,
},
}),
this.prisma.store.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize, type: query.type });
}
const where: Prisma.OrderWhereInput = { isTest: true };
if (phone) {
where.OR = [
{ receiverPhone: { contains: phone } },
{ user: { phone: { contains: phone } } },
];
}
const [items, total] = await Promise.all([
this.prisma.order.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
orderNo: true,
status: true,
payStatus: true,
payAmount: true,
receiverPhone: true,
createdAt: true,
isTest: true,
user: { select: { id: true, phone: true, userNo: true } },
},
}),
this.prisma.order.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize, type: query.type });
}
async linkedForPhoneId(id: bigint) {
const row = await this.prisma.commonTestWhitelistPhone.findUnique({ where: { id } });
if (!row) throw new NotFoundException('白名单记录不存在');
const phone = row.phone;
const [users, storeAccounts, partners, stores] = await Promise.all([
this.prisma.user.findMany({
where: { phone },
select: { id: true, userNo: true, phone: true, nickname: true, isTest: true, status: true },
}),
this.prisma.storeAccount.findMany({
where: { phone },
select: { id: true, phone: true, name: true, isTest: true, status: true },
}),
this.prisma.partnerAccount.findMany({
where: { phone },
select: { id: true, phone: true, name: true, companyName: true, isTest: true, status: true },
}),
this.prisma.store.findMany({
where: { phone },
select: { id: true, name: true, phone: true, isTest: true, status: true },
}),
]);
return serializeBigInt({
phone: row,
users,
storeAccounts,
partners,
stores,
});
}
/** 从旧商品/门店可见性子表导入全局名单(幂等) */
async migrateVisibilityPhones(createdByHqId?: bigint) {
const [productPhones, storePhones] = await Promise.all([
this.prisma.commonProductVisibilityPhone.findMany({ select: { phone: true } }),
this.prisma.storeVisibilityPhone.findMany({ select: { phone: true } }),
]);
const set = new Set<string>();
for (const row of [...productPhones, ...storePhones]) {
const p = normalizeTestPhone(row.phone);
if (/^1\d{10}$/.test(p)) set.add(p);
}
let added = 0;
for (const phone of set) {
const exists = await this.prisma.commonTestWhitelistPhone.findUnique({ where: { phone } });
if (exists) {
await this.syncTestFlagsForPhone(phone, true);
continue;
}
await this.prisma.commonTestWhitelistPhone.create({
data: {
phone,
note: '自可见性白名单迁移',
createdByHqId: createdByHqId ?? null,
},
});
await this.syncTestFlagsForPhone(phone, true);
added += 1;
}
return { importedCandidates: set.size, added };
}
}