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 { 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(); 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 }; } }