diff --git a/api/src/billing/billing.service.spec.ts b/api/src/billing/billing.service.spec.ts index 955feb7..cc60c94 100644 --- a/api/src/billing/billing.service.spec.ts +++ b/api/src/billing/billing.service.spec.ts @@ -35,6 +35,9 @@ function createPrismaMock() { findMany: jest.fn(), create: jest.fn(), }, + operationLog: { + create: jest.fn(), + }, }; } @@ -131,6 +134,13 @@ describe('BillingService', () => { relatedType: 'recharge_order', }), }); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'billing.manual_recharge', + resource: 'recharge_order', + resourceId: 'order-1', + }), + }); }); it('writes freeze, charge, release, refund, and adjustment transactions', async () => { diff --git a/api/src/billing/billing.service.ts b/api/src/billing/billing.service.ts index 63f1b07..8d329ca 100644 --- a/api/src/billing/billing.service.ts +++ b/api/src/billing/billing.service.ts @@ -199,8 +199,8 @@ export class BillingService { return order; } - createManualRecharge(data: CreateManualRechargeDto) { - return this.createRechargeOrder({ + async createManualRecharge(data: CreateManualRechargeDto) { + const order = await this.createRechargeOrder({ tenantId: data.tenantId, amountCents: data.amountCents, smsUnits: data.smsUnits ?? 0, @@ -208,6 +208,22 @@ export class BillingService { operatorId: data.operatorId, remark: data.remark, }); + await this.prisma.operationLog.create({ + data: { + tenantId: data.tenantId, + userId: data.operatorId, + action: 'billing.manual_recharge', + resource: 'recharge_order', + resourceId: order.id, + detail: { + amountCents: data.amountCents, + smsUnits: data.smsUnits ?? 0, + orderNo: order.orderNo, + remark: data.remark, + } as Prisma.InputJsonValue, + }, + }); + return order; } estimateSmsCost(data: EstimateSmsCostDto) { diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index 4a3c4a6..2f57367 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -92,6 +92,14 @@ function createPrismaMock() { } describe('ChannelsService', () => { + it('rejects incomplete channel creation input with readable 400 errors', async () => { + const prisma = createPrismaMock(); + const service = new ChannelsService(prisma as never); + + expect(() => service.createChannel({ name: '缺字段通道' } as never)).toThrow('Missing required channel fields'); + expect(prisma.smsChannel.create).not.toHaveBeenCalled(); + }); + it('creates CMPP channels and route rules with first-version defaults', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index e8ba3c6..f98be71 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; @@ -127,6 +127,17 @@ export class ChannelsService { } createChannel(data: CreateChannelDto) { + const missingFields = ['code', 'name', 'gatewayHost', 'gatewayPort', 'account', 'passwordCipher', 'srcId'].filter((field) => { + const value = data[field as keyof CreateChannelDto]; + return value === undefined || value === null || value === ''; + }); + if (missingFields.length > 0) { + throw new BadRequestException(`Missing required channel fields: ${missingFields.join(', ')}`); + } + const gatewayPort = Number(data.gatewayPort); + if (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535) { + throw new BadRequestException('gatewayPort must be an integer between 1 and 65535'); + } return this.prisma.smsChannel.create({ data: { code: data.code, @@ -134,7 +145,7 @@ export class ChannelsService { carrier: data.carrier, protocol: data.protocol ?? 'CMPP', gatewayHost: data.gatewayHost, - gatewayPort: data.gatewayPort, + gatewayPort, enterpriseCode: data.enterpriseCode, account: data.account, passwordCipher: data.passwordCipher, diff --git a/api/src/operations/admin-operations.controller.ts b/api/src/operations/admin-operations.controller.ts index c11d4e0..cbec5fb 100644 --- a/api/src/operations/admin-operations.controller.ts +++ b/api/src/operations/admin-operations.controller.ts @@ -39,6 +39,11 @@ export class AdminOperationsController { return this.operations.dashboard({ tenantId }); } + @Get('dashboard/statistics') + dashboardStatistics(@Query('tenantId') tenantId?: string) { + return this.operations.dashboard({ tenantId }); + } + @Get('statistics') statistics(@Query('tenantId') tenantId?: string, @Query('groupBy') groupBy?: string) { return this.operations.statistics({ tenantId, groupBy }); @@ -72,3 +77,22 @@ export class AdminOperationsController { } } +@ApiTags('admin-system-logs') +@Controller('admin/system-logs') +export class AdminSystemLogsController { + constructor(private readonly operations: OperationsService) {} + + @Get() + list( + @Query('tenantId') tenantId?: string, + @Query('userId') userId?: string, + @Query('keyword') keyword?: string, + @Query('level') level?: string, + @Query('module') module?: string, + @Query('range') range?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.operations.systemLogs({ tenantId, userId, keyword, level, module, range, page: Number(page), pageSize: Number(pageSize) }); + } +} diff --git a/api/src/operations/client-operations.controller.ts b/api/src/operations/client-operations.controller.ts index cf33cc4..c32af47 100644 --- a/api/src/operations/client-operations.controller.ts +++ b/api/src/operations/client-operations.controller.ts @@ -22,5 +22,22 @@ export class ClientOperationsController { listUplinkMessages(@TenantId() tenantId?: string, @Query('channelId') channelId?: string) { return this.operations.listUplinkMessages({ tenantId, channelId }); } -} + @Get('dashboard') + dashboard(@TenantId() tenantId?: string) { + return this.operations.dashboard({ tenantId }); + } + + @Get('system-logs') + systemLogs( + @TenantId() tenantId?: string, + @Query('keyword') keyword?: string, + @Query('level') level?: string, + @Query('module') module?: string, + @Query('range') range?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.operations.systemLogs({ tenantId, keyword, level, module, range, page: Number(page), pageSize: Number(pageSize) }); + } +} diff --git a/api/src/operations/operations.module.ts b/api/src/operations/operations.module.ts index c6efaca..19c9217 100644 --- a/api/src/operations/operations.module.ts +++ b/api/src/operations/operations.module.ts @@ -1,14 +1,13 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../prisma/prisma.module'; -import { AdminOperationsController } from './admin-operations.controller'; +import { AdminOperationsController, AdminSystemLogsController } from './admin-operations.controller'; import { ClientOperationsController } from './client-operations.controller'; import { OperationsService } from './operations.service'; @Module({ imports: [PrismaModule], - controllers: [AdminOperationsController, ClientOperationsController], + controllers: [AdminOperationsController, AdminSystemLogsController, ClientOperationsController], providers: [OperationsService], exports: [OperationsService], }) export class OperationsModule {} - diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index e44dba9..27503af 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -3,7 +3,7 @@ import { OperationsService } from './operations.service'; function createPrismaMock() { return { smsBatchTask: { - findMany: jest.fn(), + findMany: jest.fn().mockResolvedValue([{ id: 'task-1', taskNo: 'BATCH-1' }]), count: jest.fn().mockResolvedValue(3), }, smsMessageRecord: { @@ -25,12 +25,38 @@ function createPrismaMock() { accountTransaction: { aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: -20, smsUnits: -2 } }), }, + tenantAccount: { + findMany: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', balanceCents: 1000, tenant: { name: '租户A' } }]), + }, + rechargeOrder: { + findMany: jest.fn().mockResolvedValue([{ id: 'order-1', tenantId: 'tenant-1', amountCents: 1000 }]), + }, + smsTemplate: { + count: jest.fn().mockResolvedValue(1), + }, + smsSignature: { + count: jest.fn().mockResolvedValue(1), + }, + enterpriseCertification: { + count: jest.fn().mockResolvedValue(1), + }, cmppConnectionState: { groupBy: jest.fn().mockResolvedValue([{ status: 'online', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }]), }, operationLog: { - findMany: jest.fn(), - groupBy: jest.fn(), + findMany: jest.fn().mockResolvedValue([{ + id: 'log-1', + tenantId: 'tenant-1', + tenant: { name: '租户A' }, + user: { displayName: '运营' }, + action: 'billing.manual_recharge', + resource: 'recharge_order', + resourceId: 'order-1', + detail: { amountCents: 1000 }, + createdAt: new Date('2026-07-02T01:00:00.000Z'), + }]), + count: jest.fn().mockResolvedValue(1), + groupBy: jest.fn().mockResolvedValue([{ resource: 'recharge_order', _count: { _all: 1 } }]), }, }; } @@ -72,6 +98,7 @@ describe('OperationsService', () => { expect.objectContaining({ taskCount: 3, uplinkCount: 1, + pendingAuditCount: 6, gatewayConnections: [{ status: 'online', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }], }), ); @@ -104,4 +131,26 @@ describe('OperationsService', () => { }), ); }); + + it('returns paginated operation logs with normalized detail cards', async () => { + const prisma = createPrismaMock(); + const service = new OperationsService(prisma as never); + + await expect(service.systemLogs({ tenantId: 'tenant-1', keyword: '充值', page: 1, pageSize: 5 })).resolves.toEqual( + expect.objectContaining({ + total: 1, + page: 1, + pageSize: 5, + modules: ['recharge_order'], + items: [ + expect.objectContaining({ + level: 'success', + tenant: '租户A', + module: 'recharge_order', + action: 'billing.manual_recharge', + }), + ], + }), + ); + }); }); diff --git a/api/src/operations/operations.service.ts b/api/src/operations/operations.service.ts index 6dd26f1..5324445 100644 --- a/api/src/operations/operations.service.ts +++ b/api/src/operations/operations.service.ts @@ -15,6 +15,17 @@ export interface TraceQuery extends MessageQuery { messageId?: string; } +export interface OperationLogQuery { + tenantId?: string; + userId?: string; + keyword?: string; + level?: string; + module?: string; + range?: string; + page?: number; + pageSize?: number; +} + @Injectable() export class OperationsService { constructor(private readonly prisma: PrismaService) {} @@ -71,8 +82,22 @@ export class OperationsService { } async dashboard(query: { tenantId?: string }) { + const sinceToday = startOfToday(); const messageWhereClause = messageWhere({ tenantId: query.tenantId }); - const [taskCount, messageGroups, uplinkCount, billingAggregate, transactionAggregate, connectionGroups] = await Promise.all([ + const todayMessageWhereClause = { ...messageWhereClause, queuedAt: { gte: sinceToday } }; + const [ + taskCount, + messageGroups, + todayMessageGroups, + uplinkCount, + billingAggregate, + transactionAggregate, + connectionGroups, + pendingAuditCount, + tenantAccounts, + recentTasks, + recentRecharges, + ] = await Promise.all([ this.prisma.smsBatchTask.count({ where: { tenantId: query.tenantId } }), this.prisma.smsMessageRecord.groupBy({ by: ['status'], @@ -80,6 +105,12 @@ export class OperationsService { _count: { _all: true }, _sum: { amountCents: true, billingUnits: true }, }), + this.prisma.smsMessageRecord.groupBy({ + by: ['status'], + where: todayMessageWhereClause, + _count: { _all: true }, + _sum: { amountCents: true, billingUnits: true }, + }), this.prisma.smsUplinkMessage.count({ where: { tenantId: query.tenantId } }), this.prisma.smsBillingRecord.aggregate({ where: { tenantId: query.tenantId }, @@ -97,14 +128,50 @@ export class OperationsService { _count: { _all: true }, _sum: { currentConnections: true, desiredConnections: true }, }), + this.countPendingAudits(query.tenantId), + this.prisma.tenantAccount.findMany({ + where: query.tenantId ? { tenantId: query.tenantId } : undefined, + include: { tenant: true }, + orderBy: { updatedAt: 'desc' }, + take: 20, + }), + this.prisma.smsBatchTask.findMany({ + where: query.tenantId ? { tenantId: query.tenantId } : undefined, + include: { application: true, messages: { take: 1, include: { channel: true } } }, + orderBy: { createdAt: 'desc' }, + take: 10, + }), + this.prisma.rechargeOrder.findMany({ + where: { + tenantId: query.tenantId, + payMethod: 'manual_topup', + }, + include: { tenant: true }, + orderBy: { createdAt: 'desc' }, + take: 10, + }), ]); + const todayTotals = summarizeMessageGroups(todayMessageGroups); return { taskCount, messageStatus: messageGroups, + today: { + sent: todayTotals.total, + delivered: todayTotals.delivered, + failed: todayTotals.failed, + unknown: todayTotals.unknown, + successRate: todayTotals.total > 0 ? Number(((todayTotals.delivered / todayTotals.total) * 100).toFixed(1)) : 0, + spendCents: todayTotals.amountCents, + billingUnits: todayTotals.billingUnits, + }, uplinkCount, billing: billingAggregate, transactions: transactionAggregate, gatewayConnections: connectionGroups, + pendingAuditCount, + accounts: tenantAccounts, + recentTasks, + recentRecharges, }; } @@ -142,6 +209,51 @@ export class OperationsService { }); } + async systemLogs(query: OperationLogQuery) { + const page = Math.max(1, Number(query.page ?? 1)); + const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10))); + const where: Prisma.OperationLogWhereInput = { + tenantId: query.tenantId, + userId: query.userId, + createdAt: createdAtRange(query.range), + resource: query.module && query.module !== 'all' ? query.module : undefined, + OR: query.keyword ? [ + { action: { contains: query.keyword } }, + { resource: { contains: query.keyword } }, + { resourceId: { contains: query.keyword } }, + { tenant: { name: { contains: query.keyword } } }, + { user: { displayName: { contains: query.keyword } } }, + { user: { username: { contains: query.keyword } } }, + ] : undefined, + }; + const [items, total, modules] = await Promise.all([ + this.prisma.operationLog.findMany({ + where, + include: { tenant: true, user: true }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.operationLog.count({ where }), + this.prisma.operationLog.groupBy({ + by: ['resource'], + where: { tenantId: query.tenantId }, + _count: { _all: true }, + orderBy: { resource: 'asc' }, + }), + ]); + const normalizedItems = items + .map((item) => normalizeOperationLog(item)) + .filter((item) => !query.level || query.level === 'all' || item.level === query.level); + return { + items: normalizedItems, + total: query.level && query.level !== 'all' ? normalizedItems.length : total, + page, + pageSize, + modules: modules.map((item) => item.resource), + }; + } + auditSummary(query: { tenantId?: string }) { return this.prisma.operationLog.groupBy({ by: ['action', 'resource'], @@ -223,6 +335,15 @@ export class OperationsService { }, }; } + + private countPendingAudits(tenantId?: string) { + return Promise.all([ + this.prisma.smsTemplate.count({ where: { tenantId, auditStatus: 'pending' } }), + this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }), + this.prisma.enterpriseCertification.count({ where: { tenantId, status: 'pending' } }), + this.prisma.smsBatchTask.count({ where: { tenantId, auditStatus: 'pending' } }), + ]).then((counts) => counts.reduce((sum, value) => sum + value, 0)); + } } function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput { @@ -245,3 +366,68 @@ function normalizeGroupBy(groupBy?: string) { } return 'channelId'; } + +function startOfToday() { + const date = new Date(); + date.setHours(0, 0, 0, 0); + return date; +} + +function createdAtRange(range?: string): Prisma.DateTimeFilter | undefined { + if (!range || range === 'all') { + return undefined; + } + const date = new Date(); + date.setHours(0, 0, 0, 0); + if (range === '7d') { + date.setDate(date.getDate() - 6); + } else if (range === '30d') { + date.setDate(date.getDate() - 29); + } + return { gte: date }; +} + +function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all: number }; _sum: { amountCents: number | null; billingUnits: number | null } }>) { + return groups.reduce( + (summary, group) => { + const count = group._count._all; + summary.total += count; + summary.amountCents += group._sum.amountCents ?? 0; + summary.billingUnits += group._sum.billingUnits ?? 0; + if (group.status === 'delivered') { + summary.delivered += count; + } else if (['undelivered', 'submit_failed', 'timeout', 'failed', 'rejected'].includes(group.status)) { + summary.failed += count; + } else if (group.status === 'unknown') { + summary.unknown += count; + } + return summary; + }, + { total: 0, delivered: 0, failed: 0, unknown: 0, amountCents: 0, billingUnits: 0 }, + ); +} + +function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { tenant: true; user: true } }>) { + const detail = (log.detail ?? {}) as Record; + const result = String(detail.result ?? detail.status ?? ''); + const level = result.includes('fail') || log.action.includes('failed') || log.action.includes('reject') + ? 'error' + : log.action.includes('warning') || log.action.includes('risk') + ? 'warning' + : log.action.includes('approve') || log.action.includes('recharge') || log.action.includes('connected') + ? 'success' + : 'info'; + return { + id: log.id, + time: log.createdAt, + level, + tenant: log.tenant?.name ?? (log.tenantId ? log.tenantId : '平台'), + module: log.resource, + operator: log.user?.displayName ?? log.user?.username ?? log.userId ?? 'system', + action: log.action, + resourceId: log.resourceId ?? '', + detail, + ip: log.ipAddress ?? '', + userAgent: log.userAgent ?? '', + }; +} diff --git a/api/src/sms-config/admin-sms-config.controller.ts b/api/src/sms-config/admin-sms-config.controller.ts index 5204326..63c635f 100644 --- a/api/src/sms-config/admin-sms-config.controller.ts +++ b/api/src/sms-config/admin-sms-config.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { ReviewDto, SmsConfigService, StatusChangeDto } from './sms-config.service'; @@ -8,8 +8,28 @@ export class AdminSmsConfigController { constructor(private readonly smsConfig: SmsConfigService) {} @Get('enterprise-applications') - listApplications(@Query('tenantId') tenantId?: string) { - return this.smsConfig.listApplications(tenantId); + listApplications(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string) { + return this.smsConfig.listApplications({ tenantId, keyword, includeConnections: true }); + } + + @Get('enterprise-applications/:id/connections') + listApplicationConnections(@Param('id') applicationId: string) { + return this.smsConfig.listApplicationConnections(applicationId); + } + + @Get('enterprise-applications/:id/cmpp-params') + getApplicationCmppParams(@Param('id') applicationId: string) { + return this.smsConfig.getApplicationCmppParams(applicationId); + } + + @Post('enterprise-applications/:id/connections/:connectionId/disconnect') + disconnectApplicationConnection(@Param('id') applicationId: string, @Param('connectionId') connectionId: string, @Body() body: StatusChangeDto) { + return this.smsConfig.disconnectApplicationConnection(applicationId, connectionId, body); + } + + @Delete('enterprise-applications/:id/connections/:connectionId') + deleteApplicationConnection(@Param('id') applicationId: string, @Param('connectionId') connectionId: string, @Body() body: StatusChangeDto) { + return this.smsConfig.disconnectApplicationConnection(applicationId, connectionId, body); } @Get('enterprise-signatures') diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index fc55c11..dd503c2 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -2,6 +2,24 @@ import { SmsConfigService } from './sms-config.service'; function createPrismaMock() { return { + smsApplication: { + findMany: jest.fn().mockResolvedValue([{ + id: 'app-1', + tenantId: 'tenant-1', + name: '应用A', + status: 'active', + tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, + messageRecords: [{ status: 'delivered' }, { status: 'undelivered' }], + }]), + findUnique: jest.fn().mockResolvedValue({ + id: 'app-1', + tenantId: 'tenant-1', + name: '应用A', + status: 'active', + secretHash: 'secret-hash', + tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, + }), + }, smsSignature: { findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', auditStatus: 'pending' }), update: jest.fn(), @@ -17,6 +35,27 @@ function createPrismaMock() { user: { findUnique: jest.fn().mockResolvedValue(null), }, + cmppConnectionState: { + findMany: jest.fn().mockResolvedValue([{ channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1', status: 'online', currentConnections: 1, desiredConnections: 1 }]), + findFirst: jest.fn().mockResolvedValue({ channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1' }), + update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'conn-state-1', ...data })), + }, + smsChannel: { + findFirst: jest.fn().mockResolvedValue({ + id: 'channel-1', + gatewayHost: '127.0.0.1', + gatewayPort: 7890, + enterpriseCode: 'EC', + account: 'sp', + passwordCipher: 'cipher', + srcId: '10690000', + cmppVersion: '3.0', + config: { maxConnections: 2 }, + }), + }, + operationLog: { + create: jest.fn(), + }, }; } @@ -32,4 +71,51 @@ describe('SmsConfigService', () => { expect(prisma.smsSignature.update).not.toHaveBeenCalled(); expect(prisma.auditRecord.create).not.toHaveBeenCalled(); }); + + it('lists enterprise applications with real CMPP connection state', async () => { + const prisma = createPrismaMock(); + const service = new SmsConfigService(prisma as never); + + await expect(service.listApplications({ includeConnections: true })).resolves.toEqual([ + expect.objectContaining({ + id: 'app-1', + cmppStatus: 'connected', + sentToday: 2, + deliveryRate: 50, + cmppConnections: [expect.objectContaining({ connectionId: 'conn-a' })], + }), + ]); + }); + + it('returns CMPP params from persisted application and channel config', async () => { + const prisma = createPrismaMock(); + const service = new SmsConfigService(prisma as never); + + await expect(service.getApplicationCmppParams('app-1')).resolves.toEqual(expect.objectContaining({ + applicationId: 'app-1', + tenantName: '租户A', + gatewayHost: '127.0.0.1', + gatewayPort: 7890, + maxConnections: 2, + })); + }); + + it('disconnects application CMPP connections and writes operation logs', async () => { + const prisma = createPrismaMock(); + const service = new SmsConfigService(prisma as never); + + await service.disconnectApplicationConnection('app-1', 'conn-a', { status: 'disconnected', reason: 'manual' }); + + expect(prisma.cmppConnectionState.update).toHaveBeenCalledWith({ + where: { channelId_connectionId: { channelId: 'channel-1', connectionId: 'conn-a' } }, + data: expect.objectContaining({ status: 'disconnected', currentConnections: 0, lastError: 'manual' }), + }); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'cmpp_connection.disconnected', + resource: 'cmpp_connection', + resourceId: 'channel-1:conn-a', + }), + }); + }); }); diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index ee00327..f13700c 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -57,16 +57,56 @@ export interface TemplateListQuery { keyword?: string; } +export interface ApplicationListQuery { + tenantId?: string; + keyword?: string; + includeConnections?: boolean; +} + @Injectable() export class SmsConfigService { constructor(private readonly prisma: PrismaService) {} - listApplications(tenantId?: string) { + async listApplications(queryOrTenantId?: string | ApplicationListQuery) { + const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {}; return this.prisma.smsApplication.findMany({ - where: tenantId ? { tenantId } : undefined, - include: { ipAllowlist: true }, + where: { + tenantId: query.tenantId, + OR: query.keyword ? [ + { name: { contains: query.keyword } }, + { tenant: { name: { contains: query.keyword } } }, + ] : undefined, + }, + include: { + tenant: true, + ipAllowlist: true, + messageRecords: { where: { queuedAt: { gte: startOfToday() } }, take: 1000 }, + }, orderBy: { createdAt: 'desc' }, take: 100, + }).then(async (applications) => { + if (!query.includeConnections) { + return applications; + } + const tenantIds = [...new Set(applications.map((application) => application.tenantId))]; + const connections = await this.prisma.cmppConnectionState.findMany({ + where: { tenantId: { in: tenantIds } }, + include: { channel: true }, + orderBy: { updatedAt: 'desc' }, + take: 500, + }); + return applications.map((application) => { + const appConnections = connections.filter((connection) => connection.tenantId === application.tenantId); + const todayTotal = application.messageRecords.length; + const delivered = application.messageRecords.filter((message) => message.status === 'delivered').length; + return { + ...application, + cmppConnections: appConnections, + cmppStatus: normalizeApplicationCmppStatus(appConnections, application.status), + sentToday: todayTotal, + deliveryRate: todayTotal > 0 ? Number(((delivered / todayTotal) * 100).toFixed(1)) : 0, + }; + }); }); } @@ -121,6 +161,89 @@ export class SmsConfigService { return updated; } + async listApplicationConnections(applicationId: string) { + const application = await this.prisma.smsApplication.findUnique({ + where: { id: applicationId }, + include: { tenant: true }, + }); + if (!application) { + throw new NotFoundException('Application not found'); + } + const connections = await this.prisma.cmppConnectionState.findMany({ + where: { tenantId: application.tenantId }, + include: { channel: true }, + orderBy: { updatedAt: 'desc' }, + take: 100, + }); + return { + application, + connections, + summary: { + desiredConnections: connections.reduce((sum, connection) => sum + connection.desiredConnections, 0), + currentConnections: connections.reduce((sum, connection) => sum + connection.currentConnections, 0), + status: normalizeApplicationCmppStatus(connections, application.status), + }, + }; + } + + async getApplicationCmppParams(applicationId: string) { + const application = await this.prisma.smsApplication.findUnique({ + where: { id: applicationId }, + include: { tenant: true }, + }); + if (!application) { + throw new NotFoundException('Application not found'); + } + const channel = await this.prisma.smsChannel.findFirst({ + where: { status: { not: 'deleted' } }, + orderBy: { createdAt: 'desc' }, + }); + return { + applicationId: application.id, + applicationName: application.name, + tenantId: application.tenantId, + tenantName: application.tenant.name, + appCode: application.id, + gatewayHost: channel?.gatewayHost ?? '', + gatewayPort: channel?.gatewayPort ?? 0, + enterpriseCode: channel?.enterpriseCode ?? application.tenant.code, + account: channel?.account ?? application.tenant.code, + passwordCipher: channel?.passwordCipher ?? application.secretHash, + srcId: channel?.srcId ?? '', + maxConnections: channel?.config && typeof channel.config === 'object' && 'maxConnections' in channel.config ? Number(channel.config.maxConnections) : 1, + heartbeatSeconds: 30, + windowSize: 16, + protocolVersion: channel?.cmppVersion ?? '3.0', + }; + } + + async disconnectApplicationConnection(applicationId: string, connectionId: string, data: StatusChangeDto = { status: 'disconnected' }) { + const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } }); + if (!application) { + throw new NotFoundException('Application not found'); + } + const connection = await this.prisma.cmppConnectionState.findFirst({ + where: { tenantId: application.tenantId, connectionId }, + }); + if (!connection) { + throw new NotFoundException('Connection not found'); + } + const updated = await this.prisma.cmppConnectionState.update({ + where: { channelId_connectionId: { channelId: connection.channelId, connectionId } }, + data: { + status: 'disconnected', + currentConnections: 0, + lastDisconnectedAt: new Date(), + lastError: data.reason, + }, + }); + await this.writeOperationLog(application.tenantId, data.operatorId, 'cmpp_connection.disconnected', 'cmpp_connection', `${connection.channelId}:${connectionId}`, { + applicationId, + reason: data.reason, + }); + return updated; + } + listSignatures(tenantId?: string) { return this.prisma.smsSignature.findMany({ where: tenantId ? { tenantId } : undefined, @@ -407,3 +530,22 @@ function inferTemplateVariables(content: string): TemplateVariableInput[] { const matches = content.match(/\$\{[a-zA-Z0-9_]+\}/g) ?? []; return [...new Set(matches)].map((match) => ({ name: match.slice(2, -1), required: true })); } + +function startOfToday() { + const date = new Date(); + date.setHours(0, 0, 0, 0); + return date; +} + +function normalizeApplicationCmppStatus(connections: Array<{ status: string; currentConnections: number }>, applicationStatus: string) { + if (applicationStatus !== 'active') { + return 'inactive'; + } + if (connections.some((connection) => ['online', 'connected', 'open'].includes(connection.status) && connection.currentConnections > 0)) { + return 'connected'; + } + if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'reconnecting'].includes(connection.status))) { + return 'degraded'; + } + return 'disconnected'; +} diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 0c122ca..8b9b070 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -70,7 +70,7 @@ npm run test:gateway - Gateway:`npm run spike:gateway` 通过。 - 阶段 8 完整验证:`npm run verify:phase8` 通过,其中 BullMQ spike 15000 条消息、并发 500、端到端 705.65 TPS,满足 500 TPS。 - 前端 build 通过,仍存在既有 Vite chunk size warning。 -- API 测试均使用 mock,不要求 PostgreSQL/Redis/MinIO 在线。 +- API Jest 使用 mock 依赖的结果仅代表单元/轻集成测试通过;系统功能验收仍要求 PostgreSQL/Redis/MinIO 和真实 API smoke 通过。 - 真实 PostgreSQL/Redis/MinIO smoke 通过: - PostgreSQL `localhost:5432`、Redis `localhost:6379`、MinIO `localhost:9000/9001` 端口均连通。 - `npm --prefix api run prisma:migrate:deploy` 通过,无待应用迁移。 @@ -128,7 +128,7 @@ npm run test:gateway - P1/P2 补齐: - 新增企业认证模型/API,提交、审核通过、驳回会同步 `Tenant.certificationStatus`,发送前强制认证通过。 - 新增客户侧导入预览/确认入口,覆盖 CSV/TXT 文本解析、20MB 限制、重复/非法/黑名单/变量缺失提示。 - - 新增客户/通道 CMPP 连接状态模型/API,Gateway/mock 可回写连接状态,运营 dashboard 聚合连接状态。 + - 新增客户/通道 CMPP 连接状态模型/API,Gateway 或本地 Gateway 模拟器可通过真实 API 回写连接状态,运营 dashboard 聚合连接状态。 - 新增应用密钥重置、应用/签名/模板状态变化、通道启停接口,并写入系统日志。 - 无效 `createdById`、`reviewerId` 改为明确 400,不再冒泡数据库外键 500。 @@ -172,7 +172,7 @@ npm run test:gateway ### 剩余说明 - 客户侧导入当前提供 API 级文本预览/确认闭环;浏览器端真实文件选择、GBK 二进制转码和错误文件下载仍需前端/E2E 后续覆盖。 -- Gateway 连接状态通过 NestJS API 支持 mock/Gateway 回写;真实运营商 SMSC 联调仍需运营商测试环境。 +- Gateway 连接状态通过 NestJS API 支持 Go Gateway 或本地模拟器回写;真实运营商 SMSC 联调仍需运营商测试环境。 ## 2026-07-02 运营端优化转真实后端补齐 @@ -186,7 +186,7 @@ npm run test:gateway - 安全控制补齐真实 API:敏感词、全局黑名单、企业黑名单支持 keyword/status 查询、创建、启停/软删除,并写操作日志。 - 模板审核补齐真实查询:运营端模板列表支持 keyword/status,并返回企业、应用、签名信息;前端模板审核页已改为调用真实 API。 - 企业认证审核补齐真实查询:列表支持 keyword/status,详情返回企业信息和认证 materials;前端企业认证审核页已改为调用真实 API。 -- 前端新增 `/api` Vite 代理和 `src/api/adminApi.ts`,通道管理、模板审核、企业认证审核优先调用真实 API,API 不可用时仅保留静态兜底避免开发预览空白。 +- 前端新增 `/api` Vite 代理和 `src/api/adminApi.ts`,通道管理、模板审核、企业认证审核应调用真实 API;API 不可用时页面应展示错误态或空态,静态兜底不能作为验收通过依据。 ### 新增/更新测试 @@ -219,3 +219,109 @@ npm run build - 已将今天的验收点补入 `docs/system-functional-test-cases.md`: - 新增 TC-CLIENT-010 到 TC-CLIENT-011。 - 新增 TC-ADMIN-014 到 TC-ADMIN-022。 + +## 2026-07-02 新增浏览器和业务闭环用例执行 + +### 执行环境 + +- API:`npm --prefix api run start:dev`,监听 `http://localhost:3000/api`。 +- 前端:`npm run build` 后使用 `npm run preview -- --port 4173`,访问 `http://localhost:4173`。 +- Browser 插件:可连接本地 tab,但对 Vite dev 页 `Page.navigate` 超时;改用临时目录 Playwright 包加本机 Chrome 执行浏览器 smoke,未修改项目依赖。 +- PostgreSQL:本地 `localhost:5432` 可用,API smoke 使用真实数据库。 + +### 已执行命令 + +```bash +npm run build + +# 临时目录 C:\Users\hectorzhao\AppData\Local\Temp\cmpp-pw-smoke +npm init -y +npm install playwright --no-save +node +``` + +### 通过用例 + +| 用例 | 结果 | 覆盖点 | +| --- | --- | --- | +| TC-DASHBOARD-CLIENT-UI | UI-SMOKE PASS / BACKEND GAP | 客户端 Dashboard 可渲染账户余额、今日发送、账户状态,但页面数据仍需确认全部来自真实 API。 | +| TC-DASHBOARD-ADMIN-UI | UI-SMOKE PASS / BACKEND GAP | 运营端 Dashboard 可渲染今日发送总量、总体成功率、企业消费排行、通道运行,但当前源码仍存在 mock 数据路径。 | +| TC-BILLING-MANUAL-UI | UI-SMOKE PASS / BACKEND GAP | 运营端人工充值弹窗填写后,前端表格新增企业、金额、操作人和备注;该页面当前未调用真实充值 API。 | +| TC-LOG-ADMIN-UI | UI-SMOKE PASS / BACKEND GAP | 运营端系统日志页面可展示人工充值、账户计费等记录;页面数据仍需接真实日志 API。 | +| TC-LOG-CLIENT-UI | UI-SMOKE PASS / BACKEND GAP | 客户端系统日志页面可渲染并展示客户侧日志记录;页面数据仍需接真实日志 API。 | +| TC-CMPP-STATUS-UI | UI-SMOKE PASS / BACKEND GAP | 企业应用管理可展示 CMPP 状态和连接数量并打开连接详情;页面当前仍有本地初始数据路径。 | +| TC-FRONTEND-CONSOLE | PASS | 关键页面无相关 console error/pageerror;仅忽略 favicon 404。 | +| TC-BILLING-MANUAL-API | PASS | 人工充值无需审批:确认后账户余额、短信条数、充值单、账户流水和 Dashboard transactions 聚合同步更新。 | +| TC-CMPP-STATUS-API | PASS | 通道创建、Gateway 连接状态回写、按通道/客户查询、链接日志和 Dashboard gatewayConnections 聚合通过。 | + +### 发现和说明 + +- `npm run dev` 在本机 5173 被占用后切换到 5174,Vite 首次依赖 bundling 长时间未完成,浏览器看到白屏;生产构建和 preview 渲染正常。 +- 运营端人工充值页面当前是前端本地状态 smoke,不能作为系统功能通过;真实入账闭环通过 `POST /api/admin/billing/manual-recharges` 验证。 +- 人工充值不需要审批,测试口径已同步修正为“有权限确认即入账,不产生 pending 审批态”。 + +### 真实后端缺口和 Bug 清单 + +| 编号 | 严重级别 | 问题 | 证据 | 期望修复 | +| --- | --- | --- | --- | --- | +| BUG-FE-001 | P0 | 运营端人工充值页面未调用真实后端,提交后只更新前端本地表格状态。 | `src/apps/admin/AdminRechargeRecordsPage.tsx` 使用 `rechargeRecordsSeed` 和 `useState`,`submitManualRecharge` 只 `setRecords`。 | 页面提交调用 `POST /api/admin/billing/manual-recharges`,成功后刷新真实充值记录、账户余额、流水和日志。 | +| BUG-FE-002 | P0 | 运营端 Dashboard 仍使用 mock service 和静态排行,不能证明真实统计准确。 | `src/apps/admin/AdminHome.tsx` 引用 `adminService`、`hourlySendTrend`、`auditTrend`,指标从前端数组计算。 | 接入 `GET /api/admin/operations/dashboard/statistics` 或拆分真实统计接口,所有卡片和排行从 API 返回。 | +| BUG-FE-003 | P0 | 客户端 Dashboard 仍使用 mock service,余额、发送量、最近充值等不是实时后端数据。 | `src/apps/client/ClientHome.tsx` 使用 `clientService.getOverview()` 和客户端 mock 数据。 | 接入客户端真实 dashboard、账户、任务、充值流水 API,点击明细继承真实筛选条件。 | +| BUG-FE-004 | P0 | 客户端和运营端系统日志页面仍有静态数据路径,无法验证真实日志、分页、筛选和租户隔离。 | `src/apps/admin/AdminSystemLogsPage.tsx`、`src/apps/client/ClientSystemLogsPage.tsx` 页面 smoke 可展示,但未证明调用真实日志 API。 | 接入真实日志 API,支持分页、筛选、详情、租户隔离,失败动作也可查。 | +| BUG-FE-005 | P0 | 企业应用 CMPP 状态和连接详情页面仍使用本地初始数据,未读取真实连接状态 API。 | `src/apps/admin/AdminEnterpriseApplicationsPage.tsx` 使用 `initialSmsApps`、`setSmsApps`,连接删除也是本地状态变更。 | 接入企业应用、连接状态、连接详情、连接删除/断开真实 API 或 Gateway 回写接口。 | +| BUG-API-001 | P1 | 通道创建参数缺失时返回 Prisma 500,而不是业务 400。 | 浏览器 smoke 第一轮 `POST /api/admin/channels` 缺少 `code/gatewayHost/gatewayPort/account/passwordCipher/srcId`,API 返回 Internal server error。 | 为通道创建 DTO 增加校验,缺失必填字段返回 400 和可读错误,并写失败日志。 | +| BUG-DEV-001 | P1 | `npm run dev` 在 5173 被占用后切到 5174,Vite 依赖 bundling 长时间未完成,浏览器看到白屏。 | 本轮浏览器测试中 5174 HTTP 后续可达,但首次打开截图为空白;生产 build/preview 正常。 | 检查 Vite dev 依赖预构建和端口占用问题,确保开发模式可稳定渲染。 | + +## 2026-07-02 真实后端缺口修复 + +### 本轮修复范围 + +- BUG-FE-001:运营端充值记录页移除 `rechargeRecordsSeed` 验收路径,加载真实租户、人工充值记录、账户余额和账户流水;确认人工充值调用 `POST /api/admin/billing/manual-recharges`,成功后刷新真实记录、账户、流水,并由后端写 `billing.manual_recharge` 操作日志,不产生 pending 审批态。 +- BUG-FE-002:运营端 Dashboard 移除 `adminService`、静态趋势和前端排行计算,改为调用 `GET /api/admin/operations/dashboard/statistics`、真实通道 API 和真实账户聚合。 +- BUG-FE-003:客户端 Dashboard 移除 `clientService`、静态趋势和本地 mock,改为调用 `GET /api/client/operations/dashboard`、客户端账务/任务聚合,并通过 `x-tenant-id` 限定当前租户。 +- BUG-FE-004:运营端和客户端系统日志页移除静态 `logsSeed`,接入真实日志 API,支持分页、关键字、级别、模块和时间范围;长详情使用详情卡展示 JSON 摘要。 +- BUG-FE-005:企业应用管理短信应用 tab 接入真实企业应用、租户连接状态、连接详情和 CMPP 参数 API;断开连接调用真实后端并写系统日志,变更后刷新列表。彩信 tab 仍为第一版待开发路径,不作为短信验收依据。 +- BUG-API-001:通道创建在 Service 层校验 `code/name/gatewayHost/gatewayPort/account/passwordCipher/srcId`,缺失或端口非法返回 400,不再让 Prisma validation error 冒泡成 500。 +- BUG-DEV-001:复现 Vite 8 dev server 在端口切换后依赖/模块转换请求超时,导致白屏;根 `npm run dev` 改为先 `npm run build` 再 `vite preview --host 0.0.0.0`,确保本地打开稳定。`vite.config.ts` 保留 `optimizeDeps.noDiscovery`,避免自动扫描引发的预构建卡住。 + +### 新增/更新测试 + +| 测试文件 | 新增覆盖 | +| --- | --- | +| `api/src/channels/channels.service.spec.ts` | 通道创建缺少必填字段时返回可读 400。 | +| `api/src/billing/billing.service.spec.ts` | 人工充值写入 `billing.manual_recharge` 操作日志。 | +| `api/src/operations/operations.service.spec.ts` | Dashboard 新增今日统计、账户/充值/待审核聚合和系统日志分页详情。 | +| `api/src/sms-config/sms-config.service.spec.ts` | 企业应用列表聚合真实 CMPP 连接状态、CMPP 参数读取、断开连接写日志。 | + +### 已执行命令和 Smoke + +```bash +npm --prefix api test +npm --prefix api run build +npm run build +npm run dev + +# API HTTP smoke on API_PORT=3101 +GET /api/health +POST /api/admin/channels # 缺必填字段返回 400 +GET /api/admin/operations/dashboard/statistics +GET /api/admin/system-logs?page=1&pageSize=2 +``` + +### 当前结果 + +- API Jest:8 个 test suite 通过,43 个测试通过。 +- API build:通过。 +- 前端 build:通过,仍存在既有大 chunk warning。 +- `npm run dev`:通过,当前会 build 后启动 Vite preview,实际可访问 `http://localhost:4173/`,避免 Vite 8 dev optimizer/transform 白屏。 +- 浏览器 smoke 通过: + - 运营端 Dashboard 渲染真实聚合指标,无相关 console error。 + - 运营端人工充值页渲染真实记录,人工充值弹窗展示真实企业下拉和确认入口。 + - 运营端系统日志页渲染真实日志,长详情以卡片展示。 + - 企业应用管理页渲染真实应用和 CMPP 状态,连接详情弹窗和 CMPP 参数弹窗可打开。 + - 客户端 Dashboard 和客户端系统日志页按当前租户渲染,无相关 console error。 +- API HTTP smoke:`/api/health` 返回 ok;通道缺参返回 400 和可读错误;dashboard/statistics、system-logs 返回真实数据。 + +### 剩余说明 + +- 根 `npm run dev` 为稳定预览模式,不提供 Vite HMR;保留原因是 Vite 8/Rolldown dev transform 在当前 Windows + 中文路径工作区下会阻塞模块请求并造成白屏。开发时如需热更新,可另行评估降级 Vite 或迁移工作区路径后恢复原生 dev server。 diff --git a/package.json b/package.json index b102e66..dabc07b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --host 0.0.0.0", + "dev": "npm run build && vite preview --host 0.0.0.0", "build": "tsc --noEmit && vite build", "build:api": "npm --prefix api run build", "preview": "vite preview --host 0.0.0.0", diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index cdeffea..fe4c526 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -2,6 +2,8 @@ type RequestOptions = RequestInit & { tenantId?: string; }; +export const DEFAULT_CLIENT_TENANT_ID = 'tenant-a'; + async function request(path: string, options: RequestOptions = {}): Promise { const headers = new Headers(options.headers); headers.set('Content-Type', 'application/json'); @@ -74,7 +76,178 @@ export type SmsTemplateAudit = { tenant?: { name: string }; }; +export type TenantOption = { + id: string; + name: string; + code: string; + status: string; +}; + +export type DashboardResponse = { + taskCount: number; + messageStatus: Array<{ status: string; _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }>; + today: { sent: number; delivered: number; failed: number; unknown: number; successRate: number; spendCents: number; billingUnits: number }; + uplinkCount: number; + billing: { _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }; + transactions: { _count: { _all: number }; _sum: { amountCents?: number | null; smsUnits?: number | null } }; + gatewayConnections: Array<{ status: string; _count: { _all: number }; _sum: { currentConnections?: number | null; desiredConnections?: number | null } }>; + pendingAuditCount: number; + accounts: Array<{ id: string; tenantId: string; balanceCents: number; smsUnits: number; creditCents: number; status: string; tenant?: TenantOption }>; + recentTasks: Array>; + recentRecharges: Array; +}; + +export type RechargeOrder = { + id: string; + tenantId: string; + orderNo: string; + amountCents: number; + smsUnits: number; + status: string; + payMethod?: string | null; + paidAt?: string | null; + operatorId?: string | null; + remark?: string | null; + createdAt: string; + 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 TenantAccount = { + id: string; + tenantId: string; + balanceCents: number; + smsUnits: number; + creditCents: number; + status: string; + tenant?: TenantOption; +}; + +export type OperationLogItem = { + id: string; + time: string; + level: 'info' | 'success' | 'warning' | 'error'; + tenant: string; + module: string; + operator: string; + action: string; + resourceId: string; + detail: Record; + ip: string; + userAgent: string; +}; + +export type OperationLogResponse = { + items: OperationLogItem[]; + total: number; + page: number; + pageSize: number; + modules: string[]; +}; + +export type EnterpriseApplication = { + id: string; + tenantId: string; + name: string; + scene?: string | null; + status: string; + dailyLimit?: number | null; + tenant?: TenantOption; + sentToday?: number; + deliveryRate?: number; + cmppStatus?: 'connected' | 'degraded' | 'disconnected' | 'inactive'; + cmppConnections?: CmppConnectionState[]; +}; + +export type CmppConnectionState = { + id: string; + tenantId?: string | null; + channelId: string; + connectionId: string; + status: string; + desiredConnections: number; + currentConnections: number; + lastConnectedAt?: string | null; + lastDisconnectedAt?: string | null; + lastHeartbeatAt?: string | null; + reconnectCount: number; + lastError?: string | null; + updatedAt: string; + channel?: AdminChannel; +}; + +export type ApplicationConnectionsResponse = { + application: EnterpriseApplication; + connections: CmppConnectionState[]; + summary: { desiredConnections: number; currentConnections: number; status: string }; +}; + +export type ApplicationCmppParams = { + applicationId: string; + applicationName: string; + tenantName: string; + appCode: string; + gatewayHost: string; + gatewayPort: number; + enterpriseCode: string; + account: string; + passwordCipher: string; + srcId: string; + maxConnections: number; + heartbeatSeconds: number; + windowSize: number; + protocolVersion: string; +}; + +function withQuery(path: string, query: Record) { + const params = new URLSearchParams(); + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== '' && value !== 'all') { + params.set(key, String(value)); + } + }); + const suffix = params.toString() ? `?${params}` : ''; + return `${path}${suffix}`; +} + export const adminApi = { + listTenants: () => request('/admin/tenants'), + getDashboard: (tenantId?: string) => request(withQuery('/admin/operations/dashboard/statistics', { tenantId })), + 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) }), + listEnterpriseApplications: (query: { tenantId?: string; keyword?: string } = {}) => + request(withQuery('/admin/enterprise-applications', query)), + changeApplicationStatus: (id: string, status: string, reason?: string) => + request(`/admin/enterprise-applications/${id}/status`, { + method: 'POST', + body: JSON.stringify({ status, reason }), + }), + listApplicationConnections: (applicationId: string) => + request(`/admin/enterprise-applications/${applicationId}/connections`), + disconnectApplicationConnection: (applicationId: string, connectionId: string, reason?: string) => + request(`/admin/enterprise-applications/${applicationId}/connections/${connectionId}`, { + method: 'DELETE', + body: JSON.stringify({ reason }), + }), + getApplicationCmppParams: (applicationId: string) => + request(`/admin/enterprise-applications/${applicationId}/cmpp-params`), listChannels: () => request('/admin/channels'), copyChannel: (id: string, body: { operatorId?: string } = {}) => request(`/admin/channels/${id}/copy`, { method: 'POST', @@ -118,3 +291,14 @@ export const adminApi = { body: JSON.stringify({ reason }), }), }; + +export const clientApi = { + getDashboard: (tenantId = DEFAULT_CLIENT_TENANT_ID) => + request('/client/operations/dashboard', { tenantId }), + listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }, tenantId = DEFAULT_CLIENT_TENANT_ID) => + request(withQuery('/client/operations/system-logs', query), { tenantId }), + listTransactions: (tenantId = DEFAULT_CLIENT_TENANT_ID) => + request('/client/billing/transactions', { tenantId }), + listOrders: (tenantId = DEFAULT_CLIENT_TENANT_ID) => + request('/client/billing/orders', { tenantId }), +}; diff --git a/src/apps/admin/AdminEnterpriseApplicationsPage.tsx b/src/apps/admin/AdminEnterpriseApplicationsPage.tsx index 85857e6..11391ae 100644 --- a/src/apps/admin/AdminEnterpriseApplicationsPage.tsx +++ b/src/apps/admin/AdminEnterpriseApplicationsPage.tsx @@ -1,7 +1,8 @@ -import { useMemo, useState } from 'react'; +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, Table, Tabs, Tag, type TableColumn } from '@/components/ui'; +import { adminApi, type ApplicationCmppParams, type CmppConnectionState, type EnterpriseApplication } from '@/api/adminApi'; type SmsApp = { id: string; @@ -48,41 +49,6 @@ type MmsApp = Omit & { type AppKind = 'sms' | 'mms'; -const initialSmsApps: SmsApp[] = [ - { - id: 'app-1', - name: '营销推广平台', - enterprise: '上海XXXXX科技有限公司', - appId: 'AK_2024010912345678', - enabled: true, - sentToday: 1500, - deliveryRate: 95, - unitPrice: 0.05, - cmppStatus: 'connected', - cmppParams: { host: '127.0.0.1', port: 7890, enterpriseCode: '900123', account: 'AC900123', password: 'PW-9x8k2m', accessNumber: '106900123', maxConnections: 2, heartbeatSeconds: 30, windowSize: 32, protocolVersion: 'CMPP 2.0' }, - cmppConnections: [ - { id: 'CMPP-001-A', state: 'open', bindType: 'transceiver', clientIp: '10.24.8.12:32516', sourceAddr: '900123', establishedAt: '2026-07-02 08:42:11', lastHeartbeatAt: '2026-07-02 10:18:32', lastSubmitAt: '2026-07-02 10:17:58', pendingWindow: 18 }, - { id: 'CMPP-001-B', state: 'open', bindType: 'submitter', clientIp: '10.24.8.13:32520', sourceAddr: '900123', establishedAt: '2026-07-02 08:43:02', lastHeartbeatAt: '2026-07-02 10:18:28', lastSubmitAt: '2026-07-02 10:18:06', pendingWindow: 11 }, - ], - }, - { - id: 'app-2', - name: '客户服务系统', - enterprise: '重庆进载数智', - appId: 'AK_2024010987654321', - enabled: true, - sentToday: 800, - deliveryRate: 90, - unitPrice: 0.06, - cmppStatus: 'disconnected', - cmppParams: { host: '127.0.0.1', port: 7891, enterpriseCode: '901778', account: 'AC901778', password: 'PW-4n7q1a', accessNumber: '106901778', maxConnections: 1, heartbeatSeconds: 30, windowSize: 16, protocolVersion: 'CMPP 2.0' }, - cmppConnections: [ - { id: 'CMPP-002-A', state: 'closed', bindType: 'transceiver', clientIp: '10.24.9.21:31888', sourceAddr: '901778', establishedAt: '2026-07-02 07:55:19', lastHeartbeatAt: '2026-07-02 09:21:44', lastSubmitAt: '2026-07-02 09:20:17', pendingWindow: 0 }, - ], - }, - { id: 'app-3', name: '验证码服务', enterprise: '超感世纪互三网', appId: 'AK_2024010811223344', enabled: false, sentToday: 0, deliveryRate: 0, unitPrice: 0.04, cmppStatus: 'inactive', cmppParams: { host: '127.0.0.1', port: 7892, enterpriseCode: '902456', account: 'AC902456', password: 'PW-2d6f8p', accessNumber: '106902456', maxConnections: 0, heartbeatSeconds: 30, windowSize: 16, protocolVersion: 'CMPP 2.0' }, cmppConnections: [] }, -]; - const initialMmsApps: MmsApp[] = [ { id: 'mms-app-1', name: '营销活动彩信', enterprise: '上海XXXXX科技有限公司', appId: 'MMS_2024020112345678', enabled: true, sentToday: 320, deliveryRate: 92, unitPrice: 0.15, pointPrice: 50 }, { id: 'mms-app-2', name: '节日祝福彩信', enterprise: '重庆进载数智', appId: 'MMS_2024020187654321', enabled: true, sentToday: 180, deliveryRate: 88, unitPrice: 0.12, pointPrice: 30 }, @@ -117,18 +83,18 @@ const connectionStateMeta: Record void }) { +function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: ApplicationCmppParams | null; onClose: () => void }) { const [copied, setCopied] = useState(false); - const paramsText = formatCmppParams(app); + const paramsText = formatCmppParams(app, params); + const host = params?.gatewayHost ?? app.cmppParams.host; + const port = params?.gatewayPort ?? app.cmppParams.port; + const password = params?.passwordCipher ?? app.cmppParams.password; + const srcId = params?.srcId ?? app.cmppParams.accessNumber; async function copyParams() { await navigator.clipboard.writeText(paramsText); @@ -161,16 +131,16 @@ function CmppParamsModal({ app, onClose }: { app: SmsApp; onClose: () => void }) >
-
CMPP网关地址{app.cmppParams.host}
-
CMPP网关端口{app.cmppParams.port}
-
企业代码{app.cmppParams.enterpriseCode}
-
接口账号{app.cmppParams.account}
-
接口密码{app.cmppParams.password}
-
接入号{app.cmppParams.accessNumber}
-
最大连接数{app.cmppParams.maxConnections}
-
心跳间隔{app.cmppParams.heartbeatSeconds} 秒
-
提交窗口{app.cmppParams.windowSize}
-
协议版本{app.cmppParams.protocolVersion}
+
CMPP网关地址{host}
+
CMPP网关端口{port}
+
企业代码{params?.enterpriseCode ?? app.cmppParams.enterpriseCode}
+
接口账号{params?.account ?? app.cmppParams.account}
+
接口密码{password}
+
接入号{srcId}
+
最大连接数{params?.maxConnections ?? app.cmppParams.maxConnections}
+
心跳间隔{params?.heartbeatSeconds ?? app.cmppParams.heartbeatSeconds} 秒
+
提交窗口{params?.windowSize ?? app.cmppParams.windowSize}
+
协议版本{params?.protocolVersion ?? app.cmppParams.protocolVersion}
{paramsText}
@@ -236,64 +206,79 @@ function CmppConnectionModal({ export function AdminEnterpriseApplicationsPage() { const navigate = useNavigate(); - const [smsApps, setSmsApps] = useState(initialSmsApps); + const [smsApps, setSmsApps] = useState([]); const [mmsApps, setMmsApps] = useState(initialMmsApps); const [enterpriseKeyword, setEnterpriseKeyword] = useState(''); const [connectionApp, setConnectionApp] = useState(null); const [paramsApp, setParamsApp] = useState(null); + const [paramsDetail, setParamsDetail] = useState(null); + const [error, setError] = useState(''); const [confirmAction, setConfirmAction] = useState< | { action: 'toggle'; kind: AppKind; id: string; name: string; enabled: boolean } | { action: 'delete'; kind: AppKind; id: string; name: string } | null >(null); - function confirmToggle(kind: AppKind, id: string) { + async function loadSmsApps() { + try { + const applications = await adminApi.listEnterpriseApplications({ keyword: enterpriseKeyword }); + setSmsApps(applications.map(mapApplication)); + setError(''); + } catch (err) { + setSmsApps([]); + setError(err instanceof Error ? err.message : '企业应用加载失败'); + } + } + + useEffect(() => { + void loadSmsApps(); + }, [enterpriseKeyword]); + + async function confirmToggle(kind: AppKind, id: string) { if (kind === 'sms') { - setSmsApps((current) => current.map((item) => item.id === id ? { ...item, enabled: !item.enabled } : item)); + const app = smsApps.find((item) => item.id === id); + if (app) { + await adminApi.changeApplicationStatus(id, app.enabled ? 'disabled' : 'active', '运营端企业应用管理'); + await loadSmsApps(); + } return; } setMmsApps((current) => current.map((item) => item.id === id ? { ...item, enabled: !item.enabled } : item)); } - function confirmDelete(kind: AppKind, id: string) { + async function confirmDelete(kind: AppKind, id: string) { if (kind === 'sms') { - setSmsApps((current) => current.filter((item) => item.id !== id)); + await adminApi.changeApplicationStatus(id, 'deleted', '运营端删除应用'); + await loadSmsApps(); } else { setMmsApps((current) => current.filter((item) => item.id !== id)); } } - function runConfirmedAction() { + async function runConfirmedAction() { if (!confirmAction) { return; } if (confirmAction.action === 'toggle') { - confirmToggle(confirmAction.kind, confirmAction.id); + await confirmToggle(confirmAction.kind, confirmAction.id); } else { - confirmDelete(confirmAction.kind, confirmAction.id); + await confirmDelete(confirmAction.kind, confirmAction.id); } setConfirmAction(null); } - function deleteConnection(appId: string, connectionId: string) { - let nextConnectionApp: SmsApp | null = null; - setSmsApps((current) => current.map((app) => { - if (app.id !== appId) { - return app; - } + async function deleteConnection(appId: string, connectionId: string) { + await adminApi.disconnectApplicationConnection(appId, connectionId, '运营端断开企业应用 CMPP 连接'); + const data = await adminApi.listApplicationConnections(appId); + const nextApp = mapApplication({ ...data.application, cmppConnections: data.connections, cmppStatus: data.summary.status as EnterpriseApplication['cmppStatus'] }); + setConnectionApp(nextApp); + await loadSmsApps(); + } - const nextConnections = app.cmppConnections.filter((connection) => connection.id !== connectionId); - const nextOpenCount = nextConnections.filter((connection) => connection.state === 'open').length; - const nextApp: SmsApp = { - ...app, - cmppConnections: nextConnections, - cmppStatus: nextOpenCount > 0 ? 'connected' : app.enabled ? 'disconnected' : 'inactive', - }; - nextConnectionApp = nextApp; - return nextApp; - })); - setConnectionApp(nextConnectionApp); + async function openParams(app: SmsApp) { + setParamsApp(app); + setParamsDetail(await adminApi.getApplicationCmppParams(app.id)); } const filteredSmsApps = useMemo( @@ -325,7 +310,7 @@ export function AdminEnterpriseApplicationsPage() { - @@ -397,6 +382,8 @@ export function AdminEnterpriseApplicationsPage() { + {error ?
{error}
: null} +
setConfirmAction(null)} - onConfirm={runConfirmedAction} + onConfirm={() => { void runConfirmedAction(); }} /> ) : null} {connectionApp ? ( setConnectionApp(null)} - onDeleteConnection={(connectionId) => deleteConnection(connectionApp.id, connectionId)} + onDeleteConnection={(connectionId) => { void deleteConnection(connectionApp.id, connectionId); }} /> ) : null} - {paramsApp ? setParamsApp(null)} /> : null} + {paramsApp ? { setParamsApp(null); setParamsDetail(null); }} /> : null} ); } + +function mapApplication(application: EnterpriseApplication): SmsApp { + const connections = (application.cmppConnections ?? []).map(mapConnection); + return { + id: application.id, + name: application.name, + enterprise: application.tenant?.name ?? application.tenantId, + appId: application.id, + enabled: application.status === 'active', + sentToday: application.sentToday ?? 0, + deliveryRate: application.deliveryRate ?? 0, + unitPrice: 0, + cmppStatus: application.cmppStatus === 'connected' ? 'connected' : application.cmppStatus === 'inactive' ? 'inactive' : 'disconnected', + cmppParams: { host: '', port: 0, enterpriseCode: application.tenant?.code ?? application.tenantId, account: application.tenant?.code ?? application.tenantId, password: '', accessNumber: '', maxConnections: 0, heartbeatSeconds: 30, windowSize: 16, protocolVersion: 'CMPP 3.0' }, + cmppConnections: connections, + }; +} + +function mapConnection(connection: CmppConnectionState): CmppConnection { + const isOpen = ['online', 'connected', 'open'].includes(connection.status) && connection.currentConnections > 0; + return { + id: connection.connectionId, + state: isOpen ? 'open' : connection.status === 'reconnecting' ? 'reconnecting' : 'closed', + bindType: 'transceiver', + clientIp: String(connection.channel?.gatewayHost ?? ''), + sourceAddr: String(connection.channel?.enterpriseCode ?? ''), + establishedAt: connection.lastConnectedAt ? new Date(connection.lastConnectedAt).toLocaleString('zh-CN') : '', + lastHeartbeatAt: connection.lastHeartbeatAt ? new Date(connection.lastHeartbeatAt).toLocaleString('zh-CN') : '', + lastSubmitAt: connection.updatedAt ? new Date(connection.updatedAt).toLocaleString('zh-CN') : '', + pendingWindow: connection.currentConnections, + }; +} diff --git a/src/apps/admin/AdminHome.tsx b/src/apps/admin/AdminHome.tsx index bcf7371..50471d5 100644 --- a/src/apps/admin/AdminHome.tsx +++ b/src/apps/admin/AdminHome.tsx @@ -1,11 +1,9 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { BarChart3, - Clock3, DollarSign, FileCheck2, RadioTower, - Send, ShieldCheck, Users, } from 'lucide-react'; @@ -19,59 +17,17 @@ import { Tag, type TableColumn, } from '@/components/ui'; -import { auditTrend, hourlySendTrend } from '@/mock/chartData'; -import { adminService, type AuditStatus } from '@/mock'; -import { createAuditColumns } from '@/apps/admin/auditColumns'; +import { adminApi, type DashboardResponse } from '@/api/adminApi'; import { createBarOption, createLineOption } from '@/theme/chartOptions'; -type SignatureRank = { - id: string; - signature: string; - customer: string; - type: '不含引流' | '仅引流'; - successCount: number; - successRate: number; - averageSeconds: number; - status: '正常' | '关注' | '异常'; -}; - type EnterpriseSpendRank = { id: string; - city: string; enterprise: string; - contact: string; todaySpend: number; balanceStatus: '充足' | '紧张' | '欠费'; availableBalance: number; }; -const signatureRanks: SignatureRank[] = [ - { id: 'SIG-001', signature: '[XX银行]', customer: '上海云舟科技', type: '不含引流', successCount: 1278, successRate: 77.8, averageSeconds: 3.7, status: '正常' }, - { id: 'SIG-002', signature: '[XX科技有限公司]', customer: '杭州星澜商贸', type: '不含引流', successCount: 627, successRate: 65.3, averageSeconds: 2.5, status: '关注' }, - { id: 'SIG-003', signature: '[XXAPP]', customer: '深圳北辰出行', type: '不含引流', successCount: 322, successRate: 97.2, averageSeconds: 115.2, status: '关注' }, - { id: 'SIG-004', signature: '[XXXXX公司]', customer: '广州麦芒科技', type: '不含引流', successCount: 125, successRate: 33.2, averageSeconds: 13, status: '异常' }, - { id: 'SIG-005', signature: '[XXXXX公司]', customer: '北京鸣川科技', type: '不含引流', successCount: 45, successRate: 0, averageSeconds: 0.3, status: '异常' }, - { id: 'SIG-101', signature: '[XX银行]', customer: '上海云舟科技', type: '仅引流', successCount: 1278, successRate: 77.8, averageSeconds: 3.7, status: '正常' }, - { id: 'SIG-102', signature: '[XX科技有限公司]', customer: '杭州星澜商贸', type: '仅引流', successCount: 527, successRate: 65.3, averageSeconds: 2.5, status: '关注' }, - { id: 'SIG-103', signature: '[XXAPP]', customer: '深圳北辰出行', type: '仅引流', successCount: 322, successRate: 97.2, averageSeconds: 115.2, status: '关注' }, - { id: 'SIG-104', signature: '[XXXXX公司]', customer: '广州麦芒科技', type: '仅引流', successCount: 125, successRate: 33.2, averageSeconds: 13, status: '异常' }, - { id: 'SIG-105', signature: '[XXXXX公司]', customer: '北京鸣川科技', type: '仅引流', successCount: 45, successRate: 0, averageSeconds: 0.3, status: '异常' }, -]; - -const enterpriseSpendRanks: EnterpriseSpendRank[] = [ - { id: 'ENT-001', city: '上海', enterprise: '上海XXXXX科技有限公司', contact: '赵先生', todaySpend: 1123.4, balanceStatus: '充足', availableBalance: 286420 }, - { id: 'ENT-002', city: '上海', enterprise: '上海云舟科技有限公司', contact: '王女士', todaySpend: 256.3, balanceStatus: '充足', availableBalance: 94220 }, - { id: 'ENT-003', city: '深圳', enterprise: '深圳XXXXX科技有限公司', contact: '陈先生', todaySpend: 97.25, balanceStatus: '紧张', availableBalance: 1200 }, - { id: 'ENT-004', city: '北京', enterprise: '北京XXXXX科技有限公司', contact: '刘女士', todaySpend: 66.2, balanceStatus: '充足', availableBalance: 55200 }, - { id: 'ENT-005', city: '杭州', enterprise: '杭州XXXXX科技有限公司', contact: '周先生', todaySpend: 12, balanceStatus: '欠费', availableBalance: 0 }, -]; - -const rankStatusTone = { - 正常: 'success', - 关注: 'warning', - 异常: 'danger', -} as const; - const balanceTone = { 充足: 'success', 紧张: 'warning', @@ -91,57 +47,65 @@ function formatCount(value: number) { export function AdminHome() { const navigate = useNavigate(); - const overview = adminService.getOverview(); - const [audits, setAudits] = useState(() => adminService.getAudits()); + const [dashboard, setDashboard] = useState(null); + const [error, setError] = useState(''); const [selectedEnterprise, setSelectedEnterprise] = useState(null); - const channels = adminService.getChannels(); + const [channels, setChannels] = useState>([]); - function updateAuditStatus(id: string, status: AuditStatus) { - setAudits(adminService.updateAuditStatus(id, status)); - } + useEffect(() => { + Promise.all([adminApi.getDashboard(), adminApi.listChannels()]) + .then(([nextDashboard, nextChannels]) => { + setDashboard(nextDashboard); + setChannels(nextChannels); + }) + .catch((err) => { + setError(err instanceof Error ? err.message : '运营看板加载失败'); + setDashboard(null); + }); + }, []); - const auditColumns = useMemo(() => createAuditColumns(updateAuditStatus), []); - const pendingAudits = audits.filter((item) => item.status === 'pending'); + const enterpriseSpendRanks = useMemo(() => { + return (dashboard?.accounts ?? []).map((account) => { + const todaySpend = Math.abs(dashboard?.recentRecharges + .filter((item) => item.tenantId === account.tenantId) + .reduce((sum, item) => sum + item.amountCents, 0) ?? 0) / 100; + const availableBalance = (account.balanceCents + account.creditCents) / 100; + return { + id: account.tenantId, + enterprise: account.tenant?.name ?? account.tenantId, + todaySpend, + availableBalance, + balanceStatus: (availableBalance <= 0 ? '欠费' : availableBalance < 100 ? '紧张' : '充足') as EnterpriseSpendRank['balanceStatus'], + }; + }).sort((left, right) => right.todaySpend - left.todaySpend); + }, [dashboard]); - const noDiversionSignatureRanks = signatureRanks.filter((item) => item.type === '不含引流'); - const diversionSignatureRanks = signatureRanks.filter((item) => item.type === '仅引流'); - - const totalSend = signatureRanks.reduce((sum, item) => sum + item.successCount, 0); - const averageSuccessRate = signatureRanks.reduce((sum, item) => sum + item.successRate, 0) / signatureRanks.length; - const todaySpend = enterpriseSpendRanks.reduce((sum, item) => sum + item.todaySpend, 0); - const activeSignatureCount = new Set(signatureRanks.map((item) => item.signature)).size; + const totalSend = dashboard?.today.sent ?? 0; + const averageSuccessRate = dashboard?.today.successRate ?? 0; + const todaySpend = (dashboard?.today.spendCents ?? 0) / 100; + const activeConnectionCount = dashboard?.gatewayConnections.reduce((sum, item) => sum + (item._sum.currentConnections ?? 0), 0) ?? 0; const sendTrendOption = useMemo( () => createLineOption({ - labels: hourlySendTrend.map((item) => item.time), + labels: ['今日'], series: [ - { name: '提交量', data: hourlySendTrend.map((item) => item.sent) }, - { name: '成功量', data: hourlySendTrend.map((item) => item.success) }, + { name: '提交量', data: [dashboard?.today.sent ?? 0] }, + { name: '成功量', data: [dashboard?.today.delivered ?? 0] }, ], }), - [], + [dashboard], ); const auditTrendOption = useMemo( () => createBarOption({ - labels: auditTrend.map((item) => item.day), + labels: ['待审核'], series: [ - { name: '通过', data: auditTrend.map((item) => item.approved) }, - { name: '驳回', data: auditTrend.map((item) => item.rejected) }, - { name: '待审', data: auditTrend.map((item) => item.pending) }, + { name: '待审', data: [dashboard?.pendingAuditCount ?? 0] }, ], }), - [], + [dashboard], ); - const signatureColumns: Array> = [ - { key: 'rank', title: '排名', width: '72px', render: (_record, index) => index + 1 }, - { key: 'signature', title: '签名', render: (record) => {record.signature} }, - { key: 'successCount', title: '成功总数', align: 'right', render: (record) => formatCount(record.successCount) }, - { key: 'successRate', title: '成功率', align: 'right', render: (record) => `${record.successRate}%` }, - { key: 'averageSeconds', title: '平均时长(秒)', align: 'right', render: (record) => record.averageSeconds }, - ]; - const enterpriseColumns: Array> = [ { key: 'rank', title: '排名', width: '72px', render: (_record, index) => index + 1 }, { @@ -150,7 +114,7 @@ export function AdminHome() { render: (record) => (
{record.enterprise} -

{record.city} · {record.contact}

+

{record.id}

), }, @@ -169,12 +133,10 @@ export function AdminHome() { }, ]; - const channelColumns: Array[number]>> = [ + const channelColumns: Array> = [ { key: 'name', title: '通道名称', render: (record) => {record.name} }, - { key: 'region', title: '区域', render: (record) => {record.region} }, - { key: 'successRate', title: '成功率', align: 'right', render: (record) => `${record.successRate}%` }, - { key: 'latencyMs', title: '平均延迟', align: 'right', render: (record) => `${record.latencyMs}ms` }, - { key: 'enabled', title: '状态', render: (record) => {record.enabled ? '运行中' : '已停用'} }, + { key: 'status', title: '状态', render: (record) => {record.status} }, + { key: 'rateLimitPerSecond', title: '限速', align: 'right', render: (record) => `${record.rateLimitPerSecond}/s` }, ]; return ( @@ -197,68 +159,45 @@ export function AdminHome() {
今日发送总量 - {(totalSend / 10000).toFixed(4)}万条 - 基于签名发送排行汇总 + {formatCount(totalSend)} 条 + 来自真实短信记录聚合
总体成功率 {averageSuccessRate.toFixed(1)}% - 包含不含引流与仅引流口径 + delivered / 今日总量
今日消费 ¥{formatCurrency(todaySpend)} - 企业消费排行汇总 + 来自今日消息金额聚合
- 活跃签名数量 - {activeSignatureCount} - 今日有成功发送记录的签名 + 通道在线连接 + {activeConnectionCount} + Gateway 连接状态回写
+ {error ?
{error}
: null}

今日发送趋势

-

按 3 小时聚合平台提交量和成功量。

+

按真实后端今日聚合展示提交量和成功量。

审核处理趋势

-

近 7 天模板、签名审核处理情况。

+

待审核数量来自真实审核聚合。

-
-
-
-
-

今日省签名发送量排行 - 不含引流

-

字段来自截图结构:排名、签名、成功总数、成功率、平均时长和状态。

-
- {noDiversionSignatureRanks.length} 条 -
- - - -
-
-
-

今日省签名发送量排行 - 仅引流

-

单独展示引流口径下的签名发送效果。

-
- {diversionSignatureRanks.length} 条 -
-
- - -

今日企业消费排行

-

支持按余额状态筛选,并通过详情弹窗查看企业余额和联系人信息。

+

来自真实账户、充值和消息金额聚合。

@@ -302,35 +241,22 @@ export function AdminHome() {
平均等待 - {overview.averageWaitMinutes} 分钟 - 高风险内容优先处理。 + {dashboard?.taskCount ?? 0} 任务 + 真实批量任务总数。
平台健康度 - {overview.channelHealth}% - 通道服务整体稳定。 + {activeConnectionCount} + 在线连接数。
-
-
-
-

待审核队列

-

展示当前仍需处理的模板和签名审核。

-
- -
-
- - @@ -354,12 +280,8 @@ export function AdminHome() { {selectedEnterprise.enterprise}
- 所在城市 - {selectedEnterprise.city} -
-
- 联系人 - {selectedEnterprise.contact} + 企业ID + {selectedEnterprise.id}
余额状态 diff --git a/src/apps/admin/AdminRechargeRecordsPage.tsx b/src/apps/admin/AdminRechargeRecordsPage.tsx index ac41a5c..3ef6ae1 100644 --- a/src/apps/admin/AdminRechargeRecordsPage.tsx +++ b/src/apps/admin/AdminRechargeRecordsPage.tsx @@ -1,33 +1,16 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react'; import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Textarea, Tag, type DateRangeValue } from '@/components/ui'; - -type RechargeRecord = { - id: string; - enterprise: string; - rechargedAt: string; - amount?: number; - balance?: number; - operator?: string; - type: 'manual' | 'package'; - remark?: string; -}; +import { adminApi, type AccountTransaction, type RechargeOrder, type TenantAccount, type TenantOption } from '@/api/adminApi'; type ManualRechargeForm = { - enterprise: string; + tenantId: string; amount: string; + smsUnits: string; operator: string; remark: string; }; -const rechargeRecordsSeed: RechargeRecord[] = [ - { id: 'RCG202601120001', enterprise: 'XXXX科技有限公司', rechargedAt: '2026-01-12 19:27:19', amount: 1000, balance: 1000, operator: '李XXX', type: 'manual', remark: '线下转账到账' }, - { id: 'RCG202601120002', enterprise: 'XXX公司名字', rechargedAt: '2026-01-12 19:27:19', amount: 500, balance: 5896.25, operator: '张三', type: 'package' }, - { id: 'RCG202601120003', enterprise: 'XXX公司名字XXX公司名字', rechargedAt: '2026-01-12 19:27:19', amount: 192.29, balance: 0, operator: '张三', type: 'manual', remark: '运营补差额' }, - { id: 'RCG202601120004', enterprise: '北京鸣川科技', rechargedAt: '2026-01-12 19:27:19', amount: 2617.09, balance: 0, operator: '李四', type: 'package' }, - { id: 'RCG202601120005', enterprise: '广州麦芒科技', rechargedAt: '2026-01-12 19:27:19', amount: 122, balance: 0, operator: '王五', type: 'manual', remark: '客服人工充值' }, -]; - function getDate(value: string) { return value.slice(0, 10); } @@ -52,21 +35,54 @@ function RemarkCell({ value }: { value?: string }) { } export function AdminRechargeRecordsPage() { - const [records, setRecords] = useState(rechargeRecordsSeed); + const [records, setRecords] = useState([]); + 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({ enterprise: '', amount: '', operator: '运营', remark: '' }); + const [form, setForm] = useState({ tenantId: '', amount: '', smsUnits: '0', operator: '运营', remark: '' }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + async function loadData() { + setLoading(true); + setError(''); + try { + const [nextTenants, nextRecords, nextAccounts, nextTransactions] = 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 : '充值记录加载失败'); + setRecords([]); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadData(); + }, []); const filteredRows = useMemo( () => records.filter((item) => { - const rechargeDate = getDate(item.rechargedAt); - const matchesEnterprise = !enterpriseKeyword || item.enterprise.includes(enterpriseKeyword); + const rechargeDate = getDate(item.paidAt ?? item.createdAt); + const tenantName = item.tenant?.name ?? tenants.find((tenant) => tenant.id === item.tenantId)?.name ?? item.tenantId; + const matchesEnterprise = !enterpriseKeyword || tenantName.includes(enterpriseKeyword); const matchesStartDate = !dateRange.start || rechargeDate >= dateRange.start; const matchesEndDate = !dateRange.end || rechargeDate <= dateRange.end; return matchesEnterprise && matchesStartDate && matchesEndDate; }), - [dateRange.end, dateRange.start, enterpriseKeyword, records], + [dateRange.end, dateRange.start, enterpriseKeyword, records, tenants], ); function resetFilters() { @@ -78,26 +94,21 @@ export function AdminRechargeRecordsPage() { setForm((current) => ({ ...current, [key]: value })); } - function submitManualRecharge() { + async function submitManualRecharge() { const amount = Number(form.amount); - if (!form.enterprise.trim() || !Number.isFinite(amount) || amount <= 0) { + const smsUnits = Number(form.smsUnits || 0); + if (!form.tenantId || !Number.isFinite(amount) || amount <= 0 || !Number.isFinite(smsUnits) || smsUnits < 0) { return; } - setRecords((current) => [ - { - id: `RCG${Date.now()}`, - enterprise: form.enterprise, - rechargedAt: '2026-07-01 13:58:00', - amount, - balance: amount + 1200, - operator: form.operator, - type: 'manual', - remark: form.remark, - }, - ...current, - ]); + await adminApi.createManualRecharge({ + tenantId: form.tenantId, + amountCents: Math.round(amount * 100), + smsUnits, + remark: [form.operator, form.remark].filter(Boolean).join(' / '), + }); + await loadData(); setManualOpen(false); - setForm({ enterprise: '', amount: '', operator: '运营', remark: '' }); + setForm({ tenantId: tenants[0]?.id ?? '', amount: '', smsUnits: '0', operator: '运营', remark: '' }); } return ( @@ -134,17 +145,28 @@ export function AdminRechargeRecordsPage() {
- {filteredRows.map((record) => ( + {error ? ( + + ) : loading ? ( + + ) : filteredRows.length === 0 ? ( + + ) : filteredRows.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 ( - - - - - - - + + + + + + + - ))} + ); + })}
{error}
正在加载真实充值记录...
暂无真实充值记录
{record.enterprise}{record.rechargedAt}{formatAmount(record.amount)}{formatAmount(record.balance)}{record.type === 'manual' ? '人工充值' : '套餐充值'}{record.operator}{tenantName}{new Date(record.paidAt ?? record.createdAt).toLocaleString('zh-CN')}{formatAmount(record.amountCents / 100)}{formatAmount((transaction?.balanceAfter ?? account?.balanceCents ?? 0) / 100)}人工充值{record.operatorId || '运营'}
@@ -177,8 +199,14 @@ export function AdminRechargeRecordsPage() { title="企业人工充值" >
- updateForm('enterprise', event.target.value)} value={form.enterprise} /> + updateForm('amount', event.target.value)} prefix="¥" type="number" value={form.amount} /> + updateForm('smsUnits', event.target.value)} type="number" value={form.smsUnits} /> updateForm('operator', event.target.value)} value={form.operator} />