fix: harden real backend admin workflows and ui

This commit is contained in:
hectorzhao
2026-07-03 19:29:56 +08:00
parent dd09d91c1e
commit 8cca361441
71 changed files with 5111 additions and 4439 deletions
+206 -7
View File
@@ -15,6 +15,19 @@ export interface CreateSmsApplicationDto {
ipAllowlist?: string[];
}
export type UpdateSmsApplicationDto = Partial<Omit<CreateSmsApplicationDto, 'tenantId'>> & {
status?: string;
};
export interface ReplaceApplicationRouteRulesDto {
routes: Array<{
carrier: string;
groupId: string;
priority?: number;
status?: string;
}>;
}
export interface CreateSmsSignatureDto {
tenantId: string;
applicationId?: string;
@@ -23,6 +36,10 @@ export interface CreateSmsSignatureDto {
drainageInfo?: Record<string, unknown>;
}
export type UpdateSmsSignatureDto = Partial<Omit<CreateSmsSignatureDto, 'tenantId'>> & {
auditStatus?: string;
};
export interface CreateSignatureMaterialDto {
signatureId: string;
fileObjectId?: string;
@@ -41,6 +58,10 @@ export interface CreateSmsTemplateDto {
variables?: Array<{ name: string; example?: string; required?: boolean }>;
}
export type UpdateSmsTemplateDto = Partial<Omit<CreateSmsTemplateDto, 'tenantId'>> & {
auditStatus?: string;
};
export interface ReviewDto {
reviewerId?: string;
reason?: string;
@@ -111,6 +132,20 @@ export class SmsConfigService {
});
}
async getApplication(applicationId: string) {
const application = await this.prisma.smsApplication.findUnique({
where: { id: applicationId },
include: {
tenant: true,
ipAllowlist: true,
},
});
if (!application) {
throw new NotFoundException('Application not found');
}
return application;
}
createApplication(data: CreateSmsApplicationDto) {
const secret = randomBytes(24).toString('hex');
return this.prisma.smsApplication.create({
@@ -132,6 +167,97 @@ export class SmsConfigService {
});
}
async updateApplication(applicationId: string, data: UpdateSmsApplicationDto) {
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
if (!application) {
throw new NotFoundException('Application not found');
}
return this.prisma.$transaction(async (tx) => {
if (data.ipAllowlist) {
await tx.smsApplicationIpAllowlist.deleteMany({ where: { applicationId } });
}
return tx.smsApplication.update({
where: { id: applicationId },
data: {
name: data.name,
scene: data.scene,
callbackUrl: data.callbackUrl,
dailyLimit: data.dailyLimit,
customerUnitPrice: data.customerUnitPrice,
maxPhonesPerTask: data.maxPhonesPerTask,
templateMismatchMode: data.templateMismatchMode,
status: data.status,
ipAllowlist: data.ipAllowlist ? {
create: data.ipAllowlist.map((ipCidr) => ({ ipCidr })),
} : undefined,
},
include: { tenant: true, ipAllowlist: true },
});
});
}
async replaceApplicationRouteRules(applicationId: string, data: ReplaceApplicationRouteRulesDto) {
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
if (!application) {
throw new NotFoundException('Application not found');
}
const routes = data.routes ?? [];
if (routes.length === 0) {
throw new BadRequestException('At least one carrier channel group is required');
}
const carriers = new Set<string>();
routes.forEach((route) => {
if (!['mobile', 'unicom', 'telecom'].includes(route.carrier)) {
throw new BadRequestException('carrier must be mobile, unicom or telecom');
}
if (carriers.has(route.carrier)) {
throw new BadRequestException('Duplicate carrier route is not allowed');
}
carriers.add(route.carrier);
});
const groups = await this.prisma.smsChannelGroup.findMany({
where: { id: { in: routes.map((route) => route.groupId) }, status: { not: 'deleted' } },
select: { id: true, carrier: true },
});
const groupMap = new Map(groups.map((group) => [group.id, group]));
routes.forEach((route) => {
const group = groupMap.get(route.groupId);
if (!group) {
throw new BadRequestException(`channel group ${route.groupId} does not exist`);
}
if (group.carrier !== route.carrier) {
throw new BadRequestException('channel group carrier must match route carrier');
}
});
return this.prisma.$transaction(async (tx) => {
await tx.channelRouteRule.deleteMany({
where: {
applicationId,
channelId: null,
province: null,
},
});
await tx.channelRouteRule.createMany({
data: routes.map((route, index) => ({
tenantId: application.tenantId,
applicationId,
groupId: route.groupId,
carrier: route.carrier,
priority: route.priority ?? (index + 1) * 10,
status: route.status ?? 'active',
})),
});
return tx.channelRouteRule.findMany({
where: { applicationId, channelId: null, province: null, status: { not: 'deleted' } },
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
});
});
}
async resetApplicationSecret(applicationId: string, data: StatusChangeDto = {}) {
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
if (!application) {
@@ -188,12 +314,12 @@ export class SmsConfigService {
};
}
async getApplicationCmppParams(applicationId: string) {
async getApplicationCmppParams(applicationId: string, tenantId?: string) {
const application = await this.prisma.smsApplication.findUnique({
where: { id: applicationId },
include: { tenant: true },
});
if (!application) {
if (!application || (tenantId && application.tenantId !== tenantId)) {
throw new NotFoundException('Application not found');
}
const channel = await this.prisma.smsChannel.findFirst({
@@ -246,10 +372,20 @@ export class SmsConfigService {
return updated;
}
listSignatures(tenantId?: string) {
listSignatures(queryOrTenantId?: string | { tenantId?: string; keyword?: string; status?: string }) {
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
return this.prisma.smsSignature.findMany({
where: tenantId ? { tenantId } : undefined,
include: { materials: true },
where: {
tenantId: query.tenantId,
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
OR: query.keyword ? [
{ name: { contains: query.keyword } },
{ purpose: { contains: query.keyword } },
{ tenant: { name: { contains: query.keyword } } },
{ application: { name: { contains: query.keyword } } },
] : undefined,
},
include: { materials: true, tenant: true, application: true },
orderBy: { createdAt: 'desc' },
take: 100,
});
@@ -267,6 +403,24 @@ export class SmsConfigService {
});
}
async updateSignature(signatureId: string, data: UpdateSmsSignatureDto) {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
if (!signature) {
throw new NotFoundException('Signature not found');
}
return this.prisma.smsSignature.update({
where: { id: signatureId },
data: {
applicationId: data.applicationId,
name: data.name,
purpose: data.purpose,
auditStatus: data.auditStatus,
drainageInfo: data.drainageInfo as Prisma.InputJsonValue | undefined,
},
include: { materials: true, tenant: true, application: true },
});
}
createSignatureMaterial(data: CreateSignatureMaterialDto) {
return this.prisma.signatureMaterial.create({
data: {
@@ -305,7 +459,7 @@ export class SmsConfigService {
return this.prisma.smsTemplate.findMany({
where: {
tenantId: query.tenantId,
auditStatus: query.status && query.status !== 'all' ? query.status : undefined,
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
OR: query.keyword ? [
{ name: { contains: query.keyword } },
{ content: { contains: query.keyword } },
@@ -338,7 +492,52 @@ export class SmsConfigService {
})),
},
},
include: { variables: true },
include: { variables: true, application: true, tenant: true, signature: true },
});
}
async updateTemplate(templateId: string, data: UpdateSmsTemplateDto) {
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
if (!template) {
throw new NotFoundException('Template not found');
}
if (data.applicationId) {
const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } });
if (!application || application.tenantId !== template.tenantId) {
throw new BadRequestException('applicationId does not belong to the template tenant');
}
}
if (data.signatureId) {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: data.signatureId }, select: { tenantId: true } });
if (!signature || signature.tenantId !== template.tenantId) {
throw new BadRequestException('signatureId does not belong to the template tenant');
}
}
const variables = data.variables ?? (data.content ? inferTemplateVariables(data.content) : undefined);
return this.prisma.$transaction(async (tx) => {
if (variables) {
await tx.templateVariable.deleteMany({ where: { templateId } });
}
return tx.smsTemplate.update({
where: { id: templateId },
data: {
applicationId: data.applicationId,
signatureId: data.signatureId,
name: data.name,
content: data.content,
category: data.category,
auditStatus: data.auditStatus,
billingUnits: data.content ? estimateBillingUnits(data.content) : undefined,
variables: variables ? {
create: variables.map((variable) => ({
name: variable.name,
example: variable.example,
required: variable.required ?? true,
})),
} : undefined,
},
include: { variables: true, application: true, tenant: true, signature: true },
});
});
}