import { BadRequestException, ConflictException, Injectable, Optional } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { PhoneRoutingLookupService } from './phone-routing-lookup.service'; import { detectDrainageContentWithRules, invalidateDrainageDetectionRuleCache, validateDrainageDetectionPattern, } from '../send-chain/drainage-content-detection'; export interface CreatePhoneSegmentDto { prefix: string; carrier: string; province?: string; city?: string; } function visibleDictionaryStatus(status?: string): string | Prisma.StringFilter { return status && status !== 'all' && status !== 'deleted' ? status : { not: 'deleted' }; } export interface PhoneSegmentListQuery { keyword?: string; page?: number; pageSize?: number; } export interface CreatePhoneCarrierRuleDto { carrier: string; pattern: string; priority?: number; status?: string; remark?: string; } export interface PageQuery { keyword?: string; page?: number; pageSize?: number; } export interface CreateSensitiveWordDto { word: string; level?: string; status?: string; } export interface CreateBlacklistDto { tenantId?: string; applicationId?: string; phoneNumber: string; reason?: string; status?: string; operatorId?: string; } export interface CreateDrainageFieldDto { code: string; name: string; fieldType: 'string' | 'image' | 'file'; required?: boolean; status?: string; description?: string; } export interface UpsertDrainageDetectionRuleDto { code: string; name: string; category: 'url' | 'mobile' | 'landline'; pattern: string; flags?: string; priority?: number; status?: string; description?: string; operatorId?: string; } export interface TestDrainageDetectionDto { content: string; rule?: UpsertDrainageDetectionRuleDto; } export interface CreateCommonReportFieldDto { drainageFieldId: string; reportType: 'signature' | 'drainage'; required?: boolean; sortOrder?: number; } export interface DictionaryStatusDto { status?: string; operatorId?: string; reason?: string; } export interface DictionaryListQuery { tenantId?: string; applicationId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; phoneNumber?: string; reasonKeyword?: string; } @Injectable() export class DictionariesService { constructor( private readonly prisma: PrismaService, @Optional() private readonly phoneRoutingLookup?: PhoneRoutingLookupService, ) {} async listAdministrativeRegions() { const rows = await this.prisma.phoneSegment.findMany({ where: { province: { not: null } }, select: { province: true, city: true }, distinct: ['province', 'city'], }); const citiesByProvince = new Map>(); for (const row of rows) { const province = row.province?.trim(); if (!province) continue; const cities = citiesByProvince.get(province) ?? new Set(); const city = row.city?.trim(); if (city) cities.add(city); citiesByProvince.set(province, cities); } return Array.from(citiesByProvince, ([province, cities]) => ({ province, cities: Array.from(cities).sort((left, right) => left.localeCompare(right, 'zh-CN')), })).sort((left, right) => left.province.localeCompare(right.province, 'zh-CN')); } async listPhoneSegments(query: PhoneSegmentListQuery = {}) { const page = Math.max(1, Number(query.page ?? 1)); const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 25))); const keyword = query.keyword?.trim(); const where = keyword ? { OR: [ { prefix: { startsWith: keyword } }, { carrier: { contains: keyword } }, { province: { contains: keyword } }, { city: { contains: keyword } }, ], } : undefined; const [items, total] = await Promise.all([ this.prisma.phoneSegment.findMany({ where, orderBy: { prefix: 'asc' }, skip: (page - 1) * pageSize, take: pageSize }), this.prisma.phoneSegment.count({ where }), ]); return { items, total, page, pageSize }; } createPhoneSegment(data: CreatePhoneSegmentDto) { return this.prisma.phoneSegment.create({ data }); } deletePhoneSegment(id: string) { return this.prisma.phoneSegment.delete({ where: { id } }); } async listPhoneCarrierRules(query: PageQuery = {}) { const page = Math.max(1, Number(query.page ?? 1)); const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 25))); const where = query.keyword?.trim() ? { OR: [ { carrier: { contains: query.keyword.trim() } }, { pattern: { contains: query.keyword.trim() } }, { remark: { contains: query.keyword.trim() } }, ], } : undefined; const [items, total] = await Promise.all([ this.prisma.phoneCarrierRule.findMany({ where, orderBy: [{ priority: 'asc' }, { createdAt: 'desc' }], skip: (page - 1) * pageSize, take: pageSize }), this.prisma.phoneCarrierRule.count({ where }), ]); return { items, total, page, pageSize }; } async createPhoneCarrierRule(data: CreatePhoneCarrierRuleDto) { if (!data.carrier || !data.pattern) { throw new BadRequestException('carrier and pattern are required'); } try { new RegExp(data.pattern); } catch { throw new BadRequestException('pattern must be a valid regular expression'); } const created = await this.prisma.phoneCarrierRule.create({ data: { carrier: data.carrier, pattern: data.pattern, priority: data.priority ?? 100, status: data.status ?? 'active', remark: data.remark, }, }); this.phoneRoutingLookup?.invalidateCarrierRules(); return created; } async deletePhoneCarrierRule(id: string) { const deleted = await this.prisma.phoneCarrierRule.delete({ where: { id } }); this.phoneRoutingLookup?.invalidateCarrierRules(); return deleted; } listSensitiveWords(query: DictionaryListQuery = {}) { return this.prisma.sensitiveWord.findMany({ where: { status: visibleDictionaryStatus(query.status), OR: query.keyword ? [ { word: { contains: query.keyword } }, { level: { contains: query.keyword } }, ] : undefined, }, orderBy: { createdAt: 'desc' }, }); } async createSensitiveWord(data: CreateSensitiveWordDto) { const created = await this.prisma.sensitiveWord.create({ data: { word: data.word, level: data.level ?? 'block', status: data.status ?? 'active', }, }); await this.writeOperationLog(undefined, 'sensitive_word.create', 'sensitive_word', created.id, { word: data.word }); return created; } async changeSensitiveWordStatus(id: string, data: DictionaryStatusDto) { const status = data.status ?? 'active'; const updated = await this.prisma.sensitiveWord.update({ where: { id }, data: { status } }); await this.writeOperationLog(data.operatorId, `sensitive_word.${status}`, 'sensitive_word', id, { reason: data.reason }); return updated; } listGlobalBlacklist(query: DictionaryListQuery = {}) { return this.prisma.globalBlacklist.findMany({ where: { status: visibleDictionaryStatus(query.status), OR: query.keyword ? [ { phoneNumber: { contains: query.keyword } }, { reason: { contains: query.keyword } }, ] : undefined, }, orderBy: { createdAt: 'desc' }, }); } async createGlobalBlacklist(data: CreateBlacklistDto) { const created = await this.createBlacklistOrConflict(() => this.prisma.globalBlacklist.create({ data: { phoneNumber: data.phoneNumber, reason: data.reason, status: data.status ?? 'active', }, }), 'global'); await this.writeOperationLog(data.operatorId, 'global_blacklist.create', 'global_blacklist', created.id, { phoneNumber: data.phoneNumber, reason: data.reason, }); return created; } async changeGlobalBlacklistStatus(id: string, data: DictionaryStatusDto) { const status = data.status ?? 'active'; const updated = await this.prisma.globalBlacklist.update({ where: { id }, data: { status } }); await this.writeOperationLog(data.operatorId, `global_blacklist.${status}`, 'global_blacklist', id, { reason: data.reason }); return updated; } listEnterpriseBlacklist(query: DictionaryListQuery = {}) { return this.prisma.enterpriseBlacklist.findMany({ where: { tenantId: query.tenantId, applicationId: query.applicationId, status: visibleDictionaryStatus(query.status), tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined, application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined, phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined, reason: query.reasonKeyword ? { contains: query.reasonKeyword } : undefined, OR: query.keyword ? [ { phoneNumber: { contains: query.keyword } }, { reason: { contains: query.keyword } }, { tenant: { name: { contains: query.keyword } } }, { application: { name: { contains: query.keyword } } }, ] : undefined, }, include: { tenant: true, application: true }, orderBy: { createdAt: 'desc' }, }); } async createEnterpriseBlacklist(data: CreateBlacklistDto) { if (!data.tenantId || !data.applicationId) { throw new BadRequestException('tenantId and applicationId are required for application blacklist'); } const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true }, }); if (!application || application.tenantId !== data.tenantId) { throw new BadRequestException('applicationId does not belong to tenantId'); } const createData: Prisma.EnterpriseBlacklistUncheckedCreateInput = { tenantId: data.tenantId, applicationId: data.applicationId, phoneNumber: data.phoneNumber, reason: data.reason, status: data.status ?? 'active', }; const created = await this.createBlacklistOrConflict(() => this.prisma.enterpriseBlacklist.create({ data: createData }), 'enterprise'); await this.writeOperationLog(data.operatorId, 'enterprise_blacklist.create', 'enterprise_blacklist', created.id, { tenantId: data.tenantId, applicationId: data.applicationId, phoneNumber: data.phoneNumber, reason: data.reason, }); return created; } async changeEnterpriseBlacklistStatus(id: string, data: DictionaryStatusDto) { const status = data.status ?? 'active'; const updated = await this.prisma.enterpriseBlacklist.update({ where: { id }, data: { status } }); await this.writeOperationLog(data.operatorId, `enterprise_blacklist.${status}`, 'enterprise_blacklist', id, { reason: data.reason }); return updated; } private async createBlacklistOrConflict(operation: () => Promise, scope: 'global' | 'enterprise') { try { return await operation(); } catch (error) { if ((error as { code?: string }).code !== 'P2002') throw error; throw new ConflictException({ code: 'BLACKLIST_DUPLICATE', field: 'phoneNumber', message: scope === 'global' ? '该手机号已存在于全局黑名单;停用或逻辑删除后请恢复原记录' : '该手机号已存在于当前应用黑名单;停用或逻辑删除后请恢复原记录', }); } } async listDrainageFields() { const fields = await this.prisma.drainageField.findMany({ orderBy: { createdAt: 'desc' }, }); const fieldIds = fields.map((field) => field.id); const [channelReferences, commonReferences] = fieldIds.length ? await Promise.all([ this.prisma.channelReportField.findMany({ where: { drainageFieldId: { in: fieldIds }, channel: { status: { not: 'deleted' } }, }, select: { drainageFieldId: true, channelId: true }, }), this.prisma.commonReportField.findMany({ where: { drainageFieldId: { in: fieldIds } }, select: { drainageFieldId: true }, }), ]) : [[], []]; const channelsByField = new Map>(); for (const reference of channelReferences) { if (!reference.drainageFieldId) continue; const channelIds = channelsByField.get(reference.drainageFieldId) ?? new Set(); channelIds.add(reference.channelId); channelsByField.set(reference.drainageFieldId, channelIds); } const commonCountByField = new Map(); for (const reference of commonReferences) { commonCountByField.set(reference.drainageFieldId, (commonCountByField.get(reference.drainageFieldId) ?? 0) + 1); } return fields.map((field) => ({ ...field, usageCount: channelsByField.get(field.id)?.size ?? 0, commonUsageCount: commonCountByField.get(field.id) ?? 0, })); } createDrainageField(data: CreateDrainageFieldDto) { const code = data.code?.trim(); if (!code || !/^[A-Za-z0-9]+$/.test(code)) { throw new BadRequestException('code must contain only Arabic numerals and English letters'); } if (!['string', 'image', 'file'].includes(data.fieldType)) { throw new BadRequestException('fieldType must be string, image or file'); } return this.prisma.drainageField.create({ data: { code, name: data.name, fieldType: data.fieldType, required: data.required ?? false, status: data.status ?? 'active', description: data.description, }, }); } async deleteDrainageField(id: string) { const [usageCount, commonUsageCount] = await Promise.all([ this.prisma.channelReportField.count({ where: { drainageFieldId: id, channel: { status: { not: 'deleted' } } }, }), this.prisma.commonReportField.count({ where: { drainageFieldId: id } }), ]); if (usageCount > 0 || commonUsageCount > 0) { throw new BadRequestException(`该字段已被 ${usageCount} 个通道和 ${commonUsageCount} 个通用配置使用,不能删除`); } return this.prisma.$transaction(async (tx) => { await tx.channelReportField.deleteMany({ where: { drainageFieldId: id, channel: { status: 'deleted' } }, }); return tx.drainageField.delete({ where: { id } }); }); } listDrainageDetectionRules(query: { keyword?: string; status?: string } = {}) { const keyword = query.keyword?.trim(); return this.prisma.drainageDetectionRule.findMany({ where: { status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, OR: keyword ? [ { code: { contains: keyword, mode: 'insensitive' } }, { name: { contains: keyword, mode: 'insensitive' } }, { description: { contains: keyword, mode: 'insensitive' } }, ] : undefined, }, orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }], }); } async createDrainageDetectionRule(data: UpsertDrainageDetectionRuleDto) { this.validateDrainageDetectionRule(data); const created = await this.prisma.drainageDetectionRule.create({ data: { code: data.code.trim().toUpperCase(), name: data.name.trim(), category: data.category, pattern: data.pattern, flags: data.flags ?? 'giu', priority: data.priority ?? 100, status: data.status ?? 'active', description: data.description?.trim() || null, }, }); invalidateDrainageDetectionRuleCache(); await this.writeOperationLog(data.operatorId, 'drainage_detection_rule.create', 'drainage_detection_rule', created.id, { code: created.code }); return created; } async updateDrainageDetectionRule(id: string, data: UpsertDrainageDetectionRuleDto) { this.validateDrainageDetectionRule(data); const updated = await this.prisma.drainageDetectionRule.update({ where: { id }, data: { code: data.code.trim().toUpperCase(), name: data.name.trim(), category: data.category, pattern: data.pattern, flags: data.flags ?? 'giu', priority: data.priority ?? 100, status: data.status ?? 'active', description: data.description?.trim() || null, version: { increment: 1 }, }, }); invalidateDrainageDetectionRuleCache(); await this.writeOperationLog(data.operatorId, 'drainage_detection_rule.update', 'drainage_detection_rule', id, { code: updated.code, version: updated.version }); return updated; } async changeDrainageDetectionRuleStatus(id: string, data: DictionaryStatusDto) { const status = data.status === 'inactive' ? 'inactive' : 'active'; const updated = await this.prisma.drainageDetectionRule.update({ where: { id }, data: { status, version: { increment: 1 } }, }); invalidateDrainageDetectionRuleCache(); await this.writeOperationLog(data.operatorId, `drainage_detection_rule.${status}`, 'drainage_detection_rule', id, { reason: data.reason }); return updated; } async testDrainageDetection(data: TestDrainageDetectionDto) { if (!data.content?.trim()) throw new BadRequestException('测试短信内容不能为空'); const rules = data.rule ? [{ id: 'preview', code: data.rule.code?.trim().toUpperCase() || 'PREVIEW', name: data.rule.name?.trim() || '预览规则', category: data.rule.category, pattern: data.rule.pattern, flags: data.rule.flags ?? 'giu', priority: data.rule.priority ?? 100, version: 1, }] : await this.prisma.drainageDetectionRule.findMany({ where: { status: 'active' }, orderBy: { priority: 'asc' } }); if (data.rule) this.validateDrainageDetectionRule(data.rule); return detectDrainageContentWithRules(data.content, rules); } private validateDrainageDetectionRule(data: UpsertDrainageDetectionRuleDto) { if (!data.code?.trim() || !data.name?.trim()) throw new BadRequestException('规则编码和名称不能为空'); if (!['url', 'mobile', 'landline'].includes(data.category)) throw new BadRequestException('规则类型仅支持 URL、手机号或固话'); if (data.status && !['active', 'inactive'].includes(data.status)) throw new BadRequestException('规则状态不正确'); validateDrainageDetectionPattern(data.pattern, data.flags ?? 'giu'); } listCommonReportFields() { return this.prisma.commonReportField.findMany({ include: { drainageField: true }, orderBy: [{ reportType: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }], }); } async createCommonReportField(data: CreateCommonReportFieldDto) { if (data.reportType !== 'signature' && data.reportType !== 'drainage') { throw new BadRequestException('reportType must be signature or drainage'); } const field = await this.prisma.drainageField.findUnique({ where: { id: data.drainageFieldId } }); if (!field || field.status !== 'active') { throw new BadRequestException('报备字段库字段不存在或已停用'); } const existing = await this.prisma.commonReportField.findUnique({ where: { drainageFieldId_reportType: { drainageFieldId: field.id, reportType: data.reportType } }, }); if (existing) { throw new BadRequestException('该字段已配置为对应类型的通用字段'); } return this.prisma.commonReportField.create({ data: { drainageFieldId: field.id, reportType: data.reportType, required: data.required ?? field.required, sortOrder: data.sortOrder ?? 100, status: 'active', }, include: { drainageField: true }, }); } deleteCommonReportField(id: string) { return this.prisma.commonReportField.delete({ where: { id } }); } private writeOperationLog(userId: string | undefined, action: string, resource: string, resourceId: string, detail: Record) { return this.prisma.operationLog.create({ data: { userId, action, resource, resourceId, detail: detail as Prisma.InputJsonValue, }, }); } }