feat: complete reporting and filing workflows

This commit is contained in:
hectorzhao
2026-07-28 20:28:47 +08:00
parent 352a6293b4
commit 99c8c7c68b
52 changed files with 3490 additions and 376 deletions
@@ -99,6 +99,21 @@ export class AdminOperationsController {
return this.operations.sendQuality(date);
}
@Get('signature-quality')
signatureQuality(
@Query('date') date?: string,
@Query('keyword') keyword?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.operations.signatureQuality({
date,
keyword,
page: Number(page),
pageSize: Number(pageSize),
});
}
@Get('audit-logs')
auditLogs(
@Query('tenantId') tenantId?: string,
@@ -211,6 +226,8 @@ export class AdminOperationsController {
@Query('state') state?: string,
@Query('failureCategory') failureCategory?: string,
@Query('keyword') keyword?: string,
@Query('updatedAtFrom') updatedAtFrom?: string,
@Query('updatedAtTo') updatedAtTo?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
@@ -220,6 +237,8 @@ export class AdminOperationsController {
state,
failureCategory,
keyword,
updatedAtFrom,
updatedAtTo,
page: Number(page),
pageSize: Number(pageSize),
});
@@ -232,6 +251,8 @@ export class AdminOperationsController {
@Query('state') state: string | undefined,
@Query('failureCategory') failureCategory: string | undefined,
@Query('keyword') keyword: string | undefined,
@Query('updatedAtFrom') updatedAtFrom: string | undefined,
@Query('updatedAtTo') updatedAtTo: string | undefined,
@Res() response: DownloadResponse,
) {
const exported = await this.operations.exportDownstreamRecoveryStatuses({
@@ -240,6 +261,8 @@ export class AdminOperationsController {
state,
failureCategory,
keyword,
updatedAtFrom,
updatedAtTo,
});
response.setHeader('Content-Type', 'text/csv; charset=utf-8');
response.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(exported.fileName)}`);
@@ -521,6 +521,96 @@ describe('OperationsService', () => {
await expect(service.sendQuality('2026-02-31')).rejects.toThrow('统计日期无效');
});
it('returns paged registered-signature quality with channel and carrier breakdowns', async () => {
const prisma = createPrismaMock();
prisma.$queryRaw
.mockResolvedValueOnce([{
signatureId: 'signature-1',
signatureName: '【测试签名】',
tenantId: 'tenant-1',
tenantName: '租户A',
applicationNames: '通知应用、营销应用',
total: 5,
acceptedCount: 4,
submitFailureCount: 1,
successCount: 3,
unknownCount: 1,
failureCount: 0,
successRate: 75,
averageArrivalMs: 1200,
rowCount: 12,
}])
.mockResolvedValueOnce([
{
signatureId: 'signature-1',
channelId: 'channel-1',
channelName: '通道一',
carrier: 'mobile',
total: 4,
acceptedCount: 4,
submitFailureCount: 0,
successCount: 3,
unknownCount: 1,
failureCount: 0,
successRate: 75,
averageArrivalMs: 1200,
},
{
signatureId: 'signature-1',
channelId: 'channel-2',
channelName: '通道二',
carrier: 'telecom',
total: 2,
acceptedCount: 1,
submitFailureCount: 1,
successCount: 1,
unknownCount: 0,
failureCount: 0,
successRate: 100,
averageArrivalMs: 1800,
},
]);
const service = new OperationsService(prisma as never);
await expect(service.signatureQuality({
date: '2026-07-24',
keyword: '测试',
page: 2,
pageSize: 5,
})).resolves.toEqual({
date: '2026-07-24',
items: [expect.objectContaining({
signatureId: 'signature-1',
signatureName: '【测试签名】',
total: 5,
channelSubmitTotal: 6,
breakdowns: [
expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', total: 4 }),
expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', total: 2 }),
],
})],
total: 12,
page: 2,
pageSize: 5,
});
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2);
});
it('does not query channel details when the selected date has no registered signatures', async () => {
const prisma = createPrismaMock();
prisma.$queryRaw.mockResolvedValueOnce([]);
const service = new OperationsService(prisma as never);
await expect(service.signatureQuality({ date: '2026-07-24' })).resolves.toEqual({
date: '2026-07-24',
items: [],
total: 0,
page: 1,
pageSize: 10,
});
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
});
it('returns trace details and reconciliation diffs', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
@@ -813,6 +903,8 @@ describe('OperationsService', () => {
state: 'waiting_connection',
failureCategory: 'client_disconnected',
keyword: '100001',
updatedAtFrom: '2026-07-02',
updatedAtTo: '2026-07-08',
page: 1,
pageSize: 10,
})).resolves.toEqual({
@@ -832,6 +924,14 @@ describe('OperationsService', () => {
],
},
});
expect(prisma.gatewayDownstreamRecoveryStatus.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
updatedAt: {
gte: new Date('2026-07-01T16:00:00.000Z'),
lte: new Date('2026-07-08T15:59:59.999Z'),
},
}),
}));
});
it('returns downstream recovery status detail', async () => {
@@ -860,6 +960,8 @@ describe('OperationsService', () => {
tenantId: 'tenant-1',
state: 'waiting_connection',
keyword: '100001',
updatedAtFrom: '2026-07-02',
updatedAtTo: '2026-07-08',
})).resolves.toEqual(expect.objectContaining({
total: 1,
fileName: expect.stringMatching(/^gateway-downstream-recovery-statuses-\d{8}-\d{6}\.csv$/),
@@ -868,6 +970,10 @@ describe('OperationsService', () => {
expect(prisma.gatewayDownstreamRecoveryStatus.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
failureCategory: undefined,
updatedAt: {
gte: new Date('2026-07-01T16:00:00.000Z'),
lte: new Date('2026-07-08T15:59:59.999Z'),
},
}),
}));
});
+236 -1
View File
@@ -69,6 +69,8 @@ export interface DownstreamRecoveryStatusQuery {
state?: string;
failureCategory?: string;
keyword?: string;
updatedAtFrom?: string;
updatedAtTo?: string;
page?: number;
pageSize?: number;
}
@@ -78,6 +80,13 @@ export interface MessageSegmentAuditQuery {
messageRecordId?: string;
}
export interface SignatureQualityQuery {
date?: string;
keyword?: string;
page?: number;
pageSize?: number;
}
@Injectable()
export class OperationsService {
constructor(private readonly prisma: PrismaService) {}
@@ -274,7 +283,8 @@ export class OperationsService {
ON billing."tenantId" = tenant.id
AND billing."createdAt" >= ${businessDay.startAt}
AND billing."createdAt" < ${businessDay.endAt}
WHERE (${query.tenantId ?? null}::text IS NULL OR tenant.id = ${query.tenantId ?? null})
WHERE tenant.status <> 'deleted'
AND (${query.tenantId ?? null}::text IS NULL OR tenant.id = ${query.tenantId ?? null})
GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents"
ORDER BY "todaySpendCents" DESC, tenant.name ASC
`),
@@ -675,6 +685,228 @@ export class OperationsService {
return { date: day.key, summary, channels, signatures, applications };
}
async signatureQuality(query: SignatureQualityQuery) {
const day = qualityBusinessDay(query.date);
const page = positiveInteger(query.page, 1);
const pageSize = Math.min(50, positiveInteger(query.pageSize, 10));
const keyword = query.keyword?.trim() || null;
const keywordPattern = keyword ? `%${keyword}%` : null;
const summaries = await this.prisma.$queryRaw<Array<{
signatureId: string;
signatureName: string;
tenantId: string;
tenantName: string;
applicationNames: string | null;
total: number;
acceptedCount: number;
submitFailureCount: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
rowCount: number;
}>>(Prisma.sql`
WITH base AS (
SELECT
message."signatureId" AS signature_id,
message."applicationId" AS application_id,
message.status,
message."submitStatus" AS submit_status,
message."receiptStatus" AS receipt_status,
CASE
WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
AND message."submittedAt" IS NOT NULL
AND message."deliveredAt" >= message."submittedAt"
THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000
END AS arrival_ms
FROM "SmsMessageRecord" message
WHERE message."signatureId" IS NOT NULL
AND message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
)
SELECT
signature.id AS "signatureId",
signature.name AS "signatureName",
tenant.id AS "tenantId",
tenant.name AS "tenantName",
STRING_AGG(DISTINCT application.name, '、') FILTER (WHERE application.name IS NOT NULL) AS "applicationNames",
COUNT(*)::integer AS total,
COUNT(*) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
)::integer AS "acceptedCount",
COUNT(*) FILTER (
WHERE base.status = 'submit_failed'
OR base.submit_status IN ('rejected', 'timeout')
)::integer AS "submitFailureCount",
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
COUNT(*) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
AND NOT (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
)::integer AS "unknownCount",
COUNT(*) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
AND (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
)::integer AS "failureCount",
CASE
WHEN COUNT(*) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
) = 0 THEN 0
ELSE ROUND(
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
* 100.0
/ COUNT(*) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
),
1
)::double precision
END AS "successRate",
ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs",
COUNT(*) OVER()::integer AS "rowCount"
FROM base
JOIN "SmsSignature" signature ON signature.id = base.signature_id
JOIN "Tenant" tenant ON tenant.id = signature."tenantId"
LEFT JOIN "SmsApplication" application ON application.id = base.application_id
WHERE (
${keyword}::text IS NULL
OR signature.name ILIKE ${keywordPattern}
OR tenant.name ILIKE ${keywordPattern}
OR application.name ILIKE ${keywordPattern}
)
GROUP BY signature.id, signature.name, tenant.id, tenant.name
ORDER BY total DESC, signature.name
LIMIT ${pageSize}
OFFSET ${(page - 1) * pageSize}
`);
const signatureIds = summaries.map((item) => item.signatureId);
const breakdowns = signatureIds.length === 0
? []
: await this.prisma.$queryRaw<Array<{
signatureId: string;
channelId: string;
channelName: string;
carrier: string;
total: number;
acceptedCount: number;
submitFailureCount: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
}>>(Prisma.sql`
WITH base AS (
SELECT
message."signatureId" AS signature_id,
submit."channelId" AS channel_id,
channel.name AS channel_name,
COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier,
submit."submitStatus" AS submit_status,
receipt."deliveredAt" AS delivered_at,
failed_receipt."failedAt" AS failed_at,
COALESCE(segment_summary.segment_count, 0) AS segment_count,
COALESCE(segment_summary.delivered_count, 0) AS segment_delivered_count,
COALESCE(segment_summary.failure_count, 0) AS segment_failure_count,
CASE
WHEN segment_summary.segment_count > 0
AND segment_summary.delivered_count = segment_summary.segment_count
AND segment_summary.completed_at >= COALESCE(submit."submittedAt", submit."createdAt")
THEN EXTRACT(EPOCH FROM (segment_summary.completed_at - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
WHEN segment_summary.segment_count = 0
AND receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt")
THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
END AS arrival_ms
FROM "SmsSubmitRecord" submit
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
LEFT JOIN LATERAL (
SELECT
COUNT(*)::integer AS segment_count,
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count,
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count,
MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at
FROM "SmsMessageSegmentAudit" segment
WHERE segment."submitRecordId" = submit.id
) segment_summary ON TRUE
LEFT JOIN LATERAL (
SELECT MIN(receipt."deliveredAt") AS "deliveredAt"
FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'delivered'
) receipt ON TRUE
LEFT JOIN LATERAL (
SELECT MIN(receipt."deliveredAt") AS "failedAt"
FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'undelivered'
) failed_receipt ON TRUE
WHERE message."signatureId" IN (${Prisma.join(signatureIds)})
AND submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt}
), classified AS (
SELECT
*,
CASE
WHEN submit_status <> 'accepted' THEN 'submit_failed'
WHEN segment_count > 0 AND segment_failure_count > 0 THEN 'failure'
WHEN segment_count > 0 AND segment_delivered_count = segment_count THEN 'success'
WHEN segment_count = 0 AND failed_at IS NOT NULL THEN 'failure'
WHEN segment_count = 0 AND delivered_at IS NOT NULL THEN 'success'
ELSE 'unknown'
END AS delivery_status
FROM base
)
SELECT
signature_id AS "signatureId",
channel_id AS "channelId",
MAX(channel_name) AS "channelName",
carrier,
COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedCount",
COUNT(*) FILTER (WHERE delivery_status = 'submit_failed')::integer AS "submitFailureCount",
COUNT(*) FILTER (WHERE delivery_status = 'success')::integer AS "successCount",
COUNT(*) FILTER (WHERE delivery_status = 'unknown')::integer AS "unknownCount",
COUNT(*) FILTER (WHERE delivery_status = 'failure')::integer AS "failureCount",
CASE
WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0
ELSE ROUND(
COUNT(*) FILTER (WHERE delivery_status = 'success')
* 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'),
1
)::double precision
END AS "successRate",
ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
FROM classified
GROUP BY signature_id, channel_id, carrier
ORDER BY signature_id, COUNT(*) DESC, channel_id, carrier
`);
const items = summaries.map(({ rowCount: _rowCount, ...summary }) => {
const signatureBreakdowns = breakdowns.filter((item) => item.signatureId === summary.signatureId);
return {
...summary,
channelSubmitTotal: signatureBreakdowns.reduce((sum, item) => sum + item.total, 0),
breakdowns: signatureBreakdowns,
};
});
return {
date: day.key,
items,
total: summaries[0]?.rowCount ?? 0,
page,
pageSize,
};
}
async auditLogs(query: { tenantId?: string; userId?: string; page?: number; pageSize?: number }) {
const page = positiveInteger(query.page, 1);
const pageSize = Math.min(100, positiveInteger(query.pageSize, 20));
@@ -1420,11 +1652,14 @@ function parseDateBoundary(value?: string, endOfDay = false) {
}
function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQuery) {
const updatedAtFrom = parseDateBoundary(query.updatedAtFrom, false);
const updatedAtTo = parseDateBoundary(query.updatedAtTo, true);
return {
tenantId: query.tenantId,
applicationId: query.applicationId,
state: query.state && query.state !== 'all' ? query.state : undefined,
failureCategory: query.failureCategory && query.failureCategory !== 'all' ? query.failureCategory : undefined,
updatedAt: updatedAtFrom || updatedAtTo ? { gte: updatedAtFrom, lte: updatedAtTo } : undefined,
OR: query.keyword ? [
{ account: { contains: query.keyword } },
{ gatewayInstanceId: { contains: query.keyword } },