fix: validate signatures and restore report metrics

This commit is contained in:
hectorzhao
2026-07-27 20:51:14 +08:00
parent 04a497c791
commit 94aeacd3a2
19 changed files with 438 additions and 42 deletions
+65
View File
@@ -51,6 +51,7 @@ function createPrismaMock() {
reportFields: [{ code: 'license', name: '营业执照', fieldType: 'file', required: true, description: null, sortOrder: 1, status: 'active' }], reportFields: [{ code: 'license', name: '营业执照', fieldType: 'file', required: true, description: null, sortOrder: 1, status: 'active' }],
}; };
return { return {
$queryRaw: jest.fn().mockResolvedValue([]),
$transaction: jest.fn((callback) => callback({ $transaction: jest.fn((callback) => callback({
smsChannel: { smsChannel: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-copy', ...data })), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-copy', ...data })),
@@ -218,6 +219,70 @@ describe('ChannelsService', () => {
}); });
}); });
it('adds real today delivery statistics and the latest successful send time to report tasks', async () => {
const prisma = createPrismaMock();
const task = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' };
const taskChannel = { id: 'channel-1', code: 'CMPP-A', name: '主通道' };
prisma.channelSignatureReportTask.findMany.mockResolvedValue([
{ ...task, reportType: 'signature', drainageItemId: null, channel: taskChannel },
{ ...task, id: 'report-task-2', reportType: 'drainage', drainageItemId: 'drain-1', channel: taskChannel },
]);
prisma.$queryRaw.mockResolvedValue([
{
channelId: 'channel-1',
signatureId: 'sig-1',
drainageInfoId: null,
total: 5,
acceptedCount: 4,
submitFailureCount: 1,
successCount: 2,
unknownCount: 1,
failureCount: 1,
lastSuccessfulSentAt: new Date('2026-07-27T01:00:00.000Z'),
},
{
channelId: 'channel-1',
signatureId: 'sig-1',
drainageInfoId: 'drain-1',
total: 3,
acceptedCount: 3,
submitFailureCount: 0,
successCount: 3,
unknownCount: 0,
failureCount: 0,
lastSuccessfulSentAt: new Date('2026-07-27T02:00:00.000Z'),
},
]);
const service = new ChannelsService(prisma as never);
const result = await service.listReportTasks(undefined, undefined, 'channel-1');
expect(result[0]).toEqual(expect.objectContaining({
deliveryStats: {
total: 8,
acceptedCount: 7,
submitFailureCount: 1,
submitFailureRate: 12.5,
successCount: 5,
successRate: 71.4,
unknownCount: 1,
unknownRate: 14.3,
failureCount: 1,
failureRate: 14.3,
},
lastSuccessfulSentAt: new Date('2026-07-27T02:00:00.000Z'),
}));
expect(result[1]).toEqual(expect.objectContaining({
deliveryStats: expect.objectContaining({
total: 3,
submitFailureCount: 0,
successCount: 3,
successRate: 100,
}),
lastSuccessfulSentAt: new Date('2026-07-27T02:00:00.000Z'),
}));
});
it('changes channel report status and recomputes the signature summary atomically', async () => { it('changes channel report status and recomputes the signature summary atomically', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const tx = { const tx = {
+163 -2
View File
@@ -1154,12 +1154,116 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
}); });
} }
listReportTasks(tenantId?: string, status?: string, channelId?: string, reportType?: string) { async listReportTasks(tenantId?: string, status?: string, channelId?: string, reportType?: string) {
return this.prisma.channelSignatureReportTask.findMany({ const tasks = await this.prisma.channelSignatureReportTask.findMany({
where: { tenantId, status, channelId, reportType }, where: { tenantId, status, channelId, reportType },
include: { signature: true, channel: true, drainageInfo: true }, include: { signature: true, channel: true, drainageInfo: true },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
if (tasks.length === 0) {
return tasks;
}
const channelIds = [...new Set(tasks.map((task) => task.channelId))];
const signatureIds = [...new Set(tasks.map((task) => task.signatureId))];
const day = currentShanghaiDayRange();
const rows = await this.prisma.$queryRaw<ChannelReportDeliveryRow[]>(Prisma.sql`
WITH base AS (
SELECT
submit."channelId" AS channel_id,
message."signatureId" AS signature_id,
message."drainageInfoId" AS drainage_info_id,
submit."submitStatus" AS submit_status,
COALESCE(submit."submittedAt", submit."createdAt") AS attempted_at,
CASE
WHEN segment_summary.segment_count > 0
AND segment_summary.delivered_count = segment_summary.segment_count
THEN segment_summary.completed_at
WHEN segment_summary.segment_count = 0 THEN delivered_receipt.delivered_at
END AS successful_at,
CASE
WHEN submit."submitStatus" <> 'accepted' THEN 'submit_failed'
WHEN segment_summary.segment_count > 0 AND segment_summary.failure_count > 0 THEN 'failure'
WHEN segment_summary.segment_count > 0
AND segment_summary.delivered_count = segment_summary.segment_count THEN 'success'
WHEN segment_summary.segment_count = 0 AND failed_receipt.failed_at IS NOT NULL THEN 'failure'
WHEN segment_summary.segment_count = 0 AND delivered_receipt.delivered_at IS NOT NULL THEN 'success'
ELSE 'unknown'
END AS delivery_status
FROM "SmsSubmitRecord" submit
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
LEFT JOIN LATERAL (
SELECT
COUNT(*)::integer AS segment_count,
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count,
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count,
MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at
FROM "SmsMessageSegmentAudit" segment
WHERE segment."submitRecordId" = submit.id
) segment_summary ON TRUE
LEFT JOIN LATERAL (
SELECT MIN(receipt."deliveredAt") AS delivered_at
FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'delivered'
) delivered_receipt ON TRUE
LEFT JOIN LATERAL (
SELECT MIN(receipt."deliveredAt") AS failed_at
FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'undelivered'
) failed_receipt ON TRUE
WHERE submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
AND submit."channelId" IN (${Prisma.join(channelIds)})
AND message."signatureId" IN (${Prisma.join(signatureIds)})
)
SELECT
channel_id AS "channelId",
signature_id AS "signatureId",
drainage_info_id AS "drainageInfoId",
COUNT(*) FILTER (
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
)::integer AS total,
COUNT(*) FILTER (
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
AND submit_status = 'accepted'
)::integer AS "acceptedCount",
COUNT(*) FILTER (
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
AND delivery_status = 'submit_failed'
)::integer AS "submitFailureCount",
COUNT(*) FILTER (
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
AND delivery_status = 'success'
)::integer AS "successCount",
COUNT(*) FILTER (
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
AND delivery_status = 'unknown'
)::integer AS "unknownCount",
COUNT(*) FILTER (
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
AND delivery_status = 'failure'
)::integer AS "failureCount",
MAX(successful_at) FILTER (WHERE delivery_status = 'success') AS "lastSuccessfulSentAt"
FROM base
GROUP BY channel_id, signature_id, drainage_info_id
`);
return tasks.map((task) => {
const taskRows = rows.filter((row) => (
row.channelId === task.channelId
&& row.signatureId === task.signatureId
&& ((task.reportType ?? 'signature') === 'signature' || row.drainageInfoId === task.drainageItemId)
));
const deliveryStats = summarizeChannelReportDelivery(taskRows);
return {
...task,
deliveryStats,
lastSuccessfulSentAt: latestDate(taskRows.map((row) => row.lastSuccessfulSentAt)),
};
});
} }
async createReportTask(data: CreateReportTaskDto) { async createReportTask(data: CreateReportTaskDto) {
@@ -2143,6 +2247,63 @@ function deriveReceiptStatus(rowCount: number, successCount: number, failedCount
return 'completed'; return 'completed';
} }
type ChannelReportDeliveryRow = {
channelId: string;
signatureId: string;
drainageInfoId: string | null;
total: number;
acceptedCount: number;
submitFailureCount: number;
successCount: number;
unknownCount: number;
failureCount: number;
lastSuccessfulSentAt: Date | null;
};
function summarizeChannelReportDelivery(rows: ChannelReportDeliveryRow[]) {
const total = sumReportDelivery(rows, 'total');
const acceptedCount = sumReportDelivery(rows, 'acceptedCount');
const submitFailureCount = sumReportDelivery(rows, 'submitFailureCount');
const successCount = sumReportDelivery(rows, 'successCount');
const unknownCount = sumReportDelivery(rows, 'unknownCount');
const failureCount = sumReportDelivery(rows, 'failureCount');
return {
total,
acceptedCount,
submitFailureCount,
submitFailureRate: percentage(submitFailureCount, total),
successCount,
successRate: percentage(successCount, acceptedCount),
unknownCount,
unknownRate: percentage(unknownCount, acceptedCount),
failureCount,
failureRate: percentage(failureCount, acceptedCount),
};
}
function sumReportDelivery(rows: ChannelReportDeliveryRow[], key: keyof Pick<
ChannelReportDeliveryRow,
'total' | 'acceptedCount' | 'submitFailureCount' | 'successCount' | 'unknownCount' | 'failureCount'
>) {
return rows.reduce((total, row) => total + Number(row[key] ?? 0), 0);
}
function percentage(count: number, total: number) {
return total > 0 ? Number(((count * 100) / total).toFixed(1)) : 0;
}
function latestDate(values: Array<Date | null>) {
const timestamps = values.filter((value): value is Date => Boolean(value)).map((value) => value.getTime());
return timestamps.length > 0 ? new Date(Math.max(...timestamps)) : null;
}
function currentShanghaiDayRange(now = new Date()) {
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1_000);
const localDate = shifted.toISOString().slice(0, 10);
const startAt = new Date(`${localDate}T00:00:00+08:00`);
return { startAt, endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1_000) };
}
function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hours: number | undefined, fallbackMinutes: number) { function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hours: number | undefined, fallbackMinutes: number) {
const value = minutes ?? (hours === undefined ? fallbackMinutes : hours * 60); const value = minutes ?? (hours === undefined ? fallbackMinutes : hours * 60);
if (!Number.isInteger(value) || value <= 0 || value > 72 * 60) { if (!Number.isInteger(value) || value <= 0 || value > 72 * 60) {
+18 -1
View File
@@ -1071,7 +1071,7 @@ describe('SmsConfigService', () => {
expect(prisma.auditRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'admin_create_approved', statusAfter: 'approved' }) }); expect(prisma.auditRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'admin_create_approved', statusAfter: 'approved' }) });
}); });
it.each(['未带括号', '[英文括号]', '【【重复括号】】', '【 】'])( it.each(['未带括号', '[英文括号]', '【【重复括号】】'])(
'rejects a signature name without exactly one complete Chinese black bracket pair: %s', 'rejects a signature name without exactly one complete Chinese black bracket pair: %s',
async (name) => { async (name) => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
@@ -1092,6 +1092,23 @@ describe('SmsConfigService', () => {
expect(prisma.smsSignature.update).not.toHaveBeenCalled(); expect(prisma.smsSignature.update).not.toHaveBeenCalled();
}); });
it.each([
'【带 空格】',
' 【外部空格】',
'【不换\n行】',
'【零宽\u200B字符】',
'【不换行空格\u00A0】',
'【字节顺序标记\uFEFF】',
'【变体选择符\uFE0F】',
])('rejects spaces, controls, and invisible characters in signature names: %s', async (name) => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.createSignature({ tenantId: 'tenant-1', name }))
.rejects.toThrow('短信签名不能包含空格、换行或不可见字符');
expect(prisma.smsSignature.create).not.toHaveBeenCalled();
});
it('updates enterprise signature drainage info through the admin API path', async () => { it('updates enterprise signature drainage info through the admin API path', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never); const service = new SmsConfigService(prisma as never);
+5 -2
View File
@@ -1990,9 +1990,12 @@ function normalizeSmsSignature(name: string) {
} }
function validateCompleteSmsSignature(name: string) { function validateCompleteSmsSignature(name: string) {
const value = name.trim(); const value = name;
if (/[\p{White_Space}\p{Cc}\p{Default_Ignorable_Code_Point}]/u.test(value)) {
throw new BadRequestException('短信签名不能包含空格、换行或不可见字符');
}
const match = value.match(/^【([^【】]+)】$/); const match = value.match(/^【([^【】]+)】$/);
if (!match || match[1] !== match[1].trim()) { if (!match) {
throw new BadRequestException('短信签名必须包含完整中文黑括号,例如:【某某科技】'); throw new BadRequestException('短信签名必须包含完整中文黑括号,例如:【某某科技】');
} }
return value; return value;
@@ -129,7 +129,7 @@
### 4.3 签名与引流信息 ### 4.3 签名与引流信息
1. 客户端和运营端新增、编辑短信签名时,签名名称必须填写完整中文黑括号格式,例如 `【某某科技】`;缺少括号、英文方括号、重复括号、空括号或括号外附加文本均不得提交。NestJS API 必须执行同样校验并以完整格式写入 PostgreSQL,不能只依赖前端按钮状态。 1. 客户端和运营端新增、编辑短信签名时,签名名称必须填写完整中文黑括号格式,例如 `【某某科技】`输入过程中禁止录入普通空格、换行、制表符、不换行空格、零宽字符、BOM、变体选择符等空白或不可见字符,非法按键/粘贴不得进入受控输入值并需立即给出错误提示。缺少括号、英文方括号、重复括号、空括号或括号外附加文本均不得提交。NestJS API 必须执行同样的字符与格式校验并以完整格式写入 PostgreSQL,不能只依赖前端按钮状态。
2. 运营端企业签名管理查看签名资料。 2. 运营端企业签名管理查看签名资料。
3. 签名需完成企业内部审核和通道报备,状态包括草稿、待审核、已通过、已驳回、报备中、报备通过、报备失败。 3. 签名需完成企业内部审核和通道报备,状态包括草稿、待审核、已通过、已驳回、报备中、报备通过、报备失败。
4. 已通过且报备通过的签名才允许发送。 4. 已通过且报备通过的签名才允许发送。
@@ -488,6 +488,7 @@
- 支持查看通道成功率、未知率、失败率、累计发送量。 - 支持查看通道成功率、未知率、失败率、累计发送量。
- 通道列表的通道成本以“分”为单位展示并保留两位小数,例如 `3.00 分`;展示格式不改变 PostgreSQL 中的真实成本单价及计费快照。 - 通道列表的通道成本以“分”为单位展示并保留两位小数,例如 `3.00 分`;展示格式不改变 PostgreSQL 中的真实成本单价及计费快照。
- 支持进入通道报备详情。 - 支持进入通道报备详情。
- 通道报备详情必须按签名任务和引流信息任务展示真实“提交报备时间、报备成功时间、上次发送成功时间、今日发送”数据。“今日发送”至少拆分成功、未知、回执失败、提交失败四项并同时显示数量和比例;提交被拒绝或超时单独计入提交失败,不得混入已接受短信的回执失败率。签名任务汇总该签名在当前通道下的全部引流信息,具体引流任务只统计自身;上次发送成功时间取同一统计范围内最近一条最终成功短信,不得使用报备更新时间或静态演示值替代。
- 通道报备详情页中,签名下的引流信息默认收起,用户点击后展开;展开/收起只影响页面展示,不改变报备数据。 - 通道报备详情页中,签名下的引流信息默认收起,用户点击后展开;展开/收起只影响页面展示,不改变报备数据。
- 通道列表状态区域展示“连接日志”入口;点击后弹窗展示真实连接日志,包括连接请求、连接成功、断开、心跳、重连、异常等事件,日志来源于 Gateway 回写或 OperationLog。通道连接状态、连接数和最近错误必须来自 Gateway 真实上游连接池回写,不能以一次性探测拨号成功代替长连接在线状态。 - 通道列表状态区域展示“连接日志”入口;点击后弹窗展示真实连接日志,包括连接请求、连接成功、断开、心跳、重连、异常等事件,日志来源于 Gateway 回写或 OperationLog。通道连接状态、连接数和最近错误必须来自 Gateway 真实上游连接池回写,不能以一次性探测拨号成功代替长连接在线状态。
- 通道操作按钮应保持一致的两列布局,报备详情、编辑、复制、发送测试、启停、删除等操作文案清晰。 - 通道操作按钮应保持一致的两列布局,报备详情、编辑、复制、发送测试、启停、删除等操作文案清晰。
+23
View File
@@ -120,6 +120,20 @@
- 合法名称以完整格式写入PostgreSQL;新增、编辑和所有展示位置均为`【测试签名】` - 合法名称以完整格式写入PostgreSQL;新增、编辑和所有展示位置均为`【测试签名】`
- 模板与发送预览只包含一层签名,不出现`【【测试签名】】` - 模板与发送预览只包含一层签名,不出现`【【测试签名】】`
### TC-CLIENT-003C 签名空白与不可见字符前后端强制校验
- 优先级:P0
- 前置条件:存在可创建、编辑签名的企业和应用。
- 步骤:
1. 分别在客户端和运营端新增、编辑签名,尝试键入或粘贴包含普通空格、换行、制表符、不换行空格、零宽空格、BOM和变体选择符的名称。
2. 观察受控输入框、错误提示和提交按钮。
3. 绕过页面直接调用真实新增、编辑API提交相同非法名称。
4. 输入不含空白或不可见字符的合法名称`【测试签名】`并保存。
- 预期结果:
- 非法字符不进入客户端或运营端受控输入值,页面立即提示且不能提交;已有合法输入不因一次非法粘贴被覆盖。
- 直接调用API返回400及可行动错误,不写入或更新`SmsSignature`,不能通过前端绕过。
- 合法名称可正常保存,刷新后仍来自真实PostgreSQL记录。
### TC-CLIENT-003A 签名与引流信息工作台及通道信息隔离 ### TC-CLIENT-003A 签名与引流信息工作台及通道信息隔离
- 优先级:P0 - 优先级:P0
@@ -3907,3 +3921,12 @@ npm run verify:phase8
| TC-BILLING-ATOMIC-005 | 同一企业同时发生扣费、退款和充值 | 账户级事务锁串行化余额变更,使用数据库原子增量;每条流水`balanceAfter`连续且最终余额与流水一致 | | 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 | 历史行全部保留;每个短信只给最早一条历史回执设置唯一键,其余保持空键;新数据开始强制唯一 | | TC-MIGRATION-IDEM-007 | 在含历史重复最终回执的预生产数据上执行migration | 历史行全部保留;每个短信只给最早一条历史回执设置唯一键,其余保持空键;新数据开始强制唯一 |
## 2026-07-27 通道报备发送统计用例
| 用例编号 | 场景 | 预期结果 |
|---|---|---|
| TC-CHANNEL-REPORT-STATS-001 | 当前通道同一签名今日存在已接受成功、已接受未知、已接受失败、提交拒绝和提交超时记录 | 签名任务的“今日发送”展示四类真实数量与比例;提交拒绝/超时只计入提交失败,成功/未知/回执失败比例只以已接受数量为分母 |
| TC-CHANNEL-REPORT-STATS-002 | 同一签名有直接短信和多个引流信息,展开具体引流任务 | 签名任务汇总当前通道下该签名全部发送;具体引流任务仅统计自身,不串入同签名其他引流或直接短信 |
| TC-CHANNEL-REPORT-STATS-003 | 当前统计范围有历史成功短信但今日无成功短信 | “上次发送成功时间”显示该范围最近一次最终成功时间,不以报备时间、最后更新时间或页面当前时间代替 |
| TC-CHANNEL-REPORT-STATS-004 | 报备任务先提交、后由报备记录变为通过 | 列表和详情分别显示真实提交报备时间、最近一次报备成功时间、上次发送成功时间和今日统计;刷新后数据保持一致 |
+17
View File
@@ -2542,3 +2542,20 @@ git diff --check
- 功能提交`e0f6eed0d40e21fb06166e28dd54fefa7d0f09b0`已推送并由精确Git快照发布;快照585个条目、1741824字节,本地与服务器SHA-256均为`99ed2d4b86f91b09a87baca027441aa63bec4b0ccefae3a04be5c63d80878e3f`。部署前数据库备份为`/opt/cmpp-deploy-backups/cmpp-20260726-223344.sql`,旧运行目录保留为`/opt/cmpp-platform.previous-20260726-223344` - 功能提交`e0f6eed0d40e21fb06166e28dd54fefa7d0f09b0`已推送并由精确Git快照发布;快照585个条目、1741824字节,本地与服务器SHA-256均为`99ed2d4b86f91b09a87baca027441aa63bec4b0ccefae3a04be5c63d80878e3f`。部署前数据库备份为`/opt/cmpp-deploy-backups/cmpp-20260726-223344.sql`,旧运行目录保留为`/opt/cmpp-platform.previous-20260726-223344`
- 标准部署脚本成功应用`20260726223000_prevent_duplicate_retry_side_effects`,预发布73条migration齐全;三个目标字段、唯一索引及补发自关联外键均已落库,`.deployed-commit=e0f6eed0d40e21fb06166e28dd54fefa7d0f09b0`。API、Gateway、Nginx、PostgreSQL、MinIO均activeRedis`PONG``12026/17890/8090/3000/6379/5432/9000`监听,内外健康页、运营端和客户端入口HTTP 200,公网CMPP 17890可连接,发布后API/Gateway无error级日志。 - 标准部署脚本成功应用`20260726223000_prevent_duplicate_retry_side_effects`,预发布73条migration齐全;三个目标字段、唯一索引及补发自关联外键均已落库,`.deployed-commit=e0f6eed0d40e21fb06166e28dd54fefa7d0f09b0`。API、Gateway、Nginx、PostgreSQL、MinIO均activeRedis`PONG``12026/17890/8090/3000/6379/5432/9000`监听,内外健康页、运营端和客户端入口HTTP 200,公网CMPP 17890可连接,发布后API/Gateway无error级日志。
- 四个启用通道均为`connected/currentConnections=1/desiredConnections=1`Redis提交流消费者1、`pending=0``lag=0`。事故短信只读复核仍为4条提交尝试、12条供应商回执、3条下游投递和3条`refunded`账务流水,证明发布未删除、重写或自动冲正历史证据;本轮未发送任何真实测试短信。 - 四个启用通道均为`connected/currentConnections=1/desiredConnections=1`Redis提交流消费者1、`pending=0``lag=0`。事故短信只读复核仍为4条提交尝试、12条供应商回执、3条下游投递和3条`refunded`账务流水,证明发布未删除、重写或自动冲正历史证据;本轮未发送任何真实测试短信。
## 2026-07-27 HTTP环境前端随机ID兼容修复(发布前)
- 预发布运营端删除签名在发起删除预检前调用`crypto.randomUUID()`;公网入口为裸HTTP IP,浏览器非安全上下文中该方法不可用,导致点击后抛出`TypeError`且后端未收到删除请求。
- 新增统一随机ID工具:安全上下文优先使用原生`randomUUID()`HTTP等环境回退到`crypto.getRandomValues()`生成符合版本位和变体位要求的UUID v4;极旧环境无Web Crypto时保留随机字节兼容兜底。
- 删除签名/模板/通道、签名和模板审核通过、人工充值、报备批次生成统一改用兼容工具;企业应用密码随机生成也复用随机字节实现,避免同类问题从其他入口再次出现。
- Node.js v24.14.0下前端TypeScript检查、Vite生产构建及`git diff --check`通过;无`randomUUID()`和无Web Crypto两种模拟环境均生成格式正确的UUID v4,`getRandomValues()`回退同时生成16位十六进制应用密码。构建仅保留既有约1.95MB单chunk提示。
- 此修改已纳入用户授权的本轮统一提交、推送和预生产发布范围;工作区原有构建缓存、`outputs/`及空文件`=`继续作为非代码临时产物隔离。
## 2026-07-27 签名不可见字符校验与通道报备真实统计(发布前)
- 客户端与运营端签名新增/编辑统一禁止普通空格、Unicode空白、控制字符和默认不可见字符;非法键入或粘贴不覆盖已有受控输入,并即时显示错误。NestJS新增、编辑接口复用相同后端校验,防止绕过页面写入非法签名。
- 通道报备详情恢复设计基线的“提交报备时间、报备成功时间、上次发送成功时间、今日发送”列;报备成功时间取真实通过记录,上次成功时间取当前通道与签名/引流范围内最近一条最终成功短信。
- 今日发送统计由PostgreSQL真实提交记录、分片审计和供应商回执聚合,拆分成功、未知、回执失败和提交失败。提交拒绝/超时不混入已接受短信的回执失败率;签名任务汇总签名全部引流范围,具体引流任务严格隔离到自身。
- Node.js v24.14.0定向验证通过:通道与短信配置2 suites / 103 tests;本机临时Redis就绪后API全量26 suites / 353 tests通过,测试结束即停止临时Redis。Prisma schema校验、API TypeScript build、前端TypeScript/Vite生产构建、Gateway Go全量测试/vet和依赖安全门禁通过;前端仅保留既有约1.96MB单chunk提示。
- 预发布PostgreSQL按新口径只读执行聚合成功:当前报备任务范围覆盖512条通道提交尝试,其中北京时间今日324条、提交失败1条、最终成功169条,最近成功时间为`2026-07-27 11:18:39.04 UTC`;查询未写入或改写业务数据。提交、推送及预生产部署结果以本轮交付回执为准。
- 用户明确授权将工作区所有有效代码一并发布;`api/tsconfig.build.tsbuildinfo``tsconfig.tsbuildinfo``outputs/`及空文件`=`属于既有构建/临时产物,不纳入源码提交。
+13
View File
@@ -923,6 +923,19 @@ export type ReportTask = DictionaryItem & {
signature?: { id: string; name: string; purpose?: string | null; drainageInfo?: Record<string, unknown> | null }; signature?: { id: string; name: string; purpose?: string | null; drainageInfo?: Record<string, unknown> | null };
drainageInfo?: SmsDrainageInfo | null; drainageInfo?: SmsDrainageInfo | null;
channel?: { id: string; name: string; code: string }; channel?: { id: string; name: string; code: string };
deliveryStats?: {
total: number;
acceptedCount: number;
submitFailureCount: number;
submitFailureRate: number;
successCount: number;
successRate: number;
unknownCount: number;
unknownRate: number;
failureCount: number;
failureRate: number;
};
lastSuccessfulSentAt?: string | null;
}; };
export type ReportRecord = DictionaryItem & { export type ReportRecord = DictionaryItem & {
+33 -11
View File
@@ -46,7 +46,26 @@ function ReportStatus({ value }: { value?: string }) {
return <Tag tone={meta.tone}>{meta.label}</Tag>; return <Tag tone={meta.tone}>{meta.label}</Tag>;
} }
function DetailModal({ drainage, signature, task, onClose }: { drainage?: DrainageItem; signature?: ClientSmsSignature; task: ReportTask; onClose: () => void }) { function DeliveryStats({ task }: { task: ReportTask }) {
const stats = task.deliveryStats ?? {
submitFailureCount: 0,
submitFailureRate: 0,
successCount: 0,
successRate: 0,
unknownCount: 0,
unknownRate: 0,
failureCount: 0,
failureRate: 0,
};
return <div className="channel-report-stats">
<span><strong className="is-success">{stats.successRate}%</strong><b>{stats.successCount.toLocaleString('zh-CN')}</b></span>
<span><strong className="is-warning">{stats.unknownRate}%</strong><b>{stats.unknownCount.toLocaleString('zh-CN')}</b></span>
<span><strong className="is-danger">{stats.failureRate}%</strong><b>{stats.failureCount.toLocaleString('zh-CN')}</b></span>
<span><strong className="is-danger">{stats.submitFailureRate}%</strong><b>{stats.submitFailureCount.toLocaleString('zh-CN')}</b></span>
</div>;
}
function DetailModal({ drainage, reportedAt, signature, task, onClose }: { drainage?: DrainageItem; reportedAt?: string | null; signature?: ClientSmsSignature; task: ReportTask; onClose: () => void }) {
const payload = asRecord(signature?.drainageInfo); const payload = asRecord(signature?.drainageInfo);
const profile = asRecord(payload.signatureProfile); const profile = asRecord(payload.signatureProfile);
const reportValues = asRecord(drainage ? drainage.reportValues : payload.signatureReportValues); const reportValues = asRecord(drainage ? drainage.reportValues : payload.signatureReportValues);
@@ -56,9 +75,13 @@ function DetailModal({ drainage, signature, task, onClose }: { drainage?: Draina
<strong>{drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong> <strong>{drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong>
<p><span></span><span>{signature?.tenant?.name ?? task.tenantId}</span></p> <p><span></span><span>{signature?.tenant?.name ?? task.tenantId}</span></p>
<p><span></span><span>{signature?.application?.name ?? '-'}</span></p> <p><span></span><span>{signature?.application?.name ?? '-'}</span></p>
<p><span></span><DateTime value={drainage?.submittedAt ?? task.createdAt} /></p>
<p><span></span><DateTime value={reportedAt} /></p>
<p><span></span><DateTime value={task.lastSuccessfulSentAt} /></p>
{!drainage ? <><p><span></span><span>{String(profile.basis ?? '-')}</span></p><p><span></span><span>{String(profile.companyName ?? '-')}</span></p><p><span></span><span>{String(profile.creditCode ?? '-')}</span></p></> : null} {!drainage ? <><p><span></span><span>{String(profile.basis ?? '-')}</span></p><p><span></span><span>{String(profile.companyName ?? '-')}</span></p><p><span></span><span>{String(profile.creditCode ?? '-')}</span></p></> : null}
{drainage ? <><p><span></span><span>{String(drainage.url ?? '-')}</span></p><p><span></span><span>{String(drainage.remark ?? '-')}</span></p></> : null} {drainage ? <><p><span></span><span>{String(drainage.url ?? '-')}</span></p><p><span></span><span>{String(drainage.remark ?? '-')}</span></p></> : null}
{Object.entries(reportValues).map(([key, value]) => <p key={key}><span>{key}</span><span>{typeof value === 'object' ? String(asRecord(value).fileName ?? asRecord(value).fileObjectId ?? '-') : String(value ?? '-')}</span></p>)} {Object.entries(reportValues).map(([key, value]) => <p key={key}><span>{key}</span><span>{typeof value === 'object' ? String(asRecord(value).fileName ?? asRecord(value).fileObjectId ?? '-') : String(value ?? '-')}</span></p>)}
<section><h3></h3><DeliveryStats task={task} /></section>
</div> </div>
</Modal> </Modal>
); );
@@ -75,7 +98,7 @@ export function AdminChannelReportPage() {
const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]); const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]);
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all'); const [status, setStatus] = useState('all');
const [detail, setDetail] = useState<{ task: ReportTask; signature?: ClientSmsSignature; drainage?: DrainageItem }>(); const [detail, setDetail] = useState<{ task: ReportTask; reportedAt?: string | null; signature?: ClientSmsSignature; drainage?: DrainageItem }>();
const [statusTask, setStatusTask] = useState<ReportTask>(); const [statusTask, setStatusTask] = useState<ReportTask>();
const [nextStatus, setNextStatus] = useState('approved'); const [nextStatus, setNextStatus] = useState('approved');
const [statusReason, setStatusReason] = useState(''); const [statusReason, setStatusReason] = useState('');
@@ -111,8 +134,8 @@ export function AdminChannelReportPage() {
return matchesKeyword && (status === 'all' || task.status === status); return matchesKeyword && (status === 'all' || task.status === status);
}), [keyword, signatureMap, status, tasks]); }), [keyword, signatureMap, status, tasks]);
function lastRecord(taskId: string, action: string) { function approvedRecord(taskId: string) {
return records.find((record) => record.taskId === taskId && record.action === action); return records.find((record) => record.taskId === taskId && record.statusAfter === 'approved');
} }
async function saveFieldMapping(nextFields: Parameters<typeof adminApi.replaceChannelReportFields>[2]) { async function saveFieldMapping(nextFields: Parameters<typeof adminApi.replaceChannelReportFields>[2]) {
@@ -154,21 +177,20 @@ export function AdminChannelReportPage() {
</div> </div>
<div className="surface channel-report-table"> <div className="surface channel-report-table">
<div className="channel-report-table__head"><span /><span> / </span><span></span><span></span><span></span><span></span><span></span><span></span></div> <div className="channel-report-table__head"><span /><span> / </span><span></span><span></span><span></span><span></span><span></span><span></span></div>
{visibleTasks.length === 0 ? <div className="channel-report-empty"></div> : visibleTasks.map((task) => { {visibleTasks.length === 0 ? <div className="channel-report-empty"></div> : visibleTasks.map((task) => {
const signature = signatureMap.get(task.signatureId); const signature = signatureMap.get(task.signatureId);
const drainage = task.reportType === 'drainage' ? drainageItems(signature).find((item) => String(item.id) === task.drainageItemId) : undefined; const drainage = task.reportType === 'drainage' ? drainageItems(signature).find((item) => String(item.id) === task.drainageItemId) : undefined;
const exported = lastRecord(task.id, 'export'); const reportedAt = approvedRecord(task.id)?.createdAt;
const imported = lastRecord(task.id, 'receipt_import');
return <div className={`channel-report-row ${drainage ? 'channel-report-row--drainage' : 'channel-report-row--signature'}`} key={task.id}> return <div className={`channel-report-row ${drainage ? 'channel-report-row--drainage' : 'channel-report-row--signature'}`} key={task.id}>
<span /> <span />
<div className={`channel-report-name ${drainage ? 'channel-report-name--flow' : ''}`}>{drainage ? <i /> : null}<span><strong>{drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong><small>{drainage ? `${String(drainage.url ?? '')} · ${formatSignatureName(signature?.name ?? task.signature?.name)}` : signature?.tenant?.name ?? task.tenantId}</small></span></div> <div className={`channel-report-name ${drainage ? 'channel-report-name--flow' : ''}`}>{drainage ? <i /> : null}<span><strong>{drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong><small>{drainage ? `${String(drainage.url ?? '')} · ${formatSignatureName(signature?.name ?? task.signature?.name)}` : signature?.tenant?.name ?? task.tenantId}</small></span></div>
<ReportStatus value={task.status} /> <ReportStatus value={task.status} />
<DateTime value={drainage?.submittedAt ?? task.createdAt} /> <DateTime value={drainage?.submittedAt ?? task.createdAt} />
<DateTime value={imported?.createdAt ?? exported?.createdAt} /> <DateTime value={reportedAt} />
<DateTime value={task.updatedAt} /> <DateTime value={task.lastSuccessfulSentAt} />
<div className="channel-report-stats"><span><strong className="is-success">-</strong><b></b></span><span><strong className="is-warning">-</strong><b></b></span><span><strong className="is-danger">-</strong><b></b></span></div> <DeliveryStats task={task} />
<div className="channel-report-actions"><button onClick={() => setDetail({ task, signature, drainage })} type="button"><Eye size={16} /></button><button className="is-warning" onClick={() => { setStatusTask(task); setNextStatus(task.status); }} type="button"></button></div> <div className="channel-report-actions"><button onClick={() => setDetail({ task, reportedAt, signature, drainage })} type="button"><Eye size={16} /></button><button className="is-warning" onClick={() => { setStatusTask(task); setNextStatus(task.status); }} type="button"></button></div>
</div>; </div>;
})} })}
</div> </div>
@@ -4,7 +4,7 @@ import { adminApi, type ApplicationReportField, type ClientSmsApplication, type
import { Breadcrumb, Button, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui'; import { Breadcrumb, Button, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
import { displayFileName } from '@/utils/fileName'; import { displayFileName } from '@/utils/fileName';
import { formatDateTime } from '@/utils/dateTime'; import { formatDateTime } from '@/utils/dateTime';
import { isCompleteSmsSignature } from '@/utils/smsSignature'; import { getSmsSignatureValidationError, hasForbiddenSmsSignatureCharacter, isCompleteSmsSignature, SMS_SIGNATURE_CHARACTER_ERROR } from '@/utils/smsSignature';
type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing'; type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing';
@@ -310,6 +310,7 @@ function SignatureFormModal({
reportValues: payload?.signatureReportValues ?? {}, reportValues: payload?.signatureReportValues ?? {},
}); });
const [reportFields, setReportFields] = useState<ApplicationReportField[]>([]); const [reportFields, setReportFields] = useState<ApplicationReportField[]>([]);
const [nameInputError, setNameInputError] = useState('');
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted'); const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
useEffect(() => { useEffect(() => {
@@ -327,12 +328,15 @@ function SignatureFormModal({
setForm((current) => ({ ...current, reportValues: { ...current.reportValues, [code]: value } })); setForm((current) => ({ ...current, reportValues: { ...current.reportValues, [code]: value } }));
} }
const signatureNameValid = !nameInputError && isCompleteSmsSignature(form.name);
const signatureNameError = nameInputError || (form.name ? getSmsSignatureValidationError(form.name) : undefined);
return ( return (
<Modal <Modal
footer={( footer={(
<> <>
<Button onClick={onClose} variant="ghost"></Button> <Button onClick={onClose} variant="ghost"></Button>
<Button disabled={!form.tenantId || !isCompleteSmsSignature(form.name) || hasMissingRequiredReportValue(reportFields, form.reportValues)} onClick={() => onSubmit(form)}></Button> <Button disabled={!form.tenantId || !signatureNameValid || hasMissingRequiredReportValue(reportFields, form.reportValues)} onClick={() => onSubmit(form)}></Button>
</> </>
)} )}
onClose={onClose} onClose={onClose}
@@ -374,10 +378,18 @@ function SignatureFormModal({
value={form.applicationId} value={form.applicationId}
/> />
<Input <Input
error={form.name.trim() && !isCompleteSmsSignature(form.name) ? '必须填写完整中文黑括号签名,例如:【某某科技】' : undefined} error={signatureNameError}
hint="新增和编辑时必须保留完整的【】" hint="新增和编辑时必须保留完整的【】,且不能包含空格或不可见字符"
label="短信签名" label="短信签名"
onChange={(event) => update('name', event.target.value)} onChange={(event) => {
const value = event.target.value;
if (hasForbiddenSmsSignatureCharacter(value)) {
setNameInputError(SMS_SIGNATURE_CHARACTER_ERROR);
return;
}
setNameInputError('');
update('name', value);
}}
placeholder="请输入完整签名,例如:【某某科技】" placeholder="请输入完整签名,例如:【某某科技】"
required required
value={form.name} value={form.name}
+2 -1
View File
@@ -3,6 +3,7 @@ import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet, Layers3, Refres
import { adminApi, fileDownloadUrl, type ReportMaterialBatchPreflight, type ReportMaterialBatchResult, type ReportMaterialPendingItem } from '@/api/adminApi'; import { adminApi, fileDownloadUrl, type ReportMaterialBatchPreflight, type ReportMaterialBatchResult, type ReportMaterialPendingItem } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Tag } from '@/components/ui'; import { Breadcrumb, Button, Input, Modal, Select, Tag } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime'; import { formatDateTime } from '@/utils/dateTime';
import { createUuid } from '@/utils/randomId';
import { ReportMaterialImportModal } from './ReportMaterialImportModal'; import { ReportMaterialImportModal } from './ReportMaterialImportModal';
type Batch = Record<string, unknown> & { id: string; batchNo?: string; status?: string; createdAt?: string; selectedCount?: number; channelCount?: number; exportFiles?: Array<Record<string, unknown>> }; type Batch = Record<string, unknown> & { id: string; batchNo?: string; status?: string; createdAt?: string; selectedCount?: number; channelCount?: number; exportFiles?: Array<Record<string, unknown>> };
@@ -49,7 +50,7 @@ export function AdminReportMaterialsPage() {
const chosen = items.filter((item) => selected.has(item.id)); const chosen = items.filter((item) => selected.has(item.id));
if (!chosen.length) { setError('请先选择具备报备资格的资料'); return; } if (!chosen.length) { setError('请先选择具备报备资格的资料'); return; }
setConfirmOpen(true); setPreflightBusy(true); setPreflight(null); setBatchResult(null); setError(''); setMessage(''); setConfirmOpen(true); setPreflightBusy(true); setPreflight(null); setBatchResult(null); setError(''); setMessage('');
setOperationKey(`report-batch:${crypto.randomUUID()}`); setOperationKey(`report-batch:${createUuid()}`);
try { setPreflight(await adminApi.preflightReportMaterialBatch({ items: chosen.map(toBatchItem) })); } try { setPreflight(await adminApi.preflightReportMaterialBatch({ items: chosen.map(toBatchItem) })); }
catch (failure) { setError(failure instanceof Error ? failure.message : '报备资格预检失败'); } catch (failure) { setError(failure instanceof Error ? failure.message : '报备资格预检失败'); }
finally { setPreflightBusy(false); } finally { setPreflightBusy(false); }
@@ -4,6 +4,7 @@ import { ArrowLeft, Globe2, Info, RadioTower, RefreshCw } from 'lucide-react';
import { adminApi, type ChannelGroup, type DictionaryItem, type EnterpriseApplication, type HttpApiConfig } from '@/api/adminApi'; import { adminApi, type ChannelGroup, type DictionaryItem, type EnterpriseApplication, type HttpApiConfig } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui'; import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui';
import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency'; import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
import { createRandomHex } from '@/utils/randomId';
type Carrier = 'mobile' | 'unicom' | 'telecom'; type Carrier = 'mobile' | 'unicom' | 'telecom';
type QueuePriority = 'normal' | 'priority'; type QueuePriority = 'normal' | 'priority';
@@ -489,8 +490,5 @@ function parseIpAllowlist(value: string) {
} }
function generateApplicationPassword() { function generateApplicationPassword() {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { return createRandomHex(16);
return crypto.randomUUID().replace(/-/g, '').slice(0, 16);
}
return Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
} }
+16 -6
View File
@@ -9,7 +9,7 @@ import {
type ClientSmsSignatureView, type ClientSmsSignatureView,
type FileRef, type FileRef,
} from '@/api/adminApi'; } from '@/api/adminApi';
import { isCompleteSmsSignature } from '@/utils/smsSignature'; import { getSmsSignatureValidationError, hasForbiddenSmsSignatureCharacter, isCompleteSmsSignature, SMS_SIGNATURE_CHARACTER_ERROR } from '@/utils/smsSignature';
const EMPTY_WORKSPACE: ClientSignatureWorkspace = { const EMPTY_WORKSPACE: ClientSignatureWorkspace = {
items: [], items: [],
@@ -98,6 +98,7 @@ function SignatureModal({
const [uploadingCode, setUploadingCode] = useState(''); const [uploadingCode, setUploadingCode] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [nameInputError, setNameInputError] = useState('');
useEffect(() => { useEffect(() => {
const request = applicationId const request = applicationId
@@ -122,7 +123,7 @@ function SignatureModal({
async function save() { async function save() {
if (!isCompleteSmsSignature(name)) { if (!isCompleteSmsSignature(name)) {
setError('短信签名必须包含完整中文黑括号,例如:【某某科技】'); setError(getSmsSignatureValidationError(name) ?? '短信签名格式不正确');
return; return;
} }
setSaving(true); setSaving(true);
@@ -143,7 +144,8 @@ function SignatureModal({
} }
const missingRequired = fields.some((field) => field.required && !values[field.code]); const missingRequired = fields.some((field) => field.required && !values[field.code]);
const signatureNameValid = isCompleteSmsSignature(name); const signatureNameValid = !nameInputError && isCompleteSmsSignature(name);
const signatureNameError = nameInputError || (name ? getSmsSignatureValidationError(name) : undefined);
return <Modal return <Modal
footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!signatureNameValid || missingRequired || saving || Boolean(uploadingCode)} onClick={() => void save()}>{saving ? '提交中...' : '提交审核'}</Button></>} footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!signatureNameValid || missingRequired || saving || Boolean(uploadingCode)} onClick={() => void save()}>{saving ? '提交中...' : '提交审核'}</Button></>}
onClose={onClose} onClose={onClose}
@@ -160,10 +162,18 @@ function SignatureModal({
value={applicationId} value={applicationId}
/> />
<Input <Input
error={name.trim() && !signatureNameValid ? '必须填写完整中文黑括号签名,例如:【某某科技】' : undefined} error={signatureNameError}
hint="新增和编辑时必须保留完整的【】" hint="新增和编辑时必须保留完整的【】,且不能包含空格或不可见字符"
label="短信签名" label="短信签名"
onChange={(event) => setName(event.target.value)} onChange={(event) => {
const value = event.target.value;
if (hasForbiddenSmsSignatureCharacter(value)) {
setNameInputError(SMS_SIGNATURE_CHARACTER_ERROR);
return;
}
setNameInputError('');
setName(value);
}}
placeholder="请输入完整签名,例如:【某某科技】" placeholder="请输入完整签名,例如:【某某科技】"
required required
value={name} value={name}
+2 -1
View File
@@ -1,6 +1,7 @@
import { useState, type ReactNode } from 'react'; import { useState, type ReactNode } from 'react';
import { AlertTriangle, ShieldCheck, Trash2 } from 'lucide-react'; import { AlertTriangle, ShieldCheck, Trash2 } from 'lucide-react';
import { adminApi, clientApi, type DeletionPreflight, type DeletionResult, type DeletionTargetType } from '@/api/adminApi'; import { adminApi, clientApi, type DeletionPreflight, type DeletionResult, type DeletionTargetType } from '@/api/adminApi';
import { createUuid } from '@/utils/randomId';
import { Button } from './Button'; import { Button } from './Button';
import { Modal } from './Modal'; import { Modal } from './Modal';
import { Textarea } from './Textarea'; import { Textarea } from './Textarea';
@@ -24,7 +25,7 @@ export function DeleteRiskAction({ portal, targetType, targetId, children = '删
const [idempotencyKey, setIdempotencyKey] = useState(''); const [idempotencyKey, setIdempotencyKey] = useState('');
async function begin() { async function begin() {
const key = `delete:${targetType}:${targetId}:${crypto.randomUUID()}`; const key = `delete:${targetType}:${targetId}:${createUuid()}`;
setOpen(true); setLoading(true); setPreflight(null); setResult(null); setReason(''); setError(''); setIdempotencyKey(key); setOpen(true); setLoading(true); setPreflight(null); setResult(null); setReason(''); setError(''); setIdempotencyKey(key);
try { try {
const data = portal === 'admin' const data = portal === 'admin'
+2 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { adminApi, type ManualRechargePreflight, type ManualRechargeResult } from '@/api/adminApi'; import { adminApi, type ManualRechargePreflight, type ManualRechargeResult } from '@/api/adminApi';
import { formatCents, isValidMoneyInput, yuanToMoneyUnits } from '@/utils/currency'; import { formatCents, isValidMoneyInput, yuanToMoneyUnits } from '@/utils/currency';
import { createUuid } from '@/utils/randomId';
import { Button } from './Button'; import { Button } from './Button';
import { Input } from './Input'; import { Input } from './Input';
import { Modal } from './Modal'; import { Modal } from './Modal';
@@ -73,7 +74,7 @@ export function ManualRechargeDialog({ initialTargetId, lockTarget = false, onCl
try { try {
const preview = await adminApi.preflightManualRecharge({ tenantId, amountCents: yuanToMoneyUnits(amount) }); const preview = await adminApi.preflightManualRecharge({ tenantId, amountCents: yuanToMoneyUnits(amount) });
setReview(preview); setReview(preview);
setIdempotencyKey(`manual-recharge:${crypto.randomUUID()}`); setIdempotencyKey(`manual-recharge:${createUuid()}`);
} catch (failure) { } catch (failure) {
setError(failure instanceof Error ? failure.message : '人工充值资格核对失败'); setError(failure instanceof Error ? failure.message : '人工充值资格核对失败');
} finally { } finally {
+2 -1
View File
@@ -1,6 +1,7 @@
import { useState, type ReactNode } from 'react'; import { useState, type ReactNode } from 'react';
import { AlertTriangle, Check, ShieldCheck } from 'lucide-react'; import { AlertTriangle, Check, ShieldCheck } from 'lucide-react';
import { adminApi, type ReviewDecisionResult, type ReviewPreflight } from '@/api/adminApi'; import { adminApi, type ReviewDecisionResult, type ReviewPreflight } from '@/api/adminApi';
import { createUuid } from '@/utils/randomId';
import { Button } from './Button'; import { Button } from './Button';
import { Modal } from './Modal'; import { Modal } from './Modal';
@@ -28,7 +29,7 @@ export function RiskAction({
const [idempotencyKey, setIdempotencyKey] = useState(''); const [idempotencyKey, setIdempotencyKey] = useState('');
async function begin() { async function begin() {
const key = `review:${targetType}:${targetId}:${crypto.randomUUID()}`; const key = `review:${targetType}:${targetId}:${createUuid()}`;
setOpen(true); setOpen(true);
setLoading(true); setLoading(true);
setResult(null); setResult(null);
+5 -4
View File
@@ -7182,7 +7182,7 @@ h3 {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(4, 1fr);
min-width: 260px; min-width: 260px;
padding: var(--space-4); padding: var(--space-4);
} }
@@ -11443,11 +11443,12 @@ h3 {
} }
.channel-report-row > :nth-child(3)::before { color: var(--color-text-muted); content: "报备状态"; font-size: var(--font-size-xs); } .channel-report-row > :nth-child(3)::before { color: var(--color-text-muted); content: "报备状态"; font-size: var(--font-size-xs); }
.channel-report-row > :nth-child(4)::before { color: var(--color-text-muted); content: "提交时间"; font-size: var(--font-size-xs); } .channel-report-row > :nth-child(4)::before { color: var(--color-text-muted); content: "提交报备时间"; font-size: var(--font-size-xs); }
.channel-report-row > :nth-child(5)::before { color: var(--color-text-muted); content: "报备时间"; font-size: var(--font-size-xs); } .channel-report-row > :nth-child(5)::before { color: var(--color-text-muted); content: "报备成功时间"; font-size: var(--font-size-xs); }
.channel-report-row > :nth-child(6)::before { color: var(--color-text-muted); content: "最后更新"; font-size: var(--font-size-xs); } .channel-report-row > :nth-child(6)::before { color: var(--color-text-muted); content: "上次发送成功"; font-size: var(--font-size-xs); }
.channel-report-stats { .channel-report-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
min-width: 0; min-width: 0;
width: 100%; width: 100%;
} }
+32
View File
@@ -0,0 +1,32 @@
export function createUuid() {
const webCrypto = globalThis.crypto;
if (typeof webCrypto?.randomUUID === 'function') {
return webCrypto.randomUUID();
}
const bytes = createRandomBytes(16);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0'));
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}`;
}
export function createRandomHex(length: number) {
const normalizedLength = Math.max(0, Math.floor(length));
return Array.from(createRandomBytes(Math.ceil(normalizedLength / 2)), (value) => value.toString(16).padStart(2, '0'))
.join('')
.slice(0, normalizedLength);
}
function createRandomBytes(length: number) {
const bytes = new Uint8Array(length);
const webCrypto = globalThis.crypto;
if (typeof webCrypto?.getRandomValues === 'function') {
webCrypto.getRandomValues(bytes);
return bytes;
}
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Math.floor(Math.random() * 256);
}
return bytes;
}
+19 -2
View File
@@ -1,4 +1,7 @@
const LEADING_SMS_SIGNATURE = /^【[^】]+】/; const LEADING_SMS_SIGNATURE = /^【[^】]+】/;
const FORBIDDEN_SMS_SIGNATURE_CHARACTER = /[\p{White_Space}\p{Cc}\p{Default_Ignorable_Code_Point}]/u;
export const SMS_SIGNATURE_CHARACTER_ERROR = '短信签名不能包含空格、换行或不可见字符';
export const SMS_SIGNATURE_FORMAT_ERROR = '必须填写完整中文黑括号签名,例如:【某某科技】';
export function formatSmsSignature(name?: string | null) { export function formatSmsSignature(name?: string | null) {
const innerName = (name ?? '').trim().replace(/^[【\[]+|[】\]]+$/g, '').trim(); const innerName = (name ?? '').trim().replace(/^[【\[]+|[】\]]+$/g, '').trim();
@@ -6,9 +9,23 @@ export function formatSmsSignature(name?: string | null) {
} }
export function isCompleteSmsSignature(name?: string | null) { export function isCompleteSmsSignature(name?: string | null) {
const value = (name ?? '').trim(); return getSmsSignatureValidationError(name) === undefined;
}
export function hasForbiddenSmsSignatureCharacter(name?: string | null) {
return FORBIDDEN_SMS_SIGNATURE_CHARACTER.test(name ?? '');
}
export function getSmsSignatureValidationError(name?: string | null) {
const value = name ?? '';
if (!value) {
return SMS_SIGNATURE_FORMAT_ERROR;
}
if (hasForbiddenSmsSignatureCharacter(value)) {
return SMS_SIGNATURE_CHARACTER_ERROR;
}
const match = value.match(/^【([^【】]+)】$/); const match = value.match(/^【([^【】]+)】$/);
return Boolean(match && match[1] === match[1].trim()); return match ? undefined : SMS_SIGNATURE_FORMAT_ERROR;
} }
export function replaceLeadingSmsSignature(content: string, signatureName?: string | null) { export function replaceLeadingSmsSignature(content: string, signatureName?: string | null) {