fix: simplify balance billing and govern operation logs
This commit is contained in:
@@ -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
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 ?? '');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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. 企业签名和企业模板运营端列表只展示真实短信配置数据;彩信签名、彩信模板、彩信通道、彩信记录、彩信任务进度等仍归入待开发,不得用静态样例作为第一版短信验收结果。
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
|||||||
@@ -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 为 4;unknown=2;成功率若按 delivered/total 为 62.5%;点击卡片后的明细筛选一致。 |
|
| TC-DASHBOARD-001 | 客户 A 当天 delivered=10、failed=3、unknown=2、timeout=1;客户 B 有干扰数据。 | 客户端总量=16;成功=10;失败按 failed+timeout 为 4;unknown=2;成功率若按 delivered/total 为 62.5%;点击卡片后的明细筛选一致。 |
|
||||||
| TC-DASHBOARD-002 | 账户余额 10000 分、套餐 200 条、授信 5000 分,另有冻结、扣费、释放、退款流水。 | 可用余额不重复计算冻结;金额余额和套餐余量分开展示;账单流水余额 after 与 Dashboard 一致。 |
|
| TC-DASHBOARD-002 | 现金余额 10000 分,另有冻结、扣费、释放、退款流水。 | 可用额度仅为现金余额且不重复计算冻结;账单流水 balanceAfter 与 Dashboard 一致。 |
|
||||||
| TC-DASHBOARD-003 | 待审核签名 2、模板 3、待报备 1、pending_review 发送任务 4。 | 待处理总数和分类数准确;点击跳转后列表筛选数量一致;只包含当前租户。 |
|
| TC-DASHBOARD-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_topup;TenantAccount 同步增加;AccountTransaction 类型 recharge;充值记录“充值后余额”必须等于该订单关联 AccountTransaction.balanceAfter,不能用当前账户余额替代;运营日志和客户端流水均可追溯。 |
|
| TC-BILLING-006 | 运营端人工充值现金金额,客户端查看 Dashboard 和充值记录。 | RechargeOrder 状态为 paid/manual_topup;TenantAccount 现金余额同步增加;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 和宽屏视口打开企业短信模板页。 | 模板卡片自适应换列,页面不出现水平滚动,每张卡片的编辑和删除按钮直接可见。 |
|
||||||
|
|||||||
@@ -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
@@ -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) =>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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': '数据统计',
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function ClientLayout() {
|
|||||||
{
|
{
|
||||||
title: '账户',
|
title: '账户',
|
||||||
items: [
|
items: [
|
||||||
{ label: '充值套餐', to: '/client/billing', icon: BadgeDollarSign },
|
{ label: '账户余额', to: '/client/billing', icon: BadgeDollarSign },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export type {
|
|||||||
BatchSendType,
|
BatchSendType,
|
||||||
BatchTask,
|
BatchTask,
|
||||||
BatchTaskStatus,
|
BatchTaskStatus,
|
||||||
BillingPlan,
|
|
||||||
Channel,
|
Channel,
|
||||||
ClientOverview,
|
ClientOverview,
|
||||||
Customer,
|
Customer,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user