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
+34
View File
@@ -812,6 +812,21 @@ model CommonProductVisibilityPhone {
@@map("common_product_visibility_phone")
}
/// 全局测试白名单手机号(测试账号 + 限测商品/门店可见)
model CommonTestWhitelistPhone {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
phone String @unique @db.VarChar(20)
note String? @db.VarChar(256)
createdByHqId BigInt? @map("created_by_hq_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
createdBy HqAccount? @relation("TestWhitelistCreatedBy", fields: [createdByHqId], references: [id], onDelete: SetNull)
@@index([createdAt])
@@map("common_test_whitelist_phone")
}
model CommonProductDetailTemplate {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(32)
@@ -1043,6 +1058,8 @@ model PartnerAccount {
bankBranch String? @map("bank_branch") @db.VarChar(128)
weeklyStoreTarget Int? @default(20) @map("weekly_store_target")
managedWarehouseId BigInt? @unique @map("managed_warehouse_id") @db.UnsignedBigInt
/// 测试合伙人账号
isTest Boolean @default(false) @map("is_test")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -1059,6 +1076,7 @@ model PartnerAccount {
@@index([parentAccountId])
@@index([wxOpenId])
@@index([contactPhone])
@@index([isTest])
@@map("partner_account")
}
@@ -1102,6 +1120,7 @@ model HqAccount {
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
permissions HqAccountPermission[]
testWhitelistPhones CommonTestWhitelistPhone[] @relation("TestWhitelistCreatedBy")
@@map("hq_account")
}
@@ -1140,6 +1159,8 @@ model User {
nickname String? @db.VarChar(64)
avatarResourceId BigInt? @map("avatar_resource_id") @db.UnsignedBigInt
status Int @default(1) @db.TinyInt
/// 测试账号:命中全局测试白名单手机号
isTest Boolean @default(false) @map("is_test")
sourceType UserSourceType @default(ORGANIC) @map("source_type")
sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt
sourceLabel String? @map("source_label") @db.VarChar(128)
@@ -1166,6 +1187,7 @@ model User {
@@index([referrerUserId])
@@index([mergedIntoUserId])
@@index([wxOpenId])
@@index([isTest])
@@map("user_user")
}
@@ -1253,6 +1275,8 @@ model Store {
visibilityWhitelistEnabled Boolean @default(false) @map("visibility_whitelist_enabled")
/// FIN-001:允许未出账手动提现的白名单门店
withdrawWhitelistEnabled Boolean @default(false) @map("withdraw_whitelist_enabled")
/// 测试门店:不计结算 / HQ 可手动标记
isTest Boolean @default(false) @map("is_test")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -1274,6 +1298,7 @@ model Store {
@@index([cityId, status])
@@index([partnerAccountId])
@@index([auditStatus, createdAt])
@@index([isTest])
@@map("store_store")
}
@@ -1344,6 +1369,8 @@ model StoreAccount {
bankBranch String? @map("bank_branch") @db.VarChar(128)
status AccountStatus @default(ACTIVE)
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
/// 测试门店账号(商户)
isTest Boolean @default(false) @map("is_test")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -1354,6 +1381,7 @@ model StoreAccount {
withdrawRequests StoreWithdrawRequest[]
@@index([parentAccountId])
@@index([isTest])
@@map("store_account")
}
@@ -1432,6 +1460,8 @@ model Order {
fulfillmentHold Boolean @default(false) @map("fulfillment_hold")
fulfillmentHoldReason String? @map("fulfillment_hold_reason") @db.VarChar(64)
remark String? @db.VarChar(512)
/// 测试订单快照(下单时取自 User.isTest
isTest Boolean @default(false) @map("is_test")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -1456,6 +1486,7 @@ model Order {
@@index([gpsCity])
@@index([fulfillmentWarehouseId])
@@index([proxyPartnerAccountId])
@@index([isTest])
@@map("user_order")
}
@@ -1548,6 +1579,8 @@ model RedeemRecord {
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
/// SCAN=qrcode, PHONE=phone
channel RedeemChannel @default(SCAN)
/// 测试核销快照(User.isTest || Store.isTest
isTest Boolean @default(false) @map("is_test")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
@@ -1560,6 +1593,7 @@ model RedeemRecord {
@@index([storeId, createdAt])
@@index([storeId, channel, createdAt])
@@index([isTest])
@@map("user_redeem_record")
}
+44
View File
@@ -61,6 +61,16 @@ async function main() {
await prisma.storePayout.deleteMany();
await prisma.storeWithdrawPayoutItem.deleteMany();
await prisma.storeWithdrawRequest.deleteMany();
await prisma.storeBill.deleteMany();
await prisma.redeemPendingRecord.deleteMany();
await prisma.logStoreAnalytics.deleteMany();
await prisma.storeRating.deleteMany();
await prisma.redeemRecord.deleteMany();
@@ -83,16 +93,28 @@ async function main() {
await prisma.user.deleteMany();
await prisma.storeAccount.updateMany({ data: { parentAccountId: null } });
await prisma.storeAccount.deleteMany();
await prisma.store.deleteMany();
await prisma.partnerBill.deleteMany();
await prisma.logisticsBillItem.deleteMany();
await prisma.logisticsPrepaidLedger.deleteMany();
await prisma.logisticsBill.deleteMany();
await prisma.wineryBillItem.deleteMany();
await prisma.wineryBill.deleteMany();
await prisma.cityWarehouse.deleteMany();
await prisma.fulfillmentProvider.deleteMany();
await prisma.partnerAccount.updateMany({ data: { parentAccountId: null } });
await prisma.partnerAccount.deleteMany();
await prisma.commonCity.deleteMany();
@@ -101,6 +123,7 @@ async function main() {
await prisma.commonProductDetailTemplate.deleteMany();
await prisma.commonStoreCategory.updateMany({ data: { parentId: null } });
await prisma.commonStoreCategory.deleteMany();
await prisma.commonPromoCode.deleteMany();
@@ -914,6 +937,25 @@ async function main() {
const testWhitelistPhones = [
'13800000001',
'13700000001',
'13700000002',
'13910000001',
'13910000002',
];
for (const phone of testWhitelistPhones) {
await prisma.commonTestWhitelistPhone.upsert({
where: { phone },
create: { phone, note: 'seed 测试账号' },
update: {},
});
await prisma.user.updateMany({ where: { phone }, data: { isTest: true } });
await prisma.storeAccount.updateMany({ where: { phone }, data: { isTest: true } });
await prisma.partnerAccount.updateMany({ where: { phone }, data: { isTest: true } });
await prisma.store.updateMany({ where: { phone }, data: { isTest: true } });
}
console.log('Seed complete:', {
city: city.name,
@@ -928,6 +970,8 @@ async function main() {
stores: createdStores.length,
testWhitelistPhones,
testPhones: {
user: '13800000001',
+2
View File
@@ -21,6 +21,7 @@ import { CityScopeModule } from './modules/city-scope/city-scope.module';
import { CommonModule } from './modules/common/common.module';
import { HqOperationModule } from './common/hq-operation/hq-operation.module';
import { SystemConfigModule } from './common/system-config/system-config.module';
import { TestWhitelistModule } from './common/test-whitelist/test-whitelist.module';
import { DevPlanModule } from './modules/dev-plan/dev-plan.module';
import { CallbacksModule } from './callbacks/callbacks.module';
import { WecomModule } from './integrations/wecom/wecom.module';
@@ -37,6 +38,7 @@ import { RequestIdMiddleware } from './common/logging/request-id.middleware';
}),
PrismaModule,
SystemConfigModule,
TestWhitelistModule,
GeoModule,
RedisModule,
AlertModule,
@@ -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 };
}
}
@@ -1,6 +1,10 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import {
TestWhitelistService,
normalizeTestPhone,
} from '../../common/test-whitelist/test-whitelist.service';
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
export type CatalogViewer = {
@@ -10,13 +14,32 @@ export type CatalogViewer = {
bypassWhitelist?: boolean;
};
function normalizePhone(phone: string | null | undefined): string {
return (phone || '').replace(/\D/g, '').trim();
}
@Injectable()
export class CatalogService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly testWhitelist: TestWhitelistService,
) {}
private async whitelistPhoneSet(): Promise<Set<string>> {
const rows = await this.prisma.commonTestWhitelistPhone.findMany({
select: { phone: true },
});
return new Set(rows.map((r) => normalizeTestPhone(r.phone)).filter(Boolean));
}
isVisibleToViewer(
product: { visibilityWhitelistEnabled: boolean },
viewer?: CatalogViewer,
whitelistPhones?: Set<string>,
): boolean {
if (viewer?.bypassWhitelist) return true;
if (!product.visibilityWhitelistEnabled) return true;
const phone = normalizeTestPhone(viewer?.phone);
if (!phone) return false;
if (whitelistPhones) return whitelistPhones.has(phone);
return false;
}
async listCities() {
const cities = await this.prisma.commonCity.findMany({
@@ -58,11 +81,13 @@ export class CatalogService {
orderBy: { sortOrder: 'asc' },
include: {
coverResource: true,
visibilityPhones: { select: { phone: true } },
},
});
const visible = products.filter((p) => this.isVisibleToViewer(p, viewer));
const whitelistPhones = products.some((p) => p.visibilityWhitelistEnabled)
? await this.whitelistPhoneSet()
: new Set<string>();
const visible = products.filter((p) => this.isVisibleToViewer(p, viewer, whitelistPhones));
const productIds = visible.map((p) => p.id);
const resources = productIds.length
@@ -81,7 +106,7 @@ export class CatalogService {
return serializeBigInt(
visible.map((p) => {
const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []);
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = p;
const { visibilityWhitelistEnabled: _wl, ...rest } = p;
return {
...rest,
benefitAmount: p.benefitAmount ?? p.price,
@@ -98,11 +123,13 @@ export class CatalogService {
where: { id },
include: {
coverResource: true,
visibilityPhones: { select: { phone: true } },
},
});
if (!product) return null;
if (!this.isVisibleToViewer(product, viewer)) {
const whitelistPhones = product.visibilityWhitelistEnabled
? await this.whitelistPhoneSet()
: new Set<string>();
if (!this.isVisibleToViewer(product, viewer, whitelistPhones)) {
return null;
}
@@ -117,7 +144,7 @@ export class CatalogService {
});
const media = mapProductMedia(product, resources);
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = product;
const { visibilityWhitelistEnabled: _wl, ...rest } = product;
return serializeBigInt({
...rest,
benefitAmount: product.benefitAmount ?? product.price,
@@ -126,17 +153,19 @@ export class CatalogService {
});
}
/** 下单前校验:白名单商品仅白名单手机号可买 */
/** 下单前校验:白名单商品仅全局测试白名单手机号可买 */
async assertPurchasable(productId: bigint, viewerPhone?: string | null) {
const product = await this.prisma.commonProductItem.findUnique({
where: { id: productId },
include: { visibilityPhones: { select: { phone: true } } },
});
if (!product || product.status !== 'ON_SALE') {
throw new BadRequestException('商品不可购买');
}
if (!this.isVisibleToViewer(product, { phone: viewerPhone })) {
throw new BadRequestException('该商品暂不对当前账号开放');
if (product.visibilityWhitelistEnabled) {
const ok = await this.testWhitelist.isPhoneInWhitelist(viewerPhone);
if (!ok) {
throw new BadRequestException('该商品暂不对当前账号开放');
}
}
return product;
}
@@ -148,18 +177,4 @@ export class CatalogService {
});
return user?.phone ?? null;
}
isVisibleToViewer(
product: {
visibilityWhitelistEnabled: boolean;
visibilityPhones: Array<{ phone: string }>;
},
viewer?: CatalogViewer,
): boolean {
if (viewer?.bypassWhitelist) return true;
if (!product.visibilityWhitelistEnabled) return true;
const phone = normalizePhone(viewer?.phone);
if (!phone) return false;
return product.visibilityPhones.some((row) => normalizePhone(row.phone) === phone);
}
}
@@ -25,6 +25,7 @@ import { AnalyticsService } from '../analytics/analytics.service';
import { UserAddressService } from './user-address.service';
import { ResourceService } from '../common/resource.service';
import { HqPermissionsResolver } from '../../common/guards/hq-permission.guard';
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
import type { User } from '@prisma/client';
@@ -67,8 +68,21 @@ export class AuthService {
private readonly userAddressService: UserAddressService,
@Inject(forwardRef(() => ResourceService)) private readonly resourceService: ResourceService,
private readonly hqPermissions: HqPermissionsResolver,
private readonly testWhitelist: TestWhitelistService,
) {}
/** 登录/绑号后按全局白名单同步 isTest */
private async syncTestFlagByPhone(phone: string) {
const isTest = await this.testWhitelist.isPhoneInWhitelist(phone);
await Promise.all([
this.prisma.user.updateMany({ where: { phone }, data: { isTest } }),
this.prisma.storeAccount.updateMany({ where: { phone }, data: { isTest } }),
this.prisma.partnerAccount.updateMany({ where: { phone }, data: { isTest } }),
this.prisma.store.updateMany({ where: { phone }, data: { isTest } }),
]);
return isTest;
}
private assertMobilePhone(phone: string) {
const trimmed = phone.trim();
if (!/^1[3-9]\d{9}$/.test(trimmed)) {
@@ -375,12 +389,14 @@ export class AuthService {
});
if (!user) {
const isTest = await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
user = await this.prisma.user.create({
data: {
phone: normalizedPhone,
phoneVerifiedAt: new Date(),
userNo: generateUserNo(),
nickname: `用户${normalizedPhone.slice(-4)}`,
isTest,
sourceType: source?.sourceType ?? 'ORGANIC',
sourceRefId: source?.sourceRefId,
sourceLabel: source?.sourceLabel,
@@ -401,6 +417,11 @@ export class AuthService {
include: { avatar: true },
});
}
await this.syncTestFlagByPhone(normalizedPhone);
user = await this.prisma.user.findUniqueOrThrow({
where: { id: user.id },
include: { avatar: true },
});
await this.assertActiveUser(user.id);
}
@@ -810,12 +831,14 @@ export class AuthService {
}
}
if (!user) {
const isTest = await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
user = await this.prisma.user.create({
data: {
phone: normalizedPhone,
phoneVerifiedAt: new Date(),
userNo: generateUserNo(),
nickname: `用户${normalizedPhone.slice(-4)}`,
isTest,
cityPreference: {
create: {
selectedCityCode: '410100',
@@ -852,6 +875,12 @@ export class AuthService {
if (!user) throw new BadRequestException('登录失败');
await this.syncTestFlagByPhone(normalizedPhone);
user = await this.prisma.user.findUniqueOrThrow({
where: { id: user.id },
include: { avatar: true },
});
this.analyticsService.trackOneSafe(user.id, clientApp, {
eventName: method === 'sms' ? 'sms_login' : 'wechat_phone_login',
extraJson: { method },
@@ -998,6 +1027,7 @@ export class AuthService {
if (!account) throw new BadRequestException('该手机号未绑定门店');
if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用');
if (!account.bindings.length) throw new BadRequestException('该账号未绑定任何门店');
await this.syncTestFlagByPhone(normalizedPhone);
await this.prisma.storeAccount.update({
where: { id: account.id },
data: { lastLoginAt: new Date() },
@@ -1032,6 +1062,7 @@ export class AuthService {
});
if (!account) throw new BadRequestException('未找到合伙人账号');
if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
await this.syncTestFlagByPhone(normalizedPhone);
const primary = await this.resolvePrimaryAccount(account.id);
await this.prisma.partnerAccount.update({
where: { id: account.id },
@@ -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,
@@ -180,6 +180,15 @@ export class RedeemService {
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
const redeemChannel = analyticsExtra?.channel === 'phone' ? 'PHONE' : 'SCAN';
const [userRow, storeRow] = await Promise.all([
this.prisma.user.findUnique({ where: { id: userId }, select: { isTest: true } }),
this.prisma.store.findUnique({
where: { id: account.storeId },
select: { isTest: true },
}),
]);
const isTest = !!(userRow?.isTest || storeRow?.isTest);
let record;
try {
record = await this.prisma.$transaction(async (tx) => {
@@ -194,6 +203,7 @@ export class RedeemService {
amount,
settleAmount,
channel: redeemChannel,
isTest,
allocations: {
create: normalizedAllocations.map((item, index) => ({
couponId: BigInt(item.couponId),
@@ -204,14 +214,16 @@ export class RedeemService {
},
});
await this.settlementService.createStorePayout(
redeemRecord.id,
account.storeId,
amount,
settleAmount,
settlementRate,
tx,
);
if (!isTest) {
await this.settlementService.createStorePayout(
redeemRecord.id,
account.storeId,
amount,
settleAmount,
settlementRate,
tx,
);
}
return redeemRecord;
});
@@ -1322,9 +1322,12 @@ export class SettlementService {
cityId: primary.cityId,
payStatus: 'PAID',
paidAt: { gte: periodStart, lte: periodEnd },
isTest: false,
},
});
const orderCommission = orders.reduce((sum, o) => {
const orderCommission = primary.isTest
? 0
: orders.reduce((sum, o) => {
if (o.partnerAccountIdAtPay) {
if (o.partnerAccountIdAtPay !== primary.id) return sum;
const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0;
@@ -1338,14 +1341,16 @@ export class SettlementService {
select: { id: true },
});
const storeIds = stores.map((s) => s.id);
const redeems = storeIds.length
? await this.prisma.redeemRecord.findMany({
const redeems =
primary.isTest || !storeIds.length
? []
: await this.prisma.redeemRecord.findMany({
where: {
storeId: { in: storeIds },
createdAt: { gte: periodStart, lte: periodEnd },
isTest: false,
},
})
: [];
});
const redeemCommission = redeems.reduce(
(sum, r) => sum + Number(r.amount) * redeemCommissionRate,
0,
@@ -1588,6 +1593,7 @@ export class SettlementService {
payStatus: 'PAID',
deliveryType: { in: ['LOCAL', 'CROSS_CITY'] },
completedAt: { gte: start, lt: end },
isTest: false,
},
orderBy: { completedAt: 'asc' },
});
@@ -1921,6 +1927,7 @@ export class SettlementService {
quantity: true,
deliveryType: true,
payStatus: true,
isTest: true,
},
},
},
@@ -1929,6 +1936,7 @@ export class SettlementService {
const eligible = deliveries.filter(
(d) =>
!d.order.isTest &&
d.order.payStatus === 'PAID' &&
(d.order.deliveryType === 'LOCAL' || d.order.deliveryType === 'CROSS_CITY'),
);
@@ -16,6 +16,10 @@ import { PartnerCityService } from '../city-scope/partner-city.service';
import { AuthService } from '../iam/auth.service';
import { StoreCategoryService } from './store-category.service';
import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider';
import {
TestWhitelistService,
normalizeTestPhone,
} from '../../common/test-whitelist/test-whitelist.service';
function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
@@ -43,10 +47,6 @@ export type StoreViewer = {
bypassWhitelist?: boolean;
};
function normalizePhone(phone: string | null | undefined): string {
return (phone || '').replace(/\D/g, '').trim();
}
function parseOptionalCoord(value: unknown, kind: 'lat' | 'lng' = 'lng'): number | null {
if (value == null || value === '') return null;
const n = typeof value === 'number' ? value : Number(value);
@@ -67,8 +67,16 @@ export class StoreService {
private readonly authService: AuthService,
private readonly storeCategoryService: StoreCategoryService,
private readonly tencentLbs: TencentLbsProvider,
private readonly testWhitelist: TestWhitelistService,
) {}
private async whitelistPhoneSet(): Promise<Set<string>> {
const rows = await this.prisma.commonTestWhitelistPhone.findMany({
select: { phone: true },
});
return new Set(rows.map((r) => normalizeTestPhone(r.phone)).filter(Boolean));
}
private storeAddressText(store: {
province?: string | null;
cityName?: string | null;
@@ -116,17 +124,16 @@ export class StoreService {
}
isVisibleToViewer(
store: {
visibilityWhitelistEnabled: boolean;
visibilityPhones: Array<{ phone: string }>;
},
store: { visibilityWhitelistEnabled: boolean },
viewer?: StoreViewer,
whitelistPhones?: Set<string>,
): boolean {
if (viewer?.bypassWhitelist) return true;
if (!store.visibilityWhitelistEnabled) return true;
const phone = normalizePhone(viewer?.phone);
const phone = normalizeTestPhone(viewer?.phone);
if (!phone) return false;
return store.visibilityPhones.some((row) => normalizePhone(row.phone) === phone);
if (whitelistPhones) return whitelistPhones.has(phone);
return false;
}
async listOpenStores(
@@ -145,12 +152,14 @@ export class StoreService {
include: {
category: true,
coverResource: true,
visibilityPhones: { select: { phone: true } },
},
orderBy: { createdAt: 'desc' },
});
const visible = stores.filter((s) => this.isVisibleToViewer(s, viewer));
const whitelistPhones = stores.some((s) => s.visibilityWhitelistEnabled)
? await this.whitelistPhoneSet()
: new Set<string>();
const visible = stores.filter((s) => this.isVisibleToViewer(s, viewer, whitelistPhones));
const hasUser =
userLat != null &&
@@ -167,7 +176,7 @@ export class StoreService {
const items: StoreListItem[] = [];
for (const store of visible) {
const coords = await this.ensureStoreCoordinates(store);
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store;
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
const mapped = mapStoreCompat({
...rest,
latitude: coords?.latitude ?? store.latitude,
@@ -197,10 +206,12 @@ export class StoreService {
include: {
category: true,
coverResource: true,
visibilityPhones: { select: { phone: true } },
},
});
if (!store || !this.isVisibleToViewer(store, viewer)) {
const whitelistPhones = store?.visibilityWhitelistEnabled
? await this.whitelistPhoneSet()
: new Set<string>();
if (!store || !this.isVisibleToViewer(store, viewer, whitelistPhones)) {
throw new NotFoundException('门店不存在');
}
const coords = await this.ensureStoreCoordinates(store);
@@ -212,7 +223,7 @@ export class StoreService {
where: { storeId: id },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store;
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
return serializeBigInt(
mapStoreCompat({
...rest,
@@ -218,6 +218,11 @@ export class TradeService {
const promoCodeId =
attribution?.promoCode?.status === 'ACTIVE' ? attribution.promoCodeId : undefined;
const buyer = await this.prisma.user.findUnique({
where: { id: userId },
select: { isTest: true },
});
const order = await this.prisma.$transaction(async (tx) => {
const created = await tx.order.create({
data: {
@@ -258,6 +263,7 @@ export class TradeService {
benefitAmount: preview.benefitAmount,
payExpireAt,
promoCodeId,
isTest: !!buyer?.isTest,
},
include: { product: true, imageResource: true },
});