feat: complete reporting and filing workflows
This commit is contained in:
@@ -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'),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
Reference in New Issue
Block a user