Files
full/docs/sender.md
2026-09-22 18:53:53 +08:00

217 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# sender短信与邮件发送代码审计报告
| 项 | 内容 |
| --- | --- |
| 审计对象 | `module/base/sender` |
| 服务域 | 基础与平台服务 |
| 审计日期 | 2026-09-22 |
| 代码规模 | 手写 Go 文件 18 个、1043 行(其中 `cmd/cli/main.go` 125 行为独立的 SMTP 手工工具、`test/grpc/{sms,mail}_test.go` 共 100 行且均带 `//go:build integration``proto` 3 个(`sms.proto` 28 行、`mail.proto` 21 行、`const.proto` 326 行);`pb/` 生成文件 7 个 |
| 入口 | `cmd/main`gRPC + HTTP Gateway 单进程);聚合入口 `pkgs/all``pkgs/all/internal/service/sender.go:10-22`,会传入 `Config: config.Spec.Sender` |
| 对外协议 | gRPC + REST GatewayREST 路径 `/sender.Sms/Send``/sender.Sms/Verify``/sender.Mail/Send``pb/sms.pb.gw.go:104,124``pb/mail.pb.gw.go:77` |
| 结论摘要 | **腾讯云短信是空实现**——`internal/logic/sms/send.go:151-153``TencentSender` 直接 `return nil, nil`,调用后不报错、返回 `"null"`,调用方会误认为发送成功。阿里云通道存在 3 个高危缺陷:日发送量限制**从未生效**`limitKey` 只有读、无写,`send.go:42-51`)、验证码用 `SetNX` 写入但**忽略返回值**、却把新生成的码发出(`send.go:62-65`),导致"收到的码校验不过";黑名单集合 `BlackListCacheKey` 全仓只有读、无写(`send.go:37`),配置项 `BlackListFilter` 从未被读取。验证码用 `math/rand` 生成(`send.go:169`)且 `Verify` 成功后**不删除**该码(`verify.go:28-32`)→ 可重放;失败反而删除 → 一次输错即作废。邮件只实现了 `qq` 一个渠道(`mail/send.go:55-60`),且 `mail/send.go:89-94``smtp.NewClient` 出错时对 nil client 调 `Close()`。三个接口在模块 yaml 中全部登记为匿名(`etc/sender_dev.yaml:16-18`),聚合部署白名单未放行。 |
## 1. 服务定位与职责
为其它服务提供**出站消息通道**:短信(阿里云 / 腾讯云与邮件SMTP并附带一套手机验证码的生成/存储/校验能力。它自身不决定"何时发什么",由业务方传入 `provider`/`template_code`/`sign_name`(短信)或 `template_key`(邮件)与模板参数;模板正文对邮件存放在 `sender_template` 表,对短信存放在阿里云侧的模板 ID。
## 2. 代码结构与入口
| 路径 | 职责 |
| --- | --- |
| `cmd/main/main.go` | 独立进程入口。`ServiceKey = "sender"``:17``config.New``impl.NewImpl``server.New(nil)``:29`)→ `service.New(...)` + `srv.Start()``:32-48` |
| `cmd/cli/main.go` | 125 行的独立 SMTP 手工工具:硬编码 `smtp.exmail.qq.com:465``:13-14`)、发件人 `yanweidong@senlinai.com``:89`)、凭据取自环境变量 `BSM_SMTP_USER`/`BSM_SMTP_PASSWORD`/`BSM_SMTP_TO``:34-38`),带 3 次重试(`:68` |
| `internal/config/config.go` | `SrvConfig``:17-28`+ `SmtpConf``:31-38`/`SmsConf``:41-46`/`codeConf``:49-56``New` 只校验 `conf.NotNil(Spec.Service, Spec.Cache)``:70` |
| `internal/impl/impl.go` | 初始化 `MemorySerice`(拼写错误,`:16,22`/`RedisService`/`DBService`/`EtcdService`,最后调 `withProvider()``:30` |
| `internal/impl/provider.go` | 服务商客户端构建:`withProvider` 按 yaml 键名匹配 `google`/`qq`/`aliyun`/`tencent``:31-50``NewSMTP` **恒返回 nil**`:52-54``NewAliyun``:56-79`/`NewTencent``:81-92`)出错即 `panic` |
| `internal/logic/sms/send.go` | `Send``:26-100`)→ 校验手机号/模板码 → 黑名单 → 日限流 → 生成/取验证码 → 分派 `AliyunSender``:102-149`/`TencentSender``:151-153` |
| `internal/logic/sms/verify.go` | `Verify``:12-40`):比对 Redis 中的验证码 |
| `internal/logic/sms/const.go` | 4 个 Redis 键常量(`:3-8` |
| `internal/logic/mail/send.go` | `Send``:22-69`):查模板 → 渲染 → `QQ``:76-152``ValidateEmail``:71-74` |
| `internal/models/sender_template.go` | `sender_template` 表模型(`Title`/`Key`/`Subjet`/`Body``:10-13`);字段名 `Subjet` 为拼写错误 |
| `internal/server/{sms_server,mail_server,new}.go` | 把 `Sms`/`Mail` 注册到 gRPC`new.go:33-34` |
| `internal/excode/ex.go` | 11 个错误码;其中 `ErrAppName`/`ErrMustWhiteList`/`ErrExpired` 无引用(见 6.3 |
| `internal/routers` | **不存在**(走 gRPC + HTTP Gateway无 Gin 路由) |
| `service/{expose,dependencies}.go` | 聚合宿主注入点:`Expose` 覆盖 `config.Spec``expose.go:24-26`)并注册 Gateway handler`:30-35` |
| `proto/{sms,mail,const}.proto` | 前两个定义本模块 3 个 rpc`const.proto` 为本模块无关的共享消息 |
| `etc/sender_{dev,prod,test}.yaml` | Port 12208 / Gateway 12207**prod 与 test 均为 dev 的近似副本**(本机库/Redis、占位 AK |
| `test/grpc/{sms,mail}_test.go` | 均带 `//go:build integration``sms_test.go:1``mail_test.go:1`),默认打真实地址(`sms_test.go:17``192.168.31.148:12208` |
| `test/http/*.http` | 3 个手工请求样例,指向 `http://api.apinb.com/sender.{Sms,Mail}/*` |
| `README.md` / `UPGRADE_SUMMARY.md` | 与实现不符的文档(见 6.4 |
## 3. 接口清单
| 方法 | 路径 | 功能 | 鉴权 | 实现位置 |
| --- | --- | --- | --- | --- |
| POST | `/sender.Sms/Send` | 发送短信(可顺带生成验证码) | 无(模块 yaml 登记匿名 `etc/sender_dev.yaml:17` | `internal/logic/sms/send.go:26``internal/server/sms_server.go:18` |
| POST | `/sender.Sms/Verify` | 校验手机验证码 | 无(`etc/sender_dev.yaml:18` | `internal/logic/sms/verify.go:12``sms_server.go:22` |
| POST | `/sender.Mail/Send` | 按模板发送邮件 | 无(`etc/sender_dev.yaml:16` | `internal/logic/mail/send.go:22``internal/server/mail_server.go:18` |
| POST | gRPC `/sender.Sms/Send|Verify``/sender.Mail/Send` | 同上(同一 handler | 同上 | `internal/server/new.go:33-34` |
**声明但未实现/占位的接口**:无"声明了但 server 未实现"的方法(`proto/sms.proto:6-9``proto/mail.proto:6-8``internal/server/*_server.go` 完全对应)。但**已声明并已接入路由的 provider 存在空实现**
| 声明位置 | 空实现位置 | 表现 |
| --- | --- | --- |
| `proto/sms.proto:13``provider` 字段(`send.go:82-86` 分派 `tencent` | `internal/logic/sms/send.go:151-153` `func TencentSender(args *pb.SmsSendRequest) (map[string]any, error) { return nil, nil }` | `result == nil && err == nil``send.go:95-99` 返回 `Reply: "null"` 且无错误,**调用方误判为发送成功** |
| `internal/impl/provider.go:35-38` 匹配 `google` SMTP | `internal/impl/provider.go:52-54` `func NewSMTP(...) *smtp.Client { return nil }` | `Provider.Google`/`Provider.QQ` 恒为 nil`mail.Send` 未使用 Provider故该结构为死代码 |
| `internal/logic/mail/send.go:55-60` 的 provider switch 只有 `case "qq"` | 无 `google`/其它分支 | 传 `provider=gmail` 直接 `ErrNotProvider``test/grpc/mail_test.go:37` 正是这么调的) |
> 鉴权补充:与 `initial` 同构的三重问题——① `etc/sender_dev.yaml:14` 为 `MicroService.Enable: false`,匿名清单不会写入 etcd`D:\work\bsm-sdk\core\service\service.go:66-90`);② 独立部署时模块网关把 `GatewayMux` 直接交给 `http.ListenAndServe``service.go:106-111`、`:120-129`**不带任何鉴权中间件**,因此这三个接口对网络可达方完全开放;③ 聚合部署时 `pkgs/all/etc/default_dev.yaml:24-43` 未放行任何 `/sender.*`,与模块 yaml 的匿名声明矛盾;④ 即便改 `Enable: true`,清单条目写作 `sender.Sms.Send``etc/sender_dev.yaml:17`),而框架发现的方法是 `Sms.Send``service.go:149-158`)、网关规范化路径是 `/sender.Sms/Send``authorization.go:105-112`)——三种格式互不相等,永远不命中。
## 4. 数据模型与表
### 表 `sender_template``internal/models/sender_template.go:8-14`
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| `id`/`created_at`/`updated_at`/`deleted_at` | uint / timestamp | PK / 软删 | 来自 `types.Std_IICUDS``:9` |
| `title` | varchar(255) | not null default '' | 模板标题 |
| `key` | varchar(100) | not null **uniqueIndex** | 模板标识,`mail.Send` 按它查询(`mail/send.go:43` |
| `subjet` | varchar(255) | not null default '' | 邮件主题(字段名拼写错误,`:12` |
| `body` | text | not null | 模板内容,交给 `html/template` 解析(`mail/send.go:49` |
> 该表**没有启用/停用字段**,也没有"渠道/语言/场景"维度(`mail/send.go:43` 只按 `key` 取一条)。短信侧无任何本地表:模板 ID 与签名由调用方每次传入(`proto/sms.proto:14-15`)。
### Redis 键(`internal/logic/sms/const.go:3-8`
| 常量 | 值 | 用途 | 实际读写情况 |
| --- | --- | --- | --- |
| `KeyPrefix` | `/SMS/Code/` | 验证码:`/SMS/Code/{phone}` | 写 `send.go:65``SetNX`)、读 `verify.go:23`、删 `verify.go:35` |
| `BlackListCacheKey` | `/SMS/BlackList/` | 手机号黑名单Set | **只有读**`send.go:37``SIsMember`);全仓无任何写入点 → 黑名单永远为空 |
| `LimitCacheKey` | `/SMS/LimitCacheKey/` | 日发送计数:`/SMS/LimitCacheKey/{yyyy-mm-dd}{phone}` | **只有读**`send.go:42,45`);全仓无任何 `INCR`/`Set` → 计数恒为 0 |
| `FormatDay` | `2006-01-02` | 日限流键的日期片段 | — |
## 5. 核心流程
```mermaid
flowchart TD
A["POST /sender.Sms/Send"] --> B["校验 phone 非空且匹配正则,否则 ErrPhone"]
B --> C["校验 template_code 非空,否则 ErrTemplate"]
C --> D["SISMEMBER /SMS/BlackList/{phone}"]
D -->|"命中"| E["返回 ErrInBlackList"]
D -->|"未命中"| F["GET /SMS/LimitCacheKey/{date}{phone} 取 twice"]
F --> G{"twice > Code.MaxSentLimit"}
G -->|"是"| H["返回 ErrSentLimit"]
G -->|"否"| I{"is_gen_code"}
I -->|"true"| J["GenValidateCode 生成新码SETNX 写入 Redis忽略返回值"]
I -->|"false"| K["从 paramters[code] 取调用方传入的码"]
J --> L["按 provider 分派aliyun 走阿里云 OpenAPI"]
K --> L
L --> M{"provider"}
M -->|"tencent"| N["TencentSender 返回 nil, nil空实现"]
M -->|"其它"| O["返回 ErrNotProvider"]
L --> P["json.Marshal(result) 后作为 Reply 返回"]
N --> P
```
## 6. 审计发现
### 6.1 安全
| 级别 | 位置 | 问题 |
| --- | --- | --- |
| 高 | `internal/logic/sms/verify.go:28-32` | **验证码可重放**`if code == in.Code { return "true" }` 命中后**不删除** Redis 键,仅在失配路径才 `Del``:34-35`)。有效期 300s`etc/sender_dev.yaml:46`)内同一验证码可无限次校验通过;结合下面"无尝试次数限制"`Verify` 被暴力枚举成功后仍可反复使用。注释 `//verify pass ; delete the requestId``:34`)与实际行为(删的是失败分支)相反,说明是写错分支。 |
| 高 | `internal/logic/sms/verify.go:12-40` | **无失败次数限制/无锁定**`Verify` 不统计失败次数、不锁定手机号、无验证码错误计数6 位数字码在 300s 内可被离线暴力枚举。且验证码键**只用手机号**`const.go:5``verify.go:22`),不区分 `app`/`template_code`,同一手机号在多业务间共用一枚码,任一业务泄漏即全部可验证。 |
| 高 | `internal/logic/sms/send.go:37-39`(读)+ `const.go:6` | **黑名单形同虚设**:判定逻辑存在(`SIsMember``ErrInBlackList`),但 `/SMS/BlackList/` 集合在**整个仓库内没有任何写入点**;配置项 `Code.BlackListFilter``internal/config/config.go:55`yaml `etc/sender_dev.yaml:49-50``etc/sender_prod.yaml:49-50`)也**从未被代码读取**(全仓检索仅命中定义处与 yaml。结论无法通过该机制拦截任何号码。 |
| 高 | `internal/logic/sms/send.go:41-51`(读) | **日发送量限制从未生效**`limitKey := LimitCacheKey + 日期 + 手机号` 只被 `Get``:45`)读取,全仓没有任何 `Incr`/`Set` 写入该键,`twice` 恒为 0`twice > MaxSentLimit``:49`)恒为假。任意外部调用方可在一天内对同一号码无限次发送短信(成本与骚扰风险)。注意判断本身也应是 `>=`(当前语义为"已超过 N 次才拒",即实际允许 N+1 次)。 |
| 高 | `internal/logic/sms/send.go:66-73``:104-109` | **验证码内容由调用方控制**`is_gen_code=false` 时直接从 `in.Paramters["code"]` 取"验证码"`:68-71`);即使 `is_gen_code=true``AliyunSender` 在设置好 `templateParam["code"]` 之后又用 `args.Paramters` **覆盖同名键**`:107-109`)。因此调用方可让短信正文携带任意 `code`(配合 `sign_name`/`template_code` 自填)= 把短信通道当成任意内容/任意签名发送器(配合 6.1 前三条约等于开放短信网关)。 |
| 中 | `etc/sender_dev.yaml:16-18``etc/sender_prod.yaml:16-18``etc/sender_test.yaml:16-18` | 三个接口全部登记匿名;叠加 6.1 的开放发送能力,未鉴权即可调用。且 `Verify` 对所有号码可探测(返回 `true`/`false`),可用来枚举"哪些手机号正在流程中"。 |
| 中 | `etc/sender_dev.yaml:40` | 仓库内提交了疑似真实的阿里云 AccessKeyId `LTAI5tKEmKuuoixE4iw8NZbX``AccessKeySecret` 已脱敏为 `CHANGE_ME`。KeyId 本身不应入库;若该 Key 仍有效,应连同 Secret 一并轮换并从历史中清理。 |
| 中 | `internal/logic/sms/verify.go:23-26` | Redis 读取失败时把底层错误原文回给调用方:`errcode.NewError(1311, err.Error())`,泄漏内部错误文本;同时"验证码不存在/已过期"也走这条路径,而专用错误码 `excode.ErrExpired``internal/excode/ex.go:14`)定义了却没用。 |
| 中 | `internal/logic/sms/send.go:96,146-147` | 用 `fmt.Println` 直接把**含手机号与模板参数**的请求体、以及响应结果打到 stdout并会被 supervisor 落盘到 `etc/supervisor.bsm-apps-sender.conf:8``/data/app/logs/apps-sender.log`)。手机号属个人敏感信息,日志中不应明文记录。 |
| 中 | `internal/logic/mail/send.go:127-129` | 邮件头用字符串拼接构造:`"Subject: " + subject + "\n\n"``subject` 来自数据库模板(`mail/send.go:57``tplRecord.Subjet`)且未做 CRLF 过滤。若模板可被管理端写入 `\r\n`,即形成邮件头注入(可注入 `Bcc:` 等)。同时未设置 `Content-Type`/`MIME-Version`/`Content-Transfer-Encoding`,中文主题/正文按裸 UTF-8 发送,多数客户端会乱码。 |
| 低 | `internal/logic/mail/send.go:43-46` | 模板查询只按 `key` 精确匹配、无启用/渠道维度;模板 `body` 直接交给 `html/template` 解析(`:49`DB 内容的可信任边界未在代码中约束。`html/template` 会转义变量,故不构成模板注入,但纯文本邮件会因转义出现 `&` 一类字符。 |
| 低 | `internal/logic/sms/send.go:29,155-159` | 手机号校验仅一条正则(`^(1[3|4|5|6|7|8|9][0-9]\d{4,8})$`),无长度上限与号段白名单;`in.GetPhone()` 未做去空格/去 `+86` 归一,同一真实号码可用 `+86138…``86138…` 等写法绕过按号码的黑名单/限流(在其失效前)。 |
| 低 | `etc/supervisor.bsm-apps-sender.conf:6` | 进程以 `user=root` 运行。 |
### 6.2 正确性与逻辑缺陷
| 级别 | 位置 | 问题 |
| --- | --- | --- |
| 高 | `internal/logic/sms/send.go:62-65` + `:81` | **发出的验证码可能不是存入 Redis 的那一个**。生成新码时用 `SetNX` 写入(`:65`**完全忽略返回值**;若该手机号的键仍存在(上一次发送距今不足 `Expire`=300s`SetNX` 不生效、Redis 保留**旧码**,而紧接着 `smsCode`(新码)被传给 `AliyunSender``:81`)发到用户手机上。用户收到新码却校验不过,只有等旧码过期重新发送才可能成功。正确做法是 `Set`(覆盖)或在使用 `SetNX` 的成功分支才把该码发出。 |
| 高 | `internal/logic/sms/send.go:151-153` | **腾讯云短信为空实现**(按任务核实项,确认):函数体只有 `return nil, nil``send.go:83-85``impl.Provider.Tencent == nil` 前置判断也拦不住它(`internal/impl/provider.go:46-48` 在配置存在 tencent 时已创建非 nil 客户端),因此 `provider=tencent` 时接口返回 HTTP 200 + `Reply: "null"`,调用方无从知晓短信根本没发出去。 |
| 中 | `internal/logic/sms/send.go:161-176` | 验证码用**非密码学安全**的 `math/rand``rand.Seed(time.Now().UnixNano())``:169`+ `rand.Intn(10)``:173`)。种子与时间强相关,验证码可被预测/爆破(配合 6.1 无尝试限制)。应改用 `crypto/rand`。另 `width == 0` 时兜底为 4 位(`:163-165`),而调用侧在配置不合法时兜底为 6 位(`:57-59`),两处默认值不一致。 |
| 中 | `internal/logic/sms/send.go:55-59` | 配置不合法时**静默改写运行时配置**`config.Spec.Code.Length < 4 \|\| > 10` 时把全局 `config.Spec.Code.Length` 直接改成 6`:58`)。这是对共享可变状态的写操作,并发请求下互相影响,且掩盖了配置错误(既不报错也不打日志)。 |
| 中 | `internal/logic/sms/send.go:75-99` | `result, err` 的取值不做 `nil` 判定就 `json.Marshal(result)``:95``nil` 会序列化成 `"null"` 并作为 `Reply` 返回(`:98`)。除腾讯空实现外,任何"返回 nil, nil"的实现都会被当作成功。同时 `err` 直接向上抛(`:91-93`),未按渠道归一成模块错误码。 |
| 中 | `internal/logic/sms/send.go:41-42` | 限流键把日期与手机号直接拼接(`LimitCacheKey + "2006-01-02" + phone`),无分隔符:`/SMS/LimitCacheKey/2026-09-22` + `13800138000`。本身不影响正确性,但键的可读性与后续按号段/前缀统计都变差;一旦将来改成"先日期后号码"以外的顺序,历史计数全部错位。 |
| 中 | `internal/logic/mail/send.go:89-94` | **nil 指针解引用**`client, err := smtp.NewClient(...)` 出错时,下一行立即 `client.Close()``:91`)。此时 `client` 为 nil一旦 `smtp.NewClient` 返回错误(如服务端在握手阶段即断开)将 panic而不是返回错误。 |
| 中 | `internal/logic/mail/send.go:119-124``:146-150` | `writer` 被关闭两次:`:124``defer writer.Close()``:146` 又显式 `writer.Close()`。第二次 `Close` 的错误会被当作发送失败返回(`:146-149`),同一封邮件可能被误报失败。 |
| 中 | `internal/logic/mail/send.go:151` | 成功路径**没有调用 `client.Quit()`/`Close()`**,也未关闭 `tls.Conn``:78`)。每次发信都会残留一条未正常关闭的 TLS 连接,依赖 GC 回收;持续发信会堆积连接/文件描述符(对比 `cmd/cli/main.go:120` 的正确写法:成功时 `client.Quit()`)。 |
| 低 | `internal/logic/sms/send.go:45-48` | `Get(...).Int()``!errors.Is(err, redis.Nil)` 把"键不存在"(正常,视为 0 次)与真错误区分开——这部分写法正确(`redis.Nil` 由 SDK 导出,`D:\work\bsm-sdk\core\cache\redis\redis.go:16`)。 |
| 低 | `internal/logic/sms/send.go:57-59` | 长度校验用 `int64` 比较 `config.Spec.Code.Length`,与 `codeConf.Length int64``config.go:50`)一致;但 `yaml` 中未配置该字段时为 0会被静默改成 6调用方无法感知配置缺失。 |
| 低 | `internal/logic/mail/send.go:22-39` | 参数校验顺序为"先查 provider 配置(`:31-34`)再校验收件人格式(`:37-39`"`To` 为空时已在 `:26-28``ErrInvalidArgument`,但格式错误统一返回 `ErrEmail``excode:16`),与"provider 未配置"`ErrProviderIsNil`)的语义边界清晰,无功能问题。 |
| 低 | `internal/logic/sms/send.go:14``internal/logic/mail/send.go:18` | `ctx` 仅被透传给 Redis`send.go:37/45/65`),未用于任何 HTTP/OpenAPI 调用(阿里云 `CallApi` 未接收 ctx上游超时无法取消一次正在进行的短信发送。 |
| 低 | `internal/models/sender_template.go:12` | 字段名 `Subjet` 拼写错误(应为 `Subject`),直接映射为列名 `subjet`,与 `mail/send.go:57` 的引用形成"错得一致",但会成为对外数据契约的长期负担。 |
### 6.3 未完成实现
- **`TencentSender` 空实现**`internal/logic/sms/send.go:151-153`,见 6.2。`internal/impl/provider.go:81-92``NewTencent` 客户端已完整构建并挂在 `Provider.Tencent` 上,唯独发送函数是空的 → 属于"接线完成、逻辑未写"。
- **`NewSMTP` 空实现**`internal/impl/provider.go:52-54` 恒返回 nil导致 `ProviderClient.QQ``:24`)与 `ProviderClient.Google``:22`)永远为 nil。`internal/logic/mail/send.go` 并未使用这两个字段,所以它们是**死代码**`UPGRADE_SUMMARY.md:1-192` 声称的"完整容器化与工具链"也与此无关。
- **配置项定义了但从未被读取**(全仓检索,仅命中定义处与 yaml
- `Code.GenerateCode``internal/config/config.go:53`yaml `etc/sender_prod.yaml:47``etc/sender_test.yaml:46`)——实际是否生成验证码只取决于请求字段 `is_gen_code``send.go:55`)。
- `Code.CokeyKey``config.go:54`yaml 三处均为 `code`)——真实键名硬编码在 `send.go:104``templateParam["code"]`
- `Code.BlackListFilter``config.go:55`)——见 6.1。
- **错误码定义后无引用**`ErrAppName``internal/excode/ex.go:8`)——`SmsSendRequest` 里根本没有 app 字段;`ErrMustWhiteList``:11`)——白名单逻辑不存在;`ErrExpired``:14`)——过期路径走的是 `1311``verify.go:25`)。
- **`MemorySerice``internal/impl/impl.go:16,22`**:本模块内除赋值外无任何读取点(`service/dependencies.go:30` 是唯一另一处赋值)→ 内存缓存层初始化后不使用。
- **`test/grpc/{sms,mail}_test.go` 不是可 CI 执行的测试**:均带 `//go:build integration``sms_test.go:1``mail_test.go:1`),且依赖真实地址(`sms_test.go:17` 内网 IP、`mail_test.go:19` `api.apinb.com:10020`)。`internal/**` 下**无任何 `*_test.go`**——`Send`/`Verify`/`GenValidateCode`/`ValidateEmail` 四个纯函数零单元测试覆盖。
- **`test/grpc/mail_test.go:37` 使用 `Provider: "gmail"`**,而 `mail/send.go:55-60` 只实现 `qq`,该用例即使跑起来也必然返回 `ErrNotProvider` → 用例与实现不一致(说明 Gmail 渠道曾是计划项)。
- **`cmd/cli/main.go`** 与短信/邮件业务完全平行的一套 SMTP 实现(自带重试与 `Quit`),既不复用 `internal/logic/mail`,也不被任何代码引用 → 独立的遗留工具。
- **`proto/const.proto:1-326`** 定义 `OrderSummaryItem``FeedPostItem``MarketLoginReply``CmsCategoryItem` 等与本模块无关的消息,未被引用 → 死定义。
### 6.4 健壮性与可维护性
| 级别 | 位置 | 问题 |
| --- | --- | --- |
| 高 | `internal/logic/sms/send.go:49``:57` | **`config.Spec.Code` 未做非空校验即可解引用**`codeConf` 是指针(`config.go:27``config.New``conf.NotNil(Spec.Service, Spec.Cache)``config.go:70`)。当配置(模块 yaml 或聚合传入的 `pkgs/all` `Sender` 段)缺少 `Code` 时,`config.Spec.Code.MaxSentLimit` 会在第一次调用 `Send` 时 panic`:49`),而不是启动期报错。`pkgs/all/etc/default_dev.yaml:113-118` 目前有该段,但不构成代码层保证。 |
| 高 | `pkgs/all/etc/default_dev.yaml:98-118` vs `internal/logic/sms/send.go:76-89``internal/logic/mail/send.go:31-34` | **聚合配置的 provider 键名与代码期望不匹配**:聚合配置只有 `Sender.SMTP.default``Sender.SMS.default`,而代码要求键名等于请求传入的 `provider``mail/send.go:31``config.Spec.SMTP[provider]` 直接查表;`sms/send.go:78-80` 需要 `Provider.Aliyun`/`Provider.Tencent` 非 nil`impl/provider.go:42-49` 只在键名为 `aliyun`/`tencent` 时才创建客户端)。结果是聚合部署下短信恒返回 `ErrProviderIsNil``:79``:84`),邮件要传 `provider=default` 才能走通(与 README/用例中的 `qq`/`gmail` 都不符)。 |
| 中 | `internal/impl/provider.go:56-79``:81-92` | 构建客户端失败时 `panic``:65``:75``:88`),且 `panic(err)` 不带上下文,启动期崩溃时无法定位是哪个 provider 的哪一步;`internal/impl/impl.go:30``NewImpl` 中同步调用,属"启动即崩"路径。 |
| 中 | `etc/sender_prod.yaml:7,10` | 生产配置的 `Databases.Source``Cache` 仍指向 `127.0.0.1``:7``:10`),与 `sender_dev.yaml` 完全一致。生产若直接使用该文件会连本机库/本机 Redis。 |
| 中 | `etc/sender_prod.yaml:37-40` | 生产短信配置明显是复制错误且未填真值:`SMS.aliyun.Endpoint: smtp.gmail.com``:38`,应为 `dysmsapi.aliyuncs.com``AccessKeyId: <your-access-key-id>``AccessKeySecret: <your-access-key-secret>``:39-40`)为占位符。同时三份 yaml 都没有 `tencent` 段,`TencentSender` 即使实现也无可配置的客户端。 |
| 中 | `README.md:155,170` | README 给出的调用地址 `/v1/sms/send``/v1/mail/send`(及 `README.md:151``http://localhost:12202/swagger/`)与真实路径 `/sender.Sms/Send``/sender.Mail/Send``pb/sms.pb.gw.go:104,124``pb/mail.pb.gw.go:77`)不一致,照文档调用必然 404。 |
| 低 | `README.md:9,18` | README 宣称"腾讯云短信""多服务商支持QQ邮箱、Gmail",与实现(腾讯空实现、邮件仅 `qq`)不符。 |
| 低 | `README.md:149-151``:361-365` | README 声明 gRPC `12201` / HTTP `12202`、默认端口 `12201`,而实际 yaml 为 gRPC `12208` / Gateway `12207``etc/sender_dev.yaml:2``:23`);对不上。 |
| 低 | `README.md:63-103` | README 的配置样例使用 `Service.Name/Port` 嵌套结构(`:63-66`)和 `Databases.Default.{Host,Port,...}``:69-76`)的形式,与 `internal/config/config.go:17-28` 实际支持的结构(`Service: <string>` + `Databases.Source: [...]` 连接串)完全不同,属虚构配置。 |
| 低 | `README.md:352,379` | 声明 `GET /health` 健康检查端点,本模块无任何注册。 |
| 低 | `UPGRADE_SUMMARY.md:19-31,60-68,184-192` | 声称新增了 `Dockerfile``docker-compose.yml``Makefile``CHANGELOG.md`(并称"所有改进都保持了向后兼容性,可以安全地在生产环境中部署"),但仓库中**这四个文件都不存在**(本模块实际只有 `cmd/ internal/ pb/ proto/ etc/ test/ service/``README.md`/`UPGRADE_SUMMARY.md`)→ 文档不实描述。 |
| 低 | `internal/config/config.go:70` | 与 `ads`/`initial` 相同:`conf.NotNil` 只覆盖 `Service`/`Cache`,未覆盖 `Databases``Code``with.Databases``D:\work\bsm-sdk\core\with\databases.go:13-15`)在 `Source` 为空时直接 panic。 |
| 低 | `internal/impl/impl.go:16,22` | 变量名拼写错误 `MemorySerice`(应为 `MemoryService`);同 workspace 的 `pkgs/all/internal/impl` 用正确拼写(`pkgs/all/internal/service/sender.go:16`)。 |
| 低 | `service/expose.go:24-26` | `config.Spec = *options.Config` 直接整份覆盖全局配置;若宿主只填了部分字段(如只给 `SMTP`),其余字段(含 `Code`)会变成零值 → 触发上面第 1 条的 panic。 |
## 7. 风险汇总
| 编号 | 级别 | 问题 | 影响面 |
| --- | --- | --- | --- |
| S1 | 高 | 腾讯云短信为空实现,返回 `"null"` 且无错误(`send.go:151-153` | 业务静默失效、用户收不到短信却认为已发送 |
| S2 | 高 | 日发送量限制从未生效(`limitKey` 无写入,`send.go:42-51` | 短信轰炸、短信费用失控 |
| S3 | 高 | 验证码 `SetNX` 忽略返回值但发送新码(`send.go:62-65,81` | 收码即校验失败,登录/注册流程不可用 |
| S4 | 高 | 黑名单无写入点、`BlackListFilter` 未读取(`send.go:37` | 拦截机制形同虚设 |
| S5 | 高 | 验证码可重放 + 无尝试次数限制 + 键仅按手机号(`verify.go:28-32` | 验证码可爆破、跨业务复用 |
| S6 | 高 | 验证码内容可由调用方指定/覆盖(`send.go:66-73,104-109` | 短信通道被当作任意内容发送器 |
| S7 | 高 | `config.Spec.Code` 未校验即解引用(`send.go:49` | 缺配置时运行期 panic |
| S8 | 高 | 聚合配置 provider 键为 `default`,代码要求 `qq`/`aliyun`/`tencent` | 聚合部署下短信恒 `ErrProviderIsNil` |
| S9 | 中 | 验证码用 `math/rand` + 时间种子(`send.go:161-176` | 验证码可预测 |
| S10 | 中 | 三接口匿名 + 独立部署网关无鉴权中间件;聚合白名单未放行 | 未鉴权调用发送能力 / 上线 401 |
| S11 | 中 | 邮件构造:`client` nil 时 `Close()``mail/send.go:91`)、`writer` 双关闭(`:124,146`)、成功不 `Quit``:151` | panic、误报失败、连接泄漏 |
| S12 | 中 | 请求参数(含手机号)与结果明文 `fmt.Println``send.go:96,146-147` | PII 落盘、日志泄露 |
| S13 | 中 | `etc/sender_prod.yaml` 为本机地址 + 错误 Endpoint + 占位 AK | 生产部署误连本机/配置无效 |
| S14 | 中 | 仓库内提交疑似真实阿里云 AccessKeyId`etc/sender_dev.yaml:40` | 凭据泄露 |
| S15 | 低 | 死代码/死配置(`NewSMTP``MemorySerice`、3 个未读配置项、3 个未用错误码、`const.proto``cmd/cli`+ 无 `*_test.go` | 可维护性、无法回归验证 |
| S16 | 低 | README / UPGRADE_SUMMARY 与实现大面积不符路径、端口、渠道、Docker 文件) | 文档误导、排障成本 |
## 8. 修复建议(务实项)
1. **实现或摘除腾讯通道**`internal/logic/sms/send.go:151-153` 二选一——要么按 `impl.Provider.Tencent``sms/v20210111``SendSms`(客户端已在 `internal/impl/provider.go:81-92` 建好);要么删除 `case "tencent"` 分支(`send.go:82-86`)并同步删掉 `NewTencent`/`Provider.Tencent`,让 `tencent` 直接落到 `default: return ErrNotProvider``:87-88`)。同时给 `send.go:95-99``if result == nil` 判定,返回明确错误,避免把 `"null"` 当成功。
2. **让限流真正生效**:在 `internal/logic/sms/send.go:49` 之前(或在短信真正发出成功后)对 `limitKey` 执行一次 `Incr``Expire` 到当天结束;把比较改为 `twice >= config.Spec.Code.MaxSentLimit`。改动点仅 `send.go:41-51` 一处。
3. **修验证码写入语义**`internal/logic/sms/send.go:65``SetNX` 改为 `Set`(覆盖旧码),或判断 `SetNX` 的布尔返回值,仅当写入成功时才把 `smsCode` 交给 `AliyunSender``:81`)。二选一即可,关键是"发出去的码必须等于存进去的码"。
4. **修校验语义**`internal/logic/sms/verify.go` 把删除动作移到**校验成功**分支(把 `:34-35``Del` 上移到 `:28-32``if` 内),并加失败计数(同一个 `key` 连续失败 N 次即删除并返回 `ErrExpired`/新错误码),避免重放与爆破。
5. **黑名单落地**:明确 `/SMS/BlackList/{phone}` 的写入方(哪个服务/接口写这个 Set并在模块内提供写入入口若短期内不实现则从代码中删掉 `send.go:37-39` 的判定与 `Code.BlackListFilter` 配置,避免"看起来有防护"。
6. **禁止调用方指定验证码内容**`internal/logic/sms/send.go:66-73``is_gen_code=false` 分支应删除或改为"仅允许复用 Redis 中已有的码"`AliyunSender``:107-109` 的参数合并应排除 `code` 键(`if key == "code" { continue }`)。
7. **验证码随机源**`internal/logic/sms/send.go:161-176` 改用 `crypto/rand` 生成数字,删除 `rand.Seed`;同时把 `:55-59``config.Spec.Code.Length` 的静默改写改为"返回配置错误"。
8. **配置校验前移**`internal/config/config.go:70``conf.NotNil` 加上 `Databases``Code`;或在 `impl.NewImpl``internal/impl/impl.go:20`)入口显式判空并打印缺失项名,替代运行期 panic。
9. **聚合 provider 键名对齐**:把 `pkgs/all/etc/default_dev.yaml:99-112``SMTP.default` / `SMS.default` 改成代码实际使用的键(`qq``aliyun`,需要时再加 `tencent`),或反过来让 `mail/send.go:31``impl/provider.go:31-49` 支持 `default` 作为回退键。两条路选一条,保证聚合部署可用。
10. **邮件客户端修复**`internal/logic/mail/send.go:89-94` 改为 `if client != nil { client.Close() }` 或直接不关(`conn` 已可关);`:146-150` 只保留一个 `writer.Close()`;成功路径补 `client.Quit()`(参考 `cmd/cli/main.go:120`)。邮件头改为 `net/mail` + `mime.QEncoding` 生成,`subject` 做 CRLF 过滤。
11. **日志脱敏**:删除 `internal/logic/sms/send.go:96,146-147``fmt.Println`(或改为只打条数/不打印号码),手机号按 `138****8000` 形式记录。
12. **生产配置与凭据**`etc/sender_prod.yaml:7,10` 改为真实生产库/Redis`:38-40` 修正 Endpoint 并接入真实凭据(走环境变量而非入库);轮换 `etc/sender_dev.yaml:40` 中的 AccessKeyId。
13. **文档与测试**:改写 `README.md:149-155,170,352,379,63-103``UPGRADE_SUMMARY.md:19-31,60-68` 中与实现不符的内容路径、端口、渠道、Docker 文件);把 `test/grpc/*_test.go``Provider` 改成已实现的 `qq`;至少为 `GenValidateCode`(长度与字符集)、`VerifyPhone``ValidateEmail``Send``is_gen_code` 两分支各补一个表驱动用例(可用 `miniredis` 或直接把 Redis 操作收敛为可替换的客户端变量,注意不要为此新建抽象层,复用 `impl.RedisService` 现有赋值点即可)。
> 本报告只列出与现有实现直接相关的修复项,不引入新的分层、抽象封装或 DTO/VO 改造。