diff --git a/.env.example b/.env.example index cbbfe86..fe4691f 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,11 @@ CLIENT_SESSION_IDLE_TIMEOUT_MS=7200000 SESSION_LOCK_RECOVERY_MS=14400000 SESSION_ABSOLUTE_TIMEOUT_MS=43200000 SESSION_RECENT_AUTH_MS=1800000 +OPERATION_LOG_ARCHIVE_ENABLED=true +OPERATION_LOG_RETENTION_DAYS=180 +OPERATION_LOG_ARCHIVE_BATCH_SIZE=1000 +OPERATION_LOG_ARCHIVE_MAX_BATCHES=20 +OPERATION_LOG_ARCHIVE_INTERVAL_MS=86400000 # Local HTTP development only. Production must use HTTPS and true. SESSION_COOKIE_SECURE=false MINIO_ENDPOINT=localhost:9000 diff --git a/api/prisma/migrations/20260714170000_optimize_operation_log_retention/migration.sql b/api/prisma/migrations/20260714170000_optimize_operation_log_retention/migration.sql new file mode 100644 index 0000000..3d5d801 --- /dev/null +++ b/api/prisma/migrations/20260714170000_optimize_operation_log_retention/migration.sql @@ -0,0 +1,37 @@ +CREATE INDEX "OperationLog_createdAt_idx" + ON "OperationLog"("createdAt"); + +CREATE INDEX "OperationLog_resource_createdAt_idx" + ON "OperationLog"("resource", "createdAt"); + +CREATE TABLE "OperationLogArchive" ( + "originalId" TEXT NOT NULL, + "tenantId" TEXT, + "userId" TEXT, + "action" TEXT NOT NULL, + "resource" TEXT NOT NULL, + "resourceId" TEXT, + "ipAddress" TEXT, + "userAgent" TEXT, + "detail" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL, + "archiveMonth" TEXT NOT NULL, + "archivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "OperationLogArchive_pkey" PRIMARY KEY ("originalId") +); + +CREATE INDEX "OperationLogArchive_tenantId_createdAt_idx" + ON "OperationLogArchive"("tenantId", "createdAt"); + +CREATE INDEX "OperationLogArchive_userId_createdAt_idx" + ON "OperationLogArchive"("userId", "createdAt"); + +CREATE INDEX "OperationLogArchive_createdAt_idx" + ON "OperationLogArchive"("createdAt"); + +CREATE INDEX "OperationLogArchive_resource_createdAt_idx" + ON "OperationLogArchive"("resource", "createdAt"); + +CREATE INDEX "OperationLogArchive_archiveMonth_idx" + ON "OperationLogArchive"("archiveMonth"); diff --git a/api/prisma/migrations/20260714183000_remove_billing_packages/migration.sql b/api/prisma/migrations/20260714183000_remove_billing_packages/migration.sql new file mode 100644 index 0000000..29c5dbf --- /dev/null +++ b/api/prisma/migrations/20260714183000_remove_billing_packages/migration.sql @@ -0,0 +1,14 @@ +ALTER TABLE "RechargeOrder" DROP CONSTRAINT IF EXISTS "RechargeOrder_planId_fkey"; + +DROP TABLE IF EXISTS "BillingPlan"; + +ALTER TABLE "TenantAccount" + DROP COLUMN "smsUnits", + DROP COLUMN "creditCents"; + +ALTER TABLE "AccountTransaction" + DROP COLUMN "smsUnits"; + +ALTER TABLE "RechargeOrder" + DROP COLUMN "planId", + DROP COLUMN "smsUnits"; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 9337250..c0fb8a8 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -154,6 +154,29 @@ model OperationLog { @@index([tenantId, createdAt]) @@index([userId, createdAt]) + @@index([createdAt]) + @@index([resource, createdAt]) +} + +model OperationLogArchive { + originalId String @id + tenantId String? + userId String? + action String + resource String + resourceId String? + ipAddress String? + userAgent String? + detail Json? + createdAt DateTime + archiveMonth String + archivedAt DateTime @default(now()) + + @@index([tenantId, createdAt]) + @@index([userId, createdAt]) + @@index([createdAt]) + @@index([resource, createdAt]) + @@index([archiveMonth]) } model FileObject { @@ -240,26 +263,10 @@ model DrainageField { channelReportFields ChannelReportField[] } -model BillingPlan { - id String @id @default(cuid()) - name String - priceCents Int - smsUnits Int - validDays Int - status String @default("active") - description String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - rechargeOrders RechargeOrder[] -} - model TenantAccount { id String @id @default(cuid()) tenantId String balanceCents Int @default(0) - smsUnits Int @default(0) - creditCents Int @default(0) status String @default("active") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -274,7 +281,6 @@ model AccountTransaction { tenantId String transactionType String amountCents Int @default(0) - smsUnits Int @default(0) balanceAfter Int @default(0) relatedType String? relatedId String? @@ -301,10 +307,8 @@ model BillingRule { model RechargeOrder { id String @id @default(cuid()) tenantId String - planId String? orderNo String @unique amountCents Int - smsUnits Int @default(0) status String @default("created") payMethod String? paidAt DateTime? @@ -313,8 +317,7 @@ model RechargeOrder { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - tenant Tenant @relation(fields: [tenantId], references: [id]) - plan BillingPlan? @relation(fields: [planId], references: [id]) + tenant Tenant @relation(fields: [tenantId], references: [id]) @@index([tenantId, createdAt]) } diff --git a/api/src/audit/audit.controller.ts b/api/src/audit/audit.controller.ts index 1f51f69..b3fda55 100644 --- a/api/src/audit/audit.controller.ts +++ b/api/src/audit/audit.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Post } from '@nestjs/common'; +import { Body, Controller, Get, Post, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { TenantId } from '../common/tenant-id.decorator'; import { AuditService, CreateOperationLogDto } from './audit.service'; @@ -9,8 +9,12 @@ export class AuditController { constructor(private readonly audit: AuditService) {} @Get() - list(@TenantId() tenantId?: string) { - return this.audit.list(tenantId); + list( + @TenantId() tenantId?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.audit.list(tenantId, Number(page), Number(pageSize)); } @Post() diff --git a/api/src/audit/audit.module.ts b/api/src/audit/audit.module.ts index 3695111..24a44cd 100644 --- a/api/src/audit/audit.module.ts +++ b/api/src/audit/audit.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { AuditController } from './audit.controller'; import { AuditService } from './audit.service'; +import { OperationLogRetentionService } from './operation-log-retention.service'; @Module({ controllers: [AuditController], - providers: [AuditService], + providers: [AuditService, OperationLogRetentionService], exports: [AuditService], }) export class AuditModule {} diff --git a/api/src/audit/audit.service.spec.ts b/api/src/audit/audit.service.spec.ts new file mode 100644 index 0000000..194c007 --- /dev/null +++ b/api/src/audit/audit.service.spec.ts @@ -0,0 +1,26 @@ +import { AuditService } from './audit.service'; + +describe('AuditService', () => { + it('returns bounded paginated operation logs', async () => { + const prisma = { + operationLog: { + findMany: jest.fn().mockResolvedValue([{ id: 'log-1' }]), + count: jest.fn().mockResolvedValue(1), + }, + }; + const service = new AuditService(prisma as never); + + await expect(service.list('tenant-1', 2, 1_000)).resolves.toEqual({ + items: [{ id: 'log-1' }], + total: 1, + page: 2, + pageSize: 100, + }); + expect(prisma.operationLog.findMany).toHaveBeenCalledWith({ + where: { tenantId: 'tenant-1' }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + skip: 100, + take: 100, + }); + }); +}); diff --git a/api/src/audit/audit.service.ts b/api/src/audit/audit.service.ts index 6cb3309..b4427df 100644 --- a/api/src/audit/audit.service.ts +++ b/api/src/audit/audit.service.ts @@ -17,11 +17,20 @@ export interface CreateOperationLogDto { export class AuditService { constructor(private readonly prisma: PrismaService) {} - list(tenantId?: string) { - return this.prisma.operationLog.findMany({ - where: tenantId ? { tenantId } : undefined, - orderBy: { createdAt: 'desc' }, - }); + async list(tenantId?: string, pageInput?: number, pageSizeInput?: number) { + const page = positiveInteger(pageInput, 1); + const pageSize = Math.min(100, positiveInteger(pageSizeInput, 20)); + const where = tenantId ? { tenantId } : undefined; + const [items, total] = await Promise.all([ + this.prisma.operationLog.findMany({ + where, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.operationLog.count({ where }), + ]); + return { items, total, page, pageSize }; } create(data: CreateOperationLogDto) { @@ -38,3 +47,8 @@ export class AuditService { return this.prisma.operationLog.create({ data: createData }); } } + +function positiveInteger(value: number | undefined, fallback: number) { + const normalized = Number(value); + return Number.isInteger(normalized) && normalized > 0 ? normalized : fallback; +} diff --git a/api/src/audit/operation-log-retention.service.spec.ts b/api/src/audit/operation-log-retention.service.spec.ts new file mode 100644 index 0000000..fe1183d --- /dev/null +++ b/api/src/audit/operation-log-retention.service.spec.ts @@ -0,0 +1,28 @@ +import { OperationLogRetentionService } from './operation-log-retention.service'; + +describe('OperationLogRetentionService', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('archives expired logs in bounded batches using the configured retention window', async () => { + process.env.OPERATION_LOG_RETENTION_DAYS = '90'; + process.env.OPERATION_LOG_ARCHIVE_BATCH_SIZE = '2'; + process.env.OPERATION_LOG_ARCHIVE_MAX_BATCHES = '3'; + const prisma = { + $executeRaw: jest.fn() + .mockResolvedValueOnce(2) + .mockResolvedValueOnce(1), + }; + const service = new OperationLogRetentionService(prisma as never); + + await expect(service.archiveExpiredLogs(new Date('2026-07-14T00:00:00.000Z'))).resolves.toEqual({ + archived: 3, + cutoff: new Date('2026-04-15T00:00:00.000Z'), + retentionDays: 90, + }); + expect(prisma.$executeRaw).toHaveBeenCalledTimes(2); + }); +}); diff --git a/api/src/audit/operation-log-retention.service.ts b/api/src/audit/operation-log-retention.service.ts new file mode 100644 index 0000000..9db3be5 --- /dev/null +++ b/api/src/audit/operation-log-retention.service.ts @@ -0,0 +1,115 @@ +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; + +const DEFAULT_RETENTION_DAYS = 180; +const DEFAULT_BATCH_SIZE = 1_000; +const DEFAULT_MAX_BATCHES = 20; +const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1_000; +const INITIAL_DELAY_MS = 60_000; + +@Injectable() +export class OperationLogRetentionService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(OperationLogRetentionService.name); + private initialTimer?: ReturnType; + private intervalTimer?: ReturnType; + private running = false; + + constructor(private readonly prisma: PrismaService) {} + + onModuleInit() { + if (!operationLogArchiveEnabled()) { + return; + } + this.initialTimer = setTimeout(() => void this.runSafely(), INITIAL_DELAY_MS); + this.initialTimer.unref?.(); + this.intervalTimer = setInterval(() => void this.runSafely(), positiveIntegerEnv('OPERATION_LOG_ARCHIVE_INTERVAL_MS', DEFAULT_INTERVAL_MS)); + this.intervalTimer.unref?.(); + } + + onModuleDestroy() { + if (this.initialTimer) clearTimeout(this.initialTimer); + if (this.intervalTimer) clearInterval(this.intervalTimer); + } + + async archiveExpiredLogs(now = new Date()) { + const retentionDays = positiveIntegerEnv('OPERATION_LOG_RETENTION_DAYS', DEFAULT_RETENTION_DAYS); + const batchSize = Math.min(10_000, positiveIntegerEnv('OPERATION_LOG_ARCHIVE_BATCH_SIZE', DEFAULT_BATCH_SIZE)); + const maxBatches = Math.min(100, positiveIntegerEnv('OPERATION_LOG_ARCHIVE_MAX_BATCHES', DEFAULT_MAX_BATCHES)); + const cutoff = new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1_000); + let total = 0; + + for (let batch = 0; batch < maxBatches; batch += 1) { + const moved = await this.archiveBatch(cutoff, batchSize); + total += moved; + if (moved < batchSize) { + break; + } + } + return { archived: total, cutoff, retentionDays }; + } + + private async archiveBatch(cutoff: Date, batchSize: number) { + return this.prisma.$executeRaw(Prisma.sql` + WITH candidates AS ( + SELECT "id", "tenantId", "userId", "action", "resource", "resourceId", + "ipAddress", "userAgent", "detail", "createdAt" + FROM "OperationLog" + WHERE "createdAt" < ${cutoff} + ORDER BY "createdAt" ASC, "id" ASC + LIMIT ${batchSize} + FOR UPDATE SKIP LOCKED + ), archived AS ( + INSERT INTO "OperationLogArchive" ( + "originalId", "tenantId", "userId", "action", "resource", "resourceId", + "ipAddress", "userAgent", "detail", "createdAt", "archiveMonth", "archivedAt" + ) + SELECT "id", "tenantId", "userId", "action", "resource", "resourceId", + "ipAddress", "userAgent", "detail", "createdAt", TO_CHAR("createdAt", 'YYYY-MM'), NOW() + FROM candidates + ON CONFLICT ("originalId") DO NOTHING + RETURNING "originalId" + ) + DELETE FROM "OperationLog" source + USING candidates + WHERE source."id" = candidates."id" + AND ( + EXISTS ( + SELECT 1 FROM archived + WHERE archived."originalId" = source."id" + ) + OR EXISTS ( + SELECT 1 FROM "OperationLogArchive" archive + WHERE archive."originalId" = source."id" + ) + ) + `); + } + + private async runSafely() { + if (this.running) { + return; + } + this.running = true; + try { + const result = await this.archiveExpiredLogs(); + if (result.archived > 0) { + this.logger.log(`Archived ${result.archived} operation logs older than ${result.cutoff.toISOString()}`); + } + } catch (error) { + this.logger.error('Operation log archival failed', error instanceof Error ? error.stack : String(error)); + } finally { + this.running = false; + } + } +} + +function operationLogArchiveEnabled() { + const configured = String(process.env.OPERATION_LOG_ARCHIVE_ENABLED ?? 'true').trim().toLowerCase(); + return configured !== 'false' && configured !== '0'; +} + +function positiveIntegerEnv(name: string, fallback: number) { + const value = Number(process.env[name]); + return Number.isInteger(value) && value > 0 ? value : fallback; +} diff --git a/api/src/billing/billing.controller.ts b/api/src/billing/billing.controller.ts index 70f7af1..6228a09 100644 --- a/api/src/billing/billing.controller.ts +++ b/api/src/billing/billing.controller.ts @@ -6,8 +6,6 @@ import { BillingService, BillingActionDto, CreateManualRechargeDto, - CreateRechargeOrderDto, - CreateBillingPlanDto, CreateBillingRuleDto, CreateSmsBillingRecordDto, CreateTenantAccountDto, @@ -19,16 +17,6 @@ import { export class BillingController { constructor(private readonly billing: BillingService) {} - @Get('plans') - listPlans() { - return this.billing.listPlans(); - } - - @Post('plans') - createPlan(@Body() body: CreateBillingPlanDto) { - return this.billing.createPlan(body); - } - @Get('accounts') listAccounts() { return this.billing.listAccounts(); @@ -44,11 +32,6 @@ export class BillingController { return this.billing.listRechargeOrders(tenantId); } - @Post('recharges') - createRechargeOrder(@Body() body: CreateRechargeOrderDto) { - return this.billing.createRechargeOrder(body); - } - @Get('manual-recharges') listManualRechargeRecords(@TenantId() tenantId?: string) { return this.billing.listManualRechargeRecords(tenantId); @@ -123,16 +106,6 @@ export class BillingController { export class ClientBillingController { constructor(private readonly billing: BillingService) {} - @Get('plans') - listPlans() { - return this.billing.listPlans(); - } - - @Post('orders') - createRechargeOrder(@Body() body: CreateRechargeOrderDto) { - return this.billing.createRechargeOrder(body); - } - @Get('orders') listRechargeOrders(@TenantId() tenantId?: string) { return this.billing.listRechargeOrders(tenantId); diff --git a/api/src/billing/billing.service.spec.ts b/api/src/billing/billing.service.spec.ts index dd8cf63..e74eddc 100644 --- a/api/src/billing/billing.service.spec.ts +++ b/api/src/billing/billing.service.spec.ts @@ -1,21 +1,15 @@ import { BillingService } from './billing.service'; function createPrismaMock() { - const accountState = { tenantId: 'tenant-1', balanceCents: 1000, smsUnits: 20, creditCents: 200 }; + const accountState = { tenantId: 'tenant-1', balanceCents: 1000 }; return { accountState, - billingPlan: { - findMany: jest.fn(), - create: jest.fn(), - findUnique: jest.fn(), - }, tenantAccount: { findMany: jest.fn(), create: jest.fn(), upsert: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })), update: jest.fn().mockImplementation(({ data }) => { accountState.balanceCents = data.balanceCents; - accountState.smsUnits = data.smsUnits; return Promise.resolve({ ...accountState }); }), }, @@ -68,38 +62,33 @@ describe('BillingService', () => { ); }); - it('checks balance, credit, and package units before sending', async () => { + it('checks only the cash balance before sending', async () => { const prisma = createPrismaMock(); const service = new BillingService(prisma as never); - await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1100, smsUnits: 20 })).resolves.toEqual( - expect.objectContaining({ availableAmount: 1200, availableSmsUnits: 20, canSend: true }), + await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 5 })).resolves.toEqual( + expect.objectContaining({ availableAmount: 1000, canSend: true }), ); - await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1300, smsUnits: 20 })).resolves.toEqual( - expect.objectContaining({ canSend: false }), - ); - await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 100, smsUnits: 21 })).resolves.toEqual( + await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1001 })).resolves.toEqual( expect.objectContaining({ canSend: false }), ); }); - it('creates recharge orders and account transactions from plans', async () => { + it('creates cash recharge orders and account transactions without plans', async () => { const prisma = createPrismaMock(); - prisma.billingPlan.findUnique.mockResolvedValue({ id: 'plan-1', priceCents: 500, smsUnits: 100 }); const service = new BillingService(prisma as never); - const order = await service.createRechargeOrder({ tenantId: 'tenant-1', planId: 'plan-1', remark: 'manual top up' }); + const order = await service.createRechargeOrder({ tenantId: 'tenant-1', amountCents: 500, remark: 'manual top up' }); - expect(order).toEqual(expect.objectContaining({ amountCents: 500, smsUnits: 100, status: 'paid' })); + expect(order).toEqual(expect.objectContaining({ amountCents: 500, status: 'paid' })); expect(prisma.tenantAccount.update).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1' }, - data: { balanceCents: 1500, smsUnits: 120 }, + data: { balanceCents: 1500 }, }); expect(prisma.accountTransaction.create).toHaveBeenCalledWith({ data: expect.objectContaining({ transactionType: 'recharge', amountCents: 500, - smsUnits: 100, balanceAfter: 1500, relatedType: 'recharge_order', relatedId: 'order-1', @@ -114,7 +103,6 @@ describe('BillingService', () => { const order = await service.createManualRecharge({ tenantId: 'tenant-1', amountCents: 2000, - smsUnits: 0, operatorId: 'admin-1', remark: '线下转账人工充值', }); @@ -175,7 +163,6 @@ describe('BillingService', () => { const order = await service.createManualRecharge({ tenantId: 'tenant-1', amountCents: -300, - smsUnits: 0, operatorId: 'admin-1', remark: '人工冲正', }); @@ -183,7 +170,7 @@ describe('BillingService', () => { expect(order).toEqual(expect.objectContaining({ amountCents: -300, payMethod: 'manual_topup', status: 'paid' })); expect(prisma.tenantAccount.update).toHaveBeenCalledWith({ where: { tenantId: 'tenant-1' }, - data: { balanceCents: 700, smsUnits: 20 }, + data: { balanceCents: 700 }, }); expect(prisma.accountTransaction.create).toHaveBeenCalledWith({ data: expect.objectContaining({ @@ -205,11 +192,11 @@ describe('BillingService', () => { const prisma = createPrismaMock(); const service = new BillingService(prisma as never); - await service.freeze({ tenantId: 'tenant-1', amountCents: 100, smsUnits: 2, relatedType: 'sms_batch_task', relatedId: 'task-1' }); - await service.charge({ tenantId: 'tenant-1', amountCents: 50, smsUnits: 1, relatedType: 'sms_message_record', relatedId: 'msg-1' }); - await service.release({ tenantId: 'tenant-1', amountCents: 25, smsUnits: 1 }); - await service.refund({ tenantId: 'tenant-1', amountCents: 10, smsUnits: 1 }); - await service.adjust({ tenantId: 'tenant-1', amountCents: 5, smsUnits: 0 }); + await service.freeze({ tenantId: 'tenant-1', amountCents: 100, relatedType: 'sms_batch_task', relatedId: 'task-1' }); + await service.charge({ tenantId: 'tenant-1', amountCents: 50, relatedType: 'sms_message_record', relatedId: 'msg-1' }); + await service.release({ tenantId: 'tenant-1', amountCents: 25 }); + await service.refund({ tenantId: 'tenant-1', amountCents: 10 }); + await service.adjust({ tenantId: 'tenant-1', amountCents: 5 }); expect(prisma.accountTransaction.create.mock.calls.map(([arg]) => arg.data.transactionType)).toEqual([ 'frozen', @@ -218,7 +205,7 @@ describe('BillingService', () => { 'refunded', 'adjusted', ]); - expect(prisma.accountState).toEqual(expect.objectContaining({ balanceCents: 890, smsUnits: 19 })); + expect(prisma.accountState).toEqual(expect.objectContaining({ balanceCents: 890 })); }); it('creates SMS billing records linked to message and task identifiers', async () => { diff --git a/api/src/billing/billing.service.ts b/api/src/billing/billing.service.ts index 0fdc3ba..a898ef7 100644 --- a/api/src/billing/billing.service.ts +++ b/api/src/billing/billing.service.ts @@ -2,20 +2,9 @@ import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; -export interface CreateBillingPlanDto { - name: string; - priceCents: number; - smsUnits: number; - validDays: number; - status?: string; - description?: string; -} - export interface CreateTenantAccountDto { tenantId: string; balanceCents?: number; - smsUnits?: number; - creditCents?: number; status?: string; } @@ -23,7 +12,6 @@ export interface CreateAccountTransactionDto { tenantId: string; transactionType: string; amountCents?: number; - smsUnits?: number; balanceAfter?: number; relatedType?: string; relatedId?: string; @@ -40,9 +28,7 @@ export interface CreateBillingRuleDto { export interface CreateRechargeOrderDto { tenantId: string; - planId?: string; - amountCents?: number; - smsUnits?: number; + amountCents: number; payMethod?: string; operatorId?: string; remark?: string; @@ -51,7 +37,6 @@ export interface CreateRechargeOrderDto { export interface CreateManualRechargeDto { tenantId: string; amountCents: number; - smsUnits?: number; operatorId?: string; remark?: string; } @@ -68,7 +53,6 @@ export interface EstimateSmsCostDto { export interface BillingActionDto { tenantId: string; amountCents?: number; - smsUnits?: number; relatedType?: string; relatedId?: string; remark?: string; @@ -88,23 +72,6 @@ export interface CreateSmsBillingRecordDto { export class BillingService { constructor(private readonly prisma: PrismaService) {} - listPlans() { - return this.prisma.billingPlan.findMany({ orderBy: { createdAt: 'desc' } }); - } - - createPlan(data: CreateBillingPlanDto) { - return this.prisma.billingPlan.create({ - data: { - name: data.name, - priceCents: data.priceCents, - smsUnits: data.smsUnits, - validDays: data.validDays, - status: data.status ?? 'active', - description: data.description, - }, - }); - } - listAccounts() { return this.prisma.tenantAccount.findMany({ include: { tenant: true }, @@ -116,8 +83,6 @@ export class BillingService { const createData: Prisma.TenantAccountUncheckedCreateInput = { tenantId: data.tenantId, balanceCents: data.balanceCents ?? 0, - smsUnits: data.smsUnits ?? 0, - creditCents: data.creditCents ?? 0, status: data.status ?? 'active', }; return this.prisma.tenantAccount.create({ data: createData }); @@ -126,7 +91,6 @@ export class BillingService { listRechargeOrders(tenantId?: string) { return this.prisma.rechargeOrder.findMany({ where: tenantId ? { tenantId } : undefined, - include: { plan: true }, orderBy: { createdAt: 'desc' }, }); } @@ -137,7 +101,6 @@ export class BillingService { tenantId, payMethod: 'manual_topup', }, - include: { plan: true }, orderBy: { createdAt: 'desc' }, }); const orderIds = orders.map((order) => order.id); @@ -161,16 +124,12 @@ export class BillingService { } async createRechargeOrder(data: CreateRechargeOrderDto) { - const plan = data.planId ? await this.prisma.billingPlan.findUnique({ where: { id: data.planId } }) : null; - const amountCents = data.amountCents ?? plan?.priceCents ?? 0; - const smsUnits = data.smsUnits ?? plan?.smsUnits ?? 0; + const amountCents = data.amountCents; const order = await this.prisma.rechargeOrder.create({ data: { tenantId: data.tenantId, - planId: data.planId, orderNo: `R${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`, amountCents, - smsUnits, status: 'paid', payMethod: data.payMethod ?? 'manual', paidAt: new Date(), @@ -183,7 +142,6 @@ export class BillingService { tenantId: data.tenantId, transactionType: 'recharge', amountCents, - smsUnits, relatedType: 'recharge_order', relatedId: order.id, remark: data.remark, @@ -196,7 +154,6 @@ export class BillingService { const order = await this.createRechargeOrder({ tenantId: data.tenantId, amountCents: data.amountCents, - smsUnits: data.smsUnits ?? 0, payMethod: 'manual_topup', operatorId: data.operatorId, remark: data.remark, @@ -210,7 +167,6 @@ export class BillingService { resourceId: order.id, detail: { amountCents: data.amountCents, - smsUnits: data.smsUnits ?? 0, orderNo: order.orderNo, remark: data.remark, } as Prisma.InputJsonValue, @@ -239,15 +195,12 @@ export class BillingService { async checkAccount(data: BillingActionDto) { const account = await this.getAccountOrCreate(data.tenantId); const requiredAmount = data.amountCents ?? 0; - const requiredUnits = data.smsUnits ?? 0; - const availableAmount = account.balanceCents + account.creditCents; + const availableAmount = account.balanceCents; return { tenantId: data.tenantId, requiredAmount, - requiredUnits, availableAmount, - availableSmsUnits: account.smsUnits, - canSend: availableAmount >= requiredAmount && account.smsUnits >= requiredUnits, + canSend: availableAmount >= requiredAmount, }; } @@ -256,7 +209,6 @@ export class BillingService { ...data, transactionType: 'frozen', amountCents: -(data.amountCents ?? 0), - smsUnits: -(data.smsUnits ?? 0), }); } @@ -265,7 +217,6 @@ export class BillingService { ...data, transactionType: 'charged', amountCents: -(data.amountCents ?? 0), - smsUnits: -(data.smsUnits ?? 0), }); } @@ -274,7 +225,6 @@ export class BillingService { ...data, transactionType: 'released', amountCents: data.amountCents ?? 0, - smsUnits: data.smsUnits ?? 0, }); } @@ -283,7 +233,6 @@ export class BillingService { ...data, transactionType: 'refunded', amountCents: data.amountCents ?? 0, - smsUnits: data.smsUnits ?? 0, }); } @@ -292,7 +241,6 @@ export class BillingService { ...data, transactionType: 'adjusted', amountCents: data.amountCents ?? 0, - smsUnits: data.smsUnits ?? 0, }); } @@ -348,19 +296,17 @@ export class BillingService { return this.prisma.tenantAccount.upsert({ where: { tenantId }, update: {}, - create: { tenantId, balanceCents: 0, smsUnits: 0, creditCents: 0, status: 'active' }, + create: { tenantId, balanceCents: 0, status: 'active' }, }); } private async applyAccountDelta(data: CreateAccountTransactionDto) { const account = await this.getAccountOrCreate(data.tenantId); const nextBalance = account.balanceCents + (data.amountCents ?? 0); - const nextUnits = account.smsUnits + (data.smsUnits ?? 0); await this.prisma.tenantAccount.update({ where: { tenantId: data.tenantId }, data: { balanceCents: nextBalance, - smsUnits: nextUnits, }, }); @@ -369,7 +315,6 @@ export class BillingService { tenantId: data.tenantId, transactionType: data.transactionType, amountCents: data.amountCents ?? 0, - smsUnits: data.smsUnits ?? 0, balanceAfter: nextBalance, relatedType: data.relatedType, relatedId: data.relatedId, diff --git a/api/src/operations/admin-operations.controller.ts b/api/src/operations/admin-operations.controller.ts index 885d1d6..d9a870f 100644 --- a/api/src/operations/admin-operations.controller.ts +++ b/api/src/operations/admin-operations.controller.ts @@ -92,8 +92,13 @@ export class AdminOperationsController { } @Get('audit-logs') - auditLogs(@Query('tenantId') tenantId?: string, @Query('userId') userId?: string) { - return this.operations.auditLogs({ tenantId, userId }); + auditLogs( + @Query('tenantId') tenantId?: string, + @Query('userId') userId?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.operations.auditLogs({ tenantId, userId, page: Number(page), pageSize: Number(pageSize) }); } @Get('audit-summary') diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index 9868751..0551cba 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -26,7 +26,7 @@ function createPrismaMock() { aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }), }, accountTransaction: { - aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: -20, smsUnits: -2 } }), + aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: -20 } }), }, tenantAccount: { findMany: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', balanceCents: 1000, tenant: { name: '租户A' } }]), @@ -331,6 +331,36 @@ describe('OperationsService', () => { ); }); + it('applies operation-log level filters before database pagination', async () => { + const prisma = createPrismaMock(); + const service = new OperationsService(prisma as never); + + await service.systemLogs({ level: 'error', page: 2, pageSize: 5 }); + + expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + AND: expect.objectContaining({ OR: expect.any(Array) }), + }), + skip: 5, + take: 5, + })); + expect(prisma.operationLog.count).toHaveBeenCalledWith({ + where: expect.objectContaining({ AND: expect.objectContaining({ OR: expect.any(Array) }) }), + }); + }); + + it('caps legacy audit-log reads with pagination', async () => { + const prisma = createPrismaMock(); + const service = new OperationsService(prisma as never); + + await expect(service.auditLogs({ page: 1, pageSize: 1_000 })).resolves.toEqual(expect.objectContaining({ + total: 1, + page: 1, + pageSize: 100, + })); + expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 100 })); + }); + it('returns paginated gateway submit dead letters', async () => { const prisma = createPrismaMock(); const service = new OperationsService(prisma as never); diff --git a/api/src/operations/operations.service.ts b/api/src/operations/operations.service.ts index 6156aaf..2fed3eb 100644 --- a/api/src/operations/operations.service.ts +++ b/api/src/operations/operations.service.ts @@ -180,8 +180,8 @@ export class OperationsService { _count: { _all: true }, }), this.prisma.accountTransaction.aggregate({ - where: { tenantId: query.tenantId }, - _sum: { amountCents: true, smsUnits: true }, + where: { tenantId: query.tenantId, transactionType: 'refunded', createdAt: { gte: sinceToday } }, + _sum: { amountCents: true }, _count: { _all: true }, }), this.prisma.cmppConnectionState.groupBy({ @@ -296,21 +296,31 @@ export class OperationsService { }); } - auditLogs(query: { tenantId?: string; userId?: string }) { - return this.prisma.operationLog.findMany({ - where: { tenantId: query.tenantId, userId: query.userId }, - orderBy: { createdAt: 'desc' }, - }); + async auditLogs(query: { tenantId?: string; userId?: string; page?: number; pageSize?: number }) { + const page = positiveInteger(query.page, 1); + const pageSize = Math.min(100, positiveInteger(query.pageSize, 20)); + const where: Prisma.OperationLogWhereInput = { tenantId: query.tenantId, userId: query.userId }; + const [items, total] = await Promise.all([ + this.prisma.operationLog.findMany({ + where, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.operationLog.count({ where }), + ]); + return { items, total, page, pageSize }; } 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 page = positiveInteger(query.page, 1); + const pageSize = Math.min(100, positiveInteger(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, + AND: query.level && query.level !== 'all' ? operationLogLevelWhere(query.level) : undefined, OR: query.keyword ? [ { action: { contains: query.keyword } }, { resource: { contains: query.keyword } }, @@ -324,7 +334,7 @@ export class OperationsService { this.prisma.operationLog.findMany({ where, include: { tenant: true, user: true }, - orderBy: { createdAt: 'desc' }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], skip: (page - 1) * pageSize, take: pageSize, }), @@ -336,12 +346,9 @@ export class OperationsService { 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, + items: items.map((item) => normalizeOperationLog(item)), + total, page, pageSize, modules: modules.map((item) => item.resource), @@ -722,7 +729,7 @@ export class OperationsService { relatedId: query.taskId, }, _count: { _all: true }, - _sum: { amountCents: true, smsUnits: true }, + _sum: { amountCents: true }, }), ]); const messageAmount = messages._sum.amountCents ?? 0; @@ -969,6 +976,48 @@ function groupDownstreamByApplication( return [...summaryMap.values()]; } +function positiveInteger(value: number | undefined, fallback: number) { + const normalized = Number(value); + return Number.isInteger(normalized) && normalized > 0 ? normalized : fallback; +} + +function operationLogLevelWhere(level: string): Prisma.OperationLogWhereInput { + const error: Prisma.OperationLogWhereInput = { + OR: [ + { action: { contains: 'failed' } }, + { action: { contains: 'reject' } }, + { detail: { path: ['result'], string_contains: 'fail' } }, + { detail: { path: ['status'], string_contains: 'fail' } }, + ], + }; + const warning: Prisma.OperationLogWhereInput = { + OR: [ + { action: { contains: 'warning' } }, + { action: { contains: 'risk' } }, + ], + }; + const success: Prisma.OperationLogWhereInput = { + OR: [ + { action: { contains: 'approve' } }, + { action: { contains: 'recharge' } }, + { action: { contains: 'connected' } }, + ], + }; + if (level === 'error') { + return error; + } + if (level === 'warning') { + return { AND: [{ NOT: error }, warning] }; + } + if (level === 'success') { + return { AND: [{ NOT: error }, { NOT: warning }, success] }; + } + if (level === 'info') { + return { NOT: { OR: [error, warning, success] } }; + } + return {}; +} + function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { tenant: true; user: true } }>) { const detail = (log.detail ?? {}) as Record; const result = String(detail.result ?? detail.status ?? ''); diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index c06d2d6..2e7f3ca 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -249,11 +249,13 @@ function createPrismaMock() { updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, gatewayDownstreamRecoveryStatus: { + findUnique: jest.fn().mockResolvedValue(null), upsert: jest.fn().mockResolvedValue({ id: 'recover-1', tenantId: 'tenant-1', applicationId: 'app-1', account: '100001', + gatewayInstanceId: 'gateway-a', state: 'waiting_connection', lockOwner: 'gateway-a', lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'), @@ -341,7 +343,7 @@ describe('SendChainService', () => { expect.objectContaining({ phoneNumber: '13800000002', status: 'queued', billingUnits: 1, amountCents: 3, queuePriority: 'normal' }), ]), }); - expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 6, smsUnits: 2, relatedId: 'task-1' })); + expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 6, relatedId: 'task-1' })); expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1'); }); @@ -376,7 +378,7 @@ describe('SendChainService', () => { dispatched: 1, results: [{ taskId: 'task-1', status: 'queued', enqueued: 1 }], }); - expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, smsUnits: 1, relatedId: 'task-1' })); + expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, relatedId: 'task-1' })); expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({ where: { batchTaskId: 'task-1', status: 'scheduled' }, data: { status: 'queued' }, @@ -854,8 +856,8 @@ describe('SendChainService', () => { where: { id: 'record-1' }, data: expect.objectContaining({ gatewayMessageId: 'GW-1', status: 'submitted', submitStatus: 'accepted' }), }); - expect(billing.release).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, smsUnits: 1, relatedId: 'task-1' })); - expect(billing.charge).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, smsUnits: 1, relatedId: 'MSG-1' })); + expect(billing.release).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, relatedId: 'task-1' })); + expect(billing.charge).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, relatedId: 'MSG-1' })); expect(prisma.smsBillingRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ messageId: 'MSG-1', amountCents: 3, billingStatus: 'charged', transactionId: 'tx-charge' }), }); @@ -1412,12 +1414,39 @@ describe('SendChainService', () => { })); expect(prisma.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ - action: 'gateway.downstream_recovery_status_sync', + action: 'gateway.downstream_recovery_status_changed', resource: 'gateway_downstream_recovery_status', }), })); }); + it('does not append recovery audit logs when only periodic timestamps change', async () => { + const { service, prisma } = createService(); + prisma.gatewayDownstreamRecoveryStatus.findUnique.mockResolvedValue({ + state: 'waiting_connection', + gatewayInstanceId: 'gateway-a', + lockOwner: 'gateway-a', + failureCategory: 'client_disconnected', + lastError: 'downstream client is not connected', + lastSkipReason: null, + }); + + await service.recordGatewayDownstreamRecoveryStatus({ + account: '100001', + gatewayInstanceId: 'gateway-a', + state: 'waiting_connection', + lastAttemptAt: '2026-07-08T12:01:00.000Z', + nextRetryAt: '2026-07-08T12:11:00.000Z', + attemptCount: 3, + lockOwner: 'gateway-a', + lockExpiresAt: '2026-07-08T12:01:30.000Z', + lastError: 'downstream client is not connected', + }); + + expect(prisma.gatewayDownstreamRecoveryStatus.upsert).toHaveBeenCalled(); + expect(prisma.operationLog.create).not.toHaveBeenCalled(); + }); + it('marks downstream delivery as failed after reaching retry limit', async () => { const { service, prisma } = createService(); const previous = process.env.CMPP_DOWNSTREAM_MAX_RETRIES; diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index 65ed9f5..8220fe9 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -279,10 +279,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { const accountCheck = await this.billing.checkAccount({ tenantId: data.tenantId, amountCents: billing.amountCents, - smsUnits: billing.totalBillingUnits, }); if (!accountCheck.canSend) { - throw new BadRequestException('企业账户余额、套餐余量或授信额度不足'); + throw new BadRequestException('企业账户余额不足'); } } const task = await this.prisma.smsBatchTask.create({ @@ -305,11 +304,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { createdById: data.createdById, }, }); - if (shouldReserveBalance && billing.amountCents + billing.totalBillingUnits > 0) { + if (shouldReserveBalance && billing.amountCents > 0) { await this.billing.freeze({ tenantId: data.tenantId, amountCents: billing.amountCents, - smsUnits: billing.totalBillingUnits, relatedType: 'sms_batch_task', relatedId: task.id, remark: '发送任务创建冻结', @@ -637,16 +635,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { take: 100000, }); const amountCents = messages.reduce((sum, message) => sum + message.amountCents, 0); - const smsUnits = messages.reduce((sum, message) => sum + message.billingUnits, 0); - const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents, smsUnits }); + const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents }); if (!accountCheck.canSend) { - throw new BadRequestException('定时任务到点时企业账户余额、套餐余量或授信额度不足'); + throw new BadRequestException('定时任务到点时企业账户余额不足'); } - if (amountCents + smsUnits > 0) { + if (amountCents > 0) { await this.billing.freeze({ tenantId: task.tenantId, amountCents, - smsUnits, relatedType: 'sms_batch_task', relatedId: task.id, remark: '定时任务到点冻结', @@ -1114,9 +1110,21 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } const recoveryStatuses = (this.prisma as PrismaService & { gatewayDownstreamRecoveryStatus: { + findUnique: (args: Record) => Promise; upsert: (args: Record) => Promise; }; }).gatewayDownstreamRecoveryStatus; + const previous = await recoveryStatuses.findUnique({ + where: { account }, + select: { + state: true, + gatewayInstanceId: true, + lockOwner: true, + failureCategory: true, + lastError: true, + lastSkipReason: true, + }, + }); const application = await this.prisma.smsApplication.findUnique({ where: { cmppAccount: account }, select: { id: true, tenantId: true, name: true }, @@ -1167,27 +1175,30 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { lockOwner?: string | null; lockExpiresAt?: Date | null; }; - await this.prisma.operationLog.create({ - data: { - tenantId: updated.tenantId ?? undefined, - action: 'gateway.downstream_recovery_status_sync', - resource: 'gateway_downstream_recovery_status', - resourceId: updated.id, - detail: { - account, - state: updated.state, - lockOwner: normalizedUpdated.lockOwner, - lockExpiresAt: normalizedUpdated.lockExpiresAt, - attemptCount: updated.attemptCount, - nextRetryAt: updated.nextRetryAt, - failureCategory: normalizedUpdated.failureCategory, - applicationId: updated.applicationId, - applicationName: application?.name, - lastError: updated.lastError, - lastSkipReason: updated.lastSkipReason, + if (hasRecoveryAuditStateChanged(previous, updated)) { + await this.prisma.operationLog.create({ + data: { + tenantId: updated.tenantId ?? undefined, + action: 'gateway.downstream_recovery_status_changed', + resource: 'gateway_downstream_recovery_status', + resourceId: updated.id, + detail: { + account, + previousState: previous?.state ?? null, + state: updated.state, + gatewayInstanceId: updated.gatewayInstanceId, + lockOwner: normalizedUpdated.lockOwner, + attemptCount: updated.attemptCount, + nextRetryAt: updated.nextRetryAt, + failureCategory: normalizedUpdated.failureCategory, + applicationId: updated.applicationId, + applicationName: application?.name, + lastError: updated.lastError, + lastSkipReason: updated.lastSkipReason, + }, }, - }, - }); + }); + } return updated; } @@ -1701,16 +1712,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { const accountCheck = await this.billing.checkAccount({ tenantId: application.tenantId, amountCents: billing.amountCents, - smsUnits: billing.totalBillingUnits, }); if (!accountCheck.canSend) { - await reject('BALANCE', '企业账户余额、套餐余量或授信额度不足'); + await reject('BALANCE', '企业账户余额不足'); } else { - if (billing.amountCents + billing.totalBillingUnits > 0) { + if (billing.amountCents > 0) { await this.billing.freeze({ tenantId: application.tenantId, amountCents: billing.amountCents, - smsUnits: billing.totalBillingUnits, relatedType: 'sms_batch_task', relatedId: task.id, remark: 'CMPP 模板不匹配待审核短信冻结', @@ -1764,16 +1773,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { const accountCheck = await this.billing.checkAccount({ tenantId: application.tenantId, amountCents: billing.amountCents, - smsUnits: billing.totalBillingUnits, }); if (!accountCheck.canSend) { - await reject('BALANCE', '企业账户余额、套餐余量或授信额度不足'); + await reject('BALANCE', '企业账户余额不足'); } else { - if (billing.amountCents + billing.totalBillingUnits > 0) { + if (billing.amountCents > 0) { await this.billing.freeze({ tenantId: application.tenantId, amountCents: billing.amountCents, - smsUnits: billing.totalBillingUnits, relatedType: 'sms_batch_task', relatedId: task.id, remark: 'CMPP 入站短信冻结', @@ -2253,16 +2260,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { amountCents: number; }) { const amountCents = message.amountCents ?? 0; - const smsUnits = message.billingUnits ?? 0; + const billingUnits = message.billingUnits ?? 0; const exists = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId } }); if (exists?.billingStatus === 'charged') { return; } - if (amountCents + smsUnits > 0) { + if (amountCents > 0) { await this.billing.release({ tenantId: message.tenantId, amountCents, - smsUnits, relatedType: 'sms_batch_task', relatedId: message.batchTaskId, remark: `短信 ${message.messageId} 提交成功释放冻结并转扣费`, @@ -2271,7 +2277,6 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { const transaction = await this.billing.charge({ tenantId: message.tenantId, amountCents, - smsUnits, relatedType: 'sms_message_record', relatedId: message.messageId, remark: '提交成功扣费', @@ -2283,7 +2288,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { messageId: message.messageId, phoneNumber: message.phoneNumber, contentLength: [...message.content].length, - billingUnits: smsUnits, + billingUnits, unitPrice: message.unitPrice ?? 0, amountCents, billingStatus: 'charged', @@ -2300,7 +2305,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number; billingUnits: number }, remark: string, ) { - if ((message.amountCents ?? 0) + (message.billingUnits ?? 0) <= 0) { + if ((message.amountCents ?? 0) <= 0) { return; } const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } }); @@ -2316,7 +2321,6 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { await this.billing.release({ tenantId: message.tenantId, amountCents: message.amountCents, - smsUnits: message.billingUnits, relatedType: 'sms_message_record', relatedId: message.messageId, remark: `${remark}: ${message.messageId}`, @@ -2327,7 +2331,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { message: { tenantId: string; messageId: string; amountCents: number; billingUnits: number }, remark: string, ) { - if ((message.amountCents ?? 0) + (message.billingUnits ?? 0) <= 0) { + if ((message.amountCents ?? 0) <= 0) { return; } const refunded = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'refunded' } }); @@ -2341,7 +2345,6 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { const transaction = await this.billing.refund({ tenantId: message.tenantId, amountCents: message.amountCents, - smsUnits: message.billingUnits, relatedType: 'sms_message_record', relatedId: message.messageId, remark, @@ -2919,6 +2922,17 @@ function octetString(value: string, fixedLength: number) { return value + '\0'.repeat(fixedLength - value.length); } +function hasRecoveryAuditStateChanged( + previous: Record | null, + current: Record, +) { + if (!previous) { + return true; + } + return ['state', 'gatewayInstanceId', 'lockOwner', 'failureCategory', 'lastError', 'lastSkipReason'] + .some((key) => (previous[key] ?? null) !== (current[key] ?? null)); +} + function normalizeRecoveryFailureCategory(data: GatewayDownstreamRecoveryStatusDto) { const explicit = String(data.failureCategory ?? '').trim(); if (explicit) { diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index dbb84e6..5ab47ef 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -437,6 +437,36 @@ describe('SmsConfigService', () => { }); }); + it.each([ + ['heartbeat', 'lastHeartbeatAt'], + ['submit', 'lastSubmitAt'], + ['deliver', 'lastDeliverAt'], + ] as const)('updates downstream %s state without appending high-frequency operation logs', async (status, timestampField) => { + const prisma = createPrismaMock(); + prisma.cmppDownstreamConnection.findUnique.mockResolvedValue({ + id: 'downstream-1', + connectedAt: new Date('2026-07-11T11:00:00.000Z'), + lastHeartbeatAt: new Date('2026-07-11T11:00:00.000Z'), + lastSubmitAt: null, + lastDeliverAt: null, + lastError: null, + }); + const service = new SmsConfigService(prisma as never); + + await service.recordDownstreamConnectionEvent({ + account: '100001', + connectionId: 'gateway-1-1', + status, + observedAt: '2026-07-11T11:00:30.000Z', + }); + + expect(prisma.cmppDownstreamConnection.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'downstream-1' }, + data: expect.objectContaining({ [timestampField]: new Date('2026-07-11T11:00:30.000Z') }), + })); + expect(prisma.operationLog.create).not.toHaveBeenCalled(); + }); + it('lists enterprise signatures with keyword filters and real relations', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index 7d5e790..c0e0cba 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -593,13 +593,15 @@ export class SmsConfigService { const connection = existing ? await this.prisma.cmppDownstreamConnection.update({ where: { id: existing.id }, data: payload }) : await this.prisma.cmppDownstreamConnection.create({ data: { connectionId: data.connectionId, ...payload } }); - await this.writeOperationLog(application.tenantId, undefined, `cmpp_downstream_connection.${data.status}`, 'cmpp_downstream_connection', data.connectionId, { - applicationId: application.id, - account: data.account, - remoteIp: data.remoteIp, - protocol: data.protocol, - status: connection.status, - }); + if (data.status === 'connected' || data.status === 'disconnected') { + await this.writeOperationLog(application.tenantId, undefined, `cmpp_downstream_connection.${data.status}`, 'cmpp_downstream_connection', data.connectionId, { + applicationId: application.id, + account: data.account, + remoteIp: data.remoteIp, + protocol: data.protocol, + status: connection.status, + }); + } return connection; } diff --git a/api/src/tenants/tenants.service.spec.ts b/api/src/tenants/tenants.service.spec.ts index 2b976b6..6327336 100644 --- a/api/src/tenants/tenants.service.spec.ts +++ b/api/src/tenants/tenants.service.spec.ts @@ -23,11 +23,14 @@ function createPrismaMock() { update: jest.fn().mockResolvedValue({ id: 'cert-1' }), }, tenantAccount: { - findMany: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', balanceCents: 12000, smsUnits: 300, creditCents: 5000, status: 'active' }]), + findMany: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', balanceCents: 12000, status: 'active' }]), }, smsMessageRecord: { groupBy: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', _sum: { amountCents: 350 } }]), }, + accountTransaction: { + groupBy: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', _sum: { amountCents: 125 } }]), + }, }; } @@ -78,7 +81,7 @@ describe('TenantsService', () => { expect(prisma.tenant.update).not.toHaveBeenCalled(); }); - it('lists management rows with real account and today spend fields', async () => { + it('lists management rows with real account, today spend and actual refund fields', async () => { const prisma = createPrismaMock(); const service = new TenantsService(prisma as never); @@ -86,8 +89,9 @@ describe('TenantsService', () => { expect.objectContaining({ id: 'tenant-1', name: '测试企业', - account: expect.objectContaining({ balanceCents: 12000, creditCents: 5000 }), + account: expect.objectContaining({ balanceCents: 12000 }), todaySpendCents: 350, + todayRefundCents: 125, }), ]); expect(prisma.tenant.findMany).toHaveBeenCalledWith(expect.objectContaining({ @@ -99,5 +103,10 @@ describe('TenantsService', () => { by: ['tenantId'], _sum: { amountCents: true }, })); + expect(prisma.accountTransaction.groupBy).toHaveBeenCalledWith(expect.objectContaining({ + by: ['tenantId'], + where: expect.objectContaining({ transactionType: 'refunded' }), + _sum: { amountCents: true }, + })); }); }); diff --git a/api/src/tenants/tenants.service.ts b/api/src/tenants/tenants.service.ts index d894f46..0e916bf 100644 --- a/api/src/tenants/tenants.service.ts +++ b/api/src/tenants/tenants.service.ts @@ -45,7 +45,7 @@ export class TenantsService { async listManagementRows() { const sinceToday = startOfToday(); - const [tenants, accounts, todaySpendGroups] = await Promise.all([ + const [tenants, accounts, todaySpendGroups, todayRefundGroups] = await Promise.all([ this.prisma.tenant.findMany({ where: { status: { not: 'deleted' } }, include: { enterpriseCertifications: { orderBy: { submittedAt: 'desc' }, take: 1 } }, @@ -57,13 +57,20 @@ export class TenantsService { where: { queuedAt: { gte: sinceToday } }, _sum: { amountCents: true }, }), + this.prisma.accountTransaction.groupBy({ + by: ['tenantId'], + where: { transactionType: 'refunded', createdAt: { gte: sinceToday } }, + _sum: { amountCents: true }, + }), ]); const accountsByTenant = new Map(accounts.map((account) => [account.tenantId, account])); const todaySpendByTenant = new Map(todaySpendGroups.map((group) => [group.tenantId, group._sum.amountCents ?? 0])); + const todayRefundByTenant = new Map(todayRefundGroups.map((group) => [group.tenantId, group._sum.amountCents ?? 0])); return tenants.map((tenant) => ({ ...withEnterpriseProfile(tenant), account: accountsByTenant.get(tenant.id) ?? null, todaySpendCents: todaySpendByTenant.get(tenant.id) ?? 0, + todayRefundCents: todayRefundByTenant.get(tenant.id) ?? 0, })); } diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index bc1eaaa..0e30ae9 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -4,7 +4,7 @@ 本文基于当前前端设计原型整理,用于交给 Codex 或开发团队执行第一版落地开发。 -当前确认:第一版保留短信业务,排除彩信功能;账户计费、充值套餐、充值记录进入第一版开发范围,账单流水页面和公开交易查询 API 暂不进入第一版。彩信服务、彩信应用/签名/模板 Tab,以及运营端彩信相关菜单标记为“待开发”;业务性能指标为“平台可稳定入队并调度 500 条短信/秒,实际向通道 submit 受通道限速配置控制”。 +当前确认:第一版保留短信业务,排除彩信功能;账户按现金余额计费,人工充值和充值记录进入第一版开发范围,套餐、短信余量、授信额度、账单流水页面和公开交易查询 API 不进入第一版。彩信服务、彩信应用/签名/模板 Tab,以及运营端彩信相关菜单标记为“待开发”;业务性能指标为“平台可稳定入队并调度 500 条短信/秒,实际向通道 submit 受通道限速配置控制”。 ## 1. 项目目标 @@ -17,7 +17,7 @@ - 支持通道级限速、失败重试、回执同步和发送记录追踪。 - 支持企业、应用、签名、模板、通道、通道组、报备任务等核心配置数据的后台维护。 - 彩信功能仅保留菜单占位或隐藏,不进入第一版开发范围。 -- 账户计费、充值套餐、充值记录进入第一版范围,并与发送记录形成可对账闭环;账单流水页面暂不验收。 +- 账户现金余额计费、人工充值、充值记录进入第一版范围,并与发送记录形成可对账闭环;不提供套餐、短信余量和授信额度。 ## 2. 角色与权限 @@ -51,7 +51,7 @@ - 短信应用 - 模板管理 - 签名与引流信息 -- 充值套餐 +- 账户余额与充值记录 - 账单流水 - 企业认证 - 用户管理 @@ -73,7 +73,7 @@ - 短信通道管理、短信通道组管理 - 报备任务、报备记录 - 短信任务进度、短信记录、短信上行记录 -- 充值记录、账单流水、套餐配置、计费规则 +- 充值记录、账单流水、计费规则 - 企业黑名单、全局黑名单、敏感词管理 - 用户管理、手机号段库、引流信息字段库 @@ -327,21 +327,21 @@ ### 4.10 账户计费 -1. 客户端可查看充值套餐、购买或申请充值套餐、查看账单流水。 -2. 发送创建时按短信内容计费条数、企业应用客户单价或套餐规则生成预估费用,计费条数只按 70/67 字规则拆分;不按移动、联通、电信配置不同客户价。 -3. 平台需在发送前检查企业账户余额、套餐余量或授信额度。 +1. 客户端可查看现金余额和充值记录;充值由运营端人工入账。 +2. 发送创建时按短信内容计费条数和企业应用客户单价生成预估费用,计费条数只按 70/67 字规则拆分;不按移动、联通、电信配置不同客户价。 +3. 平台发送前只检查企业现金余额,余额大于等于预估费用即可发送。 4. 发送链路需记录计费条数、计费单价、计费金额、账务状态。 5. 账单流水与短信记录可追溯关联,支持按企业、应用、任务、手机号、时间对账。 6. 最终失败、超时失败需要退费。 7. 三网通道成本只用于平台内部成本核算,不影响客户扣费金额。 8. 当前版本计费口径固定为提交 accepted 扣费、最终 failed receipt/timeout 退款。 -9. 所有面向用户展示的金额、余额、充值金额、套餐价格和单价统一以人民币元展示并固定保留三位小数;内部仍使用分或最小计费单位持久化,不以展示精度改变账务计算。 +9. 所有面向用户展示的金额、余额、充值金额和单价统一以人民币元展示并固定保留三位小数;内部仍使用分或最小计费单位持久化,不以展示精度改变账务计算。 ## 5. 功能需求 ### 5.1 客户端工作台 -- 展示短信余量或可发送额度、今日发送量、成功率、待审核事项、快捷入口。 +- 展示现金余额、今日发送量、成功率、待审核事项、快捷入口。 - 展示最近发送批次、发送趋势、签名/模板状态。 - 数据范围限定为当前企业。 @@ -400,7 +400,7 @@ ### 5.9 客户端账户计费 -- 充值套餐:展示可购买套餐、套餐价格、短信条数、有效期、适用范围。 +- 账户余额:展示当前现金余额和人工充值记录,不提供客户端购买套餐或创建充值订单入口。 - 账单流水:展示充值、冻结、扣费、退费、调整等流水。 - 账单流水需关联短信任务、短信记录或人工调整单据。 - 客户端账号设置属于系统管理,不允许客户自行配置通道或通道组。 @@ -501,10 +501,9 @@ ### 5.19 运营端账户计费 -- 套餐配置:支持配置套餐名称、价格、短信条数、有效期、适用企业范围、启停状态。 -- 充值记录:支持人工充值、套餐购买记录、授信额度调整。 +- 充值记录:支持运营人员人工充值和负数冲正。 - 账单流水:支持冻结、扣费、退费、解冻、人工调整、失败返还。 -- 计费规则:支持按短信计费条数、企业单价、套餐余量、授信额度计算费用。 +- 计费规则:支持按短信计费条数和企业应用单价计算费用,发送额度仅取现金余额。 - 账务流水必须与短信记录形成可追溯关系,支持对账导出。 - 最终失败、超时失败需要退费。 - 计费口径可配置为按提交成功计费或按回执成功计费。 @@ -522,6 +521,9 @@ - 客户端和运营端右上角用户头像提供下拉菜单,支持退出登录、修改密码;账号设置独立菜单第一版不展示。 - 客户端和运营端系统日志均支持分页、搜索和详情展示;详情列内容较长时使用详情卡/弹窗展示,不能被表格窄列截断。 - 系统日志记录登录、退出、配置变更、审核、发送、导入导出、密钥重置、通道复制、通道启停、通道删除、连接状态变化、安全控制变更等操作。 +- CMPP 心跳、Submit、Deliver 和 Gateway 周期状态同步属于运行指标或当前状态更新,不得每次追加永久系统日志;连接建立、断开、超时、认证失败,以及恢复状态、实例、锁持有者或失败原因发生真实变化时才写系统日志。 +- 系统日志列表必须在数据库侧完成级别、租户、模块、时间和关键词筛选后再分页;所有新旧日志查询接口均限制每页最多 100 条,不允许无界返回全表。 +- `OperationLog` 在线保留期默认 180 天;到期日志以小批量事务搬入 `OperationLogArchive`,按 `archiveMonth=YYYY-MM` 形成逻辑月度归档。归档记录不自动删除,归档失败不得删除源记录;归档表达到千万级或维护窗口不满足要求时再评估 PostgreSQL 月度分区。 ## 6. 非功能需求 @@ -677,6 +679,7 @@ - user:用户。 - role、permission、user_role:权限。 - operation_log:操作日志。 +- operation_log_archive:超过在线保留期的操作日志归档,保留原始日志 id、租户、操作者、动作、资源、详情、发生时间和归档月份。 ### 9.2 短信配置 @@ -728,9 +731,8 @@ ### 9.7 账户计费 -- billing_plan:充值套餐。 - tenant_account:企业账户。 -- account_balance:余额或套餐余量。 +- account_balance:企业现金余额。 - account_transaction:账户流水。 - billing_rule:计费规则。 - sms_billing_record:短信计费记录。 @@ -937,7 +939,7 @@ 3. 实现文件上传和对象存储。 4. 实现操作日志。 5. 实现基础字典:手机号段、敏感词、黑名单、引流字段。 -6. 实现账户、套餐、账务流水基础模型。 +6. 实现现金余额账户和账务流水基础模型。 ### 阶段 3:企业与配置 @@ -974,8 +976,8 @@ 1. 实现客户端批量任务、发送详情、上行短信。 2. 实现运营端任务进度、短信记录、上行记录。 -3. 实现客户端充值套餐、账单流水。 -4. 实现运营端充值记录、账务流水、套餐配置。 +3. 实现客户端账户余额和充值记录。 +4. 实现运营端充值记录和账务流水。 5. 实现运营看板、发送监控、数据统计。 6. 实现导出权限和导出日志。 @@ -1007,7 +1009,7 @@ 1. 根据本文创建数据库迁移脚本和实体模型。 2. 实现认证、租户、权限基础模块。 3. 实现企业、应用、签名、模板 CRUD 与审核。 -4. 实现账户计费、套餐、账务流水。 +4. 实现现金余额计费和账务流水。 5. 实现通道、通道组、报备任务。 6. 实现风控规则、审核原因、规则命中记录。 7. 实现 NestJS Send Worker、BullMQ 队列和 Redis 限速。 @@ -1075,7 +1077,7 @@ ### 14.7 待确认问题 -暂无阻塞性待确认问题。后续进入详细设计或开发时,如遇具体运营商协议参数、生产部署资源规格、默认套餐价格等执行细节,再按模块补充确认。 +暂无阻塞性待确认问题。后续进入详细设计或开发时,如遇具体运营商协议参数、生产部署资源规格等执行细节,再按模块补充确认。 ## 15. 第一版落地执行路线 @@ -1210,10 +1212,10 @@ 任务: -1. 套餐配置。 -2. 充值套餐。 -3. 企业账户。 -4. 余额或套餐余量。 +1. 企业现金账户。 +2. 人工充值记录。 +3. 现金余额。 +4. 发送费用预估。 5. 充值记录。 6. 账单流水。 7. 发送预估费用。 @@ -1222,8 +1224,8 @@ 验收标准: -- 客户端充值套餐和账单流水进入第一版。 -- 发送前校验账户余额、套餐余量或授信额度。 +- 客户端账户余额和充值记录进入第一版。 +- 发送前仅校验企业现金余额。 - 每条短信记录可追溯到账务流水。 ### 阶段 6:风控与审核 @@ -1324,7 +1326,7 @@ - 运营端可针对指定企业录入人工充值金额、操作人和备注。 - 人工充值金额支持负数,用于余额冲正或调减;0 金额不得提交。 - 人工充值必须写入充值订单和账务流水。 - - 运营端充值记录需区分人工充值和套餐充值。 + - 运营端充值记录均为人工充值或负数冲正。 2. 客户端概览指标调整。 - 原“剩余条数”改为“剩余余额”。 - 原“近24小时成功率”改为“今日发送条数和今日成功率”。 @@ -1349,7 +1351,7 @@ 重要前提: - 第一版保留短信业务,排除彩信功能。 -- 账户计费、充值套餐、账单流水进入第一版。 +- 现金余额计费、人工充值、账单流水进入第一版。 - 前端使用 React + TypeScript + Vite。 - 后端 API 使用 NestJS + TypeScript。 - DB 使用 PostgreSQL。 @@ -1407,7 +1409,7 @@ 除文档或菜单明确标注为待开发的彩信能力外,第一版所有可进入菜单不得以 mock/static/localStorage 作为系统功能完成标准: -1. 客户端充值套餐、账单流水、批量任务、短信发送、短信签名、短信模板必须调用真实 API;签名/模板新增后进入真实审核状态,发送任务调用真实发送链路。 +1. 客户端账户余额、充值记录、批量任务、短信发送、短信签名、短信模板必须调用真实 API;签名/模板新增后进入真实审核状态,发送任务调用真实发送链路。 2. 运营端数据统计、账务账户、发送监控、短信审核、短信记录、安全控制、手机号段库、报备字段库、通道组、通道报备字段、报备任务和报备记录必须调用真实 API。 3. 运营端企业管理使用真实租户、账户、应用、签名、模板接口;企业新增、编辑、启用/禁用、删除必须写真实租户表,删除采用软删除或归档,不允许纯前端删除。 4. 企业签名和企业模板运营端列表只展示真实短信配置数据;彩信签名、彩信模板、彩信通道、彩信记录、彩信任务进度等仍归入待开发,不得用静态样例作为第一版短信验收结果。 diff --git a/docs/production-deployment.md b/docs/production-deployment.md index fade90c..472d786 100644 --- a/docs/production-deployment.md +++ b/docs/production-deployment.md @@ -35,6 +35,11 @@ SESSION_LOCK_RECOVERY_MS=14400000 SESSION_ABSOLUTE_TIMEOUT_MS=43200000 SESSION_RECENT_AUTH_MS=1800000 SESSION_COOKIE_SECURE=true +OPERATION_LOG_ARCHIVE_ENABLED=true +OPERATION_LOG_RETENTION_DAYS=180 +OPERATION_LOG_ARCHIVE_BATCH_SIZE=1000 +OPERATION_LOG_ARCHIVE_MAX_BATCHES=20 +OPERATION_LOG_ARCHIVE_INTERVAL_MS=86400000 CMPP_DOWNSTREAM_ACK_TIMEOUT_SECONDS=30 GATEWAY_CMPP_ADDR=0.0.0.0:17890 OBJECT_STORAGE_DRIVER=minio @@ -46,6 +51,8 @@ PROD_ADMIN_PASSWORD='change-me' 安全会话使用 HttpOnly Cookie,正式生产必须先为页面和 `/api` 配置 HTTPS,并保持 `SESSION_COOKIE_SECURE=true`。仅在用户明确授权的 HTTP 生产验证环境中,允许临时显式设置 `SESSION_COOKIE_SECURE=false` 维持验证可用性;该例外必须记录在发布验收中,不能替代正式环境 TLS。 +系统操作日志默认在线保留 180 天。API 每日以最多 20 个、每批 1000 条的小事务将过期记录搬入 `OperationLogArchive`,并用 `archiveMonth=YYYY-MM` 标记归档月份;归档记录不会自动删除。调整保留期或批量参数前,应先评估数据库、备份窗口和审计要求。归档表达到千万级或清理窗口不能满足要求时,再实施按 `createdAt` 的月度 PostgreSQL 分区,不在当前数据规模下提前改造主表分区。 + 脚本会安装 Node.js、Go、PostgreSQL、Redis、MinIO、Nginx,创建 systemd 服务,执行 Prisma migrate,构建前端/API/Gateway,并创建平台管理员。Node.js、Go 和 MinIO 下载会按服务器架构自动选择 x64/amd64 或 arm64。 如生产验证服务器临时无法稳定下载 MinIO,可显式传入 `OBJECT_STORAGE_DRIVER=local`,文件会通过真实 API 保存到服务器本地目录 `OBJECT_STORAGE_LOCAL_ROOT`,`cmpp-minio` 服务会跳过安装和启动。该模式只建议用于验证环境;正式生产建议恢复 `OBJECT_STORAGE_DRIVER=minio`。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 774a482..08c5312 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -21,7 +21,7 @@ | 模板 | 验证码模板 `验证码为 ${code}`,营销模板 `尊敬的${name},优惠活动开始`,分别准备草稿、待审核、通过、驳回。 | | 通道 | active CMPP 通道、disabled 通道、备用通道;通道组包含主备优先级。 | | 号码 | 合法号码、重复号码、非法号码、企业黑名单号码、全局黑名单号码。 | -| 账户 | 余额充足、余额不足、套餐余量充足、套餐余量不足、授信额度可用。 | +| 账户 | 现金余额充足、现金余额不足;不配置套餐余量或授信额度。 | | 企业认证 | 未认证、待审核、已通过、已驳回四类企业认证资料。 | | 客户 | 正常客户、停用客户、欠费客户、未认证客户、跨租户客户、客户联系人和开票资料。 | | 导入文件 | UTF-8 CSV、GBK CSV、TXT、超 20 MB 文件、含空行/重复/非法号码/非法字符文件。 | @@ -54,7 +54,7 @@ - 前置条件:存在 `tenant-a` 和 `tenant-b`,两个租户各有发送任务和充值/计费记录。 - 步骤: 1. 使用 `tenant-a` 企业管理员登录客户端。 - 2. 打开工作台、批量任务、发送详情、上行短信、充值套餐。 + 2. 打开工作台、批量任务、发送详情、上行短信、账户余额。 3. 使用查询条件搜索 `tenant-b` 的任务编号或手机号。 - 预期结果: - 登录成功,返回当前租户上下文。 @@ -138,7 +138,7 @@ ### TC-CLIENT-007 账户余额不足禁止发送 - 优先级:P0 -- 前置条件:账户余额、套餐余量和授信额度不足。 +- 前置条件:企业现金余额小于预估发送费用。 - 步骤: 1. 使用长内容和多个手机号生成较高预估费用。 2. 提交发送。 @@ -749,7 +749,7 @@ - 步骤:创建发送任务并进入待发送。 - 预期结果: - 生成 frozen 流水。 - - 账户余额或套餐余量减少。 + - 企业现金余额减少。 - 流水关联 taskId。 ### TC-BILLING-003 submit 成功扣费 @@ -2322,16 +2322,16 @@ ### TC-DASHBOARD-002 客户端 Dashboard 余额和可发送额度准确 - 优先级:P0 -- 前置条件:客户 A 账户余额 10000 分,套餐余量 200 条,授信额度 5000 分;存在冻结、扣费、退款、释放流水。 +- 前置条件:客户 A 现金余额 10000 分;存在冻结、扣费、退款、释放流水。 - 步骤: 1. 打开客户端 Dashboard。 - 2. 查看余额、套餐余量、授信额度、可发送额度。 + 2. 查看现金余额和可发送额度。 3. 打开账单流水。 4. 按交易类型核对充值、冻结、扣费、退款、释放后的余额。 - 预期结果: - Dashboard 余额与账户表和流水计算结果一致。 - 冻结金额不应被当作可用余额重复计算。 - - 套餐余量和金额余额分别展示,口径不混淆。 + - 可发送额度等于现金余额,不叠加其他额度。 - 跳转账单流水后可核对组成明细。 ### TC-DASHBOARD-003 客户端 Dashboard 待处理事项准确 @@ -2414,11 +2414,11 @@ 5. 运营端查看账户余额、账单流水和系统日志。 - 预期结果: - 生成 RechargeOrder,状态为 paid 或人工充值完成状态。 - - 账户余额和套餐余量同步增加。 + - 企业现金余额同步增加。 - 生成 account_transaction,类型为 recharge,关联 recharge_order。 - 客户端余额、账单流水即时可见。 - 运营端充值记录、账单流水、系统日志三处可追溯。 - - 所有金额、余额、套餐价格和单价固定展示三位小数,例如 `¥1.000`。 + - 所有金额、余额和单价固定展示三位小数,例如 `¥1.000`。 ### TC-BILLING-007 人工充值金额和短信条数只填其一 @@ -2429,7 +2429,7 @@ 2. 再发起一笔只填写短信条数,不填写充值金额。 3. 查看账户和流水。 - 预期结果: - - 系统按填写项分别增加余额或套餐余量。 + - 系统按填写金额增加或冲正现金余额。 - 未填写项按 0 处理,不产生脏数据。 - 流水金额和短信条数字段方向正确。 - 备注和操作人保留。 @@ -2445,7 +2445,7 @@ 4. 客户端查看账单流水。 - 预期结果: - 原充值记录状态变为 canceled/reversed,或生成一笔反向调整流水。 - - 账户余额和套餐余量正确回退。 + - 企业现金余额正确回退。 - 若余额已消费导致不能全额撤销,应提示不可撤销或只允许人工调整。 - 客户端和运营端均可看到冲正流水和原因。 - 系统日志记录冲正操作者和原因。 @@ -2552,6 +2552,35 @@ - 失败原因可读且与前端提示一致。 - 客户端只可见本客户失败日志,运营端可全平台查询。 +### TC-LOG-010 高频运行事件不得放大系统日志 + +- 优先级:P0 +- 前置条件:客户 CMPP 连接在线,Gateway 正常发送心跳并周期同步下游恢复状态。 +- 步骤: + 1. 建立一条客户 CMPP 连接并记录当前 `OperationLog` 数量。 + 2. 连续发送多次 heartbeat、Submit、Deliver 事件。 + 3. 连续上报仅时间、尝试次数或锁过期时间变化、业务状态未变化的恢复状态。 + 4. 触发连接断开,并将恢复状态从 waiting_connection 改为 running 或 failed。 +- 预期结果: + - heartbeat、Submit、Deliver 仍更新连接表对应时间,但不新增系统日志。 + - 周期恢复状态只更新 `GatewayDownstreamRecoveryStatus`,未发生审计字段变化时不新增系统日志。 + - 连接建立/断开以及恢复状态、实例、锁持有者、失败分类或失败原因真实变化时各新增一条系统日志。 + +### TC-LOG-011 系统日志分页索引与归档安全 + +- 优先级:P0 +- 前置条件:准备超过两页、覆盖 info/success/warning/error 的系统日志,并准备超过在线保留期的日志。 +- 步骤: + 1. 分别按级别、租户、模块、时间和关键词查询第一页、第二页。 + 2. 调用兼容审计接口并传入超大 pageSize。 + 3. 执行一次归档任务,再查询 `OperationLog` 和 `OperationLogArchive`。 + 4. 模拟归档插入失败并重新执行。 +- 预期结果: + - 级别筛选在数据库分页前生效,total、页数和当前页记录准确且排序稳定。 + - 所有接口 pageSize 最大为 100,不存在无界全表返回。 + - 到期日志按 `archiveMonth=YYYY-MM` 进入归档表,在线表只删除已成功归档的记录,原始 id 和详情不丢失。 + - 归档使用有界小批量和 `SKIP LOCKED`;归档失败时源日志仍保留,不阻塞正常日志写入。 + ### TC-CUSTOMER-001 运营端创建客户并初始化租户 - 优先级:P0 @@ -2631,7 +2660,7 @@ ### TC-CUSTOMER-006 客户欠费或额度不足状态联动发送 - 优先级:P0 -- 前置条件:客户余额、套餐余量、授信额度不足,或运营端标记欠费。 +- 前置条件:客户现金余额不足,或运营端标记欠费。 - 步骤: 1. 客户端创建发送任务。 2. API 调用发送。 @@ -2675,10 +2704,10 @@ ### TC-CUSTOMER-009 客户详情展示业务总览准确 - 优先级:P0 -- 前置条件:客户 A 下存在应用 3 个、签名 4 个、模板 5 个、通道绑定 2 个、今日发送 100 条、余额和套餐余量。 +- 前置条件:客户 A 下存在应用 3 个、签名 4 个、模板 5 个、通道绑定 2 个、今日发送 100 条和现金余额。 - 步骤: 1. 运营端打开客户详情。 - 2. 查看客户业务总览:应用数、签名数、模板数、今日发送量、成功率、余额、套餐余量、待审核数。 + 2. 查看客户业务总览:应用数、签名数、模板数、今日发送量、成功率、现金余额、待审核数。 3. 点击每个指标进入对应明细列表。 - 预期结果: - 客户详情总览只统计客户 A。 @@ -3005,7 +3034,7 @@ 3. 初始化 active/disabled 客户、未认证/待审核/已认证/驳回企业认证资料。 4. 初始化可用应用、停用应用、待删除应用。 5. 初始化签名、引流信息、模板、多变量模板、通道、通道组、路由规则。 - 6. 初始化账户余额、套餐余量、欠费/不足余额场景。 + 6. 初始化现金余额、欠费/不足余额场景。 7. 准备 CSV/TXT/GBK/大文件/非法字符/黑名单号码测试文件。 8. 确认 Gateway 模拟器可切换 online、auth_failed、heartbeat_timeout、disconnected、slow_response。 - 产出物: @@ -3164,7 +3193,7 @@ npm run verify:phase8 | 用例 | 数据准备 | 指标断言 | | --- | --- | --- | | TC-DASHBOARD-001 | 客户 A 当天 delivered=10、failed=3、unknown=2、timeout=1;客户 B 有干扰数据。 | 客户端总量=16;成功=10;失败按 failed+timeout 为 4;unknown=2;成功率若按 delivered/total 为 62.5%;点击卡片后的明细筛选一致。 | -| TC-DASHBOARD-002 | 账户余额 10000 分、套餐 200 条、授信 5000 分,另有冻结、扣费、释放、退款流水。 | 可用余额不重复计算冻结;金额余额和套餐余量分开展示;账单流水余额 after 与 Dashboard 一致。 | +| TC-DASHBOARD-002 | 现金余额 10000 分,另有冻结、扣费、释放、退款流水。 | 可用额度仅为现金余额且不重复计算冻结;账单流水 balanceAfter 与 Dashboard 一致。 | | TC-DASHBOARD-003 | 待审核签名 2、模板 3、待报备 1、pending_review 发送任务 4。 | 待处理总数和分类数准确;点击跳转后列表筛选数量一致;只包含当前租户。 | | TC-DASHBOARD-004 | 多客户、多通道、多状态发送和账务流水。 | 运营端统计全平台;活跃客户、今日发送、成功率、待审核、收入均可在明细页复核。 | | TC-DASHBOARD-005 | 客户 A/B 均有发送、账务、审核数据。 | 切换客户后所有卡片、趋势、状态分布、账务汇总同步刷新;跳转明细继承客户筛选。 | @@ -3175,11 +3204,13 @@ npm run verify:phase8 | 用例 | 细化执行点 | 必查断言 | | --- | --- | --- | -| TC-BILLING-006 | 运营端人工充值金额和短信条数,客户端查看 Dashboard 和账单流水。 | RechargeOrder 状态为 paid/manual_topup;TenantAccount 同步增加;AccountTransaction 类型 recharge;充值记录“充值后余额”必须等于该订单关联 AccountTransaction.balanceAfter,不能用当前账户余额替代;运营日志和客户端流水均可追溯。 | -| TC-BILLING-007 | 分别只填金额、只填短信条数;金额填写负数执行冲正。 | 未填项按 0;正负金额和条数字段方向正确;允许有业务含义的负数调整,不产生 null、NaN 或零变更脏数据。 | +| TC-BILLING-006 | 运营端人工充值现金金额,客户端查看 Dashboard 和充值记录。 | RechargeOrder 状态为 paid/manual_topup;TenantAccount 现金余额同步增加;AccountTransaction 类型 recharge;充值记录“充值后余额”必须等于该订单关联 AccountTransaction.balanceAfter,不能用当前账户余额替代;运营日志可追溯。 | +| TC-BILLING-007 | 分别填写正数金额和负数金额执行充值、冲正;提交 0 或非法金额。 | 正负金额方向正确;0 和非法金额被拒绝;数据模型和接口不存在短信条数、套餐或授信字段。 | | TC-BILLING-008 | 对已充值记录执行撤销/冲正,分别覆盖未消费和已部分消费。 | 未消费可全额回退;已消费按规则拒绝或生成人工调整;原订单状态和反向流水清晰;日志记录原因。 | | TC-BILLING-009 | 无权限用户、审核员、管理员分别执行充值;大额人工充值不走审批。 | 权限不足被拒绝并写失败日志;有权限用户确认后立即入账;不产生 pending 审批态;充值订单、账户余额、流水和日志同步完成。 | | TC-BILLING-010 | 余额不足发送失败,人工充值后重试发送并模拟 delivered。 | 充值前不扣费;充值后发送成功;冻结、扣费、短信计费记录完整;reconciliation diff 为 0。 | +| TC-BILLING-011 | 账户现金余额 100 分,预估费用 5 分,且数据库不存在套餐和授信数据;再将余额改为 4 分重试。 | 100 分时可发送,4 分时提示“企业账户余额不足”;判断只依赖 TenantAccount.balanceCents。 | +| TC-BILLING-012 | 已扣费短信收到最终失败回执;另一个未提交成功任务只释放冻结。 | 前者只生成一条 refunded 流水并计入“今日返还”;重复回执不重复退款;后者的 released 流水不计入“今日返还”。 | ### 17.6 系统日志细化 @@ -3188,8 +3219,10 @@ npm run verify:phase8 | TC-LOG-005 | 客户 A 查看日志并尝试查询客户 B 日志。 | 客户端只返回本租户日志;越权查询失败;日志包含 IP、User-Agent、result、resourceId。 | | TC-LOG-006 | 客户端导入号码、立即发送、创建并取消定时任务。 | 导入日志含文件名、行数、成功/失败数;发送日志含任务编号、号码数、发送类型;取消日志含取消人。 | | TC-LOG-007 | 运营端按客户、动作、资源、结果、时间查询并导出。 | 查询准确;导出内容与筛选一致;导出动作本身写日志。 | -| TC-LOG-008 | 人工充值、冲正、账户调整。 | 日志含客户、金额、短信条数、订单号、流水号、操作者;敏感字段脱敏。 | +| TC-LOG-008 | 人工充值、冲正、账户调整。 | 日志含客户、金额、订单号、流水号、操作者;敏感字段脱敏。 | | TC-LOG-009 | 触发无权限充值、余额不足发送、无在线通道发送。 | 失败动作也写日志;result/status 标记失败;失败原因与前端提示一致。 | +| TC-LOG-010 | 保持 CMPP 在线并连续发送 heartbeat/Submit/Deliver、重复恢复状态,再触发断开和状态变化。 | 高频事件只更新状态表;连接和恢复状态关键变化才新增日志;日志量不随心跳线性增长。 | +| TC-LOG-011 | 准备多级别、多页和过期日志,验证查询上限与归档重试。 | 数据库侧筛选后分页;每页最多 100;归档成功才删除源记录;archiveMonth、原始 id 和详情完整。 | ### 17.7 客户管理细化 @@ -3200,7 +3233,7 @@ npm run verify:phase8 | TC-CUSTOMER-003 | 停用客户后分别通过客户端、API、CMPP 接入尝试发送。 | 全部阻断;不入队、不扣费;失败原因是客户停用;历史任务可查。 | | TC-CUSTOMER-004 | 客户 active 时创建 scheduled,到点前停用。 | 到点重校验失败;任务 rejected/canceled/failed;冻结费用释放;日志指向客户停用。 | | TC-CUSTOMER-005 | 重新启用客户后发送。 | 客户状态 active;新发送成功进入链路;账务和日志完整。 | -| TC-CUSTOMER-006 | 余额不足、套餐不足、授信不足、欠费标记。 | 发送前账户校验失败;不投递 Gateway;客户详情展示欠费或不足状态。 | +| TC-CUSTOMER-006 | 现金余额不足或欠费标记。 | 发送前账户校验失败;不投递 Gateway;客户详情展示欠费或不足状态。 | | TC-CUSTOMER-007 | 客户 A 使用 URL/API 参数访问客户 B 资源。 | 不泄露 B 数据;返回无权限或空结果;失败访问写安全日志。 | | TC-CUSTOMER-008 | 删除/归档有历史数据的客户。 | 不允许硬删除或执行归档;新发送和未执行 scheduled 阻断;历史 trace/对账可查。 | | TC-CUSTOMER-009 | 客户详情总览应用、签名、模板、今日发送、余额。 | 各指标与明细列表聚合一致;跳转带客户筛选;异常状态有标识。 | @@ -3268,7 +3301,7 @@ npm run verify:phase8 | 用例编号 | 操作 | 预期结果 | | --- | --- | --- | -| TC-MOCK-CLEAN-001 | 断开 API 或让 API 返回 500,访问客户端充值套餐、账单流水、批量任务、短信发送、签名、模板页面。 | 页面展示错误态或空态;不得出现前端静态套餐、任务、模板、签名或最近发送记录。 | +| TC-MOCK-CLEAN-001 | 断开 API 或让 API 返回 500,访问客户端账户余额、充值记录、批量任务、短信发送、签名、模板页面。 | 页面展示错误态或空态;不得出现前端静态余额、任务、模板、签名或最近发送记录。 | | TC-MOCK-CLEAN-002 | 访问运营端数据统计、账务账户、发送监控、安全控制、手机号段库、报备字段库、通道组、报备任务、报备记录。 | 所有列表和卡片来自真实 API;新增动作写入数据库;后端缺失的编辑/删除能力不得用本地状态伪造。 | | TC-UI-ENTERPRISE-SELECT-001 | 逐一打开包含企业或企业应用选择的表单和筛选项,输入部分企业/应用名称。 | 下拉面板提供搜索框并实时缩小真实 API 选项范围;清空后恢复全部选项。 | | TC-UI-TEMPLATE-RESPONSIVE-001 | 在 1024px、1366px 和宽屏视口打开企业短信模板页。 | 模板卡片自适应换列,页面不出现水平滚动,每张卡片的编辑和删除按钮直接可见。 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 21fa98a..7a89905 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -1,5 +1,24 @@ # 第一版系统化测试进度 +## 2026-07-14 计费收敛为现金余额、失败退款展示 + +- 生产只读核查确认 17:01 的人工充值已将目标企业现金余额从 0 增加到 100 分;17:04 发送预估费用仅 5 分却被拒绝,根因是旧逻辑同时要求 `TenantAccount.smsUnits >= billingUnits`,充值只增加现金时短信余量仍为 0。 +- 发送前账户检查已收敛为只判断 `TenantAccount.balanceCents >= amountCents`,错误文案统一为“企业账户余额不足”;冻结、提交成功扣费、最终失败退款均只变更现金余额。短信 `billingUnits` 继续作为 70/67 拆分和费用计算字段,不再充当套餐额度。 +- 删除 `BillingPlan`、账户短信余量、授信额度及充值订单套餐字段,并增加 Prisma 迁移;移除管理端套餐接口和客户端套餐购买入口,客户端改为展示真实现金余额与人工充值记录,运营端人工充值只录入金额。 +- 企业管理列表隐藏企业 ID 和透支限额,增加“今日返还”;后端只聚合当日 `AccountTransaction.transactionType=refunded` 的实际退款,普通冻结释放 `released` 不计入返还。 +- 失败退款链路复核:提交拒绝或超时且未实际扣费时只释放冻结;已提交扣费短信收到最终失败回执后才退款;已有 `SmsBillingRecord.billingStatus=refunded` 的消息不会重复退款。新增 `TC-BILLING-011/012` 覆盖余额唯一判断和退款口径。 +- 同步更新第一版需求和系统功能测试用例;Prisma validate/generate、本地 PostgreSQL migration deploy、完整 API 17 suites/162 项、API build、前端 build 和 Go Gateway 全量测试均通过。前端仅保留既有 Vite chunk size warning;待部署阶段完成生产备份、迁移和线上验证。 + +## 2026-07-14 系统日志增长治理、分页索引与归档 + +- 生产只读评估确认 `OperationLog` 已有约 3 万条、总占用约 17 MB,当前查询尚未形成性能事故;其中 `cmpp_downstream_connection.heartbeat` 约 2.4 万条、`gateway.downstream_recovery_status_sync` 约 5700 条,两类周期事件约占全部日志 99.5%。 +- `SmsConfigService.recordDownstreamConnectionEvent` 调整为 heartbeat/Submit/Deliver 只更新 `CmppDownstreamConnection` 当前状态和时间,只有 connected/disconnected 写系统日志。 +- `SendChainService.recordGatewayDownstreamRecoveryStatus` 在 upsert 前读取审计状态,只在 state、gatewayInstanceId、lockOwner、failureCategory、lastError 或 lastSkipReason 真实变化时写 `gateway.downstream_recovery_status_changed`;仅尝试次数、重试时间和锁过期时间变化不再重复写日志。 +- Prisma 为 `OperationLog` 新增 `createdAt`、`resource+createdAt` 索引;系统日志 level 条件移入 PostgreSQL 查询后再 count/分页,排序增加 id 稳定次序。`/admin/operations/audit-logs` 和 `/admin/operation-logs` 均改为分页响应并限制 pageSize 最大 100。 +- 新增 `OperationLogArchive` 和定时归档服务:在线日志默认保留 180 天,每日最多 20 批、每批 1000 条,使用单条 PostgreSQL CTE、`FOR UPDATE SKIP LOCKED` 和“归档存在后才删除源记录”保证并发与失败安全;归档记录按 `archiveMonth=YYYY-MM` 标记且不自动删除。 +- 同步需求与用例:`TC-LOG-010` 覆盖高频运行事件不写永久审计,`TC-LOG-011` 覆盖数据库侧分页、接口上限、归档完整性和失败不丢数据。 +- 验证通过:Prisma schema validate、Prisma Client 生成、本地 PostgreSQL migration deploy;本地真实 PostgreSQL 归档 smoke 验证过期日志进入 `archiveMonth=2000-01` 且仅在归档成功后删除在线源记录;数据库级 error 筛选真实查询通过。相关 5 suites/87 项和完整 API 17 suites/162 项测试全部通过;API build、前端 build 通过。前端仅保留既有 Vite chunk size warning。 + ## 2026-07-09 运营端通道测试短信闭环修复 - 生产验证发现运营端通道“短信测试”弹窗仅关闭页面,未调用后端;`POST /api/admin/channels/:id/test` 仍返回 phase-4 placeholder,不创建 `SmsMessageRecord/SmsSubmitRecord`,也不写入 Gateway SubmitCommand,因此短信记录页面无记录。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index fe8c6b7..7c419b8 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -200,6 +200,7 @@ export type TenantOption = { export type TenantManagementRow = TenantOption & { account?: TenantAccount | null; todaySpendCents: number; + todayRefundCents: number; }; export type CaptchaResponse = { @@ -242,7 +243,7 @@ export type DashboardResponse = { 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 } }; + transactions: { _count: { _all: number }; _sum: { amountCents?: number | null } }; gatewayConnections: Array<{ status: string; _count: { _all: number }; _sum: { currentConnections?: number | null; desiredConnections?: number | null } }>; pendingAuditCount: number; pendingAudits: { enterpriseCertifications: number; smsAudits: number; templates: number; signatures: number; drainageInfos: number; total: number }; @@ -254,7 +255,7 @@ export type DashboardResponse = { recentFailed: number; alertCount: number; }; - accounts: Array<{ id: string; tenantId: string; balanceCents: number; smsUnits: number; creditCents: number; status: string; tenant?: TenantOption }>; + accounts: Array<{ id: string; tenantId: string; balanceCents: number; status: string; tenant?: TenantOption }>; recentTasks: Array>; recentRecharges: Array; }; @@ -264,7 +265,6 @@ export type RechargeOrder = { tenantId: string; orderNo: string; amountCents: number; - smsUnits: number; status: string; payMethod?: string | null; paidAt?: string | null; @@ -275,16 +275,6 @@ export type RechargeOrder = { tenant?: TenantOption; }; -export type BillingPlan = { - id: string; - name: string; - amountCents: number; - smsUnits: number; - unitPriceCents?: number | null; - status: string; - description?: string | null; -}; - export type ClientSmsApplication = { id: string; tenantId: string; @@ -671,8 +661,6 @@ export type TenantAccount = { id: string; tenantId: string; balanceCents: number; - smsUnits: number; - creditCents: number; status: string; tenant?: TenantOption; }; @@ -966,7 +954,7 @@ export const adminApi = { request(withQuery('/admin/system-logs', query)), listAccounts: () => request('/admin/billing/accounts'), listManualRecharges: (tenantId?: string) => request(withQuery('/admin/billing/manual-recharges', { tenantId })), - createManualRecharge: (body: { tenantId: string; amountCents: number; smsUnits?: number; operatorId?: string; remark?: string }) => + createManualRecharge: (body: { tenantId: string; amountCents: number; operatorId?: string; remark?: string }) => request('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }), listEnterpriseApplications: (query: { tenantId?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; status?: string } = {}) => request(withQuery('/admin/enterprise-applications', query)), @@ -1215,10 +1203,6 @@ export const clientApi = { request(withQuery('/client/operations/system-logs', query), { tenantId }), listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/billing/orders', { tenantId }), - listPlans: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request('/client/billing/plans', { tenantId }), - createOrder: (body: { planId?: string; amountCents?: number; smsUnits?: number; payMethod?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request('/client/billing/orders', { method: 'POST', tenantId, body: JSON.stringify(body) }), listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/applications', { tenantId }), getApplicationCmppParams: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => diff --git a/src/apps/admin/AdminCustomerDetailPage.tsx b/src/apps/admin/AdminCustomerDetailPage.tsx index 7c58239..37f689c 100644 --- a/src/apps/admin/AdminCustomerDetailPage.tsx +++ b/src/apps/admin/AdminCustomerDetailPage.tsx @@ -67,7 +67,7 @@ export function AdminCustomerDetailPage() {
企业编码{tenant?.code ?? '-'}{tenant?.status ?? '-'}
-
短信余量{(account?.smsUnits ?? 0).toLocaleString('zh-CN')}真实账户余量
+
计费方式按量计费仅从现金余额扣费
现金余额¥{formatCents(account?.balanceCents)}真实账户余额
diff --git a/src/apps/admin/AdminCustomersPage.tsx b/src/apps/admin/AdminCustomersPage.tsx index 09bf94a..314b610 100644 --- a/src/apps/admin/AdminCustomersPage.tsx +++ b/src/apps/admin/AdminCustomersPage.tsx @@ -36,10 +36,9 @@ function emptyRechargeForm(): RechargeForm { export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCustomersPageProps) { const navigate = useNavigate(); const [records, setRecords] = useState([]); - const [queryId, setQueryId] = useState(''); const [queryName, setQueryName] = useState(''); const [queryStatus, setQueryStatus] = useState('all'); - const [filters, setFilters] = useState({ id: '', name: '', status: 'all' }); + const [filters, setFilters] = useState({ name: '', status: 'all' }); const [confirmAction, setConfirmAction] = useState<{ type: 'toggle' | 'delete'; record: CustomerRow } | null>(null); const [rechargeTarget, setRechargeTarget] = useState(null); const [rechargeForm, setRechargeForm] = useState(emptyRechargeForm); @@ -61,10 +60,9 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto }, []); const filteredRecords = useMemo(() => records.filter((record) => { - const matchId = filters.id ? record.id.includes(filters.id) : true; const matchName = filters.name ? record.name.includes(filters.name) : true; const matchStatus = filters.status === 'all' ? true : record.status === filters.status; - return matchId && matchName && matchStatus; + return matchName && matchStatus; }), [filters, records]); const activeCount = records.filter((record) => record.status === 'active').length; @@ -72,7 +70,6 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto const totalBalance = records.reduce((sum, record) => sum + (record.account?.balanceCents ?? 0), 0); const columns: Array> = [ - { key: 'id', title: '企业ID', width: '160px', render: (record) => {record.id} }, { key: 'name', title: '企业名称', width: '260px', render: (record) => {record.name} }, { key: 'balance', @@ -89,8 +86,8 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto ); }, }, - { key: 'overdraftLimit', title: '透支限额', width: '150px', align: 'right', render: (record) => `¥${formatCents(record.account?.creditCents ?? 0)}` }, { key: 'todaySpend', title: '今日消费', width: '150px', align: 'right', render: (record) => `¥${formatCents(record.todaySpendCents)}` }, + { key: 'todayRefund', title: '今日返还', width: '150px', align: 'right', render: (record) => `¥${formatCents(record.todayRefundCents)}` }, { key: 'status', title: '企业状态', width: '130px', render: (record) => {record.status === 'active' ? '正常' : '已禁用'} }, { key: 'actions', @@ -133,7 +130,6 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto await adminApi.createManualRecharge({ tenantId: rechargeTarget.id, amountCents: Math.round(amount * 100), - smsUnits: 0, remark: [rechargeForm.operator, rechargeForm.remark].filter(Boolean).join(' / '), }); setRechargeTarget(null); @@ -175,12 +171,11 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto

查询条件

- setQueryId(event.target.value)} placeholder="请输入企业ID" value={queryId} /> setQueryName(event.target.value)} placeholder="请输入企业名称" value={queryName} /> updateForm('amount', event.target.value)} prefix="¥" required type="number" value={form.amount} /> - updateForm('smsUnits', event.target.value)} type="number" value={form.smsUnits} /> updateForm('operator', event.target.value)} required value={form.operator} />