test: close remaining quality verification gaps

This commit is contained in:
hectorzhao
2026-08-28 16:27:00 +08:00
parent 3834d67a30
commit 9ac41e9308
8 changed files with 602 additions and 1 deletions
+19
View File
@@ -37,6 +37,7 @@ const redis = {
jest.mock('ioredis', () => jest.fn(() => redis)); jest.mock('ioredis', () => jest.fn(() => redis));
import { SessionService } from './session.service'; import { SessionService } from './session.service';
import { ServiceUnavailableException } from '@nestjs/common';
describe('SessionService', () => { describe('SessionService', () => {
let now = 1_700_000_000_000; let now = 1_700_000_000_000;
@@ -175,4 +176,22 @@ describe('SessionService', () => {
await service.clearAnonymousLoginFailures('user@example.com', '203.0.113.10'); await service.clearAnonymousLoginFailures('user@example.com', '203.0.113.10');
await expect(service.anonymousLoginLockScope('user@example.com', '203.0.113.10')).resolves.toBeNull(); await expect(service.anonymousLoginLockScope('user@example.com', '203.0.113.10')).resolves.toBeNull();
}); });
it('fails closed when Redis cannot enforce captcha or login protection', async () => {
const service = new SessionService();
redis.eval.mockRejectedValueOnce(new Error('redis unavailable'));
await expect(service.assertCaptchaRequestAllowed('203.0.113.20')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
redis.mget.mockRejectedValueOnce(new Error('redis unavailable'));
await expect(service.anonymousLoginLockScope('user@example.com', '203.0.113.20')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
redis.eval.mockRejectedValueOnce(new Error('redis unavailable'));
await expect(service.recordAnonymousLoginFailure('user@example.com', '203.0.113.20')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
}); });
@@ -0,0 +1,205 @@
# CMPP 平台登录后浏览器验收清单
- 验收环境:测试环境 `http://100.93.204.60:12026`
- 客户端入口:`/#/client/login`
- 运营端入口:`/#/admin/login`
- 禁止范围:不得使用预生产;不得发送、补发或重投短信;不得充值、改余额、改通道账号或启停状态
- 建议浏览器:Edge 或 Chrome 无痕窗口,桌面视口优先使用 1440×900;移动端补测 390×844
## 1. 证据要求
每个失败项至少记录:入口 URL、账号类型、操作步骤、实际结果、期望结果、时间、截图、控制台错误和失败请求。每个通过项至少保留一张能证明最终状态的截图。
验收前打开开发者工具:
1.`F12`
2. 在 Network 勾选 `Preserve log`,清空旧请求。
3. 在 Console 清空旧日志。
4. Network 筛选 `Fetch/XHR`,不要只看页面文字。
统一通过标准:
- 页面不是空白页,没有 Vite/React 错误遮罩。
- Console 没有新的 errorwarning 必须能解释且与本次操作无关。
- 业务接口不能出现未处理的 500、HTML 错误页或裸 `Internal server error`
- API 请求路径必须是 `/api/client/**``/api/admin/**` 对应门户,状态码与页面结果一致。
- 刷新后状态与刷新前一致,不依赖只存在于页面内存的假数据。
## 2. 客户端登录与会话
### CL-AUTH-01 正常登录
1. 打开客户端登录页。
2. 输入客户端账号、密码和当前图形验证码。
3. 点击“登录”。
4. 确认进入客户端首页,而不是停留在登录页或白屏。
5. 在 Network 找到登录请求,确认返回 2xx;后续 `/api/client/**` 初始化请求返回 2xx。
通过标准:页面进入客户端工作台,企业名称和当前账号正确;Console 无 error。
### CL-AUTH-02 刷新与深层链接
1. 在已登录状态按 `Ctrl+R`
2. 直接在地址栏打开账户余额、短信应用、批量任务、短信详情、上行短信各一个深层路由。
3. 每个路由再刷新一次。
通过标准:刷新后仍保持登录;页面正常恢复数据;不先闪现其他企业数据;不错误跳回运营端或登录页。
### CL-AUTH-03 会话隔离
1. 保持客户端已登录,新开标签访问 `/#/admin/...` 任一运营端深层地址。
2. 不输入运营端账号。
通过标准:客户端 Cookie 不能直接获得运营端权限,应进入运营端登录页或返回明确的未授权状态。
## 3. 客户端租户边界(C2
### CL-TENANT-01 请求不携带客户端租户头
1. 已登录客户端后,在 Network 打开任意 `/api/client/**` 请求。
2. 查看 Request Headers。
通过标准:请求中没有 `x-tenant-id`;页面仍能正常显示当前企业的数据。
### CL-TENANT-02 localStorage 不参与授权
1. 在 Application → Local Storage 中记录现有内容。
2. 若存在历史 `tenantId` 字段,先截图,再删除该字段;没有则不新增。
3. 刷新当前客户端页面。
通过标准:当前企业仍由登录会话识别,页面不出现默认租户导致的 403,也不切换到其他企业。
### CL-TENANT-03 资源归属抽查
1. 打开短信应用、签名、模板、账户余额和用户管理。
2. 核对企业名称、应用、签名和余额均属于当前账号企业。
通过标准:不出现其他企业名称、应用、手机号、签名或余额。
> 安全接口矩阵中的“伪造其他租户头返回 403”已由自动化和真实 API 记录覆盖,人工浏览器验收不要求修改请求后重放。
## 4. 客户端核心页面
### CL-PAGE-01 首页与账户余额
1. 打开客户端首页,检查所有金额数字。
2. 打开账户余额页。
3. 检查标题是否为图标+页面标题,顶部不应重复出现单独“账户”标题。
4. 检查金额字体、颜色和字重。
通过标准:金额使用普通大号黑色字体;首页金额与“今日发送”视觉一致;账户状态不显示“余额水位”。
### CL-PAGE-02 短信应用
1. 打开短信应用列表和一个应用详情。
2. 对照运营端同一应用的 CMPP 连接状态。
通过标准:两端连接状态一致;页面没有 `Internal server error`
### CL-PAGE-03 接口对接
1. 打开接口对接页。
2. 依次查看配置、HTTP 凭据、Webhook、请求日志和投递日志。
通过标准:五个区域均能加载真实接口结果;空数据使用中文空态;不能显示裸 `Internal server error`
### CL-PAGE-04 签名与引流信息
1. 打开签名列表及详情。
2. 查看引流信息列表及详情。
通过标准:不显示“已提交资料”和“使用场景”;不显示笼统“是否审核”;只显示移动、联通、电信三网是否可用;运营端“部分通过”和“全部通过”在客户端均显示“报备通过”。
### CL-PAGE-05 时间默认值
分别打开:批量任务、短信发送详情、上行短信。
通过标准:首次进入时开始/结束时间默认为近7天;刷新后仍按近7天发起请求;列表结果与筛选时间一致。
### CL-PAGE-06 发送状态中文化
1. 打开短信发送详情。
2. 抽查成功、失败、处理中、未知等不同状态。
通过标准:页面不显示 `delivered``failed``submit_queued` 等英文内部状态;未知值使用明确中文兜底,不能误映射为成功。
### CL-PAGE-07 顶部导航
通过标准:客户端右上角不存在“待审核任务”按钮。
## 5. 运营端核心页面
### AD-AUTH-01 正常登录、刷新和深层链接
使用运营端账号登录,重复客户端的正常登录、刷新和深层链接检查。
通过标准:运营端会话独立,刷新不丢失;客户端 Cookie 不能替代运营端会话。
### AD-PAGE-01 签名质量通道搜索
1. 打开签名质量检测页面。
2. 在通道搜索输入一个已知通道名称或编号。
3. 清空条件后重复一次。
通过标准:列表请求携带通道条件,结果只保留匹配通道;清空后恢复全部;无匹配时显示正常空态。
### AD-PAGE-02 通道签名活跃度热力图搜索
执行与签名质量页相同的通道搜索。
通过标准:热力图和关联统计同步更新,不只是前端隐藏标签;清空后恢复。
### AD-PAGE-03 短信任务进度状态搜索
1. 打开短信任务进度页面。
2. 依次选择至少两个状态。
3. 清空状态。
通过标准:请求状态参数正确;表格与状态条件一致;清空后恢复全部。
### AD-PAGE-04 系统日志日期区间
1. 打开系统日志页面。
2. 检查默认开始和结束时间。
3. 改为一个更短区间并查询。
通过标准:默认近7天;请求携带正确起止时间;修改区间后数据同步变化。
## 6. 通用异常状态
### COMMON-01 慢请求与 Loading
在 Network 将速度切换为 Slow 3G,刷新一个列表页。
通过标准:显示稳定 Loading,不闪白屏、不出现旧企业数据;完成后 Loading 消失。
### COMMON-02 空结果
使用一个不可能匹配的关键字或安全的空时间区间查询。
通过标准:显示中文空态,不报错,不保留上一次列表冒充新结果。
### COMMON-03 业务错误
在不改变业务数据的前提下触发一个前端可校验错误,例如缺少必填字段。
通过标准:显示中文、可理解的字段或业务提示;不显示裸 500 文案。
### COMMON-04 移动端
使用开发者工具切换到 390×844,检查客户端首页、余额页、列表筛选和详情弹窗。
通过标准:无横向页面滚动;按钮、金额、标题不遮挡;弹窗可滚动且关闭按钮可见。
## 7. 验收结果模板
| 编号 | 结果 | 截图 | Console | 失败请求/备注 |
|---|---|---|---|---|
| CL-AUTH-01 | 通过/失败 | 文件名 | 0 error / 摘要 | 状态码与接口 |
| CL-TENANT-01 | 通过/失败 | 文件名 | 0 error / 摘要 | 是否存在 x-tenant-id |
| CL-PAGE-0107 | 通过/失败 | 文件名 | 0 error / 摘要 | 具体页面 |
| AD-AUTH-01 | 通过/失败 | 文件名 | 0 error / 摘要 | 状态码与接口 |
| AD-PAGE-0104 | 通过/失败 | 文件名 | 0 error / 摘要 | 筛选条件与结果 |
| COMMON-0104 | 通过/失败 | 文件名 | 0 error / 摘要 | 状态与视口 |
所有项目通过后,浏览器验收才可以标记为关闭;仅登录页可见或接口返回 200 不算登录后验收完成。
@@ -455,3 +455,10 @@ C1 客户端 DTO
``` ```
其中 C1~C4 可作为下一轮独立交付;C8 必须保持为单独的高风险重构任务。 其中 C1~C4 可作为下一轮独立交付;C8 必须保持为单独的高风险重构任务。
## 19. 2026-08-28 执行补充
- 文件所有者已明确要求完成其余未关闭项,并确认不接受任何 npm 或依赖降级。
- C9 继续采用既有 npm 10.9.4 基线,没有执行 npm、Node、Prisma或业务依赖降级;旧的未跟踪 `pnpm-lock.yaml` 已移除,正式安装仍使用根目录和API目录各自的 `package-lock.json``npm ci`
- C8 本轮只形成专项拆分方案 `docs/sendchain-service-decomposition-plan-20260828.md`,没有实施高风险发送链代码重构。
- 登录后页面由用户按 `docs/code-quality-browser-acceptance-checklist-20260828.md` 人工验收,结果回填前不得标记浏览器验收关闭。
@@ -0,0 +1,232 @@
# SendChain Service 职责拆分专项方案
- 方案日期:2026-08-28
- 对应整改项:`C8 SendChain Service 职责拆分`
- 当前范围:只制定方案,不在本轮实施发送链重构
- 适用环境:本地和测试环境 `100.93.204.60`
- 禁止范围:未经新授权不得部署预生产;不得用生产短信验证重构
## 1. 当前状态与问题
当前发送链已经存在一层门面 `send-submission.service.ts`,但两个实现类仍然过大:
| 文件 | 当前规模 | 集中职责 |
|---|---:|---|
| `send-inbound-entry.service.ts` | 约1852行、84.2 KiB | CMPP认证、入站持久化、长短信、Inbox claim、企业微批、配额、模板/引流匹配、风控、指标 |
| `send-gateway-submit.service.ts` | 约1159行、52.2 KiB | BullMQ Worker、批处理、路由、签名报备、限速、Submit命令、Outbox、任务进度、资源回收 |
| `send-submission.service.ts` | 约280行 | 对外门面、跨服务回调与编排 |
主要风险不是文件长度本身,而是以下行为边界在同一个类中耦合:
- PostgreSQL事务与 advisory lock 顺序。
- Inbox claim、租约、重试与恢复。
- 长短信分片幂等和完整消息恢复。
- 计费冻结、扣费、释放与失败补偿。
- 路由、签名报备、通道限速和主备策略。
- Submit Outbox 的租约、发布、确认和重复消费。
- 消息状态与批量任务进度的单飞/尾随刷新。
因此专项必须以“行为锁定后机械提取”为主,不允许借拆分类的机会同时重写SQL、事务或状态机。
## 2. 拆分目标
1. `SendSubmissionService` 只保留稳定门面和跨子域编排。
2. 每个子服务只有一个主要变化原因,建议控制在500行以内;复杂持久化服务可放宽至700行,但不得继续混合Worker生命周期、业务校验和发布协议。
3. 保持现有公开方法签名、队列名、Stream字段、幂等键、数据库状态和错误码不变。
4. 拆分过程中不改变吞吐参数、路由权重、计费口径和定时器周期。
5. 每一步均可独立回退,不要求一次性完成全部拆分。
## 3. 目标结构
建议新增以下内部服务,目录仍保留在 `api/src/send-chain/`,暂不建立更深目录,避免一次性路径迁移过大。
### 3.1 入站侧
| 目标服务 | 从现有文件提取的职责 | 明确不负责 |
|---|---|---|
| `inbound-authentication.service.ts` | `authenticateInboundApplication`、连接认证结果记录、IP白名单和接口状态检查 | 消息持久化、计费 |
| `inbound-long-message.service.ts` | 分片收集、完成判定、超时清理、已完成结果恢复 | 普通单条业务创建 |
| `inbound-inbox-repository.service.ts` | Inbox持久化、请求哈希、claim、租约续期/完成/失败 | 风控和模板匹配 |
| `inbound-workflow-worker.service.ts` | pump、企业公平分组、微批调度、停止与指标刷新 | SQL细节、消息业务规则 |
| `inbound-message-preparation.service.ts` | 应用快照、模板/签名/引流匹配、号码频控与风控输入组装 | 事务写入和队列发布 |
| `inbound-message-persistence.service.ts` | 单条/微批消息、任务、记录、配额预留和幂等持久化 | Worker生命周期、Gateway Submit |
### 3.2 出站侧
| 目标服务 | 从现有文件提取的职责 | 明确不负责 |
|---|---|---|
| `send-worker-lifecycle.service.ts` | BullMQ Worker启动/停止、并发槽位、微批flush、队列指标 | 路由、计费和命令构造 |
| `message-send-processor.service.ts` | 单条/批量消息处理、状态迁移、失败补偿编排 | Worker定时器和Outbox发布器生命周期 |
| `channel-routing.service.ts` | 运营商/省份识别、应用路由、候选通道、签名报备检查、主备选择 | 发布命令和数据库Outbox |
| `channel-rate-limit.service.ts` | 通道TPS等待和相关低基数指标 | 路由选择 |
| `gateway-submit-command.service.ts` | 命令构造、开放会话ID复用、直接Stream发布契约 | Outbox claim和业务状态变更 |
| `submit-outbox-publisher.service.ts` | Outbox claim、租约、批量发布、确认、重试和恢复 | 消息计费和路由 |
| `task-progress-refresher.service.ts` | 真实总量/状态聚合、单飞和尾随刷新 | Submit或回执业务处理 |
### 3.3 共享边界
- `send-chain.contracts.ts`:只保留稳定输入输出契约,不放实现函数。
- `send-chain.helpers.ts`:只保留无副作用纯函数;需要数据库、Redis、时间器或日志的逻辑必须进入Service。
- 计费继续通过现有 `BillingService`,不得复制金额算法。
- 所有数据库访问保持现有 Prisma 条件和事务客户端传递方式。
- 所有跨服务调用通过构造注入,不通过全局单例或循环引用。
## 4. 拆分前行为锁定
在移动代码前补齐特征测试,测试的是当前语义,不是期望中的新设计。
### 4.1 入站测试
- 认证成功、未知账号、禁用应用、未认证企业、IP不在白名单。
- 同一请求键并发两次只产生一份任务/消息/计费预留。
- 请求键相同但payload不同返回冲突。
- 长短信乱序、重复分片、缺片、超时、重启恢复。
- Inbox claim不重复、租约超时可恢复、失败次数和最终状态一致。
- 企业微批公平性、单企业失败不阻塞其他企业。
- 模板、签名、引流匹配和人工审核关联不改变。
- 配额预留不足时不产生半成品消息或费用。
### 4.2 出站测试
- 普通批量与CMPP内部任务入队语义保持不同且正确。
- Worker微批开启/关闭结果一致。
- 路由优先级、weighted分流、主备切换和运营商过滤不变。
- 签名未报备时的拒绝状态和费用释放一致。
- 通道TPS限制不会跨通道串扰。
- Submit命令字段、Sequence_Id、Msg_Id、Src_Id和幂等键保持一致。
- Outbox重复claim、租约恢复、发布成功/失败、Stream重复消费结果一致。
- 计费冻结、扣费、退款和释放只发生一次。
- 任务进度刷新保持单飞,并在运行中出现新状态时执行一次尾随刷新。
### 4.3 契约快照
拆分前固定以下非敏感契约样本:
- `gateway.submit.commands`
- `gateway.submit.results`
- `gateway.protocol.logs`
- BullMQ `send-message` job data
- Submit Outbox payload
契约测试只固定字段、类型、必填关系和幂等语义,不固定时间戳、随机ID或日志文本。
## 5. 分阶段实施顺序
### 阶段 S0:基线冻结
- 记录Git commit、两个文件行数/哈希、95项migration状态。
- 运行现有SendChain专项、API全量、Gateway全量、队列契约和性能基线。
- 在隔离数据库和Redis记录测试前快照。
出口:所有既有测试通过,所有临时数据带独立前缀且可清理。
### 阶段 S1:提取纯路由与命令构造
先提取 `channel-routing.service.ts``gateway-submit-command.service.ts`。这两部分事务耦合相对较低,最适合验证构造注入和门面调用方式。
规则:只移动代码;输入输出、查询条件、排序和错误文本保持不变。
### 阶段 S2:提取任务进度刷新与限速
提取 `task-progress-refresher.service.ts``channel-rate-limit.service.ts`,保留单飞Map、dirty尾随集合及Redis限速键格式。
出口:并发刷新和跨通道限速特征测试通过。
### 阶段 S3:提取Submit Outbox
提取 `submit-outbox-publisher.service.ts`,将定时器生命周期和claim/publish/ack逻辑封装在同一服务;不得改变租约所有者、批量大小、轮询周期或重试条件。
出口:故障注入后无丢失、无双发,Stream最终 `pending=0/lag=0`
### 阶段 S4:提取Worker生命周期和消息处理
将BullMQ启动/停止/微批flush移入 `send-worker-lifecycle.service.ts`,业务处理移入 `message-send-processor.service.ts`
出口:Worker关闭会等待当前flush;并发槽位与处理指标不变;吞吐基线不下降超过5%。
### 阶段 S5:提取入站认证和长短信
先移动认证,再移动长短信。两者均有明确入口和状态表,避免直接触碰Inbox微批主流程。
出口:认证审计字段、长短信幂等和超时恢复全部一致。
### 阶段 S6:提取Inbox Repository和Worker
先封装原SQL与事务为Repository,再将pump/企业分组/claim循环移入Worker。Repository方法必须接受现有事务客户端,不允许内部偷偷开启嵌套事务。
出口:claim锁顺序、租约、重试、企业公平性和停机等待不变。
### 阶段 S7:提取消息准备与持久化
这是入站侧风险最高的阶段,最后执行。先把纯准备结果定义为显式结构,再机械移动持久化;计费操作继续调用现有BillingService。
出口:消息、任务、计费、审核任务和Inbox状态在成功与每个失败点均与基线一致。
### 阶段 S8:清理门面
删除仅为旧类内部跳转存在的回调,`SendSubmissionService`仅保留公开API编排。清理必须单独提交,不能与任何业务优化合并。
## 6. 每阶段验证矩阵
| 层级 | 必须执行 |
|---|---|
| 静态 | API TypeScript、ESLint、Prettier、结构门禁、`git diff --check` |
| 单元 | 新子服务定向测试、SendChain既有全套特征测试 |
| API | 全量Jest及全源/增量覆盖率 |
| PostgreSQL | Prisma validate、95项migration、真实事务/锁/幂等契约 |
| Redis | BullMQ、限速键、Stream、Outbox幂等和最终pending/lag |
| Gateway | `go test ./... -count=1``go vet ./...`、五份队列契约 |
| 故障注入 | Redis短暂不可用、Gateway响应超时、DB事务失败、Worker中断恢复 |
| 性能 | 与S0同数据和同参数,入口延迟、完整Submit吞吐、DB池等待、队列积压 |
任何阶段出现以下情况立即停止并回退该阶段:
- 重复计费、重复Submit或消息丢失。
- 状态机出现无法收敛的新状态。
- advisory lock顺序或事务边界变化。
- 相同负载完整Submit吞吐下降超过5%,且无法由环境波动解释。
- Stream pending/lag、Inbox processing或Outbox leased无法清零。
## 7. 提交策略
每个阶段至少拆成两个提交:
1. `test(send-chain): lock <responsibility> behavior`
2. `refactor(send-chain): extract <responsibility> service`
禁止在重构提交中混入:
- SQL优化或索引变更。
- 状态、错误码、队列字段变更。
- TPS、批量大小、超时或重试参数调整。
- 依赖大版本升级和全仓格式化。
## 8. 测试环境发布与回退
每个可部署阶段发布前都必须重新建立并校验:PostgreSQL custom dump、运行目录、环境/systemd/Nginx、Redis RDB与Stream状态、MinIO清单、部署commit和migration状态。
建议S1~S3合并为第一个测试版本,S4为第二个,S5~S6为第三个,S7~S8为最终版本。每个版本均使用独立恢复点,禁止复用旧备份冒充当前基线。
回退只允许回退本阶段部署包与对应恢复资产;如已发生业务写入,应优先代码前滚修复,不得盲目恢复数据库覆盖测试期间其他数据。
## 9. 工作量与决策点
| 阶段 | 预计工作量 | 风险 |
|---|---:|---:|
| S0 行为锁定 | 23人日 | 中 |
| S1~S3 出站低/中耦合拆分 | 2~4人日 | 中 |
| S4 Worker拆分 | 12人日 | 中高 |
| S5S6 入站认证/Inbox拆分 | 24人日 | 高 |
| S7~S8 持久化与门面收口 | 2~4人日 | 高 |
总计建议按9~17人日安排,并分至少4个测试版本。开始实施前需要用户再次明确授权C8专项和测试环境发布窗口。
## 10. 最终关闭标准
- 两个超大实现类被删除或只保留不超过300行的兼容转发层。
- 每个目标服务职责单一,构造依赖和公开方法有明确边界。
- 所有行为锁定、故障注入、真实PostgreSQL/Redis和Gateway契约通过。
- 任务、消息、Submit、Outbox、计费和回执语义与S0一致。
- 同口径性能基线下降不超过5%,无新增DB池等待或队列积压。
- 测试环境发布后服务健康、错误日志为0、Stream最终 `pending=0/lag=0`
- 浏览器只需验证受影响的任务/消息状态展示;不得用页面显示替代后端全链证据。
+9
View File
@@ -4127,3 +4127,12 @@ git diff --check
- 发布后API、Send Worker、Submit Outbox、Gateway Callback、Protocol Log Worker、Gateway、Security Agent、MinIO、Nginx、PostgreSQL和Redis全部activeAPI/Gateway健康、Redis PONG`gateway.submit.commands``gateway.submit.results``gateway.protocol.logs``pending=0/lag=0`,发布窗口error级日志0。Prometheus已暴露新的认证保护指标,临时发布/校验脚本已清理。 - 发布后API、Send Worker、Submit Outbox、Gateway Callback、Protocol Log Worker、Gateway、Security Agent、MinIO、Nginx、PostgreSQL和Redis全部activeAPI/Gateway健康、Redis PONG`gateway.submit.commands``gateway.submit.results``gateway.protocol.logs``pending=0/lag=0`,发布窗口error级日志0。Prometheus已暴露新的认证保护指标,临时发布/校验脚本已清理。
- 工作站显式`curl --noproxy '*'`验证首页、客户端登录、运营端登录、API健康和客户端验证码接口均HTTP 200。Chrome真实加载客户端和运营端登录页,客户端显示“返回官网”,页面资源为`index-BvZGIM6l.js/index-DyAqoJbu.css`,两页控制台error/warning均0;未输入密码或代解验证码,登录后页面继续作为人工验收边界。 - 工作站显式`curl --noproxy '*'`验证首页、客户端登录、运营端登录、API健康和客户端验证码接口均HTTP 200。Chrome真实加载客户端和运营端登录页,客户端显示“返回官网”,页面资源为`index-BvZGIM6l.js/index-DyAqoJbu.css`,两页控制台error/warning均0;未输入密码或代解验证码,登录后页面继续作为人工验收边界。
- 全程只操作测试环境`100.93.204.60`,未访问、回退、覆盖或部署预生产`8.160.169.106`;未发送、补发或重投短信,未修改余额、应用、通道或客户连接。 - 全程只操作测试环境`100.93.204.60`,未访问、回退、覆盖或部署预生产`8.160.169.106`;未发送、补发或重投短信,未修改余额、应用、通道或客户连接。
## 2026-08-28 代码质量持续优化补充关闭
- C2沿用同一运行代码的真实双租户API矩阵证据:客户端无租户header和一致header成功、伪造其他租户header返回`403 CLIENT_TENANT_MISMATCH`、请求体tenantId不能覆盖会话租户、跨租户资源返回404;本轮前端34项测试继续验证`/api/client/**`不发送`x-tenant-id`,管理端显式租户选择不受影响。登录后可见页面由用户按`docs/code-quality-browser-acceptance-checklist-20260828.md`人工验收,结果回填前不把浏览器验收标记为关闭。
- C5新增`tools/testing/verify-auth-redis-multi-instance.mjs`真实Redis契约。两个独立`SessionService`实例共用隔离Redis,验证跨实例会话、验证码一次性消费、验证码第31次拒绝、账号/组合第5次锁定、随机账号扫描同IP第30次锁定、15分钟/24小时TTL及SHA-256键;测试完成后隔离库`dbsize=0`。另补Redis不可用时验证码、锁查询和失败记录均返回服务不可用的fail-closed单测。
- C9不执行任何npm、Node、Prisma或依赖降级;继续使用上一轮已部署验证的npm 10.9.4和根/API两份`package-lock.json`,经文件所有者授权移除旧的未跟踪`pnpm-lock.yaml`。质量门禁由“只检查已跟踪替代锁”收紧为仓库根目录只要出现`pnpm-lock.yaml``yarn.lock`即失败;空文件`=`及其他会话产生的评估报告修改继续保留、不纳入本次提交。
- 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`,因此没有重复建立恢复资产或形式化重新部署。未访问预生产,未写入测试环境业务数据,未发送、补发或重投短信。
+1
View File
@@ -20,6 +20,7 @@
"test:api": "npm --prefix api test", "test:api": "npm --prefix api test",
"test:frontend": "vitest run", "test:frontend": "vitest run",
"test:frontend:coverage": "vitest run --coverage", "test:frontend:coverage": "vitest run --coverage",
"test:auth-redis-integration": "node tools/testing/verify-auth-redis-multi-instance.mjs",
"test:gateway": "npm run spike:gateway", "test:gateway": "npm run spike:gateway",
"lint": "npm run quality:verify && node tools/quality/run-changed-code-quality.mjs lint && tsc --noEmit", "lint": "npm run quality:verify && node tools/quality/run-changed-code-quality.mjs lint && tsc --noEmit",
"format:check": "git diff --check && node tools/quality/run-changed-code-quality.mjs format", "format:check": "git diff --check && node tools/quality/run-changed-code-quality.mjs format",
+7 -1
View File
@@ -1,5 +1,5 @@
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
const root = resolve(import.meta.dirname, '../..'); const root = resolve(import.meta.dirname, '../..');
@@ -96,6 +96,12 @@ if (trackedAlternativeLocks) {
`${trackedAlternativeLocks.replace(/\r?\n/g, ', ')}: npm/package-lock.json is the only supported committed dependency lock`, `${trackedAlternativeLocks.replace(/\r?\n/g, ', ')}: npm/package-lock.json is the only supported committed dependency lock`,
); );
} }
const presentAlternativeLocks = ['pnpm-lock.yaml', 'yarn.lock'].filter((file) => existsSync(resolve(root, file)));
if (presentAlternativeLocks.length) {
violations.push(
`${presentAlternativeLocks.join(', ')}: remove alternative lock files; npm/package-lock.json is the only supported dependency lock`,
);
}
if (packageJson.packageManager !== 'npm@10.9.4') { if (packageJson.packageManager !== 'npm@10.9.4') {
violations.push('package.json: packageManager must pin the supported npm baseline'); violations.push('package.json: packageManager must pin the supported npm baseline');
} }
@@ -0,0 +1,122 @@
import assert from 'node:assert/strict';
import { createHash, randomUUID } from 'node:crypto';
import { createRequire } from 'node:module';
const redisUrl = process.env.AUTH_REDIS_INTEGRATION_URL;
if (!redisUrl) {
throw new Error('AUTH_REDIS_INTEGRATION_URL is required; use an isolated Redis database');
}
process.env.REDIS_URL = redisUrl;
const require = createRequire(import.meta.url);
const { SessionService } = require('../../api/dist/auth/session.service.js');
const IORedis = require('../../api/node_modules/ioredis');
const redis = new IORedis(redisUrl, { enableReadyCheck: true, maxRetriesPerRequest: 1 });
const first = new SessionService();
const second = new SessionService();
const runId = randomUUID();
const digest = (value) => createHash('sha256').update(value.trim()).digest('hex');
const keys = new Set();
function remember(...values) {
for (const value of values) keys.add(value);
}
async function ttlInRange(key, minimum, maximum) {
const ttl = await redis.ttl(key);
assert.ok(ttl >= minimum && ttl <= maximum, `${key} TTL ${ttl} is outside ${minimum}..${maximum}`);
}
try {
assert.equal(await redis.ping(), 'PONG');
const { token } = await first.create(`integration-user-${runId}`, 'client', 1);
const sessionKey = `cmpp:auth:session:${digest(token)}`;
remember(sessionKey);
const crossInstanceSession = await second.validate(token, false);
assert.equal(crossInstanceSession.status, 'active');
assert.equal(crossInstanceSession.record.userId, `integration-user-${runId}`);
const captchaId = `integration-captcha-${runId}`;
const captchaKey = `cmpp:auth:captcha:${captchaId}`;
remember(captchaKey);
await first.storeCaptcha(captchaId, '8291', 120);
assert.equal(await second.consumeCaptcha(captchaId), '8291');
assert.equal(await first.consumeCaptcha(captchaId), null);
const captchaIp = `198.51.100.${(Number.parseInt(runId.slice(0, 2), 16) % 200) + 1}`;
const captchaRateKey = `cmpp:auth:captcha-rate:ip:${digest(captchaIp)}`;
remember(captchaRateKey);
for (let index = 0; index < 30; index += 1) {
assert.equal(await (index % 2 === 0 ? first : second).assertCaptchaRequestAllowed(captchaIp), true);
}
assert.equal(await second.assertCaptchaRequestAllowed(captchaIp), false);
await ttlInRange(captchaRateKey, 1, 300);
const pairLogin = `pair-${runId}@integration.invalid`;
const pairIp = '198.51.100.210';
const accountDigest = digest(pairLogin.toLowerCase());
const ipDigest = digest(pairIp);
const pairDigest = digest(`${accountDigest}:${ipDigest}`);
const pairKeys = [
`cmpp:auth:failure:${accountDigest}`,
`cmpp:auth:failure:ip:${ipDigest}`,
`cmpp:auth:failure:pair:${pairDigest}`,
`cmpp:auth:lock:${accountDigest}`,
`cmpp:auth:lock:ip:${ipDigest}`,
`cmpp:auth:lock:pair:${pairDigest}`,
];
remember(...pairKeys);
for (let index = 0; index < 5; index += 1) {
await (index % 2 === 0 ? first : second).recordAnonymousLoginFailure(pairLogin, pairIp);
}
assert.equal(await second.anonymousLoginLockScope(pairLogin, pairIp), 'account');
await ttlInRange(pairKeys[0], 86_000, 86_400);
await ttlInRange(pairKeys[3], 86_000, 86_400);
await ttlInRange(pairKeys[2], 86_000, 86_400);
await ttlInRange(pairKeys[5], 86_000, 86_400);
const scanIp = '198.51.100.211';
const scanIpDigest = digest(scanIp);
const scanFailureKey = `cmpp:auth:failure:ip:${scanIpDigest}`;
const scanLockKey = `cmpp:auth:lock:ip:${scanIpDigest}`;
remember(scanFailureKey, scanLockKey);
for (let index = 0; index < 30; index += 1) {
const login = `scan-${runId}-${index}@integration.invalid`;
const loginDigest = digest(login.toLowerCase());
const loginPairDigest = digest(`${loginDigest}:${scanIpDigest}`);
remember(
`cmpp:auth:failure:${loginDigest}`,
`cmpp:auth:failure:pair:${loginPairDigest}`,
`cmpp:auth:lock:${loginDigest}`,
`cmpp:auth:lock:pair:${loginPairDigest}`,
);
await (index % 2 === 0 ? first : second).recordAnonymousLoginFailure(login, scanIp);
}
assert.equal(await first.anonymousLoginLockScope(`fresh-${runId}@integration.invalid`, scanIp), 'ip');
await ttlInRange(scanFailureKey, 600, 900);
await ttlInRange(scanLockKey, 600, 900);
const listedKeys = await redis.keys(`cmpp:auth:*${runId}*`);
assert.equal(listedKeys.length, 0, 'raw login/run identifiers must not appear in Redis keys');
console.log(
JSON.stringify({
status: 'passed',
sharedSession: true,
oneTimeCaptcha: true,
captchaRateLimit: true,
accountAndPairLock: true,
randomAccountIpLock: true,
ttlVerified: true,
hashedKeysOnly: true,
}),
);
} finally {
if (keys.size > 0) await redis.del(...keys);
await first.onModuleDestroy();
await second.onModuleDestroy();
redis.disconnect();
}