Files
lislgosms/api/src/dictionaries/dictionaries.service.ts
T

689 lines
26 KiB
TypeScript

import { BadRequestException, ConflictException, Injectable, NotFoundException, 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 type UpdateDrainageFieldDto = CreateDrainageFieldDto;
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 ReorderCommonReportFieldsDto {
reportType: 'signature' | 'drainage';
ids: string[];
}
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<string, Set<string>>();
for (const row of rows) {
const province = row.province?.trim();
if (!province) continue;
const cities = citiesByProvince.get(province) ?? new Set<string>();
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<T>(operation: () => Promise<T>, 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<string, Set<string>>();
for (const reference of channelReferences) {
if (!reference.drainageFieldId) continue;
const channelIds = channelsByField.get(reference.drainageFieldId) ?? new Set<string>();
channelIds.add(reference.channelId);
channelsByField.set(reference.drainageFieldId, channelIds);
}
const commonCountByField = new Map<string, number>();
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 updateDrainageField(id: string, data: UpdateDrainageFieldDto, operatorId?: string) {
const code = data.code?.trim();
const name = data.name?.trim();
if (!code || !/^[A-Za-z0-9]+$/.test(code)) {
throw new BadRequestException('code must contain only Arabic numerals and English letters');
}
if (!name) throw new BadRequestException('name is required');
if (!['string', 'image', 'file'].includes(data.fieldType)) {
throw new BadRequestException('fieldType must be string, image or file');
}
const existing = await this.prisma.drainageField.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('报备字段不存在');
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) && (code !== existing.code || data.fieldType !== existing.fieldType)) {
throw new BadRequestException('字段已被引用,只能修改名称和说明,不能修改字段代码或类型');
}
try {
return await this.prisma.$transaction(async (tx) => {
const updated = await tx.drainageField.update({
where: { id },
data: {
code,
name,
fieldType: data.fieldType,
description: data.description?.trim() || null,
},
});
await tx.operationLog.create({
data: {
userId: operatorId,
action: 'drainage_field.update',
resource: 'drainage_field',
resourceId: id,
detail: {
before: { code: existing.code, name: existing.name, fieldType: existing.fieldType, description: existing.description },
after: { code: updated.code, name: updated.name, fieldType: updated.fieldType, description: updated.description },
} as Prisma.InputJsonValue,
},
});
return updated;
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
throw new ConflictException('字段代码已存在');
}
throw error;
}
}
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 },
});
}
async reorderCommonReportFields(data: ReorderCommonReportFieldsDto, operatorId?: string) {
if (!['signature', 'drainage'].includes(data?.reportType) || !Array.isArray(data?.ids) || !data.ids.length) {
throw new BadRequestException('通用字段排序参数无效');
}
if (new Set(data.ids).size !== data.ids.length) throw new BadRequestException('通用字段排序不能包含重复项');
return this.prisma.$transaction(async (tx) => {
const existing = await tx.commonReportField.findMany({
where: { reportType: data.reportType, status: 'active' },
select: { id: true },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
const existingIds = existing.map((field) => field.id);
if (existingIds.length !== data.ids.length || existingIds.some((id) => !data.ids.includes(id))) {
throw new BadRequestException('通用字段排序范围已变化,请刷新页面后重试');
}
for (const [index, id] of data.ids.entries()) {
await tx.commonReportField.update({ where: { id }, data: { sortOrder: (index + 1) * 10 } });
}
await tx.operationLog.create({
data: {
userId: operatorId,
action: 'common_report_field.reorder',
resource: 'common_report_field',
detail: { reportType: data.reportType, before: existingIds, after: data.ids } as Prisma.InputJsonValue,
},
});
return tx.commonReportField.findMany({
where: { reportType: data.reportType, status: 'active' },
include: { drainageField: true },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
}
deleteCommonReportField(id: string) {
return this.prisma.commonReportField.delete({ where: { id } });
}
async updateCommonReportField(id: string, data: CreateCommonReportFieldDto, operatorId?: string) {
if (!['signature', 'drainage'].includes(data.reportType) || typeof data.required !== 'boolean') {
throw new BadRequestException('资料用途或是否必填无效');
}
if (data.sortOrder !== undefined && !Number.isInteger(data.sortOrder)) {
throw new BadRequestException('排序值必须为整数');
}
const existing = await this.prisma.commonReportField.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('通用字段配置不存在');
const field = await this.prisma.drainageField.findUnique({ where: { id: data.drainageFieldId } });
if (!field || field.status !== 'active') throw new BadRequestException('报备字段库字段不存在或已停用');
const duplicate = await this.prisma.commonReportField.findUnique({
where: { drainageFieldId_reportType: { drainageFieldId: field.id, reportType: data.reportType } },
});
if (duplicate && duplicate.id !== id) throw new ConflictException('该字段已配置为对应类型的通用字段');
try {
return await this.prisma.$transaction(async (tx) => {
const updated = await tx.commonReportField.update({
where: { id },
data: { drainageFieldId: field.id, reportType: data.reportType, required: data.required, sortOrder: data.sortOrder },
include: { drainageField: true },
});
await tx.operationLog.create({ data: {
userId: operatorId, action: 'common_report_field.update', resource: 'common_report_field', resourceId: id,
detail: { before: { drainageFieldId: existing.drainageFieldId, reportType: existing.reportType, required: existing.required }, after: { drainageFieldId: field.id, reportType: data.reportType, required: data.required } },
} });
return updated;
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
throw new ConflictException('该字段已配置为对应类型的通用字段');
}
throw error;
}
}
private writeOperationLog(userId: string | undefined, action: string, resource: string, resourceId: string, detail: Record<string, unknown>) {
return this.prisma.operationLog.create({
data: {
userId,
action,
resource,
resourceId,
detail: detail as Prisma.InputJsonValue,
},
});
}
}