diff --git a/.gitignore b/.gitignore index 6a036de..444ebd8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* logs/ +dump.rdb .DS_Store Thumbs.db diff --git a/api/prisma/migrations/20260707110000_channel_group_retry_minutes/migration.sql b/api/prisma/migrations/20260707110000_channel_group_retry_minutes/migration.sql new file mode 100644 index 0000000..49f4928 --- /dev/null +++ b/api/prisma/migrations/20260707110000_channel_group_retry_minutes/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "SmsChannelGroup" ADD COLUMN "retryTimeLimitMinutes" INTEGER NOT NULL DEFAULT 720; + +UPDATE "SmsChannelGroup" +SET "retryTimeLimitMinutes" = LEAST(GREATEST("retryTimeLimitHours", 1), 72) * 60; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 405fafb..99ad122 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -536,6 +536,7 @@ model SmsChannelGroup { status String @default("active") retryEnabled Boolean @default(true) retryTimeLimitHours Int @default(72) + retryTimeLimitMinutes Int @default(720) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/api/src/billing/billing.controller.ts b/api/src/billing/billing.controller.ts index 27deae9..90cf418 100644 --- a/api/src/billing/billing.controller.ts +++ b/api/src/billing/billing.controller.ts @@ -6,7 +6,6 @@ import { BillingActionDto, CreateManualRechargeDto, CreateRechargeOrderDto, - CreateAccountTransactionDto, CreateBillingPlanDto, CreateBillingRuleDto, CreateSmsBillingRecordDto, @@ -39,16 +38,6 @@ export class BillingController { return this.billing.createAccount(body); } - @Get('transactions') - listTransactions(@TenantId() tenantId?: string) { - return this.billing.listTransactions(tenantId); - } - - @Post('transactions') - createTransaction(@Body() body: CreateAccountTransactionDto) { - return this.billing.createTransaction(body); - } - @Get('recharges') listRechargeOrders(@TenantId() tenantId?: string) { return this.billing.listRechargeOrders(tenantId); @@ -135,11 +124,6 @@ export class ClientBillingController { return this.billing.listPlans(); } - @Get('transactions') - listTransactions(@TenantId() tenantId?: string) { - return this.billing.listTransactions(tenantId); - } - @Post('orders') createRechargeOrder(@Body() body: CreateRechargeOrderDto) { return this.billing.createRechargeOrder(body); diff --git a/api/src/billing/billing.service.ts b/api/src/billing/billing.service.ts index 8d329ca..9a24a56 100644 --- a/api/src/billing/billing.service.ts +++ b/api/src/billing/billing.service.ts @@ -124,28 +124,6 @@ export class BillingService { return this.prisma.tenantAccount.create({ data: createData }); } - listTransactions(tenantId?: string) { - return this.prisma.accountTransaction.findMany({ - where: tenantId ? { tenantId } : undefined, - orderBy: { createdAt: 'desc' }, - take: 100, - }); - } - - createTransaction(data: CreateAccountTransactionDto) { - const createData: Prisma.AccountTransactionUncheckedCreateInput = { - tenantId: data.tenantId, - transactionType: data.transactionType, - amountCents: data.amountCents ?? 0, - smsUnits: data.smsUnits ?? 0, - balanceAfter: data.balanceAfter ?? 0, - relatedType: data.relatedType, - relatedId: data.relatedId, - remark: data.remark, - }; - return this.prisma.accountTransaction.create({ data: createData }); - } - listRechargeOrders(tenantId?: string) { return this.prisma.rechargeOrder.findMany({ where: tenantId ? { tenantId } : undefined, diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index 200b73a..76d059a 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -67,7 +67,7 @@ function createPrismaMock() { channelHealthMetric: { findMany: jest.fn() }, smsChannelGroup: { findMany: jest.fn(), - findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72 }), + findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320 }), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })), delete: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组' }), }, @@ -156,7 +156,7 @@ describe('ChannelsService', () => { passwordCipher: 'secret', srcId: '10690000', }); - await service.createGroup({ code: 'G-MOBILE', name: '移动组', carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 24 }); + await service.createGroup({ code: 'G-MOBILE', name: '移动组', carrier: 'mobile', retryEnabled: true, retryTimeLimitMinutes: 750 }); await service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile' }); expect(prisma.smsChannel.create).toHaveBeenCalledWith({ @@ -188,7 +188,7 @@ describe('ChannelsService', () => { body: expect.stringContaining('"messageType":"ConnectChannel"'), })); expect(prisma.smsChannelGroup.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 24 }), + data: expect.objectContaining({ carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 13, retryTimeLimitMinutes: 750 }), }); expect(prisma.channelRouteRule.create).toHaveBeenCalledWith({ data: expect.objectContaining({ @@ -331,7 +331,7 @@ describe('ChannelsService', () => { name: '移动组更新', carrier: 'mobile', retryEnabled: true, - retryTimeLimitHours: 24, + retryTimeLimitMinutes: 750, items: [ { channelId: 'channel-sd', carrier: 'mobile', province: '山东', priority: 10 }, { channelId: 'channel-national', carrier: 'mobile', priority: 1 }, @@ -340,6 +340,20 @@ describe('ChannelsService', () => { expect(prisma.$transaction).toHaveBeenCalled(); + const transactionCallback = prisma.$transaction.mock.calls[0][0]; + const tx = { + smsChannelGroupItem: { deleteMany: jest.fn(), createMany: jest.fn() }, + smsChannelGroup: { + update: jest.fn(), + findUnique: jest.fn().mockResolvedValue({ id: 'group-1' }), + }, + }; + await transactionCallback(tx); + expect(tx.smsChannelGroup.update).toHaveBeenCalledWith({ + where: { id: 'group-1' }, + data: expect.objectContaining({ retryTimeLimitHours: 13, retryTimeLimitMinutes: 750 }), + }); + await expect(service.updateGroup('group-1', { carrier: 'mobile', items: [ diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index 8b10dd6..d9fc351 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -33,6 +33,7 @@ export interface CreateChannelGroupDto { status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; + retryTimeLimitMinutes?: number; } export interface CreateChannelGroupItemDto { @@ -54,6 +55,7 @@ export interface UpdateChannelGroupDto { status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; + retryTimeLimitMinutes?: number; items?: Array>; } @@ -175,7 +177,11 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { } listChannels() { - return this.prisma.smsChannel.findMany({ orderBy: { createdAt: 'desc' }, take: 100 }); + return this.prisma.smsChannel.findMany({ + include: { connectionStates: true }, + orderBy: { createdAt: 'desc' }, + take: 100, + }); } async createChannel(data: CreateChannelDto) { @@ -574,17 +580,14 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { listGroups() { return this.prisma.smsChannelGroup.findMany({ - include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } }, + include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } }, orderBy: { createdAt: 'desc' }, take: 100, }); } createGroup(data: CreateChannelGroupDto) { - const retryTimeLimitHours = data.retryTimeLimitHours ?? 72; - if (!Number.isInteger(retryTimeLimitHours) || retryTimeLimitHours <= 0 || retryTimeLimitHours > 72) { - throw new BadRequestException('retryTimeLimitHours must be an integer between 1 and 72'); - } + const retryTimeLimitMinutes = normalizeRetryTimeLimitMinutes(data.retryTimeLimitMinutes, data.retryTimeLimitHours, 720); const carrier = normalizeBusinessCarrier(data.carrier); return this.prisma.smsChannelGroup.create({ data: { @@ -594,7 +597,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { description: data.description, status: data.status ?? 'active', retryEnabled: data.retryEnabled ?? true, - retryTimeLimitHours, + retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60), + retryTimeLimitMinutes, }, }); } @@ -659,10 +663,11 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { if (!current) { throw new NotFoundException('Channel group not found'); } - const retryTimeLimitHours = data.retryTimeLimitHours ?? current.retryTimeLimitHours; - if (!Number.isInteger(retryTimeLimitHours) || retryTimeLimitHours <= 0 || retryTimeLimitHours > 72) { - throw new BadRequestException('retryTimeLimitHours must be an integer between 1 and 72'); - } + const retryTimeLimitMinutes = normalizeRetryTimeLimitMinutes( + data.retryTimeLimitMinutes, + data.retryTimeLimitHours, + current.retryTimeLimitMinutes ?? current.retryTimeLimitHours * 60, + ); const carrier = data.carrier ? normalizeBusinessCarrier(data.carrier) : normalizeBusinessCarrier(current.carrier); const items = data.items ?? []; const channelIds = [...new Set(items.map((item) => item.channelId))]; @@ -681,7 +686,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { description: data.description, status: data.status ?? current.status, retryEnabled: data.retryEnabled ?? current.retryEnabled, - retryTimeLimitHours, + retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60), + retryTimeLimitMinutes, }, }); if (items.length > 0) { @@ -1220,6 +1226,14 @@ function deriveReceiptStatus(rowCount: number, successCount: number, failedCount return 'completed'; } +function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hours: number | undefined, fallbackMinutes: number) { + const value = minutes ?? (hours === undefined ? fallbackMinutes : hours * 60); + if (!Number.isInteger(value) || value <= 0 || value > 72 * 60) { + throw new BadRequestException('retryTimeLimitMinutes must be an integer between 1 and 4320'); + } + return value; +} + function normalizeBusinessCarrier(carrier?: string | null) { const normalized = normalizeChannelCarrier(carrier); if (!['mobile', 'unicom', 'telecom'].includes(normalized)) { diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index da4e145..9453b05 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -49,6 +49,7 @@ function createPrismaMock() { status: 'active', retryEnabled: true, retryTimeLimitHours: 72, + retryTimeLimitMinutes: 4320, items: [{ id: 'item-1', groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', priority: 1, province: null, channel }], }, }; @@ -379,7 +380,7 @@ describe('SendChainService', () => { applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile', - group: { id: 'group-1', carrier: 'mobile', status: 'active', retryEnabled: false, retryTimeLimitHours: 72, items: [] }, + group: { id: 'group-1', carrier: 'mobile', status: 'active', retryEnabled: false, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320, items: [] }, }); await service.handleSubmitResult({ @@ -400,6 +401,53 @@ describe('SendChainService', () => { expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' })); }); + it('stops failed receipt retry after the configured minute limit', async () => { + const { service, prisma, billing } = createService(); + prisma.smsBillingRecord.findFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 'bill-1', billingStatus: 'charged' }); + prisma.smsMessageRecord.findFirst.mockResolvedValue({ + id: 'record-1', + tenantId: 'tenant-1', + batchTaskId: 'task-1', + applicationId: 'app-1', + templateId: 'tpl-1', + messageId: 'MSG-1', + phoneNumber: '13800000001', + content: 'hello', + channelId: 'channel-1', + gatewayMessageId: 'GW-1', + status: 'submitted', + amountCents: 3, + billingUnits: 1, + unitPrice: 3, + queuedAt: new Date(Date.now() - 90 * 60_000), + }); + prisma.channelRouteRule.findFirst.mockResolvedValue({ + id: 'route-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + groupId: 'group-1', + carrier: 'mobile', + group: { id: 'group-1', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 2, retryTimeLimitMinutes: 75, items: [] }, + }); + + await service.handleReceipt({ + messageId: 'MSG-1', + channelId: 'channel-1', + gatewayMessageId: 'GW-1', + receiptStatus: 'undelivered', + rawStatus: 'UNDELIV', + }); + + expect(prisma.smsSubmitRecord.create).not.toHaveBeenCalled(); + expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' })); + expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({ + where: { id: 'record-1' }, + data: expect.objectContaining({ status: 'failed', receiptStatus: 'undelivered' }), + }); + }); + it('does not let stale failed receipts overwrite a later delivered message', async () => { const { service, prisma, billing } = createService(); prisma.smsMessageRecord.findFirst.mockResolvedValue({ @@ -467,6 +515,7 @@ describe('SendChainService', () => { status: 'active', retryEnabled: true, retryTimeLimitHours: 72, + retryTimeLimitMinutes: 4320, items: [ { id: 'wrong-item', diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index 961f577..a331753 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -751,12 +751,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { take: 200, }); const attemptedChannelIds = attempts.map((attempt) => attempt.channelId); - const ageHours = (Date.now() - new Date(message.queuedAt ?? Date.now()).getTime()) / 3_600_000; - if (ageHours >= 72) { + const ageMinutes = (Date.now() - new Date(message.queuedAt ?? Date.now()).getTime()) / 60_000; + if (ageMinutes >= 72 * 60) { return null; } const route = await this.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, await this.identifyCarrier(message.phoneNumber)); - if (!route.group.retryEnabled || ageHours >= Math.min(route.group.retryTimeLimitHours, 72)) { + const retryTimeLimitMinutes = Math.min(route.group.retryTimeLimitMinutes ?? route.group.retryTimeLimitHours * 60, 72 * 60); + if (!route.group.retryEnabled || ageMinutes >= retryTimeLimitMinutes) { return null; } try { diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index e387ec5..2201623 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -4,7 +4,7 @@ 本文基于当前前端设计原型整理,用于交给 Codex 或开发团队执行第一版落地开发。 -当前确认:第一版保留短信业务,排除彩信功能;账户计费、充值套餐、账单流水进入第一版开发范围。彩信服务、彩信应用/签名/模板 Tab,以及运营端彩信相关菜单标记为“待开发”;业务性能指标为“平台可稳定入队并调度 500 条短信/秒,实际向通道 submit 受通道限速配置控制”。 +当前确认:第一版保留短信业务,排除彩信功能;账户计费、充值套餐、充值记录进入第一版开发范围,账单流水页面和公开交易查询 API 暂不进入第一版。彩信服务、彩信应用/签名/模板 Tab,以及运营端彩信相关菜单标记为“待开发”;业务性能指标为“平台可稳定入队并调度 500 条短信/秒,实际向通道 submit 受通道限速配置控制”。 ## 1. 项目目标 @@ -17,7 +17,7 @@ - 支持通道级限速、失败重试、回执同步和发送记录追踪。 - 支持企业、应用、签名、模板、通道、通道组、报备任务等核心配置数据的后台维护。 - 彩信功能仅保留菜单占位或隐藏,不进入第一版开发范围。 -- 账户计费、充值套餐、账单流水进入第一版范围,并与发送记录形成可对账闭环。 +- 账户计费、充值套餐、充值记录进入第一版范围,并与发送记录形成可对账闭环;账单流水页面暂不验收。 ## 2. 角色与权限 @@ -154,7 +154,7 @@ 19. 新建或启用通道后,系统应立即创建/更新默认 CMPP 连接状态为 connecting 并通知 Go Gateway 发起连接;如果 connecting 超过 30 秒仍未收到 Gateway 回写 connected 或 failed,API 后台兜底任务必须将该连接标记为 failed,写入 `lastError=Gateway connection request timed out after 30 seconds` 和连接日志。默认扫描间隔 5 秒,可通过环境变量调整。 20. 通道异常时应支持熔断、降级、切换备用通道和失败重试;失败补发必须在同一企业应用授权的通道组范围内执行,并使用触发补发时的当前通道组配置,保证计费、退款、幂等和 trace 可追踪。 21. 失败补发除以下情况外均应触发:短信状态为 unknown;距离客户提交时间超过 72 小时;距离客户提交时间超过通道组配置的补发时间上限;通道组关闭失败补发。 -22. 通道组补发时间上限由运营端配置,最大不得超过 72 小时;本期不配置最大补发次数、补发间隔、失败类型白名单或人工重发能力,进入最终 failed/timeout 后不再人工重发。 +22. 通道组补发时间上限由运营端配置,交互为“小时 + 分钟”,默认 12 小时 0 分钟,最小 1 分钟,最大不得超过 72 小时;真实发送链路按分钟级上限判断是否继续补发。本期不配置最大补发次数、补发间隔、失败类型白名单或人工重发能力,进入最终 failed/timeout 后不再人工重发。 23. 提交 accepted 后立即按企业应用配置的客户费率扣费;补发过程中最终成功只扣一次,submit failed 未真正发出时释放冻结且不扣费;failed receipt 导致最终全失败时退款;本期客户计费不使用通道成本价。 ### 4.7 通道签名报备 diff --git a/docs/production-deployment.md b/docs/production-deployment.md new file mode 100644 index 0000000..ca7ae86 --- /dev/null +++ b/docs/production-deployment.md @@ -0,0 +1,92 @@ +# CMPP 平台生产部署手册 + +## 本次生产端口 + +- 运营端、客户端页面:`http://8.160.169.106:12026` +- API:仅本机 `127.0.0.1:3000`,由 Nginx `/api/` 反向代理。 +- Redis:仅本机 `127.0.0.1:6379`。 +- PostgreSQL:仅本机 `127.0.0.1:5432`。 +- MinIO:仅本机 `127.0.0.1:9000/9001`,页面上传下载通过 API 转发。 +- Gateway 控制服务:仅本机 `127.0.0.1:8090`。 +- CMPP 入站端口:`17890` 已作为部署变量保留;当前 Go Gateway 代码尚未实现完整入站 CMPP Server,不要把 HTTP 控制服务误当作 CMPP 监听。 + +## 首次部署 + +1. 本机提交并推送 `main`。 +2. 使用 root 登录服务器,或先放置免密 SSH key。 +3. 在服务器执行: + +```bash +curl -fsSL http://175.27.255.91:3000/hectorzhao/lislgosms/raw/branch/main/tools/deploy/production-bootstrap.sh -o /root/production-bootstrap.sh +bash /root/production-bootstrap.sh +``` + +可覆盖的环境变量: + +```bash +APP_DIR=/opt/cmpp-platform +REPO_URL=http://175.27.255.91:3000/hectorzhao/lislgosms.git +BRANCH=main +PUBLIC_HTTP_PORT=12026 +API_PORT=3000 +GATEWAY_CMPP_ADDR=0.0.0.0:17890 +PROD_ADMIN_EMAIL=admin@example.com +PROD_ADMIN_USERNAME=prod_admin +PROD_ADMIN_PASSWORD='change-me' +``` + +脚本会安装 Node.js、Go、PostgreSQL、Redis、MinIO、Nginx,创建 systemd 服务,执行 Prisma migrate,构建前端/API/Gateway,并创建平台管理员。 + +## 后续发布 + +```bash +cd /opt/cmpp-platform +git fetch origin main +git reset --hard origin/main +bash tools/deploy/production-deploy.sh +``` + +## 账号和密钥 + +- 生产管理员账号写入 `/root/cmpp-platform-admin.txt`。 +- 首次部署汇总凭据写入 `/root/cmpp-platform-credentials.txt`。 +- 这两个文件权限为 `600`,不要提交到 Git。 +- root 密码后续可修改;SSH 私钥放入 `/root/.ssh/authorized_keys` 后即可免密登录。 + +## 日志 + +```bash +journalctl -u cmpp-api -f +journalctl -u cmpp-gateway -f +journalctl -u cmpp-minio -f +tail -f /opt/cmpp-platform/logs/api/stderr.log +tail -f /opt/cmpp-platform/logs/gateway/stderr.log +``` + +## 健康检查 + +```bash +curl http://127.0.0.1:3000/api/health +curl http://127.0.0.1:8090/health +curl http://127.0.0.1:12026/ +redis-cli -h 127.0.0.1 -p 6379 ping +pg_isready -d "$(grep '^DATABASE_URL=' /etc/cmpp-platform/cmpp-platform.env | cut -d= -f2-)" +``` + +## 回滚 + +1. 数据库备份: + +```bash +pg_dump "$(grep '^DATABASE_URL=' /etc/cmpp-platform/cmpp-platform.env | cut -d= -f2-)" > /opt/cmpp-platform/backups/cmpp-$(date +%F-%H%M%S).sql +``` + +2. 回滚代码: + +```bash +cd /opt/cmpp-platform +git reset --hard <上一版提交> +bash tools/deploy/production-deploy.sh +``` + +3. 如迁移造成不可兼容故障,先停服务,再恢复数据库备份。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index ff534d4..28ca290 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -42,7 +42,7 @@ | CMPP 连接闭环 | 通道连接状态、登录认证、active test、断线重连、连接异常对路由/发送影响、状态监控和日志告警。 | | Dashboard 闭环 | 客户端/运营端指标口径、数据范围、时间筛选、状态统计、账务统计、通道连接指标和明细跳转一致。 | | 内容校验闭环 | 非法字符展示、敏感词、控制字符、内容清洗或拒绝、计费不被非法字符干扰、错误原因可见。 | -| 计费闭环 | 预估、余额校验、冻结、扣费、释放、退款、短信计费记录、账单流水、对账。 | +| 计费闭环 | 预估、余额校验、冻结、扣费、释放、退款、短信计费记录、充值记录、对账。账单流水页面和公开交易查询 API 暂不纳入第一版验收。 | | 系统日志闭环 | 登录、创建、修改、删除、审核、导入导出、密钥重置、发送、报备、账务动作均可查询和定位操作者。 | ## 4. 客户端功能用例 @@ -50,10 +50,10 @@ ### TC-CLIENT-001 登录与租户隔离 - 优先级:P0 -- 前置条件:存在 `tenant-a` 和 `tenant-b`,两个租户各有发送任务和账单流水。 +- 前置条件:存在 `tenant-a` 和 `tenant-b`,两个租户各有发送任务和充值/计费记录。 - 步骤: 1. 使用 `tenant-a` 企业管理员登录客户端。 - 2. 打开工作台、批量任务、发送详情、上行短信、账单流水。 + 2. 打开工作台、批量任务、发送详情、上行短信、充值套餐。 3. 使用查询条件搜索 `tenant-b` 的任务编号或手机号。 - 预期结果: - 登录成功,返回当前租户上下文。 @@ -257,14 +257,17 @@ 5. 尝试在移动通道组中添加山东省网 item,但引用发送地区为河南的通道。 6. 在同一通道组中为山东省重复添加第二个省网通道。 7. 添加主通道优先级 10、备用全国通道优先级 20,并尝试添加另一个优先级 20 的全国通道。 - 8. 创建租户/应用维度路由规则。 - 9. 创建发送任务触发送链路。 + 8. 配置补发时间上限为 12 小时 30 分钟并保存,再重新打开编辑页。 + 9. 创建租户/应用维度路由规则。 + 10. 创建发送任务触发送链路。 - 预期结果: - 只能创建 mobile/unicom/telecom 通道组,三网通道组被拒绝。 - 通道组明细 carrier 必须等于通道组运营商。 - 通道组明细引用通道时必须满足通道本体能力兼容:移动组只允许 mobile/all,联通组只允许 unicom/all,电信组只允许 telecom/all。 - 省网 item 的省份必须与通道发送地区一致。 - 同一通道组内同一省份只能配置一个通道,全国通道可配置多个但优先级不能重复。 + - 省网通道和全国通道在添加/编辑页以表格展示,通道状态文案为“链接正常/通道停用”,且“链接正常”来自真实 CMPP 连接状态。 + - 补发时间上限按分钟级真实保存,12 小时 30 分钟回填为 12 小时 30 分钟。 - 路由优先命中租户/应用规则。 - 主通道 active 时选择主通道。 - 主通道 disabled 时选择备用 active 通道。 @@ -809,13 +812,13 @@ 1. 模拟普通 failed 回执,确认触发补发。 2. 模拟 unknown 状态,执行 72 小时超时补偿。 3. 将消息提交时间调整为超过 72 小时。 - 4. 将消息提交时间调整为超过通道组补发时间上限。 + 4. 将消息提交时间调整为超过通道组分钟级补发时间上限。 5. 关闭通道组失败补发后再次模拟 failed。 - 预期结果: - 普通 failed 在限制内触发补发。 - unknown 不触发补发。 - 超过 72 小时不触发补发。 - - 超过通道组补发时间上限不触发补发。 + - 超过通道组分钟级补发时间上限不触发补发。 - 通道组关闭失败补发时不触发补发。 ### TC-SEND-019 迟到旧通道回执不覆盖最终成功 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 0458253..67f64c4 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -704,3 +704,76 @@ git diff --check - 前端 build 通过,仍存在既有 Vite chunk size warning。 - `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。 + +## 2026-07-07 手机号段 Tab 和通道组补发上限 + +### 本轮修复 + +- 运营端手机号段库页面移除自定义卡片式 Tab,改用通用 `Tabs` 控件,与企业应用管理页面“短信应用/彩信应用”交互一致。 +- 通道组添加/编辑页面新增“补发时间上限(小时)”输入控件,编辑时回填 `retryTimeLimitHours`,保存时写入真实通道组接口。 +- 补发时间上限按后端现有校验限制为 1 到 72 小时。 + +### 已执行命令 + +```bash +npm run build +git diff --check +``` + +### 当前结果 + +- 前端 build 通过,仍存在既有 Vite chunk size warning。 +- `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。 + +## 2026-07-07 账单流水页面移除和列表分页 + +### 本轮修复 + +- 删除客户端账单流水页面和运营端账单流水页面,移除对应路由、菜单、占位映射和首页跳转入口。 +- 移除公开交易查询/创建接口:`GET/POST /api/admin/billing/transactions` 和 `GET /api/client/billing/transactions`。 +- 保留内部 `AccountTransaction` 写入能力,人工充值、扣费、释放、退款等真实计费动作仍可写入内部账务记录;本期不作为独立账单流水页面验收。 +- 通用 `Table` 组件新增内置分页,默认每页 10 条;服务端分页页面关闭内置分页,避免双分页。 +- 补齐手写列表和卡片列表分页:通道管理、通道组、充值记录、客户端应用、客户端充值套餐、客户端签名、客户端模板、客户端批量任务、客户端发送详情、运营端短信任务进度、运营端企业签名。 + +### 已执行命令 + +```bash +npm run build +npm --prefix api run build +npm --prefix api test -- billing.service.spec.ts --runInBand +git diff --check +``` + +### 当前结果 + +- 前端 build 通过,仍存在既有 Vite chunk size warning。 +- API build 通过。 +- BillingService 单测通过:1 个 test suite、6 个测试通过。 +- `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。 + +## 2026-07-07 通道组表格和分钟级补发上限 + +### 本轮修复 + +- 通道组添加/编辑页的省网分流、全国通道配置从卡片改为通用表格展示,行内保留编辑、删除操作。 +- 通道状态文案改为设计锚点口径“链接正常/通道停用”;“链接正常”必须来自真实 CMPP 连接状态 connected 且当前连接数大于 0,新建但未连接的 active 通道不再显示为链接正常。 +- 通道组补发时间上限从整小时升级为分钟级配置,页面交互为“小时 + 分钟”,默认 12 小时 0 分钟;后端新增 `retryTimeLimitMinutes` 持久化字段,并保留 `retryTimeLimitHours` 兼容旧调用。 +- 发送链路按分钟级上限判断是否继续补发,超过配置分钟数、超过 72 小时或关闭补发时均不再补发。 + +### 已执行命令 + +```bash +npm --prefix api run prisma:generate +npm --prefix api test -- channels.service.spec.ts send-chain.service.spec.ts --runInBand +npm --prefix api run build +npm run build +git diff --check +``` + +### 当前结果 + +- Prisma Client 已根据新 schema 生成。 +- ChannelsService 和 SendChainService 定向单测通过:2 个 test suites、35 个测试通过。 +- API build 通过。 +- 前端 build 通过,仍存在既有 Vite chunk size warning。 +- `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index b5612d6..e201c87 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -171,19 +171,6 @@ export type RechargeOrder = { tenant?: TenantOption; }; -export type AccountTransaction = { - id: string; - tenantId: string; - transactionType: string; - amountCents: number; - smsUnits: number; - balanceAfter: number; - relatedType?: string | null; - relatedId?: string | null; - remark?: string | null; - createdAt: string; -}; - export type BillingPlan = { id: string; name: string; @@ -339,6 +326,7 @@ export type ChannelGroup = DictionaryItem & { description?: string | null; retryEnabled?: boolean; retryTimeLimitHours?: number; + retryTimeLimitMinutes?: number; items?: ChannelGroupItem[]; }; @@ -555,7 +543,6 @@ export const adminApi = { listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) => request(withQuery('/admin/system-logs', query)), listAccounts: () => request('/admin/billing/accounts'), - listTransactions: (tenantId?: string) => request(withQuery('/admin/billing/transactions', { tenantId })), listManualRecharges: (tenantId?: string) => request(withQuery('/admin/billing/manual-recharges', { tenantId })), createManualRecharge: (body: { tenantId: string; amountCents: number; smsUnits?: number; operatorId?: string; remark?: string }) => request('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }), @@ -644,9 +631,9 @@ export const adminApi = { body: JSON.stringify({ reason }), }), listChannelGroups: () => request('/admin/channel-groups'), - createChannelGroup: (body: { code: string; name: string; carrier: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number }) => + createChannelGroup: (body: { code: string; name: string; carrier: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number }) => request('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }), - updateChannelGroup: (id: string, body: { code?: string; name?: string; carrier?: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; items?: Array> }) => + updateChannelGroup: (id: string, body: { code?: string; name?: string; carrier?: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number; items?: Array> }) => request(`/admin/channel-groups/${id}`, { method: 'PUT', body: JSON.stringify(body) }), deleteChannelGroup: (id: string) => request(`/admin/channel-groups/${id}`, { method: 'DELETE' }), @@ -758,8 +745,6 @@ export const clientApi = { }), listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(withQuery('/client/operations/system-logs', query), { tenantId }), - listTransactions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request('/client/billing/transactions', { tenantId }), listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/billing/orders', { tenantId }), listPlans: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => diff --git a/src/apps/admin/AdminBillingPage.tsx b/src/apps/admin/AdminBillingPage.tsx deleted file mode 100644 index c7ccd4f..0000000 --- a/src/apps/admin/AdminBillingPage.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useEffect, useState } from 'react'; -import { Breadcrumb, Table, Tag, type TableColumn } from '@/components/ui'; -import { adminApi, type TenantAccount } from '@/api/adminApi'; - -const columns: Array> = [ - { key: 'id', title: '账户编号', render: (record) => record.id }, - { key: 'name', title: '客户名称', render: (record) => record.tenant?.name ?? record.tenantId }, - { key: 'balance', title: '现金余额', render: (record) => `¥${(record.balanceCents / 100).toLocaleString('zh-CN')}` }, - { key: 'smsUnits', title: '短信余量', render: (record) => `${record.smsUnits.toLocaleString('zh-CN')} 条` }, - { key: 'creditCents', title: '授信额度', render: (record) => `¥${(record.creditCents / 100).toLocaleString('zh-CN')}` }, - { - key: 'status', - title: '账户状态', - render: (record) => ( - - {record.status === 'active' ? '正常' : '已停用'} - - ), - }, -]; - -export function AdminBillingPage() { - const [accounts, setAccounts] = useState([]); - const [error, setError] = useState(''); - - useEffect(() => { - adminApi.listAccounts() - .then((items) => { - setAccounts(items); - setError(''); - }) - .catch((failure: Error) => setError(failure.message || '账务账户加载失败')); - }, []); - - return ( -
-
-
- -
-
- {error ?

{error}

: null} -
- - - - ); -} diff --git a/src/apps/admin/AdminChannelGroupFormPage.tsx b/src/apps/admin/AdminChannelGroupFormPage.tsx index a38f0fc..60d3eae 100644 --- a/src/apps/admin/AdminChannelGroupFormPage.tsx +++ b/src/apps/admin/AdminChannelGroupFormPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Info, Pencil, Plus, Trash2 } from 'lucide-react'; import { useNavigate, useParams } from 'react-router-dom'; import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi'; -import { Breadcrumb, Button, Input, Modal, Select, Tag } from '@/components/ui'; +import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui'; type Carrier = 'mobile' | 'unicom' | 'telecom'; type ChannelStatus = 'normal' | 'stopped'; @@ -49,7 +49,7 @@ const carrierLabels: Record = { }; const statusLabels: Record = { - normal: '通道启用', + normal: '链接正常', stopped: '通道停用', }; @@ -67,45 +67,18 @@ function isCarrierCompatible(channelCarrier: string | null | undefined, carrier: } function getChannelStatus(channel?: AdminChannel): ChannelStatus { - return channel?.status === 'active' ? 'normal' : 'stopped'; + if (channel?.status !== 'active') return 'stopped'; + return (channel.connectionStates ?? []).some((connection) => + connection.status === 'connected' + && connection.desiredConnections > 0 + && connection.currentConnections > 0 + ) ? 'normal' : 'stopped'; } function StatusTag({ status }: { status: ChannelStatus }) { return {statusLabels[status]}; } -function RouteCard({ - title, - subtitle, - channel, - status, - onEdit, - onDelete, -}: { - title: string; - subtitle: string; - channel?: AdminChannel; - status: ChannelStatus; - onEdit: () => void; - onDelete: () => void; -}) { - return ( -
-
- {title} - {subtitle} -
-

{channel?.name ?? '未命名通道'}

- {channel?.sendRegion ?? '全国'} / {channel?.carrier ?? '未标记'} - -
- - -
-
- ); -} - function RouteConfigModal({ channels, carrier, @@ -135,7 +108,7 @@ function RouteConfigModal({ const channelOptions = [ { label: '请选择', value: '' }, ...selectableChannels.map((channel) => ({ - label: `${channel.name} / ${channel.carrier ?? '未标记'} / ${channel.sendRegion ?? '全国'}`, + label: `${channel.name}(${channel.code}) / ${channel.carrier ?? '未标记'} / ${channel.sendRegion ?? '全国'}`, value: channel.id, })), ]; @@ -173,6 +146,7 @@ function RouteConfigModal({ )} onClose={onClose} open + size="xl" title={modal.mode === 'edit' ? '编辑通道' : '添加通道'} >
@@ -187,7 +161,7 @@ function RouteConfigModal({
)} - setChannelId(event.target.value)} options={channelOptions} value={channelId} /> ); @@ -201,6 +175,8 @@ export function AdminChannelGroupFormPage() { const [groupName, setGroupName] = useState(''); const [carrier, setCarrier] = useState('mobile'); const [retryEnabled, setRetryEnabled] = useState(true); + const [retryLimitHours, setRetryLimitHours] = useState('12'); + const [retryLimitMinutes, setRetryLimitMinutes] = useState('0'); const [provinceRoutes, setProvinceRoutes] = useState([]); const [nationalRoutes, setNationalRoutes] = useState([]); const [modal, setModal] = useState(null); @@ -209,11 +185,66 @@ export function AdminChannelGroupFormPage() { const [error, setError] = useState(''); const channelById = useMemo(() => new Map(channels.map((channel) => [channel.id, channel])), [channels]); + const provinceColumns = useMemo>>(() => [ + { key: 'province', title: '省份', width: '160px', render: (route) => route.province }, + { + key: 'channel', + title: '通道名称', + width: '280px', + render: (route) => channelById.get(route.channelId)?.name ?? '未命名通道', + }, + { + key: 'status', + title: '通道状态', + width: '140px', + render: (route) => , + }, + { + key: 'actions', + title: '操作', + width: '160px', + render: (route) => ( +
+ + +
+ ), + }, + ], [channelById]); + const nationalColumns = useMemo>>(() => [ + { key: 'priority', title: '优先级', width: '140px', render: (route) => route.priority }, + { + key: 'channel', + title: '通道名称', + width: '280px', + render: (route) => channelById.get(route.channelId)?.name ?? '未命名通道', + }, + { + key: 'status', + title: '通道状态', + width: '140px', + render: (route) => , + }, + { + key: 'actions', + title: '操作', + width: '160px', + render: (route) => ( +
+ + +
+ ), + }, + ], [channelById]); function applyGroup(group: ChannelGroup) { setGroupName(group.name); setCarrier(group.carrier); setRetryEnabled(group.retryEnabled ?? true); + const retryMinutes = group.retryTimeLimitMinutes ?? (group.retryTimeLimitHours ?? 12) * 60; + setRetryLimitHours(String(Math.floor(retryMinutes / 60))); + setRetryLimitMinutes(String(retryMinutes % 60)); setProvinceRoutes((group.items ?? []) .filter((item) => item.province) .map((item) => ({ @@ -294,12 +325,24 @@ export function AdminChannelGroupFormPage() { setError('请输入通道组名称'); return; } + const retryHours = Number(retryLimitHours); + const retryMinutes = Number(retryLimitMinutes); + if (!Number.isInteger(retryHours) || retryHours < 0 || retryHours > 72 || !Number.isInteger(retryMinutes) || retryMinutes < 0 || retryMinutes > 59) { + setError('补发时间上限需为 0 到 72 小时、0 到 59 分钟的整数'); + return; + } + const retryTimeLimitMinutes = retryHours * 60 + retryMinutes; + if (retryTimeLimitMinutes < 1 || retryTimeLimitMinutes > 72 * 60) { + setError('补发时间上限需大于 0 分钟且不超过 72 小时'); + return; + } const payload = { name: groupName.trim(), carrier, status: 'active', retryEnabled, - retryTimeLimitHours: 72, + retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60), + retryTimeLimitMinutes, items: buildItems(), }; setSaving(true); @@ -312,7 +355,8 @@ export function AdminChannelGroupFormPage() { carrier, status: 'active', retryEnabled, - retryTimeLimitHours: 72, + retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60), + retryTimeLimitMinutes, }).then((group) => adminApi.updateChannelGroup(group.id, payload)); request @@ -351,25 +395,34 @@ export function AdminChannelGroupFormPage() { +
+ 补发时间上限 + setRetryLimitHours(event.target.value)} + suffix="小时" + type="number" + value={retryLimitHours} + /> + setRetryLimitMinutes(event.target.value)} + suffix="分钟" + type="number" + value={retryLimitMinutes} + /> + 后端限制 1 分钟到 72 小时;默认 12 小时 0 分钟。 +

省网分流配置

-
- {provinceRoutes.map((route) => ( - setProvinceRoutes((current) => current.filter((item) => item.id !== route.id))} - onEdit={() => setModal({ type: 'province', mode: 'edit', route })} - status={route.status} - subtitle="省网优先路由" - title={route.province} - /> - ))} - {provinceRoutes.length === 0 ?

暂无省网通道

: null} -
+
@@ -377,20 +430,7 @@ export function AdminChannelGroupFormPage() {

全国通道配置

-
- {nationalRoutes.map((route) => ( - setNationalRoutes((current) => current.filter((item) => item.id !== route.id))} - onEdit={() => setModal({ type: 'national', mode: 'edit', route })} - status={route.status} - subtitle="全国补发路由" - title={`优先级 ${route.priority}`} - /> - ))} - {nationalRoutes.length === 0 ?

暂无全国通道

: null} -
+
diff --git a/src/apps/admin/AdminChannelGroupsPage.tsx b/src/apps/admin/AdminChannelGroupsPage.tsx index 4a035dd..b74b5b3 100644 --- a/src/apps/admin/AdminChannelGroupsPage.tsx +++ b/src/apps/admin/AdminChannelGroupsPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; -import { Layers3, Pencil, Plus, Search, Trash2, UsersRound } from 'lucide-react'; +import { Clock3, Layers3, Pencil, Plus, RadioTower, Search, Trash2 } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; -import { Breadcrumb, Button, Input, Modal, Pagination } from '@/components/ui'; +import { Breadcrumb, Button, Input, Modal, Pagination, Tag } from '@/components/ui'; import { adminApi, type ChannelGroup } from '@/api/adminApi'; type GroupCarrier = 'mobile' | 'unicom' | 'telecom'; @@ -12,12 +12,32 @@ const carrierLabels: Record = { telecom: '电信', }; +function formatRetryLimit(group: ChannelGroup) { + if (group.retryEnabled === false) return '已关闭'; + const totalMinutes = group.retryTimeLimitMinutes ?? (group.retryTimeLimitHours ?? 12) * 60; + return `${Math.floor(totalMinutes / 60)}小时${totalMinutes % 60}分钟`; +} + +function getGroupSummary(group: ChannelGroup) { + const items = group.items ?? []; + const provinceCount = items.filter((item) => item.province).length; + const nationalCount = items.length - provinceCount; + const connectedCount = items.filter((item) => (item.channel?.connectionStates ?? []).some((connection) => + connection.status === 'connected' + && connection.desiredConnections > 0 + && connection.currentConnections > 0 + )).length; + return { connectedCount, nationalCount, provinceCount, totalCount: items.length }; +} + export function AdminChannelGroupsPage() { const navigate = useNavigate(); const [groupName, setGroupName] = useState(''); const [groups, setGroups] = useState([]); const [deleteTarget, setDeleteTarget] = useState(null); + const [page, setPage] = useState(1); const [error, setError] = useState(''); + const pageSize = 10; function loadData() { adminApi.listChannelGroups() @@ -33,6 +53,13 @@ export function AdminChannelGroupsPage() { }, []); const filteredGroups = useMemo(() => groups.filter((group) => !groupName.trim() || group.name.includes(groupName.trim())), [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]); function deleteGroup() { if (!deleteTarget) return; @@ -65,36 +92,74 @@ export function AdminChannelGroupsPage() {
-
- {filteredGroups.map((group) => ( -
-
-
- - {group.name} +
+ {visibleGroups.map((group) => { + const summary = getGroupSummary(group); + const previewItems = (group.items ?? []).slice(0, 4); + return ( +
+
+ +
+ {group.name} + {carrierLabels[group.carrier] ?? group.carrier}通道组 +
- {carrierLabels[group.carrier] ?? group.carrier} - {group.items?.length ?? 0} -
-
- {(group.items ?? []).slice(0, 5).map((item, index) => { - const channel = item.channel as { name?: string } | undefined; - return

{channel?.name ?? '未命名通道'}

; - })} - {(group.items?.length ?? 0) === 0 ?

暂无绑定通道

: null} -
-
- - -
-
- ))} + +
+
+ 省网 + {summary.provinceCount} +
+
+ 全国 + {summary.nationalCount} +
+
+ 链接正常 + {summary.connectedCount}/{summary.totalCount} +
+
+ +
+ + {group.retryEnabled === false ? '补发关闭' : '补发开启'} + + {formatRetryLimit(group)} + {summary.totalCount ? `${summary.totalCount} 个通道` : '暂无通道'} +
+ +
+ {previewItems.map((item) => ( + + {item.province ? `${item.province} / ` : `P${item.priority} / `} + {item.channel?.name ?? '未命名通道'} + + ))} + {summary.totalCount > previewItems.length ? +{summary.totalCount - previewItems.length} : null} + {summary.totalCount === 0 ? 暂无绑定通道 : null} +
+ +
+ + +
+ + ); + })}
- + = totalPages} + onNext={() => setPage((value) => Math.min(totalPages, value + 1))} + onPrevious={() => setPage((value) => Math.max(1, value - 1))} + page={currentPage} + previousDisabled={currentPage <= 1} + total={filteredGroups.length} + />
(null); const [confirmAction, setConfirmAction] = useState(null); const [logState, setLogState] = useState(null); + const [page, setPage] = useState(1); + const pageSize = 10; function loadChannels() { adminApi.listChannels() @@ -389,6 +391,13 @@ export function AdminChannelsPage() { }), [carrier, channels, keyword, status], ); + const totalPages = Math.max(1, Math.ceil(filteredChannels.length / pageSize)); + const currentPage = Math.min(page, totalPages); + const visibleChannels = filteredChannels.slice((currentPage - 1) * pageSize, currentPage * pageSize); + + useEffect(() => { + setPage(1); + }, [carrier, channels.length, keyword, status]); async function upsertChannel(nextChannel: SmsChannel) { try { @@ -495,7 +504,7 @@ export function AdminChannelsPage() { 今日发送质量 操作 - {filteredChannels.map((channel) => ( + {visibleChannels.map((channel) => (
{channel.name} @@ -531,13 +540,14 @@ export function AdminChannelsPage() {
))} -
- setKeyword(event.target.value)} placeholder={activeTab === 'segments' ? '搜索手机号段、运营商、省份或城市' : '搜索运营商、正则或备注'} prefix={} value={keyword} />
- {activeTab === 'segments' - ?
- :
} + setActiveTab(value as 'segments' | 'rules')} + value={activeTab} + items={[ + { label: `手机号段 ${segments.length}`, value: 'segments', content:
}, + { label: `运营商区分规则 ${rules.length}`, value: 'rules', content:
}, + ]} + /> ([]); const [accounts, setAccounts] = useState([]); - const [transactions, setTransactions] = useState([]); const [tenants, setTenants] = useState([]); const [enterpriseKeyword, setEnterpriseKeyword] = useState(''); const [dateRange, setDateRange] = useState({}); const [manualOpen, setManualOpen] = useState(false); const [form, setForm] = useState({ tenantId: '', amount: '', smsUnits: '0', operator: '运营', remark: '' }); + const [page, setPage] = useState(1); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -50,16 +50,14 @@ export function AdminRechargeRecordsPage() { setLoading(true); setError(''); try { - const [nextTenants, nextRecords, nextAccounts, nextTransactions] = await Promise.all([ + const [nextTenants, nextRecords, nextAccounts] = await Promise.all([ adminApi.listTenants(), adminApi.listManualRecharges(), adminApi.listAccounts(), - adminApi.listTransactions(), ]); setTenants(nextTenants); setRecords(nextRecords); setAccounts(nextAccounts); - setTransactions(nextTransactions); setForm((current) => ({ ...current, tenantId: current.tenantId || nextTenants[0]?.id || '' })); } catch (err) { setError(err instanceof Error ? err.message : '充值记录加载失败'); @@ -84,6 +82,14 @@ export function AdminRechargeRecordsPage() { }), [dateRange.end, dateRange.start, enterpriseKeyword, records, tenants], ); + const pageSize = 10; + const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize)); + const currentPage = Math.min(page, totalPages); + const visibleRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize); + + useEffect(() => { + setPage(1); + }, [dateRange.end, dateRange.start, enterpriseKeyword, records.length]); function resetFilters() { setEnterpriseKeyword(''); @@ -151,16 +157,15 @@ export function AdminRechargeRecordsPage() { ) : filteredRows.length === 0 ? ( - ) : filteredRows.map((record) => { + ) : visibleRows.map((record) => { const account = accounts.find((item) => item.tenantId === record.tenantId); - const transaction = transactions.find((item) => item.relatedId === record.id); const tenantName = record.tenant?.name ?? tenants.find((tenant) => tenant.id === record.tenantId)?.name ?? record.tenantId; return ( - + @@ -171,18 +176,14 @@ export function AdminRechargeRecordsPage() {
正在加载真实充值记录...
暂无真实充值记录
{tenantName} {new Date(record.paidAt ?? record.createdAt).toLocaleString('zh-CN')} {formatAmount(record.amountCents / 100)}{formatAmount((transaction?.balanceAfter ?? account?.balanceCents ?? 0) / 100)}{formatAmount((account?.balanceCents ?? 0) / 100)} 人工充值 {record.operatorId || '运营'}
-
-