diff --git a/api/src/deletion-governance/deletion-governance.service.spec.ts b/api/src/deletion-governance/deletion-governance.service.spec.ts index 08dd530..567bc01 100644 --- a/api/src/deletion-governance/deletion-governance.service.spec.ts +++ b/api/src/deletion-governance/deletion-governance.service.spec.ts @@ -80,7 +80,6 @@ describe('DeletionGovernanceService', () => { prisma.smsTemplate.findFirst.mockResolvedValue({ id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now, tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: { name: '示例签名' }, - sendTasks: [], batchTasks: [], }); const result = await service.preflight('template', 'template-1', 'tenant-1'); @@ -90,11 +89,9 @@ describe('DeletionGovernanceService', () => { expect(result.identity.tenant).toBe('示例企业'); expect(result.requiredSelections).toEqual([]); expect(prisma.smsTemplate.findFirst).toHaveBeenCalledWith(expect.objectContaining({ - include: expect.objectContaining({ - sendTasks: expect.objectContaining({ where: { status: { notIn: ['approved', 'rejected'] } } }), - batchTasks: expect.objectContaining({ where: { status: { notIn: expect.arrayContaining(['finished', 'canceled']) } } }), - }), + include: expect.not.objectContaining({ sendTasks: expect.anything(), batchTasks: expect.anything() }), })); + expect(result.impacts).toContain('已创建任务继续使用保存的内容快照'); }); it('turns signature dependencies into mandatory cascade selections', async () => { @@ -171,11 +168,10 @@ describe('DeletionGovernanceService', () => { prisma.smsTemplate.findFirst.mockResolvedValue({ id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now, tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: null, - sendTasks: [], batchTasks: [], }); tx.operationLog.findFirst.mockResolvedValue(null); tx.smsTemplate.findFirst.mockResolvedValue({ - id: 'template-1', tenantId: 'tenant-1', sendTasks: [], batchTasks: [], + id: 'template-1', tenantId: 'tenant-1', }); tx.smsTemplate.updateMany.mockResolvedValue({ count: 1 }); tx.operationLog.create.mockResolvedValue({ id: 'operation-1' }); @@ -185,6 +181,10 @@ describe('DeletionGovernanceService', () => { }, 'tenant-1'); expect(result).toEqual({ operationId: 'operation-1', status: 'deleted', replayed: false }); + expect(tx.smsTemplate.findFirst).toHaveBeenCalledWith({ + where: { id: 'template-1', tenantId: 'tenant-1' }, + select: { id: true, tenantId: true }, + }); expect(tx.smsTemplate.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: { auditStatus: 'deleted' } })); expect(tx.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'governance.delete', userId: 'user-1' }) })); }); @@ -241,7 +241,6 @@ describe('DeletionGovernanceService', () => { prisma.smsTemplate.findFirst.mockResolvedValue({ id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now, tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: null, - sendTasks: [], batchTasks: [], }); await expect(service.delete('template', 'template-1', { expectedUpdatedAt: '2026-07-20T10:00:00.000Z', idempotencyKey: 'stale', reason: '测试版本冲突', diff --git a/api/src/deletion-governance/deletion-governance.service.ts b/api/src/deletion-governance/deletion-governance.service.ts index 21ed889..b25aa1b 100644 --- a/api/src/deletion-governance/deletion-governance.service.ts +++ b/api/src/deletion-governance/deletion-governance.service.ts @@ -203,19 +203,13 @@ export class DeletionGovernanceService { where: { id, ...(tenantId ? { tenantId } : {}) }, include: { tenant: { select: { name: true } }, application: { select: { name: true } }, signature: { select: { name: true } }, - sendTasks: { where: { status: { notIn: TERMINAL_SEND_TASK_STATUSES } }, select: { id: true, status: true } }, - batchTasks: { where: { status: { notIn: TERMINAL_BATCH_TASK_STATUSES } }, select: { id: true, status: true } }, }, }); if (!item) throw new NotFoundException('模板不存在或无权访问'); - const dependencies: Dependency[] = [ - dep('send_tasks', '未结束发送任务', item.sendTasks.map((row) => `${row.id}(${row.status})`)), - dep('batch_tasks', '未结束批量任务', item.batchTasks.map((row) => `${row.id}(${row.status})`)), - ]; return buildPreflight('template', item.id, item.updatedAt, { name: item.name, id: item.id, tenant: item.tenant.name, application: item.application.name, signature: item.signature?.name ?? '未绑定', - }, item.auditStatus, dependencies, ['删除后不能用于新发送任务', '历史消息、计费和审核记录继续保留']); + }, item.auditStatus, [], ['删除后不能用于新发送任务', '已创建任务继续使用保存的内容快照', '历史消息、计费和审核记录继续保留']); } private async prepareChannelDeletion(tx: Prisma.TransactionClient, id: string, body: DeleteTargetDto, reason?: string) { @@ -305,17 +299,9 @@ export class DeletionGovernanceService { private async prepareTemplateDeletion(tx: Prisma.TransactionClient, id: string, tenantId?: string) { const item = await tx.smsTemplate.findFirst({ where: { id, ...(tenantId ? { tenantId } : {}) }, - include: { - sendTasks: { where: { status: { notIn: TERMINAL_SEND_TASK_STATUSES } }, select: { id: true } }, - batchTasks: { where: { status: { notIn: TERMINAL_BATCH_TASK_STATUSES } }, select: { id: true } }, - }, + select: { id: true, tenantId: true }, }); if (!item) throw new NotFoundException('模板不存在或无权访问'); - const blockers = [ - item.sendTasks.length ? `未结束发送任务共 ${item.sendTasks.length} 项,请先解除或完成` : '', - item.batchTasks.length ? `未结束批量任务共 ${item.batchTasks.length} 项,请先解除或完成` : '', - ].filter(Boolean); - if (blockers.length) throw new ConflictException({ message: '当前对象不允许删除', blockedReasons: blockers }); return { tenantId: item.tenantId, affectedSignatureIds: [] as string[], detail: {} }; } diff --git a/api/src/send-chain/send-batch-entry.service.ts b/api/src/send-chain/send-batch-entry.service.ts index f0d09d5..2231cba 100644 --- a/api/src/send-chain/send-batch-entry.service.ts +++ b/api/src/send-chain/send-batch-entry.service.ts @@ -14,7 +14,7 @@ import { PhoneFrequencyService } from '../risk-review/phone-frequency.service'; import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts'; import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers'; import { detectDrainageContent } from './drainage-content-detection'; -import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service'; +import type { SendResourceValidationOptions, SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service'; /** * R9 batchEntry implementation. Cross-method calls return through the stable SendChainService seam. @@ -469,7 +469,12 @@ async classifyRejectedPhones(tenantId: string, applicationId: string | undefined return rejected; } -async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) { +async validateSendResources( + tenantId: string, + applicationId?: string, + templateId?: string, + options: SendResourceValidationOptions = {}, + ) { const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } }); if (!tenant || tenant.status !== 'active') { throw new BadRequestException('企业客户不存在或已停用'); @@ -494,7 +499,12 @@ async validateSendResources(tenantId: string, applicationId?: string, templateId where: { id: templateId }, include: { signature: true }, }); - if (!template || template.tenantId !== tenantId || template.applicationId !== applicationId || template.auditStatus !== 'approved') { + const templateBelongsToApplication = template + && template.tenantId === tenantId + && template.applicationId === applicationId; + // 定时任务在创建时已通过模板审核并持久化内容快照;后续删除模板只能阻止新任务, + // 不应追溯性地使已接受任务失败。但仍校验租户、应用归属和签名当前安全状态。 + if (!templateBelongsToApplication || (!options.usePersistedTemplateSnapshot && template.auditStatus !== 'approved')) { throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用'); } if (!template.signature || template.signature.auditStatus !== 'approved') { diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index 8be7512..c35e9c5 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -666,6 +666,22 @@ describe('SendChainService', () => { expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); }); + it('rejects a new task that selects a deleted template', async () => { + const { service, prisma } = createService(); + prisma.smsTemplate.findUnique.mockResolvedValue({ + id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', + content: 'hello', auditStatus: 'deleted', + signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' }, + }); + + await expect(service.createBatchTask({ + tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', + content: 'hello', phones: ['13800000001'], + })).rejects.toThrow('短信模板不存在、未通过审核或不属于当前应用'); + + expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); + }); + it('rejects free content without an approved leading signature', async () => { const { service, prisma } = createService(); prisma.smsApplication.findUnique.mockResolvedValue({ @@ -927,6 +943,47 @@ describe('SendChainService', () => { }); }); + it('dispatches an accepted scheduled task from its snapshot after the template is deleted', async () => { + const { service, prisma, billing } = createService(); + prisma.smsTemplate.findUnique.mockResolvedValue({ + id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'deleted', + signature: { id: 'sig-1', auditStatus: 'approved' }, + }); + prisma.smsBatchTask.findMany.mockResolvedValue([{ + id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', status: 'scheduled', + }]); + prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]); + service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); + + await expect(service.dispatchDueScheduledTasks(new Date())).resolves.toEqual({ + dispatched: 1, + results: [{ taskId: 'task-1', status: 'queued', enqueued: 1 }], + }); + + expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ relatedId: 'task-1' })); + expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1'); + }); + + it('still blocks scheduled dispatch when the persisted template signature is no longer approved', async () => { + const { service, prisma, billing } = createService(); + service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); + prisma.smsTemplate.findUnique.mockResolvedValue({ + id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'deleted', + signature: { id: 'sig-1', auditStatus: 'deleted' }, + }); + prisma.smsBatchTask.findMany.mockResolvedValue([{ + id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', status: 'scheduled', + }]); + + await expect(service.dispatchDueScheduledTasks(new Date())).resolves.toEqual({ + dispatched: 0, + results: [{ taskId: 'task-1', status: 'failed', reason: '短信签名未审核通过' }], + }); + + expect(billing.freeze).not.toHaveBeenCalled(); + expect(service.enqueueBatchTask).not.toHaveBeenCalled(); + }); + it('terminates non-final tasks by canceling unsubmitted messages', async () => { const { service, prisma } = createService(); prisma.smsBatchTask.findUnique.mockResolvedValue({ id: 'task-1', status: 'sending' }); diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index a34f913..39ebbea 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -17,7 +17,7 @@ import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS, RECEIPT_TIMEOUT_INITIAL_DELAY_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, SCHEDULED_DISPATCH_INITIAL_DELAY_MS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS, INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS, UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, gatewaySubmitRequeueKey, drainageRejectionReason, statusFromRisk, parseSchedule, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, normalizeRegion, matchTemplateContent, escapeRegularExpression, isNationalChannel, isProvinceChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, normalizeSubmitStatus, normalizeReceiptStatus, downstreamDeliveryAttemptKey, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory } from './send-chain.helpers'; import { aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers'; import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets'; -import { SendSubmissionService } from './send-submission.service'; +import { SendSubmissionService, type SendResourceValidationOptions } from './send-submission.service'; import { SendCompletionService, type SendCompletionFacade } from './send-completion.service'; @Injectable() @@ -722,8 +722,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { return this.submission.classifyRejectedPhones(tenantId, applicationId, phones); } - private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) { - return this.submission.validateSendResources(tenantId, applicationId, templateId); + private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string, options?: SendResourceValidationOptions) { + return this.submission.validateSendResources(tenantId, applicationId, templateId, options); } private async reserveDailySendQuota(applicationId: string, requestedCount: number) { diff --git a/api/src/send-chain/send-scheduled-dispatch.service.ts b/api/src/send-chain/send-scheduled-dispatch.service.ts index eb831d2..d922d1d 100644 --- a/api/src/send-chain/send-scheduled-dispatch.service.ts +++ b/api/src/send-chain/send-scheduled-dispatch.service.ts @@ -87,7 +87,12 @@ async dispatchDueScheduledTasks(now = new Date()) { let reservationEstablished = false; let dispatchPrepared = false; try { - await this.facade.validateSendResources(task.tenantId, task.applicationId ?? undefined, task.templateId ?? undefined); + await this.facade.validateSendResources( + task.tenantId, + task.applicationId ?? undefined, + task.templateId ?? undefined, + { usePersistedTemplateSnapshot: true }, + ); const messages = await this.prisma.smsMessageRecord.findMany({ where: { batchTaskId: task.id, status: { in: ['scheduled', 'queued'] } }, select: { id: true, amountCents: true, billingUnits: true }, diff --git a/api/src/send-chain/send-submission.service.ts b/api/src/send-chain/send-submission.service.ts index e5ad0bb..ab943c4 100644 --- a/api/src/send-chain/send-submission.service.ts +++ b/api/src/send-chain/send-submission.service.ts @@ -34,6 +34,10 @@ export type SendSubmissionCallbacks = { ) => Promise; }; +export type SendResourceValidationOptions = { + usePersistedTemplateSnapshot?: boolean; +}; + /** * R9 internal compatibility facade. SendChainService remains the only public NestJS provider. */ @@ -109,8 +113,8 @@ async classifyRejectedPhones(tenantId: string, applicationId: string | undefined return this.batchEntry.classifyRejectedPhones(tenantId, applicationId, phones); } -async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) { - return this.batchEntry.validateSendResources(tenantId, applicationId, templateId); +async validateSendResources(tenantId: string, applicationId?: string, templateId?: string, options?: SendResourceValidationOptions) { + return this.batchEntry.validateSendResources(tenantId, applicationId, templateId, options); } async reserveDailySendQuota(applicationId: string, requestedCount: number) { diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index c5fcde7..da50c53 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -1631,8 +1631,8 @@ ## 2026-07-21 UI/UX A5删除治理补充 - 通道、签名和模板删除前必须由后端返回对象身份、活动依赖数量与对象摘要、影响范围、`allowedActions`、`blockedReasons`、状态版本和可恢复说明;前端不得自行推断或只显示通用风险文案。 -- 删除提交必须包含预检版本、8位以上幂等键和至少4字符原因。后端在Serializable事务中重新以`updatedAt`和未删除状态做条件更新,并写入包含原因、依赖、影响及幂等键的`OperationLog`,返回操作单号和重放标识。 -- 客户端只能预检和删除当前会话企业的签名/模板,不得删除运营通道;通道被活动通道组/路由/连接/未结束报备引用,签名被模板/引流/未结束报备引用,模板被未结束发送/批量任务引用时,后端必须阻断。 +- 删除提交必须包含预检版本和8位以上幂等键,删除原因选填。后端在Serializable事务中重新以`updatedAt`和未删除状态做条件更新,并写入包含原因、依赖、影响及幂等键的`OperationLog`,返回操作单号和重放标识。 +- 客户端只能预检和删除当前会话企业的签名/模板,不得删除运营通道;通道的活动通道组/路由/连接依赖以及签名级联安全规则继续按专项口径处理。单独删除模板不得因已创建发送或批量任务而阻断。 - 删除采用逻辑删除,历史发送、回执、计费、审核和审计数据继续保留;恢复需有审计依据。所有旧删除入口必须委托同一治理服务,禁止保留绕过路径。 ## 2026-07-21 UI/UX A6人工充值治理补充 @@ -1945,7 +1945,9 @@ - 删除通道时,若存在未结束报备任务,必须提供“同时结束关联的报备任务”勾选项;勾选后在同一事务中将任务置为`abandoned`并逻辑删除通道。已有活动通道组引用、直接路由规则或活动网关连接仍属于不能由该勾选项解决的硬依赖,必须先处理后再删除。 - 运营端可以看到未结束报备任务的真实ID和状态;客户端也允许勾选“同时结束关联的报备任务”,但客户端专用预检响应和页面不得展示任务ID、状态、通道或其他内部详情,只展示统一说明:“发现关联的未结束报备任务。勾选后将全部置为‘放弃报备’,历史任务和报备记录继续保留。” - 每一条被放弃的报备任务必须写`ChannelSignatureReportRecord`,保留变更前状态、`abandoned`变更后状态、操作人、原因和`deletion_governance`来源;级联删除的模板和引流信息必须留下子对象审计记录。删除通道并结束任务后,受影响的未删除签名必须在同一事务内按剩余有效通道重算报备汇总。 -- 关联模板若仍存在真实未结束发送或批量任务,不得仅靠“同时删除关联的模板”绕过发送安全约束;模板本身删除也使用同一活动任务口径。`SmsSendTask`的`approved/rejected`按已结束审核任务处理,`SmsBatchTask`至少将`finished/canceled/rejected/failed/completed/cancelled`按终态处理,避免已结束历史被误判为活动任务。 +- 删除签名时,关联模板若仍存在真实未结束发送或批量任务,不得仅靠“同时删除关联的模板”绕过签名和报备安全约束;该签名级联阻断与单独删除模板的规则分开。 +- 单独删除模板只阻止之后新建发送任务,不阻断也不改写已创建的`SmsSendTask`、`SmsBatchTask`和`SmsMessageRecord`。模板保持逻辑删除,历史`templateId`关联、短信内容、分类、计费与审核快照继续保留。 +- 已接受的定时任务到点时必须使用创建时持久化的内容快照继续处理,不得因模板后续逻辑删除而失败;但仍必须重新校验企业、应用及签名当前可用性,防止绕过停用和签名安全控制。 ## 发送质量矩阵与成功率色阶统一(2026-08-09) @@ -1979,3 +1981,20 @@ - 连接请求详情保存客户实际发送或由Gateway从报文解析的诊断参数,包括远端IP、`Source_Addr`账号、`AuthenticatorSource`、时间戳、协议版本及原始版本值,并保存认证结果、应用ID和失败原因。标准CMPP CONNECT不传输明文密码,页面必须明确说明这一事实,不得把平台配置的密码或密钥伪造成客户请求密码;仅兼容调用真实携带`password`字段时原样保存和展示该字段。 - 上述连接请求日志必须在认证响应返回前持久化,日志写入失败时不得把未经审计的连接当作认证成功。系统与操作日志列表继续直接显示`ipAddress`,并为`cmpp_connection.connect_requested`提供“查看详情”按钮,展示上述结构化参数。 - 供应商通道的既有连接操作日志仍保留;客户入站连接使用`cmpp_downstream_connection`资源区分方向。本功能不改变CMPP认证算法、IP白名单、最大连接数或客户连接状态。 + +## 暂缓需求:通道支持运营商多选(2026-08-09) + +- 本需求当前只记录、不实施,不改变现行`SmsChannel.carrier`单值模型、通道创建/编辑交互、通道组校验、发送选路、报备或生产数据;后续重新启动时必须另行完成影响评估、生产数据迁移方案和兼容发布计划。 +- 目标交互为取消“移动、联通、电信、三网”四选一,将通道能力改为“移动、联通、电信”三个复选项,至少选择一个;同时勾选三个运营商等价于现行“三网”,允许只勾选其中两个运营商。 +- 该变化只作用于通道本体的运营商能力集合。`SmsChannelGroup.carrier`、`SmsChannelGroupItem.carrier`、`ChannelRouteRule.carrier`及短信号码实际运营商仍保持移动/联通/电信单值;一个通道只有在能力集合包含对应运营商时,才允许加入该运营商通道组并参与选路。 +- 同一通道勾选多个运营商时继续共用一个通道单价,不增加分运营商单价;如未来出现分运营商计价需求,必须另立需求并升级为通道运营商明细模型,不能在本需求中隐式扩展。 +- 完整、理想的报备模型为“签名 × 通道 × 运营商”,用于分别记录同一通道在不同运营商下的报备状态;该模型本期明确暂不设计、不实施,现有“签名 × 通道”报备任务及历史记录保持不变。未来启动运营商多选实施前,必须再次确认是否同步升级报备粒度,不得把一个运营商的报备结果无依据地复制为其他运营商结果。 +- 未来生产数据迁移原则为:`mobile→[mobile]`、`unicom→[unicom]`、`telecom→[telecom]`、`all→[mobile,unicom,telecom]`,已删除通道也要保留并迁移历史能力;不得根据当前通道组关联、通道名称或近期流量自动缩减旧`all`通道的能力范围。 +- 未来取消某个已勾选运营商时,如果该通道仍被对应运营商的活动通道组引用,后端必须返回真实影响并阻止保存,不得自动删除通道组成员、路由、报备任务或历史发送记录;新增运营商能力也不得自动加入通道组或自动视为报备通过。 +- 未来迁移必须采用向前兼容的分阶段发布:先增加新能力集合、回填并让后端兼容读取,再开放多选写入。出现两个运营商组合后,旧单值代码无法无损解释该数据,回滚下限必须是已经支持新集合的兼容版本,不能直接回滚到仅识别`mobile/unicom/telecom/all`的旧版本。 + +## 通道组按通道筛选(2026-08-09) + +- 运营端“短信通道组管理”在通道组名称条件之外增加“通道”筛选。选定一个通道后,只展示成员配置中真实包含该`channelId`的未删除通道组;与通道组名称同时输入时按两个条件取交集。 +- 通道选项必须来自真实通道API,不使用静态列表、Mock或localStorage。页面首次加载时通道组与通道两个独立请求并行执行;选项同时展示通道名称和编码,已删除通道明确标记“已删除”。未加入任何通道组的真实通道仍可选择,选中后结果为零而不得隐藏该选项。 +- 通道选择控件必须为通用下拉可搜索控件,支持按通道名称或编码搜索;“全部通道”表示不按通道限制,点击“重置”必须同时清空通道组名称和通道条件并回到第一页。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 69bf6e9..5ffa592 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -2332,8 +2332,8 @@ 3. 使用已保存草稿或 scheduled 任务触发送。 4. 查询历史发送详情。 - 预期结果: - - 新发送不可选择已删除模板。 - - 草稿或 scheduled 到点时校验失败,不入队。 + - 新发送和尚未创建真实任务的草稿不可选择已删除模板。 + - 删除前已创建的 scheduled 任务到点时使用持久化快照继续入队;企业、应用或签名当前不可用时仍阻断。 - 历史发送详情仍展示原短信内容、模板名称或模板快照。 - 不影响历史计费和对账。 @@ -3764,7 +3764,7 @@ npm run verify:phase8 - `TC-UIUX-A5-DELETE-001`:活动通道组引用通道时打开删除确认层;真实预检返回引用数量、组名和优先级,`allowedActions`为空,前后端均禁止删除且通道状态不变。 - `TC-UIUX-A5-DELETE-002`:签名仍被未删除模板、引流信息或未结束报备任务引用;运营端和客户端均显示租户内依赖摘要并禁止删除,客户端不能读取其他企业对象。 -- `TC-UIUX-A5-DELETE-003`:模板存在未结束发送或批量任务时阻断;无依赖模板填写原因后逻辑删除,返回操作单号,PostgreSQL状态为`deleted`且OperationLog包含原因、依赖、影响和幂等键。 +- `TC-UIUX-A5-DELETE-003`:模板即使存在未结束发送或批量任务也允许逻辑删除,返回操作单号;PostgreSQL模板状态为`deleted`,既有任务、消息和历史关联不变,OperationLog包含依赖快照、影响和幂等键。 - `TC-UIUX-A5-DELETE-004`:相同删除幂等键重试返回相同操作单号且不重复审计;旧版本并发提交返回409并要求重新预检;旧删除接口不能绕过治理规则。 - `TC-UIUX-A5-DELETE-005`:在1440×900、1366×768、768×1024、390×844和375×667打开依赖确认层;对象、依赖、影响和底部操作可滚动到达,无页面级横向溢出,控制台无error/warn。 @@ -4423,7 +4423,10 @@ npm run verify:phase8 | TC-DELETE-CHANNEL-010 | 通道仅关联未结束报备任务,没有活动组、直接路由或连接 | 出现“同时结束关联的报备任务”必选项;勾选后任务置为`abandoned`并写记录,通道逻辑删除,受影响有效签名按剩余有效通道重算汇总 | | TC-DELETE-CHANNEL-011 | 通道仍存在活动通道组、直接路由或活动网关连接 | 报备任务勾选项不能绕过其他硬依赖,后端拒绝删除并返回真实阻断原因 | | TC-DELETE-REASON-012 | 分别在运营端和客户端删除无硬依赖的通道、签名、模板,删除原因留空或填写内容 | 留空时允许删除;填写时原文进入审计详情,三类对象均不再要求至少4个字符 | -| TC-DELETE-TEMPLATE-013 | 模板只关联状态为`approved/rejected`的发送审核任务和`finished/canceled`的批量任务 | 已结束任务不阻止模板删除;真实过程态发送或批量任务仍阻止删除 | +| TC-DELETE-TEMPLATE-013 | 模板关联任意终态或过程态的发送审核任务和批量任务 | 单独删除模板不查询也不依赖任务是否结束;模板逻辑删除成功,既有任务和历史关联不变 | +| TC-DELETE-TEMPLATE-014 | 使用已逻辑删除的模板创建新发送任务 | 后端拒绝创建,不产生批量任务、消息或计费记录 | +| TC-DELETE-TEMPLATE-015 | 已接受的定时任务到点前,其模板被逻辑删除,企业、应用和签名仍有效 | 任务按已持久化内容快照冻结费用并入队,不因模板当前`deleted`状态失败 | +| TC-DELETE-TEMPLATE-016 | 已接受的定时任务到点前,其签名变为未通过或删除 | 仍按签名安全规则阻断调度,不冻结费用、不入队,任务和消息记录真实标记失败原因 | ## 2026-08-09 通道组删除风险展示与历史保留用例 @@ -4484,3 +4487,27 @@ npm run verify:phase8 | TC-ENTERPRISE-REGION-002 | 打开运营端新建企业,依次选择两个不同省份并查看地市下拉 | 省份选项来自真实字典API;地市只显示当前省的对应值,切换省份后旧地市立即清空;保存后省市真实写入企业档案 | | TC-ENTERPRISE-REGION-003 | 编辑一个已存省市值暂未出现在当前号段字典的历史企业 | 页面将档案原值补入当前选项并正常显示,未主动修改时不会被清空 | | TC-ENTERPRISE-REGION-004 | 断开字典API后打开新建企业 | 页面明确提示省市字典加载失败,不显示Mock、localStorage或旧的写死选项 | + +## 2026-08-09 通道支持运营商多选规划用例(需求暂缓、未执行) + +> 本节仅保存未来验收口径。当前版本不实现运营商多选,以下用例状态均为“暂缓、未执行”,不得据此判定现有系统缺陷或功能已完成。 + +| 用例编号 | 操作 | 未来预期结果 | +| --- | --- | --- | +| TC-CHANNEL-CARRIER-MULTI-001 | 对包含`mobile/unicom/telecom/all`及已删除通道的生产数据副本执行兼容迁移 | 单运营商值分别迁移为单元素集合,`all`迁移为移动、联通、电信全选;记录数、通道ID、状态和历史关联不变,不根据名称或通道组使用情况推断并缩减能力 | +| TC-CHANNEL-CARRIER-MULTI-002 | 新建或编辑通道,分别勾选一个、两个、三个和零个运营商 | 一个、两个、三个非空组合均可真实保存并回填;零个被前后端拒绝;页面不再提供独立“三网”选项,三个全选等价于旧`all` | +| TC-CHANNEL-CARRIER-MULTI-003 | 将支持移动和联通但不支持电信的通道分别加入三类通道组并发送对应运营商短信 | 仅允许加入移动、联通通道组;电信组前后端均拒绝;发送链不会把电信短信选到该通道,通道组、路由规则和短信实际运营商仍为单值 | +| TC-CHANNEL-CARRIER-MULTI-004 | 取消通道已被活动通道组引用的运营商,再尝试保存 | 后端返回对应真实通道组及影响并阻止保存,不自动删除成员、路由、报备任务或历史数据;解除活动引用后才允许取消 | +| TC-CHANNEL-CARRIER-MULTI-005 | 多运营商通道参与移动、联通、电信发送及成本统计 | 三个运营商继续共用通道唯一单价,客户计费和平台成本不因多选被重复计算;本需求不产生分运营商价格 | +| TC-CHANNEL-CARRIER-MULTI-006 | 检查签名报备任务、报备记录和三网汇总 | 当前暂缓方案不得伪造“签名 × 通道 × 运营商”结果;未来实施前必须重新确定报备升级范围,现有“签名 × 通道”历史记录不得删除或复制成多个虚假运营商结果 | +| TC-CHANNEL-CARRIER-MULTI-007 | 分阶段部署兼容底座后写入仅支持两个运营商的通道,再执行回滚演练 | 只能回滚到能够读取运营商集合的兼容版本;仅识别旧单值的代码不得重新上线并将双运营商数据误判为三网或单网 | + +## 2026-08-09 通道组按通道筛选用例 + +| 用例编号 | 操作 | 预期结果 | +| --- | --- | --- | +| TC-CHANNEL-GROUP-FILTER-001 | 打开通道组管理页并展开“通道”下拉 | 通道组和通道真实API并行加载;下拉显示全部真实通道的名称和编码,已删除通道标记“已删除”,未加入任何组的通道也不被隐藏 | +| TC-CHANNEL-GROUP-FILTER-002 | 在通道下拉中输入完整或部分通道名称、编码 | 下拉只显示标签包含关键字的真实通道选项;无匹配时显示“无匹配选项” | +| TC-CHANNEL-GROUP-FILTER-003 | 选择某通道 | 只展示`items.channelId`包含该通道的通道组,不展示仅运营商相同但未配置该通道的组;总数和分页与筛选结果一致 | +| TC-CHANNEL-GROUP-FILTER-004 | 同时输入通道组名称并选择通道 | 按名称包含与成员通道两个条件取交集,条件变更后回到第一页 | +| TC-CHANNEL-GROUP-FILTER-005 | 点击“重置” | 通道组名称和通道条件同时清空,恢复全部未删除通道组并回到第一页 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 4582396..639f48a 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -3335,3 +3335,28 @@ git diff --check - 从预生产服务器公网复核:运营登录、客户端登录、API health和客户Swagger均为HTTP 200;API独立域名根路径及管理删除预检均为404;主站未认证运营端和客户端删除预检均为401。公网CMPP 17890纯TCP连接成功。本机执行公网检查时因本机DNS无法解析两个域名返回000,已由服务器侧公网检查闭环,不将本机DNS故障误记为平台故障。 - 9条active供应商通道发布重启后6条为`connected 1/1`;“会员营销-富泷”“移动物业-富泷”“联电物业-富泷”3条仍为`authentication / connect response status: auth failed`。本轮未修改通道账号、密码、启停状态或连接参数,只保留并报告供应商真实返回。 - 本次只部署代码和文档,未执行任何真实通道、签名、模板、引流信息或报备任务删除,未发送、补发或重投真实短信,未修改企业余额、客户连接或通道配置。部署依赖审计仍报告根项目3项high、API项目3项moderate和4项high,专用安全缓解门禁通过,未执行可能破坏兼容性的自动升级。 + +## 2026-08-09 模板删除与既有短信任务解耦(本地未提交) + +- 纠正“模板删除必须等待关联短信任务结束”的错误耦合。单独删除模板时,预检和事务内复核都不再查询或阻断`SmsSendTask`/`SmsBatchTask`;模板仅逻辑删除,已创建任务、消息、计费、审核快照及历史`templateId`关联保持不变。 +- 已删除模板仍不能用于新建发送任务。已接受的定时任务到点时改为使用持久化内容快照继续处理,不因模板当前`deleted`状态失败;仍重新校验企业、应用、模板归属和签名当前状态。签名删除的级联安全阻断本轮未改。 +- 删除治理定向1 suite / 11 tests通过;发送链3个新增边界用例通过,覆盖已删模板禁止新任务、已有定时任务按快照继续、签名失效仍阻断。排除依赖本机Redis的`send-chain.service.spec.ts`后,API其余32 suites / 324 tests全部通过;API正式构建TypeScript检查和`git diff --check`通过。 +- 完整发送链套件仍因本机Redis `127.0.0.1:6379` 未运行出现5个既有连接拒绝超时,与上次记录的环境阻塞一致;本轮新增3个发送链用例已单独精确运行并通过,未为通过测试伪造Redis或改动队列配置。 +- 本轮未连接预生产、未修改数据库,未执行任何真实模板/签名/任务操作,未发送、补发或重投短信,也未修改通道、余额或客户连接。代码和文档保持未提交、未推送、未部署。 + +## 2026-08-09 通道支持运营商多选需求评估(暂缓,未实施) + +- 新需求拟将通道本体从“移动、联通、电信、三网”单选改为“移动、联通、电信”三个运营商复选,三个全选等价于现行三网,并允许两个运营商组合。用户已明确本需求暂时不实施,本步骤只同步需求和规划用例,没有修改代码、Prisma schema、migration、API、页面或生产数据。 +- 已确认多运营商通道继续共用同一个通道单价,不设计分运营商价格。完整报备的理想模型为“签名 × 通道 × 运营商”,但本期暂不考虑该扩展;未来重新启动需求时必须先重新确认报备粒度,不能把当前“签名 × 通道”状态无依据复制到各运营商。 +- 2026-08-09预生产只读盘点共18条通道:8条`all/active`、1条`all/disabled`、1条`mobile/active`,另有6条`mobile/deleted`、1条`unicom/deleted`和1条`telecom/deleted`,没有NULL或非法旧值;另有28条通道组成员、54条活动路由规则、69条签名报备任务、148条报备历史记录和6907条提交记录需要在未来迁移与回归时保护。 +- 未来数据迁移固定按旧值语义保守映射:单运营商转单元素集合,`all`转移动/联通/电信全选,已删除通道同样迁移;不得从通道名称、当前通道组关联或近期发送量自动推断并缩减能力。取消仍被对应运营商活动通道组引用的能力时必须由真实后端阻止,禁止自动删除关联或历史。 +- 未来实施属于跨数据库、通道管理、通道组校验、发送选路、签名/引流报备、批次生成、筛选、复制、审计和文档的高风险改造,必须采用“兼容字段与回填底座→开放多选写入”的分阶段发布。生产出现双运营商组合后,回滚下限必须是已支持新集合的兼容版本,不能回滚到只识别旧`carrier`单值的版本。 +- 规划验收用例已记录为`TC-CHANNEL-CARRIER-MULTI-001`至`007`,当前均为“暂缓、未执行”,不计入现版本通过率,也不得作为现有系统Bug;本步骤未连接或修改预生产数据库,未修改通道账号、密码、启停状态、企业余额或客户连接,未发送、补发或重投真实短信。 + +## 2026-08-09 通道组按通道筛选(本地未提交) + +- 运营端“短信通道组管理”新增“通道”可搜索下拉筛选。页面首次加载并行请求真实`GET /api/admin/channel-groups`和`GET /api/admin/channels`,下拉显示全部真实通道的名称和编码;已删除通道显式标记,未加入任何组的通道也保留可选,未新增静态列表、Mock或localStorage。 +- 选定通道后按成员的精确`channelId`筛选通道组,与通道组名称条件取交集;筛选结果的总数和分页同步重算,条件变更后回到第一页。“重置”同时清空名称和通道条件。 +- 按React性能口径将通道选项和筛选结果都作为`groups`与查询状态的派生值计算,不使用effect复制派生状态,避免额外请求、重复渲染和状态偏移。 +- 前端TypeScript `--noEmit --incremental false`通过;Vite v8.1.5生产构建通过(2535 modules),仅保留既有约2.04MB单chunk告警;`git diff --check`通过。本地预览能正常加载运营端应用和登录页,但本地API未运行,请求返回502且无已登录会话,因此未伪造登录或Mock通道数据进行页面交互验收。 +- 本轮未连接预生产、未修改数据库或真实通道/通道组,未发送、补发或重投短信,也未修改余额或客户连接。代码和文档保持未提交、未推送、未部署。 diff --git a/src/apps/admin/AdminChannelGroupsPage.tsx b/src/apps/admin/AdminChannelGroupsPage.tsx index fb207f1..8fca72d 100644 --- a/src/apps/admin/AdminChannelGroupsPage.tsx +++ b/src/apps/admin/AdminChannelGroupsPage.tsx @@ -1,8 +1,8 @@ import { useEffect, useMemo, useState } from 'react'; import { Clock3, Layers3, Pencil, Plus, RadioTower, Search, Trash2 } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; -import { Breadcrumb, Button, Input, Modal, Pagination, Tag } from '@/components/ui'; -import { adminApi, type ChannelGroup, type ChannelGroupDeletionImpact } from '@/api/adminApi'; +import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag } from '@/components/ui'; +import { adminApi, type AdminChannel, type ChannelGroup, type ChannelGroupDeletionImpact } from '@/api/adminApi'; type GroupCarrier = 'mobile' | 'unicom' | 'telecom'; @@ -33,6 +33,8 @@ function getGroupSummary(group: ChannelGroup) { export function AdminChannelGroupsPage() { const navigate = useNavigate(); const [groupName, setGroupName] = useState(''); + const [channelId, setChannelId] = useState(''); + const [channels, setChannels] = useState([]); const [groups, setGroups] = useState([]); const [deleteTarget, setDeleteTarget] = useState(null); const [deletionImpact, setDeletionImpact] = useState(null); @@ -43,26 +45,42 @@ export function AdminChannelGroupsPage() { const pageSize = 10; function loadData() { - adminApi.listChannelGroups() - .then((items) => { - setGroups(items); + Promise.all([adminApi.listChannelGroups(), adminApi.listChannels()]) + .then(([groupItems, channelItems]) => { + setGroups(groupItems); + setChannels(channelItems); setError(''); }) - .catch((failure: Error) => setError(failure.message || '通道组加载失败')); + .catch((failure: Error) => setError(failure.message || '通道组数据加载失败')); } useEffect(() => { loadData(); }, []); - const filteredGroups = useMemo(() => groups.filter((group) => !groupName.trim() || group.name.includes(groupName.trim())), [groupName, groups]); + const channelOptions = useMemo(() => [ + { label: '全部通道', value: '' }, + ...channels + .map((channel) => { + const identity = `${channel.name}(${channel.code})`; + return { label: channel.status === 'deleted' ? `${identity}(已删除)` : identity, value: channel.id }; + }) + .sort((left, right) => left.label.localeCompare(right.label, 'zh-CN')), + ], [channels]); + const filteredGroups = useMemo(() => { + const keyword = groupName.trim(); + return groups.filter((group) => ( + (!keyword || group.name.includes(keyword)) + && (!channelId || (group.items ?? []).some((item) => item.channelId === channelId)) + )); + }, [channelId, groupName, groups]); const totalPages = Math.max(1, Math.ceil(filteredGroups.length / pageSize)); const currentPage = Math.min(page, totalPages); const visibleGroups = filteredGroups.slice((currentPage - 1) * pageSize, currentPage * pageSize); useEffect(() => { setPage(1); - }, [groupName, groups.length]); + }, [channelId, groupName, groups.length]); function closeDeleteModal() { if (deleting) return; @@ -108,9 +126,20 @@ export function AdminChannelGroupsPage() {
setGroupName(event.target.value)} placeholder="请输入通道组名称" value={groupName} /> +