feat: enforce signature-scoped drainage authorization before SMS submission
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user