From 2ce682c3fcb2be51efa45711038889593fe0f2d7 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Sun, 26 Jul 2026 13:08:12 +0800 Subject: [PATCH] feat: strengthen risk controls and review workflows --- .../migration.sql | 15 + .../migration.sql | 7 + .../migration.sql | 39 ++ api/prisma/schema.prisma | 56 +-- .../admin-risk-review.controller.ts | 21 +- .../risk-review/risk-review.service.spec.ts | 188 ++++++---- api/src/risk-review/risk-review.service.ts | 354 +++++++++++++----- api/src/send-chain/send-chain.service.spec.ts | 164 +++++++- api/src/send-chain/send-chain.service.ts | 185 ++++++--- .../sms-config/admin-sms-config.controller.ts | 5 + api/src/sms-config/sms-config.service.spec.ts | 77 +++- api/src/sms-config/sms-config.service.ts | 269 ++++++++++++- api/src/tenants/tenants.service.spec.ts | 15 + api/src/tenants/tenants.service.ts | 16 +- api/src/users/users.controller.ts | 19 +- api/src/users/users.service.spec.ts | 79 +++- api/src/users/users.service.ts | 41 +- .../first-version-development-requirements.md | 43 ++- docs/system-functional-test-cases.md | 38 ++ docs/testing-progress.md | 29 ++ gateway/internal/control/server.go | 29 ++ gateway/internal/control/server_test.go | 26 ++ gateway/internal/inbound/server.go | 22 ++ src/api/adminApi.ts | 88 ++++- .../admin/AdminEnterpriseApplicationsPage.tsx | 126 ++++++- src/apps/admin/AdminRiskRulesPage.tsx | 195 ++++++++++ .../admin/AdminSmsApplicationFormPage.tsx | 5 - src/apps/admin/AdminSmsAuditPage.tsx | 80 +++- src/apps/admin/AdminUsersPage.tsx | 142 +++++-- src/apps/client/ClientUsersPage.css | 27 ++ src/apps/client/ClientUsersPage.tsx | 119 ++++-- src/layouts/AdminLayout.tsx | 1 + src/routes/AppRoutes.tsx | 2 + src/styles/global.css | 35 +- 34 files changed, 2167 insertions(+), 390 deletions(-) create mode 100644 api/prisma/migrations/20260726090000_allow_reuse_deleted_user_logins/migration.sql create mode 100644 api/prisma/migrations/20260726113000_add_application_disabling_lifecycle/migration.sql create mode 100644 api/prisma/migrations/20260726143000_application_risk_rules/migration.sql create mode 100644 src/apps/admin/AdminRiskRulesPage.tsx diff --git a/api/prisma/migrations/20260726090000_allow_reuse_deleted_user_logins/migration.sql b/api/prisma/migrations/20260726090000_allow_reuse_deleted_user_logins/migration.sql new file mode 100644 index 0000000..1996820 --- /dev/null +++ b/api/prisma/migrations/20260726090000_allow_reuse_deleted_user_logins/migration.sql @@ -0,0 +1,15 @@ +DROP INDEX IF EXISTS "User_username_key"; +DROP INDEX IF EXISTS "User_email_key"; +DROP INDEX IF EXISTS "User_phone_key"; + +CREATE UNIQUE INDEX "User_active_username_key" +ON "User" ("username") +WHERE "deletedAt" IS NULL; + +CREATE UNIQUE INDEX "User_active_email_key" +ON "User" ("email") +WHERE "deletedAt" IS NULL AND "email" IS NOT NULL; + +CREATE UNIQUE INDEX "User_active_phone_key" +ON "User" ("phone") +WHERE "deletedAt" IS NULL AND "phone" IS NOT NULL; diff --git a/api/prisma/migrations/20260726113000_add_application_disabling_lifecycle/migration.sql b/api/prisma/migrations/20260726113000_add_application_disabling_lifecycle/migration.sql new file mode 100644 index 0000000..00911bd --- /dev/null +++ b/api/prisma/migrations/20260726113000_add_application_disabling_lifecycle/migration.sql @@ -0,0 +1,7 @@ +ALTER TABLE "SmsApplication" +ADD COLUMN "disablingAt" TIMESTAMP(3), +ADD COLUMN "autoDisableAt" TIMESTAMP(3), +ADD COLUMN "disableReason" TEXT; + +CREATE INDEX "SmsApplication_status_autoDisableAt_idx" +ON "SmsApplication"("status", "autoDisableAt"); diff --git a/api/prisma/migrations/20260726143000_application_risk_rules/migration.sql b/api/prisma/migrations/20260726143000_application_risk_rules/migration.sql new file mode 100644 index 0000000..e317f96 --- /dev/null +++ b/api/prisma/migrations/20260726143000_application_risk_rules/migration.sql @@ -0,0 +1,39 @@ +-- Risk thresholds now inherit from global rules and may be overridden per application. +-- Existing maxPhonesPerTask values are intentionally discarded because this environment +-- contains test-only application data and the product owner explicitly declined migration. +ALTER TABLE "RiskRule" ADD COLUMN "applicationId" TEXT; + +-- Retain historical rules and hit records for audit, but remove them from the +-- effective rule set and configuration page. +UPDATE "RiskRule" +SET "status" = 'deleted' +WHERE "code" IN ( + 'DUPLICATE_PHONE_RATIO', + 'ILLEGAL_PHONE_RATIO', + 'BLACKLIST_HIT_RATIO', + 'TEMPLATE_VARIABLE_ANOMALY' +); + +UPDATE "RiskRule" +SET "status" = 'deleted' +WHERE "tenantId" IS NOT NULL AND "applicationId" IS NULL; + +DROP INDEX IF EXISTS "RiskRule_tenantId_code_key"; +DROP INDEX IF EXISTS "RiskRule_tenantId_status_priority_idx"; + +CREATE UNIQUE INDEX "RiskRule_applicationId_code_key" +ON "RiskRule"("applicationId", "code"); + +CREATE UNIQUE INDEX "RiskRule_global_code_key" +ON "RiskRule"("code") +WHERE "applicationId" IS NULL AND "status" <> 'deleted'; + +CREATE INDEX "RiskRule_tenantId_applicationId_status_priority_idx" +ON "RiskRule"("tenantId", "applicationId", "status", "priority"); + +ALTER TABLE "RiskRule" +ADD CONSTRAINT "RiskRule_applicationId_fkey" +FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") +ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "SmsApplication" DROP COLUMN "maxPhonesPerTask"; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index feb22ff..23ce076 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -74,9 +74,9 @@ model EnterpriseCertification { model User { id String @id @default(cuid()) tenantId String? - username String @unique - email String? @unique - phone String? @unique + username String + email String? + phone String? displayName String passwordHash String status String @default("active") @@ -384,32 +384,34 @@ model SmsBillingRecord { } model SmsApplication { - id String @id @default(cuid()) + id String @id @default(cuid()) tenantId String name String scene String? callbackUrl String? - cmppAccount String @unique + cmppAccount String @unique cmppEnterpriseCode String cmppApplicationExtension String? - cmppAccessNumberFillEnabled Boolean @default(false) + cmppAccessNumberFillEnabled Boolean @default(false) cmppAccessNumberFillPrefix String? - cmppClientSrcId String? @unique + cmppClientSrcId String? @unique secretHash String - interfaceEnabled Boolean @default(true) - interfaceType String @default("cmpp20") - cmppMaxConnections Int @default(1) - cmppWindowSize Int @default(16) - dailyLimit Int @default(100000) - customerUnitPrice BigInt @default(0) - queuePriority String @default("normal") - maxPhonesPerTask Int @default(10000) - templateMismatchMode String @default("reject") - downstreamReceiptRetryEnabled Boolean @default(true) - downstreamUplinkRetryEnabled Boolean @default(true) - status String @default("active") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + interfaceEnabled Boolean @default(true) + interfaceType String @default("cmpp20") + cmppMaxConnections Int @default(1) + cmppWindowSize Int @default(16) + dailyLimit Int @default(100000) + customerUnitPrice BigInt @default(0) + queuePriority String @default("normal") + templateMismatchMode String @default("reject") + downstreamReceiptRetryEnabled Boolean @default(true) + downstreamUplinkRetryEnabled Boolean @default(true) + status String @default("active") + disablingAt DateTime? + autoDisableAt DateTime? + disableReason String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt tenant Tenant @relation(fields: [tenantId], references: [id]) ipAllowlist SmsApplicationIpAllowlist[] @@ -435,8 +437,10 @@ model SmsApplication { httpWebhookEvents HttpWebhookEvent[] dailyUsages SmsApplicationDailyUsage[] inboundLongMessages CmppInboundLongMessage[] + riskRules RiskRule[] @@index([tenantId, status]) + @@index([status, autoDisableAt]) } model SmsApplicationIpAllowlist { @@ -1213,6 +1217,7 @@ model ReportReceiptImport { model RiskRule { id String @id @default(cuid()) tenantId String? + applicationId String? code String name String description String? @@ -1225,11 +1230,12 @@ model RiskRule { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - tenant Tenant? @relation(fields: [tenantId], references: [id]) - hits RiskHitRecord[] + tenant Tenant? @relation(fields: [tenantId], references: [id]) + application SmsApplication? @relation(fields: [applicationId], references: [id], onDelete: Cascade) + hits RiskHitRecord[] - @@unique([tenantId, code]) - @@index([tenantId, status, priority]) + @@unique([applicationId, code]) + @@index([tenantId, applicationId, status, priority]) } model SmsSendTask { diff --git a/api/src/risk-review/admin-risk-review.controller.ts b/api/src/risk-review/admin-risk-review.controller.ts index 60a79b7..d7b0682 100644 --- a/api/src/risk-review/admin-risk-review.controller.ts +++ b/api/src/risk-review/admin-risk-review.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; import { SendChainService } from '../send-chain/send-chain.service'; @@ -15,8 +15,8 @@ export class AdminRiskReviewController { constructor(private readonly riskReview: RiskReviewService, private readonly sendChain: SendChainService) {} @Get('rules') - listRules(@Query('tenantId') tenantId?: string) { - return this.riskReview.listRules(tenantId); + listRules(@Query('applicationId') applicationId?: string) { + return this.riskReview.listRules(applicationId); } @Post('rules') @@ -24,6 +24,11 @@ export class AdminRiskReviewController { return this.riskReview.createRule(body); } + @Put('rules/:id') + updateRule(@Param('id') ruleId: string, @Body() body: Partial) { + return this.riskReview.updateRule(ruleId, body); + } + @Get('hits') listHits(@Query('tenantId') tenantId?: string, @Query('taskId') taskId?: string) { return this.riskReview.listHits(tenantId, taskId); @@ -39,6 +44,16 @@ export class AdminRiskReviewController { return this.riskReview.listPendingTasks(); } + @Get('tasks/:id/messages') + listTaskMessages( + @Param('id') taskId: string, + @Query('phone') phone?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.riskReview.listTaskMessages(taskId, phone, Number(page ?? 1), Number(pageSize ?? 20)); + } + @Post('tasks/:id/approve') async approveTask(@Param('id') taskId: string, @Body() body: ReviewSmsTaskDto, @CurrentSessionUserId() reviewerId?: string) { const review = { ...body, reviewerId }; diff --git a/api/src/risk-review/risk-review.service.spec.ts b/api/src/risk-review/risk-review.service.spec.ts index fab8808..32358e6 100644 --- a/api/src/risk-review/risk-review.service.spec.ts +++ b/api/src/risk-review/risk-review.service.spec.ts @@ -4,7 +4,9 @@ function createPrismaMock(overrides: Record = {}) { return { riskRule: { findFirst: jest.fn().mockResolvedValue({ id: 'default-rule' }), + findUnique: jest.fn(), create: jest.fn(), + update: jest.fn(), findMany: jest.fn().mockResolvedValue([]), }, globalBlacklist: { @@ -26,7 +28,6 @@ function createPrismaMock(overrides: Record = {}) { findUnique: jest.fn().mockResolvedValue(null), }, smsSendTask: { - count: jest.fn().mockResolvedValue(0), create: jest.fn().mockImplementation(({ data }: { data: Record }) => Promise.resolve({ id: 'risk-task-1', ...data }), ), @@ -37,6 +38,11 @@ function createPrismaMock(overrides: Record = {}) { }, smsMessageRecord: { update: jest.fn().mockResolvedValue({ id: 'message-1' }), + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }, + smsBatchTask: { + count: jest.fn().mockResolvedValue(0), }, riskHitRecord: { createMany: jest.fn(), @@ -120,16 +126,15 @@ describe('RiskReviewService', () => { await expect(service.rejectTasks({ ids: ['task-1'], reason: ' ' })).rejects.toThrow('Batch rejection reason is required'); }); - it('rejects tasks over the application max phone threshold', async () => { + it('rejects tasks over the effective max phone rule threshold', async () => { const prisma = createPrismaMock(); - prisma.smsApplication.findUnique.mockResolvedValue({ id: 'app-1', maxPhonesPerTask: 2 }); prisma.riskRule.findMany.mockResolvedValue([ { id: 'rule-max', code: 'MAX_PHONES_PER_TASK', name: '单任务最大号码数', metric: 'phoneTotal', - thresholdValue: 100000, + thresholdValue: 2, action: 'block', priority: 10, }, @@ -155,30 +160,78 @@ describe('RiskReviewService', () => { }); }); - it('routes duplicate and blacklist ratio hits to manual review', async () => { + it('uses an application rule with the same code instead of the global default', async () => { const prisma = createPrismaMock(); - prisma.globalBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000001' }]); - prisma.enterpriseBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000002' }]); prisma.riskRule.findMany.mockResolvedValue([ { - id: 'rule-dup', - code: 'DUPLICATE_PHONE_RATIO', - name: '重复号码比例', - metric: 'duplicateRatio', - thresholdValue: 0.2, - action: 'manual_review', - priority: 20, + id: 'rule-global', + applicationId: null, + code: 'MAX_PHONES_PER_TASK', + name: '单任务最大号码数', + metric: 'phoneTotal', + thresholdValue: 100000, + action: 'block', + priority: 10, }, { - id: 'rule-black', - code: 'BLACKLIST_HIT_RATIO', - name: '黑名单命中比例', - metric: 'blacklistHitRatio', - thresholdValue: 0.2, - action: 'manual_review', - priority: 40, + id: 'rule-app', + applicationId: 'app-1', + code: 'MAX_PHONES_PER_TASK', + name: '单任务最大号码数', + metric: 'phoneTotal', + thresholdValue: 1, + action: 'block', + priority: 10, }, ]); + const service = new RiskReviewService(prisma as never); + + await expect(service.evaluateTask({ + tenantId: 'tenant-1', + applicationId: 'app-1', + content: 'hello', + phones: ['13800000001', '13800000002'], + })).resolves.toEqual(expect.objectContaining({ status: 'rejected' })); + expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ ruleId: 'rule-app', thresholdValue: 1 })], + }); + }); + + it('paginates real phone records through both direct and batch review-task relations', async () => { + const prisma = createPrismaMock(); + prisma.smsSendTask.findUnique.mockResolvedValue({ id: 'review-task-1' }); + prisma.smsMessageRecord.findMany.mockResolvedValue([{ + id: 'message-1', + phoneNumber: '13800000001', + province: '上海', + carrier: 'mobile', + status: 'pending_review', + }]); + prisma.smsMessageRecord.count.mockResolvedValue(1); + const service = new RiskReviewService(prisma as never); + + await expect(service.listTaskMessages('review-task-1', '138', 1, 20)).resolves.toEqual({ + items: [expect.objectContaining({ phoneNumber: '13800000001' })], + total: 1, + page: 1, + pageSize: 20, + }); + expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { + OR: [ + { reviewTaskId: 'review-task-1' }, + { batchTask: { riskTaskId: 'review-task-1' } }, + ], + phoneNumber: { contains: '138' }, + }, + skip: 0, + take: 20, + })); + }); + + it('does not create an audit task for automatic approval', async () => { + const prisma = createPrismaMock(); + prisma.riskRule.findMany.mockResolvedValue([]); const service = new RiskReviewService(prisma as never); const result = await service.evaluateTask({ @@ -188,54 +241,20 @@ describe('RiskReviewService', () => { phones: ['13800000001', '13800000001', '13800000002'], }); - expect(result.status).toBe('pending_review'); - expect(prisma.smsSendTask.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - duplicateRatio: 0.3333, - blacklistHitRatio: 0.6667, - status: 'pending_review', - riskDecision: 'manual_review', - }), - }); - expect(prisma.enterpriseBlacklist.findMany).toHaveBeenCalledWith({ - where: { tenantId: 'tenant-1', applicationId: 'app-1', phoneNumber: { in: ['13800000001', '13800000002'] }, status: 'active' }, - select: { phoneNumber: true }, - }); - expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({ - data: expect.arrayContaining([ - expect.objectContaining({ ruleCode: 'DUPLICATE_PHONE_RATIO' }), - expect.objectContaining({ ruleCode: 'BLACKLIST_HIT_RATIO' }), - ]), - }); + expect(result).toEqual(expect.objectContaining({ status: 'approved', canSubmit: true, task: null })); + expect(prisma.smsSendTask.create).not.toHaveBeenCalled(); + expect(prisma.globalBlacklist.findMany).not.toHaveBeenCalled(); + expect(prisma.enterpriseBlacklist.findMany).not.toHaveBeenCalled(); }); - it('rejects illegal phone and template variable anomalies', async () => { + it('keeps template variable validation as deterministic rejection instead of a configurable rule', async () => { const prisma = createPrismaMock(); prisma.smsTemplate.findUnique.mockResolvedValue({ id: 'tpl-1', category: 'notice', variables: [{ name: 'code', required: true }], }); - prisma.riskRule.findMany.mockResolvedValue([ - { - id: 'rule-illegal', - code: 'ILLEGAL_PHONE_RATIO', - name: '非法号码比例', - metric: 'illegalRatio', - thresholdValue: 0.1, - action: 'block', - priority: 30, - }, - { - id: 'rule-var', - code: 'TEMPLATE_VARIABLE_ANOMALY', - name: '模板变量异常', - metric: 'variableIssueCount', - thresholdValue: 0, - action: 'block', - priority: 70, - }, - ]); + prisma.riskRule.findMany.mockResolvedValue([]); const service = new RiskReviewService(prisma as never); const result = await service.evaluateTask({ @@ -255,7 +274,9 @@ describe('RiskReviewService', () => { { type: 'missing_required_variable', name: 'code' }, { type: 'unexpected_variable', name: 'extra' }, ]), - content: [], + content: expect.arrayContaining([ + expect.objectContaining({ ruleCode: 'TEMPLATE_VARIABLE_INVALID', action: 'block' }), + ]), }, }), }); @@ -263,7 +284,7 @@ describe('RiskReviewService', () => { it('marks non-working marketing bulk and frequent task creation for manual review', async () => { const prisma = createPrismaMock(); - prisma.smsSendTask.count.mockResolvedValue(11); + prisma.smsBatchTask.count.mockResolvedValue(10); prisma.riskRule.findMany.mockResolvedValue([ { id: 'rule-night', @@ -292,15 +313,54 @@ describe('RiskReviewService', () => { content: 'promo', phones: ['13800000001', '13800000002', '13800000003'], requestedAt: '2026-07-01T22:00:00+08:00', + applicationId: 'app-1', + sourceType: 'client', }); expect(result.status).toBe('pending_review'); expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({ data: expect.arrayContaining([ expect.objectContaining({ ruleCode: 'NON_WORKING_MARKETING_BULK', actualValue: 3 }), - expect.objectContaining({ ruleCode: 'TASK_CREATE_FREQUENCY', actualValue: 11 }), + expect.objectContaining({ ruleCode: 'TASK_CREATE_FREQUENCY', actualValue: 10 }), ]), }); + expect(prisma.smsBatchTask.count).toHaveBeenCalledWith({ + where: { + applicationId: 'app-1', + sourceType: 'client', + createdAt: { gte: expect.any(Date) }, + }, + }); + }); + + it('does not include CMPP or HTTP tasks in client task frequency control', async () => { + const prisma = createPrismaMock(); + prisma.riskRule.findMany.mockResolvedValue([{ + id: 'rule-frequency', + code: 'TASK_CREATE_FREQUENCY', + name: '短时间任务创建频控', + metric: 'recentTaskCount', + thresholdValue: 1, + action: 'manual_review', + priority: 30, + }]); + const service = new RiskReviewService(prisma as never); + + await expect(service.evaluateTask({ + tenantId: 'tenant-1', + applicationId: 'app-1', + content: 'hello', + phones: ['10000000000'], + sourceType: 'cmpp', + })).resolves.toEqual(expect.objectContaining({ status: 'approved', task: null })); + await expect(service.evaluateTask({ + tenantId: 'tenant-1', + applicationId: 'app-1', + content: 'hello', + phones: ['10000000000'], + sourceType: 'api', + })).resolves.toEqual(expect.objectContaining({ status: 'approved', task: null })); + expect(prisma.smsBatchTask.count).not.toHaveBeenCalled(); }); it('rejects sensitive words and illegal control characters before sending', async () => { diff --git a/api/src/risk-review/risk-review.service.ts b/api/src/risk-review/risk-review.service.ts index 8f4f175..06cae97 100644 --- a/api/src/risk-review/risk-review.service.ts +++ b/api/src/risk-review/risk-review.service.ts @@ -5,10 +5,11 @@ import { PrismaService } from '../prisma/prisma.service'; export interface CreateRiskRuleDto { tenantId?: string; + applicationId?: string; code: string; - name: string; + name?: string; description?: string; - metric: string; + metric?: string; thresholdValue: number; action?: string; status?: string; @@ -26,6 +27,7 @@ export interface EvaluateSmsTaskDto { variables?: Record; createdById?: string; requestedAt?: string; + sourceType?: 'client' | 'api' | 'cmpp'; } export interface ReviewSmsTaskDto { @@ -66,33 +68,6 @@ const DEFAULT_RULES: CreateRiskRuleDto[] = [ action: 'block', priority: 10, }, - { - code: 'DUPLICATE_PHONE_RATIO', - name: '重复号码比例', - description: '重复号码比例过高时进入人工审核。', - metric: 'duplicateRatio', - thresholdValue: 0.2, - action: 'manual_review', - priority: 20, - }, - { - code: 'ILLEGAL_PHONE_RATIO', - name: '非法号码比例', - description: '非法手机号比例超过阈值时直接拒绝。', - metric: 'illegalRatio', - thresholdValue: 0.05, - action: 'block', - priority: 30, - }, - { - code: 'BLACKLIST_HIT_RATIO', - name: '黑名单命中比例', - description: '命中平台或企业黑名单比例过高时进入人工审核。', - metric: 'blacklistHitRatio', - thresholdValue: 0.01, - action: 'manual_review', - priority: 40, - }, { code: 'NON_WORKING_MARKETING_BULK', name: '非工作时间大批量营销发送', @@ -100,7 +75,8 @@ const DEFAULT_RULES: CreateRiskRuleDto[] = [ metric: 'nonWorkingMarketingPhones', thresholdValue: 5000, action: 'manual_review', - priority: 50, + priority: 20, + config: { startTime: '21:00', endTime: '08:00', timeZone: 'Asia/Shanghai' }, }, { code: 'TASK_CREATE_FREQUENCY', @@ -109,44 +85,86 @@ const DEFAULT_RULES: CreateRiskRuleDto[] = [ metric: 'recentTaskCount', thresholdValue: 10, action: 'manual_review', - priority: 60, - }, - { - code: 'TEMPLATE_VARIABLE_ANOMALY', - name: '模板变量异常', - description: '模板变量缺失或多传时直接拒绝。', - metric: 'variableIssueCount', - thresholdValue: 0, - action: 'block', - priority: 70, + priority: 30, }, ]; +const RULE_DEFINITIONS = new Map(DEFAULT_RULES.map((rule) => [rule.code, rule])); + @Injectable() export class RiskReviewService { constructor(private readonly prisma: PrismaService) {} - async listRules(tenantId?: string) { + async listRules(applicationId?: string) { await this.ensureDefaultRules(); return this.prisma.riskRule.findMany({ - where: tenantId ? { OR: [{ tenantId: null }, { tenantId }] } : undefined, + where: { + code: { in: [...RULE_DEFINITIONS.keys()] }, + status: { not: 'deleted' }, + ...(applicationId ? { OR: [{ applicationId: null }, { applicationId }] } : {}), + }, + include: { + application: { select: { id: true, name: true, tenantId: true, tenant: { select: { id: true, name: true } } } }, + }, orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }], }); } - createRule(data: CreateRiskRuleDto) { + async createRule(data: CreateRiskRuleDto) { + const definition = this.validateRuleInput(data); + const scope = await this.resolveRuleScope(data.applicationId); + const existing = await this.prisma.riskRule.findFirst({ + where: { applicationId: data.applicationId ?? null, code: data.code, status: { not: 'deleted' } }, + select: { id: true }, + }); + if (existing) { + throw new BadRequestException('该适用范围已存在同名风控规则'); + } return this.prisma.riskRule.create({ data: { - tenantId: data.tenantId, + tenantId: scope.tenantId, + applicationId: data.applicationId, code: data.code, - name: data.name, - description: data.description, - metric: data.metric, + name: definition.name!, + description: definition.description, + metric: definition.metric!, thresholdValue: data.thresholdValue, action: data.action ?? 'manual_review', status: data.status ?? 'active', - priority: data.priority ?? 100, - config: data.config as Prisma.InputJsonValue | undefined, + priority: data.priority ?? definition.priority ?? 100, + config: this.normalizeRuleConfig(data.code, data.config ?? definition.config) as Prisma.InputJsonValue | undefined, + }, + include: { + application: { select: { id: true, name: true, tenantId: true, tenant: { select: { id: true, name: true } } } }, + }, + }); + } + + async updateRule(ruleId: string, data: Partial) { + const rule = await this.prisma.riskRule.findUnique({ where: { id: ruleId } }); + if (!rule) { + throw new NotFoundException('Risk rule not found'); + } + this.validateRuleInput({ + code: rule.code, + thresholdValue: data.thresholdValue ?? rule.thresholdValue, + action: data.action ?? rule.action, + status: data.status ?? rule.status, + config: data.config ?? jsonObject(rule.config), + }); + return this.prisma.riskRule.update({ + where: { id: ruleId }, + data: { + thresholdValue: data.thresholdValue, + action: data.action, + status: data.status, + priority: data.priority, + config: data.config === undefined + ? undefined + : this.normalizeRuleConfig(rule.code, data.config) as Prisma.InputJsonValue, + }, + include: { + application: { select: { id: true, name: true, tenantId: true, tenant: { select: { id: true, name: true } } } }, }, }); } @@ -166,6 +184,13 @@ export class RiskReviewService { where: { tenantId, status, + ...(status === 'approved' || status === 'rejected' ? { reviewedById: { not: null } } : {}), + ...(!status ? { + OR: [ + { status: 'pending_review' }, + { reviewedById: { not: null } }, + ], + } : {}), ...(status === 'pending_review' ? { OR: [ { sourceType: { not: 'cmpp_template_mismatch' } }, @@ -186,6 +211,39 @@ export class RiskReviewService { return this.listTasks(undefined, 'pending_review'); } + async listTaskMessages(taskId: string, phone?: string, page = 1, pageSize = 20) { + const task = await this.prisma.smsSendTask.findUnique({ where: { id: taskId }, select: { id: true } }); + if (!task) { + throw new NotFoundException('SMS send task not found'); + } + const normalizedPage = Math.max(1, Math.floor(page || 1)); + const normalizedPageSize = Math.min(100, Math.max(1, Math.floor(pageSize || 20))); + const where: Prisma.SmsMessageRecordWhereInput = { + OR: [ + { reviewTaskId: taskId }, + { batchTask: { riskTaskId: taskId } }, + ], + ...(phone?.trim() ? { phoneNumber: { contains: phone.trim() } } : {}), + }; + const [items, total] = await Promise.all([ + this.prisma.smsMessageRecord.findMany({ + where, + select: { + id: true, + phoneNumber: true, + province: true, + carrier: true, + status: true, + }, + orderBy: [{ queuedAt: 'asc' }, { id: 'asc' }], + skip: (normalizedPage - 1) * normalizedPageSize, + take: normalizedPageSize, + }), + this.prisma.smsMessageRecord.count({ where }), + ]); + return { items, total, page: normalizedPage, pageSize: normalizedPageSize }; + } + async aggregateTemplateMismatch(data: AggregateTemplateMismatchDto) { const normalizedContent = data.content.replace(/\r\n/g, '\n').trim(); const contentHash = createHash('sha256').update(normalizedContent, 'utf8').digest('hex'); @@ -254,37 +312,52 @@ export class RiskReviewService { const phoneTotal = phones.length; const uniquePhoneTotal = uniquePhones.length; const duplicateRatio = ratio(phoneTotal - uniquePhoneTotal, phoneTotal); - const illegalCount = phones.filter((phone) => !isMainlandMobile(phone)).length; + const illegalCount = phones.filter((phone) => !isBasicMobileNumber(phone)).length; const illegalRatio = ratio(illegalCount, phoneTotal); - const blacklistHitCount = await this.countBlacklistHits(data.tenantId, data.applicationId, uniquePhones); - const blacklistHitRatio = ratio(blacklistHitCount, phoneTotal); - const [application, template, rules, recentTaskCount, sensitiveWords] = await Promise.all([ - data.applicationId ? this.prisma.smsApplication.findUnique({ where: { id: data.applicationId } }) : null, + const [template, rules, recentTaskCount, sensitiveWords] = await Promise.all([ data.templateId ? this.prisma.smsTemplate.findUnique({ where: { id: data.templateId }, include: { variables: true } }) : null, - this.effectiveRules(data.tenantId), - this.countRecentTasks(data.tenantId), + this.effectiveRules(data.applicationId), + this.countRecentClientTasks(data.applicationId, data.sourceType), this.prisma.sensitiveWord.findMany({ where: { status: 'active' }, select: { word: true, level: true } }), ]); const variableIssues = evaluateTemplateVariables(template?.variables ?? [], data.content, data.variables ?? {}); const contentIssues = evaluateContent(data.content, sensitiveWords); + if (variableIssues.length > 0) { + contentIssues.push({ + ruleCode: 'TEMPLATE_VARIABLE_INVALID', + ruleName: '模板变量校验失败', + thresholdValue: 0, + actualValue: variableIssues.length, + action: 'block', + reason: formatTemplateVariableIssueReason(variableIssues), + }); + } const requestedAt = data.requestedAt ? new Date(data.requestedAt) : new Date(); + const nonWorkingRule = rules.find((rule) => rule.code === 'NON_WORKING_MARKETING_BULK'); const nonWorkingMarketingPhones = - isMarketing(data.category ?? template?.category) && isNonWorkingTime(requestedAt) ? phoneTotal : 0; + isMarketing(data.category ?? template?.category) + && isNonWorkingTime(requestedAt, readNonWorkingConfig(nonWorkingRule?.config)) + ? phoneTotal + : 0; const hits = this.evaluateRules(rules, { phoneTotal, - applicationMaxPhones: application?.maxPhonesPerTask, - duplicateRatio, - illegalRatio, - blacklistHitRatio, nonWorkingMarketingPhones, recentTaskCount, - variableIssueCount: variableIssues.length, }); hits.push(...contentIssues.map(contentIssueToHit)); const decision = decideRiskAction(hits); const reason = hits.length > 0 ? hits.map((hit) => hit.reason).join('; ') : null; + if (decision.status === 'approved') { + return { + canSubmit: true, + status: decision.status, + riskDecision: decision.riskDecision, + reason, + task: null, + }; + } const task = await this.prisma.smsSendTask.create({ data: { tenantId: data.tenantId, @@ -297,7 +370,7 @@ export class RiskReviewService { uniquePhoneTotal, duplicateRatio, illegalRatio, - blacklistHitRatio, + blacklistHitRatio: 0, variableIssues: { variables: variableIssues, content: contentIssues } as unknown as Prisma.InputJsonValue, status: decision.status, riskDecision: decision.riskDecision, @@ -396,7 +469,7 @@ export class RiskReviewService { private async ensureDefaultRules() { for (const rule of DEFAULT_RULES) { const exists = await this.prisma.riskRule.findFirst({ - where: { tenantId: null, code: rule.code }, + where: { applicationId: null, code: rule.code, status: { not: 'deleted' } }, select: { id: true }, }); if (!exists) { @@ -411,43 +484,32 @@ export class RiskReviewService { } } - private async effectiveRules(tenantId: string) { + private async effectiveRules(applicationId?: string) { const rules = await this.prisma.riskRule.findMany({ where: { status: 'active', - OR: [{ tenantId: null }, { tenantId }], + OR: [{ applicationId: null }, ...(applicationId ? [{ applicationId }] : [])], }, orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }], }); const byCode = new Map(); for (const rule of rules) { - byCode.set(rule.code, rule); + if (rule.applicationId || !byCode.has(rule.code)) { + byCode.set(rule.code, rule); + } } return [...byCode.values()].sort((a, b) => a.priority - b.priority); } - private async countBlacklistHits(tenantId: string, applicationId: string | undefined, phones: string[]) { - if (phones.length === 0) { + private countRecentClientTasks(applicationId?: string, sourceType?: string) { + if (!applicationId || sourceType !== 'client') { return 0; } - const [globalHits, enterpriseHits] = await Promise.all([ - this.prisma.globalBlacklist.findMany({ - where: { phoneNumber: { in: phones }, status: 'active' }, - select: { phoneNumber: true }, - }), - applicationId ? this.prisma.enterpriseBlacklist.findMany({ - where: { tenantId, applicationId, phoneNumber: { in: phones }, status: 'active' }, - select: { phoneNumber: true }, - }) : Promise.resolve([]), - ]); - return new Set([...globalHits, ...enterpriseHits].map((hit) => hit.phoneNumber)).size; - } - - private countRecentTasks(tenantId: string) { const since = new Date(Date.now() - 10 * 60 * 1000); - return this.prisma.smsSendTask.count({ + return this.prisma.smsBatchTask.count({ where: { - tenantId, + applicationId, + sourceType: 'client', createdAt: { gte: since }, }, }); @@ -457,23 +519,17 @@ export class RiskReviewService { rules: Awaited>, metrics: { phoneTotal: number; - applicationMaxPhones?: number | null; - duplicateRatio: number; - illegalRatio: number; - blacklistHitRatio: number; nonWorkingMarketingPhones: number; recentTaskCount: number; - variableIssueCount: number; }, ): RuleEvaluation[] { const hits: RuleEvaluation[] = []; for (const rule of rules) { - const threshold = - rule.code === 'MAX_PHONES_PER_TASK' && metrics.applicationMaxPhones - ? Math.min(rule.thresholdValue, metrics.applicationMaxPhones) - : rule.thresholdValue; + const threshold = rule.thresholdValue; const actualValue = metricValue(rule.metric, metrics); - const shouldHit = rule.code === 'TEMPLATE_VARIABLE_ANOMALY' ? actualValue > threshold : actualValue > threshold; + const shouldHit = rule.code === 'TASK_CREATE_FREQUENCY' + ? actualValue >= threshold + : actualValue > threshold; if (!shouldHit) { continue; } @@ -489,6 +545,56 @@ export class RiskReviewService { } return hits; } + + private validateRuleInput(data: Pick) { + const definition = RULE_DEFINITIONS.get(data.code); + if (!definition) { + throw new BadRequestException('不支持的风控规则编码'); + } + if (!Number.isFinite(data.thresholdValue) || data.thresholdValue < 0) { + throw new BadRequestException('风控阈值必须是大于等于0的有效数字'); + } + if (data.action && !['block', 'manual_review'].includes(data.action)) { + throw new BadRequestException('风控处理动作无效'); + } + if (data.status && !['active', 'inactive'].includes(data.status)) { + throw new BadRequestException('风控规则状态无效'); + } + this.normalizeRuleConfig(data.code, data.config); + return definition; + } + + private async resolveRuleScope(applicationId?: string) { + if (!applicationId) { + return { tenantId: undefined }; + } + const application = await this.prisma.smsApplication.findUnique({ + where: { id: applicationId }, + select: { tenantId: true }, + }); + if (!application) { + throw new BadRequestException('企业应用不存在'); + } + return { tenantId: application.tenantId }; + } + + private normalizeRuleConfig(code: string, config?: Record | null) { + if (code !== 'NON_WORKING_MARKETING_BULK') { + return config ?? undefined; + } + const startTime = String(config?.startTime ?? '21:00'); + const endTime = String(config?.endTime ?? '08:00'); + const timeZone = String(config?.timeZone ?? 'Asia/Shanghai'); + if (!isClockTime(startTime) || !isClockTime(endTime) || startTime === endTime) { + throw new BadRequestException('非工作时间必须是两个不同的 HH:mm 时间'); + } + try { + new Intl.DateTimeFormat('zh-CN', { timeZone }).format(new Date()); + } catch { + throw new BadRequestException('非工作时间时区无效'); + } + return { startTime, endTime, timeZone }; + } } function ratio(count: number, total: number) { @@ -498,17 +604,55 @@ function ratio(count: number, total: number) { return Number((count / total).toFixed(4)); } -function isMainlandMobile(phone: string) { - return /^1[3-9]\d{9}$/.test(phone); +function isBasicMobileNumber(phone: string) { + return /^1\d{10}$/.test(phone); } function isMarketing(category?: string | null) { return ['marketing', 'promo', 'promotion', '营销'].includes((category ?? '').toLowerCase()); } -function isNonWorkingTime(date: Date) { - const hour = date.getHours(); - return hour < 8 || hour >= 21; +function readNonWorkingConfig(config: Prisma.JsonValue | null | undefined) { + const value = config && typeof config === 'object' && !Array.isArray(config) + ? config as Record + : {}; + return { + startTime: typeof value.startTime === 'string' ? value.startTime : '21:00', + endTime: typeof value.endTime === 'string' ? value.endTime : '08:00', + timeZone: typeof value.timeZone === 'string' ? value.timeZone : 'Asia/Shanghai', + }; +} + +function jsonObject(config: Prisma.JsonValue | null | undefined) { + return config && typeof config === 'object' && !Array.isArray(config) + ? config as Record + : undefined; +} + +function isNonWorkingTime(date: Date, config: { startTime: string; endTime: string; timeZone: string }) { + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: config.timeZone, + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }).formatToParts(date); + const hour = Number(parts.find((part) => part.type === 'hour')?.value ?? 0); + const minute = Number(parts.find((part) => part.type === 'minute')?.value ?? 0); + const current = hour * 60 + minute; + const start = clockMinutes(config.startTime); + const end = clockMinutes(config.endTime); + return start < end + ? current >= start && current < end + : current >= start || current < end; +} + +function isClockTime(value: string) { + return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value); +} + +function clockMinutes(value: string) { + const [hour, minute] = value.split(':').map(Number); + return hour * 60 + minute; } function evaluateTemplateVariables( @@ -529,6 +673,16 @@ function evaluateTemplateVariables( ]; } +function formatTemplateVariableIssueReason(issues: Array<{ type: string; name: string }>) { + const missing = issues.filter((item) => item.type === 'missing_required_variable').map((item) => item.name); + const extra = issues.filter((item) => item.type === 'unexpected_variable').map((item) => item.name); + const details = [ + missing.length > 0 ? `缺少必填变量:${missing.join('、')}` : '', + extra.length > 0 ? `包含模板未定义变量:${extra.join('、')}` : '', + ].filter(Boolean).join(';'); + return `模板变量校验失败(${details}),本次提交拒绝`; +} + function evaluateContent(content: string, sensitiveWords: Array<{ word: string; level: string }>) { const issues: RuleEvaluation[] = []; const controlMatches = [...content].filter((char) => /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(char)); diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index a81fff4..8c6b0b7 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -402,6 +402,73 @@ describe('SendChainService', () => { expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1'); }); + it('marks invalid and blacklisted client numbers as submit failures while sending valid numbers', async () => { + const { service, prisma, billing } = createService(); + service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); + prisma.globalBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000002', reason: '平台拒收' }]); + (billing.estimateSmsCost as jest.Mock).mockReturnValue({ + billingUnitsPerMessage: 1, + totalBillingUnits: 1, + unitPrice: 3, + amountCents: 3, + }); + + await service.createBatchTask({ + tenantId: 'tenant-1', + applicationId: 'app-1', + templateId: 'tpl-1', + content: 'hello', + phones: ['13800000001', '23800000002', '13800000002'], + }); + + expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({ + data: expect.arrayContaining([ + expect.objectContaining({ phoneNumber: '13800000001', status: 'queued', amountCents: 3 }), + expect.objectContaining({ + phoneNumber: '23800000002', + status: 'submit_failed', + submitStatus: 'rejected', + errorCode: 'INVALID_PHONE', + amountCents: 0, + }), + expect.objectContaining({ + phoneNumber: '13800000002', + status: 'submit_failed', + submitStatus: 'rejected', + errorCode: 'GLOBAL_BLACKLIST', + amountCents: 0, + }), + ]), + }); + expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3 })); + expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1'); + }); + + it('persists the review task id on every message waiting for manual review', async () => { + const { service, prisma, riskReview } = createService(); + (riskReview.evaluateTask as jest.Mock).mockResolvedValue({ + status: 'pending_review', + reason: '命中人工审核规则', + task: { id: 'review-task-1' }, + }); + + await service.createBatchTask({ + tenantId: 'tenant-1', + applicationId: 'app-1', + templateId: 'tpl-1', + content: 'hello', + phones: ['13800000001'], + }); + + expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ + phoneNumber: '13800000001', + status: 'pending_review', + reviewTaskId: 'review-task-1', + })], + }); + }); + it('rejects the whole batch atomically when the application daily send limit would be exceeded', async () => { const { service, prisma, billing } = createService(); prisma.$queryRaw.mockResolvedValueOnce([{ dailyLimit: 1, usedCount: null }]); @@ -807,7 +874,7 @@ describe('SendChainService', () => { })).rejects.toThrow('CMPP interface is disabled for this application'); }); - it('does not create a CMPP downstream delivery when the application interface was disabled after bind', async () => { + it('rejects new submissions synchronously when the application interface was disabled after bind', async () => { const { service, prisma } = createService(); prisma.smsApplication.findFirst.mockResolvedValue({ id: 'app-1', @@ -835,12 +902,10 @@ describe('SendChainService', () => { phoneNumber: '13800000001', content: 'hello', remoteIp: '127.0.0.1', - })).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' })); + })).rejects.toThrow('CMPP account is disabled for new submissions'); - expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'validating' }) }); - expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ messageRecordId: 'record-1', receiptStatus: 'undelivered', errorCode: 'INTERFACE' }), - }); + expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled(); + expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled(); }); @@ -852,6 +917,7 @@ describe('SendChainService', () => { id: 'app-1', tenantId: 'tenant-1', cmppAccount: '100001', + status: 'active', interfaceEnabled: false, downstreamReceiptRetryEnabled: true, downstreamUplinkRetryEnabled: true, @@ -882,7 +948,7 @@ describe('SendChainService', () => { cmppAccount: '100001', secretHash: 'secret-hash', status: 'active', - interfaceEnabled: false, + interfaceEnabled: true, queuePriority: 'normal', ipAllowlist: [{ ipCidr: '127.0.0.1/32' }], tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, @@ -919,8 +985,8 @@ describe('SendChainService', () => { expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(2); expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ phoneNumber: '13800000001', cmppSubmitSequenceId: '777823876', cmppSubmitGroupMessageId: result.messageId }) }); expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ phoneNumber: '13900000002', cmppSubmitSequenceId: '777823876', cmppSubmitGroupMessageId: result.messageId }) }); - expect(prisma.smsReceiptRecord.create).toHaveBeenCalledTimes(2); - expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2); + expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled(); + expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled(); }); it('persists inbound CMPP long-message fragments and creates one complete main record after reassembly', async () => { @@ -1292,19 +1358,37 @@ describe('SendChainService', () => { expect(billing.freeze).not.toHaveBeenCalled(); }); - it('rejects a multi-destination CMPP Submit before persistence when any destination is invalid', async () => { + it('returns a failure receipt for an invalid destination while other CMPP destinations continue', async () => { const { service, prisma } = createService(); + let messageIndex = 0; + prisma.smsMessageRecord.create.mockImplementation(({ data }) => Promise.resolve({ + id: `record-${++messageIndex}`, + ...data, + })); await expect(service.submitInboundMessage({ account: '100001', phoneNumbers: ['13800000001', 'invalid'], content: 'hello', remoteIp: '127.0.0.1', - })).rejects.toThrow('CMPP submit phone number is invalid'); + })).resolves.toEqual(expect.objectContaining({ accepted: true, phoneCount: 2 })); - expect(prisma.smsApplication.findFirst).not.toHaveBeenCalled(); - expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); - expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled(); + expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(2); + expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({ + where: { id: 'record-2' }, + data: expect.objectContaining({ + status: 'failed', + receiptStatus: 'undelivered', + errorCode: 'INVALID_PHONE', + }), + }); + expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + messageRecordId: 'record-2', + receiptStatus: 'undelivered', + errorCode: 'INVALID_PHONE', + }), + }); }); it('accepts only the filled client Src_Id and snapshots the real application extension', async () => { @@ -1571,7 +1655,8 @@ describe('SendChainService', () => { service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); prisma.smsSendTask.findUnique.mockResolvedValue({ id: 'review-task-1', - messageRecords: [{ + }); + prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', tenantId: 'tenant-1', applicationId: 'app-1', @@ -1581,8 +1666,7 @@ describe('SendChainService', () => { amountCents: 3, billingUnits: 1, batchTask: { id: 'task-1', sourceType: 'cmpp' }, - }], - }); + }]); await expect(service.handleReviewDecision('review-task-1', 'approved', '审核通过')).resolves.toEqual({ reviewTaskId: 'review-task-1', @@ -2697,6 +2781,52 @@ describe('SendChainService', () => { })); }); + it('allows a disabling application to reconnect for receipt draining but rejects new submissions', async () => { + const { service, prisma } = createService(); + prisma.smsApplication.findFirst.mockResolvedValue({ + id: 'app-1', + tenantId: 'tenant-1', + cmppAccount: '100001', + cmppEnterpriseCode: 'SP0001', + secretHash: 'secret-hash', + status: 'disabling', + interfaceEnabled: true, + cmppMaxConnections: 2, + queuePriority: 'normal', + ipAllowlist: [{ ipCidr: '127.0.0.1/32' }], + tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, + }); + + await expect(service.authenticateInboundApplication({ + account: '100001', + password: 'secret-hash', + remoteIp: '127.0.0.1', + })).resolves.toEqual(expect.objectContaining({ status: 'authenticated' })); + await expect(service.submitInboundMessage({ + account: '100001', + phoneNumber: '13800000001', + content: 'hello', + remoteIp: '127.0.0.1', + })).rejects.toThrow('disabled for new submissions'); + expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled(); + }); + + it('lets Gateway read historical pending receipts after an application or enterprise is disabled', async () => { + const { service, prisma } = createService(); + prisma.smsApplication.findFirst.mockResolvedValue({ + id: 'app-1', + tenantId: 'tenant-1', + cmppAccount: '100001', + status: 'deleted', + interfaceEnabled: true, + tenant: { id: 'tenant-1', status: 'deleted', certificationStatus: 'approved' }, + }); + prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([]); + + await expect(service.listPendingDownstreamDeliveries({ account: '100001', limit: 100 })) + .resolves.toEqual([]); + }); + it('persists each upstream SubmitResp segment before the aggregate result arrives', async () => { const { service, prisma } = createService(); diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index 7ed2692..9805ce3 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -402,6 +402,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { const phones = [...new Set(data.phones ?? [])]; const schedule = parseSchedule(data); await this.validateSendResources(data.tenantId, data.applicationId, data.templateId); + const phoneRejections = await this.classifyRejectedPhones(data.tenantId, data.applicationId, phones); + const sendablePhones = phones.filter((phone) => !phoneRejections.has(phone)); const [messageClassification, unitPrice, queuePriority, accessNumber] = await Promise.all([ this.resolveTemplateMessageClassification(data.tenantId, data.applicationId, data.templateId, data.content), this.resolveUnitPrice(data.tenantId, data.applicationId), @@ -419,16 +421,19 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { phones, variables: messageClassification.variables ?? data.variables, createdById: data.createdById, + sourceType: data.sourceType ?? 'client', }); const billing = this.billing.estimateSmsCost({ tenantId: data.tenantId, applicationId: data.applicationId, taskId: risk.task?.id, content: data.content, - phoneCount: phones.length, + phoneCount: sendablePhones.length, unitPrice, }); - const batchStatus = statusFromRisk(risk.status, Boolean(schedule.scheduledAt)); + const batchStatus = risk.status === 'approved' && sendablePhones.length === 0 + ? 'failed' + : statusFromRisk(risk.status, Boolean(schedule.scheduledAt)); const shouldReserveBalance = batchStatus === 'ready'; if (risk.status === 'approved') { const accountCheck = await this.billing.checkAccount({ @@ -439,8 +444,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { throw new BadRequestException('企业账户余额不足'); } } - if (data.applicationId && risk.status !== 'rejected') { - await this.reserveDailySendQuota(data.applicationId, phones.length); + if (data.applicationId && risk.status !== 'rejected' && sendablePhones.length > 0) { + await this.reserveDailySendQuota(data.applicationId, sendablePhones.length); } const task = await this.prisma.smsBatchTask.create({ data: { @@ -485,35 +490,50 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { sendMode: schedule.scheduledAt ? 'scheduled' : 'immediate', scheduledAt: schedule.scheduledAt?.toISOString(), }, - status: batchStatus === 'rejected' ? 'rejected' : 'accepted', + status: ['rejected', 'failed'].includes(batchStatus) ? 'rejected' : 'accepted', }, }); if (phones.length > 0) { await this.prisma.smsMessageRecord.createMany({ - data: phones.map((phone) => ({ + data: phones.map((phone) => { + const rejection = phoneRejections.get(phone); + const status = rejection + ? 'submit_failed' + : batchStatus === 'ready' + ? 'queued' + : batchStatus === 'scheduled' + ? 'scheduled' + : batchStatus; + return { tenantId: data.tenantId, batchTaskId: task.id, applicationId: data.applicationId, templateId: data.templateId, signatureId: messageClassification.signatureId, drainageInfoId: messageClassification.drainageInfoId, + reviewTaskId: !rejection && risk.status === 'pending_review' ? risk.task?.id : undefined, messageId: `MSG-${randomUUID()}`, clientMessageId: data.clientMessageId, phoneNumber: phone, content: data.content, billingUnits: billing.billingUnitsPerMessage, - unitPrice: billing.unitPrice, - amountCents: billing.billingUnitsPerMessage * billing.unitPrice, + unitPrice: rejection ? 0 : billing.unitPrice, + amountCents: rejection ? 0 : billing.billingUnitsPerMessage * billing.unitPrice, queuePriority, clientSrcId: accessNumber.clientSrcId, applicationExtension: accessNumber.applicationExtension, - status: batchStatus === 'ready' ? 'queued' : batchStatus === 'scheduled' ? 'scheduled' : batchStatus, - errorMessage: risk.status === 'rejected' ? risk.reason ?? undefined : undefined, - })), + status, + submitStatus: rejection ? 'rejected' : undefined, + errorCode: rejection?.code, + errorMessage: rejection?.reason ?? (risk.status === 'rejected' ? risk.reason ?? undefined : undefined), + }; + }), }); } - if (batchStatus === 'ready') { + if (batchStatus === 'ready' && sendablePhones.length > 0) { await this.enqueueBatchTask(task.id); + } else if (batchStatus === 'failed') { + await this.refreshTaskProgress(task.id); } return this.getBatchTask(task.id, undefined, data.sourceType ?? 'client'); } @@ -743,18 +763,25 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { async handleReviewDecision(reviewTaskId: string, decision: 'approved' | 'rejected', reason: string) { const reviewTask = await this.prisma.smsSendTask.findUnique({ where: { id: reviewTaskId }, - include: { - messageRecords: { - where: { status: 'pending_review' }, - include: { batchTask: true }, - }, - }, }); - if (!reviewTask || reviewTask.messageRecords.length === 0) { + if (!reviewTask) { + return { reviewTaskId, decision, affected: 0 }; + } + const messageRecords = await this.prisma.smsMessageRecord.findMany({ + where: { + status: 'pending_review', + OR: [ + { reviewTaskId }, + { batchTask: { riskTaskId: reviewTaskId } }, + ], + }, + include: { batchTask: true }, + }); + if (messageRecords.length === 0) { return { reviewTaskId, decision, affected: 0 }; } const batchTaskIds = new Set(); - for (const message of reviewTask.messageRecords) { + for (const message of messageRecords) { if (!message.tenantId || !message.applicationId || !message.batchTaskId) continue; if (decision === 'approved') { await this.prisma.smsMessageRecord.update({ @@ -781,7 +808,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { for (const batchTaskId of batchTaskIds) { await this.enqueueBatchTask(batchTaskId); } - return { reviewTaskId, decision, affected: reviewTask.messageRecords.length }; + return { reviewTaskId, decision, affected: messageRecords.length }; } async terminateBatchTask(taskId: string) { @@ -1440,8 +1467,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { async listPendingDownstreamDeliveries(data: GatewayPendingDeliveryQueryDto) { const application = await this.findInboundApplication(data.account); - if (!application || application.status !== 'active' || application.tenant.status !== 'active') { - throw new BadRequestException('CMPP account is invalid or disabled'); + if (!application) { + throw new BadRequestException('CMPP account is invalid'); } const expiredAcknowledgements = await this.prisma.cmppDownstreamDelivery.findMany({ where: { applicationId: application.id, status: 'awaiting_ack', ackDeadlineAt: { lte: new Date() } }, @@ -2244,23 +2271,27 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { select: { cmppAccount: true, interfaceEnabled: true, + status: true, downstreamReceiptRetryEnabled: true, downstreamUplinkRetryEnabled: true, httpConfig: true, }, }); - try { - await this.openApi?.queueWebhookEvent({ - tenantId: data.tenantId, - applicationId: data.applicationId, - messageRecordId: data.messageRecordId, - messageId: data.messageId, - uplinkMessageId: typeof data.payload.uplinkMessageId === 'string' ? data.payload.uplinkMessageId : undefined, - eventType: data.deliveryType, - payload: data.payload, - }); - } catch (error) { - this.logger.error(`HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(error)}`); + const deliveryAllowed = application?.status === 'active' || application?.status === 'disabling'; + if (deliveryAllowed) { + try { + await this.openApi?.queueWebhookEvent({ + tenantId: data.tenantId, + applicationId: data.applicationId, + messageRecordId: data.messageRecordId, + messageId: data.messageId, + uplinkMessageId: typeof data.payload.uplinkMessageId === 'string' ? data.payload.uplinkMessageId : undefined, + eventType: data.deliveryType, + payload: data.payload, + }); + } catch (error) { + this.logger.error(`HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(error)}`); + } } if (application?.interfaceEnabled !== true) { return null; @@ -2274,12 +2305,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { messageId: data.messageId, deliveryType: data.deliveryType, payload, - retryEnabled: data.deliveryType === 'uplink' + retryEnabled: deliveryAllowed && (data.deliveryType === 'uplink' ? application?.downstreamUplinkRetryEnabled ?? true - : application?.downstreamReceiptRetryEnabled ?? true, - status: 'pending', + : application?.downstreamReceiptRetryEnabled ?? true), + status: deliveryAllowed ? 'pending' : 'abandoned', + lastError: deliveryAllowed ? null : '企业应用已停用,保留回执但不再向客户应用推送', }, }); + if (!deliveryAllowed) { + return delivery; + } try { const result = await this.postGatewayControl( data.deliveryType === 'receipt' ? '/downstream/receipt' : '/downstream/uplink', @@ -2413,7 +2448,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { async authenticateInboundApplication(data: GatewayInboundAuthDto) { const application = await this.findInboundApplication(data.account); - if (!application || application.status !== 'active' || application.tenant.status !== 'active') { + if (!application || !['active', 'disabling'].includes(application.status) || application.tenant.status !== 'active') { throw new BadRequestException('CMPP account is invalid or disabled'); } if (!application.interfaceEnabled) { @@ -2445,7 +2480,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { : data.phoneNumber ? [data.phoneNumber.trim()] : []; - if (phoneNumbers.length === 0 || phoneNumbers.some((phoneNumber) => !/^1[3-9]\d{9}$/.test(phoneNumber))) { + if (phoneNumbers.length === 0) { throw new BadRequestException('CMPP submit phone number is invalid'); } @@ -2453,6 +2488,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { if (!application) { throw new BadRequestException('CMPP account is invalid'); } + if (application.status !== 'active' || application.tenant.status !== 'active' || !application.interfaceEnabled) { + throw new BadRequestException('CMPP account is disabled for new submissions'); + } if (data.longMessage) { if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) { throw new BadRequestException('CMPP source IP is not in application allowlist'); @@ -2586,7 +2624,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { }) : []; const persistedByPhone = new Map(persisted.map((item) => [item.phoneNumber, item])); - const missingPhoneCount = phoneNumbers.filter((phoneNumber) => !persistedByPhone.has(phoneNumber)).length; + const phoneRejections = await this.classifyRejectedPhones(application.tenantId, application.id, phoneNumbers); + const missingPhoneCount = phoneNumbers.filter((phoneNumber) => ( + !persistedByPhone.has(phoneNumber) && !phoneRejections.has(phoneNumber) + )).length; const dailyQuota = missingPhoneCount > 0 ? await this.tryReserveDailySendQuota(application.id, missingPhoneCount) : { reserved: true, dailyLimit: application.dailyLimit ?? 100000 }; @@ -2601,6 +2642,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { const submissions = phoneNumbers.map((phoneNumber, index) => ({ phoneNumber, persisted: persistedByPhone.get(phoneNumber), + receiptRejection: phoneRejections.get(phoneNumber), messageId: persistedByPhone.get(phoneNumber)?.messageId ?? (index === 0 ? submitGroupMessageId : `MSG-${randomUUID()}`), })); @@ -2622,7 +2664,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { ...data, phoneNumber: submission.phoneNumber, phoneNumbers: undefined, - }, submission.messageId, submitGroupMessageId, dailyLimitRejection)))); + }, submission.messageId, submitGroupMessageId, submission.receiptRejection ? undefined : dailyLimitRejection, submission.receiptRejection)))); } const first = results[0]; return { @@ -2811,6 +2853,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { messageId: string, submitGroupMessageId: string, synchronousRejection?: { code: string; reason: string }, + receiptRejection?: { code: string; reason: string }, ) { const application = await this.findInboundApplication(data.account); if (!application) { @@ -2819,9 +2862,6 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) { throw new BadRequestException('CMPP source IP is not in application allowlist'); } - if (!/^1[3-9]\d{9}$/.test(data.phoneNumber)) { - throw new BadRequestException('CMPP submit phone number is invalid'); - } const clientSrcId = validateInboundApplicationSrcId(data.srcId, application); const template = await this.resolveInboundTemplateCandidate(application.id, data.content); const templateVariables = template ? matchTemplateContent(template.content, data.content) ?? {} : {}; @@ -2870,8 +2910,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { phoneNumber: data.phoneNumber, content: data.content, billingUnits: billing.billingUnitsPerMessage, - unitPrice: billing.unitPrice, - amountCents: billing.amountCents, + unitPrice: receiptRejection ? 0 : billing.unitPrice, + amountCents: receiptRejection ? 0 : billing.amountCents, queuePriority, cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId), cmppSubmitGroupMessageId: submitGroupMessageId, @@ -2921,6 +2961,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { content: data.content, variables: options.templateId ? templateVariables : undefined, phones: [data.phoneNumber], + sourceType: 'cmpp', }); if (risk.status === 'rejected') { await reject('RISK', risk.reason || '短信被风控拒绝'); @@ -2929,7 +2970,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { if (risk.status === 'pending_review') { await this.prisma.smsMessageRecord.update({ where: { id: message.id }, - data: { status: 'pending_review', signatureId: options.signatureId, drainageInfoId }, + data: { + status: 'pending_review', + reviewTaskId: risk.task?.id, + signatureId: options.signatureId, + drainageInfoId, + }, }); await this.prisma.smsBatchTask.update({ where: { id: task.id }, @@ -2964,7 +3010,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { }); await this.enqueueBatchTask(task.id); }; - if (application.status !== 'active' || application.tenant.status !== 'active') { + if (receiptRejection) { + await reject(receiptRejection.code, receiptRejection.reason); + } else if (application.status !== 'active' || application.tenant.status !== 'active') { await reject('ACCOUNT', '企业或短信应用已停用'); } else if (!application.interfaceEnabled) { await reject('INTERFACE', '短信应用 CMPP 接口已停用'); @@ -2998,6 +3046,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { applicationId: application.id, content: data.content, phones: [data.phoneNumber], + sourceType: 'cmpp', }); if (risk.status === 'rejected') { await reject('RISK', risk.reason || '短信被风控拒绝'); @@ -3651,6 +3700,44 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { return receipt; } + private async classifyRejectedPhones(tenantId: string, applicationId: string | undefined, phones: string[]) { + const rejected = new Map(); + for (const phone of phones) { + if (!/^1\d{10}$/.test(phone)) { + rejected.set(phone, { code: 'INVALID_PHONE', reason: '手机号码必须是1开头的11位数字' }); + } + } + const validPhones = phones.filter((phone) => !rejected.has(phone)); + if (validPhones.length === 0) { + return rejected; + } + const [globalHits, enterpriseHits] = await Promise.all([ + this.prisma.globalBlacklist.findMany({ + where: { phoneNumber: { in: validPhones }, status: 'active' }, + select: { phoneNumber: true, reason: true }, + }), + applicationId + ? this.prisma.enterpriseBlacklist.findMany({ + where: { tenantId, applicationId, phoneNumber: { in: validPhones }, status: 'active' }, + select: { phoneNumber: true, reason: true }, + }) + : Promise.resolve([]), + ]); + for (const hit of globalHits) { + rejected.set(hit.phoneNumber, { + code: 'GLOBAL_BLACKLIST', + reason: hit.reason?.trim() || '号码命中平台黑名单', + }); + } + for (const hit of enterpriseHits) { + rejected.set(hit.phoneNumber, { + code: 'ENTERPRISE_BLACKLIST', + reason: hit.reason?.trim() || '号码命中企业应用黑名单', + }); + } + return rejected; + } + private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) { const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } }); if (!tenant || tenant.status !== 'active') { diff --git a/api/src/sms-config/admin-sms-config.controller.ts b/api/src/sms-config/admin-sms-config.controller.ts index cd62ef0..fa1d704 100644 --- a/api/src/sms-config/admin-sms-config.controller.ts +++ b/api/src/sms-config/admin-sms-config.controller.ts @@ -54,6 +54,11 @@ export class AdminSmsConfigController { return this.smsConfig.listApplicationConnections(applicationId); } + @Get('enterprise-applications/:id/deactivation-preview') + getApplicationDeactivationPreview(@Param('id') applicationId: string) { + return this.smsConfig.getApplicationDeactivationPreview(applicationId); + } + @Get('enterprise-applications/:id/cmpp-params') getApplicationCmppParams(@Param('id') applicationId: string) { return this.smsConfig.getApplicationCmppParams(applicationId); diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index 2861213..697764b 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -43,6 +43,7 @@ function createPrismaMock() { tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, }), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-new', ...data })), }, smsApplicationIpAllowlist: { @@ -147,12 +148,22 @@ function createPrismaMock() { update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'downstream-1', ...data })), delete: jest.fn().mockResolvedValue({ id: 'downstream-1' }), deleteMany: jest.fn().mockResolvedValue({ count: 0 }), + count: jest.fn().mockResolvedValue(1), }, smsMessageRecord: { groupBy: jest.fn().mockResolvedValue([ { applicationId: 'app-1', status: 'delivered', _count: { _all: 1 } }, { applicationId: 'app-1', status: 'undelivered', _count: { _all: 1 } }, ]), + count: jest.fn().mockResolvedValue(0), + }, + cmppDownstreamDelivery: { + count: jest.fn().mockResolvedValue(0), + findMany: jest.fn().mockResolvedValue([]), + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + cmppDownstreamDeliveryAttempt: { + updateMany: jest.fn().mockResolvedValue({ count: 0 }), }, smsChannel: { findFirst: jest.fn().mockResolvedValue({ @@ -236,6 +247,71 @@ describe('SmsConfigService', () => { })); }); + it('moves an application with outstanding receipts into disabling for 72 hours', async () => { + const prisma = createPrismaMock(); + prisma.smsMessageRecord.count.mockResolvedValue(1); + const service = new SmsConfigService(prisma as never); + + const result = await service.changeApplicationStatus('app-1', { + status: 'disabled', + reason: '运营端停用', + }); + + expect(result).toEqual(expect.objectContaining({ + status: 'disabling', + autoDisableAt: expect.any(Date), + deactivation: expect.objectContaining({ awaitingSupplierReceipt: 1 }), + })); + expect(prisma.smsApplication.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'app-1' }, + data: expect.objectContaining({ + status: 'disabling', + disablingAt: expect.any(Date), + autoDisableAt: expect.any(Date), + }), + })); + }); + + it('automatically disables and abandons outstanding deliveries after 72 hours', async () => { + const prisma = createPrismaMock(); + prisma.smsApplication.findMany.mockResolvedValue([{ + id: 'app-1', + tenantId: 'tenant-1', + cmppAccount: '100001', + status: 'disabling', + autoDisableAt: new Date(Date.now() - 1_000), + }]); + prisma.smsApplication.findUnique.mockResolvedValue({ + id: 'app-1', + tenantId: 'tenant-1', + cmppAccount: '100001', + status: 'disabling', + disablingAt: new Date(Date.now() - 73 * 60 * 60 * 1_000), + autoDisableAt: new Date(Date.now() - 1_000), + disableReason: '等待清算', + }); + prisma.smsMessageRecord.count.mockResolvedValue(1); + prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'delivery-1' }]); + prisma.cmppDownstreamDelivery.updateMany.mockResolvedValue({ count: 1 }); + const originalFetch = global.fetch; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: jest.fn().mockResolvedValue('{"account":"100001","disconnected":2}'), + }) as never; + const service = new SmsConfigService(prisma as never); + + await service['runApplicationDisableScan'](); + + expect(prisma.smsApplication.updateMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ id: 'app-1', status: 'disabling' }), + data: expect.objectContaining({ status: 'disabled' }), + })); + expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ status: 'abandoned', retryEnabled: false }), + })); + global.fetch = originalFetch; + }); + it('sorts enterprise applications by today send count descending with a stable name tie-breaker', async () => { const prisma = createPrismaMock(); const baseApplication = { @@ -324,7 +400,6 @@ describe('SmsConfigService', () => { interfaceType: 'cmpp20', queuePriority: 'priority', dailyLimit: 100000, - maxPhonesPerTask: 10000, downstreamReceiptRetryEnabled: true, downstreamUplinkRetryEnabled: true, ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] }, diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index 148e0cb..bc35380 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { randomInt, randomUUID } from 'node:crypto'; import { isIpAllowed } from '../common/ip-allowlist'; @@ -24,7 +24,6 @@ export interface CreateSmsApplicationDto { dailyLimit?: number; customerUnitPrice?: number; queuePriority?: string; - maxPhonesPerTask?: number; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; @@ -112,6 +111,7 @@ export interface StatusChangeDto { status?: string; operatorId?: string; reason?: string; + force?: boolean; } export interface TemplateListQuery { @@ -159,11 +159,31 @@ type ApplicationQueuePriority = typeof APPLICATION_QUEUE_PRIORITIES[number]; const APPLICATION_INTERFACE_TYPES = ['cmpp20'] as const; type ApplicationInterfaceType = typeof APPLICATION_INTERFACE_TYPES[number]; const DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS = 90_000; +const APPLICATION_DISABLE_GRACE_MS = 72 * 60 * 60 * 1_000; +const DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS = 60_000; +const UNRESOLVED_DOWNSTREAM_STATUSES = ['pending', 'awaiting_ack', 'failed', 'manual_requeueing'] as const; @Injectable() -export class SmsConfigService { +export class SmsConfigService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(SmsConfigService.name); + private applicationDisableTimer?: ReturnType; + private applicationDisableScanRunning = false; + constructor(private readonly prisma: PrismaService) {} + onModuleInit() { + this.applicationDisableTimer = setInterval( + () => void this.runApplicationDisableScan(), + getPositiveIntegerEnv('APPLICATION_DISABLE_SCAN_INTERVAL_MS', DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS), + ); + this.applicationDisableTimer.unref?.(); + void this.runApplicationDisableScan(); + } + + onModuleDestroy() { + if (this.applicationDisableTimer) clearInterval(this.applicationDisableTimer); + } + async listApplications(queryOrTenantId?: string | ApplicationListQuery) { const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {}; if (query.includeConnections) { @@ -202,6 +222,9 @@ export class SmsConfigService { _count: { _all: true }, }), ]); + const disablingDetails = new Map((await Promise.all(applications + .filter((application) => application.status === 'disabling') + .map(async (application) => [application.id, await this.getApplicationDeactivationPreview(application.id)] as const)))); return applications.map((application) => { const appConnections = connections.filter((connection) => connection.applicationId === application.id); const appStats = messageStats.filter((item) => item.applicationId === application.id); @@ -213,6 +236,7 @@ export class SmsConfigService { cmppStatus: normalizeApplicationCmppStatus(appConnections, application.status), sentToday: todayTotal, deliveryRate: todayTotal > 0 ? Number(((delivered / todayTotal) * 100).toFixed(1)) : 0, + deactivation: disablingDetails.get(application.id) ?? null, }; }).sort((left, right) => right.sentToday - left.sentToday || left.name.localeCompare(right.name, 'zh-CN') @@ -389,7 +413,6 @@ export class SmsConfigService { dailyLimit: getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'), customerUnitPrice: data.customerUnitPrice ?? 0, queuePriority, - maxPhonesPerTask: data.maxPhonesPerTask ?? 10000, templateMismatchMode: data.templateMismatchMode ?? 'reject', downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled ?? true, downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled ?? true, @@ -459,7 +482,6 @@ export class SmsConfigService { dailyLimit: data.dailyLimit === undefined ? undefined : getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'), customerUnitPrice: data.customerUnitPrice, queuePriority, - maxPhonesPerTask: data.maxPhonesPerTask, templateMismatchMode: data.templateMismatchMode, downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled, downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled, @@ -567,13 +589,108 @@ export class SmsConfigService { throw new NotFoundException('Application not found'); } const status = data.status ?? 'disabled'; - const updated = await this.prisma.smsApplication.update({ where: { id: applicationId }, data: { status } }); - await this.writeOperationLog(application.tenantId, data.operatorId, `sms_application.${status}`, 'sms_application', applicationId, { - statusBefore: application.status, - statusAfter: status, - reason: data.reason, + if (status === 'active') { + const updated = await this.prisma.smsApplication.update({ + where: { id: applicationId }, + data: { status: 'active', disablingAt: null, autoDisableAt: null, disableReason: null }, + }); + await this.writeApplicationStatusLog(application, data, 'active', {}); + return updated; + } + if (!['disabled', 'disabling', 'deleted'].includes(status)) { + throw new BadRequestException(`不支持的企业应用状态:${status}`); + } + + const preview = await this.getApplicationDeactivationPreview(applicationId); + if ((status === 'disabling' || status === 'disabled') && preview.totalOutstanding > 0 && !data.force) { + const disablingAt = new Date(); + const autoDisableAt = new Date(disablingAt.getTime() + APPLICATION_DISABLE_GRACE_MS); + const updated = await this.prisma.smsApplication.update({ + where: { id: applicationId }, + data: { + status: 'disabling', + disablingAt, + autoDisableAt, + disableReason: data.reason?.trim() || '等待未完成回执清算', + }, + }); + await this.writeApplicationStatusLog(application, data, 'disabling', { preview, disablingAt, autoDisableAt }); + return { ...updated, deactivation: { ...preview, disablingAt, autoDisableAt } }; + } + + const finalStatus = status === 'deleted' ? 'deleted' : 'disabled'; + const abandonReason = status === 'deleted' + ? '企业应用已删除,放弃剩余下游投递' + : data.force + ? '运营强制停用企业应用,放弃剩余下游投递' + : '企业应用无待清算数据,完成停用'; + const abandoned = await this.abandonApplicationDeliveries(applicationId, abandonReason); + const updated = await this.prisma.smsApplication.update({ + where: { id: applicationId }, + data: { + status: finalStatus, + disablingAt: null, + autoDisableAt: null, + disableReason: data.reason?.trim() || abandonReason, + }, }); - return updated; + const disconnect = await this.disconnectDownstreamAccount(application.cmppAccount, abandonReason); + await this.writeApplicationStatusLog(application, data, finalStatus, { preview, abandoned, disconnect }); + return { ...updated, deactivation: null, abandoned, disconnect }; + } + + async getApplicationDeactivationPreview(applicationId: string) { + const application = await this.prisma.smsApplication.findUnique({ + where: { id: applicationId }, + select: { + id: true, + status: true, + disablingAt: true, + autoDisableAt: true, + disableReason: true, + }, + }); + if (!application) throw new NotFoundException('Application not found'); + const [ + awaitingSupplierReceipt, + waitingToSend, + awaitingClientAck, + retryableFailures, + pendingUplinks, + activeConnections, + ] = await Promise.all([ + this.prisma.smsMessageRecord.count({ + where: { applicationId, status: { in: ['submitted', 'unknown'] }, receiptStatus: null }, + }), + this.prisma.cmppDownstreamDelivery.count({ + where: { applicationId, deliveryType: 'receipt', status: { in: ['pending', 'manual_requeueing'] } }, + }), + this.prisma.cmppDownstreamDelivery.count({ + where: { applicationId, deliveryType: 'receipt', status: 'awaiting_ack' }, + }), + this.prisma.cmppDownstreamDelivery.count({ + where: { applicationId, deliveryType: 'receipt', status: 'failed', retryEnabled: true }, + }), + this.prisma.cmppDownstreamDelivery.count({ + where: { applicationId, deliveryType: 'uplink', status: { in: [...UNRESOLVED_DOWNSTREAM_STATUSES] } }, + }), + this.prisma.cmppDownstreamConnection.count({ + where: { applicationId, status: 'connected' }, + }), + ]); + return { + status: application.status, + reason: application.disableReason, + disablingAt: application.disablingAt, + autoDisableAt: application.autoDisableAt, + awaitingSupplierReceipt, + waitingToSend, + awaitingClientAck, + retryableFailures, + pendingUplinks, + activeConnections, + totalOutstanding: awaitingSupplierReceipt + waitingToSend + awaitingClientAck + retryableFailures + pendingUplinks, + }; } async listApplicationConnections(applicationId: string) { @@ -690,7 +807,7 @@ export class SmsConfigService { }); return { connectionId: data.connectionId, status: 'disconnected', deleted: Boolean(existing) }; } - if (!application.interfaceEnabled || application.status !== 'active') { + if (!application.interfaceEnabled || !['active', 'disabling'].includes(application.status)) { throw new ForbiddenException('CMPP interface is disabled for this application'); } if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) { @@ -1658,6 +1775,132 @@ export class SmsConfigService { return this.prisma.auditRecord.create({ data }); } + private async abandonApplicationDeliveries(applicationId: string, reason: string) { + const deliveries = await this.prisma.cmppDownstreamDelivery.findMany({ + where: { applicationId, status: { in: [...UNRESOLVED_DOWNSTREAM_STATUSES] } }, + select: { id: true }, + }); + const deliveryIds = deliveries.map((delivery) => delivery.id); + if (deliveryIds.length === 0) return 0; + await this.prisma.cmppDownstreamDeliveryAttempt.updateMany({ + where: { deliveryId: { in: deliveryIds }, status: { in: ['awaiting_ack', 'sent'] } }, + data: { + status: 'abandoned', + ackDeadlineAt: null, + failureType: 'application_disabled', + errorMessage: reason, + }, + }); + const updated = await this.prisma.cmppDownstreamDelivery.updateMany({ + where: { id: { in: deliveryIds }, status: { in: [...UNRESOLVED_DOWNSTREAM_STATUSES] } }, + data: { + status: 'abandoned', + retryEnabled: false, + nextRetryAt: null, + ackDeadlineAt: null, + lastError: reason, + }, + }); + return updated.count; + } + + private async disconnectDownstreamAccount(account: string, reason: string) { + const baseUrl = process.env.GATEWAY_CONTROL_URL?.trim() || 'http://127.0.0.1:8090'; + try { + const response = await fetch(`${baseUrl}/downstream/connections/disconnect`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ account, reason }), + signal: AbortSignal.timeout(10_000), + }); + const responseText = await response.text(); + if (!response.ok) { + throw new Error(`Gateway returned ${response.status}: ${responseText}`); + } + return responseText ? JSON.parse(responseText) as { account: string; disconnected: number } : { account, disconnected: 0 }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`Failed to disconnect downstream CMPP account ${account}: ${message}`); + return { account, disconnected: 0, error: message }; + } + } + + private writeApplicationStatusLog( + application: { id: string; tenantId: string; status: string }, + data: StatusChangeDto, + statusAfter: string, + detail: Record, + ) { + return this.writeOperationLog( + application.tenantId, + data.operatorId, + `sms_application.${statusAfter}`, + 'sms_application', + application.id, + { + statusBefore: application.status, + statusAfter, + reason: data.reason, + force: Boolean(data.force), + ...JSON.parse(JSON.stringify(detail)) as Record, + }, + ); + } + + private async runApplicationDisableScan() { + if (this.applicationDisableScanRunning) return; + this.applicationDisableScanRunning = true; + try { + const applications = await this.prisma.smsApplication.findMany({ + where: { status: 'disabling' }, + select: { id: true, tenantId: true, cmppAccount: true, status: true, autoDisableAt: true }, + take: 500, + }); + const now = new Date(); + for (const application of applications) { + const preview = await this.getApplicationDeactivationPreview(application.id); + if (preview.totalOutstanding === 0) { + await this.finalizeDisablingApplication(application, false, '待处理回执已清算完成,系统自动停用', preview); + } else if (application.autoDisableAt && application.autoDisableAt <= now) { + await this.finalizeDisablingApplication(application, true, '进入停用中状态已满72小时,系统自动放弃剩余回执', preview); + } + } + } catch (error) { + this.logger.error(`Application disabling scan failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { + this.applicationDisableScanRunning = false; + } + } + + private async finalizeDisablingApplication( + application: { id: string; tenantId: string; cmppAccount: string; status: string }, + abandonOutstanding: boolean, + reason: string, + preview: Awaited>, + ) { + const claimed = await this.prisma.smsApplication.updateMany({ + where: { id: application.id, status: 'disabling' }, + data: { + status: 'disabled', + disablingAt: null, + autoDisableAt: null, + disableReason: reason, + }, + }); + if (claimed.count !== 1) return false; + const abandoned = abandonOutstanding + ? await this.abandonApplicationDeliveries(application.id, reason) + : 0; + const disconnect = await this.disconnectDownstreamAccount(application.cmppAccount, reason); + await this.writeApplicationStatusLog(application, { reason, force: abandonOutstanding }, 'disabled', { + preview, + abandoned, + disconnect, + automatic: true, + }); + return true; + } + private writeOperationLog( tenantId: string, userId: string | undefined, @@ -1837,7 +2080,7 @@ function getPositiveInteger(value: number | undefined, fallback: number, fieldNa } function normalizeApplicationCmppStatus(connections: Array<{ status: string }>, applicationStatus: string) { - if (applicationStatus !== 'active') { + if (!['active', 'disabling'].includes(applicationStatus)) { return 'inactive'; } if (connections.some((connection) => connection.status === 'connected')) { diff --git a/api/src/tenants/tenants.service.spec.ts b/api/src/tenants/tenants.service.spec.ts index 19554c9..9a5142e 100644 --- a/api/src/tenants/tenants.service.spec.ts +++ b/api/src/tenants/tenants.service.spec.ts @@ -17,6 +17,9 @@ function createPrismaMock() { create: jest.fn().mockResolvedValue(tenant), update: jest.fn().mockResolvedValue(tenant), }, + smsApplication: { + count: jest.fn().mockResolvedValue(0), + }, enterpriseCertification: { findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'cert-1' }), @@ -81,6 +84,18 @@ describe('TenantsService', () => { expect(prisma.tenant.update).not.toHaveBeenCalled(); }); + it('blocks enterprise deletion while applications are active or disabling', async () => { + const prisma = createPrismaMock(); + prisma.smsApplication.count.mockResolvedValue(2); + const service = new TenantsService(prisma as never); + + await expect(service.delete('tenant-1')).rejects.toThrow('还有 2 个启用或停用中的企业应用'); + expect(prisma.tenant.update).not.toHaveBeenCalled(); + expect(prisma.smsApplication.count).toHaveBeenCalledWith({ + where: { tenantId: 'tenant-1', status: { in: ['active', 'disabling'] } }, + }); + }); + it('rejects enterprise credit codes containing non-alphanumeric characters', async () => { const prisma = createPrismaMock(); const service = new TenantsService(prisma as never); diff --git a/api/src/tenants/tenants.service.ts b/api/src/tenants/tenants.service.ts index a3d9c7a..95db091 100644 --- a/api/src/tenants/tenants.service.ts +++ b/api/src/tenants/tenants.service.ts @@ -123,7 +123,21 @@ export class TenantsService { } delete(id: string) { - return this.changeStatus(id, 'deleted'); + return this.deleteAfterApplicationCheck(id); + } + + private async deleteAfterApplicationCheck(id: string) { + await this.ensureTenant(id); + const blockingApplications = await this.prisma.smsApplication.count({ + where: { tenantId: id, status: { in: ['active', 'disabling'] } }, + }); + if (blockingApplications > 0) { + throw new BadRequestException(`该企业还有 ${blockingApplications} 个启用或停用中的企业应用,请先完成应用停用`); + } + return this.prisma.tenant.update({ + where: { id }, + data: { status: 'deleted' }, + }); } private async ensureTenant(id: string) { diff --git a/api/src/users/users.controller.ts b/api/src/users/users.controller.ts index 4a4146f..b633f86 100644 --- a/api/src/users/users.controller.ts +++ b/api/src/users/users.controller.ts @@ -23,8 +23,14 @@ export class UsersController { @Get('admin/users') @ApiOkResponse({ type: [AdminUserResponseDto] }) - list(@Query('tenantId') tenantId?: string, @Query('roleCode') roleCode?: string) { - return this.users.list(tenantId, roleCode); + list( + @Query('tenantId') tenantId?: string, + @Query('roleCode') roleCode?: string, + @Query('displayName') displayName?: string, + @Query('login') login?: string, + @Query('status') status?: string, + ) { + return this.users.list({ tenantId, roleCode, displayName, login, status }); } @Post('admin/users') @@ -65,8 +71,13 @@ export class UsersController { @Get('client/users') @ApiOkResponse({ type: [ClientUserResponseDto] }) - listClient(@TenantId() tenantId?: string) { - return this.users.listClientUsers(tenantId); + listClient( + @TenantId() tenantId?: string, + @Query('displayName') displayName?: string, + @Query('login') login?: string, + @Query('status') status?: string, + ) { + return this.users.listClientUsers(tenantId, { displayName, login, status }); } @Post('client/users') diff --git a/api/src/users/users.service.spec.ts b/api/src/users/users.service.spec.ts index b429f5b..294e1d1 100644 --- a/api/src/users/users.service.spec.ts +++ b/api/src/users/users.service.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ConflictException, ForbiddenException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { hashPassword, UsersService } from './users.service'; function createPrismaMock() { @@ -146,6 +146,63 @@ describe('UsersService', () => { expect(user).not.toHaveProperty('failedLoginCount'); }); + it('applies separate server-side user filters', async () => { + const prisma = createPrismaMock(); + const service = new UsersService(prisma as never); + + await service.list({ + displayName: ' 赵辉 ', + login: ' zhaohui ', + tenantId: 'tenant-1', + roleCode: 'enterprise_admin', + status: 'active', + }); + + expect(prisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { + deletedAt: null, + tenantId: 'tenant-1', + roles: { some: { role: { code: 'enterprise_admin' } } }, + status: 'active', + displayName: { contains: '赵辉', mode: 'insensitive' }, + OR: [ + { username: { contains: 'zhaohui', mode: 'insensitive' } }, + { email: { contains: 'zhaohui', mode: 'insensitive' } }, + { phone: { contains: 'zhaohui' } }, + ], + }, + })); + }); + + it('keeps client user filters scoped to the current tenant', async () => { + const prisma = createPrismaMock(); + const service = new UsersService(prisma as never); + + await service.listClientUsers('tenant-1', { login: '138', status: 'disabled' }); + + expect(prisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + deletedAt: null, + tenantId: 'tenant-1', + roles: { some: { role: { code: 'enterprise_admin' } } }, + status: 'disabled', + }), + })); + }); + + it('never resolves a logically deleted user by username', async () => { + const prisma = createPrismaMock(); + const service = new UsersService(prisma as never); + + await service.findByUsername('zhaohui'); + + expect(prisma.user.findFirst).toHaveBeenCalledWith({ + where: { username: 'zhaohui', deletedAt: null }, + include: { tenant: true, roles: { include: { role: true } } }, + }); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + it('returns a safe view after changing the current password', async () => { const prisma = createPrismaMock(); prisma.user.findFirst.mockResolvedValue({ @@ -185,7 +242,12 @@ describe('UsersService', () => { prisma.user.count.mockResolvedValue(1); const service = new UsersService(prisma as never); - await expect(service.remove('user-1', 'operator-2')).rejects.toBeInstanceOf(ConflictException); + await expect(service.remove('user-1', 'operator-2')).rejects.toMatchObject({ + response: expect.objectContaining({ + code: 'LAST_PLATFORM_ADMIN', + message: expect.stringContaining('请先创建或启用另一名平台管理员'), + }), + }); }); it('forbids disabling the last active administrator of a tenant', async () => { @@ -197,7 +259,12 @@ describe('UsersService', () => { const service = new UsersService(prisma as never); await expect(service.changeStatus('user-1', { status: 'disabled' }, 'tenant-1', 'operator-2')) - .rejects.toBeInstanceOf(ConflictException); + .rejects.toMatchObject({ + response: expect.objectContaining({ + code: 'LAST_ENTERPRISE_ADMIN', + message: expect.stringContaining('请先创建或启用该企业的另一名管理员'), + }), + }); }); it('maps duplicate login identifiers to HTTP 409 with the conflicting field', async () => { @@ -208,7 +275,11 @@ describe('UsersService', () => { await expect(service.create({ displayName: '平台管理员', email: 'admin@example.com', password: 'secret1', roleCode: 'platform_admin', })).rejects.toMatchObject({ - response: expect.objectContaining({ code: 'USER_DUPLICATE', field: 'email' }), + response: expect.objectContaining({ + code: 'USER_DUPLICATE', + field: 'email', + message: '该邮箱已被其他未删除用户使用', + }), }); }); }); diff --git a/api/src/users/users.service.ts b/api/src/users/users.service.ts index 04a4cd1..31c4e49 100644 --- a/api/src/users/users.service.ts +++ b/api/src/users/users.service.ts @@ -38,6 +38,14 @@ export interface ChangePasswordDto { operatorId?: string; } +export interface UserListFilters { + tenantId?: string; + roleCode?: string; + displayName?: string; + login?: string; + status?: string; +} + export interface CreateRoleDto { code: string; name: string; @@ -70,12 +78,23 @@ const roleNames: Record = { export class UsersService { constructor(private readonly prisma: PrismaService) {} - async list(tenantId?: string, roleCode?: string) { + async list(filters: UserListFilters = {}) { + const displayName = normalizeOptional(filters.displayName); + const login = normalizeOptional(filters.login); const users = await this.prisma.user.findMany({ where: { deletedAt: null, - ...(tenantId ? { tenantId } : {}), - ...(roleCode ? { roles: { some: { role: { code: roleCode } } } } : {}), + ...(filters.tenantId ? { tenantId: filters.tenantId } : {}), + ...(filters.roleCode ? { roles: { some: { role: { code: filters.roleCode } } } } : {}), + ...(filters.status ? { status: filters.status } : {}), + ...(displayName ? { displayName: { contains: displayName, mode: 'insensitive' } } : {}), + ...(login ? { + OR: [ + { username: { contains: login, mode: 'insensitive' } }, + { email: { contains: login, mode: 'insensitive' } }, + { phone: { contains: login } }, + ], + } : {}), }, include: { tenant: true, roles: { include: { role: true } } }, orderBy: { createdAt: 'desc' }, @@ -83,15 +102,18 @@ export class UsersService { return users.map(publicUser); } - listClientUsers(tenantId?: string) { + listClientUsers(tenantId: string | undefined, filters: Omit = {}) { if (!tenantId) { throw new BadRequestException('tenantId is required for client user management'); } - return this.list(tenantId); + return this.list({ ...filters, tenantId, roleCode: 'enterprise_admin' }); } findByUsername(username: string) { - return this.prisma.user.findUnique({ where: { username }, include: { tenant: true, roles: { include: { role: true } } } }); + return this.prisma.user.findFirst({ + where: { username, deletedAt: null }, + include: { tenant: true, roles: { include: { role: true } } }, + }); } findByLogin(login: string) { @@ -374,7 +396,9 @@ export class UsersService { if (activeCount <= 1) { throw new ConflictException({ code: currentRole === 'platform_admin' ? 'LAST_PLATFORM_ADMIN' : 'LAST_ENTERPRISE_ADMIN', - message: currentRole === 'platform_admin' ? '不能删除、禁用或降权最后一个平台管理员' : '不能删除、禁用或降权最后一个企业管理员', + message: currentRole === 'platform_admin' + ? '不能删除、禁用或降权最后一个平台管理员;请先创建或启用另一名平台管理员' + : '不能删除、禁用或降权最后一个企业管理员;请先创建或启用该企业的另一名管理员', }); } } @@ -386,10 +410,11 @@ export class UsersService { if ((error as { code?: string }).code !== 'P2002') throw error; const target = (error as { meta?: { target?: string[] | string } }).meta?.target; const field = Array.isArray(target) ? target[0] : target; + const fieldLabel = field === 'username' ? '用户名' : field === 'email' ? '邮箱' : field === 'phone' ? '手机号' : '登录标识'; throw new ConflictException({ code: 'USER_DUPLICATE', field: field ?? 'login', - message: `用户${field ? `字段 ${field}` : '登录标识'}已存在;逻辑删除后仍永久保留以维持审计关联`, + message: `该${fieldLabel}已被其他未删除用户使用`, }); } } diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 171fa61..4c3bd56 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -1677,6 +1677,13 @@ 1. 运营端“系统日志”必须将人员操作审计与协议通讯日志分成两个独立页签。通讯日志至少支持协议、交互方向、事件类型、结果、关键字和时间范围过滤,并展示平台消息号、上游消息号或HTTP请求号、脱敏对象、结果码、耗时和安全详情。 2. CMPP应覆盖客户登录/Submit、供应商SubmitResp、状态报告Deliver、上行Deliver及平台下游投递;HTTP应覆盖客户发送请求和平台回执/上行Webhook。数据库中一条记录必须对应一个真实业务报文,不得把同一报文的“入口收到”和“处理成功”拆成两条记录;处理结果、结果码和耗时写在该报文同一条记录中,失败、重试等后续真实交互另行记录。 3. Gateway收到状态报告或上行后,必须对解包/解码失败及转发NestJS失败输出结构化安全日志;NestJS入口把业务处理结果合并回同一报文记录,以便区分“上游未发”“Gateway未收到”“Gateway转发失败”和“API落库失败”。一条正常短短信的供应商侧完整成功闭环应依次展示四个真实报文:平台→通道 `CMPP_SUBMIT`、通道→平台 `CMPP_SUBMIT_RESP`、通道→平台 `CMPP_DELIVER`、平台→通道 `CMPP_DELIVER_RESP`;箭头只表达报文实际传输方向。 +4. 通讯日志不得保存短信正文、密码、密钥、Token、签名鉴权值或完整HTTP请求体;手机号只保存脱敏值。CMPP心跳不得逐包写入数据库,连接健康仍使用连接状态和聚合指标。 +5. 通讯日志写入不能阻塞短信主链路,默认批量异步写入,缓冲区应有上限和溢出告警;热数据默认保留30天,保留期允许通过环境变量配置。 +6. 通讯日志方向固定使用“企业应用 → 平台、平台 → 供应商通道、供应商通道 → 平台、平台 → 企业应用”。供应商长短信每个真实 `SUBMIT` 和 `SUBMIT_RESP` 分片各记一条,企业应用每个真实 `SUBMIT_RESP` 也必须记录;内部 `submit-result` 聚合回调不是协议报文,不得重复生成通讯日志。 +7. 供应商长短信回执必须先写入对应 `SmsMessageSegmentAudit`。仅当同一提交尝试的全部分片均为 `delivered` 时,主记录才转 `delivered` 并向企业应用投递一次最终回执;任一分片明确失败可进入最终失败/补发状态,分片尚未齐全时主记录保持 `submitted`,不得由首片成功提前聚合。 +8. 长短信任一分片返回非成功终态时,系统必须通过该分片审计关联的提交记录识别当前发送尝试,不得仅以主记录保存的首片上游消息号判断;确认属于当前尝试后,整条短信立即进入失败/补发或退款终态,无需等待其余分片回执。 +9. 回执和上行投递方式不得由运营人员选择。企业应用开通CMPP接口即按CMPP投递,开通HTTP接口且对应Webhook地址非空即按HTTP投递,两者同时满足时双投;任一地址为空时只跳过该类HTTP事件。运营端企业应用HTTP参数页必须始终可编辑回执和上行Webhook地址,不因HTTP接口开关关闭而隐藏。 +10. Gateway向企业应用发送真实 `CMPP_DELIVER` 以及收到企业应用真实 `CMPP_DELIVER_RESP` 时,都必须各写一条通讯交互日志,分别使用“平台→企业应用”和“企业应用→平台”方向;下游投递记录继续承担排队、重试和ACK业务状态,不得以通讯日志替代。 ## 2026-07-26 依赖安全治理补充 @@ -1685,10 +1692,32 @@ - PostCSS 必须固定到 `8.5.18` 或更高修复版本。平台不得接受用户 CSS 后交由构建链处理;如未来新增此能力,必须显式禁用不可信 previous source map 自动加载并重新开展威胁建模。 - ExcelJS 的旧版 `minimatch` 调用接口与安全修复版 `brace-expansion` 5.x 不兼容时,允许使用受测试的本地 CommonJS 兼容适配层;适配层只能转发到官方有长度上限的实现,必须同时验证旧版 minimatch 花括号匹配、Excel 读写和干净 `npm ci`。 - Prisma CLI 只用于生成、迁移和构建,不属于 API 请求运行路径。其无上游修复版本的中危工具链公告需记录接受条件并持续跟踪,不得为审计数字清零而把 Prisma 7 数据访问栈盲目降级到 6.x。 -4. 通讯日志不得保存短信正文、密码、密钥、Token、签名鉴权值或完整HTTP请求体;手机号只保存脱敏值。CMPP心跳不得逐包写入数据库,连接健康仍使用连接状态和聚合指标。 -5. 通讯日志写入不能阻塞短信主链路,默认批量异步写入,缓冲区应有上限和溢出告警;热数据默认保留30天,保留期允许通过环境变量配置。 -6. 通讯日志方向固定使用“企业应用 → 平台、平台 → 供应商通道、供应商通道 → 平台、平台 → 企业应用”。供应商长短信每个真实 `SUBMIT` 和 `SUBMIT_RESP` 分片各记一条,企业应用每个真实 `SUBMIT_RESP` 也必须记录;内部 `submit-result` 聚合回调不是协议报文,不得重复生成通讯日志。 -7. 供应商长短信回执必须先写入对应 `SmsMessageSegmentAudit`。仅当同一提交尝试的全部分片均为 `delivered` 时,主记录才转 `delivered` 并向企业应用投递一次最终回执;任一分片明确失败可进入最终失败/补发状态,分片尚未齐全时主记录保持 `submitted`,不得由首片成功提前聚合。 -8. 长短信任一分片返回非成功终态时,系统必须通过该分片审计关联的提交记录识别当前发送尝试,不得仅以主记录保存的首片上游消息号判断;确认属于当前尝试后,整条短信立即进入失败/补发或退款终态,无需等待其余分片回执。 -9. 回执和上行投递方式不得由运营人员选择。企业应用开通CMPP接口即按CMPP投递,开通HTTP接口且对应Webhook地址非空即按HTTP投递,两者同时满足时双投;任一地址为空时只跳过该类HTTP事件。运营端企业应用HTTP参数页必须始终可编辑回执和上行Webhook地址,不因HTTP接口开关关闭而隐藏。 -10. Gateway向企业应用发送真实 `CMPP_DELIVER` 以及收到企业应用真实 `CMPP_DELIVER_RESP` 时,都必须各写一条通讯交互日志,分别使用“平台→企业应用”和“企业应用→平台”方向;下游投递记录继续承担排队、重试和ACK业务状态,不得以通讯日志替代。 + +## 2026-07-26 用户登录标识复用、组合查询与管理员保护提示 + +1. 用户逻辑删除后,原用户记录、用户主键及历史审计关联必须继续保留;用户名、邮箱和手机号仅在未删除用户范围内唯一。新建用户可以复用逻辑删除记录曾使用的登录标识,但必须生成新的用户主键,不得继承旧用户的角色、企业归属、密码、会话或权限。 +2. 用户登录和按用户名查找必须显式排除逻辑删除记录。活动用户之间的登录标识并发冲突继续由PostgreSQL唯一索引保证,并返回HTTP 409、冲突字段及明确中文提示。 +3. 运营端用户管理将用户姓名、登录账号、所属企业、用户角色和状态拆分为独立条件;客户端将用户姓名、登录账号和状态拆分。点击查询或重置时调用真实后端组合查询,条件之间使用AND,登录账号内部对用户名、邮箱和手机号使用OR;不得加载全量数据后仅在浏览器过滤。 +4. 删除、禁用或降权最后一个平台管理员/企业管理员时,后端实时拦截继续作为权威判断。前端必须在当前确认弹窗内以`role=alert`显示失败原因和处理建议,保持弹窗打开,禁止只向浏览器控制台输出未处理Promise;请求期间确认按钮必须禁用。 +## 2026-07-26 企业应用停用与回执清算补充要求 + +- 删除企业前必须检查其企业应用;只要存在`active`或`disabling`应用就阻止删除,并提示先完成应用停用。 +- 点击停用应用时,系统必须统计尚未收到供应商回执的短信、待发送回执、等待`CMPP_DELIVER_RESP`的回执、仍可重试的失败投递及待投递上行。 +- 无待清算数据时,应用直接转为`disabled`并断开该账号的全部下游CMPP连接;存在待清算数据时,运营可选择“等待回执后停用”或“强制停用并断开连接”。 +- “等待回执后停用”将应用转为`disabling`:立即拒绝新短信Submit,但保留或允许下游账号重新连接以接收历史回执;待清算数据归零后自动停用。 +- `disabling`状态必须展示进入原因、各类待清算数量、当前连接数及自动停用时间;运营点击启用可清除停用计时并恢复为`active`。 +- “停用中”最长保留72小时,从`disablingAt`起算。到期仍未清算时自动转为`disabled`,剩余下游投递标记`abandoned`并停止重试,然后断开全部下游连接。 +- 应用停用后才到达的供应商回执仍更新短信主记录并保存原始证据,但下游投递记录直接标记`abandoned`,不得继续推送或形成重试告警。 +- Gateway读取已形成的待投递回执不能依赖企业或应用当前是否启用,避免“已生成回执但账户停用导致永远无法读取”的投递死锁。 + +## 2026-07-26 风控规则、逐号码拦截与短信人工审核补充要求 + +1. 风控规则只保留全局默认和企业应用级覆盖两层;应用级同编码规则优先于全局规则。企业级覆盖不再存在,企业应用表单和数据模型中的`maxPhonesPerTask`删除,单任务号码上限完全由`MAX_PHONES_PER_TASK`规则控制。本条取代2026-07-23“应用每任务最大号码数”旧要求,测试环境既有应用值无需迁移。 +2. 可配置规则仅包括单任务最大号码数、非工作时间大批量营销发送和10分钟客户端任务创建频控。重复号码比例、非法号码比例、黑名单命中比例、模板变量异常不再作为可配置风控规则;历史规则与命中记录保留审计但不再生效或展示。 +3. 10分钟任务频控只按同一企业应用、`SmsBatchTask.sourceType=client`统计真实客户端任务;CMPP、公开HTTP、运营通道测试和风控预检不得计入。配置上限N时,第N+1个客户端任务进入规则指定动作。 +4. 非工作时间规则支持`HH:mm`开始、结束时间并允许跨日,时区固定使用`Asia/Shanghai`;全局和企业应用级覆盖均可分别配置。 +5. 手机号码基础合法性只判断“1开头、总计11位、全部为数字”,不得依赖可能滞后的手机号段表。非法号码为确定性拦截,不进入人工审核;客户端/HTTP短信记录标记`submit_failed + submitStatus=rejected + INVALID_PHONE`,CMPP已受理的多号码提交对非法目的号码生成平台失败回执,其他合法号码继续处理。 +6. 黑名单按单号码确定性拦截,不再按命中比例拒绝整批。命中平台或企业应用黑名单的客户端/HTTP记录标记提交失败、金额为0且不提交通道;CMPP已受理记录生成`REJECTD`失败回执。混合批次中的非黑名单号码继续发送。 +7. 模板变量异常指本次发送缺少模板必填变量或传入模板未定义变量。该校验保留为不可配置的确定性拒绝,返回明确的缺失/多传变量原因,不进入人工审核;模板创建时的人工审核不能替代每次发送的变量完整性校验。 +8. 短信审核页面只展示待人工审核和人工审核记录;自动放行和自动拒绝不得混入“人工通过/人工驳回”。号码数量可点击查看真实号码明细,字段仅为手机号码、号码归属地、运营商和短信记录状态,并提供服务端搜索与分页。 +9. 创建待审核批次时,每条待审`SmsMessageRecord.reviewTaskId`必须同步保存。人工通过或驳回应同时兼容短信直连审核任务和`SmsBatchTask.riskTaskId`关联路径,保证审核任务、批次、短信状态及入队/拒绝动作一致;本次不修复或补发升级前历史异常数据。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index a2c1331..f07b2cd 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -3669,6 +3669,17 @@ npm run verify:phase8 | TC-UIUX-P0-004 | 依次设置768×1024、1366×768、1440×900并复核同一用户。 | 平板四项操作全部可见且热区≥44px;1440首屏完整;1366即使存在内部横向滚动,滚动后删除必须完整可达,且页面级无横向溢出。 | | TC-UIUX-P0-005 | 完成五视口操作后检查浏览器控制台并执行前端/API构建与用户服务回归。 | 无新增console error/warn;前端与API build通过,用户服务测试通过,`git diff --check`通过。 | +### 17.17 用户登录标识复用、组合查询与管理员保护提示 + +| 用例编号 | 操作 | 预期结果 | +| --- | --- | --- | +| TC-USER-REUSE-001 | 新建用户名`zhaohui`,逻辑删除后再次使用同一用户名、邮箱或手机号新建用户。 | 新用户创建成功且主键与旧用户不同;旧用户及其OperationLog、审核关联保持原用户主键;登录只命中新用户。 | +| TC-USER-REUSE-002 | 两个未删除用户并发提交相同用户名、邮箱或手机号。 | PostgreSQL仅允许一个请求成功,另一个返回HTTP 409、`USER_DUPLICATE`、冲突字段和中文提示,不产生两个活动账号。 | +| TC-USER-FILTER-001 | 在运营端分别及组合填写用户姓名、登录账号、所属企业、用户角色和状态,点击查询,再点击重置。 | 每次操作请求真实`GET /api/admin/users`;条件分别生效,组合使用AND,登录账号匹配用户名/邮箱/手机号;重置返回全部未删除用户。 | +| TC-USER-FILTER-002 | 在客户端分别及组合填写用户姓名、登录账号和状态。 | 请求真实`GET /api/client/users`;只返回当前企业管理员,无法通过查询参数跨租户或查询平台管理员。 | +| TC-USER-CONTINUITY-UI-001 | 删除、禁用或降权最后一个平台管理员及某企业最后一个启用管理员。 | 后端返回权威冲突;确认弹窗保持打开并显示红色可访问错误及“先创建或启用另一名管理员”建议,按钮结束忙碌状态,浏览器无未处理Promise。 | +| TC-USER-CONTINUITY-UI-002 | 为相同范围增加另一名启用管理员后重复删除或禁用。 | 操作成功、弹窗关闭、列表按当前已应用查询条件刷新,并写入对应OperationLog。 | + ### 17.17 2026-07-21 下游连接恢复与历史回执回填 | 用例编号 | 操作 | 预期结果 | @@ -3815,3 +3826,30 @@ npm run verify:phase8 - `TC-DELIVERY-AUTO-014`:分别配置仅CMPP、仅HTTP、CMPP+HTTP、两者均关闭四种应用状态;回执与上行分别只产生CMPP下游记录、HTTP Webhook事件、两者各一条、均不产生。修改历史手工投递模式不得改变自动计算结果。 - `TC-HTTP-WEBHOOK-015`:运营端关闭HTTP接口后,回执和上行Webhook地址输入框仍显示且可保存;任一地址保存为空时删除对应有效端点,后续不推送该类HTTP事件,另一非空地址不受影响。 - `TC-PROTOCOL-LOG-016`:在线企业应用收到回执或上行 `CMPP_DELIVER` 并返回 `CMPP_DELIVER_RESP`;通讯日志各出现一条“平台→企业应用/DELIVER”和“企业应用→平台/DELIVER_RESP”,结果、消息号、序列号和投递记录一致,下游投递记录仍独立展示发送、ACK和重试状态。 +## 2026-07-26 企业应用停用与回执清算专项 + +- `APP-DISABLE-001`:应用无待清算数据时点击停用,直接进入已停用并断开该账号全部CMPP连接。 +- `APP-DISABLE-002`:存在等待供应商回执、待推送、待ACK或可重试失败记录时,停用弹窗展示真实分类数量,并提供等待与强制停用两个操作。 +- `APP-DISABLE-003`:选择等待后进入`disabling`;新Submit同步返回非成功响应且不创建短信记录,历史回执仍可通过原连接或重新连接推送。 +- `APP-DISABLE-004`:停用中状态悬停、聚焦时展示原因、分类数量、进入时间和72小时自动停用时间。 +- `APP-DISABLE-005`:停用中点击启用恢复`active`,清除`disablingAt/autoDisableAt`,旧扫描任务不得再次将其停用。 +- `APP-DISABLE-006`:选择强制停用后,未完成投递及尝试标记`abandoned`、停止重试,并断开同账号的所有CMPP连接。 +- `APP-DISABLE-007`:从进入停用中满72小时仍有待清算数据时,系统自动执行强制停用;API/Gateway重启不影响截止时间。 +- `APP-DISABLE-008`:停用后新到供应商回执仍更新短信终态和保存原始回执,但下游投递直接记为`abandoned`。 +- `APP-DISABLE-009`:企业存在`active/disabling`应用时删除失败;全部应用为`disabled/deleted`后允许删除。 +- `APP-DISABLE-010`:企业或应用已停用后,Gateway仍可读取此前已形成的pending回执,不再返回账户无效导致投递死锁。 + +## 2026-07-26 风控与短信人工审核专项 + +- `RISK-RULE-001`:规则页只展示单任务最大号码数、非工作时间营销批量和10分钟客户端任务频控;重复、非法号码比例、黑名单比例和模板变量规则不展示且不参与计算。 +- `RISK-RULE-002`:同编码同时存在全局和企业应用级规则时,目标应用使用应用级阈值,其他应用继承全局;停用应用级规则后回落到全局。 +- `RISK-RULE-003`:修改阈值、动作、状态、优先级和非工作开始/结束时间后重新查询与数据库一致;非法编码、负阈值、同范围重复规则和相同起止时间被后端拒绝。 +- `RISK-FREQ-004`:同一应用10分钟内已有N个客户端批次时,第N+1个客户端任务命中;同期CMPP、HTTP、运营通道测试及风险预检数量不影响结果,另一应用任务也不影响。 +- `PHONE-VALID-005`:`10000000000`视为合法基础格式并继续号段/路由处理;非1开头、非11位或包含非数字字符的号码被确定性拦截。 +- `PHONE-BLOCK-006`:客户端/HTTP混合提交合法、非法、平台黑名单和企业应用黑名单号码;合法号码入队,三类拦截号码均为`submit_failed/rejected`、金额0且没有上游提交。 +- `PHONE-BLOCK-007`:CMPP多目的提交混合合法、非法和黑名单号码;Submit被平台受理后,非法/黑名单号码各生成一条`REJECTD`失败回执并投递客户,合法号码继续发送。 +- `TEMPLATE-VAR-008`:已人工审核模板在本次发送缺少必填变量或多传未定义变量时直接拒绝并列出变量名,不生成待人工审核任务;变量完整时正常继续。 +- `SMS-REVIEW-009`:待审核列表只含`pending_review`;人工通过/驳回列表只含`reviewedById`非空记录,自动放行和自动拒绝均不出现。 +- `SMS-REVIEW-010`:点击号码数量后,通过真实后端分页查看手机号码、归属地、运营商和短信状态;号码搜索与10/20/50条分页正确,接口同时兼容`reviewTaskId`和批次`riskTaskId`关联。 +- `SMS-REVIEW-011`:客户端和CMPP待审核任务创建时短信记录保存`reviewTaskId`;人工通过后短信由`pending_review`转为`queued`并入队,人工驳回后转拒绝且执行既有资金释放,不能只更新审核任务。 +- `SMS-REVIEW-012`:升级前历史`pending_review`异常记录保持原样,不执行数据修复或短信补发;升级后新任务不再产生审核任务与短信状态不一致。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 38caa3c..49365c7 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -2460,3 +2460,32 @@ git diff --check - 独立 Linux 临时目录从锁文件执行两次全新 `npm ci --ignore-scripts` 成功:根项目 59 个包、API 726 个包;旧版 minimatch 三条真实依赖链花括号匹配和长度上限均通过。整改后根项目审计只剩同一条不适用 RSC 公告的 2 个依赖节点,API high 从 26 降为 0,仅剩 Prisma CLI→Valibot 的 3 个 moderate;该工具链不处理 API 请求且上游暂无修复版本,不降级 Prisma 7。 - 功能门禁通过:报备 Excel 专项 1 suite / 7 tests,API 全量 26 suites / 325 tests,API TypeScript build,前端 TypeScript/Vite生产构建,Gateway `go test ./...`、`go vet ./...`,以及依赖安全门禁。标准部署脚本也会在干净安装后、Prisma migration 前强制执行该门禁,失败即停止发布。前端保留既有约 1.94 MB 单 chunk 提示;API 测试保留既有 Redis 容错告警和 `--forceExit` 异步句柄提示。 - Windows 本地完整 `npm ci` 因另一进程占用两个原生 `.node` 文件而无法清理旧目录,未结束未知会话进程;随后非破坏性 `npm install` 修复本地依赖。锁文件可重建性以隔离 Linux 干净安装结果为准。提交、推送和预发布部署结果待发布后补记。 + +## 2026-07-26 用户登录标识复用、组合查询与管理员提示(本地未提交、未部署) + +- 用户逻辑删除仍保留原记录、主键、角色关联及历史审计;用户名、邮箱和手机号改为仅对`deletedAt IS NULL`记录唯一。新增migration删除原全表唯一索引并创建三个PostgreSQL部分唯一索引,允许新用户以新主键复用已删除用户的登录标识,不继承旧账号身份或权限;登录和用户名查询显式排除已删除记录。 +- 运营端用户管理将用户姓名、登录账号、所属企业、用户角色和状态拆分;客户端拆分用户姓名、登录账号和状态。查询/重置调用真实用户列表API,后端按独立条件组合PostgreSQL查询,客户端条件由当前会话企业强制限域并固定企业管理员角色,不再下载全量用户后只在浏览器过滤。 +- 新增/编辑用户的校验和重复登录标识错误改在当前表单弹窗内显示。删除、禁用或降权最后一个管理员继续由后端实时计数拦截;运营端和客户端确认弹窗捕获HTTP错误、保持打开、显示可访问红色提示和“先创建或启用另一名管理员”建议,请求期间禁用按钮,不再产生未处理Promise。操作成功后才关闭弹窗,再按已应用筛选条件刷新列表;刷新失败不会误报为删除失败。 +- Prisma validate/generate通过;用户服务定向1 suite / 15 tests、API全量26 suites / 328 tests、API TypeScript build和前端TypeScript/Vite生产构建通过。全量测试只保留既有Redis不可用容错告警和`--forceExit`异步句柄提示,前端保留既有约1.94MB单chunk警告。 +- 本地静态预览访问运营端、客户端用户管理路由时均由真实认证守卫重定向至对应图形验证码登录页;页面标题、登录表单、无框架覆盖和控制台0条error/warn通过。没有绕过验证码,因此分离筛选和管理员拦截弹窗的登录后可见交互仍保留为人工登录复核项。 +- 本轮按用户要求保持未提交、未推送、未部署;未对预发布数据库执行migration或写入测试用户。`api/tsconfig.build.tsbuildinfo`、`outputs/`及未跟踪空文件`=`保持隔离,未纳入本次修改。 + +## 2026-07-26 企业应用停用回执清算与下游断连(本地未提交、未部署) + +- 企业应用新增`disablingAt/autoDisableAt/disableReason`持久化字段和`disabling`状态;停用前统计等待供应商回执、等待推送、等待客户ACK、可重试失败、待推送上行和在线连接。 +- 无待清算数据直接停用;有数据时运营端弹窗可选择“等待回执后停用”或“强制停用并断开连接”。停用中状态支持悬停/聚焦查看原因和数量,并可点击启用恢复。 +- 停用中应用立即拒绝新Submit但允许回执清算连接;清算完成自动停用,进入停用中满72小时仍未完成时自动放弃剩余投递、标记`abandoned`并断开该账号全部下游CMPP连接。 +- Gateway新增按客户账号关闭全部下游会话的控制接口;历史pending回执读取移除企业/应用当前启用状态限制,修复企业删除后回执已生成但Gateway持续收到400的投递死锁。 +- 企业删除增加`active/disabling`应用拦截。应用停用后才到达的供应商回执继续更新真实短信终态,但下游投递直接留痕为`abandoned`且不再重试。 +- 已通过Prisma format/generate/validate、API定向3 suites / 155 tests、API全量26 suites / 333 tests、API TypeScript build、前端TypeScript及Vite生产构建、Gateway `go vet ./...`和`go test ./...`。API全量仅保留既有Redis不可用容错告警和`--forceExit`异步句柄提示;前端保留既有约1.94MB单chunk提示。 +- 应用内浏览器访问本地生产预览的企业应用管理路由,被真实认证守卫引导到运营登录页;页面标题和登录表单正常、无框架错误覆盖、控制台0条error/warn。当前没有已登录会话且存在图形验证码,未绕过认证,因此停用选择弹窗、停用中悬停详情和恢复启用的登录后视觉交互仍需持有有效会话后复核。 +- 本轮按要求不提交、不推送、不部署;工作区原有用户管理、依赖安全整改、构建产物及`outputs/`等其他会话修改保持原样。 + +## 2026-07-26 风控规则与短信人工审核整改(发布前) + +- 风控规则收敛为单任务号码上限、可自定义非工作时间营销批量、10分钟客户端任务频控三项;新增全局/企业应用级规则页和真实后端编辑接口。应用级规则稳定覆盖全局,客户端频控直接统计同应用`sourceType=client`批次,排除CMPP、HTTP、通道测试和预检。 +- 删除企业应用`maxPhonesPerTask`字段和表单配置,不迁移测试应用旧值;重复号码、非法号码比例、黑名单比例和模板变量规则转`deleted`保留历史审计但不再生效。模板变量缺失/多传继续作为确定性提交拒绝。 +- 手机号码基础校验放宽为`^1\d{10}$`,不依赖号段更新。客户端/HTTP混合批次逐号码把非法号码、平台黑名单和企业应用黑名单记为`submit_failed/rejected`且金额0,合法号码照常冻结、入队;CMPP混合多目的提交对被拦截号码生成平台`REJECTD`失败回执,合法号码继续处理。 +- 短信审核页只返回待人工审核及人工处理记录,自动放行/拒绝不再混入;号码数量恢复设计基线的查看入口,真实接口仅返回手机号、归属地、运营商和短信状态并支持服务端搜索/分页。 +- 新待审核短信同步保存`reviewTaskId`,审核决定同时按短信直连和批次`riskTaskId`查找,修复审核任务已通过而短信仍`pending_review`。遵照要求不改写现存历史异常数据、不补发历史短信。 +- 发布前门禁阶段结果:Prisma format/generate/validate通过;风险审核+发送链定向2 suites / 108 tests通过,随后补充应用覆盖、号码分页与逐号码拦截用例;API全量26 suites / 338 tests、API TypeScript build、前端TypeScript/Vite生产构建、Gateway `go test ./...`/`go vet ./...`、依赖安全门禁通过。API保留既有Redis不可用容错告警与`--forceExit`提示,前端保留约1.95MB单chunk/584.70KB gzip提示。 diff --git a/gateway/internal/control/server.go b/gateway/internal/control/server.go index a19d9a6..0e1120d 100644 --- a/gateway/internal/control/server.go +++ b/gateway/internal/control/server.go @@ -86,6 +86,11 @@ type DownstreamRecoveryOverview struct { Statuses []inbound.DownstreamRecoveryStatus `json:"statuses"` } +type DisconnectDownstreamAccountCommand struct { + Account string `json:"account"` + Reason string `json:"reason"` +} + func Register(mux *http.ServeMux, server Server) { if server.HTTPClient == nil { server.HTTPClient = &http.Client{Timeout: 10 * time.Second} @@ -110,6 +115,30 @@ func Register(mux *http.ServeMux, server Server) { mux.HandleFunc("/downstream/recovery-candidates", server.handleDownstreamRecoveryCandidates) mux.HandleFunc("/downstream/recovery-statuses", server.handleDownstreamRecoveryStatuses) mux.HandleFunc("/downstream/recovery-overview", server.handleDownstreamRecoveryOverview) + mux.HandleFunc("/downstream/connections/disconnect", server.handleDisconnectDownstreamAccount) +} + +func (s Server) handleDisconnectDownstreamAccount(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var command DisconnectDownstreamAccountCommand + if err := json.NewDecoder(r.Body).Decode(&command); err != nil { + http.Error(w, fmt.Sprintf("invalid downstream disconnect command: %v", err), http.StatusBadRequest) + return + } + if command.Account == "" { + http.Error(w, "account is required", http.StatusBadRequest) + return + } + disconnected := inbound.DisconnectAccount(command.Account) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "account": command.Account, + "disconnected": disconnected, + "reason": command.Reason, + }) } func (s Server) handleConnectChannel(w http.ResponseWriter, r *http.Request) { diff --git a/gateway/internal/control/server_test.go b/gateway/internal/control/server_test.go index 5692ca4..69dc409 100644 --- a/gateway/internal/control/server_test.go +++ b/gateway/internal/control/server_test.go @@ -173,6 +173,32 @@ func TestDisconnectChannelStopsSupplierPool(t *testing.T) { } } +func TestDisconnectDownstreamAccountEndpointIsAccountScoped(t *testing.T) { + handler := handlerWithServer(Server{}) + resp := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPost, + "/downstream/connections/disconnect", + strings.NewReader(`{"account":"100001","reason":"application_disabled"}`), + ) + + handler.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("unexpected response status: %d body=%s", resp.Code, resp.Body.String()) + } + var payload struct { + Account string `json:"account"` + Disconnected int `json:"disconnected"` + } + if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.Account != "100001" || payload.Disconnected != 0 { + t.Fatalf("unexpected downstream disconnect response: %+v", payload) + } +} + func TestRecoveryCandidatesEndpointReturnsView(t *testing.T) { handler := handlerWithServer(Server{ RecoveryCandidates: func(context.Context) ([]inbound.DownstreamPresence, error) { diff --git a/gateway/internal/inbound/server.go b/gateway/internal/inbound/server.go index 8b5d061..73c223f 100644 --- a/gateway/internal/inbound/server.go +++ b/gateway/internal/inbound/server.go @@ -1136,6 +1136,28 @@ func onlineAccounts() []string { return accounts } +// DisconnectAccount closes every live downstream CMPP session for an +// application account. The normal connection-close callback removes registry +// and presence state and reports the disconnect to the API. +func DisconnectAccount(account string) int { + account = strings.TrimSpace(account) + if account == "" { + return 0 + } + downstreamRegistry.RLock() + sessions := make([]*downstreamSession, 0) + for _, session := range downstreamRegistry.byConn { + if session != nil && session.account == account && session.conn != nil { + sessions = append(sessions, session) + } + } + downstreamRegistry.RUnlock() + for _, session := range sessions { + session.conn.Close() + } + return len(sessions) +} + func PushReceipt(event DownstreamReceipt) (bool, error) { result, err := PushReceiptWithResult(event) return result.Sent, err diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index c497b55..3f99ed4 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -988,6 +988,36 @@ export type RiskReviewTask = { _count?: { messageRecords: number }; }; +export type RiskRuleItem = { + id: string; + tenantId?: string | null; + applicationId?: string | null; + code: 'MAX_PHONES_PER_TASK' | 'NON_WORKING_MARKETING_BULK' | 'TASK_CREATE_FREQUENCY'; + name: string; + description?: string | null; + metric: string; + thresholdValue: number; + action: 'block' | 'manual_review'; + status: 'active' | 'inactive'; + priority: number; + config?: { startTime?: string; endTime?: string; timeZone?: string } | null; + application?: { id: string; name: string; tenantId: string; tenant?: { id: string; name: string } } | null; + updatedAt: string; +}; + +export type RiskTaskMessagePage = { + items: Array<{ + id: string; + phoneNumber: string; + province?: string | null; + carrier?: string | null; + status: string; + }>; + total: number; + page: number; + pageSize: number; +}; + export type TenantAccount = { id: string; tenantId: string; @@ -1144,10 +1174,13 @@ export type EnterpriseApplication = { name: string; scene?: string | null; status: string; + disablingAt?: string | null; + autoDisableAt?: string | null; + disableReason?: string | null; + deactivation?: ApplicationDeactivationPreview | null; dailyLimit?: number | null; customerUnitPrice?: number | null; queuePriority?: 'normal' | 'priority' | string | null; - maxPhonesPerTask?: number | null; templateMismatchMode?: string | null; downstreamReceiptRetryEnabled?: boolean | null; downstreamUplinkRetryEnabled?: boolean | null; @@ -1284,6 +1317,20 @@ export type DownstreamDeliveryRecord = { }>; }; +export type ApplicationDeactivationPreview = { + status: string; + reason?: string | null; + disablingAt?: string | null; + autoDisableAt?: string | null; + awaitingSupplierReceipt: number; + waitingToSend: number; + awaitingClientAck: number; + retryableFailures: number; + pendingUplinks: number; + activeConnections: number; + totalOutstanding: number; +}; + export type BatchRequeueResponse = { total: number; successCount: number; @@ -1463,7 +1510,8 @@ export const adminApi = { changeTenantStatus: (id: string, status: string) => request(`/admin/tenants/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }), deleteTenant: (id: string) => request(`/admin/tenants/${id}`, { method: 'DELETE' }), - listUsers: (query: { tenantId?: string; roleCode?: string } = {}) => request(withQuery('/admin/users', query)), + listUsers: (query: { tenantId?: string; roleCode?: string; displayName?: string; login?: string; status?: string } = {}) => + request(withQuery('/admin/users', query)), createUser: (body: UserPayload) => request('/admin/users', { method: 'POST', body: JSON.stringify(body) }), updateUser: (id: string, body: Omit) => request(`/admin/users/${id}`, { method: 'PUT', body: JSON.stringify(body) }), changeUserStatus: (id: string, status: string, operatorId?: string) => @@ -1491,14 +1539,16 @@ export const adminApi = { request(withQuery('/admin/enterprise-applications', query)), getEnterpriseApplication: (id: string) => request(`/admin/enterprise-applications/${id}`), - createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; cmppApplicationExtension?: string; cmppAccessNumberFillEnabled?: boolean; cmppAccessNumberFillPrefix?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) => + createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; cmppApplicationExtension?: string; cmppAccessNumberFillEnabled?: boolean; cmppAccessNumberFillPrefix?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) => request('/admin/enterprise-applications', { method: 'POST', body: JSON.stringify(body) }), - updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; cmppApplicationExtension?: string; cmppAccessNumberFillEnabled?: boolean; cmppAccessNumberFillPrefix?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) => + updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; cmppApplicationExtension?: string; cmppAccessNumberFillEnabled?: boolean; cmppAccessNumberFillPrefix?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) => request(`/admin/enterprise-applications/${id}`, { method: 'PUT', body: JSON.stringify(body) }), - changeApplicationStatus: (id: string, status: string, reason?: string) => + getApplicationDeactivationPreview: (id: string) => + request(`/admin/enterprise-applications/${id}/deactivation-preview`), + changeApplicationStatus: (id: string, status: string, reason?: string, force = false) => request(`/admin/enterprise-applications/${id}/status`, { method: 'POST', - body: JSON.stringify({ status, reason }), + body: JSON.stringify({ status, reason, force }), }), listApplicationConnections: (applicationId: string) => request(`/admin/enterprise-applications/${applicationId}/connections`), @@ -1706,6 +1756,26 @@ export const adminApi = { batchRequeueDownstreamDeliveries: (ids: string[]) => request('/admin/operations/downstream-deliveries/requeue', { method: 'POST', body: JSON.stringify({ ids }) }), listRiskReviewTasks: (query: { tenantId?: string; status?: string } = {}) => request(withQuery('/admin/risk-review/tasks', query)), + listRiskRules: (applicationId?: string) => + request(withQuery('/admin/risk-review/rules', { applicationId })), + createRiskRule: (body: { + applicationId?: string; + code: RiskRuleItem['code']; + thresholdValue: number; + action: RiskRuleItem['action']; + status: RiskRuleItem['status']; + priority?: number; + config?: RiskRuleItem['config']; + }) => request('/admin/risk-review/rules', { method: 'POST', body: JSON.stringify(body) }), + updateRiskRule: (id: string, body: { + thresholdValue?: number; + action?: RiskRuleItem['action']; + status?: RiskRuleItem['status']; + priority?: number; + config?: RiskRuleItem['config']; + }) => request(`/admin/risk-review/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + listRiskReviewTaskMessages: (id: string, query: { phone?: string; page?: number; pageSize?: number } = {}) => + request(withQuery(`/admin/risk-review/tasks/${id}/messages`, query)), approveRiskReviewTask: (id: string, reason?: string) => request(`/admin/risk-review/tasks/${id}/approve`, { method: 'POST', body: JSON.stringify({ reason }) }), rejectRiskReviewTask: (id: string, reason?: string) => @@ -1778,8 +1848,10 @@ export const clientApi = { getCaptcha: () => request('/client/auth/captcha'), login: (body: { login: string; password: string; captchaId: string; captchaText: string }) => request('/client/auth/login', { method: 'POST', body: JSON.stringify(body) }), - listUsers: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request('/client/users', { tenantId }), + listUsers: ( + query: { displayName?: string; login?: string; status?: string } = {}, + tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID, + ) => request(withQuery('/client/users', query), { tenantId }), createUser: (body: UserPayload, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/users', { method: 'POST', tenantId, body: JSON.stringify(body) }), updateUser: (id: string, body: Omit, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => diff --git a/src/apps/admin/AdminEnterpriseApplicationsPage.tsx b/src/apps/admin/AdminEnterpriseApplicationsPage.tsx index 2604b58..2fbef49 100644 --- a/src/apps/admin/AdminEnterpriseApplicationsPage.tsx +++ b/src/apps/admin/AdminEnterpriseApplicationsPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Copy, Edit3, Plus, Search, Settings2, Trash2 } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui'; -import { adminApi, type ApplicationCmppParams, type CmppDownstreamConnection, type EnterpriseApplication, type HttpApiConfigResponse, type TenantOption } from '@/api/adminApi'; +import { adminApi, type ApplicationCmppParams, type ApplicationDeactivationPreview, type CmppDownstreamConnection, type EnterpriseApplication, type HttpApiConfigResponse, type TenantOption } from '@/api/adminApi'; import { formatDateTime } from '@/utils/dateTime'; import { formatAmount, moneyUnitsToYuan } from '@/utils/currency'; import { copyText } from '@/utils/clipboard'; @@ -14,7 +14,9 @@ type SmsApp = { name: string; enterprise: string; appId: string; + status: string; enabled: boolean; + deactivation?: ApplicationDeactivationPreview | null; sentToday: number; deliveryRate: number; unitPrice: number; @@ -51,8 +53,22 @@ type CmppConnection = { pendingWindow: number; }; -function enabledTag(enabled: boolean) { - return {enabled ? '启用' : '停用'}; +function applicationStatusTag(app: SmsApp) { + if (app.status === 'disabling') { + const detail = app.deactivation; + const title = [ + detail?.reason || '等待未完成回执清算', + `等待供应商回执:${detail?.awaitingSupplierReceipt ?? 0}条`, + `等待推送:${detail?.waitingToSend ?? 0}条`, + `等待客户端确认:${detail?.awaitingClientAck ?? 0}条`, + `可重试失败:${detail?.retryableFailures ?? 0}条`, + `待推送上行:${detail?.pendingUplinks ?? 0}条`, + `进入停用中:${formatDateTime(detail?.disablingAt)}`, + `自动停用时间:${formatDateTime(detail?.autoDisableAt)}`, + ].join('\n'); + return 停用中; + } + return {app.status === 'active' ? '启用' : '停用'}; } function ConfirmModal({ message, danger, onCancel, onConfirm }: { message: string; danger?: boolean; onCancel: () => void; onConfirm: () => void }) { @@ -73,6 +89,51 @@ function ConfirmModal({ message, danger, onCancel, onConfirm }: { message: strin ); } +function DeactivateApplicationModal({ + app, + preview, + onCancel, + onConfirm, +}: { + app: SmsApp; + preview: ApplicationDeactivationPreview; + onCancel: () => void; + onConfirm: (mode: 'wait' | 'force') => void; +}) { + const hasOutstanding = preview.totalOutstanding > 0; + return ( + + + {hasOutstanding ? : null} + + + )} + onClose={onCancel} + open + title={`停用应用“${app.name}”`} + > + {hasOutstanding ? ( +
+

该应用还有 {preview.totalOutstanding} 项回执或上行投递义务尚未清算。

+
+
等待供应商回执{preview.awaitingSupplierReceipt}
+
等待推送{preview.waitingToSend}
+
等待客户端确认{preview.awaitingClientAck}
+
可重试失败{preview.retryableFailures}
+
待推送上行{preview.pendingUplinks}
+
当前CMPP连接{preview.activeConnections}
+
+

“等待回执后停用”会立即停止接收新短信,清算完成后自动停用;最长等待72小时。“强制停用”会立即断开全部连接并放弃剩余投递。

+
+ ) :

该应用没有待清算数据,将立即停用并断开全部 CMPP 客户端连接。

} +
+ ); +} + function AddApplicationModal({ tenants, loading, @@ -272,10 +333,11 @@ export function AdminEnterpriseApplicationsPage() { const [tenantsLoading, setTenantsLoading] = useState(false); const [selectedTenantId, setSelectedTenantId] = useState(''); const [confirmAction, setConfirmAction] = useState< - | { action: 'toggle'; id: string; name: string; enabled: boolean } + | { action: 'enable'; id: string; name: string } | { action: 'delete'; id: string; name: string } | null >(null); + const [deactivateAction, setDeactivateAction] = useState<{ app: SmsApp; preview: ApplicationDeactivationPreview } | null>(null); async function loadSmsApps(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, status: appliedStatus }) { try { @@ -312,11 +374,33 @@ export function AdminEnterpriseApplicationsPage() { } } - async function confirmToggle(id: string) { - const app = smsApps.find((item) => item.id === id); - if (app) { - await adminApi.changeApplicationStatus(id, app.enabled ? 'disabled' : 'active', '运营端企业应用管理'); + async function confirmEnable(id: string) { + await adminApi.changeApplicationStatus(id, 'active', '运营端恢复启用企业应用'); + await loadSmsApps(); + } + + async function openDeactivate(app: SmsApp) { + try { + setDeactivateAction({ app, preview: await adminApi.getApplicationDeactivationPreview(app.id) }); + setError(''); + } catch (failure) { + setError(failure instanceof Error ? failure.message : '停用影响检查失败'); + } + } + + async function confirmDeactivate(mode: 'wait' | 'force') { + if (!deactivateAction) return; + try { + await adminApi.changeApplicationStatus( + deactivateAction.app.id, + mode === 'wait' ? 'disabling' : 'disabled', + mode === 'wait' ? '运营端选择等待回执后停用' : '运营端选择强制停用并放弃剩余回执', + mode === 'force', + ); + setDeactivateAction(null); await loadSmsApps(); + } catch (failure) { + setError(failure instanceof Error ? failure.message : '企业应用停用失败'); } } @@ -329,8 +413,8 @@ export function AdminEnterpriseApplicationsPage() { if (!confirmAction) { return; } - if (confirmAction.action === 'toggle') { - await confirmToggle(confirmAction.id); + if (confirmAction.action === 'enable') { + await confirmEnable(confirmAction.id); } else { await confirmDelete(confirmAction.id); } @@ -360,7 +444,7 @@ export function AdminEnterpriseApplicationsPage() { const filteredSmsApps = useMemo( () => smsApps.filter((item) => (!appliedEnterpriseKeyword || item.enterprise.includes(appliedEnterpriseKeyword)) && (!appliedApplicationKeyword || item.name.includes(appliedApplicationKeyword)) - && (appliedStatus === 'all' || (appliedStatus === 'active' ? item.enabled : !item.enabled))), + && (appliedStatus === 'all' || item.status === appliedStatus)), [appliedApplicationKeyword, appliedEnterpriseKeyword, appliedStatus, smsApps], ); @@ -390,7 +474,7 @@ export function AdminEnterpriseApplicationsPage() { ), }, - { key: 'enabled', title: '状态', width: '130px', render: (record) => enabledTag(record.enabled) }, + { key: 'enabled', title: '状态', width: '130px', render: (record) => applicationStatusTag(record) }, { key: 'actions', title: '操作', @@ -399,8 +483,8 @@ export function AdminEnterpriseApplicationsPage() { render: (record) => (
-
@@ -436,7 +520,7 @@ export function AdminEnterpriseApplicationsPage() { setApplicationId(event.target.value)} + options={[ + { label: '全部全局规则', value: '' }, + ...applications.map((application) => ({ + label: `${application.tenant?.name ?? '未命名企业'} · ${application.name}`, + value: application.id, + })), + ]} + value={applicationId} + /> + +
+ {editor ? } + onClose={() => setEditor(null)} + open + size="xl" + title={editor.id ? '编辑风控规则' : '新增企业应用级覆盖'} + > +
+ {!editor.id ? { + const code = event.target.value as RiskRuleItem['code']; + const globalRule = rules.find((rule) => !rule.applicationId && rule.code === code); + setEditor({ ...editor, code, thresholdValue: String(globalRule?.thresholdValue ?? editor.thresholdValue), action: globalRule?.action ?? editor.action }); + }} + options={definitions.filter((item) => !existingCodes.has(item.code) || item.code === editor.code).map((item) => ({ label: item.label, value: item.code }))} + value={editor.code} + /> : item.code === editor.code)?.label ?? editor.code} />} + item.code === editor.code)?.unit ?? ''})`} min="0" onChange={(event) => setEditor({ ...editor, thresholdValue: event.target.value })} type="number" value={editor.thresholdValue} /> + setEditor({ ...editor, status: event.target.value as RiskRuleItem['status'] })} options={[{ label: '启用', value: 'active' }, { label: '停用', value: 'inactive' }]} value={editor.status} /> + setEditor({ ...editor, priority: event.target.value })} type="number" value={editor.priority} /> + {editor.code === 'NON_WORKING_MARKETING_BULK' ? <> + setEditor({ ...editor, startTime: event.target.value })} type="time" value={editor.startTime} /> + setEditor({ ...editor, endTime: event.target.value })} type="time" value={editor.endTime} /> + : null} +
+
: null} + + ); +} diff --git a/src/apps/admin/AdminSmsApplicationFormPage.tsx b/src/apps/admin/AdminSmsApplicationFormPage.tsx index 77b0eef..06f13ad 100644 --- a/src/apps/admin/AdminSmsApplicationFormPage.tsx +++ b/src/apps/admin/AdminSmsApplicationFormPage.tsx @@ -41,7 +41,6 @@ export function AdminSmsApplicationFormPage() { const [interfaceEnabled, setInterfaceEnabled] = useState(true); const [interfaceType, setInterfaceType] = useState('cmpp20'); const [cmppMaxConnections, setCmppMaxConnections] = useState('1'); - const [phoneDailyLimit, setPhoneDailyLimit] = useState('10000'); const [mismatchPolicy, setMismatchPolicy] = useState('manual_review'); const [downstreamReceiptRetryEnabled, setDownstreamReceiptRetryEnabled] = useState(true); const [downstreamUplinkRetryEnabled, setDownstreamUplinkRetryEnabled] = useState(true); @@ -82,7 +81,6 @@ export function AdminSmsApplicationFormPage() { setInterfaceEnabled(true); setInterfaceType('cmpp20'); setCmppMaxConnections('1'); - setPhoneDailyLimit('10000'); setMismatchPolicy('manual_review'); setDownstreamReceiptRetryEnabled(true); setDownstreamUplinkRetryEnabled(true); @@ -156,7 +154,6 @@ export function AdminSmsApplicationFormPage() { setInterfaceEnabled(application.interfaceEnabled !== false); setInterfaceType('cmpp20'); setCmppMaxConnections(String(application.cmppMaxConnections ?? 1)); - setPhoneDailyLimit(application.maxPhonesPerTask ? String(application.maxPhonesPerTask) : ''); setMismatchPolicy(application.templateMismatchMode ?? 'reject'); setDownstreamReceiptRetryEnabled(application.downstreamReceiptRetryEnabled !== false); setDownstreamUplinkRetryEnabled(application.downstreamUplinkRetryEnabled !== false); @@ -223,7 +220,6 @@ export function AdminSmsApplicationFormPage() { interfaceEnabled, interfaceType, cmppMaxConnections: Number(cmppMaxConnections) || 1, - maxPhonesPerTask: Number(phoneDailyLimit) || undefined, templateMismatchMode: mismatchPolicy, downstreamReceiptRetryEnabled, downstreamUplinkRetryEnabled, @@ -305,7 +301,6 @@ export function AdminSmsApplicationFormPage() { 优先队列会在发送调度中插队处理,但仍必须经过模板、签名、余额、通道组和通道限速校验。 - setPhoneDailyLimit(event.target.value)} placeholder="10000" required value={phoneDailyLimit} /> setKeyword(event.target.value)} placeholder="请输入短信内容或审核原因" value={keyword} /> - setDate(event.target.value)} placeholder="yyyy-mm-dd" prefix={} value={date} /> + setDate(event.target.value)} placeholder="yyyy-mm-dd" prefix={} value={date} />
@@ -191,6 +222,37 @@ export function AdminSmsAuditPage() {
: null} + {phoneTarget ? setPhoneTarget(null)}>关闭} onClose={() => setPhoneTarget(null)} open size="xl" title={`号码列表 · ${phoneTarget.taskNo}`}> +
+
+ setPhoneKeyword(event.target.value)} placeholder="输入完整或部分号码" value={phoneKeyword} /> +
{item.phoneNumber} }, + { key: 'province', title: '号码归属地', render: (item) => item.province || '-' }, + { key: 'carrier', title: '运营商', render: (item) => item.carrier || '-' }, + { key: 'status', title: '短信记录状态', render: (item) => {messageStatusLabel(item.status)} }, + ]} + data={phoneData.items} + emptyText="暂无号码记录" + rowKey="id" + /> + = phoneData.total} + onNext={() => setPhonePage((current) => current + 1)} + onPageChange={setPhonePage} + onPrevious={() => setPhonePage((current) => Math.max(1, current - 1))} + page={phonePage} + previousDisabled={phonePage <= 1} + total={phoneData.total} + totalPages={Math.max(1, Math.ceil(phoneData.total / phonePageSize))} + /> + + : null} + diff --git a/src/apps/admin/AdminUsersPage.tsx b/src/apps/admin/AdminUsersPage.tsx index bcb1029..6f9f8ca 100644 --- a/src/apps/admin/AdminUsersPage.tsx +++ b/src/apps/admin/AdminUsersPage.tsx @@ -21,6 +21,14 @@ type ConfirmAction = { user: ManagedUser; }; +type UserFilters = { + displayName: string; + login: string; + tenantId: string; + roleCode: string; + status: string; +}; + const emptyForm: UserForm = { tenantId: '', displayName: '', @@ -37,6 +45,14 @@ const roleLabel: Record = { enterprise_admin: '企业管理员', }; +const emptyFilters: UserFilters = { + displayName: '', + login: '', + tenantId: '', + roleCode: '', + status: '', +}; + function toForm(user?: ManagedUser): UserForm { const roleCode = user?.roles[0]?.role.code === 'enterprise_admin' ? 'enterprise_admin' : 'platform_admin'; return user ? { @@ -62,8 +78,8 @@ export function AdminUsersPage() { const session = readSession('admin'); const [users, setUsers] = useState([]); const [tenants, setTenants] = useState([]); - const [keyword, setKeyword] = useState(''); - const [appliedKeyword, setAppliedKeyword] = useState(''); + const [filters, setFilters] = useState(emptyFilters); + const [appliedFilters, setAppliedFilters] = useState(emptyFilters); const [editingUser, setEditingUser] = useState(null); const [creating, setCreating] = useState(false); const [form, setForm] = useState(emptyForm); @@ -71,35 +87,63 @@ export function AdminUsersPage() { const [newPassword, setNewPassword] = useState(''); const [showInitialPassword, setShowInitialPassword] = useState(false); const [confirmAction, setConfirmAction] = useState(null); + const [confirmError, setConfirmError] = useState(''); + const [confirming, setConfirming] = useState(false); const [error, setError] = useState(''); + const [formError, setFormError] = useState(''); const [saving, setSaving] = useState(false); + const [querying, setQuerying] = useState(false); - async function load() { - const [nextUsers, nextTenants] = await Promise.all([adminApi.listUsers(), adminApi.listTenants()]); - setUsers(nextUsers); - setTenants(nextTenants); + async function loadUsers(query: UserFilters = appliedFilters) { + setUsers(await adminApi.listUsers(query)); } useEffect(() => { - void load().catch((err) => setError(err instanceof Error ? err.message : '加载用户失败')); + void Promise.all([adminApi.listUsers(), adminApi.listTenants()]) + .then(([nextUsers, nextTenants]) => { + setUsers(nextUsers); + setTenants(nextTenants); + }) + .catch((err) => setError(err instanceof Error ? err.message : '加载用户失败')); }, []); - const filteredUsers = useMemo(() => { - const value = appliedKeyword.trim().toLowerCase(); - return users.filter((user) => { - const target = `${user.displayName} ${user.username} ${user.email ?? ''} ${user.phone ?? ''} ${user.tenant?.name ?? ''} ${roleLabel[user.roles[0]?.role.code] ?? ''}`.toLowerCase(); - return !value || target.includes(value); - }); - }, [appliedKeyword, users]); + function updateFilter(key: Key, value: UserFilters[Key]) { + setFilters((current) => ({ ...current, [key]: value })); + } + + async function queryUsers(nextFilters = filters) { + const next = { + ...nextFilters, + displayName: nextFilters.displayName.trim(), + login: nextFilters.login.trim(), + }; + setQuerying(true); + setError(''); + try { + await loadUsers(next); + setAppliedFilters(next); + } catch (failure) { + setError(failure instanceof Error ? failure.message : '查询用户失败'); + } finally { + setQuerying(false); + } + } + + function openConfirm(action: ConfirmAction) { + setConfirmError(''); + setConfirmAction(action); + } function openCreate() { setForm({ ...emptyForm, password: generateInitialPassword(), tenantId: tenants[0]?.id ?? '' }); + setFormError(''); setShowInitialPassword(false); setCreating(true); } function openEdit(user: ManagedUser) { setForm(toForm(user)); + setFormError(''); setEditingUser(user); } @@ -115,11 +159,11 @@ export function AdminUsersPage() { async function saveUser() { if (!form.displayName.trim() || (!form.email.trim() && !form.phone.trim()) || (creating && form.password.length < 6) || (form.roleCode === 'enterprise_admin' && !form.tenantId)) { - setError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位,企业管理员必须关联企业'); + setFormError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位,企业管理员必须关联企业'); return; } setSaving(true); - setError(''); + setFormError(''); const body: UserPayload = { tenantId: form.roleCode === 'enterprise_admin' ? form.tenantId : null, username: form.username || form.email || form.phone, @@ -138,9 +182,9 @@ export function AdminUsersPage() { } setCreating(false); setEditingUser(null); - await load(); + await loadUsers(); } catch (failure) { - setError(failure instanceof Error ? failure.message : '用户保存失败'); + setFormError(failure instanceof Error ? failure.message : '用户保存失败'); } finally { setSaving(false); } @@ -148,13 +192,28 @@ export function AdminUsersPage() { async function runConfirm() { if (!confirmAction) return; - if (confirmAction.type === 'delete') { - await adminApi.deleteUser(confirmAction.user.id, session?.user.id); - } else { - await adminApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id); + setConfirming(true); + setConfirmError(''); + try { + if (confirmAction.type === 'delete') { + await adminApi.deleteUser(confirmAction.user.id, session?.user.id); + } else { + await adminApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id); + } + } catch (failure) { + const detail = failure instanceof Error ? failure.message : '用户操作失败'; + setConfirmError(`${confirmAction.type === 'delete' ? '删除' : '状态变更'}失败:${detail}`); + setConfirming(false); + return; } setConfirmAction(null); - await load(); + try { + await loadUsers(); + } catch (failure) { + setError(failure instanceof Error ? failure.message : '用户列表刷新失败'); + } finally { + setConfirming(false); + } } async function savePassword() { @@ -180,10 +239,10 @@ export function AdminUsersPage() {
- - +
), }, @@ -199,16 +258,37 @@ export function AdminUsersPage() {
- setKeyword(event.target.value)} placeholder="搜索姓名、邮箱、手机号、角色或企业" prefix={} value={keyword} /> +
+ updateFilter('displayName', event.target.value)} placeholder="请输入用户姓名" value={filters.displayName} /> + updateFilter('login', event.target.value)} placeholder="用户名、邮箱或手机号" value={filters.login} /> + updateFilter('roleCode', event.target.value)} + options={[{ label: '全部角色', value: '' }, { label: '平台管理员', value: 'platform_admin' }, { label: '企业管理员', value: 'enterprise_admin' }]} + value={filters.roleCode} + /> +
+
{(creating || editingUser) ? ( @@ -263,6 +343,7 @@ export function AdminUsersPage() { + {formError ?

{formError}

: null} ) : null} @@ -276,8 +357,9 @@ export function AdminUsersPage() { ) : null} {confirmAction ? ( - } onClose={() => setConfirmAction(null)} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}> + } onClose={() => { if (!confirming) setConfirmAction(null); }} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}>

{confirmAction.type === 'delete' ? `确认删除用户 ${confirmAction.user.displayName}?` : `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}?`}

+ {confirmError ?

{confirmError}

: null}
) : null} diff --git a/src/apps/client/ClientUsersPage.css b/src/apps/client/ClientUsersPage.css index 7c09c17..4eda973 100644 --- a/src/apps/client/ClientUsersPage.css +++ b/src/apps/client/ClientUsersPage.css @@ -1,4 +1,31 @@ +.client-user-filter { + align-items: end; + display: grid; + gap: var(--space-4); + grid-template-columns: repeat(3, minmax(180px, 1fr)) auto; + max-width: none; +} + +.client-user-filter__actions { + display: flex; + gap: var(--space-3); +} + @media (max-width: 780px) { + .client-user-filter { + grid-template-columns: 1fr; + } + + .client-user-filter__actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .client-user-filter__actions .ui-button { + justify-content: center; + width: 100%; + } + .client-users-table-card .client-user-actions { display: grid; gap: var(--space-2); diff --git a/src/apps/client/ClientUsersPage.tsx b/src/apps/client/ClientUsersPage.tsx index 63c17cb..765570a 100644 --- a/src/apps/client/ClientUsersPage.tsx +++ b/src/apps/client/ClientUsersPage.tsx @@ -20,6 +20,12 @@ type ConfirmAction = { user: ManagedUser; }; +type UserFilters = { + displayName: string; + login: string; + status: string; +}; + const emptyForm: UserForm = { displayName: '', username: '', @@ -29,6 +35,12 @@ const emptyForm: UserForm = { password: '', }; +const emptyFilters: UserFilters = { + displayName: '', + login: '', + status: '', +}; + function toForm(user?: ManagedUser): UserForm { return user ? { displayName: user.displayName, @@ -44,35 +56,63 @@ export function ClientUsersPage() { const session = readSession('client'); const tenantId = session?.user.tenantId ?? undefined; const [users, setUsers] = useState([]); - const [keyword, setKeyword] = useState(''); + const [filters, setFilters] = useState(emptyFilters); + const [appliedFilters, setAppliedFilters] = useState(emptyFilters); const [editingUser, setEditingUser] = useState(null); const [creating, setCreating] = useState(false); const [form, setForm] = useState(emptyForm); const [passwordUser, setPasswordUser] = useState(null); const [newPassword, setNewPassword] = useState(''); const [confirmAction, setConfirmAction] = useState(null); + const [confirmError, setConfirmError] = useState(''); + const [confirming, setConfirming] = useState(false); const [error, setError] = useState(''); + const [formError, setFormError] = useState(''); const [saving, setSaving] = useState(false); + const [querying, setQuerying] = useState(false); - async function load() { + async function loadUsers(query: UserFilters = appliedFilters) { if (!tenantId) return; - setUsers(await clientApi.listUsers(tenantId)); + setUsers(await clientApi.listUsers(query, tenantId)); } useEffect(() => { - void load().catch((err) => setError(err instanceof Error ? err.message : '加载用户失败')); + if (!tenantId) return; + void clientApi.listUsers({}, tenantId) + .then(setUsers) + .catch((err) => setError(err instanceof Error ? err.message : '加载用户失败')); }, [tenantId]); - const filteredUsers = useMemo(() => { - const value = keyword.trim().toLowerCase(); - return users.filter((item) => { - const target = `${item.displayName} ${item.email ?? ''} ${item.phone ?? ''}`.toLowerCase(); - return !value || target.includes(value); - }); - }, [keyword, users]); + function updateFilter(key: Key, value: UserFilters[Key]) { + setFilters((current) => ({ ...current, [key]: value })); + } + + async function queryUsers(nextFilters = filters) { + const next = { + ...nextFilters, + displayName: nextFilters.displayName.trim(), + login: nextFilters.login.trim(), + }; + setQuerying(true); + setError(''); + try { + await loadUsers(next); + setAppliedFilters(next); + } catch (failure) { + setError(failure instanceof Error ? failure.message : '查询用户失败'); + } finally { + setQuerying(false); + } + } + + function openConfirm(action: ConfirmAction) { + setConfirmError(''); + setConfirmAction(action); + } function openEditor(user?: ManagedUser) { setForm(toForm(user)); + setFormError(''); setEditingUser(user ?? null); setCreating(!user); } @@ -83,10 +123,11 @@ export function ClientUsersPage() { async function saveUser() { if (!form.displayName.trim() || (!form.email.trim() && !form.phone.trim()) || (creating && form.password.length < 6)) { - setError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位'); + setFormError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位'); return; } setSaving(true); + setFormError(''); const body: UserPayload = { displayName: form.displayName, username: form.username || form.email || form.phone, @@ -104,9 +145,9 @@ export function ClientUsersPage() { } setCreating(false); setEditingUser(null); - await load(); + await loadUsers(); } catch (failure) { - setError(failure instanceof Error ? failure.message : '用户保存失败'); + setFormError(failure instanceof Error ? failure.message : '用户保存失败'); } finally { setSaving(false); } @@ -114,13 +155,28 @@ export function ClientUsersPage() { async function runConfirm() { if (!confirmAction) return; - if (confirmAction.type === 'delete') { - await clientApi.deleteUser(confirmAction.user.id, session?.user.id, tenantId); - } else { - await clientApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id, tenantId); + setConfirming(true); + setConfirmError(''); + try { + if (confirmAction.type === 'delete') { + await clientApi.deleteUser(confirmAction.user.id, session?.user.id, tenantId); + } else { + await clientApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id, tenantId); + } + } catch (failure) { + const detail = failure instanceof Error ? failure.message : '用户操作失败'; + setConfirmError(`${confirmAction.type === 'delete' ? '删除' : '状态变更'}失败:${detail}`); + setConfirming(false); + return; } setConfirmAction(null); - await load(); + try { + await loadUsers(); + } catch (failure) { + setError(failure instanceof Error ? failure.message : '用户列表刷新失败'); + } finally { + setConfirming(false); + } } async function savePassword() { @@ -145,8 +201,8 @@ export function ClientUsersPage() {
- - + +
), }, @@ -162,12 +218,23 @@ export function ClientUsersPage() { -
- setKeyword(event.target.value)} placeholder="搜索用户名、邮箱或手机号" prefix={} value={keyword} /> +
+ updateFilter('displayName', event.target.value)} placeholder="请输入用户姓名" value={filters.displayName} /> + updateFilter('login', event.target.value)} placeholder="用户名、邮箱或手机号" value={filters.login} /> +
+
{(creating || editingUser) ? ( @@ -185,6 +252,7 @@ export function ClientUsersPage() { updateField('username', event.target.value)} value={form.username} /> {creating ? updateField('password', event.target.value)} type="password" value={form.password} /> : null}