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