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
@@ -1,11 +1,9 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
HQ_DANGEROUS_PERMISSION_KEYS,
HQ_PERMISSION_CATALOG,
HQ_ROLE_DEFAULT_PERMISSIONS,
LEGACY_SYSTEM_SETTINGS_KEY,
expandHqPermissionKeys,
hqBasePermissionKeys,
type HqPermissionKey,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
@@ -83,10 +81,9 @@ export class AdminHqPermissionsService {
const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey));
if (account.adminRole === 'SUPER_ADMIN') {
const rolePermissionKeys = [
...hqBasePermissionKeys(),
...HQ_DANGEROUS_PERMISSION_KEYS,
] as HqPermissionKey[];
const rolePermissionKeys = HQ_PERMISSION_CATALOG.map(
(p) => p.key,
) as HqPermissionKey[];
const effectivePermissionKeys = [
...new Set([...rolePermissionKeys, ...userPermissionKeys]),
] as HqPermissionKey[];
@@ -43,6 +43,7 @@ export class AdminOrdersService {
if (query.createdFrom) where.createdAt.gte = new Date(query.createdFrom);
if (query.createdTo) where.createdAt.lte = new Date(query.createdTo);
}
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([
this.prisma.order.findMany({
@@ -42,6 +42,7 @@ export class AdminPartnersService {
if (query.phone) where.phone = { contains: query.phone };
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.partnerId) where.id = BigInt(query.partnerId);
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([
this.prisma.partnerAccount.findMany({
@@ -5,6 +5,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
import { groupResourcesByProductId, mapProductMedia } from '../catalog/catalog.mapper';
import type { AdminProductsQueryDto } from './dto/admin-query.dto';
import type { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
function normalizePhones(phones?: string[]): string[] {
if (!phones?.length) return [];
@@ -57,7 +58,10 @@ function resolveFulfillmentFlags(input: {
@Injectable()
export class AdminProductsService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly testWhitelist: TestWhitelistService,
) {}
async list(query: AdminProductsQueryDto) {
const page = query.page ?? 1;
@@ -140,6 +144,11 @@ export class AdminProductsService {
const phones = normalizePhones(dto.visibilityPhones);
const whitelistEnabled = !!dto.visibilityWhitelistEnabled;
if (whitelistEnabled) {
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
}
// 手机号统一在「白名单管理」维护;此处忽略分实体 phones(兼容旧客户端传参)
void phones;
const product = await this.createWithGeneratedSku({
barcode69: dto.barcode69,
@@ -158,13 +167,6 @@ export class AdminProductsService {
...(dto.detailContent !== undefined
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
: {}),
...(phones.length
? {
visibilityPhones: {
create: phones.map((phone) => ({ phone })),
},
}
: {}),
});
if (dto.coverUrl) {
@@ -225,9 +227,10 @@ export class AdminProductsService {
},
});
if (dto.visibilityPhones !== undefined) {
await this.syncVisibilityPhones(id, normalizePhones(dto.visibilityPhones));
if (dto.visibilityWhitelistEnabled) {
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
}
// 分实体手机号已废弃;忽略 dto.visibilityPhones
if (dto.coverUrl) {
await this.syncCover(id, dto.coverUrl);
@@ -42,6 +42,7 @@ export class AdminRedeemService {
if (query.channel === 'SCAN' || query.channel === 'PHONE') {
where.channel = query.channel;
}
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([
this.prisma.redeemRecord.findMany({
@@ -17,6 +17,7 @@ import type {
UpdateStoreMediaDto,
UpdateStoreStatusDto,
} from './dto/admin-mutate.dto';
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
/** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */
function normalizeStoreOptionalText(value: unknown): string | null {
@@ -51,6 +52,7 @@ export class AdminStoresService {
private readonly partnerCityService: PartnerCityService,
private readonly storeCategoryService: StoreCategoryService,
private readonly analyticsService: AnalyticsService,
private readonly testWhitelist: TestWhitelistService,
) {}
async listStores(query: AdminStoresQueryDto) {
@@ -65,6 +67,7 @@ export class AdminStoresService {
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;
const [items, total] = await Promise.all([
this.prisma.store.findMany({
@@ -283,18 +286,7 @@ export class AdminStoresService {
? !!dto.visibilityWhitelistEnabled
: current.visibilityWhitelistEnabled;
if (nextEnabled) {
const phones =
dto.visibilityPhones !== undefined
? normalizeVisibilityPhones(dto.visibilityPhones)
: (
await this.prisma.storeVisibilityPhone.findMany({
where: { storeId: id },
select: { phone: true },
})
).map((p) => p.phone);
if (!phones.length) {
throw new BadRequestException('开启白名单时请至少添加一个手机号');
}
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
}
}
const bankTouched =
@@ -372,18 +364,13 @@ export class AdminStoresService {
...(dto.visibilityWhitelistEnabled !== undefined
? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled }
: {}),
...(dto.isTest !== undefined ? { isTest: !!dto.isTest } : {}),
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
},
});
if (dto.visibilityPhones !== undefined) {
const phones = normalizeVisibilityPhones(dto.visibilityPhones);
await tx.storeVisibilityPhone.deleteMany({ where: { storeId: id } });
if (phones.length) {
await tx.storeVisibilityPhone.createMany({
data: phones.map((phone) => ({ storeId: id, phone })),
});
}
// 分实体手机号已废弃,忽略写入
}
if (dto.coverUrl) {
@@ -505,11 +492,14 @@ export class AdminStoresService {
throw new BadRequestException('好客权益券使用规则最多 1000 字');
}
const visibilityPhones = normalizeVisibilityPhones(dto.visibilityPhones);
const whitelistEnabled = !!dto.visibilityWhitelistEnabled;
if (whitelistEnabled && !visibilityPhones.length) {
throw new BadRequestException('开启白名单时请至少添加一个手机号');
if (whitelistEnabled) {
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
}
const isTest =
dto.isTest !== undefined
? !!dto.isTest
: await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
const store = await this.prisma.store.create({
data: {
@@ -531,18 +521,12 @@ export class AdminStoresService {
openTime2: openTime2 || null,
closeTime2: closeTime2 || null,
visibilityWhitelistEnabled: whitelistEnabled,
isTest,
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
status: 'OPEN',
auditStatus: 'APPROVED',
auditedAt: new Date(),
rejectReason: null,
...(visibilityPhones.length
? {
visibilityPhones: {
create: visibilityPhones.map((phone) => ({ phone })),
},
}
: {}),
},
});
@@ -746,6 +730,7 @@ export class AdminStoresService {
where.bindings = { some: { storeId: BigInt(query.storeId) } };
}
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([
this.prisma.storeAccount.findMany({
@@ -0,0 +1,167 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
import { SystemConfigService } from '../../common/system-config/system-config.service';
class ListPhonesQueryDto {
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number = 20;
}
class AddPhoneDto {
@IsString()
phone!: string;
@IsOptional()
@IsString()
@MaxLength(256)
note?: string;
}
class UpdatePhoneDto {
@IsOptional()
@IsString()
@MaxLength(256)
note?: string | null;
}
class ListAccountsQueryDto {
@IsIn(['user', 'store_account', 'partner', 'store', 'order'])
type!: 'user' | 'store_account' | 'partner' | 'store' | 'order';
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number = 20;
}
class UpdateMockFlagsDto {
@IsOptional()
@IsBoolean()
mockSms?: boolean;
@IsOptional()
@IsBoolean()
mockWechat?: boolean;
@IsOptional()
@IsBoolean()
mockPay?: boolean;
}
@Controller('admin/test-whitelist')
@UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('test_whitelist')
export class AdminTestWhitelistController {
constructor(
private readonly testWhitelist: TestWhitelistService,
private readonly systemConfig: SystemConfigService,
) {}
@Get('mock-flags')
async getMockFlags() {
const form = await this.systemConfig.getForm(['feature']);
const v = form.values;
return {
mockSms: v.MOCK_SMS === 'true' || v.MOCK_SMS === '1',
mockWechat: v.MOCK_WECHAT === 'true' || v.MOCK_WECHAT === '1',
mockPay: v.MOCK_PAY === 'true' || v.MOCK_PAY === '1',
};
}
@Put('mock-flags')
async updateMockFlags(@Body() dto: UpdateMockFlagsDto) {
const values: Record<string, string> = {};
if (dto.mockSms !== undefined) values.MOCK_SMS = String(dto.mockSms);
if (dto.mockWechat !== undefined) values.MOCK_WECHAT = String(dto.mockWechat);
if (dto.mockPay !== undefined) values.MOCK_PAY = String(dto.mockPay);
if (Object.keys(values).length) {
await this.systemConfig.update({ values }, ['feature']);
}
return this.getMockFlags();
}
@Get('phones')
listPhones(@Query() query: ListPhonesQueryDto) {
return this.testWhitelist.listPhones(query);
}
@Post('phones')
addPhone(@Body() dto: AddPhoneDto, @CurrentUser() actor: AuthUser) {
return this.testWhitelist.addPhone({
phone: dto.phone,
note: dto.note,
createdByHqId: actor.actorId,
});
}
@Patch('phones/:id')
updatePhone(@Param('id') id: string, @Body() dto: UpdatePhoneDto) {
return this.testWhitelist.updatePhone(BigInt(id), { note: dto.note });
}
@Delete('phones/:id')
removePhone(@Param('id') id: string) {
return this.testWhitelist.removePhone(BigInt(id));
}
@Get('accounts')
listAccounts(@Query() query: ListAccountsQueryDto) {
return this.testWhitelist.listAccounts(query);
}
@Get('phones/:id/linked')
linked(@Param('id') id: string) {
return this.testWhitelist.linkedForPhoneId(BigInt(id));
}
@Post('migrate-visibility')
migrate(@CurrentUser() actor: AuthUser) {
return this.testWhitelist.migrateVisibilityPhones(actor.actorId);
}
}
@@ -19,6 +19,7 @@ function mapAdminUserRow(u: {
sourceType: string;
sourceRefId: bigint | null;
sourceLabel: string | null;
isTest?: boolean;
createdAt: Date;
updatedAt: Date;
_count: { orders: number };
@@ -37,6 +38,7 @@ function mapAdminUserRow(u: {
sourceType: u.sourceType,
sourceRefId: u.sourceRefId,
sourceLabel: u.sourceLabel,
isTest: !!u.isTest,
createdAt: u.createdAt,
updatedAt: u.updatedAt,
orderCount: u._count.orders,
@@ -58,6 +60,7 @@ export class AdminUsersService {
if (query.status !== undefined) where.status = query.status;
if (query.phoneVerified === '1') where.phoneVerifiedAt = { not: null };
if (query.phoneVerified === '0') where.phoneVerifiedAt = null;
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([
this.prisma.user.findMany({
@@ -78,6 +81,7 @@ export class AdminUsersService {
sourceType: true,
sourceRefId: true,
sourceLabel: true,
isTest: true,
createdAt: true,
updatedAt: true,
_count: { select: { orders: true } },
@@ -128,16 +128,21 @@ export class CreateStoreDto {
@Min(0)
avgPrice?: number;
/** 开启后仅白名单手机号在 C 端可见 */
/** 开启后仅全局测试白名单手机号在 C 端可见 */
@IsOptional()
@IsBoolean()
visibilityWhitelistEnabled?: boolean;
/** 可见白名单手机号列表 */
/** @deprecated 已并入全局白名单,忽略 */
@IsOptional()
@IsArray()
@IsString({ each: true })
visibilityPhones?: string[];
/** 测试门店标记 */
@IsOptional()
@IsBoolean()
isTest?: boolean;
}
export class UpdateStoreDto {
@@ -227,16 +232,21 @@ export class UpdateStoreDto {
@IsString()
bankBranch?: string | null;
/** 开启后仅白名单手机号在 C 端可见 */
/** 开启后仅全局测试白名单手机号在 C 端可见 */
@IsOptional()
@IsBoolean()
visibilityWhitelistEnabled?: boolean;
/** 可见白名单手机号列表 */
/** @deprecated 已并入全局白名单,忽略 */
@IsOptional()
@IsArray()
@IsString({ each: true })
visibilityPhones?: string[];
/** 测试门店标记 */
@IsOptional()
@IsBoolean()
isTest?: boolean;
}
export class CreateStoreAccountDto {
@@ -1,5 +1,12 @@
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { Type, Transform } from 'class-transformer';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
function toOptionalBoolean(value: unknown): boolean | undefined {
if (value === undefined || value === null || value === '') return undefined;
if (value === true || value === 'true' || value === '1' || value === 1) return true;
if (value === false || value === 'false' || value === '0' || value === 0) return false;
return undefined;
}
export class PaginationQueryDto {
@IsOptional()
@@ -37,6 +44,12 @@ export class AdminUsersQueryDto extends PaginationQueryDto {
@Type(() => Number)
@IsIn([0, 1])
status?: number;
/** 勾选「过滤测试账号」时传 true */
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
}
export class AdminOrdersQueryDto extends PaginationQueryDto {
@@ -75,6 +88,11 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
createdTo?: string;
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
}
/** 概览页用户/订单 ECharts 聚合筛选 */
@@ -128,6 +146,11 @@ export class AdminStoresQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
auditStatus?: string;
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
}
export class AdminStoreAccountsQueryDto extends PaginationQueryDto {
@@ -142,6 +165,11 @@ export class AdminStoreAccountsQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
}
export class AdminPartnersQueryDto extends PaginationQueryDto {
@@ -164,6 +192,11 @@ export class AdminPartnersQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
partnerId?: string;
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
}
export class AdminCityWarehousesQueryDto extends PaginationQueryDto {
@@ -247,6 +280,11 @@ export class AdminRedeemRecordsQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
channel?: string;
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
}
export class AdminStoreRatingsQueryDto extends PaginationQueryDto {
@@ -80,6 +80,7 @@ import { AdminDevPlanController } from '../dev-plan/admin-dev-plan.controller';
import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller';
import { AdminDomainEventsController } from './admin-domain-events.controller';
import { AdminDomainEventsService } from './admin-domain-events.service';
import { AdminTestWhitelistController } from './admin-test-whitelist.controller';
@Module({
imports: [CityScopeModule, IamModule, TradeModule, AnalyticsModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, WecomModule, LlmModule, RedeemModule, StoreModule, DevPlanModule],
@@ -128,6 +129,7 @@ import { AdminDomainEventsService } from './admin-domain-events.service';
AdminKnowledgeBasesController,
AdminDevPlanController,
AdminFulfillmentProvidersController,
AdminTestWhitelistController,
],
providers: [
AdminDashboardService,