# 国际/港澳台短信按号码限频 —— 设计文档

日期：2026-07-19
状态：已评审通过，待实现

## 背景

`POST /sms/send`（`app/controller/sms.py`）目前只有一层限流：每个手机号每分钟最多 1 条（`_rate_limit()`）。国际/港澳台短信单价远高于国内短信，需要额外的每日配额约束。

## 目标

对**国际/港澳台号码**增加限制：**每个手机号每个自然日最多成功发送 2 条**，阈值可配置。

## 非目标

- **不实现全局每日总量上限**（例如全站 600 条/天）。该方案在讨论中已被「按号码限频」替代。
- **本次不引入测试框架**。项目当前无 `tests/` 目录，`requirements.txt` 无 pytest，`script/test.py` 为空。测试留待后续决定。
- 不改动国内短信的现有行为。

## 设计决策

### 1. 号码判定：排除法

```python
_MAINLAND_RE = re.compile(r"^1[3-9]\d{9}$")

def _is_intl(phone: str) -> bool:
    return not _MAINLAND_RE.match(phone)
```

匹配大陆 11 位手机号的视为国内，不受此限制；其余一律视为国际/港澳台。

**为什么选它**：无需维护国家/地区码表，天然覆盖所有国家；港澳台（852/853/886）判定完全准确。

**已知取舍**：美加 `+1` 号码与大陆号码格式冲突 —— `14155551234` 同样是 1 开头 11 位、第二位落在 3-9 区间，会被判为大陆号从而**绕过本限制**。已接受此缺口。若后续要堵，可用「阿里云国际签名与国内签名不通用」这一事实，在 config 中标注签名类型作为兜底判据。

**其他备选**（未采纳）：地区码白名单（需长期维护码表，且 `+1` 仍需特殊处理）、调用方显式传 `is_intl` 字段（改协议，且老调用方不传即绕过）。

### 2. 计数规则：自然日 + 只计成功

- **重置**：每日 00:00（Asia/Shanghai，容器已在 `docker-compose.yml` 设置 `TZ=Asia/Shanghai`）。
- **口径**：仅阿里云返回 `OK` 时计数 +1。发送失败（网络异常、签名非法等）**不消耗额度**，可重试。

**为什么只计成功**：语义符合「发送两条」的直觉，且阿里云侧故障不会误伤正常用户。失败重试虽不受日限约束，但仍被现有的每分钟 1 条限制兜住。

**其他备选**（未采纳）：计所有尝试（阿里云抽风会让用户当天彻底收不到短信）；滚动 24 小时窗口（用户无法预期何时解封）。

### 3. 存储：复用现有内存缓存

使用 `app/utils/cache_util.py`（`cacheout.Cache`），与 `_rate_limit()` 保持同一套路。

| 项 | 值 |
|---|---|
| key | `sms#intl#daily#{phone}` |
| value | 当日成功发送数（int） |
| ttl | 距当日 24:00 的秒数，每次自增都重设 |

每次自增都重算到午夜的剩余秒数，因此过期点始终锁定 0 点整，不随发送时间漂移。

**为什么选内存**：零新增依赖，改动最小。代价是容器重启后计数归零。因为额度是按号码分散的小额度（每号 2 条），重启的爆炸半径仅为「个别号码当天可能多收到 2 条」，可接受。

**其他备选**（未采纳）：本地文件持久化（需处理并发写与过期清理，代码量约 3 倍）；引入 Redis（最可靠，但项目当前无 Redis，需改 docker-compose 并多维护一个组件）。

**已知局限**：
- 容器重启导致计数归零。
- `cacheout.Cache` 的 `maxsize=100000`，超出后按 LRU 淘汰，极端情况下计数器可能被提前逐出。当前量级下不构成实际风险。

### 4. 接入点与执行顺序

在 `send()` 中的顺序：

```
token 校验 → 参数校验 → 【日限检查】 → _rate_limit() → 发送 → 成功后【计数 +1】
```

日限检查**必须放在 `_rate_limit()` 之前**：日限检查是纯读、无副作用，而 `_rate_limit()` 会写入分钟窗口 key。先跑日限可避免一个已超日限的请求白白占掉该号码的分钟额度。

计数自增只放在发送成功分支（`success == True`，紧邻现有写 `sms#code#` 的位置），以落实「只计成功」。

### 5. 配置

`app/conf/config.json` 新增顶层字段：

```json
"sms_limit": {
  "intl_daily_per_phone": 2
}
```

读取方式：`config_util.deep_get("sms_limit.intl_daily_per_phone", 2)`（`deep_get` 使用点号分隔路径）。**缺失时回退默认值 2**，使现有部署无需改配置即可工作，不会因漏配而报错。

### 6. 超限响应

```json
{
  "code": 400,
  "message": "该号码今日发送次数已达上限（2 条），请次日再试",
  "data": null
}
```

沿用现有的 400 + 中文提示风格（与「发送过于频繁」一致）。条数从配置读出后拼接，不硬编码。同时记录一条 warning 日志便于排查。

### 7. 号码归一化

在入口处对 `phone` 执行 `strip()`。否则 `"85295667799"` 与 `" 85295667799"` 会生成两个不同的计数 key，等于凭空多出一倍额度。归一化后的值同时用于计数与实际发送，保证一致。

### 8. 并发

Flask 内置服务器（`main.py` 的 `app.run()`）默认多线程，计数的「读-改-写」理论上存在竞态。但同一号码的并发请求已被 `_rate_limit()` 的每分钟 1 条挡在门外，该竞态实际不可达，因此**不加锁**。

## 实现注意事项

**cacheout 的 `ttl=0` 语义**：cacheout 中 `ttl=0` 表示「永不过期」。恰好在 0 点整调用时，「距午夜秒数」可能算出 0，将导致计数器永久驻留、该号码被永久封禁。必须用 `max(1, seconds)` 兜底。

此语义**待实现时实测确认** —— cacheout 未安装在项目的任一 venv 中，本次设计阶段无法验证。

## 验证方式

本次不写自动化测试，采用手工验证，需覆盖：

1. 港澳号码（`85295667799`）连发：第 1、2 条成功，第 3 条返回 400 超限。
2. 大陆号码（`13800138000`）不受此限制。
3. 发送失败时计数不增加。
4. 不同号码之间计数互不干扰。
5. 号码前后带空格时与不带空格算作同一号码。
6. 配置项缺失时按默认值 2 生效。

因存在每分钟 1 条的限制，手工连发验证时需在两次发送之间等待 1 分钟，或临时调整该限制。
