fix: simplify balance billing and govern operation logs

This commit is contained in:
hectorzhao
2026-07-14 17:40:32 +08:00
parent f35691f185
commit 28dad93e3e
40 changed files with 740 additions and 395 deletions
+5
View File
@@ -12,6 +12,11 @@ CLIENT_SESSION_IDLE_TIMEOUT_MS=7200000
SESSION_LOCK_RECOVERY_MS=14400000 SESSION_LOCK_RECOVERY_MS=14400000
SESSION_ABSOLUTE_TIMEOUT_MS=43200000 SESSION_ABSOLUTE_TIMEOUT_MS=43200000
SESSION_RECENT_AUTH_MS=1800000 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. # Local HTTP development only. Production must use HTTPS and true.
SESSION_COOKIE_SECURE=false SESSION_COOKIE_SECURE=false
MINIO_ENDPOINT=localhost:9000 MINIO_ENDPOINT=localhost:9000
@@ -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");
@@ -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";
+23 -20
View File
@@ -154,6 +154,29 @@ model OperationLog {
@@index([tenantId, createdAt]) @@index([tenantId, createdAt])
@@index([userId, 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 { model FileObject {
@@ -240,26 +263,10 @@ model DrainageField {
channelReportFields ChannelReportField[] 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 { model TenantAccount {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
balanceCents Int @default(0) balanceCents Int @default(0)
smsUnits Int @default(0)
creditCents Int @default(0)
status String @default("active") status String @default("active")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -274,7 +281,6 @@ model AccountTransaction {
tenantId String tenantId String
transactionType String transactionType String
amountCents Int @default(0) amountCents Int @default(0)
smsUnits Int @default(0)
balanceAfter Int @default(0) balanceAfter Int @default(0)
relatedType String? relatedType String?
relatedId String? relatedId String?
@@ -301,10 +307,8 @@ model BillingRule {
model RechargeOrder { model RechargeOrder {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
planId String?
orderNo String @unique orderNo String @unique
amountCents Int amountCents Int
smsUnits Int @default(0)
status String @default("created") status String @default("created")
payMethod String? payMethod String?
paidAt DateTime? paidAt DateTime?
@@ -314,7 +318,6 @@ model RechargeOrder {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id]) tenant Tenant @relation(fields: [tenantId], references: [id])
plan BillingPlan? @relation(fields: [planId], references: [id])
@@index([tenantId, createdAt]) @@index([tenantId, createdAt])
} }
+7 -3
View File
@@ -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 { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator'; import { TenantId } from '../common/tenant-id.decorator';
import { AuditService, CreateOperationLogDto } from './audit.service'; import { AuditService, CreateOperationLogDto } from './audit.service';
@@ -9,8 +9,12 @@ export class AuditController {
constructor(private readonly audit: AuditService) {} constructor(private readonly audit: AuditService) {}
@Get() @Get()
list(@TenantId() tenantId?: string) { list(
return this.audit.list(tenantId); @TenantId() tenantId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.audit.list(tenantId, Number(page), Number(pageSize));
} }
@Post() @Post()
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuditController } from './audit.controller'; import { AuditController } from './audit.controller';
import { AuditService } from './audit.service'; import { AuditService } from './audit.service';
import { OperationLogRetentionService } from './operation-log-retention.service';
@Module({ @Module({
controllers: [AuditController], controllers: [AuditController],
providers: [AuditService], providers: [AuditService, OperationLogRetentionService],
exports: [AuditService], exports: [AuditService],
}) })
export class AuditModule {} export class AuditModule {}
+26
View File
@@ -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,
});
});
});
+19 -5
View File
@@ -17,11 +17,20 @@ export interface CreateOperationLogDto {
export class AuditService { export class AuditService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
list(tenantId?: string) { async list(tenantId?: string, pageInput?: number, pageSizeInput?: number) {
return this.prisma.operationLog.findMany({ const page = positiveInteger(pageInput, 1);
where: tenantId ? { tenantId } : undefined, const pageSize = Math.min(100, positiveInteger(pageSizeInput, 20));
orderBy: { createdAt: 'desc' }, 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) { create(data: CreateOperationLogDto) {
@@ -38,3 +47,8 @@ export class AuditService {
return this.prisma.operationLog.create({ data: createData }); 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;
}
@@ -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);
});
});
@@ -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<typeof setTimeout>;
private intervalTimer?: ReturnType<typeof setInterval>;
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;
}
-27
View File
@@ -6,8 +6,6 @@ import {
BillingService, BillingService,
BillingActionDto, BillingActionDto,
CreateManualRechargeDto, CreateManualRechargeDto,
CreateRechargeOrderDto,
CreateBillingPlanDto,
CreateBillingRuleDto, CreateBillingRuleDto,
CreateSmsBillingRecordDto, CreateSmsBillingRecordDto,
CreateTenantAccountDto, CreateTenantAccountDto,
@@ -19,16 +17,6 @@ import {
export class BillingController { export class BillingController {
constructor(private readonly billing: BillingService) {} 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') @Get('accounts')
listAccounts() { listAccounts() {
return this.billing.listAccounts(); return this.billing.listAccounts();
@@ -44,11 +32,6 @@ export class BillingController {
return this.billing.listRechargeOrders(tenantId); return this.billing.listRechargeOrders(tenantId);
} }
@Post('recharges')
createRechargeOrder(@Body() body: CreateRechargeOrderDto) {
return this.billing.createRechargeOrder(body);
}
@Get('manual-recharges') @Get('manual-recharges')
listManualRechargeRecords(@TenantId() tenantId?: string) { listManualRechargeRecords(@TenantId() tenantId?: string) {
return this.billing.listManualRechargeRecords(tenantId); return this.billing.listManualRechargeRecords(tenantId);
@@ -123,16 +106,6 @@ export class BillingController {
export class ClientBillingController { export class ClientBillingController {
constructor(private readonly billing: BillingService) {} 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') @Get('orders')
listRechargeOrders(@TenantId() tenantId?: string) { listRechargeOrders(@TenantId() tenantId?: string) {
return this.billing.listRechargeOrders(tenantId); return this.billing.listRechargeOrders(tenantId);
+16 -29
View File
@@ -1,21 +1,15 @@
import { BillingService } from './billing.service'; import { BillingService } from './billing.service';
function createPrismaMock() { function createPrismaMock() {
const accountState = { tenantId: 'tenant-1', balanceCents: 1000, smsUnits: 20, creditCents: 200 }; const accountState = { tenantId: 'tenant-1', balanceCents: 1000 };
return { return {
accountState, accountState,
billingPlan: {
findMany: jest.fn(),
create: jest.fn(),
findUnique: jest.fn(),
},
tenantAccount: { tenantAccount: {
findMany: jest.fn(), findMany: jest.fn(),
create: jest.fn(), create: jest.fn(),
upsert: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })), upsert: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
update: jest.fn().mockImplementation(({ data }) => { update: jest.fn().mockImplementation(({ data }) => {
accountState.balanceCents = data.balanceCents; accountState.balanceCents = data.balanceCents;
accountState.smsUnits = data.smsUnits;
return Promise.resolve({ ...accountState }); 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 prisma = createPrismaMock();
const service = new BillingService(prisma as never); const service = new BillingService(prisma as never);
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1100, smsUnits: 20 })).resolves.toEqual( await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 5 })).resolves.toEqual(
expect.objectContaining({ availableAmount: 1200, availableSmsUnits: 20, canSend: true }), expect.objectContaining({ availableAmount: 1000, canSend: true }),
); );
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1300, smsUnits: 20 })).resolves.toEqual( await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1001 })).resolves.toEqual(
expect.objectContaining({ canSend: false }),
);
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 100, smsUnits: 21 })).resolves.toEqual(
expect.objectContaining({ canSend: false }), 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(); const prisma = createPrismaMock();
prisma.billingPlan.findUnique.mockResolvedValue({ id: 'plan-1', priceCents: 500, smsUnits: 100 });
const service = new BillingService(prisma as never); 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({ expect(prisma.tenantAccount.update).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1' }, where: { tenantId: 'tenant-1' },
data: { balanceCents: 1500, smsUnits: 120 }, data: { balanceCents: 1500 },
}); });
expect(prisma.accountTransaction.create).toHaveBeenCalledWith({ expect(prisma.accountTransaction.create).toHaveBeenCalledWith({
data: expect.objectContaining({ data: expect.objectContaining({
transactionType: 'recharge', transactionType: 'recharge',
amountCents: 500, amountCents: 500,
smsUnits: 100,
balanceAfter: 1500, balanceAfter: 1500,
relatedType: 'recharge_order', relatedType: 'recharge_order',
relatedId: 'order-1', relatedId: 'order-1',
@@ -114,7 +103,6 @@ describe('BillingService', () => {
const order = await service.createManualRecharge({ const order = await service.createManualRecharge({
tenantId: 'tenant-1', tenantId: 'tenant-1',
amountCents: 2000, amountCents: 2000,
smsUnits: 0,
operatorId: 'admin-1', operatorId: 'admin-1',
remark: '线下转账人工充值', remark: '线下转账人工充值',
}); });
@@ -175,7 +163,6 @@ describe('BillingService', () => {
const order = await service.createManualRecharge({ const order = await service.createManualRecharge({
tenantId: 'tenant-1', tenantId: 'tenant-1',
amountCents: -300, amountCents: -300,
smsUnits: 0,
operatorId: 'admin-1', operatorId: 'admin-1',
remark: '人工冲正', remark: '人工冲正',
}); });
@@ -183,7 +170,7 @@ describe('BillingService', () => {
expect(order).toEqual(expect.objectContaining({ amountCents: -300, payMethod: 'manual_topup', status: 'paid' })); expect(order).toEqual(expect.objectContaining({ amountCents: -300, payMethod: 'manual_topup', status: 'paid' }));
expect(prisma.tenantAccount.update).toHaveBeenCalledWith({ expect(prisma.tenantAccount.update).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1' }, where: { tenantId: 'tenant-1' },
data: { balanceCents: 700, smsUnits: 20 }, data: { balanceCents: 700 },
}); });
expect(prisma.accountTransaction.create).toHaveBeenCalledWith({ expect(prisma.accountTransaction.create).toHaveBeenCalledWith({
data: expect.objectContaining({ data: expect.objectContaining({
@@ -205,11 +192,11 @@ describe('BillingService', () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new BillingService(prisma as never); 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.freeze({ tenantId: 'tenant-1', amountCents: 100, 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.charge({ tenantId: 'tenant-1', amountCents: 50, relatedType: 'sms_message_record', relatedId: 'msg-1' });
await service.release({ tenantId: 'tenant-1', amountCents: 25, smsUnits: 1 }); await service.release({ tenantId: 'tenant-1', amountCents: 25 });
await service.refund({ tenantId: 'tenant-1', amountCents: 10, smsUnits: 1 }); await service.refund({ tenantId: 'tenant-1', amountCents: 10 });
await service.adjust({ tenantId: 'tenant-1', amountCents: 5, smsUnits: 0 }); await service.adjust({ tenantId: 'tenant-1', amountCents: 5 });
expect(prisma.accountTransaction.create.mock.calls.map(([arg]) => arg.data.transactionType)).toEqual([ expect(prisma.accountTransaction.create.mock.calls.map(([arg]) => arg.data.transactionType)).toEqual([
'frozen', 'frozen',
@@ -218,7 +205,7 @@ describe('BillingService', () => {
'refunded', 'refunded',
'adjusted', '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 () => { it('creates SMS billing records linked to message and task identifiers', async () => {
+5 -60
View File
@@ -2,20 +2,9 @@ import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
export interface CreateBillingPlanDto {
name: string;
priceCents: number;
smsUnits: number;
validDays: number;
status?: string;
description?: string;
}
export interface CreateTenantAccountDto { export interface CreateTenantAccountDto {
tenantId: string; tenantId: string;
balanceCents?: number; balanceCents?: number;
smsUnits?: number;
creditCents?: number;
status?: string; status?: string;
} }
@@ -23,7 +12,6 @@ export interface CreateAccountTransactionDto {
tenantId: string; tenantId: string;
transactionType: string; transactionType: string;
amountCents?: number; amountCents?: number;
smsUnits?: number;
balanceAfter?: number; balanceAfter?: number;
relatedType?: string; relatedType?: string;
relatedId?: string; relatedId?: string;
@@ -40,9 +28,7 @@ export interface CreateBillingRuleDto {
export interface CreateRechargeOrderDto { export interface CreateRechargeOrderDto {
tenantId: string; tenantId: string;
planId?: string; amountCents: number;
amountCents?: number;
smsUnits?: number;
payMethod?: string; payMethod?: string;
operatorId?: string; operatorId?: string;
remark?: string; remark?: string;
@@ -51,7 +37,6 @@ export interface CreateRechargeOrderDto {
export interface CreateManualRechargeDto { export interface CreateManualRechargeDto {
tenantId: string; tenantId: string;
amountCents: number; amountCents: number;
smsUnits?: number;
operatorId?: string; operatorId?: string;
remark?: string; remark?: string;
} }
@@ -68,7 +53,6 @@ export interface EstimateSmsCostDto {
export interface BillingActionDto { export interface BillingActionDto {
tenantId: string; tenantId: string;
amountCents?: number; amountCents?: number;
smsUnits?: number;
relatedType?: string; relatedType?: string;
relatedId?: string; relatedId?: string;
remark?: string; remark?: string;
@@ -88,23 +72,6 @@ export interface CreateSmsBillingRecordDto {
export class BillingService { export class BillingService {
constructor(private readonly prisma: PrismaService) {} 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() { listAccounts() {
return this.prisma.tenantAccount.findMany({ return this.prisma.tenantAccount.findMany({
include: { tenant: true }, include: { tenant: true },
@@ -116,8 +83,6 @@ export class BillingService {
const createData: Prisma.TenantAccountUncheckedCreateInput = { const createData: Prisma.TenantAccountUncheckedCreateInput = {
tenantId: data.tenantId, tenantId: data.tenantId,
balanceCents: data.balanceCents ?? 0, balanceCents: data.balanceCents ?? 0,
smsUnits: data.smsUnits ?? 0,
creditCents: data.creditCents ?? 0,
status: data.status ?? 'active', status: data.status ?? 'active',
}; };
return this.prisma.tenantAccount.create({ data: createData }); return this.prisma.tenantAccount.create({ data: createData });
@@ -126,7 +91,6 @@ export class BillingService {
listRechargeOrders(tenantId?: string) { listRechargeOrders(tenantId?: string) {
return this.prisma.rechargeOrder.findMany({ return this.prisma.rechargeOrder.findMany({
where: tenantId ? { tenantId } : undefined, where: tenantId ? { tenantId } : undefined,
include: { plan: true },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
} }
@@ -137,7 +101,6 @@ export class BillingService {
tenantId, tenantId,
payMethod: 'manual_topup', payMethod: 'manual_topup',
}, },
include: { plan: true },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
const orderIds = orders.map((order) => order.id); const orderIds = orders.map((order) => order.id);
@@ -161,16 +124,12 @@ export class BillingService {
} }
async createRechargeOrder(data: CreateRechargeOrderDto) { async createRechargeOrder(data: CreateRechargeOrderDto) {
const plan = data.planId ? await this.prisma.billingPlan.findUnique({ where: { id: data.planId } }) : null; const amountCents = data.amountCents;
const amountCents = data.amountCents ?? plan?.priceCents ?? 0;
const smsUnits = data.smsUnits ?? plan?.smsUnits ?? 0;
const order = await this.prisma.rechargeOrder.create({ const order = await this.prisma.rechargeOrder.create({
data: { data: {
tenantId: data.tenantId, tenantId: data.tenantId,
planId: data.planId,
orderNo: `R${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`, orderNo: `R${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`,
amountCents, amountCents,
smsUnits,
status: 'paid', status: 'paid',
payMethod: data.payMethod ?? 'manual', payMethod: data.payMethod ?? 'manual',
paidAt: new Date(), paidAt: new Date(),
@@ -183,7 +142,6 @@ export class BillingService {
tenantId: data.tenantId, tenantId: data.tenantId,
transactionType: 'recharge', transactionType: 'recharge',
amountCents, amountCents,
smsUnits,
relatedType: 'recharge_order', relatedType: 'recharge_order',
relatedId: order.id, relatedId: order.id,
remark: data.remark, remark: data.remark,
@@ -196,7 +154,6 @@ export class BillingService {
const order = await this.createRechargeOrder({ const order = await this.createRechargeOrder({
tenantId: data.tenantId, tenantId: data.tenantId,
amountCents: data.amountCents, amountCents: data.amountCents,
smsUnits: data.smsUnits ?? 0,
payMethod: 'manual_topup', payMethod: 'manual_topup',
operatorId: data.operatorId, operatorId: data.operatorId,
remark: data.remark, remark: data.remark,
@@ -210,7 +167,6 @@ export class BillingService {
resourceId: order.id, resourceId: order.id,
detail: { detail: {
amountCents: data.amountCents, amountCents: data.amountCents,
smsUnits: data.smsUnits ?? 0,
orderNo: order.orderNo, orderNo: order.orderNo,
remark: data.remark, remark: data.remark,
} as Prisma.InputJsonValue, } as Prisma.InputJsonValue,
@@ -239,15 +195,12 @@ export class BillingService {
async checkAccount(data: BillingActionDto) { async checkAccount(data: BillingActionDto) {
const account = await this.getAccountOrCreate(data.tenantId); const account = await this.getAccountOrCreate(data.tenantId);
const requiredAmount = data.amountCents ?? 0; const requiredAmount = data.amountCents ?? 0;
const requiredUnits = data.smsUnits ?? 0; const availableAmount = account.balanceCents;
const availableAmount = account.balanceCents + account.creditCents;
return { return {
tenantId: data.tenantId, tenantId: data.tenantId,
requiredAmount, requiredAmount,
requiredUnits,
availableAmount, availableAmount,
availableSmsUnits: account.smsUnits, canSend: availableAmount >= requiredAmount,
canSend: availableAmount >= requiredAmount && account.smsUnits >= requiredUnits,
}; };
} }
@@ -256,7 +209,6 @@ export class BillingService {
...data, ...data,
transactionType: 'frozen', transactionType: 'frozen',
amountCents: -(data.amountCents ?? 0), amountCents: -(data.amountCents ?? 0),
smsUnits: -(data.smsUnits ?? 0),
}); });
} }
@@ -265,7 +217,6 @@ export class BillingService {
...data, ...data,
transactionType: 'charged', transactionType: 'charged',
amountCents: -(data.amountCents ?? 0), amountCents: -(data.amountCents ?? 0),
smsUnits: -(data.smsUnits ?? 0),
}); });
} }
@@ -274,7 +225,6 @@ export class BillingService {
...data, ...data,
transactionType: 'released', transactionType: 'released',
amountCents: data.amountCents ?? 0, amountCents: data.amountCents ?? 0,
smsUnits: data.smsUnits ?? 0,
}); });
} }
@@ -283,7 +233,6 @@ export class BillingService {
...data, ...data,
transactionType: 'refunded', transactionType: 'refunded',
amountCents: data.amountCents ?? 0, amountCents: data.amountCents ?? 0,
smsUnits: data.smsUnits ?? 0,
}); });
} }
@@ -292,7 +241,6 @@ export class BillingService {
...data, ...data,
transactionType: 'adjusted', transactionType: 'adjusted',
amountCents: data.amountCents ?? 0, amountCents: data.amountCents ?? 0,
smsUnits: data.smsUnits ?? 0,
}); });
} }
@@ -348,19 +296,17 @@ export class BillingService {
return this.prisma.tenantAccount.upsert({ return this.prisma.tenantAccount.upsert({
where: { tenantId }, where: { tenantId },
update: {}, update: {},
create: { tenantId, balanceCents: 0, smsUnits: 0, creditCents: 0, status: 'active' }, create: { tenantId, balanceCents: 0, status: 'active' },
}); });
} }
private async applyAccountDelta(data: CreateAccountTransactionDto) { private async applyAccountDelta(data: CreateAccountTransactionDto) {
const account = await this.getAccountOrCreate(data.tenantId); const account = await this.getAccountOrCreate(data.tenantId);
const nextBalance = account.balanceCents + (data.amountCents ?? 0); const nextBalance = account.balanceCents + (data.amountCents ?? 0);
const nextUnits = account.smsUnits + (data.smsUnits ?? 0);
await this.prisma.tenantAccount.update({ await this.prisma.tenantAccount.update({
where: { tenantId: data.tenantId }, where: { tenantId: data.tenantId },
data: { data: {
balanceCents: nextBalance, balanceCents: nextBalance,
smsUnits: nextUnits,
}, },
}); });
@@ -369,7 +315,6 @@ export class BillingService {
tenantId: data.tenantId, tenantId: data.tenantId,
transactionType: data.transactionType, transactionType: data.transactionType,
amountCents: data.amountCents ?? 0, amountCents: data.amountCents ?? 0,
smsUnits: data.smsUnits ?? 0,
balanceAfter: nextBalance, balanceAfter: nextBalance,
relatedType: data.relatedType, relatedType: data.relatedType,
relatedId: data.relatedId, relatedId: data.relatedId,
@@ -92,8 +92,13 @@ export class AdminOperationsController {
} }
@Get('audit-logs') @Get('audit-logs')
auditLogs(@Query('tenantId') tenantId?: string, @Query('userId') userId?: string) { auditLogs(
return this.operations.auditLogs({ tenantId, userId }); @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') @Get('audit-summary')
+31 -1
View File
@@ -26,7 +26,7 @@ function createPrismaMock() {
aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }), aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }),
}, },
accountTransaction: { accountTransaction: {
aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: -20, smsUnits: -2 } }), aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: -20 } }),
}, },
tenantAccount: { tenantAccount: {
findMany: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', balanceCents: 1000, tenant: { name: '租户A' } }]), 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 () => { it('returns paginated gateway submit dead letters', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new OperationsService(prisma as never); const service = new OperationsService(prisma as never);
+65 -16
View File
@@ -180,8 +180,8 @@ export class OperationsService {
_count: { _all: true }, _count: { _all: true },
}), }),
this.prisma.accountTransaction.aggregate({ this.prisma.accountTransaction.aggregate({
where: { tenantId: query.tenantId }, where: { tenantId: query.tenantId, transactionType: 'refunded', createdAt: { gte: sinceToday } },
_sum: { amountCents: true, smsUnits: true }, _sum: { amountCents: true },
_count: { _all: true }, _count: { _all: true },
}), }),
this.prisma.cmppConnectionState.groupBy({ this.prisma.cmppConnectionState.groupBy({
@@ -296,21 +296,31 @@ export class OperationsService {
}); });
} }
auditLogs(query: { tenantId?: string; userId?: string }) { async auditLogs(query: { tenantId?: string; userId?: string; page?: number; pageSize?: number }) {
return this.prisma.operationLog.findMany({ const page = positiveInteger(query.page, 1);
where: { tenantId: query.tenantId, userId: query.userId }, const pageSize = Math.min(100, positiveInteger(query.pageSize, 20));
orderBy: { createdAt: 'desc' }, 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) { async systemLogs(query: OperationLogQuery) {
const page = Math.max(1, Number(query.page ?? 1)); const page = positiveInteger(query.page, 1);
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10))); const pageSize = Math.min(100, positiveInteger(query.pageSize, 10));
const where: Prisma.OperationLogWhereInput = { const where: Prisma.OperationLogWhereInput = {
tenantId: query.tenantId, tenantId: query.tenantId,
userId: query.userId, userId: query.userId,
createdAt: createdAtRange(query.range), createdAt: createdAtRange(query.range),
resource: query.module && query.module !== 'all' ? query.module : undefined, resource: query.module && query.module !== 'all' ? query.module : undefined,
AND: query.level && query.level !== 'all' ? operationLogLevelWhere(query.level) : undefined,
OR: query.keyword ? [ OR: query.keyword ? [
{ action: { contains: query.keyword } }, { action: { contains: query.keyword } },
{ resource: { contains: query.keyword } }, { resource: { contains: query.keyword } },
@@ -324,7 +334,7 @@ export class OperationsService {
this.prisma.operationLog.findMany({ this.prisma.operationLog.findMany({
where, where,
include: { tenant: true, user: true }, include: { tenant: true, user: true },
orderBy: { createdAt: 'desc' }, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
}), }),
@@ -336,12 +346,9 @@ export class OperationsService {
orderBy: { resource: 'asc' }, orderBy: { resource: 'asc' },
}), }),
]); ]);
const normalizedItems = items
.map((item) => normalizeOperationLog(item))
.filter((item) => !query.level || query.level === 'all' || item.level === query.level);
return { return {
items: normalizedItems, items: items.map((item) => normalizeOperationLog(item)),
total: query.level && query.level !== 'all' ? normalizedItems.length : total, total,
page, page,
pageSize, pageSize,
modules: modules.map((item) => item.resource), modules: modules.map((item) => item.resource),
@@ -722,7 +729,7 @@ export class OperationsService {
relatedId: query.taskId, relatedId: query.taskId,
}, },
_count: { _all: true }, _count: { _all: true },
_sum: { amountCents: true, smsUnits: true }, _sum: { amountCents: true },
}), }),
]); ]);
const messageAmount = messages._sum.amountCents ?? 0; const messageAmount = messages._sum.amountCents ?? 0;
@@ -969,6 +976,48 @@ function groupDownstreamByApplication(
return [...summaryMap.values()]; 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 } }>) { function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { tenant: true; user: true } }>) {
const detail = (log.detail ?? {}) as Record<string, unknown>; const detail = (log.detail ?? {}) as Record<string, unknown>;
const result = String(detail.result ?? detail.status ?? ''); const result = String(detail.result ?? detail.status ?? '');
+34 -5
View File
@@ -249,11 +249,13 @@ function createPrismaMock() {
updateMany: jest.fn().mockResolvedValue({ count: 1 }), updateMany: jest.fn().mockResolvedValue({ count: 1 }),
}, },
gatewayDownstreamRecoveryStatus: { gatewayDownstreamRecoveryStatus: {
findUnique: jest.fn().mockResolvedValue(null),
upsert: jest.fn().mockResolvedValue({ upsert: jest.fn().mockResolvedValue({
id: 'recover-1', id: 'recover-1',
tenantId: 'tenant-1', tenantId: 'tenant-1',
applicationId: 'app-1', applicationId: 'app-1',
account: '100001', account: '100001',
gatewayInstanceId: 'gateway-a',
state: 'waiting_connection', state: 'waiting_connection',
lockOwner: 'gateway-a', lockOwner: 'gateway-a',
lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'), 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.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'); expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
}); });
@@ -376,7 +378,7 @@ describe('SendChainService', () => {
dispatched: 1, dispatched: 1,
results: [{ taskId: 'task-1', status: 'queued', enqueued: 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({ expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
where: { batchTaskId: 'task-1', status: 'scheduled' }, where: { batchTaskId: 'task-1', status: 'scheduled' },
data: { status: 'queued' }, data: { status: 'queued' },
@@ -854,8 +856,8 @@ describe('SendChainService', () => {
where: { id: 'record-1' }, where: { id: 'record-1' },
data: expect.objectContaining({ gatewayMessageId: 'GW-1', status: 'submitted', submitStatus: 'accepted' }), 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.release).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, relatedId: 'task-1' }));
expect(billing.charge).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, smsUnits: 1, relatedId: 'MSG-1' })); expect(billing.charge).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, relatedId: 'MSG-1' }));
expect(prisma.smsBillingRecord.create).toHaveBeenCalledWith({ expect(prisma.smsBillingRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({ messageId: 'MSG-1', amountCents: 3, billingStatus: 'charged', transactionId: 'tx-charge' }), 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({ expect(prisma.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ data: expect.objectContaining({
action: 'gateway.downstream_recovery_status_sync', action: 'gateway.downstream_recovery_status_changed',
resource: 'gateway_downstream_recovery_status', 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 () => { it('marks downstream delivery as failed after reaching retry limit', async () => {
const { service, prisma } = createService(); const { service, prisma } = createService();
const previous = process.env.CMPP_DOWNSTREAM_MAX_RETRIES; const previous = process.env.CMPP_DOWNSTREAM_MAX_RETRIES;
+42 -28
View File
@@ -279,10 +279,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const accountCheck = await this.billing.checkAccount({ const accountCheck = await this.billing.checkAccount({
tenantId: data.tenantId, tenantId: data.tenantId,
amountCents: billing.amountCents, amountCents: billing.amountCents,
smsUnits: billing.totalBillingUnits,
}); });
if (!accountCheck.canSend) { if (!accountCheck.canSend) {
throw new BadRequestException('企业账户余额、套餐余量或授信额度不足'); throw new BadRequestException('企业账户余额不足');
} }
} }
const task = await this.prisma.smsBatchTask.create({ const task = await this.prisma.smsBatchTask.create({
@@ -305,11 +304,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
createdById: data.createdById, createdById: data.createdById,
}, },
}); });
if (shouldReserveBalance && billing.amountCents + billing.totalBillingUnits > 0) { if (shouldReserveBalance && billing.amountCents > 0) {
await this.billing.freeze({ await this.billing.freeze({
tenantId: data.tenantId, tenantId: data.tenantId,
amountCents: billing.amountCents, amountCents: billing.amountCents,
smsUnits: billing.totalBillingUnits,
relatedType: 'sms_batch_task', relatedType: 'sms_batch_task',
relatedId: task.id, relatedId: task.id,
remark: '发送任务创建冻结', remark: '发送任务创建冻结',
@@ -637,16 +635,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
take: 100000, take: 100000,
}); });
const amountCents = messages.reduce((sum, message) => sum + message.amountCents, 0); 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 });
const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents, smsUnits });
if (!accountCheck.canSend) { if (!accountCheck.canSend) {
throw new BadRequestException('定时任务到点时企业账户余额、套餐余量或授信额度不足'); throw new BadRequestException('定时任务到点时企业账户余额不足');
} }
if (amountCents + smsUnits > 0) { if (amountCents > 0) {
await this.billing.freeze({ await this.billing.freeze({
tenantId: task.tenantId, tenantId: task.tenantId,
amountCents, amountCents,
smsUnits,
relatedType: 'sms_batch_task', relatedType: 'sms_batch_task',
relatedId: task.id, relatedId: task.id,
remark: '定时任务到点冻结', remark: '定时任务到点冻结',
@@ -1114,9 +1110,21 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
} }
const recoveryStatuses = (this.prisma as PrismaService & { const recoveryStatuses = (this.prisma as PrismaService & {
gatewayDownstreamRecoveryStatus: { gatewayDownstreamRecoveryStatus: {
findUnique: (args: Record<string, unknown>) => Promise<any>;
upsert: (args: Record<string, unknown>) => Promise<any>; upsert: (args: Record<string, unknown>) => Promise<any>;
}; };
}).gatewayDownstreamRecoveryStatus; }).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({ const application = await this.prisma.smsApplication.findUnique({
where: { cmppAccount: account }, where: { cmppAccount: account },
select: { id: true, tenantId: true, name: true }, select: { id: true, tenantId: true, name: true },
@@ -1167,17 +1175,19 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
lockOwner?: string | null; lockOwner?: string | null;
lockExpiresAt?: Date | null; lockExpiresAt?: Date | null;
}; };
if (hasRecoveryAuditStateChanged(previous, updated)) {
await this.prisma.operationLog.create({ await this.prisma.operationLog.create({
data: { data: {
tenantId: updated.tenantId ?? undefined, tenantId: updated.tenantId ?? undefined,
action: 'gateway.downstream_recovery_status_sync', action: 'gateway.downstream_recovery_status_changed',
resource: 'gateway_downstream_recovery_status', resource: 'gateway_downstream_recovery_status',
resourceId: updated.id, resourceId: updated.id,
detail: { detail: {
account, account,
previousState: previous?.state ?? null,
state: updated.state, state: updated.state,
gatewayInstanceId: updated.gatewayInstanceId,
lockOwner: normalizedUpdated.lockOwner, lockOwner: normalizedUpdated.lockOwner,
lockExpiresAt: normalizedUpdated.lockExpiresAt,
attemptCount: updated.attemptCount, attemptCount: updated.attemptCount,
nextRetryAt: updated.nextRetryAt, nextRetryAt: updated.nextRetryAt,
failureCategory: normalizedUpdated.failureCategory, failureCategory: normalizedUpdated.failureCategory,
@@ -1188,6 +1198,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}, },
}, },
}); });
}
return updated; return updated;
} }
@@ -1701,16 +1712,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const accountCheck = await this.billing.checkAccount({ const accountCheck = await this.billing.checkAccount({
tenantId: application.tenantId, tenantId: application.tenantId,
amountCents: billing.amountCents, amountCents: billing.amountCents,
smsUnits: billing.totalBillingUnits,
}); });
if (!accountCheck.canSend) { if (!accountCheck.canSend) {
await reject('BALANCE', '企业账户余额、套餐余量或授信额度不足'); await reject('BALANCE', '企业账户余额不足');
} else { } else {
if (billing.amountCents + billing.totalBillingUnits > 0) { if (billing.amountCents > 0) {
await this.billing.freeze({ await this.billing.freeze({
tenantId: application.tenantId, tenantId: application.tenantId,
amountCents: billing.amountCents, amountCents: billing.amountCents,
smsUnits: billing.totalBillingUnits,
relatedType: 'sms_batch_task', relatedType: 'sms_batch_task',
relatedId: task.id, relatedId: task.id,
remark: 'CMPP 模板不匹配待审核短信冻结', remark: 'CMPP 模板不匹配待审核短信冻结',
@@ -1764,16 +1773,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const accountCheck = await this.billing.checkAccount({ const accountCheck = await this.billing.checkAccount({
tenantId: application.tenantId, tenantId: application.tenantId,
amountCents: billing.amountCents, amountCents: billing.amountCents,
smsUnits: billing.totalBillingUnits,
}); });
if (!accountCheck.canSend) { if (!accountCheck.canSend) {
await reject('BALANCE', '企业账户余额、套餐余量或授信额度不足'); await reject('BALANCE', '企业账户余额不足');
} else { } else {
if (billing.amountCents + billing.totalBillingUnits > 0) { if (billing.amountCents > 0) {
await this.billing.freeze({ await this.billing.freeze({
tenantId: application.tenantId, tenantId: application.tenantId,
amountCents: billing.amountCents, amountCents: billing.amountCents,
smsUnits: billing.totalBillingUnits,
relatedType: 'sms_batch_task', relatedType: 'sms_batch_task',
relatedId: task.id, relatedId: task.id,
remark: 'CMPP 入站短信冻结', remark: 'CMPP 入站短信冻结',
@@ -2253,16 +2260,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
amountCents: number; amountCents: number;
}) { }) {
const amountCents = message.amountCents ?? 0; 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 } }); const exists = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId } });
if (exists?.billingStatus === 'charged') { if (exists?.billingStatus === 'charged') {
return; return;
} }
if (amountCents + smsUnits > 0) { if (amountCents > 0) {
await this.billing.release({ await this.billing.release({
tenantId: message.tenantId, tenantId: message.tenantId,
amountCents, amountCents,
smsUnits,
relatedType: 'sms_batch_task', relatedType: 'sms_batch_task',
relatedId: message.batchTaskId, relatedId: message.batchTaskId,
remark: `短信 ${message.messageId} 提交成功释放冻结并转扣费`, remark: `短信 ${message.messageId} 提交成功释放冻结并转扣费`,
@@ -2271,7 +2277,6 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const transaction = await this.billing.charge({ const transaction = await this.billing.charge({
tenantId: message.tenantId, tenantId: message.tenantId,
amountCents, amountCents,
smsUnits,
relatedType: 'sms_message_record', relatedType: 'sms_message_record',
relatedId: message.messageId, relatedId: message.messageId,
remark: '提交成功扣费', remark: '提交成功扣费',
@@ -2283,7 +2288,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
messageId: message.messageId, messageId: message.messageId,
phoneNumber: message.phoneNumber, phoneNumber: message.phoneNumber,
contentLength: [...message.content].length, contentLength: [...message.content].length,
billingUnits: smsUnits, billingUnits,
unitPrice: message.unitPrice ?? 0, unitPrice: message.unitPrice ?? 0,
amountCents, amountCents,
billingStatus: 'charged', billingStatus: 'charged',
@@ -2300,7 +2305,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number; billingUnits: number }, message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number; billingUnits: number },
remark: string, remark: string,
) { ) {
if ((message.amountCents ?? 0) + (message.billingUnits ?? 0) <= 0) { if ((message.amountCents ?? 0) <= 0) {
return; return;
} }
const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } }); 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({ await this.billing.release({
tenantId: message.tenantId, tenantId: message.tenantId,
amountCents: message.amountCents, amountCents: message.amountCents,
smsUnits: message.billingUnits,
relatedType: 'sms_message_record', relatedType: 'sms_message_record',
relatedId: message.messageId, relatedId: message.messageId,
remark: `${remark}: ${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 }, message: { tenantId: string; messageId: string; amountCents: number; billingUnits: number },
remark: string, remark: string,
) { ) {
if ((message.amountCents ?? 0) + (message.billingUnits ?? 0) <= 0) { if ((message.amountCents ?? 0) <= 0) {
return; return;
} }
const refunded = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'refunded' } }); 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({ const transaction = await this.billing.refund({
tenantId: message.tenantId, tenantId: message.tenantId,
amountCents: message.amountCents, amountCents: message.amountCents,
smsUnits: message.billingUnits,
relatedType: 'sms_message_record', relatedType: 'sms_message_record',
relatedId: message.messageId, relatedId: message.messageId,
remark, remark,
@@ -2919,6 +2922,17 @@ function octetString(value: string, fixedLength: number) {
return value + '\0'.repeat(fixedLength - value.length); return value + '\0'.repeat(fixedLength - value.length);
} }
function hasRecoveryAuditStateChanged(
previous: Record<string, unknown> | null,
current: Record<string, unknown>,
) {
if (!previous) {
return true;
}
return ['state', 'gatewayInstanceId', 'lockOwner', 'failureCategory', 'lastError', 'lastSkipReason']
.some((key) => (previous[key] ?? null) !== (current[key] ?? null));
}
function normalizeRecoveryFailureCategory(data: GatewayDownstreamRecoveryStatusDto) { function normalizeRecoveryFailureCategory(data: GatewayDownstreamRecoveryStatusDto) {
const explicit = String(data.failureCategory ?? '').trim(); const explicit = String(data.failureCategory ?? '').trim();
if (explicit) { if (explicit) {
@@ -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 () => { it('lists enterprise signatures with keyword filters and real relations', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never); const service = new SmsConfigService(prisma as never);
+2
View File
@@ -593,6 +593,7 @@ export class SmsConfigService {
const connection = existing const connection = existing
? await this.prisma.cmppDownstreamConnection.update({ where: { id: existing.id }, data: payload }) ? await this.prisma.cmppDownstreamConnection.update({ where: { id: existing.id }, data: payload })
: await this.prisma.cmppDownstreamConnection.create({ data: { connectionId: data.connectionId, ...payload } }); : await this.prisma.cmppDownstreamConnection.create({ data: { connectionId: data.connectionId, ...payload } });
if (data.status === 'connected' || data.status === 'disconnected') {
await this.writeOperationLog(application.tenantId, undefined, `cmpp_downstream_connection.${data.status}`, 'cmpp_downstream_connection', data.connectionId, { await this.writeOperationLog(application.tenantId, undefined, `cmpp_downstream_connection.${data.status}`, 'cmpp_downstream_connection', data.connectionId, {
applicationId: application.id, applicationId: application.id,
account: data.account, account: data.account,
@@ -600,6 +601,7 @@ export class SmsConfigService {
protocol: data.protocol, protocol: data.protocol,
status: connection.status, status: connection.status,
}); });
}
return connection; return connection;
} }
+12 -3
View File
@@ -23,11 +23,14 @@ function createPrismaMock() {
update: jest.fn().mockResolvedValue({ id: 'cert-1' }), update: jest.fn().mockResolvedValue({ id: 'cert-1' }),
}, },
tenantAccount: { 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: { smsMessageRecord: {
groupBy: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', _sum: { amountCents: 350 } }]), 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(); 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 prisma = createPrismaMock();
const service = new TenantsService(prisma as never); const service = new TenantsService(prisma as never);
@@ -86,8 +89,9 @@ describe('TenantsService', () => {
expect.objectContaining({ expect.objectContaining({
id: 'tenant-1', id: 'tenant-1',
name: '测试企业', name: '测试企业',
account: expect.objectContaining({ balanceCents: 12000, creditCents: 5000 }), account: expect.objectContaining({ balanceCents: 12000 }),
todaySpendCents: 350, todaySpendCents: 350,
todayRefundCents: 125,
}), }),
]); ]);
expect(prisma.tenant.findMany).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.tenant.findMany).toHaveBeenCalledWith(expect.objectContaining({
@@ -99,5 +103,10 @@ describe('TenantsService', () => {
by: ['tenantId'], by: ['tenantId'],
_sum: { amountCents: true }, _sum: { amountCents: true },
})); }));
expect(prisma.accountTransaction.groupBy).toHaveBeenCalledWith(expect.objectContaining({
by: ['tenantId'],
where: expect.objectContaining({ transactionType: 'refunded' }),
_sum: { amountCents: true },
}));
}); });
}); });
+8 -1
View File
@@ -45,7 +45,7 @@ export class TenantsService {
async listManagementRows() { async listManagementRows() {
const sinceToday = startOfToday(); const sinceToday = startOfToday();
const [tenants, accounts, todaySpendGroups] = await Promise.all([ const [tenants, accounts, todaySpendGroups, todayRefundGroups] = await Promise.all([
this.prisma.tenant.findMany({ this.prisma.tenant.findMany({
where: { status: { not: 'deleted' } }, where: { status: { not: 'deleted' } },
include: { enterpriseCertifications: { orderBy: { submittedAt: 'desc' }, take: 1 } }, include: { enterpriseCertifications: { orderBy: { submittedAt: 'desc' }, take: 1 } },
@@ -57,13 +57,20 @@ export class TenantsService {
where: { queuedAt: { gte: sinceToday } }, where: { queuedAt: { gte: sinceToday } },
_sum: { amountCents: true }, _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 accountsByTenant = new Map(accounts.map((account) => [account.tenantId, account]));
const todaySpendByTenant = new Map(todaySpendGroups.map((group) => [group.tenantId, group._sum.amountCents ?? 0])); 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) => ({ return tenants.map((tenant) => ({
...withEnterpriseProfile(tenant), ...withEnterpriseProfile(tenant),
account: accountsByTenant.get(tenant.id) ?? null, account: accountsByTenant.get(tenant.id) ?? null,
todaySpendCents: todaySpendByTenant.get(tenant.id) ?? 0, todaySpendCents: todaySpendByTenant.get(tenant.id) ?? 0,
todayRefundCents: todayRefundByTenant.get(tenant.id) ?? 0,
})); }));
} }
+31 -29
View File
@@ -4,7 +4,7 @@
本文基于当前前端设计原型整理,用于交给 Codex 或开发团队执行第一版落地开发。 本文基于当前前端设计原型整理,用于交给 Codex 或开发团队执行第一版落地开发。
当前确认:第一版保留短信业务,排除彩信功能;账户计费、充值套餐、充值记录进入第一版开发范围,账单流水页面和公开交易查询 API 不进入第一版。彩信服务、彩信应用/签名/模板 Tab,以及运营端彩信相关菜单标记为“待开发”;业务性能指标为“平台可稳定入队并调度 500 条短信/秒,实际向通道 submit 受通道限速配置控制”。 当前确认:第一版保留短信业务,排除彩信功能;账户按现金余额计费,人工充值和充值记录进入第一版开发范围,套餐、短信余量、授信额度、账单流水页面和公开交易查询 API 不进入第一版。彩信服务、彩信应用/签名/模板 Tab,以及运营端彩信相关菜单标记为“待开发”;业务性能指标为“平台可稳定入队并调度 500 条短信/秒,实际向通道 submit 受通道限速配置控制”。
## 1. 项目目标 ## 1. 项目目标
@@ -17,7 +17,7 @@
- 支持通道级限速、失败重试、回执同步和发送记录追踪。 - 支持通道级限速、失败重试、回执同步和发送记录追踪。
- 支持企业、应用、签名、模板、通道、通道组、报备任务等核心配置数据的后台维护。 - 支持企业、应用、签名、模板、通道、通道组、报备任务等核心配置数据的后台维护。
- 彩信功能仅保留菜单占位或隐藏,不进入第一版开发范围。 - 彩信功能仅保留菜单占位或隐藏,不进入第一版开发范围。
- 账户计费、充值套餐、充值记录进入第一版范围,并与发送记录形成可对账闭环;账单流水页面暂不验收 - 账户现金余额计费、人工充值、充值记录进入第一版范围,并与发送记录形成可对账闭环;不提供套餐、短信余量和授信额度
## 2. 角色与权限 ## 2. 角色与权限
@@ -51,7 +51,7 @@
- 短信应用 - 短信应用
- 模板管理 - 模板管理
- 签名与引流信息 - 签名与引流信息
- 充值套餐 - 账户余额与充值记录
- 账单流水 - 账单流水
- 企业认证 - 企业认证
- 用户管理 - 用户管理
@@ -73,7 +73,7 @@
- 短信通道管理、短信通道组管理 - 短信通道管理、短信通道组管理
- 报备任务、报备记录 - 报备任务、报备记录
- 短信任务进度、短信记录、短信上行记录 - 短信任务进度、短信记录、短信上行记录
- 充值记录、账单流水、套餐配置、计费规则 - 充值记录、账单流水、计费规则
- 企业黑名单、全局黑名单、敏感词管理 - 企业黑名单、全局黑名单、敏感词管理
- 用户管理、手机号段库、引流信息字段库 - 用户管理、手机号段库、引流信息字段库
@@ -327,21 +327,21 @@
### 4.10 账户计费 ### 4.10 账户计费
1. 客户端可查看充值套餐、购买或申请充值套餐、查看账单流水 1. 客户端可查看现金余额和充值记录;充值由运营端人工入账
2. 发送创建时按短信内容计费条数企业应用客户单价或套餐规则生成预估费用,计费条数只按 70/67 字规则拆分;不按移动、联通、电信配置不同客户价。 2. 发送创建时按短信内容计费条数企业应用客户单价生成预估费用,计费条数只按 70/67 字规则拆分;不按移动、联通、电信配置不同客户价。
3. 平台需在发送前检查企业账户余额、套餐余量或授信额度 3. 平台发送前检查企业现金余额,余额大于等于预估费用即可发送
4. 发送链路需记录计费条数、计费单价、计费金额、账务状态。 4. 发送链路需记录计费条数、计费单价、计费金额、账务状态。
5. 账单流水与短信记录可追溯关联,支持按企业、应用、任务、手机号、时间对账。 5. 账单流水与短信记录可追溯关联,支持按企业、应用、任务、手机号、时间对账。
6. 最终失败、超时失败需要退费。 6. 最终失败、超时失败需要退费。
7. 三网通道成本只用于平台内部成本核算,不影响客户扣费金额。 7. 三网通道成本只用于平台内部成本核算,不影响客户扣费金额。
8. 当前版本计费口径固定为提交 accepted 扣费、最终 failed receipt/timeout 退款。 8. 当前版本计费口径固定为提交 accepted 扣费、最终 failed receipt/timeout 退款。
9. 所有面向用户展示的金额、余额、充值金额、套餐价格和单价统一以人民币元展示并固定保留三位小数;内部仍使用分或最小计费单位持久化,不以展示精度改变账务计算。 9. 所有面向用户展示的金额、余额、充值金额和单价统一以人民币元展示并固定保留三位小数;内部仍使用分或最小计费单位持久化,不以展示精度改变账务计算。
## 5. 功能需求 ## 5. 功能需求
### 5.1 客户端工作台 ### 5.1 客户端工作台
- 展示短信余量或可发送额度、今日发送量、成功率、待审核事项、快捷入口。 - 展示现金余额、今日发送量、成功率、待审核事项、快捷入口。
- 展示最近发送批次、发送趋势、签名/模板状态。 - 展示最近发送批次、发送趋势、签名/模板状态。
- 数据范围限定为当前企业。 - 数据范围限定为当前企业。
@@ -400,7 +400,7 @@
### 5.9 客户端账户计费 ### 5.9 客户端账户计费
- 充值套餐:展示可购买套餐、套餐价格、短信条数、有效期、适用范围 - 账户余额:展示当前现金余额和人工充值记录,不提供客户端购买套餐或创建充值订单入口
- 账单流水:展示充值、冻结、扣费、退费、调整等流水。 - 账单流水:展示充值、冻结、扣费、退费、调整等流水。
- 账单流水需关联短信任务、短信记录或人工调整单据。 - 账单流水需关联短信任务、短信记录或人工调整单据。
- 客户端账号设置属于系统管理,不允许客户自行配置通道或通道组。 - 客户端账号设置属于系统管理,不允许客户自行配置通道或通道组。
@@ -501,10 +501,9 @@
### 5.19 运营端账户计费 ### 5.19 运营端账户计费
- 套餐配置:支持配置套餐名称、价格、短信条数、有效期、适用企业范围、启停状态 - 充值记录:支持运营人员人工充值和负数冲正
- 充值记录:支持人工充值、套餐购买记录、授信额度调整。
- 账单流水:支持冻结、扣费、退费、解冻、人工调整、失败返还。 - 账单流水:支持冻结、扣费、退费、解冻、人工调整、失败返还。
- 计费规则:支持按短信计费条数企业单价、套餐余量、授信额度计算费用 - 计费规则:支持按短信计费条数企业应用单价计算费用,发送额度仅取现金余额
- 账务流水必须与短信记录形成可追溯关系,支持对账导出。 - 账务流水必须与短信记录形成可追溯关系,支持对账导出。
- 最终失败、超时失败需要退费。 - 最终失败、超时失败需要退费。
- 计费口径可配置为按提交成功计费或按回执成功计费。 - 计费口径可配置为按提交成功计费或按回执成功计费。
@@ -522,6 +521,9 @@
- 客户端和运营端右上角用户头像提供下拉菜单,支持退出登录、修改密码;账号设置独立菜单第一版不展示。 - 客户端和运营端右上角用户头像提供下拉菜单,支持退出登录、修改密码;账号设置独立菜单第一版不展示。
- 客户端和运营端系统日志均支持分页、搜索和详情展示;详情列内容较长时使用详情卡/弹窗展示,不能被表格窄列截断。 - 客户端和运营端系统日志均支持分页、搜索和详情展示;详情列内容较长时使用详情卡/弹窗展示,不能被表格窄列截断。
- 系统日志记录登录、退出、配置变更、审核、发送、导入导出、密钥重置、通道复制、通道启停、通道删除、连接状态变化、安全控制变更等操作。 - 系统日志记录登录、退出、配置变更、审核、发送、导入导出、密钥重置、通道复制、通道启停、通道删除、连接状态变化、安全控制变更等操作。
- CMPP 心跳、Submit、Deliver 和 Gateway 周期状态同步属于运行指标或当前状态更新,不得每次追加永久系统日志;连接建立、断开、超时、认证失败,以及恢复状态、实例、锁持有者或失败原因发生真实变化时才写系统日志。
- 系统日志列表必须在数据库侧完成级别、租户、模块、时间和关键词筛选后再分页;所有新旧日志查询接口均限制每页最多 100 条,不允许无界返回全表。
- `OperationLog` 在线保留期默认 180 天;到期日志以小批量事务搬入 `OperationLogArchive`,按 `archiveMonth=YYYY-MM` 形成逻辑月度归档。归档记录不自动删除,归档失败不得删除源记录;归档表达到千万级或维护窗口不满足要求时再评估 PostgreSQL 月度分区。
## 6. 非功能需求 ## 6. 非功能需求
@@ -677,6 +679,7 @@
- user:用户。 - user:用户。
- role、permission、user_role:权限。 - role、permission、user_role:权限。
- operation_log:操作日志。 - operation_log:操作日志。
- operation_log_archive:超过在线保留期的操作日志归档,保留原始日志 id、租户、操作者、动作、资源、详情、发生时间和归档月份。
### 9.2 短信配置 ### 9.2 短信配置
@@ -728,9 +731,8 @@
### 9.7 账户计费 ### 9.7 账户计费
- billing_plan:充值套餐。
- tenant_account:企业账户。 - tenant_account:企业账户。
- account_balance:余额或套餐余量 - account_balance企业现金余额。
- account_transaction:账户流水。 - account_transaction:账户流水。
- billing_rule:计费规则。 - billing_rule:计费规则。
- sms_billing_record:短信计费记录。 - sms_billing_record:短信计费记录。
@@ -937,7 +939,7 @@
3. 实现文件上传和对象存储。 3. 实现文件上传和对象存储。
4. 实现操作日志。 4. 实现操作日志。
5. 实现基础字典:手机号段、敏感词、黑名单、引流字段。 5. 实现基础字典:手机号段、敏感词、黑名单、引流字段。
6. 实现账户、套餐、账务流水基础模型。 6. 实现现金余额账户和账务流水基础模型。
### 阶段 3:企业与配置 ### 阶段 3:企业与配置
@@ -974,8 +976,8 @@
1. 实现客户端批量任务、发送详情、上行短信。 1. 实现客户端批量任务、发送详情、上行短信。
2. 实现运营端任务进度、短信记录、上行记录。 2. 实现运营端任务进度、短信记录、上行记录。
3. 实现客户端充值套餐、账单流水 3. 实现客户端账户余额和充值记录
4. 实现运营端充值记录账务流水、套餐配置 4. 实现运营端充值记录账务流水。
5. 实现运营看板、发送监控、数据统计。 5. 实现运营看板、发送监控、数据统计。
6. 实现导出权限和导出日志。 6. 实现导出权限和导出日志。
@@ -1007,7 +1009,7 @@
1. 根据本文创建数据库迁移脚本和实体模型。 1. 根据本文创建数据库迁移脚本和实体模型。
2. 实现认证、租户、权限基础模块。 2. 实现认证、租户、权限基础模块。
3. 实现企业、应用、签名、模板 CRUD 与审核。 3. 实现企业、应用、签名、模板 CRUD 与审核。
4. 实现账户计费、套餐、账务流水。 4. 实现现金余额计费和账务流水。
5. 实现通道、通道组、报备任务。 5. 实现通道、通道组、报备任务。
6. 实现风控规则、审核原因、规则命中记录。 6. 实现风控规则、审核原因、规则命中记录。
7. 实现 NestJS Send Worker、BullMQ 队列和 Redis 限速。 7. 实现 NestJS Send Worker、BullMQ 队列和 Redis 限速。
@@ -1075,7 +1077,7 @@
### 14.7 待确认问题 ### 14.7 待确认问题
暂无阻塞性待确认问题。后续进入详细设计或开发时,如遇具体运营商协议参数、生产部署资源规格、默认套餐价格等执行细节,再按模块补充确认。 暂无阻塞性待确认问题。后续进入详细设计或开发时,如遇具体运营商协议参数、生产部署资源规格等执行细节,再按模块补充确认。
## 15. 第一版落地执行路线 ## 15. 第一版落地执行路线
@@ -1210,10 +1212,10 @@
任务: 任务:
1. 套餐配置 1. 企业现金账户
2. 充值套餐 2. 人工充值记录
3. 企业账户 3. 现金余额
4. 余额或套餐余量 4. 发送费用预估
5. 充值记录。 5. 充值记录。
6. 账单流水。 6. 账单流水。
7. 发送预估费用。 7. 发送预估费用。
@@ -1222,8 +1224,8 @@
验收标准: 验收标准:
- 客户端充值套餐和账单流水进入第一版。 - 客户端账户余额和充值记录进入第一版。
- 发送前校验账户余额、套餐余量或授信额度 - 发送前校验企业现金余额
- 每条短信记录可追溯到账务流水。 - 每条短信记录可追溯到账务流水。
### 阶段 6:风控与审核 ### 阶段 6:风控与审核
@@ -1324,7 +1326,7 @@
- 运营端可针对指定企业录入人工充值金额、操作人和备注。 - 运营端可针对指定企业录入人工充值金额、操作人和备注。
- 人工充值金额支持负数,用于余额冲正或调减;0 金额不得提交。 - 人工充值金额支持负数,用于余额冲正或调减;0 金额不得提交。
- 人工充值必须写入充值订单和账务流水。 - 人工充值必须写入充值订单和账务流水。
- 运营端充值记录需区分人工充值和套餐充值 - 运营端充值记录均为人工充值或负数冲正
2. 客户端概览指标调整。 2. 客户端概览指标调整。
- 原“剩余条数”改为“剩余余额”。 - 原“剩余条数”改为“剩余余额”。
- 原“近24小时成功率”改为“今日发送条数和今日成功率”。 - 原“近24小时成功率”改为“今日发送条数和今日成功率”。
@@ -1349,7 +1351,7 @@
重要前提: 重要前提:
- 第一版保留短信业务,排除彩信功能。 - 第一版保留短信业务,排除彩信功能。
- 账户计费、充值套餐、账单流水进入第一版。 - 现金余额计费、人工充值、账单流水进入第一版。
- 前端使用 React + TypeScript + Vite。 - 前端使用 React + TypeScript + Vite。
- 后端 API 使用 NestJS + TypeScript。 - 后端 API 使用 NestJS + TypeScript。
- DB 使用 PostgreSQL。 - DB 使用 PostgreSQL。
@@ -1407,7 +1409,7 @@
除文档或菜单明确标注为待开发的彩信能力外,第一版所有可进入菜单不得以 mock/static/localStorage 作为系统功能完成标准: 除文档或菜单明确标注为待开发的彩信能力外,第一版所有可进入菜单不得以 mock/static/localStorage 作为系统功能完成标准:
1. 客户端充值套餐、账单流水、批量任务、短信发送、短信签名、短信模板必须调用真实 API;签名/模板新增后进入真实审核状态,发送任务调用真实发送链路。 1. 客户端账户余额、充值记录、批量任务、短信发送、短信签名、短信模板必须调用真实 API;签名/模板新增后进入真实审核状态,发送任务调用真实发送链路。
2. 运营端数据统计、账务账户、发送监控、短信审核、短信记录、安全控制、手机号段库、报备字段库、通道组、通道报备字段、报备任务和报备记录必须调用真实 API。 2. 运营端数据统计、账务账户、发送监控、短信审核、短信记录、安全控制、手机号段库、报备字段库、通道组、通道报备字段、报备任务和报备记录必须调用真实 API。
3. 运营端企业管理使用真实租户、账户、应用、签名、模板接口;企业新增、编辑、启用/禁用、删除必须写真实租户表,删除采用软删除或归档,不允许纯前端删除。 3. 运营端企业管理使用真实租户、账户、应用、签名、模板接口;企业新增、编辑、启用/禁用、删除必须写真实租户表,删除采用软删除或归档,不允许纯前端删除。
4. 企业签名和企业模板运营端列表只展示真实短信配置数据;彩信签名、彩信模板、彩信通道、彩信记录、彩信任务进度等仍归入待开发,不得用静态样例作为第一版短信验收结果。 4. 企业签名和企业模板运营端列表只展示真实短信配置数据;彩信签名、彩信模板、彩信通道、彩信记录、彩信任务进度等仍归入待开发,不得用静态样例作为第一版短信验收结果。
+7
View File
@@ -35,6 +35,11 @@ SESSION_LOCK_RECOVERY_MS=14400000
SESSION_ABSOLUTE_TIMEOUT_MS=43200000 SESSION_ABSOLUTE_TIMEOUT_MS=43200000
SESSION_RECENT_AUTH_MS=1800000 SESSION_RECENT_AUTH_MS=1800000
SESSION_COOKIE_SECURE=true 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 CMPP_DOWNSTREAM_ACK_TIMEOUT_SECONDS=30
GATEWAY_CMPP_ADDR=0.0.0.0:17890 GATEWAY_CMPP_ADDR=0.0.0.0:17890
OBJECT_STORAGE_DRIVER=minio 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。 安全会话使用 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。 脚本会安装 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` 如生产验证服务器临时无法稳定下载 MinIO,可显式传入 `OBJECT_STORAGE_DRIVER=local`,文件会通过真实 API 保存到服务器本地目录 `OBJECT_STORAGE_LOCAL_ROOT``cmpp-minio` 服务会跳过安装和启动。该模式只建议用于验证环境;正式生产建议恢复 `OBJECT_STORAGE_DRIVER=minio`
+54 -21
View File
@@ -21,7 +21,7 @@
| 模板 | 验证码模板 `验证码为 ${code}`,营销模板 `尊敬的${name},优惠活动开始`,分别准备草稿、待审核、通过、驳回。 | | 模板 | 验证码模板 `验证码为 ${code}`,营销模板 `尊敬的${name},优惠活动开始`,分别准备草稿、待审核、通过、驳回。 |
| 通道 | active CMPP 通道、disabled 通道、备用通道;通道组包含主备优先级。 | | 通道 | active CMPP 通道、disabled 通道、备用通道;通道组包含主备优先级。 |
| 号码 | 合法号码、重复号码、非法号码、企业黑名单号码、全局黑名单号码。 | | 号码 | 合法号码、重复号码、非法号码、企业黑名单号码、全局黑名单号码。 |
| 账户 | 余额充足、余额不足、套餐余量充足、套餐余量不足、授信额度可用。 | | 账户 | 现金余额充足、现金余额不足;不配置套餐余量授信额度。 |
| 企业认证 | 未认证、待审核、已通过、已驳回四类企业认证资料。 | | 企业认证 | 未认证、待审核、已通过、已驳回四类企业认证资料。 |
| 客户 | 正常客户、停用客户、欠费客户、未认证客户、跨租户客户、客户联系人和开票资料。 | | 客户 | 正常客户、停用客户、欠费客户、未认证客户、跨租户客户、客户联系人和开票资料。 |
| 导入文件 | UTF-8 CSV、GBK CSV、TXT、超 20 MB 文件、含空行/重复/非法号码/非法字符文件。 | | 导入文件 | UTF-8 CSV、GBK CSV、TXT、超 20 MB 文件、含空行/重复/非法号码/非法字符文件。 |
@@ -54,7 +54,7 @@
- 前置条件:存在 `tenant-a``tenant-b`,两个租户各有发送任务和充值/计费记录。 - 前置条件:存在 `tenant-a``tenant-b`,两个租户各有发送任务和充值/计费记录。
- 步骤: - 步骤:
1. 使用 `tenant-a` 企业管理员登录客户端。 1. 使用 `tenant-a` 企业管理员登录客户端。
2. 打开工作台、批量任务、发送详情、上行短信、充值套餐 2. 打开工作台、批量任务、发送详情、上行短信、账户余额
3. 使用查询条件搜索 `tenant-b` 的任务编号或手机号。 3. 使用查询条件搜索 `tenant-b` 的任务编号或手机号。
- 预期结果: - 预期结果:
- 登录成功,返回当前租户上下文。 - 登录成功,返回当前租户上下文。
@@ -138,7 +138,7 @@
### TC-CLIENT-007 账户余额不足禁止发送 ### TC-CLIENT-007 账户余额不足禁止发送
- 优先级:P0 - 优先级:P0
- 前置条件:账户余额、套餐余量和授信额度不足 - 前置条件:企业现金余额小于预估发送费用
- 步骤: - 步骤:
1. 使用长内容和多个手机号生成较高预估费用。 1. 使用长内容和多个手机号生成较高预估费用。
2. 提交发送。 2. 提交发送。
@@ -749,7 +749,7 @@
- 步骤:创建发送任务并进入待发送。 - 步骤:创建发送任务并进入待发送。
- 预期结果: - 预期结果:
- 生成 frozen 流水。 - 生成 frozen 流水。
- 账户余额或套餐余量减少。 - 企业现金余额减少。
- 流水关联 taskId。 - 流水关联 taskId。
### TC-BILLING-003 submit 成功扣费 ### TC-BILLING-003 submit 成功扣费
@@ -2322,16 +2322,16 @@
### TC-DASHBOARD-002 客户端 Dashboard 余额和可发送额度准确 ### TC-DASHBOARD-002 客户端 Dashboard 余额和可发送额度准确
- 优先级:P0 - 优先级:P0
- 前置条件:客户 A 账户余额 10000 分,套餐余量 200 条,授信额度 5000 分;存在冻结、扣费、退款、释放流水。 - 前置条件:客户 A 现金余额 10000 分;存在冻结、扣费、退款、释放流水。
- 步骤: - 步骤:
1. 打开客户端 Dashboard。 1. 打开客户端 Dashboard。
2. 查看余额、套餐余量、授信额度、可发送额度。 2. 查看现金余额和可发送额度。
3. 打开账单流水。 3. 打开账单流水。
4. 按交易类型核对充值、冻结、扣费、退款、释放后的余额。 4. 按交易类型核对充值、冻结、扣费、退款、释放后的余额。
- 预期结果: - 预期结果:
- Dashboard 余额与账户表和流水计算结果一致。 - Dashboard 余额与账户表和流水计算结果一致。
- 冻结金额不应被当作可用余额重复计算。 - 冻结金额不应被当作可用余额重复计算。
- 套餐余量和金额余额分别展示,口径不混淆 - 可发送额度等于现金余额,不叠加其他额度
- 跳转账单流水后可核对组成明细。 - 跳转账单流水后可核对组成明细。
### TC-DASHBOARD-003 客户端 Dashboard 待处理事项准确 ### TC-DASHBOARD-003 客户端 Dashboard 待处理事项准确
@@ -2414,11 +2414,11 @@
5. 运营端查看账户余额、账单流水和系统日志。 5. 运营端查看账户余额、账单流水和系统日志。
- 预期结果: - 预期结果:
- 生成 RechargeOrder,状态为 paid 或人工充值完成状态。 - 生成 RechargeOrder,状态为 paid 或人工充值完成状态。
- 账户余额和套餐余量同步增加。 - 企业现金余额同步增加。
- 生成 account_transaction,类型为 recharge,关联 recharge_order。 - 生成 account_transaction,类型为 recharge,关联 recharge_order。
- 客户端余额、账单流水即时可见。 - 客户端余额、账单流水即时可见。
- 运营端充值记录、账单流水、系统日志三处可追溯。 - 运营端充值记录、账单流水、系统日志三处可追溯。
- 所有金额、余额、套餐价格和单价固定展示三位小数,例如 `¥1.000` - 所有金额、余额和单价固定展示三位小数,例如 `¥1.000`
### TC-BILLING-007 人工充值金额和短信条数只填其一 ### TC-BILLING-007 人工充值金额和短信条数只填其一
@@ -2429,7 +2429,7 @@
2. 再发起一笔只填写短信条数,不填写充值金额。 2. 再发起一笔只填写短信条数,不填写充值金额。
3. 查看账户和流水。 3. 查看账户和流水。
- 预期结果: - 预期结果:
- 系统按填写项分别增加余额或套餐余量 - 系统按填写金额增加或冲正现金余额
- 未填写项按 0 处理,不产生脏数据。 - 未填写项按 0 处理,不产生脏数据。
- 流水金额和短信条数字段方向正确。 - 流水金额和短信条数字段方向正确。
- 备注和操作人保留。 - 备注和操作人保留。
@@ -2445,7 +2445,7 @@
4. 客户端查看账单流水。 4. 客户端查看账单流水。
- 预期结果: - 预期结果:
- 原充值记录状态变为 canceled/reversed,或生成一笔反向调整流水。 - 原充值记录状态变为 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 运营端创建客户并初始化租户 ### TC-CUSTOMER-001 运营端创建客户并初始化租户
- 优先级:P0 - 优先级:P0
@@ -2631,7 +2660,7 @@
### TC-CUSTOMER-006 客户欠费或额度不足状态联动发送 ### TC-CUSTOMER-006 客户欠费或额度不足状态联动发送
- 优先级:P0 - 优先级:P0
- 前置条件:客户余额、套餐余量、授信额度不足,或运营端标记欠费。 - 前置条件:客户现金余额不足,或运营端标记欠费。
- 步骤: - 步骤:
1. 客户端创建发送任务。 1. 客户端创建发送任务。
2. API 调用发送。 2. API 调用发送。
@@ -2675,10 +2704,10 @@
### TC-CUSTOMER-009 客户详情展示业务总览准确 ### TC-CUSTOMER-009 客户详情展示业务总览准确
- 优先级:P0 - 优先级:P0
- 前置条件:客户 A 下存在应用 3 个、签名 4 个、模板 5 个、通道绑定 2 个、今日发送 100 条、余额和套餐余量 - 前置条件:客户 A 下存在应用 3 个、签名 4 个、模板 5 个、通道绑定 2 个、今日发送 100 条和现金余额
- 步骤: - 步骤:
1. 运营端打开客户详情。 1. 运营端打开客户详情。
2. 查看客户业务总览:应用数、签名数、模板数、今日发送量、成功率、余额、套餐余量、待审核数。 2. 查看客户业务总览:应用数、签名数、模板数、今日发送量、成功率、现金余额、待审核数。
3. 点击每个指标进入对应明细列表。 3. 点击每个指标进入对应明细列表。
- 预期结果: - 预期结果:
- 客户详情总览只统计客户 A。 - 客户详情总览只统计客户 A。
@@ -3005,7 +3034,7 @@
3. 初始化 active/disabled 客户、未认证/待审核/已认证/驳回企业认证资料。 3. 初始化 active/disabled 客户、未认证/待审核/已认证/驳回企业认证资料。
4. 初始化可用应用、停用应用、待删除应用。 4. 初始化可用应用、停用应用、待删除应用。
5. 初始化签名、引流信息、模板、多变量模板、通道、通道组、路由规则。 5. 初始化签名、引流信息、模板、多变量模板、通道、通道组、路由规则。
6. 初始化账户余额、套餐余量、欠费/不足余额场景。 6. 初始化现金余额、欠费/不足余额场景。
7. 准备 CSV/TXT/GBK/大文件/非法字符/黑名单号码测试文件。 7. 准备 CSV/TXT/GBK/大文件/非法字符/黑名单号码测试文件。
8. 确认 Gateway 模拟器可切换 online、auth_failed、heartbeat_timeout、disconnected、slow_response。 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 为 4unknown=2;成功率若按 delivered/total 为 62.5%;点击卡片后的明细筛选一致。 | | TC-DASHBOARD-001 | 客户 A 当天 delivered=10、failed=3、unknown=2、timeout=1;客户 B 有干扰数据。 | 客户端总量=16;成功=10;失败按 failed+timeout 为 4unknown=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-003 | 待审核签名 2、模板 3、待报备 1、pending_review 发送任务 4。 | 待处理总数和分类数准确;点击跳转后列表筛选数量一致;只包含当前租户。 |
| TC-DASHBOARD-004 | 多客户、多通道、多状态发送和账务流水。 | 运营端统计全平台;活跃客户、今日发送、成功率、待审核、收入均可在明细页复核。 | | TC-DASHBOARD-004 | 多客户、多通道、多状态发送和账务流水。 | 运营端统计全平台;活跃客户、今日发送、成功率、待审核、收入均可在明细页复核。 |
| TC-DASHBOARD-005 | 客户 A/B 均有发送、账务、审核数据。 | 切换客户后所有卡片、趋势、状态分布、账务汇总同步刷新;跳转明细继承客户筛选。 | | TC-DASHBOARD-005 | 客户 A/B 均有发送、账务、审核数据。 | 切换客户后所有卡片、趋势、状态分布、账务汇总同步刷新;跳转明细继承客户筛选。 |
@@ -3175,11 +3204,13 @@ npm run verify:phase8
| 用例 | 细化执行点 | 必查断言 | | 用例 | 细化执行点 | 必查断言 |
| --- | --- | --- | | --- | --- | --- |
| TC-BILLING-006 | 运营端人工充值金额和短信条数,客户端查看 Dashboard 和账单流水。 | RechargeOrder 状态为 paid/manual_topupTenantAccount 同步增加;AccountTransaction 类型 recharge;充值记录“充值后余额”必须等于该订单关联 AccountTransaction.balanceAfter,不能用当前账户余额替代;运营日志和客户端流水均可追溯。 | | TC-BILLING-006 | 运营端人工充值现金金额,客户端查看 Dashboard 和充值记录。 | RechargeOrder 状态为 paid/manual_topupTenantAccount 现金余额同步增加;AccountTransaction 类型 recharge;充值记录“充值后余额”必须等于该订单关联 AccountTransaction.balanceAfter,不能用当前账户余额替代;运营日志可追溯。 |
| TC-BILLING-007 | 分别只填金额、只填短信条数;金额填写负数执行冲正。 | 未填项按 0;正负金额和条数字段方向正确;允许有业务含义的负数调整,不产生 null、NaN 或零变更脏数据。 | | TC-BILLING-007 | 分别填写正数金额和负数金额执行充值、冲正;提交 0 或非法金额。 | 正负金额方向正确;0 和非法金额被拒绝;数据模型和接口不存在短信条数、套餐或授信字段。 |
| TC-BILLING-008 | 对已充值记录执行撤销/冲正,分别覆盖未消费和已部分消费。 | 未消费可全额回退;已消费按规则拒绝或生成人工调整;原订单状态和反向流水清晰;日志记录原因。 | | TC-BILLING-008 | 对已充值记录执行撤销/冲正,分别覆盖未消费和已部分消费。 | 未消费可全额回退;已消费按规则拒绝或生成人工调整;原订单状态和反向流水清晰;日志记录原因。 |
| TC-BILLING-009 | 无权限用户、审核员、管理员分别执行充值;大额人工充值不走审批。 | 权限不足被拒绝并写失败日志;有权限用户确认后立即入账;不产生 pending 审批态;充值订单、账户余额、流水和日志同步完成。 | | TC-BILLING-009 | 无权限用户、审核员、管理员分别执行充值;大额人工充值不走审批。 | 权限不足被拒绝并写失败日志;有权限用户确认后立即入账;不产生 pending 审批态;充值订单、账户余额、流水和日志同步完成。 |
| TC-BILLING-010 | 余额不足发送失败,人工充值后重试发送并模拟 delivered。 | 充值前不扣费;充值后发送成功;冻结、扣费、短信计费记录完整;reconciliation diff 为 0。 | | TC-BILLING-010 | 余额不足发送失败,人工充值后重试发送并模拟 delivered。 | 充值前不扣费;充值后发送成功;冻结、扣费、短信计费记录完整;reconciliation diff 为 0。 |
| TC-BILLING-011 | 账户现金余额 100 分,预估费用 5 分,且数据库不存在套餐和授信数据;再将余额改为 4 分重试。 | 100 分时可发送,4 分时提示“企业账户余额不足”;判断只依赖 TenantAccount.balanceCents。 |
| TC-BILLING-012 | 已扣费短信收到最终失败回执;另一个未提交成功任务只释放冻结。 | 前者只生成一条 refunded 流水并计入“今日返还”;重复回执不重复退款;后者的 released 流水不计入“今日返还”。 |
### 17.6 系统日志细化 ### 17.6 系统日志细化
@@ -3188,8 +3219,10 @@ npm run verify:phase8
| TC-LOG-005 | 客户 A 查看日志并尝试查询客户 B 日志。 | 客户端只返回本租户日志;越权查询失败;日志包含 IP、User-Agent、result、resourceId。 | | TC-LOG-005 | 客户 A 查看日志并尝试查询客户 B 日志。 | 客户端只返回本租户日志;越权查询失败;日志包含 IP、User-Agent、result、resourceId。 |
| TC-LOG-006 | 客户端导入号码、立即发送、创建并取消定时任务。 | 导入日志含文件名、行数、成功/失败数;发送日志含任务编号、号码数、发送类型;取消日志含取消人。 | | TC-LOG-006 | 客户端导入号码、立即发送、创建并取消定时任务。 | 导入日志含文件名、行数、成功/失败数;发送日志含任务编号、号码数、发送类型;取消日志含取消人。 |
| TC-LOG-007 | 运营端按客户、动作、资源、结果、时间查询并导出。 | 查询准确;导出内容与筛选一致;导出动作本身写日志。 | | TC-LOG-007 | 运营端按客户、动作、资源、结果、时间查询并导出。 | 查询准确;导出内容与筛选一致;导出动作本身写日志。 |
| TC-LOG-008 | 人工充值、冲正、账户调整。 | 日志含客户、金额、短信条数、订单号、流水号、操作者;敏感字段脱敏。 | | TC-LOG-008 | 人工充值、冲正、账户调整。 | 日志含客户、金额、订单号、流水号、操作者;敏感字段脱敏。 |
| TC-LOG-009 | 触发无权限充值、余额不足发送、无在线通道发送。 | 失败动作也写日志;result/status 标记失败;失败原因与前端提示一致。 | | TC-LOG-009 | 触发无权限充值、余额不足发送、无在线通道发送。 | 失败动作也写日志;result/status 标记失败;失败原因与前端提示一致。 |
| TC-LOG-010 | 保持 CMPP 在线并连续发送 heartbeat/Submit/Deliver、重复恢复状态,再触发断开和状态变化。 | 高频事件只更新状态表;连接和恢复状态关键变化才新增日志;日志量不随心跳线性增长。 |
| TC-LOG-011 | 准备多级别、多页和过期日志,验证查询上限与归档重试。 | 数据库侧筛选后分页;每页最多 100;归档成功才删除源记录;archiveMonth、原始 id 和详情完整。 |
### 17.7 客户管理细化 ### 17.7 客户管理细化
@@ -3200,7 +3233,7 @@ npm run verify:phase8
| TC-CUSTOMER-003 | 停用客户后分别通过客户端、API、CMPP 接入尝试发送。 | 全部阻断;不入队、不扣费;失败原因是客户停用;历史任务可查。 | | TC-CUSTOMER-003 | 停用客户后分别通过客户端、API、CMPP 接入尝试发送。 | 全部阻断;不入队、不扣费;失败原因是客户停用;历史任务可查。 |
| TC-CUSTOMER-004 | 客户 active 时创建 scheduled,到点前停用。 | 到点重校验失败;任务 rejected/canceled/failed;冻结费用释放;日志指向客户停用。 | | TC-CUSTOMER-004 | 客户 active 时创建 scheduled,到点前停用。 | 到点重校验失败;任务 rejected/canceled/failed;冻结费用释放;日志指向客户停用。 |
| TC-CUSTOMER-005 | 重新启用客户后发送。 | 客户状态 active;新发送成功进入链路;账务和日志完整。 | | TC-CUSTOMER-005 | 重新启用客户后发送。 | 客户状态 active;新发送成功进入链路;账务和日志完整。 |
| TC-CUSTOMER-006 | 余额不足、套餐不足、授信不足、欠费标记。 | 发送前账户校验失败;不投递 Gateway;客户详情展示欠费或不足状态。 | | TC-CUSTOMER-006 | 现金余额不足欠费标记。 | 发送前账户校验失败;不投递 Gateway;客户详情展示欠费或不足状态。 |
| TC-CUSTOMER-007 | 客户 A 使用 URL/API 参数访问客户 B 资源。 | 不泄露 B 数据;返回无权限或空结果;失败访问写安全日志。 | | TC-CUSTOMER-007 | 客户 A 使用 URL/API 参数访问客户 B 资源。 | 不泄露 B 数据;返回无权限或空结果;失败访问写安全日志。 |
| TC-CUSTOMER-008 | 删除/归档有历史数据的客户。 | 不允许硬删除或执行归档;新发送和未执行 scheduled 阻断;历史 trace/对账可查。 | | TC-CUSTOMER-008 | 删除/归档有历史数据的客户。 | 不允许硬删除或执行归档;新发送和未执行 scheduled 阻断;历史 trace/对账可查。 |
| TC-CUSTOMER-009 | 客户详情总览应用、签名、模板、今日发送、余额。 | 各指标与明细列表聚合一致;跳转带客户筛选;异常状态有标识。 | | 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-MOCK-CLEAN-002 | 访问运营端数据统计、账务账户、发送监控、安全控制、手机号段库、报备字段库、通道组、报备任务、报备记录。 | 所有列表和卡片来自真实 API;新增动作写入数据库;后端缺失的编辑/删除能力不得用本地状态伪造。 |
| TC-UI-ENTERPRISE-SELECT-001 | 逐一打开包含企业或企业应用选择的表单和筛选项,输入部分企业/应用名称。 | 下拉面板提供搜索框并实时缩小真实 API 选项范围;清空后恢复全部选项。 | | TC-UI-ENTERPRISE-SELECT-001 | 逐一打开包含企业或企业应用选择的表单和筛选项,输入部分企业/应用名称。 | 下拉面板提供搜索框并实时缩小真实 API 选项范围;清空后恢复全部选项。 |
| TC-UI-TEMPLATE-RESPONSIVE-001 | 在 1024px、1366px 和宽屏视口打开企业短信模板页。 | 模板卡片自适应换列,页面不出现水平滚动,每张卡片的编辑和删除按钮直接可见。 | | TC-UI-TEMPLATE-RESPONSIVE-001 | 在 1024px、1366px 和宽屏视口打开企业短信模板页。 | 模板卡片自适应换列,页面不出现水平滚动,每张卡片的编辑和删除按钮直接可见。 |
+19
View File
@@ -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 运营端通道测试短信闭环修复 ## 2026-07-09 运营端通道测试短信闭环修复
- 生产验证发现运营端通道“短信测试”弹窗仅关闭页面,未调用后端;`POST /api/admin/channels/:id/test` 仍返回 phase-4 placeholder,不创建 `SmsMessageRecord/SmsSubmitRecord`,也不写入 Gateway SubmitCommand,因此短信记录页面无记录。 - 生产验证发现运营端通道“短信测试”弹窗仅关闭页面,未调用后端;`POST /api/admin/channels/:id/test` 仍返回 phase-4 placeholder,不创建 `SmsMessageRecord/SmsSubmitRecord`,也不写入 Gateway SubmitCommand,因此短信记录页面无记录。
+4 -20
View File
@@ -200,6 +200,7 @@ export type TenantOption = {
export type TenantManagementRow = TenantOption & { export type TenantManagementRow = TenantOption & {
account?: TenantAccount | null; account?: TenantAccount | null;
todaySpendCents: number; todaySpendCents: number;
todayRefundCents: number;
}; };
export type CaptchaResponse = { 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 }; today: { sent: number; delivered: number; failed: number; unknown: number; successRate: number; spendCents: number; billingUnits: number };
uplinkCount: number; uplinkCount: number;
billing: { _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }; 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 } }>; gatewayConnections: Array<{ status: string; _count: { _all: number }; _sum: { currentConnections?: number | null; desiredConnections?: number | null } }>;
pendingAuditCount: number; pendingAuditCount: number;
pendingAudits: { enterpriseCertifications: number; smsAudits: number; templates: number; signatures: number; drainageInfos: number; total: number }; pendingAudits: { enterpriseCertifications: number; smsAudits: number; templates: number; signatures: number; drainageInfos: number; total: number };
@@ -254,7 +255,7 @@ export type DashboardResponse = {
recentFailed: number; recentFailed: number;
alertCount: 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<Record<string, unknown>>; recentTasks: Array<Record<string, unknown>>;
recentRecharges: Array<RechargeOrder>; recentRecharges: Array<RechargeOrder>;
}; };
@@ -264,7 +265,6 @@ export type RechargeOrder = {
tenantId: string; tenantId: string;
orderNo: string; orderNo: string;
amountCents: number; amountCents: number;
smsUnits: number;
status: string; status: string;
payMethod?: string | null; payMethod?: string | null;
paidAt?: string | null; paidAt?: string | null;
@@ -275,16 +275,6 @@ export type RechargeOrder = {
tenant?: TenantOption; tenant?: TenantOption;
}; };
export type BillingPlan = {
id: string;
name: string;
amountCents: number;
smsUnits: number;
unitPriceCents?: number | null;
status: string;
description?: string | null;
};
export type ClientSmsApplication = { export type ClientSmsApplication = {
id: string; id: string;
tenantId: string; tenantId: string;
@@ -671,8 +661,6 @@ export type TenantAccount = {
id: string; id: string;
tenantId: string; tenantId: string;
balanceCents: number; balanceCents: number;
smsUnits: number;
creditCents: number;
status: string; status: string;
tenant?: TenantOption; tenant?: TenantOption;
}; };
@@ -966,7 +954,7 @@ export const adminApi = {
request<OperationLogResponse>(withQuery('/admin/system-logs', query)), request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
listAccounts: () => request<TenantAccount[]>('/admin/billing/accounts'), listAccounts: () => request<TenantAccount[]>('/admin/billing/accounts'),
listManualRecharges: (tenantId?: string) => request<RechargeOrder[]>(withQuery('/admin/billing/manual-recharges', { tenantId })), listManualRecharges: (tenantId?: string) => request<RechargeOrder[]>(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<RechargeOrder>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }), request<RechargeOrder>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
listEnterpriseApplications: (query: { tenantId?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; status?: string } = {}) => listEnterpriseApplications: (query: { tenantId?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; status?: string } = {}) =>
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)), request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
@@ -1215,10 +1203,6 @@ export const clientApi = {
request<OperationLogResponse>(withQuery('/client/operations/system-logs', query), { tenantId }), request<OperationLogResponse>(withQuery('/client/operations/system-logs', query), { tenantId }),
listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<RechargeOrder[]>('/client/billing/orders', { tenantId }), request<RechargeOrder[]>('/client/billing/orders', { tenantId }),
listPlans: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<BillingPlan[]>('/client/billing/plans', { tenantId }),
createOrder: (body: { planId?: string; amountCents?: number; smsUnits?: number; payMethod?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<RechargeOrder>('/client/billing/orders', { method: 'POST', tenantId, body: JSON.stringify(body) }),
listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsApplication[]>('/client/applications', { tenantId }), request<ClientSmsApplication[]>('/client/applications', { tenantId }),
getApplicationCmppParams: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => getApplicationCmppParams: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
+1 -1
View File
@@ -67,7 +67,7 @@ export function AdminCustomerDetailPage() {
<div className="dashboard-grid enterprise-summary-grid"> <div className="dashboard-grid enterprise-summary-grid">
<div className="surface mini-status-card"><Server size={22} /><div><span></span><strong>{tenant?.code ?? '-'}</strong><small>{tenant?.status ?? '-'}</small></div></div> <div className="surface mini-status-card"><Server size={22} /><div><span></span><strong>{tenant?.code ?? '-'}</strong><small>{tenant?.status ?? '-'}</small></div></div>
<div className="surface mini-status-card"><MessageSquare size={22} /><div><span></span><strong>{(account?.smsUnits ?? 0).toLocaleString('zh-CN')}</strong><small></small></div></div> <div className="surface mini-status-card"><MessageSquare size={22} /><div><span></span><strong></strong><small></small></div></div>
<div className="surface mini-status-card"><FileText size={22} /><div><span></span><strong>¥{formatCents(account?.balanceCents)}</strong><small></small></div></div> <div className="surface mini-status-card"><FileText size={22} /><div><span></span><strong>¥{formatCents(account?.balanceCents)}</strong><small></small></div></div>
</div> </div>
+5 -10
View File
@@ -36,10 +36,9 @@ function emptyRechargeForm(): RechargeForm {
export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCustomersPageProps) { export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCustomersPageProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const [records, setRecords] = useState<CustomerRow[]>([]); const [records, setRecords] = useState<CustomerRow[]>([]);
const [queryId, setQueryId] = useState('');
const [queryName, setQueryName] = useState(''); const [queryName, setQueryName] = useState('');
const [queryStatus, setQueryStatus] = useState('all'); 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 [confirmAction, setConfirmAction] = useState<{ type: 'toggle' | 'delete'; record: CustomerRow } | null>(null);
const [rechargeTarget, setRechargeTarget] = useState<CustomerRow | null>(null); const [rechargeTarget, setRechargeTarget] = useState<CustomerRow | null>(null);
const [rechargeForm, setRechargeForm] = useState<RechargeForm>(emptyRechargeForm); const [rechargeForm, setRechargeForm] = useState<RechargeForm>(emptyRechargeForm);
@@ -61,10 +60,9 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
}, []); }, []);
const filteredRecords = useMemo(() => records.filter((record) => { 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 matchName = filters.name ? record.name.includes(filters.name) : true;
const matchStatus = filters.status === 'all' ? true : record.status === filters.status; const matchStatus = filters.status === 'all' ? true : record.status === filters.status;
return matchId && matchName && matchStatus; return matchName && matchStatus;
}), [filters, records]); }), [filters, records]);
const activeCount = records.filter((record) => record.status === 'active').length; 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 totalBalance = records.reduce((sum, record) => sum + (record.account?.balanceCents ?? 0), 0);
const columns: Array<TableColumn<CustomerRow>> = [ const columns: Array<TableColumn<CustomerRow>> = [
{ key: 'id', title: '企业ID', width: '160px', render: (record) => <span className="table-mono-id">{record.id}</span> },
{ key: 'name', title: '企业名称', width: '260px', render: (record) => <strong className="table-strong-text">{record.name}</strong> }, { key: 'name', title: '企业名称', width: '260px', render: (record) => <strong className="table-strong-text">{record.name}</strong> },
{ {
key: 'balance', 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: '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) => <Tag tone={record.status === 'active' ? 'success' : 'warning'}>{record.status === 'active' ? '正常' : '已禁用'}</Tag> }, { key: 'status', title: '企业状态', width: '130px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'warning'}>{record.status === 'active' ? '正常' : '已禁用'}</Tag> },
{ {
key: 'actions', key: 'actions',
@@ -133,7 +130,6 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
await adminApi.createManualRecharge({ await adminApi.createManualRecharge({
tenantId: rechargeTarget.id, tenantId: rechargeTarget.id,
amountCents: Math.round(amount * 100), amountCents: Math.round(amount * 100),
smsUnits: 0,
remark: [rechargeForm.operator, rechargeForm.remark].filter(Boolean).join(' / '), remark: [rechargeForm.operator, rechargeForm.remark].filter(Boolean).join(' / '),
}); });
setRechargeTarget(null); setRechargeTarget(null);
@@ -175,12 +171,11 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
<div className="surface ui-query-panel"> <div className="surface ui-query-panel">
<h2></h2> <h2></h2>
<div className="ui-query-panel__grid enterprise-query-grid"> <div className="ui-query-panel__grid enterprise-query-grid">
<Input label="企业ID" onChange={(event) => setQueryId(event.target.value)} placeholder="请输入企业ID" value={queryId} />
<Input label="企业名称" onChange={(event) => setQueryName(event.target.value)} placeholder="请输入企业名称" value={queryName} /> <Input label="企业名称" onChange={(event) => setQueryName(event.target.value)} placeholder="请输入企业名称" value={queryName} />
<Select label="企业状态" onChange={(event) => setQueryStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={queryStatus} /> <Select label="企业状态" onChange={(event) => setQueryStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={queryStatus} />
<div className="enterprise-query-actions"> <div className="enterprise-query-actions">
<Button onClick={() => setFilters({ id: queryId, name: queryName, status: queryStatus })} variant="secondary"></Button> <Button onClick={() => setFilters({ name: queryName, status: queryStatus })} variant="secondary"></Button>
<Button onClick={() => { setQueryId(''); setQueryName(''); setQueryStatus('all'); setFilters({ id: '', name: '', status: 'all' }); }} variant="ghost"></Button> <Button onClick={() => { setQueryName(''); setQueryStatus('all'); setFilters({ name: '', status: 'all' }); }} variant="ghost"></Button>
</div> </div>
</div> </div>
</div> </div>
+1 -1
View File
@@ -69,7 +69,7 @@ export function AdminHome() {
const todaySpend = Math.abs(dashboard?.recentRecharges const todaySpend = Math.abs(dashboard?.recentRecharges
.filter((item) => item.tenantId === account.tenantId) .filter((item) => item.tenantId === account.tenantId)
.reduce((sum, item) => sum + item.amountCents, 0) ?? 0) / 100; .reduce((sum, item) => sum + item.amountCents, 0) ?? 0) / 100;
const availableBalance = (account.balanceCents + account.creditCents) / 100; const availableBalance = account.balanceCents / 100;
return { return {
id: account.tenantId, id: account.tenantId,
enterprise: account.tenant?.name ?? account.tenantId, enterprise: account.tenant?.name ?? account.tenantId,
+7 -11
View File
@@ -8,7 +8,6 @@ import { formatAmount } from '@/utils/currency';
type ManualRechargeForm = { type ManualRechargeForm = {
tenantId: string; tenantId: string;
amount: string; amount: string;
smsUnits: string;
operator: string; operator: string;
remark: string; remark: string;
}; };
@@ -31,7 +30,7 @@ export function AdminRechargeRecordsPage() {
const [enterpriseKeyword, setEnterpriseKeyword] = useState(''); const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({}); const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [manualOpen, setManualOpen] = useState(false); const [manualOpen, setManualOpen] = useState(false);
const [form, setForm] = useState<ManualRechargeForm>({ tenantId: '', amount: '', smsUnits: '0', operator: '运营', remark: '' }); const [form, setForm] = useState<ManualRechargeForm>({ tenantId: '', amount: '', operator: '运营', remark: '' });
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -93,9 +92,8 @@ export function AdminRechargeRecordsPage() {
async function submitManualRecharge() { async function submitManualRecharge() {
const amount = Number(form.amount); const amount = Number(form.amount);
const smsUnits = Number(form.smsUnits || 0); if (!form.tenantId || !Number.isFinite(amount) || amount === 0) {
if (!form.tenantId || !Number.isFinite(amount) || !Number.isFinite(smsUnits) || (amount === 0 && smsUnits === 0)) { setManualError('请填写非 0 的充值金额;金额支持负数冲正。');
setManualError('请填写非 0 的充值金额或短信条数;金额支持负数冲正。');
return; return;
} }
setSubmitting(true); setSubmitting(true);
@@ -104,12 +102,11 @@ export function AdminRechargeRecordsPage() {
await adminApi.createManualRecharge({ await adminApi.createManualRecharge({
tenantId: form.tenantId, tenantId: form.tenantId,
amountCents: Math.round(amount * 100), amountCents: Math.round(amount * 100),
smsUnits,
remark: [form.operator, form.remark].filter(Boolean).join(' / '), remark: [form.operator, form.remark].filter(Boolean).join(' / '),
}); });
await loadData(); await loadData();
setManualOpen(false); setManualOpen(false);
setForm({ tenantId: tenants[0]?.id ?? '', amount: '', smsUnits: '0', operator: '运营', remark: '' }); setForm({ tenantId: tenants[0]?.id ?? '', amount: '', operator: '运营', remark: '' });
} catch (failure) { } catch (failure) {
setManualError(failure instanceof Error ? failure.message : '人工充值失败'); setManualError(failure instanceof Error ? failure.message : '人工充值失败');
} finally { } finally {
@@ -151,11 +148,11 @@ export function AdminRechargeRecordsPage() {
</thead> </thead>
<tbody> <tbody>
{error ? ( {error ? (
<tr><td className="ui-table__empty" colSpan={7}>{error}</td></tr> <tr><td className="ui-table__empty" colSpan={6}>{error}</td></tr>
) : loading ? ( ) : loading ? (
<tr><td className="ui-table__empty" colSpan={7}>...</td></tr> <tr><td className="ui-table__empty" colSpan={6}>...</td></tr>
) : filteredRows.length === 0 ? ( ) : filteredRows.length === 0 ? (
<tr><td className="ui-table__empty" colSpan={7}></td></tr> <tr><td className="ui-table__empty" colSpan={6}></td></tr>
) : visibleRows.map((record) => { ) : visibleRows.map((record) => {
const tenantName = record.tenant?.name ?? tenants.find((tenant) => tenant.id === record.tenantId)?.name ?? record.tenantId; const tenantName = record.tenant?.name ?? tenants.find((tenant) => tenant.id === record.tenantId)?.name ?? record.tenantId;
return ( return (
@@ -207,7 +204,6 @@ export function AdminRechargeRecordsPage() {
value={form.tenantId} value={form.tenantId}
/> />
<Input label="充值金额" onChange={(event) => updateForm('amount', event.target.value)} prefix="¥" required type="number" value={form.amount} /> <Input label="充值金额" onChange={(event) => updateForm('amount', event.target.value)} prefix="¥" required type="number" value={form.amount} />
<Input label="短信条数" onChange={(event) => updateForm('smsUnits', event.target.value)} type="number" value={form.smsUnits} />
<Input label="操作人" onChange={(event) => updateForm('operator', event.target.value)} required value={form.operator} /> <Input label="操作人" onChange={(event) => updateForm('operator', event.target.value)} required value={form.operator} />
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateForm('remark', event.target.value)} rows={4} value={form.remark} /> <Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateForm('remark', event.target.value)} rows={4} value={form.remark} />
</div> </div>
+39 -48
View File
@@ -1,76 +1,67 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { CreditCard } from 'lucide-react'; import { WalletCards } from 'lucide-react';
import { Button, Pagination, Tag } from '@/components/ui'; import { Pagination, Tag } from '@/components/ui';
import { clientApi, type BillingPlan } from '@/api/adminApi'; import { clientApi, type RechargeOrder } from '@/api/adminApi';
import { formatCents } from '@/utils/currency'; import { formatCents } from '@/utils/currency';
import { formatDateTime } from '@/utils/dateTime';
export function ClientBillingPage() { export function ClientBillingPage() {
const [plans, setPlans] = useState<BillingPlan[]>([]); const [balanceCents, setBalanceCents] = useState(0);
const [orders, setOrders] = useState<RechargeOrder[]>([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const pageSize = 10; const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(plans.length / pageSize)); const totalPages = Math.max(1, Math.ceil(orders.length / pageSize));
const currentPage = Math.min(page, totalPages); const currentPage = Math.min(page, totalPages);
const visiblePlans = plans.slice((currentPage - 1) * pageSize, currentPage * pageSize); const visibleOrders = orders.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
clientApi.listPlans() Promise.all([clientApi.getDashboard(), clientApi.listOrders()])
.then((items) => { .then(([dashboard, nextOrders]) => {
setPlans(items.filter((item) => item.status !== 'disabled' && item.status !== 'deleted')); setBalanceCents(dashboard.accounts[0]?.balanceCents ?? 0);
setOrders(nextOrders);
setError(''); setError('');
}) })
.catch((reason: Error) => setError(reason.message || '充值套餐加载失败')) .catch((reason: Error) => setError(reason.message || '账户信息加载失败'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
useEffect(() => {
setPage(1);
}, [plans.length]);
function createOrder(plan: BillingPlan) {
clientApi.createOrder({ planId: plan.id, amountCents: plan.amountCents, smsUnits: plan.smsUnits, payMethod: 'manual' })
.catch((reason: Error) => setError(reason.message || '充值订单创建失败'));
}
return ( return (
<section className="page-stack"> <section className="page-stack">
<div className="page-heading"> <div className="page-heading">
<div> <div><p className="eyebrow"></p><h1></h1></div>
<p className="eyebrow"></p> </div>
<h1></h1> <div className="dashboard-grid enterprise-summary-grid">
<div className="surface mini-status-card">
<WalletCards size={22} />
<div><span></span><strong>¥{formatCents(balanceCents)}</strong><small></small></div>
</div> </div>
</div> </div>
<div className="surface"> <div className="surface section-stack">
{loading ? <p className="muted">...</p> : null} <div className="section-heading"><div><h2></h2><p className="muted"></p></div><Tag tone="info">{orders.length} </Tag></div>
{loading ? <p className="muted">...</p> : null}
{error ? <p className="form-error">{error}</p> : null} {error ? <p className="form-error">{error}</p> : null}
<div className="plan-grid"> {!loading && !error ? (
{visiblePlans.map((plan) => ( <div className="ui-table-wrap">
<article className={['plan-card', plan.smsUnits >= 100000 ? 'plan-card--highlight' : ''].filter(Boolean).join(' ')} key={plan.id}> <table className="ui-table">
<div className="section-heading"> <thead><tr><th></th><th></th><th></th><th></th><th></th></tr></thead>
<h2>{plan.name}</h2> <tbody>
{plan.smsUnits >= 100000 ? <Tag tone="accent"></Tag> : null} {visibleOrders.length === 0 ? <tr><td className="ui-table__empty" colSpan={5}></td></tr> : visibleOrders.map((order) => (
</div> <tr key={order.id}>
<strong>{plan.smsUnits.toLocaleString('zh-CN')} </strong> <td><span className="table-mono-id">{order.orderNo}</span></td>
<p className="muted">{plan.description ?? '适合阶段性短信发送和活动通知。'}</p> <td>{formatDateTime(order.paidAt ?? order.createdAt)}</td>
<Button icon={<CreditCard size={16} />} onClick={() => createOrder(plan)} variant={plan.smsUnits >= 100000 ? 'primary' : 'ghost'}> <td>¥{formatCents(order.amountCents)}</td>
¥{formatCents(plan.amountCents)} <td><Tag tone={order.status === 'paid' ? 'success' : 'warning'}>{order.status === 'paid' ? '已入账' : order.status}</Tag></td>
</Button> <td>{order.remark || '-'}</td>
</article> </tr>
))} ))}
</tbody>
</table>
</div> </div>
<Pagination ) : null}
nextDisabled={currentPage >= totalPages} <Pagination nextDisabled={currentPage >= totalPages} onNext={() => setPage((value) => Math.min(totalPages, value + 1))} onPrevious={() => setPage((value) => Math.max(1, value - 1))} page={currentPage} totalPages={totalPages} onPageChange={setPage} previousDisabled={currentPage <= 1} total={orders.length} />
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={plans.length}
/>
{!loading && !error && plans.length === 0 ? <p className="muted"></p> : null}
</div> </div>
</section> </section>
); );
+2 -2
View File
@@ -50,7 +50,7 @@ export function ClientHome() {
}, []); }, []);
const account = dashboard?.accounts[0]; const account = dashboard?.accounts[0];
const availableBalance = ((account?.balanceCents ?? 0) + (account?.creditCents ?? 0)) / 100; const availableBalance = (account?.balanceCents ?? 0) / 100;
const todaySpend = (dashboard?.today.spendCents ?? 0) / 100; const todaySpend = (dashboard?.today.spendCents ?? 0) / 100;
const todayRefund = Math.abs((dashboard?.transactions._sum.amountCents ?? 0) < 0 ? 0 : dashboard?.transactions._sum.amountCents ?? 0) / 100; const todayRefund = Math.abs((dashboard?.transactions._sum.amountCents ?? 0) < 0 ? 0 : dashboard?.transactions._sum.amountCents ?? 0) / 100;
const balanceBaseline = Math.max(availableBalance + todaySpend - todayRefund, availableBalance, 1); const balanceBaseline = Math.max(availableBalance + todaySpend - todayRefund, availableBalance, 1);
@@ -145,7 +145,7 @@ export function ClientHome() {
</button> </button>
<button className="quick-action" onClick={() => navigate('/client/billing')} type="button"> <button className="quick-action" onClick={() => navigate('/client/billing')} type="button">
<WalletCards size={20} /> <WalletCards size={20} />
<span></span> <span></span>
<small></small> <small></small>
</button> </button>
</div> </div>
+1 -1
View File
@@ -4,7 +4,7 @@ const pageTitleMap: Record<string, string> = {
'/client/send': '短信发送', '/client/send': '短信发送',
'/client/templates': '模板管理', '/client/templates': '模板管理',
'/client/signatures': '签名与引流信息', '/client/signatures': '签名与引流信息',
'/client/billing': '充值套餐', '/client/billing': '账户余额',
'/client/settings': '账号设置', '/client/settings': '账号设置',
'/admin/monitor': '发送监控', '/admin/monitor': '发送监控',
'/admin/analytics': '数据统计', '/admin/analytics': '数据统计',
+1 -1
View File
@@ -64,7 +64,7 @@ export function ClientLayout() {
{ {
title: '账户', title: '账户',
items: [ items: [
{ label: '充值套餐', to: '/client/billing', icon: BadgeDollarSign }, { label: '账户余额', to: '/client/billing', icon: BadgeDollarSign },
], ],
}, },
{ {
+1 -7
View File
@@ -1,4 +1,4 @@
import type { BatchTask, BillingPlan, ClientOverview, Invoice, RecentMessage, Signature, SmsTemplate } from '@/mock/types'; import type { BatchTask, ClientOverview, Invoice, RecentMessage, Signature, SmsTemplate } from '@/mock/types';
export const clientOverview: ClientOverview = { export const clientOverview: ClientOverview = {
availableBalance: 28642.5, availableBalance: 28642.5,
@@ -70,12 +70,6 @@ export const signatures: Signature[] = [
{ id: 'SIG-103', name: '北辰出行', company: '深圳北辰出行服务有限公司', status: 'rejected' }, { id: 'SIG-103', name: '北辰出行', company: '深圳北辰出行服务有限公司', status: 'rejected' },
]; ];
export const billingPlans: BillingPlan[] = [
{ id: 'PLAN-01', name: '基础包', messages: 10000, price: 680 },
{ id: 'PLAN-02', name: '增长包', messages: 50000, price: 2980, highlight: true },
{ id: 'PLAN-03', name: '企业包', messages: 200000, price: 10800 },
];
export const invoices: Invoice[] = [ export const invoices: Invoice[] = [
{ id: 'INV-20260601', title: '增长包充值', amount: 2980, messages: 50000, status: 'paid', createdAt: '2026-06-01 10:12' }, { id: 'INV-20260601', title: '增长包充值', amount: 2980, messages: 50000, status: 'paid', createdAt: '2026-06-01 10:12' },
{ id: 'INV-20260518', title: '基础包充值', amount: 680, messages: 10000, status: 'paid', createdAt: '2026-05-18 14:26' }, { id: 'INV-20260518', title: '基础包充值', amount: 680, messages: 10000, status: 'paid', createdAt: '2026-05-18 14:26' },
+1 -5
View File
@@ -1,4 +1,4 @@
import { batchTasks, billingPlans, clientOverview, invoices, recentMessages, signatures, smsTemplates } from '@/mock/clientData'; import { batchTasks, clientOverview, invoices, recentMessages, signatures, smsTemplates } from '@/mock/clientData';
import { readLocalData, writeLocalData } from '@/mock/storage'; import { readLocalData, writeLocalData } from '@/mock/storage';
import type { BatchTask, RecentMessage, Signature, SmsTemplate } from '@/mock/types'; import type { BatchTask, RecentMessage, Signature, SmsTemplate } from '@/mock/types';
@@ -45,10 +45,6 @@ export const clientService = {
writeLocalData(keys.signatures, nextSignatures); writeLocalData(keys.signatures, nextSignatures);
}, },
getBillingPlans() {
return billingPlans;
},
getInvoices() { getInvoices() {
return invoices; return invoices;
}, },
-1
View File
@@ -8,7 +8,6 @@ export type {
BatchSendType, BatchSendType,
BatchTask, BatchTask,
BatchTaskStatus, BatchTaskStatus,
BillingPlan,
Channel, Channel,
ClientOverview, ClientOverview,
Customer, Customer,
-8
View File
@@ -75,14 +75,6 @@ export type Channel = {
enabled: boolean; enabled: boolean;
}; };
export type BillingPlan = {
id: string;
name: string;
messages: number;
price: number;
highlight?: boolean;
};
export type Invoice = { export type Invoice = {
id: string; id: string;
title: string; title: string;
+10
View File
@@ -19,6 +19,11 @@ OBJECT_STORAGE_LOCAL_ROOT="${OBJECT_STORAGE_LOCAL_ROOT:-/var/lib/cmpp-platform/o
PROD_ADMIN_EMAIL="${PROD_ADMIN_EMAIL:-admin@example.com}" PROD_ADMIN_EMAIL="${PROD_ADMIN_EMAIL:-admin@example.com}"
PROD_ADMIN_USERNAME="${PROD_ADMIN_USERNAME:-prod_admin}" PROD_ADMIN_USERNAME="${PROD_ADMIN_USERNAME:-prod_admin}"
PROD_ADMIN_PASSWORD="${PROD_ADMIN_PASSWORD:-$(openssl rand -base64 18 | tr -d '\n')}" PROD_ADMIN_PASSWORD="${PROD_ADMIN_PASSWORD:-$(openssl rand -base64 18 | tr -d '\n')}"
OPERATION_LOG_ARCHIVE_ENABLED="${OPERATION_LOG_ARCHIVE_ENABLED:-true}"
OPERATION_LOG_RETENTION_DAYS="${OPERATION_LOG_RETENTION_DAYS:-180}"
OPERATION_LOG_ARCHIVE_BATCH_SIZE="${OPERATION_LOG_ARCHIVE_BATCH_SIZE:-1000}"
OPERATION_LOG_ARCHIVE_MAX_BATCHES="${OPERATION_LOG_ARCHIVE_MAX_BATCHES:-20}"
OPERATION_LOG_ARCHIVE_INTERVAL_MS="${OPERATION_LOG_ARCHIVE_INTERVAL_MS:-86400000}"
if [[ "$(id -u)" -ne 0 ]]; then if [[ "$(id -u)" -ne 0 ]]; then
echo "Run as root." >&2 echo "Run as root." >&2
@@ -170,6 +175,11 @@ API_PORT=${API_PORT}
DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@127.0.0.1:5432/${DB_NAME}?schema=public DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@127.0.0.1:5432/${DB_NAME}?schema=public
REDIS_HOST=127.0.0.1 REDIS_HOST=127.0.0.1
REDIS_PORT=6379 REDIS_PORT=6379
OPERATION_LOG_ARCHIVE_ENABLED=${OPERATION_LOG_ARCHIVE_ENABLED}
OPERATION_LOG_RETENTION_DAYS=${OPERATION_LOG_RETENTION_DAYS}
OPERATION_LOG_ARCHIVE_BATCH_SIZE=${OPERATION_LOG_ARCHIVE_BATCH_SIZE}
OPERATION_LOG_ARCHIVE_MAX_BATCHES=${OPERATION_LOG_ARCHIVE_MAX_BATCHES}
OPERATION_LOG_ARCHIVE_INTERVAL_MS=${OPERATION_LOG_ARCHIVE_INTERVAL_MS}
MINIO_ENDPOINT=127.0.0.1:9000 MINIO_ENDPOINT=127.0.0.1:9000
MINIO_ACCESS_KEY=${MINIO_ROOT_USER} MINIO_ACCESS_KEY=${MINIO_ROOT_USER}
MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD} MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD}