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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user