From 5328bb09bf89170b4368407e896dbce9527a7b5c Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Fri, 28 Aug 2026 17:58:45 +0800 Subject: [PATCH] fix: accept scheme-less drainage URLs --- api/src/common/client-write.dto.spec.ts | 3 +++ api/src/common/client-write.dto.ts | 5 +++-- api/src/common/drainage-target.ts | 8 ++++++++ .../report-materials/official-export.service.ts | 2 +- api/src/sms-config/drainage.service.ts | 9 +++------ api/src/sms-config/sms-config.service.spec.ts | 14 ++++++++++++++ docs/first-version-development-requirements.md | 2 +- docs/system-functional-test-cases.md | 2 +- docs/testing-progress.md | 13 +++++++++++++ .../enterprise-signatures/DrainageFormModal.tsx | 3 ++- src/apps/client/ClientSignaturesPage.tsx | 2 +- 11 files changed, 50 insertions(+), 13 deletions(-) create mode 100644 api/src/common/drainage-target.ts diff --git a/api/src/common/client-write.dto.spec.ts b/api/src/common/client-write.dto.spec.ts index 1ee5126..dad644c 100644 --- a/api/src/common/client-write.dto.spec.ts +++ b/api/src/common/client-write.dto.spec.ts @@ -48,6 +48,9 @@ describe('strict client write DTOs', () => { it.each([ 'https://example.com/path', + 'example.com/path?source=sms', + 'www.example.com', + '192.0.2.10:8080/landing', '13800138000', '+86 138-0013-8000', '0755-12345678', diff --git a/api/src/common/client-write.dto.ts b/api/src/common/client-write.dto.ts index 30237d2..d1d8a4f 100644 --- a/api/src/common/client-write.dto.ts +++ b/api/src/common/client-write.dto.ts @@ -19,6 +19,7 @@ import { ValidateNested, } from 'class-validator'; import { IsBoundedJsonObject } from './bounded-json-object.validator'; +import { DRAINAGE_TARGET_ERROR, DRAINAGE_TARGET_PATTERN } from './drainage-target'; export class ClientCertificationSubmissionDto { @IsString() @MinLength(1) @MaxLength(200) companyName!: string; @@ -105,8 +106,8 @@ export class ClientDrainageInfoDto { @IsString() @MinLength(1) @MaxLength(2048) - @Matches(/^(?:https?:\/\/\S+|(?:\+?86[\s-]?)?1(?:[\s-]?\d){10}|(?:\+?86[\s-]?)?(?:\(?0\d{2,3}\)?[\s-]?)?\d{7,8}(?:[\s-]?(?:转|ext\.?)?[\s-]?\d{1,6})?)$/i, { - message: '引流信息必须是 http/https URL、手机号码或固定电话号码', + @Matches(DRAINAGE_TARGET_PATTERN, { + message: DRAINAGE_TARGET_ERROR, }) url!: string; @IsOptional() @IsString() @MaxLength(1000) remark?: string; diff --git a/api/src/common/drainage-target.ts b/api/src/common/drainage-target.ts new file mode 100644 index 0000000..7dfec2d --- /dev/null +++ b/api/src/common/drainage-target.ts @@ -0,0 +1,8 @@ +export const DRAINAGE_TARGET_PATTERN = /^(?:(?:https?:\/\/)?(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})|(?:\d{1,3}\.){3}\d{1,3})(?::\d{1,5})?(?:[/?#]\S*)?|(?:\+?86[\s-]?)?1(?:[\s-]?\d){10}|(?:\+?86[\s-]?)?(?:\(?0\d{2,3}\)?[\s-]?)?\d{7,8}(?:[\s-]?(?:转|ext\.?)?[\s-]?\d{1,6})?)$/i; + +export const DRAINAGE_TARGET_ERROR = '引流信息必须是 URL(可不带协议)、手机号码或固定电话号码'; + +export function normalizeDrainageTarget(value?: string) { + const target = value?.trim() ?? ''; + return target && DRAINAGE_TARGET_PATTERN.test(target) ? target : undefined; +} diff --git a/api/src/report-materials/official-export.service.ts b/api/src/report-materials/official-export.service.ts index a415376..53667ad 100644 --- a/api/src/report-materials/official-export.service.ts +++ b/api/src/report-materials/official-export.service.ts @@ -24,7 +24,7 @@ export class ReportOfficialExportService { sheet.addRow(headers); sheet.addRow(reportType === 'signature' ? ['示例签名', '验证码通知', '请在本单元格插入图片', '请在本单元格插入图片', '示例行,导入前请删除'] - : ['示例签名', 'https://example.com 或 13800138000', '示例行,导入前请删除', '请在本单元格插入图片']); + : ['示例签名', 'example.com/path 或 13800138000', '示例行,导入前请删除', '请在本单元格插入图片']); styleHeader(sheet.getRow(1)); sheet.columns.forEach((column) => { column.width = 24; }); sheet.getRow(2).height = 48; diff --git a/api/src/sms-config/drainage.service.ts b/api/src/sms-config/drainage.service.ts index 5b7eb2f..a21a2e0 100644 --- a/api/src/sms-config/drainage.service.ts +++ b/api/src/sms-config/drainage.service.ts @@ -10,14 +10,11 @@ import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL import { SmsReportValidationService } from './report-validation.service'; import { SmsAuditService } from './audit.service'; import { shanghaiDateRange } from '../common/shanghai-date-range'; - -const DRAINAGE_TARGET_PATTERN = /^(?:https?:\/\/\S+|(?:\+?86[\s-]?)?1(?:[\s-]?\d){10}|(?:\+?86[\s-]?)?(?:\(?0\d{2,3}\)?[\s-]?)?\d{7,8}(?:[\s-]?(?:转|ext\.?)?[\s-]?\d{1,6})?)$/i; +import { DRAINAGE_TARGET_ERROR, normalizeDrainageTarget as readDrainageTarget } from '../common/drainage-target'; function normalizeDrainageTarget(value?: string) { - const target = value?.trim() ?? ''; - if (!target || !DRAINAGE_TARGET_PATTERN.test(target)) { - throw new BadRequestException('引流信息必须是 http/https URL、手机号码或固定电话号码'); - } + const target = readDrainageTarget(value); + if (!target) throw new BadRequestException(DRAINAGE_TARGET_ERROR); return target; } diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index 2237da0..542fe1d 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -1024,6 +1024,20 @@ describe('SmsConfigService', () => { expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled(); }); + it('accepts a scheme-less drainage URL and synchronizes the compatibility name', async () => { + const prisma = createPrismaMock(); + prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'approved' }); + prisma.channelRouteRule.findMany.mockResolvedValue([]); + const service = new SmsConfigService(prisma as never); + + await expect(service.createDrainageInfo('sig-1', { url: 'example.com/path', reportValues: {} }, {}, 'tenant-1')) + .resolves.toEqual(expect.objectContaining({ id: 'drainage-1', auditStatus: 'pending' })); + + expect(prisma.smsDrainageInfo.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ siteName: 'example.com/path', url: 'example.com/path' }), + })); + }); + it('resets an approved signature to pending when key content is changed', async () => { const prisma = createPrismaMock(); prisma.smsSignature.findUnique.mockResolvedValue({ diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 5cf3c01..7c551f7 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -2183,6 +2183,6 @@ ## 2026-08-28 签名与引流三网状态及引流字段统一 - 客户端“签名与引流信息”列表将移动、联通、电信的真实报备可用性集中展示为同一组三网状态单元;每个单元同时显示运营商名称、状态指示和“报备通过/暂不可用”,签名与关联引流信息使用一致布局。状态继续来自应用当前路由、通道运营商范围和真实报备任务汇总,不新增静态兜底或前端推测。 -- 引流信息的业务主字段统一为“引流 URL 或号码”,支持 `http/https` URL、手机号码和固定电话号码。客户端新增/修改弹窗不再显示或要求“名称”,原“访问地址”统一改名为“引流 URL 或号码”。 +- 引流信息的业务主字段统一为“引流 URL 或号码”,支持带 `http/https` 协议或不带协议的域名/URL、手机号码和固定电话号码。客户端新增/修改弹窗不再显示或要求“名称”,原“访问地址”统一改名为“引流 URL 或号码”。 - API 和服务端必须真实接受上述三种内容并拒绝无效任意文本。数据库既有 `SmsDrainageInfo.siteName` 仅作为兼容列保留,新建或修改时由后端同步写入规范化后的 URL/号码;客户端不得继续提交或依赖独立名称。 - 运营端单条审核列表、审核详情、导入审核、报备任务和报备记录统一只展示 URL/号码,不再把兼容列作为独立名称。批量导入映射和官方模板去掉“站点名称”必填列,只要求所属短信签名及“引流 URL 或号码”。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index cb1857e..8643ef7 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -4959,7 +4959,7 @@ npm run verify:phase8 | --- | --- | --- | | TC-DRAINAGE-UI-001 | 客户端查看签名及展开后的引流信息列表 | 移动、联通、电信在同一组三网状态单元中清晰展示;每项含运营商名称和真实“报备通过/暂不可用”状态,签名与引流行布局一致 | | TC-DRAINAGE-UI-002 | 客户端打开新增或修改引流信息弹窗 | 仅有必填“引流 URL 或号码”,不存在“名称”和“访问地址”;提示明确支持 URL、手机号码和固定电话号码 | -| TC-DRAINAGE-UI-003 | 分别提交 `https://example.com/path`、`13800138000`、`0755-12345678` | 三种输入均通过真实客户端 API 和服务端校验,写入 PostgreSQL;兼容 `siteName` 列与 `url` 列同步保存相同目标值,进入真实待审核流程 | +| TC-DRAINAGE-UI-003 | 分别提交 `https://example.com/path`、`example.com/path`、`www.example.com`、`13800138000`、`0755-12345678` | 带协议URL、不带协议URL、手机和固话均通过真实客户端 API 和服务端校验,写入 PostgreSQL;兼容 `siteName` 列与 `url` 列同步保存相同目标值,进入真实待审核流程 | | TC-DRAINAGE-UI-004 | 提交普通文字或空值 | 前端禁止空值提交;绕过前端提交普通文字时后端返回受控参数错误,不写数据库、不创建审核记录 | | TC-DRAINAGE-UI-005 | 运营端查看单条审核列表、详情、报备任务及报备记录 | 统一显示“引流 URL 或号码”及真实目标值,不再显示独立站点名称或旧“引流地址”标签;审核通过/驳回仍调用原真实 API | | TC-DRAINAGE-UI-006 | 下载引流官方导入模板并配置导入映射 | 模板和映射仅要求“所属短信签名”“引流 URL 或号码”,不再要求“站点名称”;导入项进入真实审核批次 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index e005c89..ef82f6e 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4136,3 +4136,16 @@ git diff --check - C8只新增`docs/sendchain-service-decomposition-plan-20260828.md`,按认证、长短信、Inbox Repository/Worker、消息准备/持久化、Worker生命周期、路由、限速、Submit命令、Outbox和任务进度刷新分S0~S8实施;本轮没有修改发送链运行代码。 - 回归通过:认证相关3套26项、API全量Jest、前端34项、API/前端TypeScript、Vite生产构建、真实Redis多实例契约、ESLint、Prettier、结构质量和依赖缓解门禁。首次从仓库根目录启动Jest时误扫描`outputs/`历史副本且未加载API转换配置,随后使用`api/jest.config.cjs`明确项目范围通过;未修改受保护历史资产。 - 本轮运行代码没有变化,测试环境仍运行已验证的`226527f1cef122e4dbd163da782485e8c0254dc5`,因此没有重复建立恢复资产或形式化重新部署。未访问预生产,未写入测试环境业务数据,未发送、补发或重投短信。 + +## 2026-08-28 签名与引流三网状态及引流字段统一发布 + +- 重新验证本地基线为`main`,实施前`HEAD=origin/main=9ac41e9`;既有`docs/code-quality-reassessment-20260828-v2.md`修改、未跟踪`docs/code-quality-reassessment-20260828-v3.md`和空文件`=`均保留且未纳入提交。本轮功能提交为`c68ac7a3db82d1bdb867260378df18f93cca0fd2`,未推送远端。 +- 客户端签名与引流列表将移动、联通、电信改为同一组三网状态卡片,签名和引流行统一显示真实汇总得到的“报备通过/暂不可用”。新增/修改弹窗移除“名称”,将“访问地址”统一为“引流 URL 或号码”;URL 保持安全外链,手机和固定电话按普通文本展示。 +- 客户端和运营端不再提交独立`siteName`。后端 DTO 与领域服务真实接受`http/https` URL、手机号码和固定电话号码,拒绝空值及任意文字;既有非空兼容列由服务端同步写入同一规范化目标。单条审核、审核详情、导入审核、报备任务、报备记录、导入映射和官方模板同步移除独立站点名称口径,没有使用 mock、静态数据或 localStorage 代替产品功能。 +- 本地门禁通过:前端 Vitest 5个文件36项、TypeScript、Vite生产构建和bundle预算通过;API Jest 51套571项、TypeScript正式构建和Prisma schema校验通过;Gateway`go test ./... -count=1`与`go vet ./...`、增量Lint/Prettier、结构质量及`git diff --check`通过。专项用例覆盖URL、手机、带格式手机、固定电话、分机号、非法文字拒绝、兼容列同步和官方模板无站点名称。 +- 应用内Browser访问测试机在导航阶段连续两次超时并重置,未据此声称登录后线上页面已验收。随后用与正式发布一致的生产构建做本地API拦截视觉夹具:桌面展开态和新增弹窗、390px窄屏均渲染正常,页面级横向溢出为false,URL为`A`、固定电话为`SPAN`,弹窗不存在“名称/访问地址”,控制台事件0。夹具只用于布局回归,不作为线上业务数据证据。 +- 部署前测试机真实基线为`226527f1cef122e4dbd163da782485e8c0254dc5`,源码与数据库均95项migration,11项相关服务active,API/Gateway/Redis健康。新恢复点`/opt/cmpp-platform-backups/drainage-carrier-ui-20260828T092840Z`包含238MB PostgreSQL custom dump、211MB完整运行目录、24KB环境/systemd/Nginx配置、原部署标记、服务/Stream状态和恢复说明;`pg_restore --list`842行、运行tar 52592项、配置tar 309项,`sha256sum -c`全部成功后才开始覆盖。 +- 精确Git归档`outputs/cmpp-drainage-carrier-ui-c68ac7a-20260828T092917Z.tar.gz`为2612752字节,本地和服务器SHA-256均为`ef39ebdef18efc5937b2e4001398d4cb4667f739c1fe6b4a2206208ff280054f`且tar可读。标准部署脚本完成两套依赖安装、安全门禁、Prisma、前端/API/Gateway构建、Nginx校验和服务重启;95项migration无待执行项,旧运行目录保留为`/opt/cmpp-platform.previous-drainage-20260828T093754Z`。 +- 测试环境最终`.deployed-commit=c68ac7a3db82d1bdb867260378df18f93cca0fd2`。11项相关服务均active,API、Gateway、Gateway Callback健康,Redis PONG;`gateway.submit.commands`、`gateway.submit.results`、`gateway.protocol.logs`全部`pending=0/lag=0`,发布窗口7项核心服务error级journal均为0。 +- 线上前端资源为`index-BLUa_d1S.js`、`index-BB9q6lcg.css`、`ClientSignaturesPage-COD8bZjo.js`和`AdminDrainageAuditPage-Dg8qCDBw.js`;服务器与工作站HTTP回读SHA-256一致。首页、客户端登录、运营端登录和API健康均HTTP 200。真实测试库当前未删除引流信息为0,因此未创建业务记录模拟页面数据,也未执行审核写操作;未发送、补发或重投短信,未修改余额、通道或客户配置。 +- 仅部署测试环境`100.93.204.60`;预生产`8.160.169.106`未访问、未部署、未覆盖、未回退或修改。 diff --git a/src/apps/admin/enterprise-signatures/DrainageFormModal.tsx b/src/apps/admin/enterprise-signatures/DrainageFormModal.tsx index 8517120..c8873d5 100644 --- a/src/apps/admin/enterprise-signatures/DrainageFormModal.tsx +++ b/src/apps/admin/enterprise-signatures/DrainageFormModal.tsx @@ -64,8 +64,9 @@ export function DrainageFormModal({ applicationId, item, onClose, onSubmit }: {

基本信息

update('url', event.target.value)} - placeholder="请输入引流url或号码" + placeholder="例如:example.com/path 或 13800138000" required value={form.url} /> diff --git a/src/apps/client/ClientSignaturesPage.tsx b/src/apps/client/ClientSignaturesPage.tsx index e8ac03d..0554b06 100644 --- a/src/apps/client/ClientSignaturesPage.tsx +++ b/src/apps/client/ClientSignaturesPage.tsx @@ -251,7 +251,7 @@ function DrainageModal({ item, signature, onClose, onSaved }: { item?: ClientDra >
{item?.rejectReason ?
修改说明{item.rejectReason}
: null} - setUrl(event.target.value)} placeholder="例如:https://example.com 或 13800138000" required value={url} /> + setUrl(event.target.value)} placeholder="例如:example.com/path 或 13800138000" required value={url} />