fix: validate signatures and restore report metrics
This commit is contained in:
@@ -51,6 +51,7 @@ function createPrismaMock() {
|
||||
reportFields: [{ code: 'license', name: '营业执照', fieldType: 'file', required: true, description: null, sortOrder: 1, status: 'active' }],
|
||||
};
|
||||
return {
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
$transaction: jest.fn((callback) => callback({
|
||||
smsChannel: {
|
||||
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 () => {
|
||||
const prisma = createPrismaMock();
|
||||
const tx = {
|
||||
|
||||
@@ -1154,12 +1154,116 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
listReportTasks(tenantId?: string, status?: string, channelId?: string, reportType?: string) {
|
||||
return this.prisma.channelSignatureReportTask.findMany({
|
||||
async listReportTasks(tenantId?: string, status?: string, channelId?: string, reportType?: string) {
|
||||
const tasks = await this.prisma.channelSignatureReportTask.findMany({
|
||||
where: { tenantId, status, channelId, reportType },
|
||||
include: { signature: true, channel: true, drainageInfo: true },
|
||||
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) {
|
||||
@@ -2143,6 +2247,63 @@ function deriveReceiptStatus(rowCount: number, successCount: number, failedCount
|
||||
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) {
|
||||
const value = minutes ?? (hours === undefined ? fallbackMinutes : hours * 60);
|
||||
if (!Number.isInteger(value) || value <= 0 || value > 72 * 60) {
|
||||
|
||||
@@ -1071,7 +1071,7 @@ describe('SmsConfigService', () => {
|
||||
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',
|
||||
async (name) => {
|
||||
const prisma = createPrismaMock();
|
||||
@@ -1092,6 +1092,23 @@ describe('SmsConfigService', () => {
|
||||
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 () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
@@ -1990,9 +1990,12 @@ function normalizeSmsSignature(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(/^【([^【】]+)】$/);
|
||||
if (!match || match[1] !== match[1].trim()) {
|
||||
if (!match) {
|
||||
throw new BadRequestException('短信签名必须包含完整中文黑括号,例如:【某某科技】');
|
||||
}
|
||||
return value;
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
|
||||
### 4.3 签名与引流信息
|
||||
|
||||
1. 客户端和运营端新增、编辑短信签名时,签名名称必须填写完整中文黑括号格式,例如 `【某某科技】`;缺少括号、英文方括号、重复括号、空括号或括号外附加文本均不得提交。NestJS API 必须执行同样校验并以完整格式写入 PostgreSQL,不能只依赖前端按钮状态。
|
||||
1. 客户端和运营端新增、编辑短信签名时,签名名称必须填写完整中文黑括号格式,例如 `【某某科技】`;输入过程中禁止录入普通空格、换行、制表符、不换行空格、零宽字符、BOM、变体选择符等空白或不可见字符,非法按键/粘贴不得进入受控输入值并需立即给出错误提示。缺少括号、英文方括号、重复括号、空括号或括号外附加文本均不得提交。NestJS API 必须执行同样的字符与格式校验并以完整格式写入 PostgreSQL,不能只依赖前端按钮状态。
|
||||
2. 运营端企业签名管理查看签名资料。
|
||||
3. 签名需完成企业内部审核和通道报备,状态包括草稿、待审核、已通过、已驳回、报备中、报备通过、报备失败。
|
||||
4. 已通过且报备通过的签名才允许发送。
|
||||
@@ -488,6 +488,7 @@
|
||||
- 支持查看通道成功率、未知率、失败率、累计发送量。
|
||||
- 通道列表的通道成本以“分”为单位展示并保留两位小数,例如 `3.00 分`;展示格式不改变 PostgreSQL 中的真实成本单价及计费快照。
|
||||
- 支持进入通道报备详情。
|
||||
- 通道报备详情必须按签名任务和引流信息任务展示真实“提交报备时间、报备成功时间、上次发送成功时间、今日发送”数据。“今日发送”至少拆分成功、未知、回执失败、提交失败四项并同时显示数量和比例;提交被拒绝或超时单独计入提交失败,不得混入已接受短信的回执失败率。签名任务汇总该签名在当前通道下的全部引流信息,具体引流任务只统计自身;上次发送成功时间取同一统计范围内最近一条最终成功短信,不得使用报备更新时间或静态演示值替代。
|
||||
- 通道报备详情页中,签名下的引流信息默认收起,用户点击后展开;展开/收起只影响页面展示,不改变报备数据。
|
||||
- 通道列表状态区域展示“连接日志”入口;点击后弹窗展示真实连接日志,包括连接请求、连接成功、断开、心跳、重连、异常等事件,日志来源于 Gateway 回写或 OperationLog。通道连接状态、连接数和最近错误必须来自 Gateway 真实上游连接池回写,不能以一次性探测拨号成功代替长连接在线状态。
|
||||
- 通道操作按钮应保持一致的两列布局,报备详情、编辑、复制、发送测试、启停、删除等操作文案清晰。
|
||||
|
||||
@@ -120,6 +120,20 @@
|
||||
- 合法名称以完整格式写入PostgreSQL;新增、编辑和所有展示位置均为`【测试签名】`。
|
||||
- 模板与发送预览只包含一层签名,不出现`【【测试签名】】`。
|
||||
|
||||
### TC-CLIENT-003C 签名空白与不可见字符前后端强制校验
|
||||
|
||||
- 优先级:P0
|
||||
- 前置条件:存在可创建、编辑签名的企业和应用。
|
||||
- 步骤:
|
||||
1. 分别在客户端和运营端新增、编辑签名,尝试键入或粘贴包含普通空格、换行、制表符、不换行空格、零宽空格、BOM和变体选择符的名称。
|
||||
2. 观察受控输入框、错误提示和提交按钮。
|
||||
3. 绕过页面直接调用真实新增、编辑API提交相同非法名称。
|
||||
4. 输入不含空白或不可见字符的合法名称`【测试签名】`并保存。
|
||||
- 预期结果:
|
||||
- 非法字符不进入客户端或运营端受控输入值,页面立即提示且不能提交;已有合法输入不因一次非法粘贴被覆盖。
|
||||
- 直接调用API返回400及可行动错误,不写入或更新`SmsSignature`,不能通过前端绕过。
|
||||
- 合法名称可正常保存,刷新后仍来自真实PostgreSQL记录。
|
||||
|
||||
### TC-CLIENT-003A 签名与引流信息工作台及通道信息隔离
|
||||
|
||||
- 优先级:P0
|
||||
@@ -3907,3 +3921,12 @@ npm run verify:phase8
|
||||
| TC-BILLING-ATOMIC-005 | 同一企业同时发生扣费、退款和充值 | 账户级事务锁串行化余额变更,使用数据库原子增量;每条流水`balanceAfter`连续且最终余额与流水一致 |
|
||||
| TC-DOWNSTREAM-IDEM-006 | 同一短信终态被重复处理,企业同时启用CMPP和HTTP | CMPP只有一条`CmppDownstreamDelivery`且只发送一次;HTTP只有一个稳定事件和一条端点投递 |
|
||||
| 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 | 报备任务先提交、后由报备记录变为通过 | 列表和详情分别显示真实提交报备时间、最近一次报备成功时间、上次发送成功时间和今日统计;刷新后数据保持一致 |
|
||||
|
||||
@@ -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`。
|
||||
- 标准部署脚本成功应用`20260726223000_prevent_duplicate_retry_side_effects`,预发布73条migration齐全;三个目标字段、唯一索引及补发自关联外键均已落库,`.deployed-commit=e0f6eed0d40e21fb06166e28dd54fefa7d0f09b0`。API、Gateway、Nginx、PostgreSQL、MinIO均active,Redis`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`账务流水,证明发布未删除、重写或自动冲正历史证据;本轮未发送任何真实测试短信。
|
||||
|
||||
## 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/`及空文件`=`属于既有构建/临时产物,不纳入源码提交。
|
||||
|
||||
@@ -923,6 +923,19 @@ export type ReportTask = DictionaryItem & {
|
||||
signature?: { id: string; name: string; purpose?: string | null; drainageInfo?: Record<string, unknown> | null };
|
||||
drainageInfo?: SmsDrainageInfo | null;
|
||||
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 & {
|
||||
|
||||
@@ -46,7 +46,26 @@ function ReportStatus({ value }: { value?: string }) {
|
||||
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 profile = asRecord(payload.signatureProfile);
|
||||
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>
|
||||
<p><span>企业</span><span>{signature?.tenant?.name ?? task.tenantId}</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(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>)}
|
||||
<section><h3>今日发送</h3><DeliveryStats task={task} /></section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
@@ -75,7 +98,7 @@ export function AdminChannelReportPage() {
|
||||
const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
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 [nextStatus, setNextStatus] = useState('approved');
|
||||
const [statusReason, setStatusReason] = useState('');
|
||||
@@ -111,8 +134,8 @@ export function AdminChannelReportPage() {
|
||||
return matchesKeyword && (status === 'all' || task.status === status);
|
||||
}), [keyword, signatureMap, status, tasks]);
|
||||
|
||||
function lastRecord(taskId: string, action: string) {
|
||||
return records.find((record) => record.taskId === taskId && record.action === action);
|
||||
function approvedRecord(taskId: string) {
|
||||
return records.find((record) => record.taskId === taskId && record.statusAfter === 'approved');
|
||||
}
|
||||
|
||||
async function saveFieldMapping(nextFields: Parameters<typeof adminApi.replaceChannelReportFields>[2]) {
|
||||
@@ -154,21 +177,20 @@ export function AdminChannelReportPage() {
|
||||
</div>
|
||||
|
||||
<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) => {
|
||||
const signature = signatureMap.get(task.signatureId);
|
||||
const drainage = task.reportType === 'drainage' ? drainageItems(signature).find((item) => String(item.id) === task.drainageItemId) : undefined;
|
||||
const exported = lastRecord(task.id, 'export');
|
||||
const imported = lastRecord(task.id, 'receipt_import');
|
||||
const reportedAt = approvedRecord(task.id)?.createdAt;
|
||||
return <div className={`channel-report-row ${drainage ? 'channel-report-row--drainage' : 'channel-report-row--signature'}`} key={task.id}>
|
||||
<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>
|
||||
<ReportStatus value={task.status} />
|
||||
<DateTime value={drainage?.submittedAt ?? task.createdAt} />
|
||||
<DateTime value={imported?.createdAt ?? exported?.createdAt} />
|
||||
<DateTime value={task.updatedAt} />
|
||||
<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>
|
||||
<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>
|
||||
<DateTime value={reportedAt} />
|
||||
<DateTime value={task.lastSuccessfulSentAt} />
|
||||
<DeliveryStats task={task} />
|
||||
<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>
|
||||
|
||||
@@ -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 { displayFileName } from '@/utils/fileName';
|
||||
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';
|
||||
|
||||
@@ -310,6 +310,7 @@ function SignatureFormModal({
|
||||
reportValues: payload?.signatureReportValues ?? {},
|
||||
});
|
||||
const [reportFields, setReportFields] = useState<ApplicationReportField[]>([]);
|
||||
const [nameInputError, setNameInputError] = useState('');
|
||||
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -327,12 +328,15 @@ function SignatureFormModal({
|
||||
setForm((current) => ({ ...current, reportValues: { ...current.reportValues, [code]: value } }));
|
||||
}
|
||||
|
||||
const signatureNameValid = !nameInputError && isCompleteSmsSignature(form.name);
|
||||
const signatureNameError = nameInputError || (form.name ? getSmsSignatureValidationError(form.name) : undefined);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<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}
|
||||
@@ -374,10 +378,18 @@ function SignatureFormModal({
|
||||
value={form.applicationId}
|
||||
/>
|
||||
<Input
|
||||
error={form.name.trim() && !isCompleteSmsSignature(form.name) ? '必须填写完整中文黑括号签名,例如:【某某科技】' : undefined}
|
||||
hint="新增和编辑时都必须保留完整的【】"
|
||||
error={signatureNameError}
|
||||
hint="新增和编辑时必须保留完整的【】,且不能包含空格或不可见字符"
|
||||
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="请输入完整签名,例如:【某某科技】"
|
||||
required
|
||||
value={form.name}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet, Layers3, Refres
|
||||
import { adminApi, fileDownloadUrl, type ReportMaterialBatchPreflight, type ReportMaterialBatchResult, type ReportMaterialPendingItem } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Tag } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { createUuid } from '@/utils/randomId';
|
||||
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>> };
|
||||
@@ -49,7 +50,7 @@ export function AdminReportMaterialsPage() {
|
||||
const chosen = items.filter((item) => selected.has(item.id));
|
||||
if (!chosen.length) { setError('请先选择具备报备资格的资料'); return; }
|
||||
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) })); }
|
||||
catch (failure) { setError(failure instanceof Error ? failure.message : '报备资格预检失败'); }
|
||||
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 { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui';
|
||||
import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
|
||||
import { createRandomHex } from '@/utils/randomId';
|
||||
|
||||
type Carrier = 'mobile' | 'unicom' | 'telecom';
|
||||
type QueuePriority = 'normal' | 'priority';
|
||||
@@ -489,8 +490,5 @@ function parseIpAllowlist(value: string) {
|
||||
}
|
||||
|
||||
function generateApplicationPassword() {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return crypto.randomUUID().replace(/-/g, '').slice(0, 16);
|
||||
}
|
||||
return Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
|
||||
return createRandomHex(16);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type ClientSmsSignatureView,
|
||||
type FileRef,
|
||||
} from '@/api/adminApi';
|
||||
import { isCompleteSmsSignature } from '@/utils/smsSignature';
|
||||
import { getSmsSignatureValidationError, hasForbiddenSmsSignatureCharacter, isCompleteSmsSignature, SMS_SIGNATURE_CHARACTER_ERROR } from '@/utils/smsSignature';
|
||||
|
||||
const EMPTY_WORKSPACE: ClientSignatureWorkspace = {
|
||||
items: [],
|
||||
@@ -98,6 +98,7 @@ function SignatureModal({
|
||||
const [uploadingCode, setUploadingCode] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [nameInputError, setNameInputError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const request = applicationId
|
||||
@@ -122,7 +123,7 @@ function SignatureModal({
|
||||
|
||||
async function save() {
|
||||
if (!isCompleteSmsSignature(name)) {
|
||||
setError('短信签名必须包含完整中文黑括号,例如:【某某科技】');
|
||||
setError(getSmsSignatureValidationError(name) ?? '短信签名格式不正确');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
@@ -143,7 +144,8 @@ function SignatureModal({
|
||||
}
|
||||
|
||||
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
|
||||
footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!signatureNameValid || missingRequired || saving || Boolean(uploadingCode)} onClick={() => void save()}>{saving ? '提交中...' : '提交审核'}</Button></>}
|
||||
onClose={onClose}
|
||||
@@ -160,10 +162,18 @@ function SignatureModal({
|
||||
value={applicationId}
|
||||
/>
|
||||
<Input
|
||||
error={name.trim() && !signatureNameValid ? '必须填写完整中文黑括号签名,例如:【某某科技】' : undefined}
|
||||
hint="新增和编辑时都必须保留完整的【】"
|
||||
error={signatureNameError}
|
||||
hint="新增和编辑时必须保留完整的【】,且不能包含空格或不可见字符"
|
||||
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="请输入完整签名,例如:【某某科技】"
|
||||
required
|
||||
value={name}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { AlertTriangle, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import { adminApi, clientApi, type DeletionPreflight, type DeletionResult, type DeletionTargetType } from '@/api/adminApi';
|
||||
import { createUuid } from '@/utils/randomId';
|
||||
import { Button } from './Button';
|
||||
import { Modal } from './Modal';
|
||||
import { Textarea } from './Textarea';
|
||||
@@ -24,7 +25,7 @@ export function DeleteRiskAction({ portal, targetType, targetId, children = '删
|
||||
const [idempotencyKey, setIdempotencyKey] = useState('');
|
||||
|
||||
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);
|
||||
try {
|
||||
const data = portal === 'admin'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { adminApi, type ManualRechargePreflight, type ManualRechargeResult } from '@/api/adminApi';
|
||||
import { formatCents, isValidMoneyInput, yuanToMoneyUnits } from '@/utils/currency';
|
||||
import { createUuid } from '@/utils/randomId';
|
||||
import { Button } from './Button';
|
||||
import { Input } from './Input';
|
||||
import { Modal } from './Modal';
|
||||
@@ -73,7 +74,7 @@ export function ManualRechargeDialog({ initialTargetId, lockTarget = false, onCl
|
||||
try {
|
||||
const preview = await adminApi.preflightManualRecharge({ tenantId, amountCents: yuanToMoneyUnits(amount) });
|
||||
setReview(preview);
|
||||
setIdempotencyKey(`manual-recharge:${crypto.randomUUID()}`);
|
||||
setIdempotencyKey(`manual-recharge:${createUuid()}`);
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '人工充值资格核对失败');
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { AlertTriangle, Check, ShieldCheck } from 'lucide-react';
|
||||
import { adminApi, type ReviewDecisionResult, type ReviewPreflight } from '@/api/adminApi';
|
||||
import { createUuid } from '@/utils/randomId';
|
||||
import { Button } from './Button';
|
||||
import { Modal } from './Modal';
|
||||
|
||||
@@ -28,7 +29,7 @@ export function RiskAction({
|
||||
const [idempotencyKey, setIdempotencyKey] = useState('');
|
||||
|
||||
async function begin() {
|
||||
const key = `review:${targetType}:${targetId}:${crypto.randomUUID()}`;
|
||||
const key = `review:${targetType}:${targetId}:${createUuid()}`;
|
||||
setOpen(true);
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
|
||||
@@ -7182,7 +7182,7 @@ h3 {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
min-width: 260px;
|
||||
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(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(6)::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(6)::before { color: var(--color-text-muted); content: "上次发送成功"; font-size: var(--font-size-xs); }
|
||||
|
||||
.channel-report-stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
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) {
|
||||
const innerName = (name ?? '').trim().replace(/^[【\[]+|[】\]]+$/g, '').trim();
|
||||
@@ -6,9 +9,23 @@ export function formatSmsSignature(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(/^【([^【】]+)】$/);
|
||||
return Boolean(match && match[1] === match[1].trim());
|
||||
return match ? undefined : SMS_SIGNATURE_FORMAT_ERROR;
|
||||
}
|
||||
|
||||
export function replaceLeadingSmsSignature(content: string, signatureName?: string | null) {
|
||||
|
||||
Reference in New Issue
Block a user