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
@@ -0,0 +1,34 @@
CREATE TABLE "DrainageDetectionRule" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT NOT NULL,
"pattern" TEXT NOT NULL,
"flags" TEXT NOT NULL DEFAULT 'giu',
"priority" INTEGER NOT NULL DEFAULT 100,
"status" TEXT NOT NULL DEFAULT 'active',
"description" TEXT,
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DrainageDetectionRule_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "DrainageDetectionRule_code_key" ON "DrainageDetectionRule"("code");
CREATE INDEX "DrainageDetectionRule_status_priority_idx" ON "DrainageDetectionRule"("status", "priority");
ALTER TABLE "SmsMessageRecord"
ADD COLUMN "hasDrainageContent" BOOLEAN,
ADD COLUMN "drainageDetection" JSONB,
ADD COLUMN "drainageDetectionVersion" TEXT,
ADD COLUMN "drainageEvaluatedAt" TIMESTAMP(3);
CREATE INDEX "SmsMessageRecord_hasDrainageContent_queuedAt_idx"
ON "SmsMessageRecord"("hasDrainageContent", "queuedAt");
INSERT INTO "DrainageDetectionRule"
("id", "code", "name", "category", "pattern", "flags", "priority", "status", "description", "version", "updatedAt")
VALUES
('drainage-rule-url', 'URL', 'URL及裸域名', 'url', $regex$(?:https?:\/\/)?(?:www\.)?(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,24}|(?:\d{1,3}\.){3}\d{1,3})(?::\d{1,5})?(?:\/[^\s,;!?<>]*)?$regex$, 'giu', 10, 'active', '识别协议链接、裸域名、短链接及IP地址链接;邮箱区间由检测器排除', 1, CURRENT_TIMESTAMP),
('drainage-rule-mobile', 'MOBILE', '手机号码', 'mobile', $regex$(?:^|[^0-9])((?:\+?86)?1[3-9][0-9]{9})(?:$|[^0-9])$regex$, 'giu', 20, 'active', '规范化后识别+86、空格、短横线及中文标点拆分手机号', 1, CURRENT_TIMESTAMP),
('drainage-rule-landline', 'LANDLINE', '固定电话号码', 'landline', $regex$(?:^|[^0-9])((?:\+?86)?(?:\(0[0-9]{2,3}\)|0[0-9]{2,3})-?[0-9]{7,8}(?:(?:||ext)[0-9]{1,6})?)(?:$|[^0-9])$regex$, 'giu', 30, 'active', '识别区号括号、分隔符和分机号', 1, CURRENT_TIMESTAMP);
@@ -0,0 +1,23 @@
-- PostgreSQL standard-conforming strings preserve backslashes literally. The initial
-- seed used JavaScript-style escaping, so already-migrated databases need their three
-- built-in patterns normalized to the single backslashes expected by RegExp.
UPDATE "DrainageDetectionRule"
SET
"pattern" = $regex$(?:https?:\/\/)?(?:www\.)?(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,24}|(?:\d{1,3}\.){3}\d{1,3})(?::\d{1,5})?(?:\/[^\s,;!?<>]*)?$regex$,
"version" = "version" + 1,
"updatedAt" = CURRENT_TIMESTAMP
WHERE "code" = 'URL';
UPDATE "DrainageDetectionRule"
SET
"pattern" = $regex$(?:^|[^0-9])((?:\+?86)?1[3-9][0-9]{9})(?:$|[^0-9])$regex$,
"version" = "version" + 1,
"updatedAt" = CURRENT_TIMESTAMP
WHERE "code" = 'MOBILE';
UPDATE "DrainageDetectionRule"
SET
"pattern" = $regex$(?:^|[^0-9])((?:\+?86)?(?:\(0[0-9]{2,3}\)|0[0-9]{2,3})-?[0-9]{7,8}(?:(?:||ext)[0-9]{1,6})?)(?:$|[^0-9])$regex$,
"version" = "version" + 1,
"updatedAt" = CURRENT_TIMESTAMP
WHERE "code" = 'LANDLINE';
@@ -0,0 +1,12 @@
ALTER TABLE "SmsMessageRecord"
ADD COLUMN "cmppRegisteredDelivery" BOOLEAN,
ADD COLUMN "timeoutReceiptQueuedAt" TIMESTAMP(3);
ALTER TABLE "CmppInboundLongMessageSegment"
ADD COLUMN "registeredDelivery" BOOLEAN NOT NULL DEFAULT true;
-- Historical CMPP submissions were accepted before Registered_Delivery was
-- persisted. Preserve their existing receipt-enabled behavior.
UPDATE "SmsMessageRecord"
SET "cmppRegisteredDelivery" = true
WHERE "cmppSubmitSequenceId" IS NOT NULL;
+25
View File
@@ -305,6 +305,23 @@ model DrainageField {
commonReportFields CommonReportField[]
}
model DrainageDetectionRule {
id String @id @default(cuid())
code String @unique
name String
category String
pattern String
flags String @default("giu")
priority Int @default(100)
status String @default("active")
description String?
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, priority])
}
model TenantAccount {
id String @id @default(cuid())
tenantId String
@@ -1502,6 +1519,10 @@ model SmsMessageRecord {
carrier String?
province String?
content String
hasDrainageContent Boolean?
drainageDetection Json?
drainageDetectionVersion String?
drainageEvaluatedAt DateTime?
billingUnits Int @default(1)
unitPrice BigInt @default(0)
amountCents BigInt @default(0)
@@ -1511,6 +1532,7 @@ model SmsMessageRecord {
gatewayMessageId String?
cmppSubmitSequenceId String?
cmppSubmitGroupMessageId String?
cmppRegisteredDelivery Boolean?
clientSrcId String?
applicationExtension String?
status String @default("queued")
@@ -1523,6 +1545,7 @@ model SmsMessageRecord {
submittedAt DateTime?
deliveredAt DateTime?
timeoutAt DateTime?
timeoutReceiptQueuedAt DateTime?
updatedAt DateTime @updatedAt
tenant Tenant? @relation(fields: [tenantId], references: [id])
@@ -1548,6 +1571,7 @@ model SmsMessageRecord {
@@index([phoneNumber])
@@index([gatewayMessageId])
@@index([drainageInfoId, queuedAt])
@@index([hasDrainageContent, queuedAt])
}
model CmppSubmitSession {
@@ -1758,6 +1782,7 @@ model CmppInboundLongMessageSegment {
groupId String
segmentIndex Int
sequenceId String?
registeredDelivery Boolean @default(true)
content String
contentHash String
createdAt DateTime @default(now())
@@ -26,8 +26,8 @@ export class AdminCertificationController {
constructor(private readonly certifications: CertificationService) {}
@Get()
list(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string) {
return this.certifications.list(tenantId, status, keyword);
list(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('submittedAtFrom') submittedAtFrom?: string, @Query('submittedAtTo') submittedAtTo?: string) {
return this.certifications.list(tenantId, status, keyword, submittedAtFrom, submittedAtTo);
}
@Get(':id')
@@ -38,6 +38,22 @@ describe('CertificationService', () => {
]);
});
it('filters enterprise certification submissions by Shanghai date range', async () => {
const prisma = createPrismaMock();
const service = new CertificationService(prisma as never);
await service.list(undefined, 'pending', undefined, '2026-08-01', '2026-08-03');
expect(prisma.enterpriseCertification.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
submittedAt: {
gte: new Date('2026-08-01T00:00:00+08:00'),
lte: new Date('2026-08-03T23:59:59.999+08:00'),
},
}),
}));
});
it('submits certification and marks tenant pending', async () => {
const prisma = createPrismaMock();
const service = new CertificationService(prisma as never);
@@ -1,6 +1,7 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { shanghaiDateRange } from '../common/shanghai-date-range';
export interface SubmitCertificationDto {
tenantId: string;
@@ -20,11 +21,12 @@ export interface ReviewCertificationDto {
export class CertificationService {
constructor(private readonly prisma: PrismaService) {}
async list(tenantId?: string, status?: string, keyword?: string) {
async list(tenantId?: string, status?: string, keyword?: string, submittedAtFrom?: string, submittedAtTo?: string) {
const records = await this.prisma.enterpriseCertification.findMany({
where: {
tenantId,
status: status && status !== 'all' ? status : undefined,
submittedAt: shanghaiDateRange(submittedAtFrom, submittedAtTo),
OR: keyword ? [
{ companyName: { contains: keyword } },
{ licenseNo: { contains: keyword } },
+3
View File
@@ -8,6 +8,7 @@ import { PrismaService } from '../prisma/prisma.service';
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
import { ChannelConnectionService } from './channel-connection.service';
import { detectDrainageContent } from '../send-chain/drainage-content-detection';
/** R5 channel domain service composed behind ChannelsService. */
export class ChannelTestService {
@@ -35,6 +36,7 @@ export class ChannelTestService {
const createdAt = new Date();
const testNo = `CHTEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
const drainageDetection = await detectDrainageContent(this.prisma, content);
const results = [];
for (const [index, phoneNumber] of phoneNumbers.entries()) {
const messageId = `MSG-TEST-${Date.now()}-${randomUUID().slice(0, 8)}`;
@@ -49,6 +51,7 @@ export class ChannelTestService {
messageId,
phoneNumber,
content,
...drainageDetection,
billingUnits: calculateBillingUnits(content),
unitPrice: 0,
amountCents: 0,
@@ -104,6 +104,9 @@ function createPrismaMock() {
findUnique: jest.fn().mockResolvedValue({ id: 'library-1', code: 'license', name: '营业执照', fieldType: 'file', required: true, status: 'active', description: '执照文件' }),
findMany: jest.fn().mockResolvedValue([{ id: 'library-1', code: 'license', name: '营业执照', fieldType: 'file', required: true, status: 'active', description: '执照文件' }]),
},
drainageDetectionRule: {
findMany: jest.fn().mockResolvedValue([]),
},
signatureReportMaterial: {
findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]),
createMany: jest.fn(),
@@ -0,0 +1,17 @@
import { BadRequestException } from '@nestjs/common';
import { shanghaiDateRange } from './shanghai-date-range';
describe('shanghaiDateRange', () => {
it('builds an inclusive Asia/Shanghai day range', () => {
expect(shanghaiDateRange('2026-08-01', '2026-08-03')).toEqual({
gte: new Date('2026-08-01T00:00:00+08:00'),
lte: new Date('2026-08-03T23:59:59.999+08:00'),
});
});
it('rejects malformed or reversed ranges', () => {
expect(() => shanghaiDateRange('2026/08/01', undefined)).toThrow(BadRequestException);
expect(() => shanghaiDateRange('2026-02-31', undefined)).toThrow('日期无效');
expect(() => shanghaiDateRange('2026-08-03', '2026-08-01')).toThrow('开始日期不能晚于结束日期');
});
});
+24
View File
@@ -0,0 +1,24 @@
import { BadRequestException } from '@nestjs/common';
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
function parseBoundary(value: string | undefined, endOfDay: boolean) {
if (!value) return undefined;
if (!DATE_PATTERN.test(value)) throw new BadRequestException('日期格式必须为 YYYY-MM-DD');
const [year, month, day] = value.split('-').map(Number);
const calendarDate = new Date(Date.UTC(year, month - 1, day));
if (calendarDate.getUTCFullYear() !== year || calendarDate.getUTCMonth() !== month - 1 || calendarDate.getUTCDate() !== day) {
throw new BadRequestException('日期无效');
}
const parsed = new Date(`${value}T${endOfDay ? '23:59:59.999' : '00:00:00'}+08:00`);
if (Number.isNaN(parsed.getTime())) throw new BadRequestException('日期无效');
return parsed;
}
/** Converts UI calendar dates to an inclusive Asia/Shanghai database range. */
export function shanghaiDateRange(from?: string, to?: string) {
const gte = parseBoundary(from, false);
const lte = parseBoundary(to, true);
if (gte && lte && gte > lte) throw new BadRequestException('开始日期不能晚于结束日期');
return gte || lte ? { gte, lte } : undefined;
}
@@ -1,9 +1,12 @@
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import {
CreateCommonReportFieldDto,
CreateBlacklistDto,
CreateDrainageFieldDto,
UpsertDrainageDetectionRuleDto,
TestDrainageDetectionDto,
CreatePhoneCarrierRuleDto,
CreatePhoneSegmentDto,
CreateSensitiveWordDto,
@@ -125,6 +128,31 @@ export class DictionariesController {
return this.dictionaries.deleteDrainageField(id);
}
@Get('drainage-detection-rules')
listDrainageDetectionRules(@Query('keyword') keyword?: string, @Query('status') status?: string) {
return this.dictionaries.listDrainageDetectionRules({ keyword, status });
}
@Post('drainage-detection-rules/test')
testDrainageDetection(@Body() body: TestDrainageDetectionDto) {
return this.dictionaries.testDrainageDetection(body);
}
@Post('drainage-detection-rules')
createDrainageDetectionRule(@Body() body: UpsertDrainageDetectionRuleDto, @CurrentSessionUserId() operatorId?: string) {
return this.dictionaries.createDrainageDetectionRule({ ...body, operatorId });
}
@Put('drainage-detection-rules/:id')
updateDrainageDetectionRule(@Param('id') id: string, @Body() body: UpsertDrainageDetectionRuleDto, @CurrentSessionUserId() operatorId?: string) {
return this.dictionaries.updateDrainageDetectionRule(id, { ...body, operatorId });
}
@Post('drainage-detection-rules/:id/status')
changeDrainageDetectionRuleStatus(@Param('id') id: string, @Body() body: DictionaryStatusDto, @CurrentSessionUserId() operatorId?: string) {
return this.dictionaries.changeDrainageDetectionRuleStatus(id, { ...body, operatorId });
}
@Get('common-report-fields')
listCommonReportFields() {
return this.dictionaries.listCommonReportFields();
@@ -2,6 +2,11 @@ import { BadRequestException, ConflictException, Injectable, Optional } from '@n
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
import {
detectDrainageContentWithRules,
invalidateDrainageDetectionRuleCache,
validateDrainageDetectionPattern,
} from '../send-chain/drainage-content-detection';
export interface CreatePhoneSegmentDto {
prefix: string;
@@ -58,6 +63,23 @@ export interface CreateDrainageFieldDto {
description?: string;
}
export interface UpsertDrainageDetectionRuleDto {
code: string;
name: string;
category: 'url' | 'mobile' | 'landline';
pattern: string;
flags?: string;
priority?: number;
status?: string;
description?: string;
operatorId?: string;
}
export interface TestDrainageDetectionDto {
content: string;
rule?: UpsertDrainageDetectionRuleDto;
}
export interface CreateCommonReportFieldDto {
drainageFieldId: string;
reportType: 'signature' | 'drainage';
@@ -374,6 +396,97 @@ export class DictionariesService {
});
}
listDrainageDetectionRules(query: { keyword?: string; status?: string } = {}) {
const keyword = query.keyword?.trim();
return this.prisma.drainageDetectionRule.findMany({
where: {
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
OR: keyword ? [
{ code: { contains: keyword, mode: 'insensitive' } },
{ name: { contains: keyword, mode: 'insensitive' } },
{ description: { contains: keyword, mode: 'insensitive' } },
] : undefined,
},
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
});
}
async createDrainageDetectionRule(data: UpsertDrainageDetectionRuleDto) {
this.validateDrainageDetectionRule(data);
const created = await this.prisma.drainageDetectionRule.create({
data: {
code: data.code.trim().toUpperCase(),
name: data.name.trim(),
category: data.category,
pattern: data.pattern,
flags: data.flags ?? 'giu',
priority: data.priority ?? 100,
status: data.status ?? 'active',
description: data.description?.trim() || null,
},
});
invalidateDrainageDetectionRuleCache();
await this.writeOperationLog(data.operatorId, 'drainage_detection_rule.create', 'drainage_detection_rule', created.id, { code: created.code });
return created;
}
async updateDrainageDetectionRule(id: string, data: UpsertDrainageDetectionRuleDto) {
this.validateDrainageDetectionRule(data);
const updated = await this.prisma.drainageDetectionRule.update({
where: { id },
data: {
code: data.code.trim().toUpperCase(),
name: data.name.trim(),
category: data.category,
pattern: data.pattern,
flags: data.flags ?? 'giu',
priority: data.priority ?? 100,
status: data.status ?? 'active',
description: data.description?.trim() || null,
version: { increment: 1 },
},
});
invalidateDrainageDetectionRuleCache();
await this.writeOperationLog(data.operatorId, 'drainage_detection_rule.update', 'drainage_detection_rule', id, { code: updated.code, version: updated.version });
return updated;
}
async changeDrainageDetectionRuleStatus(id: string, data: DictionaryStatusDto) {
const status = data.status === 'inactive' ? 'inactive' : 'active';
const updated = await this.prisma.drainageDetectionRule.update({
where: { id },
data: { status, version: { increment: 1 } },
});
invalidateDrainageDetectionRuleCache();
await this.writeOperationLog(data.operatorId, `drainage_detection_rule.${status}`, 'drainage_detection_rule', id, { reason: data.reason });
return updated;
}
async testDrainageDetection(data: TestDrainageDetectionDto) {
if (!data.content?.trim()) throw new BadRequestException('测试短信内容不能为空');
const rules = data.rule
? [{
id: 'preview',
code: data.rule.code?.trim().toUpperCase() || 'PREVIEW',
name: data.rule.name?.trim() || '预览规则',
category: data.rule.category,
pattern: data.rule.pattern,
flags: data.rule.flags ?? 'giu',
priority: data.rule.priority ?? 100,
version: 1,
}]
: await this.prisma.drainageDetectionRule.findMany({ where: { status: 'active' }, orderBy: { priority: 'asc' } });
if (data.rule) this.validateDrainageDetectionRule(data.rule);
return detectDrainageContentWithRules(data.content, rules);
}
private validateDrainageDetectionRule(data: UpsertDrainageDetectionRuleDto) {
if (!data.code?.trim() || !data.name?.trim()) throw new BadRequestException('规则编码和名称不能为空');
if (!['url', 'mobile', 'landline'].includes(data.category)) throw new BadRequestException('规则类型仅支持 URL、手机号或固话');
if (data.status && !['active', 'inactive'].includes(data.status)) throw new BadRequestException('规则状态不正确');
validateDrainageDetectionPattern(data.pattern, data.flags ?? 'giu');
}
listCommonReportFields() {
return this.prisma.commonReportField.findMany({
include: { drainageField: true },
@@ -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),
};
});
}
@@ -123,8 +123,8 @@ export class AdminRiskReviewController {
}
@Get('tasks')
listTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string) {
return this.riskReview.listTasks(tenantId, status);
listTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('submittedAtFrom') submittedAtFrom?: string, @Query('submittedAtTo') submittedAtTo?: string) {
return this.riskReview.listTasks(tenantId, status, submittedAtFrom, submittedAtTo);
}
@Get('tasks/pending')
@@ -88,6 +88,23 @@ describe('RiskReviewService', () => {
}));
});
it('filters SMS review tasks by their submission time', async () => {
const prisma = createPrismaMock();
prisma.smsSendTask.findMany.mockResolvedValue([]);
const service = new RiskReviewService(prisma as never);
await service.listTasks(undefined, 'pending_review', '2026-08-01', '2026-08-03');
expect(prisma.smsSendTask.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
createdAt: {
gte: new Date('2026-08-01T00:00:00+08:00'),
lte: new Date('2026-08-03T23:59:59.999+08:00'),
},
}),
}));
});
it('groups identical CMPP template mismatches into a deterministic short review window', async () => {
const prisma = createPrismaMock();
prisma.smsSendTask.findUnique.mockResolvedValue({
+3 -1
View File
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { Prisma } from '@prisma/client';
import { createHash, randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { shanghaiDateRange } from '../common/shanghai-date-range';
export interface CreateRiskRuleDto {
tenantId?: string;
@@ -199,11 +200,12 @@ export class RiskReviewService {
});
}
listTasks(tenantId?: string, status?: string) {
listTasks(tenantId?: string, status?: string, submittedAtFrom?: string, submittedAtTo?: string) {
return this.prisma.smsSendTask.findMany({
where: {
tenantId,
status,
createdAt: shanghaiDateRange(submittedAtFrom, submittedAtTo),
...(status === 'approved' || status === 'rejected' ? { reviewedById: { not: null } } : {}),
...(!status ? {
OR: [
@@ -0,0 +1,73 @@
import { queueFinalReceiptDeliveries } from './downstream-receipt-targets';
describe('queueFinalReceiptDeliveries', () => {
it('queues one HTTP event and one CMPP receipt for each registered client fragment', async () => {
const prisma = {
cmppInboundLongMessage: {
findFirst: jest.fn().mockResolvedValue({
messageId: 'MSG-GROUP',
segmentTotal: 3,
segments: [
{ segmentIndex: 1, sequenceId: '101', registeredDelivery: true },
{ segmentIndex: 2, sequenceId: '102', registeredDelivery: false },
{ segmentIndex: 3, sequenceId: '103', registeredDelivery: true },
],
}),
},
};
const queue = jest.fn().mockResolvedValue({ id: 'queued' });
await queueFinalReceiptDeliveries(prisma as never, queue, {
message: {
id: 'record-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
cmppSubmitGroupMessageId: 'MSG-GROUP',
},
payload: { receiptStatus: 'delivered', rawStatus: 'DELIVRD' },
segmentPayloads: {
1: { receiptStatus: 'delivered', rawStatus: 'DELIVRD' },
3: { receiptStatus: 'undelivered', rawStatus: 'REJECTD' },
},
});
expect(queue).toHaveBeenCalledTimes(3);
expect(queue).toHaveBeenNthCalledWith(1, expect.objectContaining({
queueHttpWebhook: true,
queueCmppDelivery: false,
}));
expect(queue).toHaveBeenNthCalledWith(2, expect.objectContaining({
receiptDedupeKey: 'receipt:record-1:segment:1',
queueHttpWebhook: false,
payload: expect.objectContaining({ submitSequenceId: 101, clientSegmentIndex: 1 }),
}));
expect(queue).toHaveBeenNthCalledWith(3, expect.objectContaining({
receiptDedupeKey: 'receipt:record-1:segment:3',
payload: expect.objectContaining({ submitSequenceId: 103, clientSegmentIndex: 3, receiptStatus: 'undelivered', rawStatus: 'REJECTD' }),
}));
});
it('queues only the message-level HTTP event when the submission did not originate from CMPP', async () => {
const prisma = { cmppInboundLongMessage: { findFirst: jest.fn() } };
const queue = jest.fn().mockResolvedValue({ id: 'queued' });
await queueFinalReceiptDeliveries(prisma as never, queue, {
message: {
id: 'record-http',
tenantId: 'tenant-1',
applicationId: 'app-1',
messageId: 'MSG-HTTP',
phoneNumber: '13800000001',
},
payload: { receiptStatus: 'undelivered', rawStatus: 'EXPIRED' },
});
expect(queue).toHaveBeenCalledTimes(1);
expect(queue).toHaveBeenCalledWith(expect.objectContaining({
queueHttpWebhook: true,
queueCmppDelivery: false,
}));
});
});
@@ -0,0 +1,131 @@
import type { PrismaService } from '../prisma/prisma.service';
export type FinalReceiptMessage = {
id: string;
tenantId?: string | null;
applicationId?: string | null;
messageId: string;
phoneNumber: string;
cmppSubmitSequenceId?: string | null;
cmppSubmitGroupMessageId?: string | null;
cmppRegisteredDelivery?: boolean | null;
};
export type DownstreamDeliveryQueueRequest = {
tenantId: string;
applicationId?: string | null;
messageRecordId?: string | null;
messageId?: string | null;
deliveryType: 'receipt' | 'uplink';
payload: Record<string, unknown>;
receiptDedupeKey?: string;
queueHttpWebhook?: boolean;
queueCmppDelivery?: boolean;
propagateHttpQueueError?: boolean;
};
type ClientReceiptTarget = {
segmentIndex: number;
segmentTotal: number;
submitSequenceId: number;
submitGroupMessageId: string;
registeredDelivery: boolean;
};
async function resolveClientReceiptTargets(
prisma: PrismaService,
message: FinalReceiptMessage,
): Promise<ClientReceiptTarget[]> {
if (message.cmppSubmitGroupMessageId) {
const group = await prisma.cmppInboundLongMessage.findFirst({
where: { messageId: message.cmppSubmitGroupMessageId },
include: { segments: { orderBy: { segmentIndex: 'asc' } } },
});
if (group?.segments.length) {
return group.segments.flatMap((segment) => {
const submitSequenceId = Number(segment.sequenceId);
if (!Number.isInteger(submitSequenceId) || submitSequenceId <= 0) return [];
return [{
segmentIndex: segment.segmentIndex,
segmentTotal: group.segmentTotal,
submitSequenceId,
submitGroupMessageId: group.messageId,
registeredDelivery: segment.registeredDelivery,
}];
});
}
}
const submitSequenceId = Number(message.cmppSubmitSequenceId);
if (!Number.isInteger(submitSequenceId) || submitSequenceId <= 0) return [];
return [{
segmentIndex: 1,
segmentTotal: 1,
submitSequenceId,
submitGroupMessageId: message.cmppSubmitGroupMessageId ?? message.messageId,
// Null means a historical CMPP record created before this field existed.
registeredDelivery: message.cmppRegisteredDelivery !== false,
}];
}
/**
* Queue one business-level HTTP callback and one CMPP status report for every
* original client fragment that requested Registered_Delivery. Internal retry,
* refund and billing remain message-level; only protocol delivery is expanded.
*/
export async function queueFinalReceiptDeliveries(
prisma: PrismaService,
queue: (request: DownstreamDeliveryQueueRequest) => Promise<unknown>,
data: {
message: FinalReceiptMessage;
payload: Record<string, unknown>;
segmentPayloads?: Record<number, Record<string, unknown>>;
propagateHttpQueueError?: boolean;
},
) {
const { message } = data;
if (!message.tenantId || !message.applicationId) {
return { queued: false, cmppTargetCount: 0 };
}
// HTTP submissions have one client message identity, so their webhook stays
// message-level even when the carrier internally split the SMS into segments.
await queue({
tenantId: message.tenantId,
applicationId: message.applicationId,
messageRecordId: message.id,
messageId: message.messageId,
deliveryType: 'receipt',
payload: data.payload,
queueHttpWebhook: true,
queueCmppDelivery: false,
propagateHttpQueueError: data.propagateHttpQueueError,
});
const targets = (await resolveClientReceiptTargets(prisma, message))
.filter((target) => target.registeredDelivery);
for (const target of targets) {
const isSingleFragment = target.segmentTotal === 1;
await queue({
tenantId: message.tenantId,
applicationId: message.applicationId,
messageRecordId: message.id,
messageId: message.messageId,
deliveryType: 'receipt',
payload: {
...data.payload,
...data.segmentPayloads?.[target.segmentIndex],
submitSequenceId: target.submitSequenceId,
submitGroupMessageId: target.submitGroupMessageId,
clientSegmentIndex: target.segmentIndex,
clientSegmentTotal: target.segmentTotal,
},
receiptDedupeKey: isSingleFragment
? `receipt:${message.id}`
: `receipt:${message.id}:segment:${target.segmentIndex}`,
queueHttpWebhook: false,
queueCmppDelivery: true,
});
}
return { queued: true, cmppTargetCount: targets.length };
}
@@ -0,0 +1,35 @@
import { detectDrainageContentWithRules, type DrainageDetectionRuleSnapshot } from './drainage-content-detection';
const rules: DrainageDetectionRuleSnapshot[] = [
{ id: 'url', code: 'URL', name: 'URL', category: 'url', priority: 10, version: 1, flags: 'giu', pattern: '(?:https?:\\/\\/)?(?:www\\.)?(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,24}|(?:\\d{1,3}\\.){3}\\d{1,3})(?::\\d{1,5})?(?:\\/[^\\s,;!?<>《》]*)?' },
{ id: 'mobile', code: 'MOBILE', name: '手机', category: 'mobile', priority: 20, version: 1, flags: 'giu', pattern: '(?:^|[^0-9])((?:\\+?86)?1[3-9][0-9]{9})(?:$|[^0-9])' },
{ id: 'landline', code: 'LANDLINE', name: '固话', category: 'landline', priority: 30, version: 1, flags: 'giu', pattern: '(?:^|[^0-9])((?:\\+?86)?(?:\\(0[0-9]{2,3}\\)|0[0-9]{2,3})-?[0-9]{7,8}(?:(?:转|分机|ext)[0-9]{1,6})?)(?:$|[^0-9])' },
];
describe('drainage content detection', () => {
test.each([
['裸域名', '访问 t.cn/a1 查看详情', 'url'],
['IP 链接', '入口 192.168.1.10:8080/path。', 'url'],
['中文句号拆分域名', '请访问 example。com 领取', 'url'],
['空格拆分域名', '请访问 ex ample . com 领取', 'url'],
['+86 和空格手机号', '电话 +86 138 0013 8000', 'mobile'],
['短横线手机号', '电话 138-0013-8000', 'mobile'],
['括号区号和分机', '致电(0108888-8888 转 123', 'landline'],
])('%s', (_name, content, category) => {
const result = detectDrainageContentWithRules(content, rules);
expect(result.hasDrainageContent).toBe(true);
expect((result.drainageDetection as { matches: Array<{ category: string }> }).matches.some((item) => item.category === category)).toBe(true);
});
it('does not classify an email address as drainage information', () => {
expect(detectDrainageContentWithRules('联系邮箱 service@example.com,谢谢', rules).hasDrainageContent).toBe(false);
expect(detectDrainageContentWithRules('邮箱 13800138000 @ example . com', rules).hasDrainageContent).toBe(false);
});
it('keeps original offsets for record-page highlighting', () => {
const content = '📨详情请看 example。com/path,谢谢';
const result = detectDrainageContentWithRules(content, rules);
const [match] = (result.drainageDetection as { matches: Array<{ start: number; end: number }> }).matches;
expect(content.slice(match.start, match.end)).toContain('example。com/path');
});
});
@@ -0,0 +1,208 @@
import { BadRequestException } from '@nestjs/common';
import { createHash } from 'node:crypto';
import type { Prisma } from '@prisma/client';
import type { PrismaService } from '../prisma/prisma.service';
export type DrainageDetectionCategory = 'url' | 'mobile' | 'landline' | string;
export type DrainageDetectionRuleSnapshot = {
id: string;
code: string;
name: string;
category: DrainageDetectionCategory;
pattern: string;
flags: string;
priority: number;
version: number;
};
export type DrainageDetectionMatch = {
ruleId: string;
ruleCode: string;
ruleName: string;
category: DrainageDetectionCategory;
text: string;
normalizedText: string;
start: number;
end: number;
};
export type DrainageDetectionResult = {
hasDrainageContent: boolean;
drainageDetection: Prisma.InputJsonValue;
drainageDetectionVersion: string;
drainageEvaluatedAt: Date;
};
type NormalizedContent = {
text: string;
sourceStarts: number[];
sourceEnds: number[];
};
const RULE_CACHE_TTL_MS = 30_000;
const MAX_PATTERN_LENGTH = 1_000;
const MAX_CONTENT_LENGTH = 20_000;
const MAX_MATCHES = 50;
let cachedRules: { expiresAt: number; rules: DrainageDetectionRuleSnapshot[] } | undefined;
export function invalidateDrainageDetectionRuleCache() {
cachedRules = undefined;
}
export function validateDrainageDetectionPattern(pattern: string, flags = 'giu') {
if (!pattern.trim()) throw new BadRequestException('识别表达式不能为空');
if (pattern.length > MAX_PATTERN_LENGTH) throw new BadRequestException(`识别表达式不能超过 ${MAX_PATTERN_LENGTH} 个字符`);
if (!/^[giu]*$/.test(flags) || new Set(flags).size !== flags.length) {
throw new BadRequestException('表达式标志仅支持 g、i、u,且不能重复');
}
// 可配置规则会运行在发送入口,禁止容易造成灾难性回溯或跨文本引用的结构。
if (/\\[1-9]/.test(pattern) || /\(\?<([=!])/.test(pattern) || /\([^)]*[+*][^)]*\)[+*{]/.test(pattern)) {
throw new BadRequestException('表达式包含不安全的回溯、后行断言或嵌套量词');
}
try {
// 强制全局匹配,避免配置遗漏 g 后只能识别首个命中。
new RegExp(pattern, flags.includes('g') ? flags : `${flags}g`);
} catch {
throw new BadRequestException('识别表达式格式不正确');
}
}
function normalizeContent(content: string, category: DrainageDetectionCategory): NormalizedContent {
let text = '';
const sourceStarts: number[] = [];
const sourceEnds: number[] = [];
let sourceIndex = 0;
for (const sourceChar of content.slice(0, MAX_CONTENT_LENGTH)) {
const sourceEnd = sourceIndex + sourceChar.length;
let normalized = sourceChar.normalize('NFKC')
.replace(/[.。]/g, '.')
.replace(/[:﹕]/g, ':')
.replace(/[]/g, '/')
.replace(/[()]/g, (char) => char === '' ? '(' : ')')
.replace(/[]/g, '+');
if (category === 'url') {
// 链接常被空格或中文句号拆开;句末中文句号也安全地成为正则边界。
normalized = normalized.replace(/\s+/gu, '').replace(//g, '.');
} else if (category === 'mobile' || category === 'landline') {
// 电话号码仅在检测副本中去除常见规避分隔符,绝不改写实际发送内容。
normalized = normalized.replace(/[\s\-.·,]/gu, '');
}
for (const char of normalized) {
text += char;
// RegExp.index 使用 UTF-16 code unit,映射数组必须采用相同计数方式,避免表情符号导致高亮偏移。
for (let codeUnit = 0; codeUnit < char.length; codeUnit += 1) {
sourceStarts.push(sourceIndex);
sourceEnds.push(sourceEnd);
}
}
sourceIndex = sourceEnd;
}
return { text, sourceStarts, sourceEnds };
}
function sourceRange(normalized: NormalizedContent, start: number, end: number) {
const safeStart = Math.max(0, Math.min(start, normalized.sourceStarts.length - 1));
const safeEnd = Math.max(safeStart, Math.min(end - 1, normalized.sourceEnds.length - 1));
return {
start: normalized.sourceStarts[safeStart] ?? 0,
end: normalized.sourceEnds[safeEnd] ?? 0,
};
}
function emailRanges(normalized: NormalizedContent) {
const ranges: Array<{ start: number; end: number }> = [];
const email = /[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+/giu;
for (const match of normalized.text.matchAll(email)) {
ranges.push({ start: match.index, end: match.index + match[0].length });
}
return ranges;
}
function overlaps(start: number, end: number, range: { start: number; end: number }) {
return start < range.end && end > range.start;
}
export function detectDrainageContentWithRules(
content: string,
rules: DrainageDetectionRuleSnapshot[],
evaluatedAt = new Date(),
): DrainageDetectionResult {
const matches: DrainageDetectionMatch[] = [];
const normalizedByCategory = new Map<string, NormalizedContent>();
const emailNormalized = normalizeContent(content, 'url');
const originalEmailRanges = emailRanges(emailNormalized).map((range) => sourceRange(emailNormalized, range.start, range.end));
for (const rule of [...rules].sort((a, b) => a.priority - b.priority || a.code.localeCompare(b.code))) {
validateDrainageDetectionPattern(rule.pattern, rule.flags);
const normalized = normalizedByCategory.get(rule.category) ?? normalizeContent(content, rule.category);
normalizedByCategory.set(rule.category, normalized);
const regex = new RegExp(rule.pattern, rule.flags.includes('g') ? rule.flags : `${rule.flags}g`);
for (const match of normalized.text.matchAll(regex)) {
const captured = match[1] || match[0];
const capturedOffset = match[0].indexOf(captured);
const normalizedStart = match.index + Math.max(0, capturedOffset);
const normalizedEnd = normalizedStart + captured.length;
const range = sourceRange(normalized, normalizedStart, normalizedEnd);
if (range.end <= range.start) continue;
// 邮箱整体不是引流信息;不仅排除其中的域名,也排除数字本地部分被电话规则误识别。
if (originalEmailRanges.some((emailRange) => overlaps(range.start, range.end, emailRange))) continue;
const candidate: DrainageDetectionMatch = {
ruleId: rule.id,
ruleCode: rule.code,
ruleName: rule.name,
category: rule.category,
text: content.slice(range.start, range.end),
normalizedText: captured,
start: range.start,
end: range.end,
};
if (!matches.some((item) => item.category === candidate.category && item.start === candidate.start && item.end === candidate.end)) {
matches.push(candidate);
}
if (matches.length >= MAX_MATCHES) break;
}
if (matches.length >= MAX_MATCHES) break;
}
matches.sort((a, b) => a.start - b.start || a.end - b.end);
const versionSource = rules
.map((rule) => `${rule.code}:${rule.version}`)
.sort()
.join('|');
const drainageDetectionVersion = createHash('sha256').update(versionSource).digest('hex').slice(0, 16);
return {
hasDrainageContent: matches.length > 0,
drainageDetection: {
matches,
categories: [...new Set(matches.map((item) => item.category))],
ruleCount: rules.length,
truncated: content.length > MAX_CONTENT_LENGTH || matches.length >= MAX_MATCHES,
} as Prisma.InputJsonValue,
drainageDetectionVersion,
drainageEvaluatedAt: evaluatedAt,
};
}
async function activeRules(prisma: PrismaService) {
if (cachedRules && cachedRules.expiresAt > Date.now()) return cachedRules.rules;
const rules = await prisma.drainageDetectionRule.findMany({
where: { status: 'active' },
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
select: {
id: true,
code: true,
name: true,
category: true,
pattern: true,
flags: true,
priority: true,
version: true,
},
});
cachedRules = { rules, expiresAt: Date.now() + RULE_CACHE_TTL_MS };
return rules;
}
export async function detectDrainageContent(prisma: PrismaService, content: string) {
return detectDrainageContentWithRules(content, await activeRules(prisma));
}
@@ -12,7 +12,8 @@ import { PrismaService } from '../prisma/prisma.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, drainageRejectionReason, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
import { detectDrainageContent } from './drainage-content-detection';
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
/**
@@ -62,11 +63,12 @@ async createBatchTask(data: CreateBatchTaskDto) {
await this.facade.validateSendResources(data.tenantId, data.applicationId, data.templateId);
const phoneRejections = await this.facade.classifyRejectedPhones(data.tenantId, data.applicationId, phones);
let sendablePhones = phones.filter((phone) => !phoneRejections.has(phone));
const [messageClassification, unitPrice, queuePriority, accessNumber] = await Promise.all([
const [messageClassification, unitPrice, queuePriority, accessNumber, drainageDetection] = await Promise.all([
this.facade.resolveTemplateMessageClassification(data.tenantId, data.applicationId, data.templateId, data.content),
this.facade.resolveUnitPrice(data.tenantId, data.applicationId),
this.facade.resolveQueuePriority(data.tenantId, data.applicationId),
this.facade.resolveApplicationAccessNumber(data.tenantId, data.applicationId),
detectDrainageContent(this.prisma, data.content),
]);
const risk = messageClassification.rejectionReason
? { status: 'rejected', reason: messageClassification.rejectionReason, task: null }
@@ -205,6 +207,7 @@ async createBatchTask(data: CreateBatchTaskDto) {
clientMessageId: data.clientMessageId,
phoneNumber: phone,
content: data.content,
...drainageDetection,
billingUnits: billing.billingUnitsPerMessage,
unitPrice: rejection ? 0 : billing.unitPrice,
amountCents: rejection ? 0 : billing.billingUnitsPerMessage * billing.unitPrice,
@@ -395,7 +398,8 @@ async resolveTemplateMessageClassification(
signatureId: template.signatureId,
drainageInfoId: drainage?.id,
variables,
rejectionReason: drainageRejectionReason(drainage),
// 引流资料只做关联与监控,报备审核状态不参与本期发送决策。
rejectionReason: undefined,
};
}
@@ -423,7 +427,7 @@ async resolveTemplateMessageClassification(
signatureId: signature.id,
drainageInfoId: drainage?.id,
variables: undefined,
rejectionReason: drainageRejectionReason(drainage),
rejectionReason: undefined,
};
}
@@ -35,6 +35,7 @@ export interface GatewayInboundSubmitDto {
srcId?: string;
destId?: string;
sequenceId?: number;
registeredDelivery?: number;
remoteIp?: string;
longMessage?: {
reference: number;
+3 -3
View File
@@ -61,9 +61,9 @@ export function gatewaySubmitRequeueKey(deadLetterId: string, attempt: number) {
return `gateway:submit:requeue:${deadLetterId}:${attempt}`;
}
export function drainageRejectionReason(drainage?: { id: string; auditStatus: string }) {
if (!drainage || drainage.auditStatus === 'approved') return undefined;
return `短信内容匹配的引流资料 ${drainage.id} 当前为 ${drainage.auditStatus},必须审核通过后才能发送`;
/** @deprecated 引流资料审核状态自本期起只用于监控,不得产生发送拒绝。 */
export function drainageRejectionReason(_drainage?: { id: string; auditStatus: string }) {
return undefined;
}
export function statusFromRisk(status: string, scheduled: boolean) {
+143 -16
View File
@@ -23,6 +23,9 @@ function createPrismaMock() {
submitId: 'SUB-1',
gatewayMessageId: 'GW-1',
channelId: 'channel-1',
cmppSubmitSequenceId: '101',
cmppSubmitGroupMessageId: null,
cmppRegisteredDelivery: true,
template: { signature: { id: 'sig-1', name: '签名' } },
};
const channel = {
@@ -109,6 +112,9 @@ function createPrismaMock() {
smsDrainageInfo: {
findMany: jest.fn().mockResolvedValue([]),
},
drainageDetectionRule: {
findMany: jest.fn().mockResolvedValue([]),
},
smsSendTask: {
findUnique: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue({ id: 'review-task-1', status: 'rejected' }),
@@ -688,7 +694,7 @@ describe('SendChainService', () => {
});
});
it.each(['pending', 'rejected'])('blocks a matched %s drainage URL and preserves the matched resource on rejected records', async (auditStatus) => {
it.each(['pending', 'rejected'])('does not block a matched %s drainage URL and still preserves the matched resource', async (auditStatus) => {
const { service, prisma, riskReview } = createService();
prisma.smsTemplate.findUnique.mockResolvedValue({
id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1',
@@ -705,14 +711,14 @@ describe('SendChainService', () => {
})).resolves.toBeDefined();
expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({
data: expect.objectContaining({ status: 'rejected', rejectReason: expect.stringContaining(`drain-blocked 当前为 ${auditStatus}`) }),
data: expect.objectContaining({ status: 'ready', rejectReason: null }),
});
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({
drainageInfoId: 'drain-blocked', status: 'rejected', errorMessage: expect.stringContaining(`drain-blocked 当前为 ${auditStatus}`),
drainageInfoId: 'drain-blocked', status: 'queued', errorMessage: undefined,
})],
});
expect(riskReview.evaluateTask).not.toHaveBeenCalled();
expect(riskReview.evaluateTask).toHaveBeenCalled();
});
it('creates scheduled tasks without immediate enqueue and dispatches due tasks later', async () => {
@@ -2576,6 +2582,18 @@ describe('SendChainService', () => {
gatewayMessageId: 'GW-SEG-1',
status: 'submitted',
billingUnits: 2,
cmppSubmitSequenceId: '501',
cmppSubmitGroupMessageId: 'MSG-LONG-GROUP-1',
cmppRegisteredDelivery: true,
});
prisma.cmppInboundLongMessage.findFirst.mockResolvedValue({
id: 'long-group-receipt-1',
messageId: 'MSG-LONG-GROUP-1',
segmentTotal: 2,
segments: [
{ segmentIndex: 1, sequenceId: '501', registeredDelivery: true },
{ segmentIndex: 2, sequenceId: '502', registeredDelivery: true },
],
});
prisma.smsSubmitRecord.findFirst.mockResolvedValue({
id: 'submit-long',
@@ -2621,10 +2639,22 @@ describe('SendChainService', () => {
where: { id: 'record-long' },
data: expect.objectContaining({ status: 'delivered', receiptStatus: 'delivered' }),
}));
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(1);
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2);
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenNthCalledWith(1, {
data: expect.objectContaining({
dedupeKey: 'receipt:record-long:segment:1',
payload: expect.objectContaining({ submitSequenceId: 501, clientSegmentIndex: 1, clientSegmentTotal: 2 }),
}),
});
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenNthCalledWith(2, {
data: expect.objectContaining({
dedupeKey: 'receipt:record-long:segment:2',
payload: expect.objectContaining({ submitSequenceId: 502, clientSegmentIndex: 2, clientSegmentTotal: 2 }),
}),
});
});
it('creates and sends only one downstream final receipt under concurrent completion', async () => {
it('creates and sends only one downstream receipt for the same fragment dedupe key', async () => {
const { service, prisma } = createService();
let claimedDelivery: Record<string, unknown> | null = null;
prisma.cmppDownstreamDelivery.create.mockImplementation(async ({ data }) => {
@@ -2690,6 +2720,18 @@ describe('SendChainService', () => {
billingUnits: 2,
amountCents: 6,
unitPrice: 3,
cmppSubmitSequenceId: '601',
cmppSubmitGroupMessageId: 'MSG-LONG-GROUP-FAIL',
cmppRegisteredDelivery: true,
});
prisma.cmppInboundLongMessage.findFirst.mockResolvedValue({
id: 'long-group-receipt-fail',
messageId: 'MSG-LONG-GROUP-FAIL',
segmentTotal: 2,
segments: [
{ segmentIndex: 1, sequenceId: '601', registeredDelivery: true },
{ segmentIndex: 2, sequenceId: '602', registeredDelivery: true },
],
});
prisma.smsMessageSegmentAudit.findFirst.mockResolvedValue({
id: 'segment-2',
@@ -2754,12 +2796,9 @@ describe('SendChainService', () => {
}),
});
expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' }));
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2);
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
data: expect.objectContaining({
messageRecordId: 'record-long',
deliveryType: 'receipt',
status: 'pending',
}),
data: expect.objectContaining({ messageRecordId: 'record-long', deliveryType: 'receipt', status: 'pending' }),
});
});
@@ -3801,8 +3840,8 @@ describe('SendChainService', () => {
it('marks submitted or unknown messages without a final receipt for 72 hours as timeout and refunds them', async () => {
const { service, prisma, billing } = createService();
prisma.smsMessageRecord.findMany.mockResolvedValue([
{ id: 'record-1', tenantId: 'tenant-1', batchTaskId: 'task-1', messageId: 'MSG-1', amountCents: 3, billingUnits: 1 },
{ id: 'record-2', tenantId: 'tenant-1', batchTaskId: 'task-1', messageId: 'MSG-2', amountCents: 3, billingUnits: 1 },
{ id: 'record-1', tenantId: 'tenant-1', batchTaskId: 'task-1', applicationId: 'app-1', messageId: 'MSG-1', phoneNumber: '13800000001', amountCents: 3, billingUnits: 1, status: 'submitted', cmppSubmitSequenceId: '701', cmppRegisteredDelivery: true, timeoutAt: null },
{ id: 'record-2', tenantId: 'tenant-1', batchTaskId: 'task-1', applicationId: 'app-1', messageId: 'MSG-2', phoneNumber: '13900000002', amountCents: 3, billingUnits: 1, status: 'unknown', cmppSubmitSequenceId: '702', cmppRegisteredDelivery: true, timeoutAt: null },
]);
prisma.smsBillingRecord.findFirst
.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 'bill-1', billingStatus: 'charged' })
@@ -3812,10 +3851,12 @@ describe('SendChainService', () => {
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith({
where: {
tenantId: { not: null },
status: { in: ['submitted', 'unknown'] },
submittedAt: { lte: expect.any(Date) },
OR: [
{ status: { in: ['submitted', 'unknown'] }, submittedAt: { lte: expect.any(Date) } },
{ status: 'timeout', errorCode: 'RECEIPT_TIMEOUT', timeoutReceiptQueuedAt: null },
],
},
select: { id: true, tenantId: true, batchTaskId: true, messageId: true, amountCents: true, billingUnits: true },
select: expect.objectContaining({ id: true, applicationId: true, cmppSubmitSequenceId: true, timeoutAt: true }),
take: 10000,
});
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
@@ -3823,9 +3864,95 @@ describe('SendChainService', () => {
data: expect.objectContaining({ status: 'timeout', errorMessage: '72小时未收到明确回执,自动转超时' }),
});
expect(billing.refund).toHaveBeenCalledTimes(2);
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2);
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
data: expect.objectContaining({
dedupeKey: 'receipt:record-1',
payload: expect.objectContaining({ rawStatus: 'EXPIRED', submitSequenceId: 701 }),
}),
});
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
where: { id: 'record-1', status: 'timeout', timeoutReceiptQueuedAt: null },
data: { timeoutReceiptQueuedAt: expect.any(Date) },
});
expect(prisma.smsBatchTask.update).toHaveBeenCalled();
});
it('queues an explicit HTTP failure webhook when a receipt times out', async () => {
const prisma = createPrismaMock();
const openApi = { queueWebhookEvent: jest.fn().mockResolvedValue({ id: 'http-timeout-delivery' }) };
const { service } = createService(prisma, openApi);
prisma.smsMessageRecord.findMany.mockResolvedValue([{
id: 'record-http-timeout',
tenantId: 'tenant-1',
batchTaskId: null,
applicationId: 'app-1',
messageId: 'MSG-HTTP-TIMEOUT',
phoneNumber: '13800000001',
amountCents: 0,
billingUnits: 1,
status: 'submitted',
cmppSubmitSequenceId: null,
cmppSubmitGroupMessageId: null,
cmppRegisteredDelivery: null,
timeoutAt: null,
}]);
await expect(service.markUnknownTimeout({ olderThanHours: 72 })).resolves.toEqual({ timeout: 1 });
expect(openApi.queueWebhookEvent).toHaveBeenCalledWith(expect.objectContaining({
applicationId: 'app-1',
messageRecordId: 'record-http-timeout',
eventType: 'receipt',
payload: expect.objectContaining({
receiptStatus: 'undelivered',
rawStatus: 'EXPIRED',
errorCode: 'RECEIPT_TIMEOUT',
}),
}));
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
where: { id: 'record-http-timeout', status: 'timeout', timeoutReceiptQueuedAt: null },
data: { timeoutReceiptQueuedAt: expect.any(Date) },
});
});
it('recovers timeout refund and downstream queueing when the prior scan stopped before setting the outbox marker', async () => {
const { service, prisma, billing } = createService();
prisma.smsBillingRecord.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'billing-timeout-recovery', billingStatus: 'charged' });
prisma.smsMessageRecord.findMany.mockResolvedValue([{
id: 'record-timeout-recovery',
tenantId: 'tenant-1',
batchTaskId: null,
applicationId: 'app-1',
messageId: 'MSG-TIMEOUT-RECOVERY',
phoneNumber: '13800000001',
amountCents: 3,
billingUnits: 1,
status: 'timeout',
cmppSubmitSequenceId: '703',
cmppSubmitGroupMessageId: null,
cmppRegisteredDelivery: true,
timeoutAt: new Date('2026-08-01T00:00:00.000Z'),
}]);
await expect(service.markUnknownTimeout({ olderThanHours: 72 })).resolves.toEqual({ timeout: 0 });
expect(billing.refund).toHaveBeenCalledTimes(1);
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
data: expect.objectContaining({
dedupeKey: 'receipt:record-timeout-recovery',
payload: expect.objectContaining({ rawStatus: 'EXPIRED', submitSequenceId: 703 }),
}),
});
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
where: { id: 'record-timeout-recovery', status: 'timeout', timeoutReceiptQueuedAt: null },
data: { timeoutReceiptQueuedAt: expect.any(Date) },
});
});
it('terminates downstream deliveries that remain pending for 72 hours after the latest manual retry', async () => {
const { service, prisma } = createService();
prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'delivery-expired' }]);
+2 -8
View File
@@ -16,6 +16,7 @@ import { OpenApiService } from '../open-api/open-api.service';
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, UplinkMatchCandidateInput, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS, RECEIPT_TIMEOUT_INITIAL_DELAY_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, SCHEDULED_DISPATCH_INITIAL_DELAY_MS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS, INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS, UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, gatewaySubmitRequeueKey, drainageRejectionReason, statusFromRisk, parseSchedule, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, normalizeRegion, matchTemplateContent, escapeRegularExpression, isNationalChannel, isProvinceChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, normalizeSubmitStatus, normalizeReceiptStatus, downstreamDeliveryAttemptKey, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory } from './send-chain.helpers';
import { aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
import { SendSubmissionService } from './send-submission.service';
import { SendCompletionService, type SendCompletionFacade } from './send-completion.service';
@@ -500,14 +501,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.completion.claimUplinkMatchCandidate(uplinkMessageId, candidateId, operatorId);
}
private async queueAndTryDownstreamDelivery(data: {
tenantId: string;
applicationId?: string | null;
messageRecordId?: string | null;
messageId?: string | null;
deliveryType: 'receipt' | 'uplink';
payload: Record<string, unknown>;
}) {
private async queueAndTryDownstreamDelivery(data: DownstreamDeliveryQueueRequest) {
return this.completion.queueAndTryDownstreamDelivery(data);
}
@@ -11,6 +11,7 @@ import { SendGatewayResultService } from './send-gateway-result.service';
import { SendReceiptService } from './send-receipt.service';
import { SendRetryService } from './send-retry.service';
import { SendTimeoutService } from './send-timeout.service';
import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
export type SendCompletionCallbacks = Record<string, never>;
@@ -263,14 +264,7 @@ export class SendCompletionService {
return this.downstreamDelivery.claimUplinkMatchCandidate(uplinkMessageId, candidateId, operatorId);
}
async queueAndTryDownstreamDelivery(data: {
tenantId: string;
applicationId?: string | null;
messageRecordId?: string | null;
messageId?: string | null;
deliveryType: 'receipt' | 'uplink';
payload: Record<string, unknown>;
}) {
async queueAndTryDownstreamDelivery(data: DownstreamDeliveryQueueRequest) {
return this.downstreamDelivery.queueAndTryDownstreamDelivery(data);
}
@@ -298,6 +292,7 @@ export class SendCompletionService {
phoneNumber: string;
cmppSubmitSequenceId?: string | null;
cmppSubmitGroupMessageId?: string | null;
cmppRegisteredDelivery?: boolean | null;
},
errorCode: string,
reason: string,
@@ -9,6 +9,7 @@ import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayRece
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
import type { SendSubmissionService } from './send-submission.service';
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
import { queueFinalReceiptDeliveries, type DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
/**
@@ -188,14 +189,7 @@ export class SendDownstreamDeliveryService {
});
}
async queueAndTryDownstreamDelivery(data: {
tenantId: string;
applicationId?: string | null;
messageRecordId?: string | null;
messageId?: string | null;
deliveryType: 'receipt' | 'uplink';
payload: Record<string, unknown>;
}) {
async queueAndTryDownstreamDelivery(data: DownstreamDeliveryQueueRequest) {
if (!data.applicationId) {
return null;
}
@@ -211,7 +205,7 @@ export class SendDownstreamDeliveryService {
},
});
const deliveryAllowed = application?.status === 'active' || application?.status === 'disabling';
if (deliveryAllowed) {
if (deliveryAllowed && data.queueHttpWebhook !== false) {
try {
await this.openApi?.queueWebhookEvent({
tenantId: data.tenantId,
@@ -224,14 +218,18 @@ export class SendDownstreamDeliveryService {
});
} catch (error) {
this.logger.error(`HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(error)}`);
if (data.propagateHttpQueueError) throw error;
}
}
if (data.queueCmppDelivery === false) {
return null;
}
if (application?.interfaceEnabled !== true) {
return null;
}
const payload = { account: application?.cmppAccount, applicationId: data.applicationId, ...data.payload };
const dedupeKey = data.deliveryType === 'receipt' && data.messageRecordId
? `receipt:${data.messageRecordId}`
? data.receiptDedupeKey ?? `receipt:${data.messageRecordId}`
: data.deliveryType === 'uplink' && typeof data.payload.uplinkMessageId === 'string'
? `uplink:${data.payload.uplinkMessageId}`
: null;
@@ -419,6 +417,7 @@ export class SendDownstreamDeliveryService {
phoneNumber: string;
cmppSubmitSequenceId?: string | null;
cmppSubmitGroupMessageId?: string | null;
cmppRegisteredDelivery?: boolean | null;
},
errorCode: string,
reason: string,
@@ -450,12 +449,11 @@ export class SendDownstreamDeliveryService {
deliveredAt,
},
});
await this.facade.queueAndTryDownstreamDelivery({
tenantId: message.tenantId,
applicationId: message.applicationId,
messageRecordId: message.id,
messageId: message.messageId,
deliveryType: 'receipt',
await queueFinalReceiptDeliveries(
this.prisma,
(request) => this.facade.queueAndTryDownstreamDelivery(request),
{
message,
payload: {
messageId: message.messageId,
gatewayMessageId: `PLATFORM:${message.messageId}`,
@@ -464,11 +462,10 @@ export class SendDownstreamDeliveryService {
rawStatus: 'REJECTD',
errorCode,
errorMessage: reason,
submitSequenceId: message.cmppSubmitSequenceId ? Number(message.cmppSubmitSequenceId) : undefined,
submitGroupMessageId: message.cmppSubmitGroupMessageId ?? undefined,
deliveredAt: deliveredAt.toISOString(),
},
});
},
);
if (message.batchTaskId) await this.facade.refreshTaskProgress(message.batchTaskId);
return receipt;
}
@@ -12,8 +12,9 @@ import { PrismaService } from '../prisma/prisma.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, drainageRejectionReason, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, statusFromRisk, parseSchedule, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, matchTemplateContent, isNationalChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, selectChannelCandidate } from './send-chain.helpers';
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
import { detectDrainageContent } from './drainage-content-detection';
/**
* R9 inboundEntry implementation. Cross-method calls return through the stable SendChainService seam.
@@ -48,6 +49,7 @@ export class SendInboundEntryService {
phoneNumber: string;
cmppSubmitSequenceId?: string | null;
cmppSubmitGroupMessageId?: string | null;
cmppRegisteredDelivery?: boolean | null;
},
errorCode: string,
reason: string,
@@ -85,6 +87,9 @@ async authenticateInboundApplication(data: GatewayInboundAuthDto) {
}
async submitInboundMessage(data: GatewayInboundSubmitDto) {
if (data.registeredDelivery != null && ![0, 1].includes(data.registeredDelivery)) {
throw new BadRequestException('CMPP Registered_Delivery must be 0 or 1');
}
const phoneNumbers = data.phoneNumbers?.length
? data.phoneNumbers.map((phoneNumber) => phoneNumber.trim())
: data.phoneNumber
@@ -136,6 +141,7 @@ async submitInboundMessage(data: GatewayInboundSubmitDto) {
...data,
content: collection.content,
sequenceId: collection.sequenceId,
registeredDelivery: collection.registeredDelivery ? 1 : 0,
longMessage: undefined,
}, phoneNumbers, application, collection.messageId);
await this.prisma.cmppInboundLongMessage.update({
@@ -350,6 +356,7 @@ async collectInboundLongMessageFragment(
response: recent.response as any,
content: recent.segments.map((item) => item.content).join(''),
sequenceId: parseOptionalSequenceId(recent.segments[0]?.sequenceId),
registeredDelivery: recent.segments[0]?.registeredDelivery ?? true,
};
}
@@ -392,6 +399,7 @@ async collectInboundLongMessageFragment(
response: null,
content: group.segments.map((item) => item.content).join(''),
sequenceId: parseOptionalSequenceId(group.segments[0]?.sequenceId),
registeredDelivery: group.segments[0]?.registeredDelivery ?? true,
};
}
return {
@@ -402,12 +410,14 @@ async collectInboundLongMessageFragment(
response: group.response as any,
content: '',
sequenceId: undefined,
registeredDelivery: true,
};
}
const existing = group.segments.find((item) => item.segmentIndex === fragment.index);
if (existing && (existing.contentHash !== contentHash
|| existing.sequenceId !== (data.sequenceId == null ? null : String(data.sequenceId)))) {
|| existing.sequenceId !== (data.sequenceId == null ? null : String(data.sequenceId))
|| existing.registeredDelivery !== (data.registeredDelivery !== 0))) {
throw new BadRequestException(`CMPP long message fragment ${fragment.index} conflicts with the stored fragment`);
}
if (!existing) {
@@ -416,6 +426,7 @@ async collectInboundLongMessageFragment(
groupId: group.id,
segmentIndex: fragment.index,
sequenceId: data.sequenceId == null ? null : String(data.sequenceId),
registeredDelivery: data.registeredDelivery !== 0,
content: data.content,
contentHash,
},
@@ -441,6 +452,7 @@ async collectInboundLongMessageFragment(
response: null,
content: complete ? segments.map((item) => item.content).join('') : '',
sequenceId: parseOptionalSequenceId(segments[0]?.sequenceId),
registeredDelivery: segments[0]?.registeredDelivery ?? true,
};
});
}
@@ -510,6 +522,7 @@ async submitInboundSingleMessage(
status: synchronousRejection ? 'rejected' : 'accepted',
},
});
const drainageDetection = await detectDrainageContent(this.prisma, data.content);
const message = await this.prisma.smsMessageRecord.create({
data: {
tenantId: application.tenantId,
@@ -519,12 +532,14 @@ async submitInboundSingleMessage(
messageId,
phoneNumber: data.phoneNumber,
content: data.content,
...drainageDetection,
billingUnits: billing.billingUnitsPerMessage,
unitPrice: receiptRejection ? 0 : billing.unitPrice,
amountCents: receiptRejection ? 0 : billing.amountCents,
queuePriority,
cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId),
cmppSubmitGroupMessageId: submitGroupMessageId,
cmppRegisteredDelivery: data.registeredDelivery !== 0,
clientSrcId,
applicationExtension: application.cmppApplicationExtension,
status: synchronousRejection ? 'rejected' : 'validating',
@@ -555,15 +570,6 @@ async submitInboundSingleMessage(
const queueAfterRiskChecks = async (options: { templateId?: string; signatureId?: string }) => {
const drainage = await this.facade.resolveDrainageInfoMatch(options.signatureId, data.content);
const drainageInfoId = drainage?.id;
const drainageReason = drainageRejectionReason(drainage);
if (drainageReason) {
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { drainageInfoId, signatureId: options.signatureId },
});
await reject('DRAINAGE_NOT_APPROVED', drainageReason);
return;
}
const risk = await this.facade.evaluateRiskWithPhoneFrequency({
tenantId: application.tenantId,
applicationId: application.id,
@@ -634,23 +640,6 @@ async submitInboundSingleMessage(
await reject('SIGNATURE', '短信内容未识别到已审核通过的签名');
} else {
const drainage = await this.facade.resolveDrainageInfoMatch(signature.id, data.content);
const drainageReason = drainageRejectionReason(drainage);
if (drainageReason) {
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { drainageInfoId: drainage?.id, signatureId: signature.id },
});
await reject('DRAINAGE_NOT_APPROVED', drainageReason);
return {
accepted: true,
tenantId: application.tenantId,
applicationId: application.id,
messageId,
messageRecordId: message.id,
taskId: task.id,
status: 'rejected',
};
}
const risk = await this.facade.evaluateRiskWithPhoneFrequency({
tenantId: application.tenantId,
applicationId: application.id,
+22 -10
View File
@@ -9,6 +9,7 @@ import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayRece
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
import type { SendSubmissionService } from './send-submission.service';
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
import { queueFinalReceiptDeliveries } from './downstream-receipt-targets';
/**
@@ -309,12 +310,11 @@ export class SendReceiptService {
},
});
if (!isStandaloneChannelTest && message.tenantId && message.applicationId) {
await this.facade.queueAndTryDownstreamDelivery({
tenantId: message.tenantId,
applicationId: message.applicationId,
messageRecordId: message.id,
messageId: message.messageId,
deliveryType: 'receipt',
await queueFinalReceiptDeliveries(
this.prisma,
(request) => this.facade.queueAndTryDownstreamDelivery(request),
{
message,
payload: {
messageId: message.messageId,
gatewayMessageId: data.gatewayMessageId,
@@ -322,11 +322,20 @@ export class SendReceiptService {
receiptStatus: aggregate.receiptStatus,
rawStatus: aggregate.rawStatus,
errorCode: aggregate.errorCode,
submitSequenceId: message.cmppSubmitSequenceId ? Number(message.cmppSubmitSequenceId) : undefined,
submitGroupMessageId: message.cmppSubmitGroupMessageId ?? undefined,
deliveredAt: aggregate.deliveredAt.toISOString(),
},
});
segmentPayloads: Object.fromEntries(
aggregate.segments
.filter((segment) => segment.receiptStatus)
.map((segment) => [segment.segmentIndex, {
receiptStatus: segment.receiptStatus,
rawStatus: segment.rawStatus,
errorCode: segment.errorCode,
deliveredAt: segment.deliveredAt?.toISOString() ?? aggregate.deliveredAt.toISOString(),
}]),
),
},
);
}
if (message.batchTaskId) {
await this.facade.refreshTaskProgress(message.batchTaskId);
@@ -427,7 +436,10 @@ export class SendReceiptService {
: { messageRecordId: message.id, gatewayMessageId: data.gatewayMessageId },
orderBy: { segmentIndex: 'asc' },
});
return aggregateReceiptSegmentState(audits, message.billingUnits, data, deliveredAt);
return {
...aggregateReceiptSegmentState(audits, message.billingUnits, data, deliveredAt),
segments: audits,
};
}
async resolveReceiptMessage(
@@ -27,6 +27,7 @@ export type SendSubmissionCallbacks = {
phoneNumber: string;
cmppSubmitSequenceId?: string | null;
cmppSubmitGroupMessageId?: string | null;
cmppRegisteredDelivery?: boolean | null;
},
errorCode: string,
reason: string,
+58 -4
View File
@@ -9,6 +9,7 @@ import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayRece
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
import type { SendSubmissionService } from './send-submission.service';
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
import { queueFinalReceiptDeliveries } from './downstream-receipt-targets';
/**
@@ -33,23 +34,76 @@ export class SendTimeoutService {
const candidates = await this.prisma.smsMessageRecord.findMany({
where: {
tenantId: { not: null },
status: { in: ['submitted', 'unknown'] },
submittedAt: { lte: cutoff },
OR: [
{ status: { in: ['submitted', 'unknown'] }, submittedAt: { lte: cutoff } },
{ status: 'timeout', errorCode: 'RECEIPT_TIMEOUT', timeoutReceiptQueuedAt: null },
],
},
select: {
id: true,
tenantId: true,
batchTaskId: true,
applicationId: true,
messageId: true,
phoneNumber: true,
amountCents: true,
billingUnits: true,
status: true,
cmppSubmitSequenceId: true,
cmppSubmitGroupMessageId: true,
cmppRegisteredDelivery: true,
timeoutAt: true,
},
select: { id: true, tenantId: true, batchTaskId: true, messageId: true, amountCents: true, billingUnits: true },
take: 10000,
});
const timedOutTaskIds = new Set<string>();
let timeout = 0;
for (const candidate of candidates) {
if (!candidate.tenantId) continue;
const timedOutAt = candidate.timeoutAt ?? new Date();
if (candidate.status !== 'timeout') {
const transitioned = await this.prisma.smsMessageRecord.updateMany({
where: { id: candidate.id, status: { in: ['submitted', 'unknown'] } },
data: { status: 'timeout', timeoutAt: new Date(), errorMessage: `${olderThanHours}小时未收到明确回执,自动转超时` },
data: {
status: 'timeout',
receiptStatus: 'undelivered',
receiptRawStatus: 'EXPIRED',
errorCode: 'RECEIPT_TIMEOUT',
errorMessage: `${olderThanHours}小时未收到明确回执,自动转超时`,
timeoutAt: timedOutAt,
},
});
if (transitioned.count !== 1) continue;
timeout += 1;
}
// Refund uses the platform-message idempotency key. Re-running it for a
// timeout whose downstream outbox was not fully queued also recovers a
// crash between the state transition and the original refund call.
await this.facade.refundMessage(candidate as typeof candidate & { tenantId: string }, `${olderThanHours}小时未收到明确回执,自动超时退款`);
const queued = await queueFinalReceiptDeliveries(
this.prisma,
(request) => this.facade.queueAndTryDownstreamDelivery(request),
{
message: candidate,
payload: {
messageId: candidate.messageId,
gatewayMessageId: `PLATFORM_TIMEOUT:${candidate.messageId}`,
phoneNumber: candidate.phoneNumber,
receiptStatus: 'undelivered',
rawStatus: 'EXPIRED',
errorCode: 'RECEIPT_TIMEOUT',
errorMessage: `${olderThanHours}小时未收到明确回执,自动转超时`,
deliveredAt: timedOutAt.toISOString(),
},
propagateHttpQueueError: true,
},
);
if (queued.queued) {
await this.prisma.smsMessageRecord.updateMany({
where: { id: candidate.id, status: 'timeout', timeoutReceiptQueuedAt: null },
data: { timeoutReceiptQueuedAt: new Date() },
});
}
if (candidate.batchTaskId) timedOutTaskIds.add(candidate.batchTaskId);
}
for (const batchTaskId of timedOutTaskIds) {
@@ -72,8 +72,8 @@ export class AdminSmsConfigController {
}
@Get('enterprise-signatures')
listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('signatureKeyword') signatureKeyword?: string, @Query('drainageKeyword') drainageKeyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
const query = { tenantId, keyword, status, enterpriseKeyword, applicationKeyword, signatureKeyword, drainageKeyword, page: Number(page), pageSize: Number(pageSize) };
listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('signatureKeyword') signatureKeyword?: string, @Query('drainageKeyword') drainageKeyword?: string, @Query('submittedAtFrom') submittedAtFrom?: string, @Query('submittedAtTo') submittedAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
const query = { tenantId, keyword, status, enterpriseKeyword, applicationKeyword, signatureKeyword, drainageKeyword, submittedAtFrom, submittedAtTo, page: Number(page), pageSize: Number(pageSize) };
return page || pageSize ? this.smsConfig.listSignaturesPage(query) : this.smsConfig.listSignatures(query);
}
@@ -93,8 +93,8 @@ export class AdminSmsConfigController {
}
@Get('drainage-infos')
listDrainageInfos(@Query('tenantId') tenantId?: string, @Query('signatureId') signatureId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string) {
return this.smsConfig.listDrainageInfos({ tenantId, signatureId, status, keyword });
listDrainageInfos(@Query('tenantId') tenantId?: string, @Query('signatureId') signatureId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('submittedAtFrom') submittedAtFrom?: string, @Query('submittedAtTo') submittedAtTo?: string) {
return this.smsConfig.listDrainageInfos({ tenantId, signatureId, status, keyword, submittedAtFrom, submittedAtTo });
}
@Post('enterprise-signatures/:id/drainage-infos')
@@ -126,8 +126,8 @@ export class AdminSmsConfigController {
}
@Get('enterprise-templates')
listTemplates(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('nameKeyword') nameKeyword?: string, @Query('contentKeyword') contentKeyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
const query = { tenantId, status, keyword, enterpriseKeyword, applicationKeyword, nameKeyword, contentKeyword, page: Number(page), pageSize: Number(pageSize) };
listTemplates(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('nameKeyword') nameKeyword?: string, @Query('contentKeyword') contentKeyword?: string, @Query('submittedAtFrom') submittedAtFrom?: string, @Query('submittedAtTo') submittedAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
const query = { tenantId, status, keyword, enterpriseKeyword, applicationKeyword, nameKeyword, contentKeyword, submittedAtFrom, submittedAtTo, page: Number(page), pageSize: Number(pageSize) };
return page || pageSize ? this.smsConfig.listTemplatesPage(query) : this.smsConfig.listTemplates(query);
}
+2
View File
@@ -9,6 +9,7 @@ import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplica
import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers';
import { SmsReportValidationService } from './report-validation.service';
import { SmsAuditService } from './audit.service';
import { shanghaiDateRange } from '../common/shanghai-date-range';
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
export class SmsDrainageService {
@@ -50,6 +51,7 @@ export class SmsDrainageService {
tenantId: query.tenantId,
signatureId: query.signatureId,
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
submittedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
OR: query.keyword ? [
{ siteName: { contains: query.keyword } },
{ url: { contains: query.keyword } },
+3
View File
@@ -9,6 +9,7 @@ import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplica
import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers';
import { SmsReportValidationService } from './report-validation.service';
import { SmsAuditService } from './audit.service';
import { shanghaiDateRange } from '../common/shanghai-date-range';
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
export class SmsSignatureService {
@@ -22,6 +23,7 @@ export class SmsSignatureService {
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined,
updatedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
drainageItems: query.drainageKeyword ? {
some: {
auditStatus: { not: 'deleted' },
@@ -134,6 +136,7 @@ export class SmsSignatureService {
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined,
updatedAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
drainageItems: query.drainageKeyword ? {
some: {
auditStatus: { not: 'deleted' },
@@ -67,6 +67,8 @@ export interface DrainageInfoListQuery {
signatureId?: string;
status?: string;
keyword?: string;
submittedAtFrom?: string;
submittedAtTo?: string;
}
export interface CreateSignatureMaterialDto {
@@ -116,6 +118,8 @@ export interface TemplateListQuery {
applicationKeyword?: string;
nameKeyword?: string;
contentKeyword?: string;
submittedAtFrom?: string;
submittedAtTo?: string;
page?: number;
pageSize?: number;
}
@@ -139,6 +143,8 @@ export interface SignatureListQuery {
applicationKeyword?: string;
signatureKeyword?: string;
drainageKeyword?: string;
submittedAtFrom?: string;
submittedAtTo?: string;
page?: number;
pageSize?: number;
}
@@ -1103,6 +1103,24 @@ describe('SmsConfigService', () => {
expect(prisma.smsSignature.update).not.toHaveBeenCalled();
});
it('applies the audit submission range to signatures, templates and drainage records', async () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
const query = { submittedAtFrom: '2026-08-01', submittedAtTo: '2026-08-03' };
const expectedRange = {
gte: new Date('2026-08-01T00:00:00+08:00'),
lte: new Date('2026-08-03T23:59:59.999+08:00'),
};
await service.listSignatures(query);
await service.listTemplates(query);
await service.listDrainageInfos(query);
expect(prisma.smsSignature.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ updatedAt: expectedRange }) }));
expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ createdAt: expectedRange }) }));
expect(prisma.smsDrainageInfo.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ submittedAt: expectedRange }) }));
});
it.each([
'【带 空格】',
' 【外部空格】',
+3
View File
@@ -8,6 +8,7 @@ import { automaticDeliveryMode } from '../open-api/delivery-mode';
import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts';
import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers';
import { SmsAuditService } from './audit.service';
import { shanghaiDateRange } from '../common/shanghai-date-range';
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
export class SmsTemplateService {
@@ -22,6 +23,7 @@ export class SmsTemplateService {
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
name: query.nameKeyword ? { contains: query.nameKeyword } : undefined,
content: query.contentKeyword ? { contains: query.contentKeyword } : undefined,
createdAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
OR: query.keyword ? [
{ name: { contains: query.keyword } },
{ content: { contains: query.keyword } },
@@ -49,6 +51,7 @@ export class SmsTemplateService {
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
name: query.nameKeyword ? { contains: query.nameKeyword } : undefined,
content: query.contentKeyword ? { contains: query.contentKeyword } : undefined,
createdAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
OR: query.keyword ? [
{ name: { contains: query.keyword } },
{ content: { contains: query.keyword } },
+28 -8
View File
@@ -1,7 +1,7 @@
{
"version": "r1",
"baselineCommit": "0af671b4ed4713912e703defd08791f164d4eb25",
"generatedAt": "2026-07-31",
"generatedAt": "2026-08-03",
"coreFunctions": {
"readErrorBody": "a12b96623f266784d928784ab83fe172fea7470f14b8e2be6684f776119b50dd",
"requestPortal": "0b19b95d4ece5b775f2b62951368b1a8b19d7e74f4c2644b40b02866783b49ea",
@@ -311,7 +311,7 @@
},
{
"name": "listTemplateAudits",
"implementationSha256": "cd52a1a4ce1d7a64468089f5cb7597904c246963665b1174e01ec6fce673760d"
"implementationSha256": "2d1a9f1441b5ab75fd464f29673e4534b8bc8737ac5b92425094af82d2489531"
},
{
"name": "approveTemplate",
@@ -339,7 +339,7 @@
},
{
"name": "listEnterpriseSignatures",
"implementationSha256": "e60e04452f022052a5aa21159b1b69828c7a0c7de731ce723c7d5742751ecb74"
"implementationSha256": "132c04125b1d2ae39fa21f8a71ecfe0d3e12e64cce3c8fab9d089e2423e329ee"
},
{
"name": "listEnterpriseSignaturesPage",
@@ -363,7 +363,7 @@
},
{
"name": "listDrainageInfos",
"implementationSha256": "e75bafd3e0bb991f853f9cb195b8043c2ab180383e14ae0bd195cba441c70dbe"
"implementationSha256": "9ff26a607e03786eaefd96f8e138509d01f14be71258c75c063b1555b1947960"
},
{
"name": "listAuditRecords",
@@ -411,7 +411,7 @@
},
{
"name": "listEnterpriseCertifications",
"implementationSha256": "dde64635239c428de705e832356bcf39195ae5ce3af93bccd08d0be0ca436d23"
"implementationSha256": "a02638b588d6c53d3b51df33336c012f1d9f3d0d4bb5ed9f7b550dd114d4e96c"
},
{
"name": "getEnterpriseCertification",
@@ -571,11 +571,11 @@
},
{
"name": "listOperationMessages",
"implementationSha256": "d89407edcb6942fe8bc5fd618c2fcc17abb151a1d395d76c512a1b8412a0cb71"
"implementationSha256": "ca21bae1df2e2df8d51ee8b3bc0e5d0cddf056cfa3e00dbb839ec0382b01b821"
},
{
"name": "exportOperationMessages",
"implementationSha256": "01c49d9ca8e071c085244a9a2dd66e5f27d271ef9a746f18769956647a62cbd2"
"implementationSha256": "e42feb6aa6eacc571a2bc52315f24f4cdd54d673ce71ac49902893fc816ab62f"
},
{
"name": "listAdminUplinkMessages",
@@ -635,7 +635,7 @@
},
{
"name": "listRiskReviewTasks",
"implementationSha256": "1040e5775f210a00a26df88b3aac01523ca61c29409edd5f30e6e1c98fb3e5cc"
"implementationSha256": "8be7604b795105e51d3301a0e9c2f3a7a4b83a65981070fd64aafed27df67125"
},
{
"name": "listRiskRules",
@@ -773,6 +773,26 @@
"name": "deleteCommonReportField",
"implementationSha256": "e305aff8b3eabaa020c2044d1993bbd8e5556d2b5b74451c2d97df44a91a96a9"
},
{
"name": "listDrainageDetectionRules",
"implementationSha256": "f64be5ee02acc626a6cae581604a9a8400cfb6f4110913ab7a5d30e0518f8001"
},
{
"name": "createDrainageDetectionRule",
"implementationSha256": "165e44de881102c91f04076d3c5a34fc81fa5ac9d1dc55d5c5167d30f43f4944"
},
{
"name": "updateDrainageDetectionRule",
"implementationSha256": "6e7c079dd3511acedac00b7aa530c9568c7c066538667e253d685ba202af28de"
},
{
"name": "changeDrainageDetectionRuleStatus",
"implementationSha256": "950d2bfcf817375996d3160e96f48f89de15cee07b6545cbb1fe4c013b9fbb92"
},
{
"name": "testDrainageDetectionRule",
"implementationSha256": "908309fed077f6fa165715a8ee90ae1bcf1132b470fc4af013784aaae61a38a7"
},
{
"name": "uploadFileObject",
"implementationSha256": "e4e12f112fdb40274b08c8109dc0bb585cd23ab62dfed0ce9e8e11ca7e508482"
+2 -2
View File
@@ -1,7 +1,7 @@
{
"version": "R5",
"source": "api/src/channels/channels.service.ts",
"generatedAt": "2026-07-31T02:40:10.189Z",
"generatedAt": "2026-08-03T15:27:00+08:00",
"publicMethods": [
{
"name": "onModuleInit",
@@ -96,7 +96,7 @@
{
"name": "testChannel",
"signature": "async testChannel(channelId: string, data: TestChannelDto = {})",
"canonicalBodySha256": "e01b10f3981c66c5fab6cfd6d1b886abbb45e1fe53852b57c0903fcd826815d3",
"canonicalBodySha256": "6336a3d1ecfb3c9fb838a9a198c35ab20009677e34b6cf23e7ac21f7d4b09f97",
"originalLines": [
536,
636
@@ -32,6 +32,9 @@
"--sidebar-width",
"--topbar-height",
"--control-height-md",
"--query-control-width",
"--query-date-range-width",
"--query-action-width",
"--z-modal"
],
"resetFile": "src/styles/reset.css",
+6 -6
View File
@@ -1,9 +1,9 @@
{
"version": "r2",
"baselineCommit": "0af671b4ed4713912e703defd08791f164d4eb25",
"generatedAt": "2026-07-31",
"generatedAt": "2026-08-03",
"contracts": {
"MessageQuery": "07530ba61641758c96b5cd91dcdaba8692d5da4d9657d66e71009e7abf338f13",
"MessageQuery": "b2713cdb6dedf7d6d9c3aa23235b595a15a8f0c82c045a0bd9b39151a40cf00c",
"TraceQuery": "636138d9593d61b2936eb32231d071b4682ca1d36cbf6a2b07d3b71b4f5d8453",
"OperationLogQuery": "084d60f6487a2b38264211bf030950baacd2bc9b218e992867fb0e429dae7ece",
"GatewaySubmitDeadLetterQuery": "d40dea1cc7d5ee369ab16a49cada5c6bcfae2b21fc66459c49d9377374aa1445",
@@ -47,7 +47,7 @@
"group": "messages",
"isPrivate": false,
"signatureSha256": "37075fd6448d81f6acc25efe9a3670c8d6ec962add6a0b1e3115a31c05f8d7a6",
"bodySha256": "649439bf58c411100dcc24d634c7f2c87c6323c74cfc76539f853485c3c59461"
"bodySha256": "44fb733c7f072fb69a85eba97a9b896e0fc93b6daf240870ce595611725e26b9"
},
{
"name": "listClientMessages",
@@ -117,14 +117,14 @@
"group": "quality",
"isPrivate": false,
"signatureSha256": "7fa4cd6bcee4092390a38d693aa30c7980781ca1baa73dd07a3a61f097774e9c",
"bodySha256": "730b291f2fa96a9bcb054589a1341bc21f8ade161d232141d74f77eb426c4522"
"bodySha256": "e1334241997093de755a8e31ca8655694635b014d9016d6180dfd42ee283c231"
},
{
"name": "signatureQuality",
"group": "quality",
"isPrivate": false,
"signatureSha256": "6b9c3761674cb0d01000caca5e12cf91a6e526757592ffb783fc673c178b204c",
"bodySha256": "c85644b03f607339c2eae5390b01fb520264e87f16d0439c81a0c101b4277fa2"
"bodySha256": "a7203deb5a8c7c3b6f6afaf83a55bb1ff45a0bf198f86a2158868c4b8c759b2d"
},
{
"name": "auditLogs",
@@ -240,7 +240,7 @@
}
],
"helpers": {
"messageWhere": "8d73104a7035970fceee92d95f2f76960c64aa5e1862be3441879d666d57a947",
"messageWhere": "072069862f457a9ed100e76fefe664c6c7b9a977ca21cd51eb6bfcf548dcb2a5",
"recognizedCarrierValues": "d60957621e921bf9f5df8fdef616f4e2a0b16be78623c85316a65bae83025bdb",
"carrierWhere": "1c7947c02a1a5cd3aaeda4a586421f6d7d63ae2f912cb11292831af2ffa4c379",
"startOfShanghaiDay": "12eb964ff5ab3680837cf50bccabe5e8e7af733c248d06cb57f20c0c65ce0b4e",
@@ -1,6 +1,6 @@
{
"version": "R10",
"generatedAt": "2026-07-31",
"generatedAt": "2026-08-03",
"source": "api/src/send-chain/send-chain.service.ts at R9 local baseline",
"facade": "api/src/send-chain/send-completion.service.ts",
"methods": [
@@ -67,7 +67,7 @@
{
"name": "handleReceipt",
"file": "send-receipt.service.ts",
"bodySha256": "e3b63dc74af50db96d899c805124c7974067ca729e44a1ec8a558569e81fbd6c"
"bodySha256": "7302c72f2f3f3e374f38c365234f1a9705e80150de750d6fee73c22856efee46"
},
{
"name": "recordReceiptSegment",
@@ -77,7 +77,7 @@
{
"name": "aggregateReceiptSegments",
"file": "send-receipt.service.ts",
"bodySha256": "7dc565bd0847462bd0e23b5f072a4f82fd6daa88f164485138ce653d8cf936b8"
"bodySha256": "865a66025c8320096fac36c827b34bd612ccb34a11d523fd8590cdd4491da523"
},
{
"name": "resolveReceiptMessage",
@@ -177,7 +177,7 @@
{
"name": "queueAndTryDownstreamDelivery",
"file": "send-downstream-delivery.service.ts",
"bodySha256": "0b5f6f5e9ffebad5ddda003962efdabb0daeb2ec5942a246bdf9d922f274d4ac"
"bodySha256": "190b4c332e5913ef5ec44a25694270ef6320b3203df347bd25ef40f22cc0d7d9"
},
{
"name": "resolveUplinkMatch",
@@ -187,7 +187,7 @@
{
"name": "recordCmppFailureReceipt",
"file": "send-downstream-delivery.service.ts",
"bodySha256": "d48ecabfcce62787668486a8f14b3ea7afc935d5d5d910651fc1ddf8d8ac3952"
"bodySha256": "030175c6265820e62350a1527270627f90404df343ace9faf42e929fc00db790"
},
{
"name": "postGatewayControl",
@@ -197,7 +197,7 @@
{
"name": "markUnknownTimeout",
"file": "send-timeout.service.ts",
"bodySha256": "6f254ffbf05067483801cdd2c131f32cb9aaaf755d19f3aff065fe99b8d7753b"
"bodySha256": "21a83514324e383e11c0c3c369f0478d3d09dba7c52e479685141a4d9a1fd9d2"
},
{
"name": "markExpiredDownstreamDeliveries",
+2 -2
View File
@@ -20,7 +20,7 @@
{
"name": "GatewayInboundSubmitDto",
"kind": "interface",
"sha256": "28f99a1e29385b64c3a6b4eab2e5befa3fc6cd8a6995090e0865f5af6871246a"
"sha256": "9662a33869bb44dacf7a56d1b907e31a7f6c1b9129e536cae2f234f1087074e3"
},
{
"name": "GatewayInboundSingleSubmitResult",
@@ -257,7 +257,7 @@
{
"name": "drainageRejectionReason",
"kind": "function",
"sha256": "4bc0aa5e5c7f96549cd8bad93d3b25fd5e65e6b017ae9bf83baa5c47cab08c02"
"sha256": "98308cc1e9cb4d8979da0484992d0ba272046e122912b68cfc13b33ac80bf7f2"
},
{
"name": "statusFromRisk",
+6 -6
View File
@@ -1,12 +1,12 @@
{
"version": "R9",
"generatedAt": "2026-07-31",
"generatedAt": "2026-08-03",
"source": "api/src/send-chain/send-chain.service.ts at R8 local baseline",
"target": "api/src/send-chain/send-*.service.ts via send-submission.service.ts compatibility facade",
"methods": [
{
"name": "createBatchTask",
"bodySha256": "c7baccb2b49a95ef4b36a97f6f56c987f0ac60c45ed634cd050201f2a9bce8b5",
"bodySha256": "7ba5573e99f0616a7e57f53c242c60f3ec96d66a5164bbb170f177805ddfe9b0",
"file": "send-batch-entry.service.ts"
},
{
@@ -66,7 +66,7 @@
},
{
"name": "submitInboundMessage",
"bodySha256": "281e33a732f80e4ede6cc9d04a5b206a9348e17e66dee33d53f2d8d1d9bb9472",
"bodySha256": "dfe4a7516f9472870957e40acbe74fb7c8f76dd2113dda9ce06ea338241d1dba",
"file": "send-inbound-entry.service.ts"
},
{
@@ -81,7 +81,7 @@
},
{
"name": "collectInboundLongMessageFragment",
"bodySha256": "5b4e7f795966462fccb8c4c7a2cfae649ef19ee1b8176e7356eb2d621526ba41",
"bodySha256": "ac157c4848e12d46b98d46a8fbb13b65c6f18ae6dd2de3340d68f8bfa6908f63",
"file": "send-inbound-entry.service.ts"
},
{
@@ -91,7 +91,7 @@
},
{
"name": "submitInboundSingleMessage",
"bodySha256": "df97548b1710b217f118cc297104d6c5cc845acc40947df4180036b8d284d847",
"bodySha256": "6fa4c577848eb06f635a8f186d1e1510b1213f0ba6d439d032ea08c593725d14",
"file": "send-inbound-entry.service.ts"
},
{
@@ -156,7 +156,7 @@
},
{
"name": "resolveTemplateMessageClassification",
"bodySha256": "f2b99e2e9d7fa00172b3cca72f080493fbf6f090f378784611388320254575f7",
"bodySha256": "33c02992cc542862382456ffc4674cf0359e3b3e9271cabb23bd4d6f041f17a9",
"file": "send-batch-entry.service.ts"
},
{
+31
View File
@@ -52,6 +52,9 @@
"ui-modal__header",
"ui-modal__body",
"ui-modal__footer"
],
"src/components/ui/DateRangeInput.tsx": [
"ui-date-range-field"
]
},
"requiredRuleFragments": [
@@ -105,6 +108,19 @@
"gap:var(--space-6)"
]
},
{
"selector": ".ui-filter-row",
"includes": [
"display:flex",
"flex-wrap:wrap"
]
},
{
"selector": ".ui-filter-actions .ui-button",
"includes": [
"width:var(--query-action-width)"
]
},
{
"selector": ".form-grid--two",
"media": "(max-width: 780px)",
@@ -112,6 +128,21 @@
"grid-template-columns:1fr"
]
},
{
"selector": ".ui-filter-actions",
"media": "(max-width: 780px)",
"includes": [
"grid-template-columns:repeat(2, minmax(0, 1fr))",
"width:100%"
]
},
{
"selector": ".ui-filter-actions",
"media": "(max-width: 360px)",
"includes": [
"grid-template-columns:minmax(0, 1fr)"
]
},
{
"selector": ".table-actions",
"media": "(max-width: 780px)",
+14 -14
View File
@@ -1,7 +1,7 @@
{
"version": "R3",
"source": "api/src/sms-config/sms-config.service.ts",
"generatedAt": "2026-07-31T01:56:36.226Z",
"generatedAt": "2026-08-03T15:32:00+08:00",
"publicMethods": [
{
"name": "onModuleInit",
@@ -204,8 +204,8 @@
{
"name": "listSignatures",
"signature": "async listSignatures(queryOrTenantId?: string | SignatureListQuery)",
"bodySha256": "9e8d363c2ee1022f429a9fa16e5e933f334a528e640f49ccd83e0cfc7686aea6",
"canonicalBodySha256": "91e26f503140018b00afe5563a4f86d22117c2805ac86532559ac44f1360d8a2",
"bodySha256": "bb4d8d983ed5cda59235b39396d09a568a554ad72c564ef4daab2d92dac6633a",
"canonicalBodySha256": "bb4d8d983ed5cda59235b39396d09a568a554ad72c564ef4daab2d92dac6633a",
"originalLines": [
915,
1025
@@ -215,8 +215,8 @@
{
"name": "listSignaturesPage",
"signature": "async listSignaturesPage(query: SignatureListQuery)",
"bodySha256": "24e161ae5049af0689d3a9d0e4ab802964b87b1f2d407d8309b8e6cbe8afdd56",
"canonicalBodySha256": "34ee94d016bdc986d98b01612f4c64fdc03b0e921abedeff8fe84bd64dec958c",
"bodySha256": "b3554a30debe1457938985f211c9107a4ca26b28581509b523810a45bb6f432a",
"canonicalBodySha256": "b3554a30debe1457938985f211c9107a4ca26b28581509b523810a45bb6f432a",
"originalLines": [
1027,
1058
@@ -325,8 +325,8 @@
{
"name": "listDrainageInfos",
"signature": "listDrainageInfos(query: DrainageInfoListQuery = {})",
"bodySha256": "2841e83c70180cdce83943d9d6ae212a9d29c6eae701ecf928bdbaedbdc93457",
"canonicalBodySha256": "0de53393ca5f91da7a521c13a96813b1f8211ded00f0fa797b752b6a87b73148",
"bodySha256": "99f53b52b01b3f75fe85886a1b62c98d1ae7fa72afb11eae98bbdc88103616c8",
"canonicalBodySha256": "99f53b52b01b3f75fe85886a1b62c98d1ae7fa72afb11eae98bbdc88103616c8",
"originalLines": [
1302,
1319
@@ -413,8 +413,8 @@
{
"name": "listTemplates",
"signature": "listTemplates(queryOrTenantId?: string | TemplateListQuery)",
"bodySha256": "c7c9b9b6f03c046c94459d021604dcce3b5d9d2ce192d547bce9d31310b27a7f",
"canonicalBodySha256": "e276e9f029b2cb5d5cf9551959f0525a9c501fefc97bf7a9300ba9d6554aa714",
"bodySha256": "0cd3e2ab2856057243722f323915cc4e905e3af888f8f70d8a9f164a07ebb234",
"canonicalBodySha256": "0cd3e2ab2856057243722f323915cc4e905e3af888f8f70d8a9f164a07ebb234",
"originalLines": [
1558,
1583
@@ -424,8 +424,8 @@
{
"name": "listTemplatesPage",
"signature": "async listTemplatesPage(query: TemplateListQuery)",
"bodySha256": "a0a7baafc68189e2daf93b970826f7d60ee60aa35662f48eeb7b4cb4e119b021",
"canonicalBodySha256": "2e836e44a397557b95cb0d978745df82c9c86cf77360157e78382ca71c0156a0",
"bodySha256": "c4b8553dd957e8471cd6ccb970162694f2db62141c66b5b3014220e0eeb866cc",
"canonicalBodySha256": "c4b8553dd957e8471cd6ccb970162694f2db62141c66b5b3014220e0eeb866cc",
"originalLines": [
1585,
1608
@@ -833,7 +833,7 @@
},
{
"name": "DrainageInfoListQuery",
"sha256": "9cd9cfe5af9422ed64d5d00071c9b4c58b0ae820c43eeb7dad604b67fd5f6fc1"
"sha256": "40708e60bfa39481897d00a8b2e99583effdffdd588efd99ee9b73687eae82ae"
},
{
"name": "CreateSignatureMaterialDto",
@@ -861,7 +861,7 @@
},
{
"name": "TemplateListQuery",
"sha256": "06c34488ffaa2d870d781fc2f666e294c408eefce1acb1ece11727f6aabc76c2"
"sha256": "9d38ad4689f0cda80c3d921a3a05e93cdf0b8a7696c782d5d9541966347bf78a"
},
{
"name": "ApplicationListQuery",
@@ -869,7 +869,7 @@
},
{
"name": "SignatureListQuery",
"sha256": "619cbff41cf63f18e5732ebfc8f899c5fd5c3394f7c54d9788e0561bc25eb027"
"sha256": "04cd16b62df3c381cd6c0ef873fb192e56fb745dd1adf1bdf550a8800aad2256"
},
{
"name": "GatewayDownstreamConnectionEventDto",
+34 -3
View File
@@ -16,6 +16,7 @@
- 企业应用连接详情只展示当前已连接会话;断开或心跳超时会话从活跃连接表删除,不保留为连接历史。
- 短信审核批量通过必须基于明确勾选,只处理已选待审核任务;不得默认通过当前筛选结果或全部数据。
- 下游投递记录支持创建日期范围筛选,并将日期条件下推 PostgreSQL 列表与 Dashboard 聚合。
- 下游投递记录和恢复状态管理的多条件筛选区必须遵循共享查询控件规范:普通条件、日期范围和操作按钮使用统一宽度,空间不足时按完整控件自动换行,不允许为维持单行而压缩、重叠或截断控件;移动端条件整行展示,查询与重置按钮保持清晰的独立操作区。
- 短信模板变量插入到文本框当前选区或光标位置,插入后光标移动到变量之后。
- 运营端短信记录使用自适应信息卡片,发送详情按概览、短信内容、通道回执、状态和分片审计分组,失败原因使用独立警示区域突出;该项只调整前端展示,不改变短信记录后端语义。
- 人工充值弹窗不要求填写操作人,操作身份从当前登录会话和后端操作日志取得。
@@ -1691,7 +1692,7 @@
4. 通讯日志不得保存短信正文、密码、密钥、Token、签名鉴权值或完整HTTP请求体;手机号只保存脱敏值。CMPP心跳不得逐包写入数据库,连接健康仍使用连接状态和聚合指标。
5. 通讯日志写入不能阻塞短信主链路,默认批量异步写入,缓冲区应有上限和溢出告警;热数据默认保留30天,保留期允许通过环境变量配置。
6. 通讯日志方向固定使用“企业应用 → 平台、平台 → 供应商通道、供应商通道 → 平台、平台 → 企业应用”。供应商长短信每个真实 `SUBMIT``SUBMIT_RESP` 分片各记一条,企业应用每个真实 `SUBMIT_RESP` 也必须记录;内部 `submit-result` 聚合回调不是协议报文,不得重复生成通讯日志。
7. 供应商长短信回执必须先写入对应 `SmsMessageSegmentAudit`。仅当同一提交尝试的全部分片均为 `delivered` 时,主记录才转 `delivered` 并向企业应用投递一次最终回执;任一分片明确失败可进入最终失败/补发状态,分片尚未齐全时主记录保持 `submitted`,不得由首片成功提前聚合。
7. 供应商长短信回执必须先写入对应 `SmsMessageSegmentAudit`。仅当同一提交尝试的全部分片均为 `delivered` 时,主记录才转 `delivered`;任一分片明确失败可进入最终失败/补发状态,分片尚未齐全时主记录保持 `submitted`,不得由首片成功提前聚合。内部业务终态只聚合一次,但对企业应用的CMPP状态报告必须按其原始Submit分片逐片投递,并分别使用平台当初为该分片返回的`CMPP_SUBMIT_RESP.Msg_Id`HTTP Webhook仍按原HTTP消息投递一个最终事件。
8. 长短信任一分片返回非成功终态时,系统必须通过该分片审计关联的提交记录识别当前发送尝试,不得仅以主记录保存的首片上游消息号判断;确认属于当前尝试后,整条短信立即进入失败/补发或退款终态,无需等待其余分片回执。
9. 回执和上行投递方式不得由运营人员选择。企业应用开通CMPP接口即按CMPP投递,开通HTTP接口且对应Webhook地址非空即按HTTP投递,两者同时满足时双投;任一地址为空时只跳过该类HTTP事件。运营端企业应用HTTP参数页必须始终可编辑回执和上行Webhook地址,不因HTTP接口开关关闭而隐藏。
10. Gateway向企业应用发送真实 `CMPP_DELIVER` 以及收到企业应用真实 `CMPP_DELIVER_RESP` 时,都必须各写一条通讯交互日志,分别使用“平台→企业应用”和“企业应用→平台”方向;下游投递记录继续承担排队、重试和ACK业务状态,不得以通讯日志替代。
@@ -1709,7 +1710,8 @@
1. 用户逻辑删除后,原用户记录、用户主键及历史审计关联必须继续保留;用户名、邮箱和手机号仅在未删除用户范围内唯一。新建用户可以复用逻辑删除记录曾使用的登录标识,但必须生成新的用户主键,不得继承旧用户的角色、企业归属、密码、会话或权限。
2. 用户登录和按用户名查找必须显式排除逻辑删除记录。活动用户之间的登录标识并发冲突继续由PostgreSQL唯一索引保证,并返回HTTP 409、冲突字段及明确中文提示。
3. 运营端用户管理将用户姓名、登录账号、所属企业、用户角色和状态拆分为独立条件;客户端将用户姓名、登录账号和状态拆分。点击查询或重置时调用真实后端组合查询,条件之间使用AND,登录账号内部对用户名、邮箱和手机号使用OR;不得加载全量数据后仅在浏览器过滤。
4. 删除、禁用或降权最后一个平台管理员时,后端实时拦截继续作为权威判断;企业管理员不设“最后一名”保护,可删除或停用至零人。前端必须在当前确认弹窗内以`role=alert`显示平台管理员保护失败原因和处理建议,保持弹窗打开,禁止只向浏览器控制台输出未处理Promise;请求期间确认按钮必须禁用
4. 运营端用户管理的五组查询条件与查询/重置操作必须使用共享查询控件宽度并允许按完整控件自然换行;不得通过缩窄字段把所有条件强塞在一行。“新增用户”作为业务操作与查询操作保持独立,在窄屏下不得挤入查询字段之间
5. 删除、禁用或降权最后一个平台管理员时,后端实时拦截继续作为权威判断;企业管理员不设“最后一名”保护,可删除或停用至零人。前端必须在当前确认弹窗内以`role=alert`显示平台管理员保护失败原因和处理建议,保持弹窗打开,禁止只向浏览器控制台输出未处理Promise;请求期间确认按钮必须禁用。
## 2026-07-26 企业应用停用与回执清算补充要求
- 删除企业前必须检查其企业应用;只要存在`active``disabling`应用就阻止删除,并提示先完成应用停用。
@@ -1768,7 +1770,7 @@
1. 同一`SmsSubmitRecord`无论收到多少个分片失败回执、重复聚合结果或并发工作线程,只允许创建一个下一跳补发记录。数据库必须以来源提交记录建立唯一补发关系,不能只依靠进程内锁、先查后建或短信主记录当前状态判断。
2. 任一分片明确失败仍可及时判定本次长短信尝试失败,不要求等待其余失败回执;后续分片回执继续完整落库和写通讯日志,但只能复用已取得的补发决定,不得再次向Gateway发布提交命令。
3. 短信扣费、冻结释放和最终失败退款必须使用稳定业务幂等键。企业账户余额变更在数据库事务内按企业串行并使用原子增量,禁止读取旧余额后覆盖写入;相同幂等键重复调用必须返回原交易,不得再次改变余额。
4. 同一短信记录只允许成一最终回执投递。CMPP下游投递使用数据库唯一去重键,HTTP Webhook使用稳定事件号和事件/端点唯一关系;重复终态处理返回原投递,不得再次向客户发送
4. 同一短信记录只允许成一个内部最终业务结论、一笔最终退款和一个HTTP最终回执事件。CMPP下游必须按原始客户Submit分片分别生成最终状态报告,使用“短信记录+原始客户分片序号”数据库唯一键;同一分片重复终态处理返回原投递,不得再次发送。HTTP Webhook继续使用短信记录级稳定事件号和事件/端点唯一关系。
5. 补发抢占成功、并发复用及下游投递去重必须写结构化日志,至少包含平台消息号、短信记录、来源提交记录、下一跳`submitId`、通道和复用的投递记录。
6. 历史重复提交、通讯报文、回执、退款及客户ACK属于事故审计证据,不得在功能migration中删除或覆盖。历史余额修正必须先完成账户与流水专项对账,再通过可审计冲正处理。
@@ -1873,3 +1875,32 @@
- 短信发送页的字数和预计条数必须随编辑后的实际短信内容即时重新计算;单条短信不超过70个Unicode字符计1条,长短信按每67个字符计费,预计条数为单号码计费条数乘有效号码数。单价单位显示为“元/条”。
- 提交发送任务失败时使用页面中央弹窗展示真实后端错误;定时发送控件提供按北京时间计算的“今天”按钮,并以浅色背景标出北京时间当天。提交给后端的定时时间必须显式携带`+08:00`时区。
- 提交任务成功后弹窗展示真实任务编号和发送号码数。“继续发送短信”清空当前发送表单和导入内容;“查看任务进度”进入批量任务页面并按任务编号定位本次任务。
## 工作台金额与审核查询控件统一(2026-08-03)
- 客户端短信服务工作台在“今日返还金额”前展示“今日消费金额”,金额取当前企业 Dashboard API 按北京时间当天汇总的真实消费值,与运营端账务口径一致,不在浏览器重新估算。
- 审核中心的企业认证、短信、短信模板、短信签名和引流信息审核页面均提供“提交时间”日期区间筛选;短信签名和引流信息的“导入批次审核”页签也按导入批次提交时间筛选。
- 提交时间筛选必须传入真实后端并由 PostgreSQL 执行。日期边界按 `Asia/Shanghai` 自然日解释,开始日包含 `00:00:00`,结束日包含 `23:59:59.999`;格式非法、日历日期不存在或开始日晚于结束日时,API 返回受控参数错误。
- 运营端查询区建立全局统一尺寸:普通输入框/下拉框使用统一标准宽度,日期区间控件使用更宽的统一宽度,查询/重置按钮使用统一固定宽度;窄屏下控件和按钮自适应整行,不产生页面级横向溢出。
- 风控规则页的规则范围、平台白名单和号码频次触发记录三组查询条件均使用上述统一布局。白名单和触发记录提供查询、重置,重置后以明确的默认条件重新请求真实后端。
## 短信内容引流信息识别与统计(2026-08-03)
- 本期只识别、记录、查询和统计短信内容是否含引流信息。引流资料是否已报备、审核状态及报备进度均不得拦截或转人工审核;所有发送入口移除`DRAINAGE_NOT_APPROVED`决策,既有模板、签名、余额、黑名单和其他风控规则保持不变。
- 运营端“系统管理”新增“引流识别规则”页面,规则存储在真实数据库,支持 URL、手机号码、固定电话三类表达式的新增、编辑、启停、优先级和测试。变更保留版本并写操作日志,发送入口按当前启用规则生成识别快照和规则版本。
- URL 识别覆盖带协议链接、无`http://`的裸域名、短链接、IP 地址及端口/路径,并支持中文标点相邻、空格或中文句号拆分等规避写法;手机号码支持`+86`、空格、短横线和中文标点拆分;固定电话支持区号括号、分隔符和分机号。邮箱地址不属于引流信息。
- 识别规范化只作用于检测副本,不得修改真实短信发送内容。消息记录持久化是否含引流、命中类型、原文位置、规则版本和检测时间;历史未检测数据保留为“未检测”,不得伪造为不含引流。
- 运营端短信记录提供“是否含引流信息”筛选,支持含引流、不含引流和未检测;含引流记录使用提示色底色并高亮原文命中片段,CSV 同步导出该维度。
- 数据统计的签名发送质量明细保留原“通道 × 运营商”整体矩阵,并提供按含引流、不含引流、未检测切分的矩阵视图;整体统计必须直接反映全部提交,不得用分组平均值替代。
- 运营看板“今日签名发送统计”按签名统计全部短信,不区分是否含引流;“今日签名发送统计 - 含引流”只统计内容识别结果为含引流的消息。
- 未来可在识别结果之上增加发送、拒绝或人工审核决策,但必须作为独立迭代启用,不能在本期以隐式状态或既有报备审核逻辑提前拦截。
## CMPP逐分片最终回执与72小时超时回执(2026-08-03
- CMPP协议状态值中的`ACCEPTD``UNKNOWN`不得在产品和代码中扩展解释为协议规定的“临时状态生命周期”;平台只依据明确业务规则判断是否继续等待,不能臆造协议阶段。
- Gateway接收每个客户`CMPP_SUBMIT`时必须保存该包的`Registered_Delivery``Sequence_Id`。长短信每个原始分片的身份独立保存,不得只保留第一片;历史未保存`Registered_Delivery`的CMPP记录按原有“请求回执”行为兼容。
- 内部仍以一条`SmsMessageRecord`聚合长短信业务终态、重投、计费和退款;对外CMPP状态报告按原始客户分片逐片建立。每片使用`SubmitGroupMessageId + 原Sequence_Id`重建平台当初返回的`SUBMIT_RESP.Msg_Id`,并以“短信记录+客户分片序号”独立幂等。
- 已取得真实分片回执时,下游对应分片必须使用该片自己的状态、原始状态码、错误码和到达时间,不得把另一片的结果复制过来;整条短信明确最终失败而部分片仍无结果时,缺失片使用整条短信的明确最终失败状态,已成功片不得改写为失败。
- `Registered_Delivery=0`的客户分片不生成CMPP状态报告;同一HTTP提交仍只生成一个消息级最终Webhook,不因供应商内部计费分片数而重复回调。
- 提交或未知状态满72小时仍无明确最终回执时,主记录转为`timeout`并写入`undelivered/EXPIRED/RECEIPT_TIMEOUT`。CMPP对每个请求状态报告的原始分片建立失败回执,HTTP建立一个明确失败Webhook;退款保持消息级一次。
- 超时状态变更与下游回执建单之间必须可恢复:仅在HTTP事件及全部应建CMPP分片投递均成功持久化后写`timeoutReceiptQueuedAt`;中途失败保留空标记,由后续定时扫描按稳定幂等键补齐,禁止出现“已转超时但永久没有下游回执”。
+47 -8
View File
@@ -1447,6 +1447,7 @@
- `awaiting_ack` 记录在前端不可选且后端拒绝并发重投,不能仅依赖按钮禁用。
- 确认弹窗明确展示投递类型、消息 ID 和重复处理风险;取消时不得请求后端,确认提交期间操作按钮禁用并显示处理中状态。
- 单条成功弹窗展示真实返回的当前状态和 `manualRetryCount`;接口失败时弹窗展示错误,并提醒先刷新核对人工次数再决定是否重试,不能静默失败或诱导重复提交。
- 筛选区使用平台共享查询控件宽度;桌面端空间不足时条件按完整控件自然换行,不得把关键字、日期、状态、类型、应用和操作按钮挤压在同一行;移动端条件整行展示,查询与重置按钮清晰可操作。
### TC-GW-015 下游投递指数退避
@@ -1598,6 +1599,7 @@
- 页面刷新后恢复状态仍然存在,可继续用于生产排查。
- 页面解释恢复状态与逐条下游投递记录的用途差异;恢复状态和下游投递记录默认均选择近 7 天。
- 恢复状态按更新时间区间筛选,摘要、失败分类、列表和 CSV 导出使用同一时间口径;列表标题与外框保持正常内边距,最后错误/跳过原因列具备可读宽度。
- 筛选区复用平台共享查询控件宽度;关键字、日期、状态、失败分类、应用和操作按钮在桌面端按可用空间自然换行,移动端条件整行展示,不出现控件压缩、重叠、截断或按钮混入字段的问题。
### TC-GW-025 多 Gateway 恢复抢占协调
@@ -3445,7 +3447,7 @@ npm run verify:phase8
| TC-BILLING-010 | 余额不足发送失败,人工充值后重试发送并模拟 delivered。 | 充值前不扣费;充值后发送成功;冻结、扣费、短信计费记录完整;reconciliation diff 为 0。 |
| TC-BILLING-011 | 分别准备 `余额+授信` 为正数、0 和负数的账户,使用相同短信费用发起发送。 | 和为正数时允许发送;和为 0 或负数时提示余额不足。判断公式为 `balanceCents + creditCents > 0`,与本次费用和套餐无关。 |
| TC-BILLING-012 | 已扣费短信收到最终失败回执;另一个消息在提交前失败并释放冻结;另准备一笔任务冻结转扣费时的批次级释放。 | 最终失败只生成一条 `refunded` 并计入“今日返还”,重复回执不重复退款;提交前失败生成 `released + relatedType=sms_message_record` 并计入“今日返还”;冻结转扣费的 `released + relatedType=sms_batch_task` 属于内部转换,不计入“今日返还”;客户端和运营端当日金额一致且保留三位小数。 |
| TC-BILLING-013 | 准备已提交扣费但 72 小时完全无回执的 `submitted` 短信,以及有 `UNKNOWN` 回执且超过 72 小时的短信;启动 API 定时扫描并模拟重复扫描。 | 两类短信都转为 timeout 并退款;任务进度刷新;同一短信只退款一次;定时扫描默认启用且每 5 分钟执行。 |
| TC-BILLING-013 | 准备已提交扣费但 72 小时完全无回执的 `submitted` 短信,以及有 `UNKNOWN` 回执且超过 72 小时的短信;分别覆盖HTTP提交、CMPP短短信和多分片长短信,启动 API 定时扫描并模拟投递建单失败后重复扫描。 | 两类短信都转为 timeout、写入`undelivered/EXPIRED/RECEIPT_TIMEOUT`并只退款一次;HTTP产生一个明确失败Webhook,CMPP对每个请求回执的原始分片产生失败状态报告且使用各自SubmitResp Msg_Id;建单未完成时`timeoutReceiptQueuedAt`保持空并由后续扫描补齐,成功建单后不重复;任务进度刷新。 |
| TC-BILLING-014 | 在运营端充值记录中分别打开整数金额、含1至4位有效小数、负数冲正以及缺少可追溯余额的真实订单回执。 | 每行提供“查看回执”;弹窗左上只使用系统真实Logo;企业、订单号、时间、备注与数据库订单一致;可追溯订单的入账前余额等于入账后余额减本次变动;无快照时前后余额不得伪造;主金额整数不显示小数,非整数仅显示有效小数,余额仍显示四位精度;正数显示已入账,负数显示已冲正。 |
| TC-SEC-006 | 安装 API 生产依赖并执行 `npm audit`;使用缺文件、多文件、超大文件、超量字段和正常单文件调用认证后的 multipart 上传接口。 | NestJS/Multer/Hono 已升级或锁定到修复版本,生产依赖 audit 为 0;接口只接受一个不超过 20MB 的文件,并限制字段、part、字段名、字段值和 header pair 数量;异常请求返回受控 4xx,正常文件仍写入真实 MinIO 和 `FileObject`。 |
@@ -3706,7 +3708,7 @@ npm run verify:phase8
| --- | --- | --- |
| TC-USER-REUSE-001 | 新建用户名`zhaohui`,逻辑删除后再次使用同一用户名、邮箱或手机号新建用户。 | 新用户创建成功且主键与旧用户不同;旧用户及其OperationLog、审核关联保持原用户主键;登录只命中新用户。 |
| TC-USER-REUSE-002 | 两个未删除用户并发提交相同用户名、邮箱或手机号。 | PostgreSQL仅允许一个请求成功,另一个返回HTTP 409、`USER_DUPLICATE`、冲突字段和中文提示,不产生两个活动账号。 |
| TC-USER-FILTER-001 | 在运营端分别及组合填写用户姓名、登录账号、所属企业、用户角色和状态,点击查询,再点击重置。 | 每次操作请求真实`GET /api/admin/users`;条件分别生效,组合使用AND,登录账号匹配用户名/邮箱/手机号;重置返回全部未删除用户。 |
| TC-USER-FILTER-001 | 在运营端分别及组合填写用户姓名、登录账号、所属企业、用户角色和状态,点击查询,再点击重置;分别使用桌面和移动视口检查筛选布局。 | 每次操作请求真实`GET /api/admin/users`;条件分别生效,组合使用AND,登录账号匹配用户名/邮箱/手机号;重置返回全部未删除用户。五组条件按共享宽度自然换行,不被压缩或截断;移动端条件整行展示,查询/重置与“新增用户”分区清晰且均可操作。 |
| TC-USER-FILTER-002 | 在客户端分别及组合填写用户姓名、登录账号和状态。 | 请求真实`GET /api/client/users`;只返回当前企业管理员,无法通过查询参数跨租户或查询平台管理员。 |
| TC-USER-CONTINUITY-UI-001 | 删除、禁用或降权最后一个平台管理员,再删除或停用某企业最后一个启用管理员。 | 平台管理员操作由后端权威拦截并在确认弹窗显示建议;企业管理员操作成功且可归零;按钮结束忙碌状态,浏览器无未处理Promise。 |
| TC-USER-CONTINUITY-UI-002 | 为相同范围增加另一名启用管理员后重复删除或禁用。 | 操作成功、弹窗关闭、列表按当前已应用查询条件刷新,并写入对应OperationLog。 |
@@ -3851,9 +3853,9 @@ npm run verify:phase8
- `TC-PROTOCOL-LOG-008`:一条真实短短信取得成功状态报告后,按同一平台消息号查询应恰好看到四个供应商侧真实业务报文:`平台→通道/CMPP_SUBMIT``通道→平台/CMPP_SUBMIT_RESP``通道→平台/CMPP_DELIVER``平台→通道/CMPP_DELIVER_RESP`;每个报文只出现一条,箭头与抓包传输方向一致,长短信则按实际分片分别记录Submit/SubmitResp。
- `TC-PROTOCOL-LOG-009`:企业应用提交短信时,入站Submit显示“企业应用→平台”,每个实际返回的SubmitResp显示“平台→企业应用”;供应商侧统一显示“平台→供应商通道/供应商通道→平台”,不得再使用含义模糊的客户/通道箭头。
- `TC-RECEIPT-SHARED-010`:供应商账号、Gateway主机、端口、协议和CMPP版本均相同的两个物理通道连接中,回执从副连接进入、原连接存在唯一`gatewayMessageId + DestTerminalId`分片候选时,应写入原提交逻辑通道;账号或端点任一不同、或候选超过一条时不得自动匹配。
- `TC-RECEIPT-LONG-011`:两分片长短信仅收到第一片`DELIVRD`时,`SmsMessageRecord`保持`submitted`且不创建企业应用最终回执;第二片到达后两条分片审计均为`delivered`,主记录只聚合一次为`delivered`,重复回执不得重复投递、扣费或退款。
- `TC-RECEIPT-LONG-011`:两分片长短信仅收到第一片`DELIVRD`时,`SmsMessageRecord`保持`submitted`且不创建企业应用最终回执;第二片到达后两条分片审计均为`delivered`,主记录只聚合一次为`delivered`CMPP按两个原始分片各创建一条`DELIVRD`且分别使用两个SubmitResp Msg_IdHTTP只创建一个最终事件;重复回执不得重复投递、扣费或退款。
- `TC-PROTOCOL-LOG-012`:供应商长短信每个真实分片分别产生一条`平台→供应商通道/CMPP_SUBMIT`和一条`供应商通道→平台/CMPP_SUBMIT_RESP`;内部`submit-result`聚合回调不得额外落协议日志。
- `TC-RECEIPT-LONG-013`:两分片长短信主记录保存首片上游消息号,第二片返回`YL:1014`等任意非成功状态且首片未回执;系统通过第二片审计识别当前提交尝试,整条短信进入失败/补发或退款终态并只投递一次最终失败回执,不再卡在`submitted`
- `TC-RECEIPT-LONG-013`:两分片长短信主记录保存首片上游消息号,第二片返回`YL:1014`等任意非成功状态且首片未回执;系统通过第二片审计识别当前提交尝试,整条短信进入失败/补发或退款终态,不再卡在`submitted`;最终不再补发时,对两个请求回执的原始客户分片分别投递失败状态报告,Msg_Id与各自SubmitResp一致
- `TC-DELIVERY-AUTO-014`:分别配置仅CMPP、仅HTTP、CMPP+HTTP、两者均关闭四种应用状态;回执与上行分别只产生CMPP下游记录、HTTP Webhook事件、两者各一条、均不产生。修改历史手工投递模式不得改变自动计算结果。
- `TC-HTTP-WEBHOOK-015`:运营端关闭HTTP接口后,回执和上行Webhook地址输入框仍显示且可保存;任一地址保存为空时删除对应有效端点,后续不推送该类HTTP事件,另一非空地址不受影响。
- `TC-PROTOCOL-LOG-016`:在线企业应用收到回执或上行 `CMPP_DELIVER` 并返回 `CMPP_DELIVER_RESP`;通讯日志各出现一条“平台→企业应用/DELIVER”和“企业应用→平台/DELIVER_RESP”,结果、消息号、序列号和投递记录一致,下游投递记录仍独立展示发送、ACK和重试状态。
@@ -3934,10 +3936,10 @@ npm run verify:phase8
|---|---|---|
| TC-RETRY-RACE-001 | 三分片长短信的三个失败回执并发进入API,备用通道可用 | 三个回执和通讯报文全部保存;来源提交记录只关联一个补发记录,只发布一个Gateway命令、三个补发分片 |
| TC-RETRY-RACE-002 | 三个线程在唯一补发记录提交前后交错执行 | 只有一个线程取得`retryOfSubmitRecordId`唯一关系;其他线程返回同一下一跳`submitId`并写复用日志,不退款、不生成最终回执 |
| TC-RETRY-RACE-003 | 三个失败回执并发处理且没有可用备用通道 | 主记录最终失败;只产生一笔退款交易和一次余额增量,只生成一个CMPP最终失败回执及一个HTTP回调事件 |
| TC-RETRY-RACE-003 | 三个失败回执并发处理且没有可用备用通道 | 主记录最终失败;只产生一笔退款交易和一次余额增量;HTTP只生成一个最终失败事件,CMPP对三个原始客户分片各生成一条失败回执且每片只生成一次 |
| TC-BILLING-IDEM-004 | 三个线程使用同一短信退款幂等键并发退款 | 三次调用返回同一交易ID,`AccountTransaction`只有一条,账户余额只增加一次 |
| TC-BILLING-ATOMIC-005 | 同一企业同时发生扣费、退款和充值 | 账户级事务锁串行化余额变更,使用数据库原子增量;每条流水`balanceAfter`连续且最终余额与流水一致 |
| TC-DOWNSTREAM-IDEM-006 | 同一短信终态被重复处理,企业同时启用CMPP和HTTP | CMPP有一条`CmppDownstreamDelivery`且只发送一次;HTTP只有一个稳定事件和一条端点投递 |
| TC-DOWNSTREAM-IDEM-006 | 同一短信终态被重复处理,企业同时启用CMPP和HTTP | CMPP每个请求回执的原始客户分片各有一条稳定`CmppDownstreamDelivery`只发送一次;HTTP只有一个稳定事件和一条端点投递 |
| TC-MIGRATION-IDEM-007 | 在含历史重复最终回执的预生产数据上执行migration | 历史行全部保留;每个短信只给最早一条历史回执设置唯一键,其余保持空键;新数据开始强制唯一 |
## 2026-07-27 通道报备发送统计用例
@@ -4251,9 +4253,9 @@ npm run verify:phase8
| TC-REFACTOR-R10-005 | 分片未收齐、全部成功、明确失败或全部未知 | 未收齐不提前成功;全部成功才最终成功;明确失败优先;未知按既有超时和失败口径处理 |
| TC-REFACTOR-R10-006 | 两个执行者并发抢占同一失败短信补发 | 来源提交唯一关系、事务条件更新和P2002唯一冲突处理保证最多创建一个新提交尝试 |
| TC-REFACTOR-R10-007 | 重复执行成功扣费、失败退款或预占释放 | 账务使用原稳定幂等键,同一业务事件只产生一次账户流水,余额和预占不重复变化 |
| TC-REFACTOR-R10-008 | 创建、认领、发送并ACK最终CMPP/HTTP回执 | 每短信只有一最终回执语义;下游去重、attempt记录、ACK确认、超时恢复和失败分类保持 |
| TC-REFACTOR-R10-008 | 创建、认领、发送并ACK最终CMPP/HTTP回执 | 内部每短信只有一最终业务结论;CMPP按原始客户分片投递并逐片去重,HTTP按消息投递并去重;attempt记录、ACK确认、超时恢复和失败分类保持 |
| TC-REFACTOR-R10-009 | 人工重排失败下游投递或恢复陈旧认领 | 使用稳定重排键和原状态条件,已完成或正由其他执行者处理的记录不得重复投递 |
| TC-REFACTOR-R10-010 | 执行回执超时扫描 | 仅处理满足既有时间和状态条件的当前记录;扫描并发保护和终态聚合保持 |
| TC-REFACTOR-R10-010 | 执行回执超时扫描 | 仅处理满足既有时间和状态条件的当前记录;转为明确`EXPIRED`失败并为CMPP逐分片、HTTP逐消息建立可重试下游回执,建单标记未完成时后续扫描可恢复;扫描并发保护和内部终态聚合保持 |
| TC-REFACTOR-R10-011 | 检查七个完成链领域的持久化操作 | 不存在删除历史事故记录的`deleteMany`路径;提交、回执、attempt、死信和账务历史继续保留 |
| TC-REFACTOR-R10-012 | 对真实本地PostgreSQL查询R10八类表 | 查询前后计数完全一致;无分片或attempt样本时如实记为0,不造数、不发短信、不触发补发或重投 |
| TC-REFACTOR-R10-013 | 执行SendChain定向、API全量、前后端构建、Gateway及R0R10门禁 | 112项定向和389项API测试通过;schema、migration、事务语义、Redis契约、CMPP协议及其他业务不因R10改变 |
@@ -4348,3 +4350,40 @@ npm run verify:phase8
| TC-REFACTOR-R11-084 | 打开签名、发送、企业认证、发送详情和模板代表页面 | 各单页面样式仍由原global规则托管,页面结构、真实接口和交互不因第九步改变 |
| TC-REFACTOR-R11-085 | 在375×812视口检查首页、账单及一个单页面代表 | 标题、卡片、表格和操作区无新增横向溢出,控制台无相关warning/error |
| TC-REFACTOR-R11-086 | 执行前端生产构建、API/Gateway全量、Prisma、安全门禁、全部R0~R11结构门禁和`git diff --check` | 29个API套件/389项测试、API构建、Gateway测试/vet及全部门禁通过;R11九步完成且业务行为不因client共享CSS迁移改变 |
## 2026-08-03 工作台金额与审核筛选回归用例
| 用例编号 | 操作 | 预期结果 |
| --- | --- | --- |
| TC-CLIENT-DASHBOARD-012 | 使用客户端企业登录短信服务工作台,对比 Dashboard API、数据库当天消费聚合和页面金额卡 | “今日消费金额”位于“今日返还金额”之前,展示当前企业北京时间当天真实消费金额;刷新后与 API/数据库一致,不使用静态值或浏览器缓存计算 |
| TC-AUDIT-FILTER-014 | 依次打开企业认证、短信、模板、签名、引流信息五个审核页面,选择同一提交日期区间并查询 | 五页均显示日期区间控件;请求携带开始/结束日期,列表只返回 PostgreSQL 中提交时间位于北京时间闭区间内的记录 |
| TC-AUDIT-FILTER-015 | 在签名和引流信息审核页切换到“导入批次审核”,按提交时间查询并翻页 | 导入批次真实分页 API 同时携带日期条件,当前页和总数均受日期范围约束,不在前端仅过滤当前页 |
| TC-AUDIT-FILTER-016 | 分别只选择开始日、只选择结束日、选择跨日范围,再输入非法日期或开始日晚于结束日直接调用 API | 单边范围可正确查询;完整范围包含开始日 00:00:00 和结束日 23:59:59.999;非法或倒置范围返回受控 400,不执行无界误查询 |
| TC-QUERY-LAYOUT-009 | 在风控规则页检查规则范围、平台白名单和号码频次触发记录查询区,并执行查询与重置 | 普通控件使用统一标准宽度,条件可换行但不占满整行;查询/重置按钮等宽;重置后白名单恢复全部有效记录,触发记录恢复“拦截中”并重新请求真实后端 |
| TC-QUERY-LAYOUT-010 | 在五个审核页面及导入审核页签检查普通控件、日期区间和查询/重置按钮,并切换桌面与375×812视口 | 普通控件宽度一致,日期区间明显更宽,查询/重置按钮宽度一致;窄屏转为整行且无横向溢出、遮挡或弹层裁切 |
## 2026-08-03 引流信息识别与统计用例
| 用例编号 | 操作 | 预期结果 |
| --- | --- | --- |
| TC-DRAINAGE-DETECT-001 | 在引流识别规则页新增、编辑、停用规则并刷新 | 所有操作调用真实后端并持久化到 PostgreSQL;版本递增、状态生效并留下操作日志,刷新后不丢失 |
| TC-DRAINAGE-DETECT-002 | 分别测试协议 URL、裸域名、短链接、IP:端口/路径及中文标点相邻链接 | 均识别为含引流,命中类型为 URL,保存原始内容位置;检测不会改写短信原文 |
| TC-DRAINAGE-DETECT-003 | 输入被空格、中文句号拆开的域名以及普通邮箱地址 | 规避域名仍命中;完整或带空格规避的邮箱不被当作引流 URL |
| TC-DRAINAGE-DETECT-004 | 测试`+86 138 0013 8000``138-0013-8000`及中文标点拆分手机号 | 均识别为手机号引流,原文片段可在短信记录中正确高亮 |
| TC-DRAINAGE-DETECT-005 | 测试`0108888-8888 转 123`等固话 | 区号括号、分隔符和分机号均可识别为固定电话引流 |
| TC-DRAINAGE-SEND-001 | 使用待审核、驳回或未报备的既有引流资料分别创建客户端批次和 CMPP 入站任务 | 不产生`DRAINAGE_NOT_APPROVED`,不因引流资料状态拒绝或转人工;其他发送校验仍正常执行,禁止用真实短信完成自动测试 |
| TC-DRAINAGE-RECORD-001 | 查询含引流、不含引流和未检测三类短信记录并导出 CSV | PostgreSQL 分页和总数按筛选值返回;含引流原文使用提示色和片段高亮;CSV“是否含引流”与数据库一致 |
| TC-DRAINAGE-STATS-001 | 打开签名发送质量明细并切换“整体统计/按引流切分” | 整体矩阵显示全部通道×运营商真实提交;切分矩阵分别显示含引流、不含引流、未检测,分组计数之和等于整体计数 |
| TC-DRAINAGE-DASHBOARD-001 | 对比运营看板两个签名统计表与数据库当天数据 | “今日签名发送统计”包含全部短信;“含引流”表只包含`hasDrainageContent=true`,历史 null 不计入含引流 |
| TC-DRAINAGE-SAFETY-001 | 保存超长、非法标志、后行断言、反向引用或嵌套量词规则 | API 返回受控参数错误,不保存可能在发送入口造成灾难性回溯的规则 |
## 2026-08-03 CMPP逐分片回执与超时失败回执用例
| 用例编号 | 操作 | 预期结果 |
| --- | --- | --- |
| TC-RECEIPT-FRAGMENT-001 | 客户提交三分片长短信,每片`Registered_Delivery=1`,供应商三片分别返回成功、成功、失败 | 内部主记录只形成一个最终业务结论;下游产生3条CMPP状态报告,各自Msg_Id等于对应SubmitResp Msg_Id,状态/原始码/时间来自对应上游分片;HTTP只产生1个消息级最终事件 |
| TC-RECEIPT-FRAGMENT-002 | 三分片中第二片`Registered_Delivery=0`,其余两片为1 | 数据库保留三片的真实请求值;下游只为第一、三片建立CMPP状态报告,第二片不生成;不影响内部整条短信状态和HTTP最终事件 |
| TC-RECEIPT-FRAGMENT-003 | 对同一长短信终态并发处理三次并重放重复上游回执 | 每个原始客户分片最多一条`CmppDownstreamDelivery`,唯一键分别含分片序号;已建立分片不重复发,HTTP稳定事件不重复,退款和补发仍只有一次 |
| TC-RECEIPT-FRAGMENT-004 | 客户在线时分别向同一长短信的两个分片推送回执 | Gateway发送的两个`CMPP_DELIVER`回执内容分别携带两个不同的原SubmitResp Msg_Id,不因在线会话按业务消息查找而都复用第一片Msg_Id |
| TC-RECEIPT-TIMEOUT-005 | HTTP消息超过72小时无明确回执,首次Webhook建单失败,下一轮扫描恢复 | 主记录只转一次`timeout`并只退款一次;失败时`timeoutReceiptQueuedAt`为空,后续扫描补建`undelivered/EXPIRED/RECEIPT_TIMEOUT`事件后写入标记,HTTP最终事件只有一个 |
| TC-RECEIPT-TIMEOUT-006 | CMPP长短信超过72小时无明确回执,其中部分片已有真实回执,其余片缺失 | 已有结果的分片保留自己的最终状态;缺失片收到明确`EXPIRED`失败回执;每片使用各自原SubmitResp Msg_Id并可分别ACK,重复扫描不重复发送 |
+49
View File
@@ -3110,3 +3110,52 @@ git diff --check
- Redis返回`PONG``gateway.submit.commands`消费者组为`cmpp-gateway`,消费者1、pending=0、lag=0、entries-read=3047。部署后约95秒内API/Gateway error级journal均为0,关键字检查无`panic/fatal/unhandled/exception/error`120秒内活跃下游客户连接为0。
- 4条active供应商通道中3条稳定connected 1/1`会员营销-铁布衫``赛邮行业-王斯评中转``赛邮行业-王斯评中转副本``会员营销-富泷`在部署前为connected 1/1Gateway重启后持续约95秒为failed 0/1,数据库原因为`authentication / connect response status: auth failed`;保留系统自动重连,没有修改其账号、密码或启停状态。
- 依赖缓解安全门禁通过。npm audit仍报告既有前端2项high(未使用的React Router RSC路径)和API 3项moderatePrisma工具链)告警,未执行可能引入破坏性升级的`audit fix --force`。本次没有发送、重投或补发真实短信,没有修改企业余额、客户连接或真实通道配置。
## 2026-08-03 客户端今日消费金额、审核提交时间与查询控件统一(本地未提交)
- 客户端短信服务工作台在“今日返还金额”前增加“今日消费金额”,直接展示当前企业真实 Dashboard API 的`today.spendCents`;该字段由后端按当前租户及北京时间当天`SmsMessageRecord`汇总,不新增浏览器估算、静态数据或本地缓存。四张金额/发送指标卡在桌面端四列、1180px以下两列、780px以下单列展示。
- 新增全局查询尺寸令牌:普通条件220px、日期区间320px、查询/重置按钮88px;新增共享`.ui-filter-row/.ui-filter-actions`布局并保留R11既有页面类兼容。风控规则页的规则范围、平台白名单和号码频次触发记录不再整行拉伸,白名单与触发记录增加等宽重置按钮并按明确默认值重新请求真实接口。
- 企业认证、短信、模板、签名、引流信息五个审核页面均增加“提交时间”日期区间;签名和引流的导入批次审核Tab也使用已有真实分页日期参数。前端分别传递`submittedAtFrom/submittedAtTo`或导入批次`startAt/endAt`,不是仅过滤当前页面数组。
- 后端新增共享北京时间日期边界解析,校验`YYYY-MM-DD`、真实日历日期和范围顺序,并生成包含开始日00:00:00及结束日23:59:59.999的闭区间。数据库查询分别落到企业认证`submittedAt`、短信审核任务`createdAt`、模板`createdAt`、签名当前提交口径`updatedAt`和引流信息`submittedAt`;没有新增schema或migration。
- 新增/补充日期筛选单元测试。定向结果为日期边界与企业认证2 suites / 6 tests、风控审核1 suite / 16 tests、短信配置1 suite / 63 tests全部通过;本地Redis和PostgreSQL端口均监听时,API全量30 suites / 394 tests全部通过,保留既有`--forceExit`提示及测试场景内预期的错误/告警日志。
- API TypeScript正式构建、前端TypeScript与Vite v8.1.5生产构建通过(2532 modulesCSS 239.86kB/gzip 35.11kBJS 2007.30kB/gzip 597.78kB),仅保留既有大chunk提示。R11 foundation/shared components/admin/client四项样式门禁和`git diff --check`通过;契约同步锁定3个查询尺寸令牌、共享筛选布局和DateRangeInput绑定。
- 应用内浏览器访问本地客户端工作台和运营端风控路由时,均被真实鉴权守卫正确重定向到对应图形验证码登录页;页面身份正确、无框架错误覆盖,控制台0条warning/error。没有可复用登录态,未解验证码或伪造会话,因此登录后金额卡、审核筛选和风控布局的真实交互视觉验收保留为人工登录复核项。
- 已同步首版需求、系统功能测试用例和本进度文档。本轮按用户要求保持全部业务代码未提交、未推送、未部署;既有`api/tsconfig.build.tsbuildinfo`、根目录`tsconfig.tsbuildinfo``outputs/`和空文件`=`继续保留且不纳入本轮归因,未发送短信、修改余额、通道配置或客户连接。
## 2026-08-03 引流信息识别与统计(本地开发中,未提交)
- 本期范围确定为只识别、记录、查询和统计是否含引流信息;不根据报备/审核状态做发送、拒绝或人工审核。客户端批量发送和 CMPP 入站中的`DRAINAGE_NOT_APPROVED`分支已移除,既有引流资料匹配只保留关联信息,不参与发送决策。
- 新增`DrainageDetectionRule`真实数据库模型、消息记录识别快照字段及 migration;检测器按规则类型对副本规范化,覆盖裸域名/短链/IP、中文标点和空格拆分 URL、`+86`/空格/短横线手机号、括号区号/分机固话,并排除邮箱。批量发送、CMPP 入站和通道测试记录写入同一识别快照。
- 运营端新增“系统管理 → 引流识别规则”页面及真实 CRUD、启停、测试接口;短信记录新增含引流/不含引流/未检测筛选、命中片段提示色高亮和 CSV 列。
- 发送质量明细的通道×运营商矩阵保留整体聚合,并新增含引流、不含引流、未检测切分结果;运营看板第一张签名表改为全部短信,第二张只取识别为含引流的短信。
- 当前验证:Prisma Client 已基于新 schema 生成且 schema validate 通过;API TypeScript 构建、前端 TypeScript检查和 Vite v8.1.5 生产构建通过(2533 modulesCSS 241.13kB/gzip 35.30kBJS 2016.72kB/gzip 600.04kB),仅保留既有大 chunk 提示。识别器及发送链路/通道/运营统计定向回归 4 suites / 180 tests 通过,API 全量 31 suites / 403 tests 通过;Jest 仍保留既有开放句柄`--forceExit`提示。未在本地或预生产执行 migration,未执行 Gateway 回归或登录后浏览器验收。
- 全部修改仅保留本地,未提交、未推送、未部署;未发送、补发、重投真实短信,未修改真实通道账号、密码、启停状态、企业余额或客户连接。工作区中原有认证、风控、审核样式、日期筛选、构建缓存和临时文件继续保留,不归因于本需求。
- 本地页面联调时发现3000端口仍由2026-08-02启动的旧API进程占用,当前源码进程因`EADDRINUSE`未实际接管,导致登录后访问新接口返回`Cannot GET /api/admin/dictionaries/drainage-detection-rules`。已仅重启本工作区本地API,确认新路由完成挂载、API health与4173前端预览均为HTTP 200。
- 初始migration中的默认正则使用了JavaScript字符串式双反斜杠,而PostgreSQL标准字符串会原样保存,导致数据库内3条默认规则无法命中。初始种子已改用美元引用的单反斜杠表达式,并新增`20260803140000_fix_default_drainage_detection_rule_patterns`向前修复migration;本地库已应用至80条migration。真实本地数据库现有URL/裸域名、手机号、固话3条active默认规则;组合样例正确命中中文句号裸域名、`+86`短横线手机号和括号区号带分机固话,同时排除邮箱。
## 2026-08-03 下游投递与恢复状态筛选布局优化(本地未提交)
- 设计规范核对确认R11已有共享`.ui-filter-row/.ui-filter-actions`:普通查询控件使用`--query-control-width: 220px`,日期范围使用`--query-date-range-width: 320px`,查询/重置按钮使用`--query-action-width: 88px`;容器允许按完整控件换行,780px以下条件整行、按钮两列。下游投递记录和恢复状态管理仍使用旧`admin-task-filter`自适应网格,5组条件及按钮会为维持单行而被压缩。
- 两页筛选区已改为直接复用共享R11布局和操作区,不新增页面私有宽度、不改变筛选状态、查询接口、日期口径、导出或重投行为。已同步首版需求和`TC-GW-014/TC-GW-024`的桌面换行、移动端整行及无压缩/重叠验收要求。
- 同轮纳入运营端用户管理:原页面在外层工具栏内再用`repeat(auto-fit, minmax(160px, 1fr))`压缩五组查询条件,现改为与查询/重置共同使用共享筛选流式布局;“新增用户”继续作为独立业务操作。查询参数、真实`GET /api/admin/users`组合查询及新增用户行为均未改变,并同步`TC-USER-FILTER-001`布局验收。
- Node.js v24.16.0下前端TypeScript检查和Vite v8.1.5生产构建通过(2533 modulesCSS 241.00kB/gzip 35.28kBJS 2016.78kB/gzip 600.05kB),仅保留既有大chunk提示;R11 foundation/shared components两项样式门禁及`git diff --check`通过。系统PATH旧Node首次执行Vite时因不支持`??=`产生未处理Promise警告但错误返回0,已明确判定无效并用Node.js v24重新完成全部门禁。
- 本地深链访问下游投递页被真实鉴权守卫正确重定向至运营端登录页,页面身份正常、无框架覆盖、控制台0条warning/error;没有可复用登录态且未解图形验证码或伪造会话,因此三页登录后桌面/移动实际截图与查询交互仍需人工登录复核。修改仅保留本地,未提交、未推送、未部署,也未触发任何下游重投或真实短信操作。
## 2026-08-03 CMPP逐分片回执与72小时超时失败回执(本地未提交)
- 协议和历史代码复核纠正了“临时状态”和“长短信聚合回执”两项不严谨结论:CMPP只有状态值,没有规定临时状态生命周期,也没有长短信聚合回执报文。历史版本曾按上游分片产生多条下游投递,但全部复用第一片客户Msg_Id;后续为修复并发重复补发/退款改成每条业务短信一条回执,两种实现都不满足逐个原始客户分片精确关联。
- Gateway入站契约现传递每包真实`Registered_Delivery`;短短信写入`SmsMessageRecord.cmppRegisteredDelivery`,长短信逐片写入`CmppInboundLongMessageSegment.registeredDelivery/sequenceId`。migration`20260803190000_downstream_fragment_receipts`同时增加`timeoutReceiptQueuedAt`,历史已有CMPP记录按原行为回填为请求回执;本地PostgreSQL已成功应用至81条migration。
- 内部长短信仍只形成一个业务终态、一次重投决定、一次退款和一个HTTP最终事件。CMPP下游改用`receipt:{messageRecordId}:segment:{segmentIndex}`逐片幂等;Gateway根据每片原始`SubmitGroupMessageId + Sequence_Id`重建对应SubmitResp Msg_Id,在线会话不再导致所有分片回执复用第一片Msg_Id。`Registered_Delivery=0`分片不建CMPP回执。
- 下游逐片payload优先使用对应`SmsMessageSegmentAudit`的真实状态、原始码、错误码和到达时间;已成功分片不会因另一片失败被改写。业务已明确最终失败而个别片尚无状态时,缺失片使用整条短信的明确失败结果,保证每个请求回执的原始分片都有最终答复。
- 72小时扫描将`submitted/unknown`明确改为`timeout + undelivered + EXPIRED + RECEIPT_TIMEOUT`HTTP建立一个失败Webhook,CMPP为每个请求回执的原始分片建立失败状态报告。只有全部应建投递持久化后才写`timeoutReceiptQueuedAt`;建单中断时下一轮扫描继续补齐,退款仍由原消息级幂等键保证一次。
- 验证结果:Prisma format、validate、generate和API TypeScript正式构建通过;新增逐片目标/Registered_Delivery/HTTP超时及中断恢复测试后,定向3 suites / 117 tests、API全量32 suites / 407 tests通过,保留既有Jest开放句柄`--forceExit`提示及预期场景日志。Gateway`go test ./... -count=1``go vet ./...`通过,并新增不同原Sequence_Id生成不同回执Msg_Id的专项测试。
- 本轮没有修改或清理工作区中既有的引流识别、审核筛选、查询布局和其他会话修改;构建缓存、`outputs/`和空文件`=`继续保留。代码按用户要求未提交、未推送、未部署;没有发送、补发或重投短信,没有修改预生产数据库、企业余额、通道配置或客户连接。
## 2026-08-03 跨会话工作区整合与提交前完整回归
- 汇总当前工作区全部有效修改后,组合范围确认为四组:客户端今日消费与运营统计、审核日期和共享查询布局、引流信息识别与统计、CMPP逐分片回执及72小时超时失败回执。3条新增migration按`20260803113000``20260803140000``20260803190000`顺序衔接;本地真实PostgreSQL共81条migration且schema up to date。
- 完整回归发现拆分阶段的结构契约未同步业务演进:R1新增5个引流识别规则API且7个查询实现更新,R5通道测试加入引流识别,R2短信导出/质量统计查询变化,R8/R9/R10的引流识别、Registered_Delivery、逐片回执及超时回执实现变化,R3审核提交时间查询变化。已按实际组合实现更新对应契约哈希;R0将已失效的“单条聚合最终回执”特征替换为“同一分片幂等一次”和“HTTP单事件+CMPP逐请求分片”两项真实特征,没有恢复错误的聚合回执行为。
- Node.js v24.16.0下Prisma format、validate、generate、migrate status通过;API全量32 suites / 407 tests全部通过,API TypeScript正式构建通过;前端TypeScript与Vite v8.1.5生产构建通过(2533 modules),仅保留既有约2.02MB单chunk和插件耗时提示。
- Gateway`go test ./... -count=1``go vet ./...`通过;19个R0-R11结构门禁全部通过;依赖缓解安全门禁通过;`git diff --check`和合并冲突标记扫描通过。Jest仍保留既有`--forceExit`开放句柄提示及测试场景内预期日志。
- 依赖审计仍有已知告警:前端2项high来自项目未启用的React Router RSC路径,专用门禁已验证RSC未使用;API 3项moderate来自Prisma开发工具链的Valibot间接依赖。未执行可能改变依赖或引入破坏性升级的自动修复。
- `api/tsconfig.build.tsbuildinfo`、根目录`tsconfig.tsbuildinfo``outputs/`和空文件`=`继续作为构建缓存或临时产物排除,不提交、不删除、不错误归因。本轮未部署、未发送/补发/重投短信,也未修改预生产数据库、企业余额、通道配置或客户连接。
+17 -2
View File
@@ -142,8 +142,13 @@ func pushReceiptWithResult(event DownstreamReceipt, allowRecovery bool) (Downstr
when = parsed
}
}
// Every inbound CMPP_SUBMIT fragment receives its own SUBMIT_RESP Msg_Id.
// Rebuild that exact client Msg_Id from the fragment's original Sequence_Id;
// the live session stores only connection state and must not collapse a long
// message back to the first fragment's Msg_Id.
receiptMessageID := downstreamReceiptMessageID(event, session)
receipt := &cmpp.CmppReceiptPkt{
MsgId: session.gatewayMsgID,
MsgId: receiptMessageID,
Stat: stat,
SubmitTime: when.Format("0601021504"),
DoneTime: when.Format("0601021504"),
@@ -154,10 +159,20 @@ func pushReceiptWithResult(event DownstreamReceipt, allowRecovery bool) (Downstr
if err != nil {
return DownstreamSendResult{}, err
}
deliver := downstreamDeliverPacket(session, session.gatewayMsgID, session.srcID, defaultString(event.PhoneNumber, session.phoneNumber), 0, 1, string(receiptBytes))
deliver := downstreamDeliverPacket(session, receiptMessageID, session.srcID, defaultString(event.PhoneNumber, session.phoneNumber), 0, 1, string(receiptBytes))
return sendDownstream(session, deliver, event.DeliveryID)
}
func downstreamReceiptMessageID(event DownstreamReceipt, session *downstreamSession) uint64 {
if event.SubmitSequenceID != 0 {
return messageIDFrom(defaultString(event.SubmitGroupMessageID, event.MessageID), event.SubmitSequenceID)
}
if session == nil {
return 0
}
return session.gatewayMsgID
}
func findReceiptSession(messageID string, account string) *downstreamSession {
downstreamRegistry.RLock()
defer downstreamRegistry.RUnlock()
+19
View File
@@ -364,6 +364,9 @@ func TestInboundServerForwardsLongMessageFragmentsWithoutUDHAndAcknowledgesEachS
if submit.Content != parts[index] {
t.Fatalf("fragment %d content = %q", index+1, submit.Content)
}
if submit.RegisteredDelivery != 1 {
t.Fatalf("fragment %d Registered_Delivery = %d, want 1", index+1, submit.RegisteredDelivery)
}
if submit.LongMessage == nil || submit.LongMessage.Reference != 0x22 ||
submit.LongMessage.Total != 2 || submit.LongMessage.Index != index+1 || submit.LongMessage.Format != 8 {
t.Fatalf("fragment %d metadata = %+v", index+1, submit.LongMessage)
@@ -1015,6 +1018,22 @@ func TestReceiptLookupDoesNotFallbackToAccountBeforeSubmitMappingExists(t *testi
}
}
func TestLongMessageReceiptsUseEachOriginalFragmentMsgID(t *testing.T) {
session := &downstreamSession{gatewayMsgID: messageIDFrom("MSG-GROUP", 101)}
first := downstreamReceiptMessageID(DownstreamReceipt{
MessageID: "MSG-CHILD", SubmitGroupMessageID: "MSG-GROUP", SubmitSequenceID: 101,
}, session)
second := downstreamReceiptMessageID(DownstreamReceipt{
MessageID: "MSG-CHILD", SubmitGroupMessageID: "MSG-GROUP", SubmitSequenceID: 102,
}, session)
if first != messageIDFrom("MSG-GROUP", 101) || second != messageIDFrom("MSG-GROUP", 102) {
t.Fatalf("fragment receipt Msg_Id mismatch: first=%d second=%d", first, second)
}
if first == second {
t.Fatalf("different fragment Sequence_Id values produced the same Msg_Id: %d", first)
}
}
func TestSendDownstreamRejectsZeroMessageID(t *testing.T) {
_, err := sendDownstream(
&downstreamSession{mu: &sync.Mutex{}},
+3 -4
View File
@@ -130,10 +130,9 @@ func rememberDownstream(session downstreamSession) {
session.touchPresence("connected", true, false)
downstreamRegistry.Lock()
if existing := downstreamRegistry.byMessageID[session.messageID]; existing != nil && existing.conn == session.conn {
// A downstream long message returns one SUBMIT_RESP per fragment but is
// persisted as one platform message. Keep the first fragment Msg_Id so
// online delivery and restart recovery (which persists the first
// Sequence_Id) address the same client-side message.
// Keep a stable message-level lookup for the live connection. Receipt
// delivery no longer uses this value for long-message fragments; it
// reconstructs every fragment Msg_Id from that fragment's Sequence_Id.
session.gatewayMsgID = existing.gatewayMsgID
}
downstreamRegistry.byMessageID[session.messageID] = &session
+5 -2
View File
@@ -25,6 +25,7 @@ type submitRequest struct {
SrcID string `json:"srcId,omitempty"`
DestID string `json:"destId,omitempty"`
SequenceID uint32 `json:"sequenceId,omitempty"`
RegisteredDelivery uint8 `json:"registeredDelivery"`
RemoteIP string `json:"remoteIp,omitempty"`
LongMessage *inboundLongMessageFragment `json:"longMessage,omitempty"`
}
@@ -113,6 +114,7 @@ func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logge
SrcID: req.srcID,
DestID: phone,
SequenceID: req.sequenceID,
RegisteredDelivery: req.registeredDelivery,
RemoteIP: remoteIP(remote),
LongMessage: longMessage,
})
@@ -216,6 +218,7 @@ type inboundSubmitPacket struct {
destTerminalIDs []string
msgContent string
sequenceID uint32
registeredDelivery uint8
}
func normalizeInboundSubmit(packet any) (inboundSubmitPacket, bool) {
@@ -224,13 +227,13 @@ func normalizeInboundSubmit(packet any) (inboundSubmitPacket, bool) {
return inboundSubmitPacket{
protocol: "cmpp20", pkTotal: req.PkTotal, pkNumber: req.PkNumber, tpUdhi: req.TpUdhi, msgFmt: req.MsgFmt,
msgSrc: req.MsgSrc, srcID: req.SrcId, destTerminalIDs: req.DestTerminalId,
msgContent: req.MsgContent, sequenceID: req.SeqId,
msgContent: req.MsgContent, sequenceID: req.SeqId, registeredDelivery: req.RegisteredDelivery,
}, true
case *cmpp.Cmpp3SubmitReqPkt:
return inboundSubmitPacket{
protocol: "cmpp30", pkTotal: req.PkTotal, pkNumber: req.PkNumber, tpUdhi: req.TpUdhi, msgFmt: req.MsgFmt,
msgSrc: req.MsgSrc, srcID: req.SrcId, destTerminalIDs: req.DestTerminalId,
msgContent: req.MsgContent, sequenceID: req.SeqId,
msgContent: req.MsgContent, sequenceID: req.SeqId, registeredDelivery: req.RegisteredDelivery,
}, true
default:
return inboundSubmitPacket{}, false
+20 -6
View File
@@ -1,5 +1,5 @@
import { request, requestBlob, requestForm, withQuery } from '../core/httpClient';
import type { AuditRecord, ClientSmsSignature, ClientSmsTemplate, CommonReportField, DictionaryItem, EnterpriseCertification, ManualRechargePreflight, ManualRechargeResult, PagedResult, PhoneFrequencyHit, PhoneFrequencyWhitelistItem, RechargeOrder, ReviewDecisionResult, ReviewPreflight, RiskReviewTask, RiskRuleItem, RiskTaskMessagePage, SmsDrainageInfo, SmsTemplateAudit, TenantAccount } from '../types';
import type { AuditRecord, ClientSmsSignature, ClientSmsTemplate, CommonReportField, DictionaryItem, DrainageDetectionResult, DrainageDetectionRule, EnterpriseCertification, ManualRechargePreflight, ManualRechargeResult, PagedResult, PhoneFrequencyHit, PhoneFrequencyWhitelistItem, RechargeOrder, ReviewDecisionResult, ReviewPreflight, RiskReviewTask, RiskRuleItem, RiskTaskMessagePage, SmsDrainageInfo, SmsTemplateAudit, TenantAccount } from '../types';
// Review, risk and billing mutations keep their original URLs, payloads and
// response types behind one governance boundary.
@@ -14,10 +14,12 @@ export const adminGovernanceApi = {
request<ManualRechargePreflight>('/admin/billing/manual-recharges/preflight', { method: 'POST', body: JSON.stringify(body) }),
createManualRecharge: (body: { tenantId: string; amountCents: number; expectedAccountUpdatedAt: string; idempotencyKey: string; remark?: string }) =>
request<ManualRechargeResult>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
listTemplateAudits: (query: { keyword?: string; status?: string }) => {
listTemplateAudits: (query: { keyword?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string }) => {
const params = new URLSearchParams();
if (query.keyword) params.set('keyword', query.keyword);
if (query.status && query.status !== 'all') params.set('status', query.status);
if (query.submittedAtFrom) params.set('submittedAtFrom', query.submittedAtFrom);
if (query.submittedAtTo) params.set('submittedAtTo', query.submittedAtTo);
const suffix = params.toString() ? `?${params}` : '';
return request<SmsTemplateAudit[]>(`/admin/enterprise-templates${suffix}`);
},
@@ -35,7 +37,7 @@ export const adminGovernanceApi = {
request<ReviewPreflight>(`/admin/reviews/${type}/${id}/preflight`),
submitReviewDecision: (type: 'signature' | 'template', id: string, body: { decision: 'approve' | 'reject'; expectedUpdatedAt: string; idempotencyKey: string; reason?: string }) =>
request<ReviewDecisionResult>(`/admin/reviews/${type}/${id}/decision`, { method: 'POST', body: JSON.stringify(body) }),
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string } = {}) =>
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}) =>
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signatures', query)),
listEnterpriseSignaturesPage: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; page: number; pageSize: number }) =>
request<PagedResult<ClientSmsSignature>>(withQuery('/admin/enterprise-signatures', query)),
@@ -47,7 +49,7 @@ export const adminGovernanceApi = {
request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeEnterpriseSignatureStatus: (id: string, status: string, reason?: string) =>
request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }),
listDrainageInfos: (query: { tenantId?: string; signatureId?: string; keyword?: string; status?: string } = {}) =>
listDrainageInfos: (query: { tenantId?: string; signatureId?: string; keyword?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}) =>
request<SmsDrainageInfo[]>(withQuery('/admin/drainage-infos', query)),
listAuditRecords: (query: { targetType?: string; targetId?: string } = {}) =>
request<AuditRecord[]>(withQuery('/admin/audit-records', query)),
@@ -71,10 +73,12 @@ export const adminGovernanceApi = {
request<ClientSmsTemplate>(`/admin/enterprise-templates/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeEnterpriseTemplateStatus: (id: string, status: string, reason?: string) =>
request<ClientSmsTemplate>(`/admin/enterprise-templates/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }),
listEnterpriseCertifications: (query: { keyword?: string; status?: string }) => {
listEnterpriseCertifications: (query: { keyword?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string }) => {
const params = new URLSearchParams();
if (query.keyword) params.set('keyword', query.keyword);
if (query.status && query.status !== 'all') params.set('status', query.status);
if (query.submittedAtFrom) params.set('submittedAtFrom', query.submittedAtFrom);
if (query.submittedAtTo) params.set('submittedAtTo', query.submittedAtTo);
const suffix = params.toString() ? `?${params}` : '';
return request<EnterpriseCertification[]>(`/admin/enterprise-certifications${suffix}`);
},
@@ -87,7 +91,7 @@ export const adminGovernanceApi = {
method: 'POST',
body: JSON.stringify({ reason }),
}),
listRiskReviewTasks: (query: { tenantId?: string; status?: string } = {}) => request<RiskReviewTask[]>(withQuery('/admin/risk-review/tasks', query)),
listRiskReviewTasks: (query: { tenantId?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}) => request<RiskReviewTask[]>(withQuery('/admin/risk-review/tasks', query)),
listRiskRules: (applicationId?: string) =>
request<RiskRuleItem[]>(withQuery('/admin/risk-review/rules', { applicationId })),
createRiskRule: (body: {
@@ -187,6 +191,16 @@ export const adminGovernanceApi = {
createDrainageField: (body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; required?: boolean; status?: string; description?: string }) =>
request<DictionaryItem>('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }),
deleteDrainageField: (id: string) => request<DictionaryItem>(`/admin/dictionaries/drainage-fields/${id}`, { method: 'DELETE' }),
listDrainageDetectionRules: (query: { keyword?: string; status?: string } = {}) =>
request<DrainageDetectionRule[]>(withQuery('/admin/dictionaries/drainage-detection-rules', query)),
createDrainageDetectionRule: (body: Omit<DrainageDetectionRule, 'id' | 'version' | 'createdAt' | 'updatedAt'>) =>
request<DrainageDetectionRule>('/admin/dictionaries/drainage-detection-rules', { method: 'POST', body: JSON.stringify(body) }),
updateDrainageDetectionRule: (id: string, body: Omit<DrainageDetectionRule, 'id' | 'version' | 'createdAt' | 'updatedAt'>) =>
request<DrainageDetectionRule>(`/admin/dictionaries/drainage-detection-rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeDrainageDetectionRuleStatus: (id: string, status: 'active' | 'inactive') =>
request<DrainageDetectionRule>(`/admin/dictionaries/drainage-detection-rules/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }),
testDrainageDetectionRule: (body: { content: string; rule?: Omit<DrainageDetectionRule, 'id' | 'version' | 'createdAt' | 'updatedAt'> }) =>
request<DrainageDetectionResult>('/admin/dictionaries/drainage-detection-rules/test', { method: 'POST', body: JSON.stringify(body) }),
listCommonReportFields: () => request<CommonReportField[]>('/admin/dictionaries/common-report-fields'),
createCommonReportField: (body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean; sortOrder?: number }) =>
request<CommonReportField>('/admin/dictionaries/common-report-fields', { method: 'POST', body: JSON.stringify(body) }),
+2 -2
View File
@@ -37,9 +37,9 @@ export const adminOperationsApi = {
request<SmsMessageRecord[]>(withQuery('/admin/send/messages', query)),
listMessageSegmentAudits: (query: { messageId?: string; messageRecordId?: string }) =>
request<SmsMessageSegmentAudit[]>(withQuery('/admin/operations/message-segment-audits', query)),
listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}) =>
listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; hasDrainage?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResult<SmsMessageRecord>>(withQuery('/admin/operations/messages', query)),
exportOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string } = {}) =>
exportOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; hasDrainage?: string; queuedAtFrom?: string; queuedAtTo?: string } = {}) =>
requestBlob(withQuery('/admin/operations/messages/export', query)),
listAdminUplinkMessages: (query: { tenantId?: string; channelId?: string } = {}) =>
request<SmsUplinkMessage[]>(withQuery('/admin/operations/uplink-messages', query)),
+26
View File
@@ -94,3 +94,29 @@ export type RiskTaskMessagePage = {
};
export type BatchTaskMessagePage = RiskTaskMessagePage;
export type DrainageDetectionRule = {
id: string;
code: string;
name: string;
category: 'url' | 'mobile' | 'landline';
pattern: string;
flags: string;
priority: number;
status: 'active' | 'inactive';
description?: string | null;
version: number;
createdAt: string;
updatedAt: string;
};
export type DrainageDetectionResult = {
hasDrainageContent: boolean;
drainageDetection: {
matches: Array<{ ruleCode: string; ruleName: string; category: string; text: string; normalizedText: string; start: number; end: number }>;
categories: string[];
truncated: boolean;
};
drainageDetectionVersion: string;
drainageEvaluatedAt: string;
};
+14
View File
@@ -52,6 +52,10 @@ export type SignatureChannelCarrierQualityStat = {
averageArrivalMs?: number | null;
};
export type SignatureChannelCarrierDrainageQualityStat = SignatureChannelCarrierQualityStat & {
drainageState: 'with' | 'without' | 'unknown';
};
export type SignatureCarrierBusinessQualityStat = {
signatureId: string;
carrier: string;
@@ -78,6 +82,7 @@ export type SignatureChannelQualityItem = {
channelSubmitTotal: number;
carrierOverview: SignatureCarrierBusinessQualityStat[];
breakdowns: SignatureChannelCarrierQualityStat[];
drainageBreakdowns: SignatureChannelCarrierDrainageQualityStat[];
};
export type SignatureChannelQualityResponse = {
@@ -108,6 +113,7 @@ export type SendQualityResponse = {
summary: DailySendSummary;
channels: ChannelQualityStat[];
signatures: SignatureQualityStat[];
drainageSignatures: SignatureQualityStat[];
applications: ApplicationQualityStat[];
};
@@ -122,6 +128,14 @@ export type SmsMessageRecord = {
carrier?: string | null;
province?: string | null;
content: string;
hasDrainageContent?: boolean | null;
drainageDetection?: {
matches?: Array<{ category: string; text: string; normalizedText: string; start: number; end: number }>;
categories?: string[];
truncated?: boolean;
} | null;
drainageDetectionVersion?: string | null;
drainageEvaluatedAt?: string | null;
clientSrcId?: string | null;
applicationExtension?: string | null;
billingUnits: number;
+18 -2
View File
@@ -4,6 +4,7 @@ import {
adminApi,
type SendQualityResponse,
type SignatureChannelCarrierQualityStat,
type SignatureChannelCarrierDrainageQualityStat,
type SignatureChannelQualityItem,
type SignatureChannelQualityResponse,
} from '@/api/adminApi';
@@ -290,6 +291,7 @@ function SignatureQualityDrawer({
item: SignatureChannelQualityItem;
onClose: () => void;
}) {
const [matrixMode, setMatrixMode] = useState<'overall' | 'drainage'>('overall');
const carriers = item.carrierOverview
.map((carrier) => ({
...carrier,
@@ -360,8 +362,9 @@ function SignatureQualityDrawer({
<div className="signature-quality-section__heading">
<div>
<h3> × </h3>
<p></p>
<p>{matrixMode === 'overall' ? '整体口径展示该组合全部真实提交。' : '引流切分口径分别展示含引流、不含引流和历史未检测数据。'}</p>
</div>
<div className="page-actions"><Button onClick={() => setMatrixMode('overall')} size="sm" variant={matrixMode === 'overall' ? 'primary' : 'ghost'}></Button><Button onClick={() => setMatrixMode('drainage')} size="sm" variant={matrixMode === 'drainage' ? 'primary' : 'ghost'}></Button></div>
</div>
<div className="signature-quality-matrix">
<table>
@@ -379,9 +382,14 @@ function SignatureQualityDrawer({
const metric = item.breakdowns.find((entry) => (
entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier
));
const drainageMetrics = item.drainageBreakdowns.filter((entry) => (
entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier
));
return (
<td key={carrier}>
{metric ? <MatrixMetric metric={metric} /> : <span className="signature-quality-matrix__empty"></span>}
{matrixMode === 'overall'
? metric ? <MatrixMetric metric={metric} /> : <span className="signature-quality-matrix__empty"></span>
: drainageMetrics.length ? <DrainageMatrixMetrics metrics={drainageMetrics} /> : <span className="signature-quality-matrix__empty"></span>}
</td>
);
})}
@@ -423,6 +431,14 @@ function MatrixMetric({ metric }: { metric: SignatureChannelCarrierQualityStat }
);
}
function DrainageMatrixMetrics({ metrics }: { metrics: SignatureChannelCarrierDrainageQualityStat[] }) {
const labels = { with: '含引流', without: '不含引流', unknown: '未检测' };
return <div className="signature-quality-matrix__drainage">{(['with', 'without', 'unknown'] as const).map((state) => {
const metric = metrics.find((item) => item.drainageState === state);
return metric ? <div key={state}><b>{labels[state]}</b><MatrixMetric metric={metric} /></div> : null;
})}</div>;
}
function QualityRate({ value }: { value: number }) {
return (
<div className="signature-quality-rate">
@@ -317,7 +317,7 @@ export function AdminDownstreamDeliveriesPage() {
</div>
</div>
<div className="surface admin-task-filter">
<div className="surface ui-filter-row">
<Input label="消息ID / 账号 / 手机号 / 错误" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
<DateRangeInput label="创建日期" onChange={(value) => { setDateRange(value); setPage(1); }} value={dateRange} />
<Select
@@ -362,7 +362,7 @@ export function AdminDownstreamDeliveriesPage() {
setPage(1);
}}
/>
<div className="admin-task-filter__actions">
<div className="admin-task-filter__actions ui-filter-actions">
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button
onClick={() => {
@@ -309,7 +309,7 @@ export function AdminDownstreamRecoveryStatusesPage() {
</div>
</div>
<div className="surface admin-task-filter">
<div className="surface ui-filter-row">
<Input label="账号 / 企业 / 应用 / 错误" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
<DateRangeInput label="最近更新时间" onChange={(value) => { setDateRange(value); setPage(1); }} value={dateRange} />
<Select
@@ -358,7 +358,7 @@ export function AdminDownstreamRecoveryStatusesPage() {
setPage(1);
}}
/>
<div className="admin-task-filter__actions">
<div className="admin-task-filter__actions ui-filter-actions">
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button onClick={resetFilters} variant="ghost"></Button>
</div>
+10 -4
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Check, Eye, Search, X } from 'lucide-react';
import { adminApi, type FileRef, type SmsDrainageInfo } from '@/api/adminApi';
import { AuditReviewInfo, Breadcrumb, Button, FileActions, Input, Modal, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui';
import { AuditReviewInfo, Breadcrumb, Button, DateRangeInput, FileActions, Input, Modal, Select, Table, Tabs, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
import { ReportImportAuditPanel } from './ReportImportAuditPanel';
@@ -54,18 +54,24 @@ export function AdminDrainageAuditPage() {
const [items, setItems] = useState<SmsDrainageInfo[]>([]);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('pending');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [detail, setDetail] = useState<SmsDrainageInfo>();
const [rejectTarget, setRejectTarget] = useState<SmsDrainageInfo>();
const [reason, setReason] = useState('');
const [error, setError] = useState('');
function loadData() {
adminApi.listDrainageInfos({ keyword, status: status === 'all' ? undefined : status })
adminApi.listDrainageInfos({
keyword,
status: status === 'all' ? undefined : status,
submittedAtFrom: submittedDateRange.start,
submittedAtTo: submittedDateRange.end,
})
.then((records) => { setItems(records); setError(''); })
.catch((failure: Error) => setError(failure.message || '引流信息审核列表加载失败'));
}
useEffect(loadData, [keyword, status]);
useEffect(loadData, [keyword, status, submittedDateRange.end, submittedDateRange.start]);
async function approve(item: SmsDrainageInfo) {
try { await adminApi.approveDrainageInfo(item.id); loadData(); } catch (failure) { setError(failure instanceof Error ? failure.message : '引流信息审核通过失败'); }
@@ -89,7 +95,7 @@ export function AdminDrainageAuditPage() {
<div className="page-heading"><div><Breadcrumb items={['审核中心', '引流信息审核']} /><h1></h1></div></div>
{error ? <p className="form-error">{error}</p> : null}
<Tabs items={[
{ label: '单条引流信息审核', value: 'single', content: <div className="page-stack"><div className="surface audit-filter-card"><div className="audit-filter-grid audit-filter-grid--template"><Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索企业、应用、签名、站点或网址" prefix={<Search size={16} />} value={keyword} /><Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '待审核', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }]} value={status} /><div className="audit-filter-actions"><Button icon={<Search size={17} />} onClick={loadData}></Button><Button onClick={() => { setKeyword(''); setStatus('pending'); }} variant="ghost"></Button></div></div></div><div className="surface"><Table columns={columns} data={items} emptyText="暂无引流信息审核记录" rowKey="id" /></div></div> },
{ label: '单条引流信息审核', value: 'single', content: <div className="page-stack"><div className="surface audit-filter-card"><div className="ui-filter-row"><Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索企业、应用、签名、站点或网址" prefix={<Search size={16} />} value={keyword} /><Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '待审核', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }]} value={status} /><DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} /><div className="audit-filter-actions ui-filter-actions"><Button icon={<Search size={17} />} onClick={loadData}></Button><Button onClick={() => { setKeyword(''); setStatus('pending'); setSubmittedDateRange({}); }} variant="ghost"></Button></div></div></div><div className="surface"><Table columns={columns} data={items} emptyText="暂无引流信息审核记录" rowKey="id" /></div></div> },
{ label: '导入批次审核', value: 'import', content: <ReportImportAuditPanel reportType="drainage" /> },
]} />
{detail ? <DrainageDetail item={detail} onClose={() => setDetail(undefined)} /> : null}
@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';
import { FlaskConical, Pencil, Plus, Power, PowerOff, Search } from 'lucide-react';
import { adminApi, type DrainageDetectionResult, type DrainageDetectionRule } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
type RuleDraft = Omit<DrainageDetectionRule, 'id' | 'version' | 'createdAt' | 'updatedAt'>;
const emptyDraft: RuleDraft = { code: '', name: '', category: 'url', pattern: '', flags: 'giu', priority: 100, status: 'active', description: '' };
const categoryOptions = [
{ label: 'URL / 域名', value: 'url' }, { label: '手机号码', value: 'mobile' }, { label: '固定电话', value: 'landline' },
];
const categoryLabels = { url: 'URL / 域名', mobile: '手机号码', landline: '固定电话' };
export function AdminDrainageDetectionRulesPage() {
const [rules, setRules] = useState<DrainageDetectionRule[]>([]);
const [keyword, setKeyword] = useState('');
const [draft, setDraft] = useState<RuleDraft>(emptyDraft);
const [editingId, setEditingId] = useState<string>();
const [open, setOpen] = useState(false);
const [testContent, setTestContent] = useState('');
const [testResult, setTestResult] = useState<DrainageDetectionResult>();
const [error, setError] = useState('');
function load(search = keyword) {
adminApi.listDrainageDetectionRules({ keyword: search.trim() || undefined })
.then((items) => { setRules(items); setError(''); })
.catch((failure: Error) => setError(failure.message || '引流识别规则加载失败'));
}
useEffect(() => { load(''); }, []);
function openEditor(rule?: DrainageDetectionRule) {
setEditingId(rule?.id);
setDraft(rule ? { code: rule.code, name: rule.name, category: rule.category, pattern: rule.pattern, flags: rule.flags, priority: rule.priority, status: rule.status, description: rule.description } : emptyDraft);
setTestContent(''); setTestResult(undefined); setOpen(true);
}
async function save() {
try {
if (editingId) await adminApi.updateDrainageDetectionRule(editingId, draft);
else await adminApi.createDrainageDetectionRule(draft);
setOpen(false); load();
} catch (failure) { setError(failure instanceof Error ? failure.message : '规则保存失败'); }
}
async function test() {
try { setTestResult(await adminApi.testDrainageDetectionRule({ content: testContent, rule: draft })); setError(''); }
catch (failure) { setError(failure instanceof Error ? failure.message : '规则测试失败'); }
}
async function toggle(rule: DrainageDetectionRule) {
try { await adminApi.changeDrainageDetectionRuleStatus(rule.id, rule.status === 'active' ? 'inactive' : 'active'); load(); }
catch (failure) { setError(failure instanceof Error ? failure.message : '规则状态修改失败'); }
}
return <section className="page-stack admin-system-page">
<div className="page-heading"><div><Breadcrumb items={['系统管理', '引流识别规则']} /><h1></h1><p> URL</p></div><Button icon={<Plus size={16} />} onClick={() => openEditor()}></Button></div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-system-toolbar"><Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索编码、名称或说明" prefix={<Search size={16} />} value={keyword} /><div className="admin-system-toolbar__actions"><Button onClick={() => load()}></Button><Button onClick={() => { setKeyword(''); load(''); }} variant="ghost"></Button></div></div>
<div className="surface ui-table-wrap"><table className="ui-table"><thead><tr><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead><tbody>{rules.map((rule) => <tr key={rule.id}><td><strong>{rule.name}</strong><br /><small>{rule.code}</small></td><td>{categoryLabels[rule.category]}</td><td>{rule.priority}</td><td>v{rule.version}</td><td><Tag tone={rule.status === 'active' ? 'success' : 'neutral'}>{rule.status === 'active' ? '启用' : '停用'}</Tag></td><td><code title={rule.pattern}>{rule.pattern}</code></td><td><div className="ui-table__actions"><Button icon={<Pencil size={14} />} onClick={() => openEditor(rule)} size="sm" variant="ghost"></Button><Button icon={rule.status === 'active' ? <PowerOff size={14} /> : <Power size={14} />} onClick={() => void toggle(rule)} size="sm" variant="ghost">{rule.status === 'active' ? '停用' : '启用'}</Button></div></td></tr>)}</tbody></table>{rules.length === 0 ? <div className="ui-table__empty"></div> : null}</div>
<Modal footer={<><Button onClick={() => setOpen(false)} variant="ghost"></Button><Button disabled={!draft.code || !draft.name || !draft.pattern} onClick={() => void save()}></Button></>} onClose={() => setOpen(false)} open={open} title={editingId ? '编辑引流识别规则' : '新增引流识别规则'}>
<div className="admin-system-modal-form">
<Input label="规则编码" onChange={(event) => setDraft({ ...draft, code: event.target.value })} value={draft.code} />
<Input label="规则名称" onChange={(event) => setDraft({ ...draft, name: event.target.value })} value={draft.name} />
<Select label="识别类型" onChange={(event) => setDraft({ ...draft, category: event.target.value as RuleDraft['category'] })} options={categoryOptions} value={draft.category} />
<Input label="优先级" min="1" onChange={(event) => setDraft({ ...draft, priority: Number(event.target.value) || 100 })} type="number" value={String(draft.priority)} />
<Textarea className="admin-system-modal-form__wide" label="识别表达式" onChange={(event) => setDraft({ ...draft, pattern: event.target.value })} rows={4} value={draft.pattern} />
<Input label="表达式标志" onChange={(event) => setDraft({ ...draft, flags: event.target.value })} value={draft.flags} />
<Textarea label="说明" onChange={(event) => setDraft({ ...draft, description: event.target.value })} rows={3} value={draft.description ?? ''} />
<Textarea className="admin-system-modal-form__wide" label="测试短信内容" onChange={(event) => setTestContent(event.target.value)} rows={3} value={testContent} />
<div><Button disabled={!testContent || !draft.pattern} icon={<FlaskConical size={15} />} onClick={() => void test()} size="sm" variant="secondary"></Button>{testResult ? <p>{testResult.hasDrainageContent ? `命中:${testResult.drainageDetection.matches.map((item) => item.text).join('、')}` : '未命中引流信息'}</p> : null}</div>
</div>
</Modal>
</section>;
}
+8 -6
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Check, FileSearch, Search, X } from 'lucide-react';
import { adminApi, type EnterpriseCertification } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui';
type EnterpriseAuditStatus = 'pending' | 'approved' | 'rejected';
@@ -73,12 +73,13 @@ function mapCertification(record: EnterpriseCertification): EnterpriseAuditRecor
export function AdminEnterpriseAuditPage() {
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('pending');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [records, setRecords] = useState<EnterpriseAuditRecord[]>([]);
const [error, setError] = useState('');
const [detailRecord, setDetailRecord] = useState<EnterpriseAuditRecord | null>(null);
useEffect(() => {
adminApi.listEnterpriseCertifications({ keyword, status })
adminApi.listEnterpriseCertifications({ keyword, status, submittedAtFrom: submittedDateRange.start, submittedAtTo: submittedDateRange.end })
.then((items) => {
setRecords(items.map(mapCertification));
setError('');
@@ -87,7 +88,7 @@ export function AdminEnterpriseAuditPage() {
setRecords([]);
setError(failure.message || '企业认证审核数据加载失败');
});
}, [keyword, status]);
}, [keyword, status, submittedDateRange.end, submittedDateRange.start]);
const filteredRecords = useMemo(
() => records.filter((record) => {
@@ -141,12 +142,13 @@ export function AdminEnterpriseAuditPage() {
<Breadcrumb items={['审核中心', '企业认证审核']} />
{error ? <p className="form-error">{error}</p> : null}
<div className="surface audit-filter-card">
<div className="audit-filter-grid audit-filter-grid--enterprise">
<div className="ui-filter-row">
<Input label="企业名称/信用代码" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入企业名称或统一社会信用代码" value={keyword} />
<Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
<div className="audit-filter-actions">
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
<div className="audit-filter-actions ui-filter-actions">
<Button icon={<Search size={17} />}></Button>
<Button onClick={() => { setKeyword(''); setStatus('pending'); }} variant="ghost"></Button>
<Button onClick={() => { setKeyword(''); setStatus('pending'); setSubmittedDateRange({}); }} variant="ghost"></Button>
</div>
</div>
</div>
+6 -6
View File
@@ -156,8 +156,8 @@ export function AdminHome() {
{ key: 'successRate', title: '成功率', align: 'right', render: (record) => `${record.successRate.toFixed(1)}%` },
{ key: 'averageArrivalMs', title: '平均到达', align: 'right', render: (record) => record.averageArrivalMs === null || record.averageArrivalMs === undefined ? '-' : `${(record.averageArrivalMs / 1000).toFixed(1)}` },
];
const plainSignatureQuality = quality?.signatures.filter((item) => !item.hasDrainage) ?? [];
const drainageSignatureQuality = quality?.signatures.filter((item) => item.hasDrainage) ?? [];
const plainSignatureQuality = quality?.signatures ?? [];
const drainageSignatureQuality = quality?.drainageSignatures ?? [];
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
@@ -233,12 +233,12 @@ export function AdminHome() {
<div className="surface section-stack">
<div className="section-heading">
<div>
<h2> - </h2>
<p className="muted"></p>
<h2></h2>
<p className="muted"></p>
</div>
<Tag tone="info">{plainSignatureQuality.length} </Tag>
</div>
<Table columns={signatureColumns} data={pagedPlainSignatureQuality} emptyText="今日暂无不含引流的签名发送记录" rowKey="id" />
<Table columns={signatureColumns} data={pagedPlainSignatureQuality} emptyText="今日暂无签名发送记录" rowKey="id" />
<Pagination
total={plainSignatureQuality.length}
page={plainSignaturePage}
@@ -254,7 +254,7 @@ export function AdminHome() {
<div className="section-heading">
<div>
<h2> - </h2>
<p className="muted"></p>
<p className="muted"></p>
</div>
<Tag tone="accent">{drainageSignatureQuality.length} </Tag>
</div>
+13 -11
View File
@@ -105,11 +105,11 @@ export function AdminRiskRulesPage() {
useEffect(load, [applicationId]);
function loadFrequencyHits(page = hitPage) {
function loadFrequencyHits(page = hitPage, filters = { phoneNumber: hitPhone, status: hitStatus }) {
adminApi.listPhoneFrequencyHits({
applicationId: applicationId || undefined,
phoneNumber: hitPhone.trim() || undefined,
status: hitStatus || undefined,
phoneNumber: filters.phoneNumber.trim() || undefined,
status: filters.status || undefined,
page,
pageSize: 20,
}).then((result) => {
@@ -124,10 +124,10 @@ export function AdminRiskRulesPage() {
loadFrequencyHits(1);
}, [applicationId]);
function loadWhitelist(page = whitelistPage) {
function loadWhitelist(page = whitelistPage, filters = { phoneNumber: whitelistPhone, status: whitelistStatus }) {
adminApi.listPhoneFrequencyWhitelist({
phoneNumber: whitelistPhone.trim() || undefined,
status: whitelistStatus || undefined,
phoneNumber: filters.phoneNumber.trim() || undefined,
status: filters.status || undefined,
page,
pageSize: 20,
}).then((result) => {
@@ -325,7 +325,7 @@ export function AdminRiskRulesPage() {
</div>
</div>
{error ? <p className="form-error" role="alert">{error}</p> : null}
<div className="surface sms-audit-filter">
<div className="surface sms-audit-filter ui-filter-row">
<Select
label="查看范围"
onChange={(event) => setApplicationId(event.target.value)}
@@ -348,7 +348,7 @@ export function AdminRiskRulesPage() {
<Button icon={<Plus size={16} />} onClick={() => setWhitelistEditor({ phoneNumber: '', reason: '', remark: '', status: 'active' })}></Button>
</div>
</div>
<div className="sms-audit-filter">
<div className="sms-audit-filter ui-filter-row">
<Input label="手机号码" onChange={(event) => setWhitelistPhone(event.target.value)} placeholder="输入完整或部分号码" value={whitelistPhone} />
<Select
label="白名单状态"
@@ -361,8 +361,9 @@ export function AdminRiskRulesPage() {
]}
value={whitelistStatus}
/>
<div className="admin-task-filter__actions">
<div className="admin-task-filter__actions ui-filter-actions">
<Button icon={<Search size={16} />} onClick={() => loadWhitelist(1)}></Button>
<Button onClick={() => { setWhitelistPhone(''); setWhitelistStatus(''); loadWhitelist(1, { phoneNumber: '', status: '' }); }} variant="ghost"></Button>
</div>
</div>
<Table columns={whitelistColumns} data={whitelist} emptyText="暂无号码频控白名单" pagination={false} rowKey="id" />
@@ -382,7 +383,7 @@ export function AdminRiskRulesPage() {
<div><h2></h2><p></p></div>
<Tag tone="warning">{hitTotal} </Tag>
</div>
<div className="sms-audit-filter">
<div className="sms-audit-filter ui-filter-row">
<Input label="手机号码" onChange={(event) => setHitPhone(event.target.value)} placeholder="输入完整或部分号码" value={hitPhone} />
<Select
label="记录状态"
@@ -395,8 +396,9 @@ export function AdminRiskRulesPage() {
]}
value={hitStatus}
/>
<div className="admin-task-filter__actions">
<div className="admin-task-filter__actions ui-filter-actions">
<Button icon={<Search size={16} />} onClick={() => { setHitPage(1); loadFrequencyHits(1); }}></Button>
<Button onClick={() => { setHitPhone(''); setHitStatus('active'); setHitPage(1); loadFrequencyHits(1, { phoneNumber: '', status: 'active' }); }} variant="ghost"></Button>
</div>
</div>
<Table columns={hitColumns} data={frequencyHits} emptyText="暂无号码频次触发记录" pagination={false} rowKey="id" />
+10 -4
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Eye, Search, X } from 'lucide-react';
import { adminApi, type ClientSmsSignature, type FileRef } from '@/api/adminApi';
import { AuditReviewInfo, Breadcrumb, Button, FileActions, Input, Modal, RiskAction, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui';
import { AuditReviewInfo, Breadcrumb, Button, DateRangeInput, FileActions, Input, Modal, RiskAction, Select, Table, Tabs, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
import { ReportImportAuditPanel } from './ReportImportAuditPanel';
@@ -71,18 +71,24 @@ export function AdminSignatureAuditPage() {
const [items, setItems] = useState<ClientSmsSignature[]>([]);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('pending');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [detail, setDetail] = useState<ClientSmsSignature>();
const [rejectTarget, setRejectTarget] = useState<ClientSmsSignature>();
const [reason, setReason] = useState('');
const [error, setError] = useState('');
function loadData() {
adminApi.listEnterpriseSignatures({ keyword, status: status === 'all' ? undefined : status })
adminApi.listEnterpriseSignatures({
keyword,
status: status === 'all' ? undefined : status,
submittedAtFrom: submittedDateRange.start,
submittedAtTo: submittedDateRange.end,
})
.then((records) => { setItems(records); setError(''); })
.catch((failure: Error) => setError(failure.message || '签名审核列表加载失败'));
}
useEffect(loadData, [keyword, status]);
useEffect(loadData, [keyword, status, submittedDateRange.end, submittedDateRange.start]);
const visible = useMemo(() => items.filter((item) => status === 'all' || item.auditStatus === status), [items, status]);
@@ -104,7 +110,7 @@ export function AdminSignatureAuditPage() {
<div className="page-heading"><div><Breadcrumb items={['审核中心', '短信签名审核']} /><h1></h1></div></div>
{error ? <p className="form-error">{error}</p> : null}
<Tabs items={[
{ label: '单条签名审核', value: 'single', content: <div className="page-stack"><div className="surface audit-filter-card"><div className="audit-filter-grid audit-filter-grid--template"><Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索企业、应用或签名" prefix={<Search size={16} />} value={keyword} /><Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '待审核', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }, { label: '草稿', value: 'draft' }]} value={status} /><div className="audit-filter-actions"><Button icon={<Search size={17} />} onClick={loadData}></Button><Button onClick={() => { setKeyword(''); setStatus('pending'); }} variant="ghost"></Button></div></div></div><div className="surface"><Table columns={columns} data={visible} emptyText="暂无签名审核记录" rowKey="id" /></div></div> },
{ label: '单条签名审核', value: 'single', content: <div className="page-stack"><div className="surface audit-filter-card"><div className="ui-filter-row"><Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索企业、应用或签名" prefix={<Search size={16} />} value={keyword} /><Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '待审核', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }, { label: '草稿', value: 'draft' }]} value={status} /><DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} /><div className="audit-filter-actions ui-filter-actions"><Button icon={<Search size={17} />} onClick={loadData}></Button><Button onClick={() => { setKeyword(''); setStatus('pending'); setSubmittedDateRange({}); }} variant="ghost"></Button></div></div></div><div className="surface"><Table columns={columns} data={visible} emptyText="暂无签名审核记录" rowKey="id" /></div></div> },
{ label: '导入批次审核', value: 'import', content: <ReportImportAuditPanel reportType="signature" /> },
]} />
{detail ? <SignatureDetail item={detail} onClose={() => setDetail(undefined)} /> : null}
+17 -15
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { CalendarDays, Check, Eye, Search, X } from 'lucide-react';
import { Check, Eye, Search, X } from 'lucide-react';
import { adminApi, type RiskReviewTask, type RiskTaskMessagePage } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const statusLabel: Record<string, string> = {
@@ -38,7 +38,7 @@ export function AdminSmsAuditPage() {
const [records, setRecords] = useState<RiskReviewTask[]>([]);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('pending_review');
const [date, setDate] = useState('');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [error, setError] = useState('');
const [approveTarget, setApproveTarget] = useState<RiskReviewTask | 'batch' | null>(null);
const [rejectTarget, setRejectTarget] = useState<RiskReviewTask | 'batch' | null>(null);
@@ -56,7 +56,11 @@ export function AdminSmsAuditPage() {
}
function loadData() {
adminApi.listRiskReviewTasks({ status: status === 'all' ? undefined : status })
adminApi.listRiskReviewTasks({
status: status === 'all' ? undefined : status,
submittedAtFrom: submittedDateRange.start,
submittedAtTo: submittedDateRange.end,
})
.then((items) => {
setRecords(items);
setSelectedIds((current) => current.filter((id) => items.some((item) => item.id === id && item.status === 'pending_review')));
@@ -67,16 +71,14 @@ export function AdminSmsAuditPage() {
useEffect(() => {
loadData();
}, [status]);
}, [status, submittedDateRange.end, submittedDateRange.start]);
const filteredRecords = useMemo(
() => records.filter((record) => {
const matchesKeyword = !keyword || [record.taskNo, record.content, record.reviewReason, record.rejectReason].join(' ').includes(keyword);
const relevantDate = record.status === 'pending_review' ? record.createdAt : record.reviewedAt ?? record.createdAt;
const matchesDate = !date || relevantDate.startsWith(date);
return matchesKeyword && matchesDate;
return matchesKeyword;
}),
[date, keyword, records],
[keyword, records],
);
function loadPhones(target = phoneTarget, page = phonePage, pageSize = phonePageSize) {
@@ -192,7 +194,7 @@ export function AdminSmsAuditPage() {
{error ? <p className="form-error">{error}</p> : null}
<div className="surface sms-audit-filter">
<h2></h2>
<div className="audit-filter-grid audit-filter-grid--sms">
<div className="ui-filter-row">
<Select
label="审核状态"
onChange={(event) => setStatus(event.target.value)}
@@ -205,10 +207,10 @@ export function AdminSmsAuditPage() {
value={status}
/>
<Input label="审核任务号/短信内容" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入审核任务号、短信内容或审核原因" value={keyword} />
<Input label={status === 'pending_review' ? '提交日期' : '审核日期'} onChange={(event) => setDate(event.target.value)} placeholder="yyyy-mm-dd" prefix={<CalendarDays size={16} />} value={date} />
<div className="audit-filter-actions">
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
<div className="audit-filter-actions ui-filter-actions">
<Button icon={<Search size={17} />} onClick={loadData}></Button>
<Button onClick={() => { setKeyword(''); setDate(''); setStatus('pending_review'); }} variant="ghost"></Button>
<Button onClick={() => { setKeyword(''); setSubmittedDateRange({}); setStatus('pending_review'); }} variant="ghost"></Button>
</div>
</div>
<div className="sms-bulk-actions">
@@ -252,10 +254,10 @@ export function AdminSmsAuditPage() {
{phoneTarget ? <Modal footer={<Button onClick={() => setPhoneTarget(null)}></Button>} onClose={() => setPhoneTarget(null)} open size="xl" title={`号码列表 · 审核任务号 ${phoneTarget.taskNo}`}>
<div className="page-stack">
<div className="audit-filter-grid">
<div className="ui-filter-row">
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} placeholder="输入完整或部分号码" value={phoneKeyword} />
<Select label="每页条数" onChange={(event) => { setPhonePageSize(Number(event.target.value)); setPhonePage(1); }} options={[{ label: '10条/页', value: '10' }, { label: '20条/页', value: '20' }, { label: '50条/页', value: '50' }]} value={String(phonePageSize)} />
<div className="audit-filter-actions"><Button icon={<Search size={16} />} onClick={() => { setPhonePage(1); loadPhones(phoneTarget, 1, phonePageSize); }}></Button></div>
<div className="audit-filter-actions ui-filter-actions"><Button icon={<Search size={16} />} onClick={() => { setPhonePage(1); loadPhones(phoneTarget, 1, phonePageSize); }}></Button></div>
</div>
<Table
columns={[
+5
View File
@@ -20,6 +20,7 @@ export function AdminSmsRecordsPage() {
const [channelKeyword, setChannelKeyword] = useState('');
const [carrier, setCarrier] = useState('all');
const [status, setStatus] = useState('all');
const [hasDrainage, setHasDrainage] = useState('all');
const [selectedRecord, setSelectedRecord] = useState<SmsMessageRecord | null>(null);
const [segmentAudits, setSegmentAudits] = useState<SmsMessageSegmentAudit[]>([]);
const [segmentLoading, setSegmentLoading] = useState(false);
@@ -41,6 +42,7 @@ export function AdminSmsRecordsPage() {
queuedAtFrom: dateRange.start,
queuedAtTo: dateRange.end,
status: status === 'all' ? undefined : status,
hasDrainage: hasDrainage === 'all' ? undefined : hasDrainage,
};
}
@@ -114,6 +116,7 @@ export function AdminSmsRecordsPage() {
setChannelKeyword('');
setCarrier('all');
setStatus('all');
setHasDrainage('all');
if (page !== 1) setPage(1);
else loadData({ queuedAtFrom: defaultDateRange.start, queuedAtTo: defaultDateRange.end }, 1);
}
@@ -151,6 +154,7 @@ export function AdminSmsRecordsPage() {
dateRange={dateRange}
enterprise={enterprise}
enterpriseOptions={enterpriseOptions}
hasDrainage={hasDrainage}
phoneKeyword={phoneKeyword}
status={status}
onApplicationChange={setApplication}
@@ -162,6 +166,7 @@ export function AdminSmsRecordsPage() {
setEnterprise(value);
setApplication('all');
}}
onHasDrainageChange={setHasDrainage}
onPhoneKeywordChange={setPhoneKeyword}
onQuery={() => {
if (page !== 1) setPage(1);
+10 -8
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Eye, Search, X } from 'lucide-react';
import { AuditReviewInfo, Breadcrumb, Button, Input, Modal, RiskAction, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { AuditReviewInfo, Breadcrumb, Button, DateRangeInput, Input, Modal, RiskAction, Select, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui';
import { adminApi, type SmsTemplateAudit } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
@@ -22,13 +22,14 @@ export function AdminTemplateAuditPage() {
const [audits, setAudits] = useState<SmsTemplateAudit[]>([]);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('pending');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [detail, setDetail] = useState<SmsTemplateAudit>();
useEffect(() => {
adminApi.listTemplateAudits({ keyword, status })
adminApi.listTemplateAudits({ keyword, status, submittedAtFrom: submittedDateRange.start, submittedAtTo: submittedDateRange.end })
.then(setAudits)
.catch(() => setAudits([]));
}, [keyword, status]);
}, [keyword, status, submittedDateRange.end, submittedDateRange.start]);
async function rejectTemplate(id: string) {
const updated = await adminApi.rejectTemplate(id);
@@ -57,7 +58,7 @@ export function AdminTemplateAuditPage() {
render: (record) => (
<div className="table-actions">
<Button icon={<Eye size={15} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button>
<RiskAction disabled={record.auditStatus !== 'pending'} onCompleted={() => adminApi.listTemplateAudits({ keyword, status }).then(setAudits)} targetId={record.id} targetType="template" />
<RiskAction disabled={record.auditStatus !== 'pending'} onCompleted={() => adminApi.listTemplateAudits({ keyword, status, submittedAtFrom: submittedDateRange.start, submittedAtTo: submittedDateRange.end }).then(setAudits)} targetId={record.id} targetType="template" />
<Button
disabled={record.auditStatus !== 'pending'}
icon={<X size={15} />}
@@ -71,7 +72,7 @@ export function AdminTemplateAuditPage() {
),
},
],
[keyword, status],
[keyword, status, submittedDateRange.end, submittedDateRange.start],
);
const templateAudits = audits;
@@ -83,12 +84,13 @@ export function AdminTemplateAuditPage() {
</div>
</div>
<div className="surface audit-filter-card">
<div className="audit-filter-grid audit-filter-grid--template">
<div className="ui-filter-row">
<Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索客户、应用或模板内容" value={keyword} />
<Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
<div className="audit-filter-actions">
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
<div className="audit-filter-actions ui-filter-actions">
<Button icon={<Search size={17} />}></Button>
<Button onClick={() => { setKeyword(''); setStatus('pending'); }} variant="ghost"></Button>
<Button onClick={() => { setKeyword(''); setStatus('pending'); setSubmittedDateRange({}); }} variant="ghost"></Button>
</div>
</div>
</div>
+3 -3
View File
@@ -258,7 +258,7 @@ export function AdminUsersPage() {
</div>
<div className="surface admin-system-toolbar admin-user-toolbar">
<div className="admin-user-filter-grid">
<div className="admin-user-filter-grid ui-filter-row">
<Input label="用户姓名" onChange={(event) => updateFilter('displayName', event.target.value)} placeholder="请输入用户姓名" value={filters.displayName} />
<Input label="登录账号" onChange={(event) => updateFilter('login', event.target.value)} placeholder="用户名、邮箱或手机号" value={filters.login} />
<Select
@@ -279,11 +279,11 @@ export function AdminUsersPage() {
options={[{ label: '全部状态', value: '' }, { label: '启用', value: 'active' }, { label: '禁用', value: 'disabled' }]}
value={filters.status}
/>
</div>
<div className="admin-system-toolbar__actions">
<div className="admin-system-toolbar__actions ui-filter-actions">
<Button disabled={querying} icon={<Search size={16} />} onClick={() => void queryUsers()}>{querying ? '查询中...' : '查询'}</Button>
<Button disabled={querying} onClick={() => { setFilters(emptyFilters); void queryUsers(emptyFilters); }} variant="ghost"></Button>
</div>
</div>
<Button icon={<Plus size={16} />} onClick={openCreate} size="sm"></Button>
</div>
{error ? <div className="surface empty-state">{error}</div> : null}
+10 -4
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Check, Eye, Search, X } from 'lucide-react';
import { adminApi, type ReportImportReviewBatch, type ReportImportReviewItem } from '@/api/adminApi';
import { Button, Input, Modal, Pagination, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
import { Button, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const statusLabels: Record<string, string> = {
@@ -38,6 +38,7 @@ export function ReportImportAuditPanel({ reportType }: { reportType: 'signature'
const pageSize = 20;
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [detail, setDetail] = useState<ReportImportReviewBatch>();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [rejectOpen, setRejectOpen] = useState(false);
@@ -50,6 +51,8 @@ export function ReportImportAuditPanel({ reportType }: { reportType: 'signature'
reportType,
status: status === 'all' ? undefined : status,
keyword: keyword.trim() || undefined,
startAt: submittedDateRange.start,
endAt: submittedDateRange.end,
page: targetPage,
pageSize,
}).then((result) => {
@@ -61,7 +64,7 @@ export function ReportImportAuditPanel({ reportType }: { reportType: 'signature'
useEffect(() => {
load(page);
}, [page, pageSize, reportType, status]);
}, [page, pageSize, reportType, status, submittedDateRange.end, submittedDateRange.start]);
const pendingItems = detail?.items.filter((item) => item.status === 'pending_review') ?? [];
const allPendingSelected = pendingItems.length > 0 && pendingItems.every((item) => selected.has(item.id));
@@ -81,6 +84,8 @@ export function ReportImportAuditPanel({ reportType }: { reportType: 'signature'
reportType,
status: status === 'all' ? undefined : status,
keyword: keyword.trim() || undefined,
startAt: submittedDateRange.start,
endAt: submittedDateRange.end,
page,
pageSize,
});
@@ -126,7 +131,7 @@ export function ReportImportAuditPanel({ reportType }: { reportType: 'signature'
return <div className="page-stack">
{error ? <p className="form-error">{error}</p> : null}
<div className="surface audit-filter-card">
<div className="audit-filter-grid audit-filter-grid--template">
<div className="ui-filter-row">
<Input label="批次号/文件名" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索导入批次或文件" value={keyword} />
<Select label="批次状态" onChange={(event) => { setStatus(event.target.value); setPage(1); }} options={[
{ label: '全部状态', value: 'all' },
@@ -136,7 +141,8 @@ export function ReportImportAuditPanel({ reportType }: { reportType: 'signature'
{ label: '部分通过', value: 'partially_approved' },
{ label: '已驳回', value: 'rejected' },
]} value={status} />
<div className="audit-filter-actions"><Button icon={<Search size={16} />} onClick={() => { setPage(1); load(1); }}></Button></div>
<DateRangeInput label="提交时间" onChange={(value) => { setSubmittedDateRange(value); setPage(1); }} value={submittedDateRange} />
<div className="audit-filter-actions ui-filter-actions"><Button icon={<Search size={16} />} onClick={() => { setPage(1); load(1); }}></Button><Button onClick={() => { setKeyword(''); setStatus('all'); setSubmittedDateRange({}); setPage(1); }} variant="ghost"></Button></div>
</div>
</div>
<div className="surface"><Table columns={columns} data={data.items} emptyText="暂无导入审核批次" pagination={false} rowKey="id" /></div>
@@ -78,6 +78,39 @@
-webkit-line-clamp: 2;
}
.admin-sms-record-card .admin-sms-record-content.is-drainage {
background: color-mix(in srgb, #f59e0b 13%, var(--color-surface));
border: 1px solid color-mix(in srgb, #f59e0b 34%, var(--color-border));
}
.admin-sms-record-content mark {
background: color-mix(in srgb, #f59e0b 32%, transparent);
border-radius: 3px;
color: inherit;
padding: 0 2px;
}
.admin-sms-record-drainage-badge {
border-radius: var(--radius-full);
display: inline-flex;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
margin-left: var(--space-3);
padding: 1px var(--space-2);
vertical-align: middle;
}
.admin-sms-record-drainage-badge.is-yes {
background: color-mix(in srgb, #f59e0b 18%, transparent);
color: #92400e;
}
.admin-sms-record-drainage-badge.is-no,
.admin-sms-record-drainage-badge.is-unknown {
background: var(--color-bg-subtle);
color: var(--color-text-muted);
}
.admin-sms-record-card__meta {
display: grid;
gap: var(--space-4);
@@ -18,6 +18,7 @@ type SmsRecordFilterProps = {
dateRange: DateRangeValue;
enterprise: string;
enterpriseOptions: SelectOption[];
hasDrainage: string;
phoneKeyword: string;
status: string;
onApplicationChange: (value: string) => void;
@@ -26,6 +27,7 @@ type SmsRecordFilterProps = {
onContentKeywordChange: (value: string) => void;
onDateRangeChange: (value: DateRangeValue) => void;
onEnterpriseChange: (value: string) => void;
onHasDrainageChange: (value: string) => void;
onPhoneKeywordChange: (value: string) => void;
onQuery: () => void;
onReset: () => void;
@@ -48,6 +50,13 @@ const statusOptions = [
{ label: '送达失败', value: 'failed' },
];
const drainageOptions = [
{ label: '全部', value: 'all' },
{ label: '含引流', value: 'true' },
{ label: '不含引流', value: 'false' },
{ label: '未检测', value: 'unknown' },
];
export function SmsRecordFilter({
application,
applicationOptions,
@@ -57,6 +66,7 @@ export function SmsRecordFilter({
dateRange,
enterprise,
enterpriseOptions,
hasDrainage,
phoneKeyword,
status,
onApplicationChange,
@@ -65,6 +75,7 @@ export function SmsRecordFilter({
onContentKeywordChange,
onDateRangeChange,
onEnterpriseChange,
onHasDrainageChange,
onPhoneKeywordChange,
onQuery,
onReset,
@@ -80,6 +91,7 @@ export function SmsRecordFilter({
<Input label="短信内容" onChange={(event) => onContentKeywordChange(event.target.value)} value={contentKeyword} />
<Input label="通道名称" onChange={(event) => onChannelKeywordChange(event.target.value)} value={channelKeyword} />
<Select label="发送状态" onChange={(event) => onStatusChange(event.target.value)} options={statusOptions} value={status} />
<Select label="是否含引流信息" onChange={(event) => onHasDrainageChange(event.target.value)} options={drainageOptions} value={hasDrainage} />
<div className="admin-sms-record-filter__actions">
<Button icon={<Search size={16} />} onClick={onQuery}></Button>
<Button onClick={onReset} variant="ghost"></Button>
+24 -1
View File
@@ -1,4 +1,5 @@
import { Download } from 'lucide-react';
import type { ReactNode } from 'react';
import type { SmsMessageRecord } from '@/api/adminApi';
import { Button, Pagination } from '@/components/ui';
import { formatCents } from '@/utils/currency';
@@ -22,6 +23,23 @@ function StatusLine({ record }: { record: SmsMessageRecord }) {
);
}
function DrainageContent({ record }: { record: SmsMessageRecord }) {
const ranges = (record.drainageDetection?.matches ?? [])
.filter((match) => Number.isInteger(match.start) && Number.isInteger(match.end) && match.start >= 0 && match.end > match.start && match.end <= record.content.length)
.sort((a, b) => a.start - b.start || a.end - b.end)
.filter((match, index, items) => index === 0 || match.start >= items[index - 1].end);
if (!record.hasDrainageContent || ranges.length === 0) return <>{record.content}</>;
const parts: ReactNode[] = [];
let cursor = 0;
ranges.forEach((range, index) => {
if (range.start > cursor) parts.push(record.content.slice(cursor, range.start));
parts.push(<mark key={`${range.start}-${range.end}-${index}`}>{record.content.slice(range.start, range.end)}</mark>);
cursor = range.end;
});
if (cursor < record.content.length) parts.push(record.content.slice(cursor));
return <>{parts}</>;
}
type SmsRecordListProps = {
currentPage: number;
loading: boolean;
@@ -59,7 +77,12 @@ export function SmsRecordList({
<StatusLine record={record} />
<time>{getDate(record.queuedAt)} {getClock(record.queuedAt)}</time>
</header>
<p className="admin-sms-record-content">{record.content}</p>
<p className={`admin-sms-record-content${record.hasDrainageContent ? ' is-drainage' : ''}`}>
<DrainageContent record={record} />
<span className={`admin-sms-record-drainage-badge is-${record.hasDrainageContent === true ? 'yes' : record.hasDrainageContent === false ? 'no' : 'unknown'}`}>
{record.hasDrainageContent === true ? '含引流' : record.hasDrainageContent === false ? '不含引流' : '未检测'}
</span>
</p>
<div className="admin-sms-record-card__meta">
<div><span></span><strong>{record.phoneNumber}</strong><small>{record.province ?? '-'} · {getCarrierLabel(record.carrier)}</small></div>
<div><span></span><strong>{record.billingUnits} / ¥{formatCents(record.amountCents)}</strong><small>{record.content.length} </small></div>
@@ -19,6 +19,7 @@ export type MessageFilters = {
queuedAtFrom?: string;
queuedAtTo?: string;
status?: string;
hasDrainage?: string;
};
export type TenantOption = {
+6 -1
View File
@@ -98,7 +98,7 @@ export function ClientHome() {
</div>
</div>
<div className="dashboard-grid">
<div className="dashboard-grid dashboard-grid--four">
<div className="surface metric-card metric-card--featured">
<span></span>
<strong>¥{formatAmount(availableBalance)}</strong>
@@ -109,6 +109,11 @@ export function ClientHome() {
<strong>{(dashboard?.today.sent ?? 0).toLocaleString('zh-CN')}</strong>
<small> {dashboard?.today.successRate ?? 0}%</small>
</div>
<div className="surface metric-card">
<span></span>
<strong>¥{formatCents(dashboard?.today.spendCents)}</strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>¥{formatCents(todayRefundCents)}</strong>
+3 -2
View File
@@ -7,6 +7,7 @@ export type DateRangeValue = {
};
type DateRangeInputProps = {
className?: string;
label?: string;
value: DateRangeValue;
onChange: (value: DateRangeValue) => void;
@@ -65,7 +66,7 @@ function getCalendarDays(viewDate: Date) {
});
}
export function DateRangeInput({ label, value, onChange }: DateRangeInputProps) {
export function DateRangeInput({ className = '', label, value, onChange }: DateRangeInputProps) {
const [open, setOpen] = useState(false);
const [viewDate, setViewDate] = useState(() => parseDate(value.start) ?? new Date());
const startDate = parseDate(value.start);
@@ -112,7 +113,7 @@ export function DateRangeInput({ label, value, onChange }: DateRangeInputProps)
}
return (
<div className="ui-field ui-date-range-field">
<div className={['ui-field', 'ui-date-range-field', className].filter(Boolean).join(' ')}>
{label ? <span className="ui-field__label">{label}</span> : null}
<button
className={['ui-date-range-trigger', open ? 'ui-date-range-trigger--open' : '', value.start || value.end ? 'ui-date-range-trigger--filled' : ''].filter(Boolean).join(' ')}
+2
View File
@@ -25,6 +25,7 @@ import {
ReceiptText,
ClipboardList,
Send,
ScanSearch,
Settings,
Shield,
ShieldOff,
@@ -182,6 +183,7 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
{ label: '用户管理', to: '/admin/users', icon: Users },
{ label: '手机号段库', to: '/admin/phone-segments', icon: Phone },
{ label: '报备字段库', to: '/admin/drainage-fields', icon: Hash },
{ label: '引流识别规则', to: '/admin/drainage-detection-rules', icon: ScanSearch },
{ label: '系统日志', to: '/admin/system-logs', icon: FileText },
],
},
+2
View File
@@ -8,6 +8,7 @@ import { AdminCustomerDetailPage } from '@/apps/admin/AdminCustomerDetailPage';
import { AdminCustomerFormPage } from '@/apps/admin/AdminCustomerFormPage';
import { AdminCustomersPage } from '@/apps/admin/AdminCustomersPage';
import { AdminDrainageFieldsPage } from '@/apps/admin/AdminDrainageFieldsPage';
import { AdminDrainageDetectionRulesPage } from '@/apps/admin/AdminDrainageDetectionRulesPage';
import { AdminDownstreamDeliveriesPage } from '@/apps/admin/AdminDownstreamDeliveriesPage';
import { AdminDownstreamRecoveryStatusesPage } from '@/apps/admin/AdminDownstreamRecoveryStatusesPage';
import { AdminEnterpriseApplicationsPage } from '@/apps/admin/AdminEnterpriseApplicationsPage';
@@ -137,6 +138,7 @@ export function AppRoutes() {
<Route path="users" element={<AdminUsersPage />} />
<Route path="phone-segments" element={<AdminPhoneSegmentsPage />} />
<Route path="drainage-fields" element={<AdminDrainageFieldsPage />} />
<Route path="drainage-detection-rules" element={<AdminDrainageDetectionRulesPage />} />
<Route path="system-logs" element={<AdminSystemLogsPage />} />
<Route path="*" element={<PagePlaceholder />} />
</Route>
+59 -2
View File
@@ -801,8 +801,41 @@
}
.ui-query-actions .ui-button {
flex: 0 0 88px;
width: 88px;
flex: 0 0 var(--query-action-width);
width: var(--query-action-width);
}
/* Shared list-filter sizing keeps ordinary controls compact and leaves enough
room for a complete date range without page-specific width overrides. */
.ui-filter-row {
align-items: end;
display: flex;
flex-wrap: wrap;
gap: var(--space-5);
}
.ui-filter-row > .ui-field,
.ui-filter-field {
flex: 0 1 var(--query-control-width);
width: min(100%, var(--query-control-width));
}
.ui-filter-row > .ui-date-range-field,
.ui-filter-field--date-range {
flex-basis: var(--query-date-range-width);
width: min(100%, var(--query-date-range-width));
}
.ui-filter-actions {
align-items: center;
display: flex;
flex: 0 0 auto;
gap: var(--space-3);
}
.ui-filter-actions .ui-button {
flex: 0 0 var(--query-action-width);
width: var(--query-action-width);
}
.ui-pagination {
@@ -1569,6 +1602,24 @@
.ui-modal__guard { padding: var(--space-5); }
.ui-modal__guard footer { display: grid; }
.ui-filter-row > .ui-field,
.ui-filter-field,
.ui-filter-row > .ui-date-range-field,
.ui-filter-field--date-range {
flex-basis: 100%;
width: 100%;
}
.ui-filter-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 100%;
}
.ui-filter-actions .ui-button {
width: 100%;
}
.ui-query-panel__grid,
.ui-detail-info-grid,
.ui-detail-progress-stats,
@@ -1583,6 +1634,12 @@
}
}
@media (max-width: 360px) {
.ui-filter-actions {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 780px) {
.ui-table-wrap {
background: transparent;
+36 -6
View File
@@ -132,6 +132,16 @@
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.dashboard-grid--four {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (max-width: 1180px) {
.dashboard-grid--four {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.metric-card {
display: grid;
gap: var(--space-2);
@@ -6849,6 +6859,30 @@
font-style: normal;
}
.signature-quality-matrix__drainage {
display: grid;
gap: var(--space-2);
min-width: 150px;
}
.signature-quality-matrix__drainage > div {
border-bottom: 1px dashed var(--color-border);
display: grid;
gap: 4px;
grid-template-columns: 58px 1fr;
padding-bottom: var(--space-2);
}
.signature-quality-matrix__drainage > div:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.signature-quality-matrix__drainage b {
color: var(--color-text-muted);
font-size: 12px;
}
.signature-quality-matrix__rate {
font-weight: 600;
}
@@ -8026,14 +8060,11 @@
}
.admin-user-toolbar {
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-columns: minmax(0, 1fr) auto;
}
.admin-user-filter-grid {
align-items: end;
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
min-width: 0;
}
.admin-user-toolbar > .ui-button {
@@ -8235,7 +8266,6 @@
.admin-drainage-toolbar { grid-template-columns: 1fr; }
.admin-drainage-section__heading { align-items: stretch; flex-direction: column; }
.admin-user-toolbar { grid-template-columns: 1fr; }
.admin-user-filter-grid { grid-template-columns: 1fr; }
}
.admin-drainage-actions .ui-button--ghost {
+3
View File
@@ -91,6 +91,9 @@
--control-height-md: 38px;
--control-height-lg: 44px;
--control-padding-x: 12px;
--query-control-width: 220px;
--query-date-range-width: 320px;
--query-action-width: 88px;
--z-dropdown: 1000;
--z-modal: 1100;
--z-toast: 1200;
+2 -1
View File
@@ -44,7 +44,8 @@ for (const [path, stableExport] of facadeChecks) {
const characterizationChecks = [
['api/src/send-chain/send-chain.service.spec.ts', 'allows only one retry submit when three long-message failure receipts race'],
['api/src/send-chain/send-chain.service.spec.ts', 'creates and sends only one downstream final receipt under concurrent completion'],
['api/src/send-chain/send-chain.service.spec.ts', 'creates and sends only one downstream receipt for the same fragment dedupe key'],
['api/src/send-chain/downstream-receipt-targets.spec.ts', 'queues one HTTP event and one CMPP receipt for each registered client fragment'],
['api/src/send-chain/send-chain.service.spec.ts', 'atomically claims a downstream manual requeue so concurrent requests only call Gateway once'],
['api/src/billing/billing.service.spec.ts', 'serializes and replays concurrent refunds with one balance mutation'],
['gateway/internal/inbound/server_test.go', 'TestDownstreamDeliveryRequiresAcknowledgement'],