feat: integrate analytics and fragment receipt improvements

This commit is contained in:
hectorzhao
2026-08-03 15:34:41 +08:00
parent 3357ace7e1
commit 530a65de80
89 changed files with 1964 additions and 324 deletions
@@ -43,6 +43,7 @@ export class AdminOperationsController {
@Query('queuedAtFrom') queuedAtFrom?: string,
@Query('queuedAtTo') queuedAtTo?: string,
@Query('status') status?: string,
@Query('hasDrainage') hasDrainage?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
@@ -59,6 +60,7 @@ export class AdminOperationsController {
queuedAtFrom,
queuedAtTo,
status,
hasDrainage,
page: Number(page),
pageSize: Number(pageSize),
});
@@ -76,6 +78,7 @@ export class AdminOperationsController {
@Query('queuedAtFrom') queuedAtFrom: string | undefined,
@Query('queuedAtTo') queuedAtTo: string | undefined,
@Query('status') status: string | undefined,
@Query('hasDrainage') hasDrainage: string | undefined,
@Res() response: DownloadResponse,
) {
const exported = await this.operations.exportMessages({
@@ -89,6 +92,7 @@ export class AdminOperationsController {
queuedAtFrom,
queuedAtTo,
status,
hasDrainage,
});
response.setHeader('Content-Type', 'text/csv; charset=utf-8');
response.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(exported.fileName)}`);
@@ -11,6 +11,7 @@ export interface MessageQuery {
contentKeyword?: string;
carrier?: string;
status?: string;
hasDrainage?: string;
queuedAtFrom?: string;
queuedAtTo?: string;
page?: number;
+4
View File
@@ -21,6 +21,10 @@ export function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereI
phoneNumber: query.phoneNumber,
...carrierWhere(query.carrier),
...statusWhere,
...(query.hasDrainage === 'true' ? { hasDrainageContent: true }
: query.hasDrainage === 'false' ? { hasDrainageContent: false }
: query.hasDrainage === 'unknown' ? { hasDrainageContent: null }
: {}),
...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}),
...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}),
...(query.queuedAtFrom || query.queuedAtTo ? {
@@ -210,6 +210,7 @@ describe('OperationsService', () => {
queuedAtFrom: '2026-07-01',
queuedAtTo: '2026-07-02',
status: 'delivered',
hasDrainage: 'true',
});
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith({
@@ -222,6 +223,7 @@ describe('OperationsService', () => {
phoneNumber: '13800000001',
carrier: { in: ['mobile', 'cmcc', '移动', '中国移动'] },
status: 'delivered',
hasDrainageContent: true,
content: { contains: '验证码', mode: 'insensitive' },
channel: { name: { contains: '移动通道', mode: 'insensitive' } },
queuedAt: {
@@ -582,6 +584,7 @@ describe('OperationsService', () => {
await expect(service.sendQuality('2026-07-24')).resolves.toEqual({
date: '2026-07-24',
drainageSignatures: [],
summary: {
total: 5,
successCount: 3,
@@ -639,6 +642,7 @@ describe('OperationsService', () => {
channelId: 'channel-1',
channelName: '通道一',
carrier: 'mobile',
drainageState: 'with',
total: 4,
acceptedCount: 4,
submitFailureCount: 0,
@@ -653,6 +657,7 @@ describe('OperationsService', () => {
channelId: 'channel-2',
channelName: '通道二',
carrier: 'telecom',
drainageState: 'without',
total: 2,
acceptedCount: 1,
submitFailureCount: 1,
@@ -713,6 +718,10 @@ describe('OperationsService', () => {
expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', total: 4 }),
expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', total: 2 }),
],
drainageBreakdowns: [
expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', drainageState: 'with', total: 4 }),
expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', drainageState: 'without', total: 2 }),
],
})],
total: 12,
page: 2,
@@ -107,6 +107,7 @@ async exportMessages(query: MessageQuery) {
submitStatus: true,
deliveredAt: true,
content: true,
hasDrainageContent: true,
tenant: { select: { name: true } },
application: { select: { name: true } },
channel: { select: { name: true } },
@@ -114,7 +115,7 @@ async exportMessages(query: MessageQuery) {
orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }],
});
const rows = [
['消息编号', '企业', '应用', '提交时间', '手机号', '地区', '运营商', '计费条数', '金额', '通道', '状态', '回执时间', '短信内容'],
['消息编号', '企业', '应用', '提交时间', '手机号', '地区', '运营商', '计费条数', '金额', '通道', '状态', '是否含引流', '回执时间', '短信内容'],
...items.map((item) => [
item.messageId,
item.tenant?.name ?? '',
@@ -127,6 +128,7 @@ async exportMessages(query: MessageQuery) {
String(moneyToNumber(item.amountCents)),
item.channel?.name ?? '',
item.status === 'submit_failed' || ['rejected', 'timeout'].includes(item.submitStatus ?? '') ? 'submit_failed' : item.status,
item.hasDrainageContent === true ? '是' : item.hasDrainageContent === false ? '否' : '未检测',
item.deliveredAt?.toISOString() ?? '',
item.content,
]),
+104 -7
View File
@@ -37,7 +37,7 @@ async statistics(query: { tenantId?: string; groupBy?: string }) {
}
async sendQuality(date?: string) {
const day = qualityBusinessDay(date);
const [channels, signatures, summaryRows, applications] = await Promise.all([
const [channels, signatureSplits, summaryRows, applications] = await Promise.all([
this.prisma.$queryRaw<Array<{
channelId: string;
channelName: string;
@@ -150,7 +150,7 @@ async sendQuality(date?: string) {
WITH base AS (
SELECT
message."signatureId" AS signature_id,
(message."drainageInfoId" IS NOT NULL) AS has_drainage,
(message."hasDrainageContent" IS TRUE) AS has_drainage,
message.status,
message."submitStatus" AS submit_status,
message."receiptStatus" AS receipt_status,
@@ -303,6 +303,8 @@ async sendQuality(date?: string) {
ORDER BY total DESC, application.name
`),
]);
const signatures = aggregateSignatureRows(signatureSplits);
const drainageSignatures = signatureSplits.filter((item) => item.hasDrainage);
const summary = summaryRows[0] ?? {
total: 0,
successCount: 0,
@@ -310,7 +312,7 @@ async sendQuality(date?: string) {
failureCount: 0,
successRate: 0,
};
return { date: day.key, summary, channels, signatures, applications };
return { date: day.key, summary, channels, signatures, drainageSignatures, applications };
}
async signatureQuality(query: SignatureQualityQuery) {
const day = qualityBusinessDay(query.date);
@@ -413,13 +415,14 @@ async signatureQuality(query: SignatureQualityQuery) {
OFFSET ${(page - 1) * pageSize}
`);
const signatureIds = summaries.map((item) => item.signatureId);
const breakdowns = signatureIds.length === 0
const drainageBreakdowns = signatureIds.length === 0
? []
: await this.prisma.$queryRaw<Array<{
signatureId: string;
channelId: string;
channelName: string;
carrier: string;
drainageState: 'with' | 'without' | 'unknown';
total: number;
acceptedCount: number;
submitFailureCount: number;
@@ -435,6 +438,11 @@ async signatureQuality(query: SignatureQualityQuery) {
submit."channelId" AS channel_id,
channel.name AS channel_name,
COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier,
CASE
WHEN message."hasDrainageContent" IS TRUE THEN 'with'
WHEN message."hasDrainageContent" IS FALSE THEN 'without'
ELSE 'unknown'
END AS drainage_state,
submit."submitStatus" AS submit_status,
receipt."deliveredAt" AS delivered_at,
failed_receipt."failedAt" AS failed_at,
@@ -498,6 +506,7 @@ async signatureQuality(query: SignatureQualityQuery) {
channel_id AS "channelId",
MAX(channel_name) AS "channelName",
carrier,
drainage_state AS "drainageState",
COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedCount",
COUNT(*) FILTER (WHERE delivery_status = 'submit_failed')::integer AS "submitFailureCount",
@@ -514,8 +523,8 @@ async signatureQuality(query: SignatureQualityQuery) {
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
GROUP BY signature_id, channel_id, carrier, drainage_state
ORDER BY signature_id, COUNT(*) DESC, channel_id, carrier, drainage_state
`);
const carrierOverview = signatureIds.length === 0
? []
@@ -561,12 +570,14 @@ async signatureQuality(query: SignatureQualityQuery) {
ORDER BY message."signatureId", COUNT(*) DESC, carrier
`);
const items = summaries.map(({ rowCount: _rowCount, ...summary }) => {
const signatureBreakdowns = breakdowns.filter((item) => item.signatureId === summary.signatureId);
const signatureDrainageBreakdowns = drainageBreakdowns.filter((item) => item.signatureId === summary.signatureId);
const signatureBreakdowns = aggregateChannelCarrierRows(signatureDrainageBreakdowns);
return {
...summary,
channelSubmitTotal: signatureBreakdowns.reduce((sum, item) => sum + item.total, 0),
carrierOverview: carrierOverview.filter((item) => item.signatureId === summary.signatureId),
breakdowns: signatureBreakdowns,
drainageBreakdowns: signatureDrainageBreakdowns,
};
});
return {
@@ -578,3 +589,89 @@ async signatureQuality(query: SignatureQualityQuery) {
};
}
}
type SignatureSplitRow = {
id: string;
signatureId: string;
signatureName: string;
tenantId: string;
tenantName: string;
hasDrainage: boolean;
total: number;
acceptedCount: number;
submitFailureCount: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
};
function aggregateSignatureRows(rows: SignatureSplitRow[]) {
const grouped = new Map<string, SignatureSplitRow[]>();
rows.forEach((row) => grouped.set(row.signatureId, [...(grouped.get(row.signatureId) ?? []), row]));
return [...grouped.values()].map((parts) => {
const first = parts[0];
const total = parts.reduce((sum, item) => sum + item.total, 0);
const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0);
const successCount = parts.reduce((sum, item) => sum + item.successCount, 0);
const arrivalWeight = parts.reduce((sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount), 0);
return {
...first,
id: first.signatureId,
hasDrainage: false,
total,
acceptedCount,
submitFailureCount: parts.reduce((sum, item) => sum + item.submitFailureCount, 0),
successCount,
unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0),
failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0),
successRate: acceptedCount === 0 ? 0 : Math.round(successCount * 1000 / acceptedCount) / 10,
averageArrivalMs: arrivalWeight === 0 ? null : Math.round(parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight),
};
}).sort((left, right) => right.successCount - left.successCount || right.total - left.total || left.signatureName.localeCompare(right.signatureName));
}
type DrainageBreakdownRow = {
signatureId: string;
channelId: string;
channelName: string;
carrier: string;
drainageState: 'with' | 'without' | 'unknown';
total: number;
acceptedCount: number;
submitFailureCount: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
};
function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) {
const grouped = new Map<string, DrainageBreakdownRow[]>();
rows.forEach((row) => {
const key = `${row.signatureId}\u0000${row.channelId}\u0000${row.carrier}`;
grouped.set(key, [...(grouped.get(key) ?? []), row]);
});
return [...grouped.values()].map((parts) => {
const first = parts[0];
const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0);
const successCount = parts.reduce((sum, item) => sum + item.successCount, 0);
const arrivalWeight = parts.reduce((sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount), 0);
return {
signatureId: first.signatureId,
channelId: first.channelId,
channelName: first.channelName,
carrier: first.carrier,
total: parts.reduce((sum, item) => sum + item.total, 0),
acceptedCount,
submitFailureCount: parts.reduce((sum, item) => sum + item.submitFailureCount, 0),
successCount,
unknownCount: parts.reduce((sum, item) => sum + item.unknownCount, 0),
failureCount: parts.reduce((sum, item) => sum + item.failureCount, 0),
successRate: acceptedCount === 0 ? 0 : Math.round(successCount * 1000 / acceptedCount) / 10,
averageArrivalMs: arrivalWeight === 0 ? null : Math.round(parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight),
};
});
}