fix: align daily operations statistics
This commit is contained in:
@@ -222,11 +222,38 @@ describe('OperationsService', () => {
|
|||||||
lte: new Date('2026-07-02T23:59:59.999+08:00'),
|
lte: new Date('2026-07-02T23:59:59.999+08:00'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: { tenant: true, application: true, channel: true, submitRecords: true, receiptRecords: true },
|
include: {
|
||||||
|
tenant: true,
|
||||||
|
application: true,
|
||||||
|
channel: true,
|
||||||
|
submitRecords: true,
|
||||||
|
receiptRecords: true,
|
||||||
|
downstreamDeliveries: {
|
||||||
|
where: { deliveryType: 'receipt' },
|
||||||
|
select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
orderBy: { queuedAt: 'desc' },
|
orderBy: { queuedAt: 'desc' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('separates upstream submit failures from post-acceptance delivery failures', async () => {
|
||||||
|
const prisma = createPrismaMock();
|
||||||
|
const service = new OperationsService(prisma as never);
|
||||||
|
|
||||||
|
await service.listMessages({ status: 'submit_failed' });
|
||||||
|
expect(prisma.smsMessageRecord.findMany).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await service.listMessages({ status: 'failed' });
|
||||||
|
expect(prisma.smsMessageRecord.findMany).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||||
|
where: expect.objectContaining({ status: 'failed', submitStatus: 'accepted' }),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it('returns uplink messages with tenant and channel display data', async () => {
|
it('returns uplink messages with tenant and channel display data', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new OperationsService(prisma as never);
|
const service = new OperationsService(prisma as never);
|
||||||
@@ -399,6 +426,9 @@ describe('OperationsService', () => {
|
|||||||
channelId: 'channel-1',
|
channelId: 'channel-1',
|
||||||
channelName: '通道一',
|
channelName: '通道一',
|
||||||
total: 5,
|
total: 5,
|
||||||
|
acceptedCount: 4,
|
||||||
|
submitFailureCount: 1,
|
||||||
|
submitFailureRate: 20,
|
||||||
successCount: 3,
|
successCount: 3,
|
||||||
unknownCount: 1,
|
unknownCount: 1,
|
||||||
failureCount: 1,
|
failureCount: 1,
|
||||||
@@ -420,15 +450,48 @@ describe('OperationsService', () => {
|
|||||||
failureCount: 1,
|
failureCount: 1,
|
||||||
successRate: 60,
|
successRate: 60,
|
||||||
averageArrivalMs: 1200,
|
averageArrivalMs: 1200,
|
||||||
|
}])
|
||||||
|
.mockResolvedValueOnce([{
|
||||||
|
total: 5,
|
||||||
|
successCount: 3,
|
||||||
|
unknownCount: 1,
|
||||||
|
failureCount: 1,
|
||||||
|
successRate: 60,
|
||||||
|
}])
|
||||||
|
.mockResolvedValueOnce([{
|
||||||
|
applicationId: 'app-1',
|
||||||
|
applicationName: '通知应用',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
tenantName: '租户A',
|
||||||
|
total: 5,
|
||||||
|
successCount: 3,
|
||||||
|
unknownCount: 1,
|
||||||
|
failureCount: 1,
|
||||||
|
successRate: 60,
|
||||||
}]);
|
}]);
|
||||||
const service = new OperationsService(prisma as never);
|
const service = new OperationsService(prisma as never);
|
||||||
|
|
||||||
await expect(service.sendQuality('2026-07-24')).resolves.toEqual({
|
await expect(service.sendQuality('2026-07-24')).resolves.toEqual({
|
||||||
date: '2026-07-24',
|
date: '2026-07-24',
|
||||||
channels: [expect.objectContaining({ channelId: 'channel-1', total: 5, successRate: 60 })],
|
summary: {
|
||||||
|
total: 5,
|
||||||
|
successCount: 3,
|
||||||
|
unknownCount: 1,
|
||||||
|
failureCount: 1,
|
||||||
|
successRate: 60,
|
||||||
|
},
|
||||||
|
channels: [expect.objectContaining({
|
||||||
|
channelId: 'channel-1',
|
||||||
|
total: 5,
|
||||||
|
acceptedCount: 4,
|
||||||
|
submitFailureCount: 1,
|
||||||
|
submitFailureRate: 20,
|
||||||
|
successRate: 60,
|
||||||
|
})],
|
||||||
signatures: [expect.objectContaining({ signatureId: 'signature-1', signatureName: '【测试签名】', hasDrainage: false })],
|
signatures: [expect.objectContaining({ signatureId: 'signature-1', signatureName: '【测试签名】', hasDrainage: false })],
|
||||||
|
applications: [expect.objectContaining({ applicationId: 'app-1', applicationName: '通知应用', tenantName: '租户A', total: 5 })],
|
||||||
});
|
});
|
||||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2);
|
expect(prisma.$queryRaw).toHaveBeenCalledTimes(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid send quality dates', async () => {
|
it('rejects invalid send quality dates', async () => {
|
||||||
|
|||||||
@@ -98,7 +98,17 @@ export class OperationsService {
|
|||||||
listMessages(query: MessageQuery) {
|
listMessages(query: MessageQuery) {
|
||||||
return this.prisma.smsMessageRecord.findMany({
|
return this.prisma.smsMessageRecord.findMany({
|
||||||
where: messageWhere(query),
|
where: messageWhere(query),
|
||||||
include: { tenant: true, application: true, channel: true, submitRecords: true, receiptRecords: true },
|
include: {
|
||||||
|
tenant: true,
|
||||||
|
application: true,
|
||||||
|
channel: true,
|
||||||
|
submitRecords: true,
|
||||||
|
receiptRecords: true,
|
||||||
|
downstreamDeliveries: {
|
||||||
|
where: { deliveryType: 'receipt' },
|
||||||
|
select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
orderBy: { queuedAt: 'desc' },
|
orderBy: { queuedAt: 'desc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -356,11 +366,14 @@ export class OperationsService {
|
|||||||
|
|
||||||
async sendQuality(date?: string) {
|
async sendQuality(date?: string) {
|
||||||
const day = qualityBusinessDay(date);
|
const day = qualityBusinessDay(date);
|
||||||
const [channels, signatures] = await Promise.all([
|
const [channels, signatures, summaryRows, applications] = await Promise.all([
|
||||||
this.prisma.$queryRaw<Array<{
|
this.prisma.$queryRaw<Array<{
|
||||||
channelId: string;
|
channelId: string;
|
||||||
channelName: string;
|
channelName: string;
|
||||||
total: number;
|
total: number;
|
||||||
|
acceptedCount: number;
|
||||||
|
submitFailureCount: number;
|
||||||
|
submitFailureRate: number;
|
||||||
successCount: number;
|
successCount: number;
|
||||||
unknownCount: number;
|
unknownCount: number;
|
||||||
failureCount: number;
|
failureCount: number;
|
||||||
@@ -373,14 +386,32 @@ export class OperationsService {
|
|||||||
SELECT
|
SELECT
|
||||||
submit."channelId" AS channel_id,
|
submit."channelId" AS channel_id,
|
||||||
channel.name AS channel_name,
|
channel.name AS channel_name,
|
||||||
|
submit."submitStatus" AS submit_status,
|
||||||
receipt."deliveredAt" AS delivered_at,
|
receipt."deliveredAt" AS delivered_at,
|
||||||
failed_receipt."failedAt" AS failed_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
|
CASE
|
||||||
WHEN receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt")
|
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
|
THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
|
||||||
END AS arrival_ms
|
END AS arrival_ms
|
||||||
FROM "SmsSubmitRecord" submit
|
FROM "SmsSubmitRecord" submit
|
||||||
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
|
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 (
|
LEFT JOIN LATERAL (
|
||||||
SELECT MIN(receipt."deliveredAt") AS "deliveredAt"
|
SELECT MIN(receipt."deliveredAt") AS "deliveredAt"
|
||||||
FROM "SmsReceiptRecord" receipt
|
FROM "SmsReceiptRecord" receipt
|
||||||
@@ -395,22 +426,37 @@ export class OperationsService {
|
|||||||
AND receipt."channelId" = submit."channelId"
|
AND receipt."channelId" = submit."channelId"
|
||||||
AND receipt."receiptStatus" = 'undelivered'
|
AND receipt."receiptStatus" = 'undelivered'
|
||||||
) failed_receipt ON TRUE
|
) failed_receipt ON TRUE
|
||||||
WHERE submit."submitStatus" = 'accepted'
|
WHERE submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
|
||||||
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
|
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
|
||||||
AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt}
|
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
|
SELECT
|
||||||
channel_id AS "channelId",
|
channel_id AS "channelId",
|
||||||
MAX(channel_name) AS "channelName",
|
MAX(channel_name) AS "channelName",
|
||||||
COUNT(*)::integer AS total,
|
COUNT(*)::integer AS total,
|
||||||
COUNT(*) FILTER (WHERE delivered_at IS NOT NULL)::integer AS "successCount",
|
COUNT(*) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedCount",
|
||||||
COUNT(*) FILTER (WHERE delivered_at IS NULL AND failed_at IS NULL)::integer AS "unknownCount",
|
COUNT(*) FILTER (WHERE delivery_status = 'submit_failed')::integer AS "submitFailureCount",
|
||||||
COUNT(*) FILTER (WHERE delivered_at IS NULL AND failed_at IS NOT NULL)::integer AS "failureCount",
|
CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'submit_failed') * 100.0 / COUNT(*), 1)::double precision END AS "submitFailureRate",
|
||||||
CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivered_at IS NOT NULL) * 100.0 / COUNT(*), 1)::double precision END AS "successRate",
|
COUNT(*) FILTER (WHERE delivery_status = 'success')::integer AS "successCount",
|
||||||
CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivered_at IS NULL AND failed_at IS NULL) * 100.0 / COUNT(*), 1)::double precision END AS "unknownRate",
|
COUNT(*) FILTER (WHERE delivery_status = 'unknown')::integer AS "unknownCount",
|
||||||
CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivered_at IS NULL AND failed_at IS NOT NULL) * 100.0 / COUNT(*), 1)::double precision END AS "failureRate",
|
COUNT(*) FILTER (WHERE delivery_status = 'failure')::integer AS "failureCount",
|
||||||
ROUND(AVG(arrival_ms) FILTER (WHERE arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
|
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",
|
||||||
FROM base
|
CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'unknown') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "unknownRate",
|
||||||
|
CASE WHEN COUNT(*) FILTER (WHERE submit_status = 'accepted') = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivery_status = 'failure') * 100.0 / COUNT(*) FILTER (WHERE submit_status = 'accepted'), 1)::double precision END AS "failureRate",
|
||||||
|
ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
|
||||||
|
FROM classified
|
||||||
GROUP BY channel_id
|
GROUP BY channel_id
|
||||||
ORDER BY COUNT(*) DESC, channel_id
|
ORDER BY COUNT(*) DESC, channel_id
|
||||||
`),
|
`),
|
||||||
@@ -479,8 +525,101 @@ export class OperationsService {
|
|||||||
GROUP BY signature.id, signature.name, tenant.id, tenant.name, base.has_drainage
|
GROUP BY signature.id, signature.name, tenant.id, tenant.name, base.has_drainage
|
||||||
ORDER BY "successCount" DESC, total DESC, signature.name
|
ORDER BY "successCount" DESC, total DESC, signature.name
|
||||||
`),
|
`),
|
||||||
|
this.prisma.$queryRaw<Array<{
|
||||||
|
total: number;
|
||||||
|
successCount: number;
|
||||||
|
unknownCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
successRate: number;
|
||||||
|
}>>(Prisma.sql`
|
||||||
|
WITH base AS (
|
||||||
|
SELECT message.status, message."receiptStatus" AS receipt_status
|
||||||
|
FROM "SmsMessageRecord" message
|
||||||
|
WHERE message."queuedAt" >= ${day.startAt}
|
||||||
|
AND message."queuedAt" < ${day.endAt}
|
||||||
|
AND COALESCE(message.status, '') <> 'rejected'
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::integer AS total,
|
||||||
|
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||||
|
AND NOT (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||||
|
)::integer AS "unknownCount",
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||||
|
AND (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||||
|
)::integer AS "failureCount",
|
||||||
|
CASE
|
||||||
|
WHEN COUNT(*) = 0 THEN 0
|
||||||
|
ELSE ROUND(
|
||||||
|
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
|
||||||
|
* 100.0 / COUNT(*),
|
||||||
|
1
|
||||||
|
)::double precision
|
||||||
|
END AS "successRate"
|
||||||
|
FROM base
|
||||||
|
`),
|
||||||
|
this.prisma.$queryRaw<Array<{
|
||||||
|
applicationId: string;
|
||||||
|
applicationName: string;
|
||||||
|
tenantId: string;
|
||||||
|
tenantName: string;
|
||||||
|
total: number;
|
||||||
|
successCount: number;
|
||||||
|
unknownCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
successRate: number;
|
||||||
|
}>>(Prisma.sql`
|
||||||
|
WITH base AS (
|
||||||
|
SELECT
|
||||||
|
message."applicationId" AS application_id,
|
||||||
|
message.status,
|
||||||
|
message."receiptStatus" AS receipt_status
|
||||||
|
FROM "SmsMessageRecord" message
|
||||||
|
WHERE message."applicationId" IS NOT NULL
|
||||||
|
AND message."queuedAt" >= ${day.startAt}
|
||||||
|
AND message."queuedAt" < ${day.endAt}
|
||||||
|
AND COALESCE(message.status, '') <> 'rejected'
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
application.id AS "applicationId",
|
||||||
|
application.name AS "applicationName",
|
||||||
|
tenant.id AS "tenantId",
|
||||||
|
tenant.name AS "tenantName",
|
||||||
|
COUNT(*)::integer AS total,
|
||||||
|
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||||
|
AND NOT (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||||
|
)::integer AS "unknownCount",
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||||
|
AND (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||||
|
)::integer AS "failureCount",
|
||||||
|
CASE
|
||||||
|
WHEN COUNT(*) = 0 THEN 0
|
||||||
|
ELSE ROUND(
|
||||||
|
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
|
||||||
|
* 100.0 / COUNT(*),
|
||||||
|
1
|
||||||
|
)::double precision
|
||||||
|
END AS "successRate"
|
||||||
|
FROM base
|
||||||
|
JOIN "SmsApplication" application ON application.id = base.application_id
|
||||||
|
JOIN "Tenant" tenant ON tenant.id = application."tenantId"
|
||||||
|
GROUP BY application.id, application.name, tenant.id, tenant.name
|
||||||
|
ORDER BY total DESC, application.name
|
||||||
|
`),
|
||||||
]);
|
]);
|
||||||
return { date: day.key, channels, signatures };
|
const summary = summaryRows[0] ?? {
|
||||||
|
total: 0,
|
||||||
|
successCount: 0,
|
||||||
|
unknownCount: 0,
|
||||||
|
failureCount: 0,
|
||||||
|
successRate: 0,
|
||||||
|
};
|
||||||
|
return { date: day.key, summary, channels, signatures, applications };
|
||||||
}
|
}
|
||||||
|
|
||||||
async auditLogs(query: { tenantId?: string; userId?: string; page?: number; pageSize?: number }) {
|
async auditLogs(query: { tenantId?: string; userId?: string; page?: number; pageSize?: number }) {
|
||||||
@@ -1057,6 +1196,13 @@ export class OperationsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
|
function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
|
||||||
|
const statusWhere = query.status === 'submit_failed'
|
||||||
|
? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] }
|
||||||
|
: query.status === 'failed'
|
||||||
|
? { status: 'failed', submitStatus: 'accepted' }
|
||||||
|
: query.status
|
||||||
|
? { status: query.status }
|
||||||
|
: {};
|
||||||
return {
|
return {
|
||||||
tenantId: query.tenantId,
|
tenantId: query.tenantId,
|
||||||
applicationId: query.applicationId,
|
applicationId: query.applicationId,
|
||||||
@@ -1064,7 +1210,7 @@ function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
|
|||||||
batchTaskId: query.taskId,
|
batchTaskId: query.taskId,
|
||||||
messageId: query.messageId,
|
messageId: query.messageId,
|
||||||
phoneNumber: query.phoneNumber,
|
phoneNumber: query.phoneNumber,
|
||||||
status: query.status,
|
...statusWhere,
|
||||||
...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}),
|
...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}),
|
||||||
...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}),
|
...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}),
|
||||||
...(query.queuedAtFrom || query.queuedAtTo ? {
|
...(query.queuedAtFrom || query.queuedAtTo ? {
|
||||||
|
|||||||
@@ -2412,3 +2412,21 @@ git diff --check
|
|||||||
- 发布后Gateway、API、Nginx、PostgreSQL、Redis和MinIO均active;`12026/17890/3000/6379/5432/9000`监听,API health正常、Redis PONG。`gateway.submit.commands`消费者1、`pending=0`、`lag=0`,3条active供应商通道均为`connected/currentConnections=1/desiredConnections=1`。
|
- 发布后Gateway、API、Nginx、PostgreSQL、Redis和MinIO均active;`12026/17890/3000/6379/5432/9000`监听,API health正常、Redis PONG。`gateway.submit.commands`消费者1、`pending=0`、`lag=0`,3条active供应商通道均为`connected/currentConnections=1/desiredConnections=1`。
|
||||||
- 新路由未登录访问返回受控401,证明路由已加载且认证保护有效;直接调用预发布已部署`OperationsService`并查询真实PostgreSQL返回日期`2026-07-24`、3行通道和4行签名统计。通道总量为10:富泷物业-移动5条(成功0、未知3、失败2),赛邮行业-王斯评中转4条(成功1、未知3、失败0),富泷物业-联通1条(成功1、未知0、失败0),修复后不再是前端硬编码全0。
|
- 新路由未登录访问返回受控401,证明路由已加载且认证保护有效;直接调用预发布已部署`OperationsService`并查询真实PostgreSQL返回日期`2026-07-24`、3行通道和4行签名统计。通道总量为10:富泷物业-移动5条(成功0、未知3、失败2),赛邮行业-王斯评中转4条(成功1、未知3、失败0),富泷物业-联通1条(成功1、未知0、失败0),修复后不再是前端硬编码全0。
|
||||||
- 公网首页、运营登录页、客户端登录页和API health均返回HTTP 200,公网CMPP 17890 TCP连接成功。发布以来API、Gateway和Nginx无warning及以上日志。服务器留存的旧管理员凭据已与当前账户密码不一致,接口验收首次登录返回一次401后停止重试,未触发锁定;本轮未绕过认证,登录后页面交互仍需持有当前密码的人工会话复核,未虚报通过。
|
- 公网首页、运营登录页、客户端登录页和API health均返回HTTP 200,公网CMPP 17890 TCP连接成功。发布以来API、Gateway和Nginx无warning及以上日志。服务器留存的旧管理员凭据已与当前账户密码不一致,接口验收首次登录返回一次401后停止重试,未触发锁定;本轮未绕过认证,登录后页面交互仍需持有当前密码的人工会话复核,未虚报通过。
|
||||||
|
|
||||||
|
## 2026-07-24 提交失败与送达失败口径拆分(本地未提交、未部署)
|
||||||
|
|
||||||
|
- 预发布只读核对确认三条上游结果均为`rejected / 103`:企业CMPP正式短信`18821203795`由平台生成`PLATFORM:MSG-* / REJECTD`失败回执并已完成企业侧投递;运营端通道测试`13127620092`、`15821447161`没有企业和应用归属,无需生成客户回执。三者业务结果统一归为“提交失败”,是否存在平台通知回执不再改变列表分类。
|
||||||
|
- 短信记录列表、CSV、详情和状态筛选按阶段拆分:`submit_failed`或`submitStatus=rejected/timeout`显示“提交失败”;只有供应商已受理且最终失败才显示“送达失败”。详情根据真实平台失败回执和`CmppDownstreamDelivery`状态显示“平台已生成失败回执并通知企业”,独立通道测试显示“运营端通道测试,无需生成客户回执”。
|
||||||
|
- 通道当天质量纳入`accepted/rejected/timeout`终态提交,“今日提交”包含全部实际提交结果,并新增“提交失败”;送达成功、回执未知、送达失败只以供应商已受理数为分母。分片回执优先按`SmsMessageSegmentAudit.submitRecordId`聚合,任一分片明确失败即归为送达失败,避免非首片失败被误算为未知。
|
||||||
|
- 通道页“查询”按钮绑定`loadChannels()`。风险复核确认该函数只调用通道列表、当天质量和连接状态三个GET接口,服务端均为只读`findMany`/聚合,不会连接、断开、重连通道,不会修改配置或写操作日志;不增加自动轮询。
|
||||||
|
- 新SQL在预发布真实库只读执行成功:会员营销-铁布衫为今日提交3、提交失败3、已受理0;富泷物业-移动为已受理5、送达成功0、回执未知2、送达失败3;富泷物业-联通为已受理1、送达成功0、回执未知1;赛邮行业-王斯评中转为已受理4、送达成功1、回执未知3。
|
||||||
|
- Node.js v24.14.0下API Operations定向1 suite / 22项、API全量26 suites / 320项、API TypeScript build、前端TypeScript/Vite build通过;全量测试仅出现既有Redis不可用容错告警并以`--forceExit`结束既有异步句柄。前端保留既有约1.93MB单chunk/580.73KB gzip告警。系统默认Node 14执行Jest/Vite时因不支持当前依赖语法却错误返回0,已排除该结果并使用工作区Node 24直接调用本地Jest/TypeScript/Vite可执行文件重跑。
|
||||||
|
- 本轮按用户要求仅修改代码并验证,未提交、未推送、未部署;`api/tsconfig.build.tsbuildinfo`和`outputs/`继续作为既有其他会话/构建产物保留。
|
||||||
|
|
||||||
|
## 2026-07-24 运营看板签名分页与单日企业应用统计(发布前)
|
||||||
|
|
||||||
|
- 运营看板“不含引流”和“含引流”两个今日签名发送统计区改为各占整行;每页展示10个签名,分别维护页码,并支持首页、末页、上一页、下一页和指定页跳转。跨页排名按完整结果集连续计算,不会在每页重新从1开始。
|
||||||
|
- 数据统计统一使用日期选择器指定的北京时间单日数据,默认当天;发送量、成功率、企业应用排行和通道占比均来自同一次只读`send-quality`后端快照。指标标题展示实际已加载日期,修改日期但尚未点击查询时不会把旧数据误标成新日期。
|
||||||
|
- 数据统计移除“待审核”。发送量和成功率从所选日期全部有效`SmsMessageRecord`聚合;企业排行改为企业应用维度,后端联表返回真实应用名称和所属企业名称,不再把企业ID或应用编号作为图表名称,也不混入历史累计记录。
|
||||||
|
- 后端新增单日汇总及企业应用统计回归数据,继续排除平台预校验`rejected`记录;已送达优先于失败判定,`submit_failed/failed/timeout`或失败回执计入失败,其余计入未知,满足`发送总数 = 成功 + 未知 + 失败`。
|
||||||
|
- 发布前门禁:Node.js v24.14.0下API Operations定向1 suite / 22项、API全量26 suites / 320项、API TypeScript build、前端TypeScript/Vite生产构建、Prisma generate/validate、Gateway `go test ./...`和`go vet ./...`、`git diff --check`全部通过。Jest保留既有Redis不可用容错告警和`--forceExit`异步句柄提示;前端保留既有约1.93MB单chunk/580.79KB gzip告警。浏览器验收、提交、推送与预发布部署结果待完成后补记。
|
||||||
|
|||||||
+28
-1
@@ -354,6 +354,9 @@ export type ChannelQualityStat = {
|
|||||||
channelId: string;
|
channelId: string;
|
||||||
channelName: string;
|
channelName: string;
|
||||||
total: number;
|
total: number;
|
||||||
|
acceptedCount: number;
|
||||||
|
submitFailureCount: number;
|
||||||
|
submitFailureRate: number;
|
||||||
successCount: number;
|
successCount: number;
|
||||||
unknownCount: number;
|
unknownCount: number;
|
||||||
failureCount: number;
|
failureCount: number;
|
||||||
@@ -378,10 +381,27 @@ export type SignatureQualityStat = {
|
|||||||
averageArrivalMs?: number | null;
|
averageArrivalMs?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DailySendSummary = {
|
||||||
|
total: number;
|
||||||
|
successCount: number;
|
||||||
|
unknownCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
successRate: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApplicationQualityStat = DailySendSummary & {
|
||||||
|
applicationId: string;
|
||||||
|
applicationName: string;
|
||||||
|
tenantId: string;
|
||||||
|
tenantName: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type SendQualityResponse = {
|
export type SendQualityResponse = {
|
||||||
date: string;
|
date: string;
|
||||||
|
summary: DailySendSummary;
|
||||||
channels: ChannelQualityStat[];
|
channels: ChannelQualityStat[];
|
||||||
signatures: SignatureQualityStat[];
|
signatures: SignatureQualityStat[];
|
||||||
|
applications: ApplicationQualityStat[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RechargeOrder = {
|
export type RechargeOrder = {
|
||||||
@@ -581,7 +601,7 @@ export type ImportPreviewResponse = {
|
|||||||
|
|
||||||
export type SmsMessageRecord = {
|
export type SmsMessageRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
tenantId: string;
|
tenantId?: string | null;
|
||||||
batchTaskId?: string | null;
|
batchTaskId?: string | null;
|
||||||
applicationId?: string | null;
|
applicationId?: string | null;
|
||||||
channelId?: string | null;
|
channelId?: string | null;
|
||||||
@@ -607,6 +627,13 @@ export type SmsMessageRecord = {
|
|||||||
application?: { id: string; name: string };
|
application?: { id: string; name: string };
|
||||||
submitRecords?: SmsSubmitRecord[];
|
submitRecords?: SmsSubmitRecord[];
|
||||||
receiptRecords?: SmsReceiptRecord[];
|
receiptRecords?: SmsReceiptRecord[];
|
||||||
|
downstreamDeliveries?: Array<{
|
||||||
|
id: string;
|
||||||
|
deliveryType: string;
|
||||||
|
status: string;
|
||||||
|
deliveredAt?: string | null;
|
||||||
|
lastError?: string | null;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SmsSubmitRecord = {
|
export type SmsSubmitRecord = {
|
||||||
|
|||||||
@@ -1,26 +1,18 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { BarChart3 } from 'lucide-react';
|
import { BarChart3 } from 'lucide-react';
|
||||||
import { adminApi, type DashboardResponse } from '@/api/adminApi';
|
import { adminApi, type SendQualityResponse } from '@/api/adminApi';
|
||||||
import { Breadcrumb, Button, Chart, Input, Tag } from '@/components/ui';
|
import { Breadcrumb, Button, Chart, Input, Tag } from '@/components/ui';
|
||||||
import { createBarOption, createPieOption } from '@/theme/chartOptions';
|
import { createBarOption, createPieOption } from '@/theme/chartOptions';
|
||||||
|
|
||||||
export function AdminAnalyticsPage() {
|
export function AdminAnalyticsPage() {
|
||||||
const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey());
|
const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey());
|
||||||
const [dashboard, setDashboard] = useState<DashboardResponse | null>(null);
|
const [quality, setQuality] = useState<SendQualityResponse | null>(null);
|
||||||
const [tenantStats, setTenantStats] = useState<Array<{ tenantId: string | null; _count: { _all: number }; _sum: { amountCents?: number | null } }>>([]);
|
|
||||||
const [channelStats, setChannelStats] = useState<Array<{ channelId: string; channelName: string; total: number }>>([]);
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
function loadData() {
|
function loadData() {
|
||||||
Promise.all([
|
adminApi.getSendQuality(statisticsDate)
|
||||||
adminApi.getDashboard(),
|
.then((qualityData) => {
|
||||||
adminApi.listStatistics({ groupBy: 'tenantId' }),
|
setQuality(qualityData);
|
||||||
adminApi.getSendQuality(statisticsDate),
|
|
||||||
])
|
|
||||||
.then(([dashboardData, tenantData, qualityData]) => {
|
|
||||||
setDashboard(dashboardData);
|
|
||||||
setTenantStats((Array.isArray(tenantData) ? tenantData : []) as Array<{ tenantId: string | null; _count: { _all: number }; _sum: { amountCents?: number | null } }>);
|
|
||||||
setChannelStats(qualityData.channels.map((item) => ({ channelId: item.channelId, channelName: item.channelName, total: item.total })));
|
|
||||||
setError('');
|
setError('');
|
||||||
})
|
})
|
||||||
.catch((failure: Error) => setError(failure.message || '统计数据加载失败'));
|
.catch((failure: Error) => setError(failure.message || '统计数据加载失败'));
|
||||||
@@ -30,14 +22,15 @@ export function AdminAnalyticsPage() {
|
|||||||
loadData();
|
loadData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const tenantOption = useMemo(() => createBarOption({
|
const applicationOption = useMemo(() => createBarOption({
|
||||||
labels: tenantStats.map((item) => item.tenantId ?? '未绑定企业'),
|
labels: quality?.applications.map((item) => item.applicationName) ?? [],
|
||||||
series: [{ name: '发送量', data: tenantStats.map((item) => item._count._all) }],
|
series: [{ name: '发送量', data: quality?.applications.map((item) => item.total) ?? [] }],
|
||||||
}), [tenantStats]);
|
}), [quality]);
|
||||||
|
|
||||||
const channelOption = useMemo(() => createPieOption({
|
const channelOption = useMemo(() => createPieOption({
|
||||||
data: channelStats.map((item) => ({ name: item.channelName || item.channelId, value: item.total })),
|
data: quality?.channels.map((item) => ({ name: item.channelName || item.channelId, value: item.total })) ?? [],
|
||||||
}), [channelStats]);
|
}), [quality]);
|
||||||
|
const effectiveDate = quality?.date ?? statisticsDate;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page-stack">
|
<section className="page-stack">
|
||||||
@@ -60,19 +53,14 @@ export function AdminAnalyticsPage() {
|
|||||||
|
|
||||||
<div className="dashboard-grid">
|
<div className="dashboard-grid">
|
||||||
<div className="surface metric-card">
|
<div className="surface metric-card">
|
||||||
<span>今日发送量</span>
|
<span>{effectiveDate} 发送量</span>
|
||||||
<strong>{dashboard?.today.sent.toLocaleString('zh-CN') ?? 0}</strong>
|
<strong>{quality?.summary.total.toLocaleString('zh-CN') ?? 0}</strong>
|
||||||
<small>真实消息记录</small>
|
<small>所选日期真实消息记录</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="surface metric-card">
|
<div className="surface metric-card">
|
||||||
<span>成功率</span>
|
<span>{effectiveDate} 成功率</span>
|
||||||
<strong>{dashboard?.today.successRate ?? 0}%</strong>
|
<strong>{(quality?.summary.successRate ?? 0).toFixed(1)}%</strong>
|
||||||
<small>今日已回执</small>
|
<small>{quality?.summary.successCount.toLocaleString('zh-CN') ?? 0} 条已送达 / {quality?.summary.total.toLocaleString('zh-CN') ?? 0} 条发送</small>
|
||||||
</div>
|
|
||||||
<div className="surface metric-card">
|
|
||||||
<span>待审核</span>
|
|
||||||
<strong>{dashboard?.pendingAuditCount ?? 0}</strong>
|
|
||||||
<small>企业/签名/模板/风控</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -80,18 +68,18 @@ export function AdminAnalyticsPage() {
|
|||||||
<div className="surface chart-card">
|
<div className="surface chart-card">
|
||||||
<div className="section-heading">
|
<div className="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>企业发送排行</h2>
|
<h2>企业应用发送排行</h2>
|
||||||
<p className="muted">按真实短信消息记录聚合。</p>
|
<p className="muted">{effectiveDate} 当天按企业应用名称聚合真实短信消息记录。</p>
|
||||||
</div>
|
</div>
|
||||||
<Tag tone="info">企业</Tag>
|
<Tag tone="info">企业应用</Tag>
|
||||||
</div>
|
</div>
|
||||||
<Chart height={320} option={tenantOption} />
|
<Chart height={320} option={applicationOption} />
|
||||||
</div>
|
</div>
|
||||||
<div className="surface chart-card">
|
<div className="surface chart-card">
|
||||||
<div className="section-heading">
|
<div className="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>通道占比</h2>
|
<h2>通道占比</h2>
|
||||||
<p className="muted">{statisticsDate} 当天按真实通道提交及回执聚合。</p>
|
<p className="muted">{effectiveDate} 当天按真实通道提交及回执聚合。</p>
|
||||||
</div>
|
</div>
|
||||||
<Tag tone="accent">通道</Tag>
|
<Tag tone="accent">通道</Tag>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ type SmsChannel = {
|
|||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
status: ChannelStatus;
|
status: ChannelStatus;
|
||||||
total: number;
|
total: number;
|
||||||
|
submitFailureRate: number;
|
||||||
|
submitFailureCount: number;
|
||||||
successRate: number;
|
successRate: number;
|
||||||
successCount: number;
|
successCount: number;
|
||||||
unknownRate: number;
|
unknownRate: number;
|
||||||
@@ -154,6 +156,8 @@ function mapApiChannel(
|
|||||||
unitPrice: channel.unitPrice,
|
unitPrice: channel.unitPrice,
|
||||||
status: resolveChannelStatus(channel, connections),
|
status: resolveChannelStatus(channel, connections),
|
||||||
total: quality?.total ?? 0,
|
total: quality?.total ?? 0,
|
||||||
|
submitFailureRate: quality?.submitFailureRate ?? 0,
|
||||||
|
submitFailureCount: quality?.submitFailureCount ?? 0,
|
||||||
successRate: quality?.successRate ?? 0,
|
successRate: quality?.successRate ?? 0,
|
||||||
successCount: quality?.successCount ?? 0,
|
successCount: quality?.successCount ?? 0,
|
||||||
unknownRate: quality?.unknownRate ?? 0,
|
unknownRate: quality?.unknownRate ?? 0,
|
||||||
@@ -256,6 +260,8 @@ function ChannelFormModal({
|
|||||||
unitPrice: yuanToMoneyUnits(unitPrice),
|
unitPrice: yuanToMoneyUnits(unitPrice),
|
||||||
status: channel?.status ?? 'connecting',
|
status: channel?.status ?? 'connecting',
|
||||||
total: channel?.total ?? 0,
|
total: channel?.total ?? 0,
|
||||||
|
submitFailureRate: channel?.submitFailureRate ?? 0,
|
||||||
|
submitFailureCount: channel?.submitFailureCount ?? 0,
|
||||||
successRate: channel?.successRate ?? 0,
|
successRate: channel?.successRate ?? 0,
|
||||||
successCount: channel?.successCount ?? 0,
|
successCount: channel?.successCount ?? 0,
|
||||||
unknownRate: channel?.unknownRate ?? 0,
|
unknownRate: channel?.unknownRate ?? 0,
|
||||||
@@ -607,7 +613,7 @@ export function AdminChannelsPage() {
|
|||||||
<Select label="运营商" onChange={(event) => setCarrier(event.target.value)} options={carrierOptions} value={carrier} />
|
<Select label="运营商" onChange={(event) => setCarrier(event.target.value)} options={carrierOptions} value={carrier} />
|
||||||
<Select label="当前状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
|
<Select label="当前状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
|
||||||
<div className="audit-filter-actions">
|
<div className="audit-filter-actions">
|
||||||
<Button icon={<Search size={16} />}>查询</Button>
|
<Button icon={<Search size={16} />} onClick={() => void loadChannels()}>查询</Button>
|
||||||
<Button onClick={() => { setKeyword(''); setCarrier('all'); setStatus('all'); }} variant="ghost">重置</Button>
|
<Button onClick={() => { setKeyword(''); setCarrier('all'); setStatus('all'); }} variant="ghost">重置</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -618,8 +624,8 @@ export function AdminChannelsPage() {
|
|||||||
<span>通道信息</span>
|
<span>通道信息</span>
|
||||||
<span>运营商 / 成本</span>
|
<span>运营商 / 成本</span>
|
||||||
<span>状态</span>
|
<span>状态</span>
|
||||||
<span>今日总数</span>
|
<span>今日提交</span>
|
||||||
<span>今日发送质量</span>
|
<span>今日提交 / 送达质量</span>
|
||||||
<span>操作</span>
|
<span>操作</span>
|
||||||
</div>
|
</div>
|
||||||
{visibleChannels.map((channel) => (
|
{visibleChannels.map((channel) => (
|
||||||
@@ -640,9 +646,10 @@ export function AdminChannelsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<strong className="sms-channel-total">{channel.total.toLocaleString('zh-CN')}</strong>
|
<strong className="sms-channel-total">{channel.total.toLocaleString('zh-CN')}</strong>
|
||||||
<div className="sms-channel-quality">
|
<div className="sms-channel-quality">
|
||||||
<RateBlock count={channel.successCount} label="成功" rate={channel.successRate} tone={channel.successRate >= 80 ? 'success' : 'warning'} />
|
<RateBlock count={channel.submitFailureCount} label="提交失败" rate={channel.submitFailureRate} tone={channel.submitFailureCount > 0 ? 'danger' : 'neutral'} />
|
||||||
<RateBlock count={channel.unknownCount} label="未知" rate={channel.unknownRate} />
|
<RateBlock count={channel.successCount} label="送达成功" rate={channel.successRate} tone={channel.successRate >= 80 ? 'success' : 'warning'} />
|
||||||
<RateBlock count={channel.failureCount} label="失败" rate={channel.failureRate} tone={channel.failureRate >= 50 ? 'danger' : 'neutral'} />
|
<RateBlock count={channel.unknownCount} label="回执未知" rate={channel.unknownRate} />
|
||||||
|
<RateBlock count={channel.failureCount} label="送达失败" rate={channel.failureRate} tone={channel.failureRate >= 50 ? 'danger' : 'neutral'} />
|
||||||
</div>
|
</div>
|
||||||
<div className="sms-channel-actions">
|
<div className="sms-channel-actions">
|
||||||
<button className="sms-channel-report-entry" onClick={() => navigate(`/admin/channels/${channel.id}/reports`)} type="button">
|
<button className="sms-channel-report-entry" onClick={() => navigate(`/admin/channels/${channel.id}/reports`)} type="button">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Chart,
|
Chart,
|
||||||
Modal,
|
Modal,
|
||||||
|
Pagination,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
type TableColumn,
|
type TableColumn,
|
||||||
@@ -27,6 +28,12 @@ type EnterpriseSpendRank = {
|
|||||||
availableBalance: number;
|
availableBalance: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RankedSignatureQualityStat = SignatureQualityStat & {
|
||||||
|
rank: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIGNATURE_PAGE_SIZE = 10;
|
||||||
|
|
||||||
const balanceTone = {
|
const balanceTone = {
|
||||||
充足: 'success',
|
充足: 'success',
|
||||||
紧张: 'warning',
|
紧张: 'warning',
|
||||||
@@ -47,6 +54,8 @@ export function AdminHome() {
|
|||||||
const [quality, setQuality] = useState<SendQualityResponse | null>(null);
|
const [quality, setQuality] = useState<SendQualityResponse | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [selectedEnterprise, setSelectedEnterprise] = useState<EnterpriseSpendRank | null>(null);
|
const [selectedEnterprise, setSelectedEnterprise] = useState<EnterpriseSpendRank | null>(null);
|
||||||
|
const [plainSignaturePage, setPlainSignaturePage] = useState(1);
|
||||||
|
const [drainageSignaturePage, setDrainageSignaturePage] = useState(1);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([adminApi.getDashboard(), adminApi.getSendQuality()])
|
Promise.all([adminApi.getDashboard(), adminApi.getSendQuality()])
|
||||||
@@ -131,8 +140,8 @@ export function AdminHome() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const signatureColumns: Array<TableColumn<SignatureQualityStat>> = [
|
const signatureColumns: Array<TableColumn<RankedSignatureQualityStat>> = [
|
||||||
{ key: 'rank', title: '排名', width: '72px', render: (_record, index) => index + 1 },
|
{ key: 'rank', title: '排名', width: '72px', render: (record) => record.rank },
|
||||||
{ key: 'signatureName', title: '签名', render: (record) => <div><strong>{record.signatureName}</strong><p className="text-caption">{record.tenantName}</p></div> },
|
{ key: 'signatureName', title: '签名', render: (record) => <div><strong>{record.signatureName}</strong><p className="text-caption">{record.tenantName}</p></div> },
|
||||||
{ key: 'total', title: '发送总数', align: 'right', render: (record) => formatCount(record.total) },
|
{ key: 'total', title: '发送总数', align: 'right', render: (record) => formatCount(record.total) },
|
||||||
{ key: 'successCount', title: '成功', align: 'right', render: (record) => formatCount(record.successCount) },
|
{ key: 'successCount', title: '成功', align: 'right', render: (record) => formatCount(record.successCount) },
|
||||||
@@ -143,6 +152,22 @@ export function AdminHome() {
|
|||||||
];
|
];
|
||||||
const plainSignatureQuality = quality?.signatures.filter((item) => !item.hasDrainage) ?? [];
|
const plainSignatureQuality = quality?.signatures.filter((item) => !item.hasDrainage) ?? [];
|
||||||
const drainageSignatureQuality = quality?.signatures.filter((item) => item.hasDrainage) ?? [];
|
const drainageSignatureQuality = quality?.signatures.filter((item) => item.hasDrainage) ?? [];
|
||||||
|
const plainSignatureTotalPages = Math.max(1, Math.ceil(plainSignatureQuality.length / SIGNATURE_PAGE_SIZE));
|
||||||
|
const drainageSignatureTotalPages = Math.max(1, Math.ceil(drainageSignatureQuality.length / SIGNATURE_PAGE_SIZE));
|
||||||
|
const pagedPlainSignatureQuality = plainSignatureQuality
|
||||||
|
.slice((plainSignaturePage - 1) * SIGNATURE_PAGE_SIZE, plainSignaturePage * SIGNATURE_PAGE_SIZE)
|
||||||
|
.map((item, index) => ({ ...item, rank: (plainSignaturePage - 1) * SIGNATURE_PAGE_SIZE + index + 1 }));
|
||||||
|
const pagedDrainageSignatureQuality = drainageSignatureQuality
|
||||||
|
.slice((drainageSignaturePage - 1) * SIGNATURE_PAGE_SIZE, drainageSignaturePage * SIGNATURE_PAGE_SIZE)
|
||||||
|
.map((item, index) => ({ ...item, rank: (drainageSignaturePage - 1) * SIGNATURE_PAGE_SIZE + index + 1 }));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPlainSignaturePage((page) => Math.min(page, plainSignatureTotalPages));
|
||||||
|
}, [plainSignatureTotalPages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDrainageSignaturePage((page) => Math.min(page, drainageSignatureTotalPages));
|
||||||
|
}, [drainageSignatureTotalPages]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page-stack admin-dashboard">
|
<section className="page-stack admin-dashboard">
|
||||||
@@ -207,7 +232,17 @@ export function AdminHome() {
|
|||||||
</div>
|
</div>
|
||||||
<Tag tone="info">{plainSignatureQuality.length} 个签名</Tag>
|
<Tag tone="info">{plainSignatureQuality.length} 个签名</Tag>
|
||||||
</div>
|
</div>
|
||||||
<Table columns={signatureColumns} data={plainSignatureQuality} emptyText="今日暂无不含引流的签名发送记录" rowKey="id" />
|
<Table columns={signatureColumns} data={pagedPlainSignatureQuality} emptyText="今日暂无不含引流的签名发送记录" rowKey="id" />
|
||||||
|
<Pagination
|
||||||
|
total={plainSignatureQuality.length}
|
||||||
|
page={plainSignaturePage}
|
||||||
|
totalPages={plainSignatureTotalPages}
|
||||||
|
previousDisabled={plainSignaturePage <= 1}
|
||||||
|
nextDisabled={plainSignaturePage >= plainSignatureTotalPages}
|
||||||
|
onPrevious={() => setPlainSignaturePage((page) => Math.max(1, page - 1))}
|
||||||
|
onNext={() => setPlainSignaturePage((page) => Math.min(plainSignatureTotalPages, page + 1))}
|
||||||
|
onPageChange={setPlainSignaturePage}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="surface section-stack">
|
<div className="surface section-stack">
|
||||||
<div className="section-heading">
|
<div className="section-heading">
|
||||||
@@ -217,7 +252,17 @@ export function AdminHome() {
|
|||||||
</div>
|
</div>
|
||||||
<Tag tone="accent">{drainageSignatureQuality.length} 个签名</Tag>
|
<Tag tone="accent">{drainageSignatureQuality.length} 个签名</Tag>
|
||||||
</div>
|
</div>
|
||||||
<Table columns={signatureColumns} data={drainageSignatureQuality} emptyText="今日暂无含引流的签名发送记录" rowKey="id" />
|
<Table columns={signatureColumns} data={pagedDrainageSignatureQuality} emptyText="今日暂无含引流的签名发送记录" rowKey="id" />
|
||||||
|
<Pagination
|
||||||
|
total={drainageSignatureQuality.length}
|
||||||
|
page={drainageSignaturePage}
|
||||||
|
totalPages={drainageSignatureTotalPages}
|
||||||
|
previousDisabled={drainageSignaturePage <= 1}
|
||||||
|
nextDisabled={drainageSignaturePage >= drainageSignatureTotalPages}
|
||||||
|
onPrevious={() => setDrainageSignaturePage((page) => Math.max(1, page - 1))}
|
||||||
|
onNext={() => setDrainageSignaturePage((page) => Math.min(drainageSignatureTotalPages, page + 1))}
|
||||||
|
onPageChange={setDrainageSignaturePage}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { AlertTriangle, Download, MessageSquare, Search, Smartphone } from 'lucide-react';
|
import { AlertTriangle, Download, Info, MessageSquare, Search, Smartphone } from 'lucide-react';
|
||||||
import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit, type SmsReceiptRecord, type SmsSubmitRecord } from '@/api/adminApi';
|
import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit, type SmsReceiptRecord, type SmsSubmitRecord } from '@/api/adminApi';
|
||||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
|
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
|
||||||
import { formatCents } from '@/utils/currency';
|
import { formatCents } from '@/utils/currency';
|
||||||
@@ -8,8 +8,9 @@ const statusLabelMap: Record<string, string> = {
|
|||||||
delivered: '发送成功',
|
delivered: '发送成功',
|
||||||
queued: '排队中',
|
queued: '排队中',
|
||||||
submitted: '已提交',
|
submitted: '已提交',
|
||||||
|
submit_failed: '提交失败',
|
||||||
unknown: '未知',
|
unknown: '未知',
|
||||||
failed: '失败',
|
failed: '送达失败',
|
||||||
rejected: '已拒绝',
|
rejected: '已拒绝',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ const statusToneMap: Record<string, 'success' | 'neutral' | 'danger' | 'info'> =
|
|||||||
delivered: 'success',
|
delivered: 'success',
|
||||||
queued: 'info',
|
queued: 'info',
|
||||||
submitted: 'info',
|
submitted: 'info',
|
||||||
|
submit_failed: 'danger',
|
||||||
unknown: 'neutral',
|
unknown: 'neutral',
|
||||||
failed: 'danger',
|
failed: 'danger',
|
||||||
rejected: 'danger',
|
rejected: 'danger',
|
||||||
@@ -26,6 +28,7 @@ const statusDotClassMap: Record<string, string> = {
|
|||||||
delivered: 'is-success',
|
delivered: 'is-success',
|
||||||
queued: 'is-unknown',
|
queued: 'is-unknown',
|
||||||
submitted: 'is-unknown',
|
submitted: 'is-unknown',
|
||||||
|
submit_failed: 'is-failed',
|
||||||
unknown: 'is-unknown',
|
unknown: 'is-unknown',
|
||||||
failed: 'is-failed',
|
failed: 'is-failed',
|
||||||
rejected: 'is-failed',
|
rejected: 'is-failed',
|
||||||
@@ -85,6 +88,36 @@ function getStatusLabel(status?: string | null) {
|
|||||||
return status ? (statusLabelMap[status] ?? status) : '-';
|
return status ? (statusLabelMap[status] ?? status) : '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSubmitFailure(record: SmsMessageRecord) {
|
||||||
|
return record.status === 'submit_failed' || ['rejected', 'timeout'].includes(record.submitStatus ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordStatus(record: SmsMessageRecord) {
|
||||||
|
return isSubmitFailure(record) ? 'submit_failed' : record.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordStatusLabel(record: SmsMessageRecord) {
|
||||||
|
return getStatusLabel(getRecordStatus(record));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReceiptNotice(record: SmsMessageRecord) {
|
||||||
|
const hasPlatformFailureReceipt = (record.receiptRecords ?? []).some((receipt) =>
|
||||||
|
receipt.gatewayMessageId.startsWith('PLATFORM:') && receipt.rawStatus === 'REJECTD',
|
||||||
|
);
|
||||||
|
if (hasPlatformFailureReceipt) {
|
||||||
|
const deliveries = (record.downstreamDeliveries ?? []).filter((item) => item.deliveryType === 'receipt');
|
||||||
|
if (deliveries.some((item) => item.status === 'delivered')) {
|
||||||
|
return '平台已生成失败回执并通知企业';
|
||||||
|
}
|
||||||
|
const deliveryStatuses = Array.from(new Set(deliveries.map((item) => item.status)));
|
||||||
|
return `平台已生成失败回执,企业通知状态:${deliveryStatuses.join('、') || '待投递'}`;
|
||||||
|
}
|
||||||
|
if (!record.tenantId && !record.applicationId && record.messageId.startsWith('MSG-TEST-')) {
|
||||||
|
return '运营端通道测试,无需生成客户回执';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function getCarrierLabel(carrier?: string | null) {
|
function getCarrierLabel(carrier?: string | null) {
|
||||||
return carrier ? (carrierLabelMap[carrier] ?? carrier) : '-';
|
return carrier ? (carrierLabelMap[carrier] ?? carrier) : '-';
|
||||||
}
|
}
|
||||||
@@ -121,7 +154,8 @@ function buildRouteRows(record: SmsMessageRecord): RouteRow[] {
|
|||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusLine({ status }: { status: string }) {
|
function StatusLine({ record }: { record: SmsMessageRecord }) {
|
||||||
|
const status = getRecordStatus(record);
|
||||||
return (
|
return (
|
||||||
<span className="admin-sms-record-status">
|
<span className="admin-sms-record-status">
|
||||||
<i className={statusDotClassMap[status] ?? 'is-unknown'} />
|
<i className={statusDotClassMap[status] ?? 'is-unknown'} />
|
||||||
@@ -149,7 +183,7 @@ function downloadCsv(records: SmsMessageRecord[]) {
|
|||||||
record.billingUnits,
|
record.billingUnits,
|
||||||
formatCents(record.amountCents),
|
formatCents(record.amountCents),
|
||||||
record.channel?.name ?? record.channelId ?? '',
|
record.channel?.name ?? record.channelId ?? '',
|
||||||
getStatusLabel(record.status),
|
getRecordStatusLabel(record),
|
||||||
getTime(record.deliveredAt),
|
getTime(record.deliveredAt),
|
||||||
record.content,
|
record.content,
|
||||||
]),
|
]),
|
||||||
@@ -176,6 +210,8 @@ function SendDetailModal({
|
|||||||
}) {
|
}) {
|
||||||
const routeRows = buildRouteRows(record);
|
const routeRows = buildRouteRows(record);
|
||||||
const sentAccessNumber = `${record.channel?.srcId ?? ''}${record.applicationExtension ?? ''}`;
|
const sentAccessNumber = `${record.channel?.srcId ?? ''}${record.applicationExtension ?? ''}`;
|
||||||
|
const displayStatus = getRecordStatus(record);
|
||||||
|
const receiptNotice = getReceiptNotice(record);
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
footer={<Button onClick={onClose} variant="ghost">关闭</Button>}
|
footer={<Button onClick={onClose} variant="ghost">关闭</Button>}
|
||||||
@@ -188,7 +224,7 @@ function SendDetailModal({
|
|||||||
<div className="admin-sms-detail-overview">
|
<div className="admin-sms-detail-overview">
|
||||||
<div>
|
<div>
|
||||||
<span>最终状态</span>
|
<span>最终状态</span>
|
||||||
<Tag tone={record.status === 'delivered' ? 'success' : ['failed', 'rejected'].includes(record.status) ? 'danger' : 'info'}>{getStatusLabel(record.status)}</Tag>
|
<Tag tone={statusToneMap[displayStatus] ?? 'info'}>{getRecordStatusLabel(record)}</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>提交状态</span>
|
<span>提交状态</span>
|
||||||
@@ -219,6 +255,12 @@ function SendDetailModal({
|
|||||||
<strong>{sentAccessNumber || '-'}</strong>
|
<strong>{sentAccessNumber || '-'}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{receiptNotice ? (
|
||||||
|
<div className="admin-sms-detail-notice" role="status">
|
||||||
|
<Info size={20} />
|
||||||
|
<strong>{receiptNotice}</strong>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<section>
|
<section>
|
||||||
<h3><MessageSquare size={18} /> 短信内容</h3>
|
<h3><MessageSquare size={18} /> 短信内容</h3>
|
||||||
<p className="admin-sms-detail-content">{record.content}</p>
|
<p className="admin-sms-detail-content">{record.content}</p>
|
||||||
@@ -260,7 +302,7 @@ function SendDetailModal({
|
|||||||
<h3>状态信息</h3>
|
<h3>状态信息</h3>
|
||||||
<div className="admin-sms-detail-status-grid">
|
<div className="admin-sms-detail-status-grid">
|
||||||
<div><span>消息编号</span><strong>{record.messageId}</strong></div>
|
<div><span>消息编号</span><strong>{record.messageId}</strong></div>
|
||||||
<div><span>发送状态</span><strong>{getStatusLabel(record.status)}</strong></div>
|
<div><span>发送状态</span><strong>{getRecordStatusLabel(record)}</strong></div>
|
||||||
<div><span>提交状态</span><strong>{record.submitStatus ?? '-'}</strong></div>
|
<div><span>提交状态</span><strong>{record.submitStatus ?? '-'}</strong></div>
|
||||||
<div><span>回执状态</span><strong>{record.receiptStatus ?? '-'}</strong></div>
|
<div><span>回执状态</span><strong>{record.receiptStatus ?? '-'}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -377,7 +419,11 @@ export function AdminSmsRecordsPage() {
|
|||||||
|
|
||||||
const enterpriseOptions = useMemo(() => {
|
const enterpriseOptions = useMemo(() => {
|
||||||
const tenants = new Map<string, string>();
|
const tenants = new Map<string, string>();
|
||||||
records.forEach((record) => tenants.set(record.tenantId, record.tenant?.name ?? record.tenantId));
|
records.forEach((record) => {
|
||||||
|
if (record.tenantId) {
|
||||||
|
tenants.set(record.tenantId, record.tenant?.name ?? record.tenantId);
|
||||||
|
}
|
||||||
|
});
|
||||||
return [{ label: '全部企业', value: 'all' }, ...Array.from(tenants, ([value, label]) => ({ label, value }))];
|
return [{ label: '全部企业', value: 'all' }, ...Array.from(tenants, ([value, label]) => ({ label, value }))];
|
||||||
}, [records]);
|
}, [records]);
|
||||||
|
|
||||||
@@ -441,7 +487,8 @@ export function AdminSmsRecordsPage() {
|
|||||||
{ label: '全部', value: 'all' },
|
{ label: '全部', value: 'all' },
|
||||||
{ label: '发送成功', value: 'delivered' },
|
{ label: '发送成功', value: 'delivered' },
|
||||||
{ label: '未知', value: 'unknown' },
|
{ label: '未知', value: 'unknown' },
|
||||||
{ label: '失败', value: 'failed' },
|
{ label: '提交失败', value: 'submit_failed' },
|
||||||
|
{ label: '送达失败', value: 'failed' },
|
||||||
]}
|
]}
|
||||||
value={status}
|
value={status}
|
||||||
/>
|
/>
|
||||||
@@ -460,10 +507,10 @@ export function AdminSmsRecordsPage() {
|
|||||||
<article className="admin-sms-record-card" key={record.id}>
|
<article className="admin-sms-record-card" key={record.id}>
|
||||||
<header>
|
<header>
|
||||||
<div className="admin-sms-record-sender">
|
<div className="admin-sms-record-sender">
|
||||||
<strong>{record.tenant?.name ?? record.tenantId}</strong>
|
<strong>{record.tenant?.name ?? record.tenantId ?? '运营端通道测试'}</strong>
|
||||||
<span>{record.application?.name ?? record.applicationId ?? '-'}</span>
|
<span>{record.application?.name ?? record.applicationId ?? '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
<StatusLine status={record.status} />
|
<StatusLine record={record} />
|
||||||
<time>{getDate(record.queuedAt)} {getClock(record.queuedAt)}</time>
|
<time>{getDate(record.queuedAt)} {getClock(record.queuedAt)}</time>
|
||||||
</header>
|
</header>
|
||||||
<p className="admin-sms-record-content">{record.content}</p>
|
<p className="admin-sms-record-content">{record.content}</p>
|
||||||
|
|||||||
+19
-4
@@ -5342,7 +5342,7 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-signature-rank-grid {
|
.admin-signature-rank-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-workload-grid {
|
.admin-workload-grid {
|
||||||
@@ -6661,8 +6661,8 @@ h3 {
|
|||||||
.sms-channel-table__row {
|
.sms-channel-table__row {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
grid-template-columns: minmax(150px, 1.2fr) 88px 86px 96px minmax(210px, 1.35fr) 190px;
|
grid-template-columns: minmax(150px, 1.2fr) 88px 86px 96px minmax(280px, 1.6fr) 190px;
|
||||||
min-width: 860px;
|
min-width: 940px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sms-channel-table__head {
|
.sms-channel-table__head {
|
||||||
@@ -6740,7 +6740,7 @@ h3 {
|
|||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9293,6 +9293,21 @@ h3 {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-sms-detail-notice {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--color-selected-soft);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-selected) 28%, transparent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-selected);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sms-detail-notice strong {
|
||||||
|
color: var(--color-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
.admin-sms-send-detail h3 {
|
.admin-sms-send-detail h3 {
|
||||||
color: var(--color-text-strong);
|
color: var(--color-text-strong);
|
||||||
font-size: var(--font-size-md);
|
font-size: var(--font-size-md);
|
||||||
|
|||||||
Reference in New Issue
Block a user