From b909cfbac0cc3c1fcc2fd3efef74c27b0bca54b5 Mon Sep 17 00:00:00 2001 From: zhr Date: Tue, 22 Sep 2026 17:39:38 +0800 Subject: [PATCH] feat: update sms docs --- .cursor/skills/spark-im-v3/http.md | 11 ++- README.md | 8 +- docs/sms.md | 97 +++++++++++++++++++++++ internal/transport/http/docs/openapi.yaml | 22 +++-- 4 files changed, 128 insertions(+), 10 deletions(-) create mode 100644 docs/sms.md diff --git a/.cursor/skills/spark-im-v3/http.md b/.cursor/skills/spark-im-v3/http.md index c11a7ea..6879ea9 100644 --- a/.cursor/skills/spark-im-v3/http.md +++ b/.cursor/skills/spark-im-v3/http.md @@ -80,7 +80,7 @@ Public HTTP is `/v1/{resource}/...`. Register with Gin groups in `internal/trans |-------|--------|----------| | system | `/healthz`, `/readyz`, `/docs` | liveness, readiness, OpenAPI(`/docs` API,`/docs/enums` 枚举,`/docs/errors` 错误码) | | auth | `/v1/auth` | `POST /v1/auth/login`, `POST /v1/auth/token/refresh`, `POST /v1/auth/logout` | -| sms | `/v1/sms` | `POST /v1/sms/codes`(`X-Language` 必填) | +| sms | `/v1/sms` | `POST /v1/sms/codes`(`X-Language` 必填;封控见 `docs/sms.md`) | | users | `/v1/users` | `GET/PATCH/DELETE /v1/users/me` | | friends | `/v1/friends` | apply, requests, accept, profile, remark, pin, block, report;`GET /v1/friends/lookup?q=` | | groups | `/v1/groups` | create, profile, members, invite, kick, leave, transfer, dissolve | @@ -96,6 +96,15 @@ Rules: - Noun resources, HTTP verbs for actions. No `/getUser`, `/sendMessage` under HTTP. - Login stays `POST /v1/auth/login` (create session). Do not put login under `/v1/users`. - Anonymous paths belong in `publicPath` (login, `POST /v1/sms/codes`, token refresh, healthz, readyz, docs, `GET /v1/meta/enums`). + +### OTP send rate limits (`POST /v1/sms/codes`) + +Documented in **`docs/sms.md`**. Implementation: + +1. **`AuthRateLimit`** on `RealIP` for `/v1/sms/codes`, `/v1/auth/login`, `/v1/auth/token/refresh` — token bucket `rate_limit.auth_per_second` (default 5). +2. **`sms.logic.Send`** — per `{channel}:{purpose}:{target}` **60s** cooldown via `CodeStore.SetCooldown` (`sms:cd:*` in Redis); OTP TTL **300s** (`sms:code:*`). + +Both return **`errs.ErrRateLimited` (`42902`)**. UI countdown should follow **60s** target cooldown, not `expires_in`. - OpenAPI `tags` must match these groups. Docs jump tags `enums` / `errors` sit above `system` and open `/docs/enums`、`/docs/errors`. ## Checklist when adding an HTTP API diff --git a/README.md b/README.md index 378dea8..404486a 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ Spark IM 服务端。Go 1.26 **模块化单体**(一台机器、一个进程 仓库: 完整设计:[docs/architecture-v3.md](docs/architecture-v3.md) +验证码发码 / 封控:[docs/sms.md](docs/sms.md) 朋友圈(HTTP v1,无 TCP Push):[docs/moments.md](docs/moments.md) 在线协议:`GET /docs`(Swagger)、`/docs/enums`、`/docs/errors`,规范 `GET /openapi.yaml`。 @@ -94,13 +95,13 @@ HTTP 与 TCP **应答 Body** 同一套信封。业务成败只看 `code` 字符 | Path | 资源 id:好友 uid、群号、MsgId | | Query | GET 筛选 / 分页 | -语言:`zh-CN`(缺省)/ `en` / `zh-TW`。网关写 `X-Real-IP`,客户端不要发。 +语言:`en`(缺省)/ `zh-CN` / `zh-TW`(`X-Language` 或 `Accept-Language`)。发码接口 **`X-Language` 必填**。网关写 `X-Real-IP`,客户端不要发。 --- ## 登录 -1. (可选)`POST /v1/sms/codes`,Header **`X-Language` 必填**,Body `{"purpose":1,"target":"…"}`。 +1. (可选)`POST /v1/sms/codes`,Header **`X-Language` 必填**,Body `{"purpose":1,"target":"…"}`。封控:**RealIP 令牌桶**(默认 5/s,与 login/refresh 共用)+ **同 target 60s 冷却**,均 `42902` — 见 [docs/sms.md](docs/sms.md)。 2. `POST /v1/auth/login` 拿 `access_token` / `refresh_token`。`device_id` 由**客户端生成并持久化**,服务端不分配;写入 JWT `did`。 3. 其它 HTTP:`Authorization: Bearer …`,可选 `X-Device-ID`(须等于登录时的 device_id)。 4. 续期:`POST /v1/auth/token/refresh`,Body `{"refresh_token"}`,不要带 Bearer。 @@ -121,7 +122,8 @@ HTTP 与 TCP **应答 Body** 同一套信封。业务成败只看 `code` 字符 | 探活 | `/healthz` `/readyz` | 存活;未就绪 HTTP 503 | | 文档 | `/docs` `/docs/enums` `/docs/errors` `/openapi.yaml` | 无需登录 | | 枚举 | `GET /v1/meta/enums` | 无需登录 | -| 认证 | `/v1/auth` | 发码、登录、刷新、登出当前设备 | +| 验证码 | `/v1/sms/codes` | 发码(封控见 [docs/sms.md](docs/sms.md)) | +| 认证 | `/v1/auth` | 登录、刷新、登出当前设备 | | 用户 | `/v1/users/me` | 资料;`DELETE` 注销(迁入 `user_delete`) | | 好友 | `/v1/friends` | 申请、同意、查找(`GET /lookup?q=`)、备注、置顶、拉黑、举报 | | 群 | `/v1/groups` | 创建、资料、成员、邀请、踢人、转让、解散 | diff --git a/docs/sms.md b/docs/sms.md new file mode 100644 index 0000000..c6fec3b --- /dev/null +++ b/docs/sms.md @@ -0,0 +1,97 @@ +# 验证码(SMS 模块) + +邮箱与手机 OTP 统一走 **`POST /v1/sms/codes`**。校验在登录 / 注销等业务里调用 `sms` logic,不单独暴露校验 HTTP。 + +OpenAPI:`GET /openapi.yaml` → `/v1/sms/codes`。错误码目录:`/docs/errors`(限流 **`42902`**)。 + +--- + +## 发码接口 + +| 项 | 规则 | +| --- | --- | +| 路径 | `POST /v1/sms/codes` | +| 登录 | 无需 Bearer | +| 注销(`purpose=2`) | 需要 Bearer;`target` 必须是当前账号**绑定邮箱**(不支持向手机号发注销码) | +| Header | **`X-Language` 必填**(`zh-CN` / `en` / `zh-TW`),用于邮件主题/正文;非法或缺失 → `40001` | +| Body | `purpose`:`1`=登录,`2`=注销;`target`:邮箱或大陆 11 位手机号(可带 `+86`),服务端识别,**不要**再传 channel | +| 成功 `data` | `{"expires_in":300}` — 验证码有效秒数(固定 **300**,与 Redis TTL 一致) | + +网关须写入 **`X-Real-IP`**(或 PROXY 解析后的等价头)。限流键用 **`reqctx.RealIP`**,不用 gnet `RemoteAddr`(C6)。 + +--- + +## 封控规则(两层,都会返回 `42902`) + +客户端应:**同一 `target` 至少间隔 60s 再点「发送」**;并避免同一公网 IP 在短时间内狂刷登录/发码/刷新。 + +### 1. HTTP 层:按客户端 IP 令牌桶 + +| 项 | 值 | +| --- | --- | +| 中间件 | `AuthRateLimit`(在 Auth 之前) | +| 限流键 | `RealIP`(空则记为 `unknown`) | +| 算法 | 每 IP 一个令牌桶:`rate = burst = auth_per_second` | +| 配置 | `rate_limit.auth_per_second`(`configs/config.yaml`,默认 **5**) | +| 命中路径 | `POST /v1/sms/codes`、`POST /v1/auth/login`、`POST /v1/auth/token/refresh`(三条**共用**同一 IP 桶) | +| 响应 | HTTP 200,`code` **`42902`**,`msg` 随语言(如「请求过于频繁」/ `rate limited`) | + +说明:这是**进程内**限流,多实例各自计数;前面 Nginx 仍可再加 WAF / 全局限流。 + +### 2. 业务层:按「渠道 + 用途 + 目标」发送冷却 + +| 项 | 值 | +| --- | --- | +| 冷却时长 | **60 秒**(代码常量 `defaultCodeCooldownSeconds`,当前不可 YAML 配置) | +| 粒度 | 规范化后的 **`target`** + **`purpose`** + 渠道(邮箱 / 手机各算一条) | +| 存储键(逻辑) | `{channel}:{purpose}:{target}`,例如 `1:1:ada@example.com` | +| Redis | 冷却:`SETNX sms:cd:{逻辑键}` TTL 60s;验证码:`sms:code:{逻辑键}` TTL 300s | +| 行为 | 60s 内对**同一** target+purpose+渠道再次发码 → **`42902`**(不覆盖未过期的码) | +| 发送失败 | SMTP / 阿里云返回错误时会 `Del` 该逻辑键(含冷却),可立即重试(仍受 IP 桶约束) | + +**独立计数示例**(互不影响冷却): + +- `purpose=1` 与 `purpose=2` 各 60s 一条; +- 同一邮箱与同一手机号是不同 `channel`; +- 不同邮箱 / 不同手机号各自 60s。 + +### 客户端建议 + +| 场景 | 建议 | +| --- | --- | +| UI 倒计时 | 以 **60s** 为准(与业务冷却一致);`expires_in=300` 只表示码多久失效 | +| 收到 `42902` | 不要立刻连点;至少等待 60s 或提示用户稍后再试 | +| 多 Tab / 多设备 | 同一 target 共享冷却;不同 IP 仍共享 target 冷却 | + +--- + +## 验证码本身 + +| 项 | 规则 | +| --- | --- | +| 长度 | 6 位数字 | +| 有效期 | **300s**(5 分钟) | +| 校验 | 登录 / 注销等业务调用 `CheckAndConsume`:**正确则删除**(一次性);错误 / 过期 / 已用过 → **`40103`**(统一「验证码无效」) | +| 校验限流 | **无**单独「输错 N 次锁定」;仍受登录等接口的 IP 桶约束 | + +--- + +## 测试与白名单(不走真实 SMTP/短信) + +| 类型 | 规则 | +| --- | --- | +| 邮箱 | `test01@spark.com`~`test10@spark.com`:`Send` **不**发邮件、**不**写 Redis 冷却;登录 / 注销校验 **任意** `verify_code` | +| 手机 | `+8613800138000`~`+8613800138002`:不发短信;校验任意码(与测试邮箱类似) | + +--- + +## 配置速查 + +| 配置项 | 作用 | +| --- | --- | +| `rate_limit.auth_per_second` | IP 桶速率(默认 5/s,与 login / refresh / 发码共用) | +| `sms.smtp.*` | 邮箱 OTP(DirectMail SMTP) | +| `sms.aliyun_sms.*` | 手机 OTP(按运营商模板) | +| Redis | 生产存 OTP + 冷却;无 Redis 时进程内 MemoryStore(单实例开发) | + +OTP 的 **60s 冷却** 与 **300s 有效期** 在 `internal/module/sms/logic/service.go` 常量中;若要运营可调,需改代码并重新发布。 diff --git a/internal/transport/http/docs/openapi.yaml b/internal/transport/http/docs/openapi.yaml index 9c1b970..927efae 100644 --- a/internal/transport/http/docs/openapi.yaml +++ b/internal/transport/http/docs/openapi.yaml @@ -93,7 +93,7 @@ info: ### 调用顺序 - 1. (可选)`POST /v1/sms/codes` Header **`X-Language` 必填**(`zh-CN` / `en` / `zh-TW`),Body `{"purpose":1,"target":"邮箱或手机号"}`。服务端按格式识别邮箱/手机。测试号 `test01@spark.com`~`test10@spark.com` 可跳过发码。 + 1. (可选)`POST /v1/sms/codes` Header **`X-Language` 必填**(`zh-CN` / `en` / `zh-TW`),Body `{"purpose":1,"target":"邮箱或手机号"}`。封控见 [docs/sms.md](../../../docs/sms.md)(IP 桶 + 同 target 60s 冷却,均 `42902`)。测试号 `test01@spark.com`~`test10@spark.com` 可跳过发码。 2. `POST /v1/auth/login` Body 拿 `access_token` / `refresh_token`。`device_id` 由客户端生成并持久化,不要每次换。 3. 其它 HTTP:Header `Authorization: Bearer …`,可选 `X-Device-ID`、`X-Language`。 4. 续期:`POST /v1/auth/token/refresh`,**Body** `{"refresh_token"}`,不要带 Bearer。 @@ -510,10 +510,20 @@ paths: security: [] summary: 发送验证码 description: | - Body:`purpose` 1=登录 2=注销;`target` 为邮箱或大陆手机号(服务端识别,无需 channel)。 - **Header `X-Language` 必填**(不接受仅 Accept-Language),用于邮件正文语言;未识别时默认 `en`。 - 登录发码无需 Bearer;注销发码需 Bearer,且 `target` 须为当前绑定邮箱。 - 邮箱经 SMTP(`noreply@sparkmail.wyrs365.com`);手机走阿里云短信。 + Body:`purpose` 1=登录 2=注销;`target` 为邮箱或大陆 11 位手机号(服务端识别,无需 channel)。 + **Header `X-Language` 必填**(不接受仅 Accept-Language),用于邮件主题/正文。 + 登录发码无需 Bearer;注销发码需 Bearer,且 `target` 须为当前绑定邮箱(不支持手机号注销发码)。 + + **封控(失败均为 envelope `42902`,HTTP 仍 200)** + + 1. **按客户端 IP**(`RealIP`,依赖网关 `X-Real-IP` / PROXY):令牌桶,速率 `rate_limit.auth_per_second`(默认 5/s),与 + `POST /v1/auth/login`、`POST /v1/auth/token/refresh` **共用**同一 IP 桶。 + 2. **按 target**:同一「渠道 + purpose + 规范化 target」**60 秒内**只能成功触发一次发信/发短信;Redis 键 `sms:cd:*`(无 Redis 时为进程内内存)。 + 不同 purpose、不同邮箱/手机号互不影响。成功响应 `data.expires_in` 固定 **300**(码有效期秒数,与 60s 冷却无关)。 + + 测试邮箱 `test01@spark.com`~`test10@spark.com` 不发信且不占用 target 冷却。详情 [docs/sms.md](../../../docs/sms.md)。 + + 邮箱经 SMTP;手机走阿里云短信(按号段选运营商模板)。 operationId: sendVerificationCode parameters: - $ref: "#/components/parameters/X-Language" @@ -691,7 +701,7 @@ paths: summary: 注销当前账号 description: | 吊销全部设备的 JWT 并踢掉 TCP。将 `users` 行迁入 `user_delete` 后删除原行(邮箱可重新注册)。 - 非测试账号需先 `POST /v1/sms/codes`(purpose=2, channel=1)向绑定邮箱发码,再在本接口提交 `verify_code`。 + 非测试账号需先 `POST /v1/sms/codes`(`purpose=2`,`target` 为绑定邮箱)发码,再在本接口提交 `verify_code`(遵守发码封控,见 docs/sms.md)。 同时清除好友关系、朋友圈、收藏、群成员(群主把群转给一名管理员,没有管理员则转给成员表第一位后退出;仅剩自己则解散)、会话成员、收件箱 Redis 键与 mailbox。 对方仍可见历史消息(不删 `messages`)。 operationId: deleteMe