feat: add operations acceptance phase

This commit is contained in:
hectorzhao
2026-07-01 13:46:54 +08:00
parent 27bb2d798a
commit 924457a48e
11 changed files with 692 additions and 2 deletions
+2
View File
@@ -7,6 +7,7 @@ import { ChannelsModule } from './channels/channels.module';
import { DictionariesModule } from './dictionaries/dictionaries.module';
import { FilesModule } from './files/files.module';
import { HealthController } from './health.controller';
import { OperationsModule } from './operations/operations.module';
import { PrismaModule } from './prisma/prisma.module';
import { RiskReviewModule } from './risk-review/risk-review.module';
import { SendChainModule } from './send-chain/send-chain.module';
@@ -32,6 +33,7 @@ import { UsersModule } from './users/users.module';
ChannelsModule,
RiskReviewModule,
SendChainModule,
OperationsModule,
],
controllers: [HealthController],
})
@@ -0,0 +1,74 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { OperationsService } from './operations.service';
@ApiTags('operations')
@Controller('admin/operations')
export class AdminOperationsController {
constructor(private readonly operations: OperationsService) {}
@Get('monitor')
monitor(@Query('tenantId') tenantId?: string, @Query('channelId') channelId?: string) {
return this.operations.monitor({ tenantId, channelId });
}
@Get('task-progress')
taskProgress(@Query('tenantId') tenantId?: string, @Query('status') status?: string) {
return this.operations.listBatchTasks({ tenantId, status });
}
@Get('messages')
listMessages(
@Query('tenantId') tenantId?: string,
@Query('applicationId') applicationId?: string,
@Query('channelId') channelId?: string,
@Query('taskId') taskId?: string,
@Query('phoneNumber') phoneNumber?: string,
@Query('status') status?: string,
) {
return this.operations.listMessages({ tenantId, applicationId, channelId, taskId, phoneNumber, status });
}
@Get('uplink-messages')
listUplinkMessages(@Query('tenantId') tenantId?: string, @Query('channelId') channelId?: string) {
return this.operations.listUplinkMessages({ tenantId, channelId });
}
@Get('dashboard')
dashboard(@Query('tenantId') tenantId?: string) {
return this.operations.dashboard({ tenantId });
}
@Get('statistics')
statistics(@Query('tenantId') tenantId?: string, @Query('groupBy') groupBy?: string) {
return this.operations.statistics({ tenantId, groupBy });
}
@Get('audit-logs')
auditLogs(@Query('tenantId') tenantId?: string, @Query('userId') userId?: string) {
return this.operations.auditLogs({ tenantId, userId });
}
@Get('audit-summary')
auditSummary(@Query('tenantId') tenantId?: string) {
return this.operations.auditSummary({ tenantId });
}
@Get('trace')
trace(
@Query('tenantId') tenantId?: string,
@Query('applicationId') applicationId?: string,
@Query('channelId') channelId?: string,
@Query('taskId') taskId?: string,
@Query('phoneNumber') phoneNumber?: string,
@Query('messageId') messageId?: string,
) {
return this.operations.trace({ tenantId, applicationId, channelId, taskId, phoneNumber, messageId });
}
@Get('reconciliation')
reconciliation(@Query('tenantId') tenantId?: string, @Query('taskId') taskId?: string) {
return this.operations.reconciliation({ tenantId, taskId });
}
}
@@ -0,0 +1,26 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import { OperationsService } from './operations.service';
@ApiTags('client-operations')
@Controller('client/operations')
export class ClientOperationsController {
constructor(private readonly operations: OperationsService) {}
@Get('batch-tasks')
listBatchTasks(@TenantId() tenantId?: string, @Query('status') status?: string) {
return this.operations.listBatchTasks({ tenantId, status });
}
@Get('batch-tasks/:id/messages')
listTaskMessages(@Param('id') taskId: string, @Query('phoneNumber') phoneNumber?: string) {
return this.operations.listMessages({ taskId, phoneNumber });
}
@Get('uplink-messages')
listUplinkMessages(@TenantId() tenantId?: string, @Query('channelId') channelId?: string) {
return this.operations.listUplinkMessages({ tenantId, channelId });
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { AdminOperationsController } from './admin-operations.controller';
import { ClientOperationsController } from './client-operations.controller';
import { OperationsService } from './operations.service';
@Module({
imports: [PrismaModule],
controllers: [AdminOperationsController, ClientOperationsController],
providers: [OperationsService],
exports: [OperationsService],
})
export class OperationsModule {}
+241
View File
@@ -0,0 +1,241 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export interface MessageQuery {
tenantId?: string;
applicationId?: string;
channelId?: string;
taskId?: string;
phoneNumber?: string;
status?: string;
}
export interface TraceQuery extends MessageQuery {
messageId?: string;
}
@Injectable()
export class OperationsService {
constructor(private readonly prisma: PrismaService) {}
listBatchTasks(query: { tenantId?: string; status?: string }) {
return this.prisma.smsBatchTask.findMany({
where: { tenantId: query.tenantId, status: query.status },
include: { apiRequests: true },
orderBy: { createdAt: 'desc' },
take: 200,
});
}
listMessages(query: MessageQuery) {
return this.prisma.smsMessageRecord.findMany({
where: messageWhere(query),
include: { submitRecords: true, receiptRecords: true },
orderBy: { queuedAt: 'desc' },
take: 500,
});
}
listUplinkMessages(query: { tenantId?: string; channelId?: string }) {
return this.prisma.smsUplinkMessage.findMany({
where: { tenantId: query.tenantId, channelId: query.channelId },
orderBy: { receivedAt: 'desc' },
take: 500,
});
}
async monitor(query: { tenantId?: string; channelId?: string }) {
const where = messageWhere(query);
const [byStatus, recentMessages, recentReceipts, recentUplinks] = await Promise.all([
this.prisma.smsMessageRecord.groupBy({ by: ['status'], where, _count: { _all: true } }),
this.prisma.smsMessageRecord.findMany({
where,
include: { submitRecords: true, receiptRecords: true },
orderBy: { queuedAt: 'desc' },
take: 20,
}),
this.prisma.smsReceiptRecord.findMany({
where: { tenantId: query.tenantId, channelId: query.channelId },
orderBy: { createdAt: 'desc' },
take: 20,
}),
this.listUplinkMessages({ tenantId: query.tenantId, channelId: query.channelId }),
]);
return {
byStatus,
recentMessages,
recentReceipts,
recentUplinks: recentUplinks.slice(0, 20),
};
}
async dashboard(query: { tenantId?: string }) {
const messageWhereClause = messageWhere({ tenantId: query.tenantId });
const [taskCount, messageGroups, uplinkCount, billingAggregate, transactionAggregate] = await Promise.all([
this.prisma.smsBatchTask.count({ where: { tenantId: query.tenantId } }),
this.prisma.smsMessageRecord.groupBy({
by: ['status'],
where: messageWhereClause,
_count: { _all: true },
_sum: { amountCents: true, billingUnits: true },
}),
this.prisma.smsUplinkMessage.count({ where: { tenantId: query.tenantId } }),
this.prisma.smsBillingRecord.aggregate({
where: { tenantId: query.tenantId },
_sum: { amountCents: true, billingUnits: true },
_count: { _all: true },
}),
this.prisma.accountTransaction.aggregate({
where: { tenantId: query.tenantId },
_sum: { amountCents: true, smsUnits: true },
_count: { _all: true },
}),
]);
return {
taskCount,
messageStatus: messageGroups,
uplinkCount,
billing: billingAggregate,
transactions: transactionAggregate,
};
}
async statistics(query: { tenantId?: string; groupBy?: string }) {
const groupBy = normalizeGroupBy(query.groupBy);
if (groupBy === 'tenantId') {
return this.prisma.smsMessageRecord.groupBy({
by: ['tenantId'],
where: messageWhere({ tenantId: query.tenantId }),
_count: { _all: true },
_sum: { amountCents: true, billingUnits: true },
});
}
if (groupBy === 'applicationId') {
return this.prisma.smsMessageRecord.groupBy({
by: ['applicationId'],
where: messageWhere({ tenantId: query.tenantId }),
_count: { _all: true },
_sum: { amountCents: true, billingUnits: true },
});
}
return this.prisma.smsMessageRecord.groupBy({
by: ['channelId'],
where: messageWhere({ tenantId: query.tenantId }),
_count: { _all: true },
_sum: { amountCents: true, billingUnits: true },
});
}
auditLogs(query: { tenantId?: string; userId?: string }) {
return this.prisma.operationLog.findMany({
where: { tenantId: query.tenantId, userId: query.userId },
orderBy: { createdAt: 'desc' },
take: 500,
});
}
auditSummary(query: { tenantId?: string }) {
return this.prisma.operationLog.groupBy({
by: ['action', 'resource'],
where: { tenantId: query.tenantId },
_count: { _all: true },
orderBy: { _count: { action: 'desc' } },
take: 100,
});
}
async trace(query: TraceQuery) {
const messages = await this.prisma.smsMessageRecord.findMany({
where: {
...messageWhere(query),
messageId: query.messageId,
},
include: {
batchTask: { include: { apiRequests: true } },
submitRecords: { include: { session: true } },
receiptRecords: true,
},
orderBy: { queuedAt: 'desc' },
take: 100,
});
const messageIds = messages.map((message) => message.messageId);
const [billingRecords, uplinks] = await Promise.all([
this.prisma.smsBillingRecord.findMany({
where: {
tenantId: query.tenantId,
taskId: query.taskId,
messageId: messageIds.length > 0 ? { in: messageIds } : undefined,
},
orderBy: { createdAt: 'desc' },
}),
this.prisma.smsUplinkMessage.findMany({
where: {
tenantId: query.tenantId,
messageId: messageIds.length > 0 ? { in: messageIds } : undefined,
},
orderBy: { receivedAt: 'desc' },
}),
]);
return { messages, billingRecords, uplinks };
}
async reconciliation(query: { tenantId?: string; taskId?: string }) {
const [messages, billing, transactions] = await Promise.all([
this.prisma.smsMessageRecord.aggregate({
where: messageWhere({ tenantId: query.tenantId, taskId: query.taskId }),
_count: { _all: true },
_sum: { amountCents: true, billingUnits: true },
}),
this.prisma.smsBillingRecord.aggregate({
where: { tenantId: query.tenantId, taskId: query.taskId },
_count: { _all: true },
_sum: { amountCents: true, billingUnits: true },
}),
this.prisma.accountTransaction.aggregate({
where: {
tenantId: query.tenantId,
relatedType: query.taskId ? { in: ['sms_batch_task', 'sms_message_record'] } : undefined,
relatedId: query.taskId,
},
_count: { _all: true },
_sum: { amountCents: true, smsUnits: true },
}),
]);
const messageAmount = messages._sum.amountCents ?? 0;
const billingAmount = billing._sum.amountCents ?? 0;
const transactionAmount = transactions._sum.amountCents ?? 0;
return {
messages,
billing,
transactions,
diff: {
messageVsBillingAmountCents: messageAmount - billingAmount,
billingVsTransactionAmountCents: billingAmount + transactionAmount,
messageVsBillingUnits: (messages._sum.billingUnits ?? 0) - (billing._sum.billingUnits ?? 0),
},
};
}
}
function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
return {
tenantId: query.tenantId,
applicationId: query.applicationId,
channelId: query.channelId,
batchTaskId: query.taskId,
phoneNumber: query.phoneNumber,
status: query.status,
};
}
function normalizeGroupBy(groupBy?: string) {
if (groupBy === 'tenant' || groupBy === 'tenantId') {
return 'tenantId';
}
if (groupBy === 'application' || groupBy === 'applicationId') {
return 'applicationId';
}
return 'channelId';
}
File diff suppressed because one or more lines are too long