feat: enforce signature-scoped drainage authorization before SMS submission

This commit is contained in:
hectorzhao
2026-09-10 13:29:04 +08:00
parent 5bcdbb2a03
commit 0c3f820cc9
35 changed files with 2769 additions and 791 deletions
+153 -69
View File
@@ -1,17 +1,22 @@
import { Prisma } from '@prisma/client';
import { BadRequestException } from '@nestjs/common';
import { moneyToNumber } from '../common/money';
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from './operations.contracts';
import type {
MessageQuery,
DownstreamDeliveryDashboardQuery,
DownstreamRecoveryStatusQuery,
} from './operations.contracts';
// Pure query builders and response mappers shared by the R2 query domains.
export 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 }
: {};
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 {
tenantId: query.tenantId,
applicationId: query.applicationId,
@@ -21,25 +26,39 @@ 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.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 ? {
queuedAt: {
...(query.queuedAtFrom ? { gte: startOfShanghaiDay(query.queuedAtFrom) } : {}),
...(query.queuedAtTo ? { lte: endOfShanghaiDay(query.queuedAtTo) } : {}),
},
} : {}),
...(query.queuedAtFrom || query.queuedAtTo
? {
queuedAt: {
...(query.queuedAtFrom ? { gte: startOfShanghaiDay(query.queuedAtFrom) } : {}),
...(query.queuedAtTo ? { lte: endOfShanghaiDay(query.queuedAtTo) } : {}),
},
}
: {}),
};
}
export const recognizedCarrierValues = [
'mobile', 'cmcc', '移动', '中国移动',
'unicom', 'cucc', '联通', '中国联通',
'telecom', 'ctcc', '电信', '中国电信',
'mobile',
'cmcc',
'移动',
'中国移动',
'unicom',
'cucc',
'联通',
'中国联通',
'telecom',
'ctcc',
'电信',
'中国电信',
];
export function carrierWhere(carrier?: string): Prisma.SmsMessageRecordWhereInput {
if (!carrier) return {};
@@ -48,10 +67,7 @@ export function carrierWhere(carrier?: string): Prisma.SmsMessageRecordWhereInpu
return {
AND: [
{
OR: [
{ carrier: null },
{ carrier: { notIn: recognizedCarrierValues } },
],
OR: [{ carrier: null }, { carrier: { notIn: recognizedCarrierValues } }],
},
],
};
@@ -107,10 +123,7 @@ export function returnedTransactionWhere(since: Date, tenantId?: string): Prisma
return {
tenantId,
createdAt: { gte: since },
OR: [
{ transactionType: 'refunded' },
{ transactionType: 'released', relatedType: 'sms_message_record' },
],
OR: [{ transactionType: 'refunded' }, { transactionType: 'released', relatedType: 'sms_message_record' }],
};
}
export function createdAtRange(range?: string): Prisma.DateTimeFilter | undefined {
@@ -161,13 +174,12 @@ export function downstreamAlertWhere(
export function stalledPendingWhere(cutoff: Date): Prisma.CmppDownstreamDeliveryWhereInput {
return {
status: 'pending',
OR: [
{ lastRetriedAt: null, createdAt: { lte: cutoff } },
{ lastRetriedAt: { lte: cutoff } },
],
OR: [{ lastRetriedAt: null, createdAt: { lte: cutoff } }, { lastRetriedAt: { lte: cutoff } }],
};
}
export function downstreamDeliveryScopedWhere(query: DownstreamDeliveryDashboardQuery): Prisma.CmppDownstreamDeliveryWhereInput {
export function downstreamDeliveryScopedWhere(
query: DownstreamDeliveryDashboardQuery,
): Prisma.CmppDownstreamDeliveryWhereInput {
const createdAtFrom = parseDateBoundary(query.createdAtFrom, false);
const createdAtTo = parseDateBoundary(query.createdAtTo, true);
return {
@@ -191,14 +203,16 @@ export function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQue
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 } },
{ lastError: { contains: query.keyword } },
{ lastSkipReason: { contains: query.keyword } },
{ tenant: { name: { contains: query.keyword } } },
{ application: { name: { contains: query.keyword } } },
] : undefined,
OR: query.keyword
? [
{ account: { contains: query.keyword } },
{ gatewayInstanceId: { contains: query.keyword } },
{ lastError: { contains: query.keyword } },
{ lastSkipReason: { contains: query.keyword } },
{ tenant: { name: { contains: query.keyword } } },
{ application: { name: { contains: query.keyword } } },
]
: undefined,
};
}
export function escapeCsvCell(value: string) {
@@ -254,6 +268,21 @@ export function clientMessageView(message: Record<string, any>) {
carrier: message.carrier ?? null,
province: message.province ?? null,
content: message.content,
drainageGate: message.drainageGate
? {
version: message.drainageGate.version,
evaluatedAt: message.drainageGate.evaluatedAt,
reason: message.drainageGate.reason,
reasonCode: message.drainageGate.reasonCode,
targets: (message.drainageGate.targets ?? []).map(
(target: { text: string; category: string; value: string }) => ({
text: target.text,
category: target.category,
value: target.value,
}),
),
}
: null,
billingUnits: message.billingUnits,
amountCents: moneyToNumber(message.amountCents),
status: message.status,
@@ -338,7 +367,13 @@ export function clientRechargeView(order: Record<string, any>) {
completedAt: order.completedAt ?? null,
};
}
export function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all: number }; _sum: { amountCents: number | bigint | null; billingUnits: number | null } }>) {
export function summarizeMessageGroups(
groups: Array<{
status: string;
_count: { _all: number };
_sum: { amountCents: number | bigint | null; billingUnits: number | null };
}>,
) {
return groups.reduce(
(summary, group) => {
const count = group._count._all;
@@ -360,8 +395,29 @@ export function summarizeMessageGroups(groups: Array<{ status: string; _count: {
export function groupDownstreamByType(
groups: Array<{ deliveryType: string; status: string; _count: { _all: number } }>,
) {
return groups.reduce<Record<string, { total: number; pending: number; awaitingAck: number; delivered: number; failed: number; unconfirmed: number; rejected: number }>>((accumulator, item) => {
const current = accumulator[item.deliveryType] ?? { total: 0, pending: 0, awaitingAck: 0, delivered: 0, failed: 0, unconfirmed: 0, rejected: 0 };
return groups.reduce<
Record<
string,
{
total: number;
pending: number;
awaitingAck: number;
delivered: number;
failed: number;
unconfirmed: number;
rejected: number;
}
>
>((accumulator, item) => {
const current = accumulator[item.deliveryType] ?? {
total: 0,
pending: 0,
awaitingAck: 0,
delivered: 0,
failed: 0,
unconfirmed: 0,
rejected: 0,
};
current.total += item._count._all;
if (item.status === 'pending') {
current.pending += item._count._all;
@@ -385,7 +441,20 @@ export function groupDownstreamByApplication(
applicationMap: Map<string, string>,
applicationAlertMap: Map<string, number>,
) {
const summaryMap = new Map<string, { applicationId: string; name: string; pending: number; awaitingAck: number; failed: number; unconfirmed: number; rejected: number; delivered: number; alertCount: number }>();
const summaryMap = new Map<
string,
{
applicationId: string;
name: string;
pending: number;
awaitingAck: number;
failed: number;
unconfirmed: number;
rejected: number;
delivered: number;
alertCount: number;
}
>();
groups.forEach((item) => {
const current = summaryMap.get(item.applicationId) ?? {
applicationId: item.applicationId,
@@ -430,10 +499,7 @@ export function operationLogLevelWhere(level: string): Prisma.OperationLogWhereI
],
};
const warning: Prisma.OperationLogWhereInput = {
OR: [
{ action: { contains: 'warning' } },
{ action: { contains: 'risk' } },
],
OR: [{ action: { contains: 'warning' } }, { action: { contains: 'risk' } }],
};
const success: Prisma.OperationLogWhereInput = {
OR: [
@@ -459,13 +525,14 @@ export function operationLogLevelWhere(level: string): Prisma.OperationLogWhereI
export function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { tenant: true; user: true } }>) {
const detail = (log.detail ?? {}) as Record<string, unknown>;
const result = String(detail.result ?? detail.status ?? '');
const level = result.includes('fail') || log.action.includes('failed') || log.action.includes('reject')
? 'error'
: log.action.includes('warning') || log.action.includes('risk')
? 'warning'
: log.action.includes('approve') || log.action.includes('recharge') || log.action.includes('connected')
? 'success'
: 'info';
const level =
result.includes('fail') || log.action.includes('failed') || log.action.includes('reject')
? 'error'
: log.action.includes('warning') || log.action.includes('risk')
? 'warning'
: log.action.includes('approve') || log.action.includes('recharge') || log.action.includes('connected')
? 'success'
: 'info';
return {
id: log.id,
time: log.createdAt,
@@ -482,22 +549,32 @@ export function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ inclu
}
export function sanitizeGatewaySubmitException(
item: Prisma.GatewaySubmitDeadLetterGetPayload<{ include: { tenant: true; application: true; channel: true } }>,
messageState?: { status: string; submitStatus: string | null; receiptStatus: string | null; phoneNumber: string; content: string },
messageState?: {
status: string;
submitStatus: string | null;
receiptStatus: string | null;
phoneNumber: string;
content: string;
},
) {
const { rawPayload, commandPayload, tenant, application, channel, ...record } = item;
return {
...record,
tenant: tenant ? { id: tenant.id, name: tenant.name, code: tenant.code, status: tenant.status } : null,
application: application ? { id: application.id, tenantId: application.tenantId, name: application.name, status: application.status } : null,
channel: channel ? {
id: channel.id,
code: channel.code,
name: channel.name,
status: channel.status,
carrier: channel.carrier,
sendRegion: channel.sendRegion,
rateLimitPerSecond: channel.rateLimitPerSecond,
} : null,
application: application
? { id: application.id, tenantId: application.tenantId, name: application.name, status: application.status }
: null,
channel: channel
? {
id: channel.id,
code: channel.code,
name: channel.name,
status: channel.status,
carrier: channel.carrier,
sendRegion: channel.sendRegion,
rateLimitPerSecond: channel.rateLimitPerSecond,
}
: null,
rawPayloadAvailable: Boolean(rawPayload),
commandPayload: redactGatewayCommandValue(commandPayload),
messageState: messageState ?? null,
@@ -512,8 +589,15 @@ export function redactGatewayCommandValue(value: Prisma.JsonValue | null): Prism
for (const [key, child] of Object.entries(value)) {
const normalizedKey = key.toLowerCase();
redacted[key] = [
'password', 'passwordcipher', 'secret', 'secrethash', 'authsource',
'token', 'apikey', 'accesskey', 'secretkey',
'password',
'passwordcipher',
'secret',
'secrethash',
'authsource',
'token',
'apikey',
'accesskey',
'secretkey',
].includes(normalizedKey)
? '[REDACTED]'
: redactGatewayCommandValue(child as Prisma.JsonValue);