# 国际/港澳台短信按号码限频 —— 实现计划

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 为国际/港澳台手机号增加「每号每自然日最多成功发送 2 条」的限制，阈值可配置。

**Architecture:** 全部改动集中在 `app/controller/sms.py`。用正则排除法判定国际号码，用现有的进程内缓存 `cache_util` 存放按号码的当日计数，TTL 锁定到当日 24:00。日限检查前置于现有的分钟级限流，计数只在阿里云返回成功后自增。

**Tech Stack:** Python 3.10、Flask、cacheout 0.13.1、阿里云 dysmsapi SDK。

**设计文档：** `docs/superpowers/specs/2026-07-19-sms-intl-daily-limit-design.md`

## Global Constraints

- **本次不引入测试框架**。不新增 pytest、不创建 `tests/` 目录。验证一律通过可执行的 Python 片段与 Flask test client 完成。
- **不得改变国内短信的现有行为**。大陆号码不受任何新增限制。
- **不实现全局每日总量上限**（如全站 600 条/天），该方案已被按号码限频替代。
- 阈值默认值为 `2`，配置缺失时必须回退到该默认值，不得抛异常。
- 计数 key 格式固定为 `sms#intl#daily#{phone}`。
- TTL 计算结果必须 `>= 1`。**已实测确认**：cacheout `cache.py:313` 为 `if ttl and ttl > 0`，传入 `0` 时不写入过期时间，key 将永不过期。
- 所有新增用户可见文案使用中文，与现有 `"发送过于频繁，请等待1分钟后再试"` 风格一致。

## 已知取舍（不要在实现中"修复"）

美加 `+1` 号码（如 `14155551234`）与大陆号码格式冲突，会被判为国内号码从而绕过本限制。这是设计阶段已接受的取舍，**不要自行添加特殊处理**。

## File Structure

| 文件 | 动作 | 职责 |
|---|---|---|
| `app/controller/sms.py` | 修改 | 号码判定、TTL 计算、日限计数器、接入 `send()` 流程 |
| `app/conf/config.json` | 修改 | 新增 `sms_limit.intl_daily_per_phone` 配置项 |

⚠️ **`app/conf/` 在 `.gitignore` 中，config.json 不受版本控制**。对它的修改只存在于本地，无法提交，也不会同步到其他部署。代码必须在该配置缺失时正常工作。

---

### Task 1: 恢复可用的 Python 环境

当前项目**没有任何能运行的 Python 环境**，后续所有验证步骤都依赖本任务：

- `venv/bin/python` 是指向已不存在的 Python 3.8 的坏符号链接。
- 系统 `python3` 为 3.13，而阿里云 SDK 依赖的 `aiohttp` 会 `import cgi`，该模块在 Python 3.13 已被移除，导入必然失败。

`/opt/homebrew/bin/python3.10` 可用，与 `Dockerfile` 的 `python:3.10.14-alpine` 一致。

**Files:**
- 无源码改动（仅重建被 gitignore 的 `venv/` 目录）

**Interfaces:**
- Produces: 可用的解释器 `venv/bin/python`，供后续所有任务的验证步骤使用

- [ ] **Step 1: 确认待删除的 venv 确实是坏的**

```bash
cd /Users/yaha/git-projects/nsrc-open-message
ls -l venv/bin/python
```

预期：显示一个指向不存在路径的符号链接（`No such file or directory` 或箭头指向缺失的 python3.8）。

⚠️ **这一步会删除 `venv/` 目录。** 该目录已被 gitignore、且可由 `requirements.txt` 完整重建，但删除前请确认上面的输出证实它确实已损坏。若它其实可用，**停下来向用户确认**，不要删。

- [ ] **Step 2: 重建 venv 并安装依赖**

```bash
cd /Users/yaha/git-projects/nsrc-open-message
rm -rf venv
/opt/homebrew/bin/python3.10 -m venv venv
venv/bin/pip install -q -r requirements.txt
```

预期：安装过程无 ERROR。

⚠️ **注意**：`requirements.txt` 未锁定任何版本号，本次会装入各依赖的最新版本，可能与原环境不一致。若后续步骤出现与本计划描述不符的行为，优先怀疑依赖版本差异。

- [ ] **Step 3: 验证环境可用**

```bash
cd /Users/yaha/git-projects/nsrc-open-message
venv/bin/python -c "
import app.controller.sms as s
from cacheout import Cache
print('模块导入成功')
print('cacheout 版本可用:', Cache is not None)
"
```

预期输出：
```
模块导入成功
cacheout 版本可用: True
```

若报 `ModuleNotFoundError`，说明依赖未装全，回到 Step 2 检查 pip 输出。

- [ ] **Step 4: 无需提交**

本任务不产生任何受版本控制的改动（`venv/` 已被 gitignore）。跳过提交。

---

### Task 2: 号码判定与午夜 TTL 两个纯函数

**Files:**
- Modify: `app/controller/sms.py`

**Interfaces:**
- Consumes: 无
- Produces:
  - `_is_intl(phone: str) -> bool` —— 是否为国际/港澳台号码
  - `_seconds_to_midnight() -> int` —— 距当日 24:00 的秒数，保证 `>= 1`
  - 模块级常量 `_MAINLAND_RE`、`_INTL_DAILY_LIMIT_DEFAULT`

- [ ] **Step 1: 补充 import**

把 `app/controller/sms.py` 顶部的：

```python
import json
```

改为：

```python
import json
import re
from datetime import datetime, timedelta
```

- [ ] **Step 2: 新增模块级常量**

在 `logger = log_util.logger()` 这一行之后，插入：

```python

# 大陆手机号：1[3-9] 开头共 11 位
_MAINLAND_RE = re.compile(r"^1[3-9]\d{9}$")
# 国际/港澳台号码每号每日发送上限的兜底默认值（config 缺失时生效）
_INTL_DAILY_LIMIT_DEFAULT = 2
```

- [ ] **Step 3: 新增两个纯函数**

追加到文件**末尾**（`_rate_limit` 函数之后）：

```python


def _is_intl(phone: str) -> bool:
    """
    是否为国际/港澳台号码。
    大陆 11 位手机号视为国内，其余一律视为国际/港澳台。
    已知取舍：美加 +1 号码（如 14155551234）与大陆号码格式冲突，会被判为国内。
    """
    return _MAINLAND_RE.match(phone or "") is None


def _seconds_to_midnight() -> int:
    """
    距当日 24:00 的剩余秒数（本地时区，容器为 Asia/Shanghai）。
    最小返回 1：cacheout 中 ttl=0 表示永不过期，必须避开。
    """
    now = datetime.now()
    midnight = (now + timedelta(days=1)).replace(
        hour=0, minute=0, second=0, microsecond=0)
    return max(1, int((midnight - now).total_seconds()))
```

- [ ] **Step 4: 验证两个函数的行为**

```bash
cd /Users/yaha/git-projects/nsrc-open-message
venv/bin/python -c "
from app.controller.sms import _is_intl, _seconds_to_midnight

cases = [
    ('13800138000',  False, '大陆号'),
    ('19912345678',  False, '大陆号 199 段'),
    ('85295667799',  True,  '香港'),
    ('85366123456',  True,  '澳门'),
    ('886912345678', True,  '台湾'),
    ('447927102700', True,  '英国'),
    ('14155551234',  False, '美国-已知取舍，判为国内'),
    ('',             True,  '空串'),
]
bad = 0
for phone, expect, desc in cases:
    got = _is_intl(phone)
    ok = got == expect
    bad += 0 if ok else 1
    print(('OK  ' if ok else 'FAIL'), '{:14}'.format(repr(phone)), '_is_intl =', got, '|', desc)

s = _seconds_to_midnight()
ok = 1 <= s <= 86400
bad += 0 if ok else 1
print(('OK  ' if ok else 'FAIL'), '_seconds_to_midnight() =', s, '(应落在 1..86400)')
print()
print('全部通过' if bad == 0 else '{} 项不符预期'.format(bad))
"
```

预期输出最后一行为 `全部通过`，且美国号码那行显示 `_is_intl = False`（这是刻意保留的已知取舍，不是 bug）。

- [ ] **Step 5: 提交**

```bash
cd /Users/yaha/git-projects/nsrc-open-message
git add app/controller/sms.py
git commit -m "feat[sms]: 新增国际号码判定与午夜 TTL 计算

_is_intl 用排除法判定国际/港澳台号码；_seconds_to_midnight 返回
距当日 24:00 的秒数并保证不小于 1，避开 cacheout 中 ttl=0 表示
永不过期的语义。"
```

---

### Task 3: 日限计数器

**Files:**
- Modify: `app/controller/sms.py`
- Modify: `app/conf/config.json` （**gitignore 中，不会被提交**）

**Interfaces:**
- Consumes: `_is_intl(phone) -> bool`、`_seconds_to_midnight() -> int`（Task 2）
- Produces:
  - `_intl_daily_limit() -> int` —— 读取配置的上限，缺失回退 2
  - `_intl_daily_key(phone: str) -> str` —— 计数缓存 key
  - `_intl_daily_exceeded(phone: str) -> bool` —— 是否已达上限；国内号码恒为 `False`
  - `_intl_daily_incr(phone: str) -> None` —— 计数 +1；国内号码为空操作

- [ ] **Step 1: 在 config.json 新增配置项**

编辑 `app/conf/config.json`，在顶层对象内新增 `sms_limit` 字段（放在 `"api_tokens"` 之前，注意逗号）：

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

⚠️ 该文件被 gitignore，此改动**不会进入版本控制**。代码在配置缺失时须回退默认值 2，这一点由 Step 4 验证。

- [ ] **Step 2: 新增四个计数器函数**

追加到 `app/controller/sms.py` **末尾**（Task 2 新增的 `_seconds_to_midnight` 之后）：

```python


def _intl_daily_limit() -> int:
    """国际/港澳台号码每号每日发送上限，配置缺失时回退默认值。"""
    return config_util.deep_get("sms_limit.intl_daily_per_phone",
                                _INTL_DAILY_LIMIT_DEFAULT)


def _intl_daily_key(phone: str) -> str:
    return "sms#intl#daily#{}".format(phone)


def _intl_daily_exceeded(phone: str) -> bool:
    """国际/港澳台号码当日成功发送数是否已达上限。国内号码恒为 False。"""
    if not _is_intl(phone):
        return False
    return cache_util.get(_intl_daily_key(phone), 0) >= _intl_daily_limit()


def _intl_daily_incr(phone: str) -> None:
    """国际/港澳台号码当日成功发送数 +1，过期时间锁定当日 24:00。国内号码不计数。"""
    if not _is_intl(phone):
        return
    key = _intl_daily_key(phone)
    cache_util.set(key, cache_util.get(key, 0) + 1, ttl=_seconds_to_midnight())
```

- [ ] **Step 3: 验证计数与放行/拦截行为**

```bash
cd /Users/yaha/git-projects/nsrc-open-message
venv/bin/python -c "
from app.utils import cache_util
from app.controller.sms import (
    _intl_daily_limit, _intl_daily_exceeded, _intl_daily_incr, _intl_daily_key)

bad = 0
def check(desc, got, expect):
    global bad
    ok = got == expect
    bad += 0 if ok else 1
    print(('OK  ' if ok else 'FAIL'), desc, '->', got, '(期望', expect, ')')

check('配置读取的上限', _intl_daily_limit(), 2)

hk = '85295667799'
check('港号初始未超限', _intl_daily_exceeded(hk), False)
_intl_daily_incr(hk)
check('港号发1条后未超限', _intl_daily_exceeded(hk), False)
_intl_daily_incr(hk)
check('港号发2条后已超限', _intl_daily_exceeded(hk), True)
check('港号计数值', cache_util.get(_intl_daily_key(hk)), 2)

other = '85366123456'
check('另一澳号不受影响', _intl_daily_exceeded(other), False)

cn = '13800138000'
for _ in range(5):
    _intl_daily_incr(cn)
check('大陆号发5条后未超限', _intl_daily_exceeded(cn), False)
check('大陆号完全不写计数', cache_util.get(_intl_daily_key(cn)), None)

print()
print('全部通过' if bad == 0 else '{} 项不符预期'.format(bad))
"
```

预期输出最后一行为 `全部通过`。

- [ ] **Step 4: 验证配置缺失时回退默认值**

```bash
cd /Users/yaha/git-projects/nsrc-open-message
venv/bin/python -c "
from app.utils import config_util
from app.controller.sms import _intl_daily_limit

# 模拟配置里没有 sms_limit 的情况
orig = config_util._config_.pop('sms_limit', None)
got = _intl_daily_limit()
print(('OK  ' if got == 2 else 'FAIL'), '配置缺失时回退值 =', got, '(期望 2)')
if orig is not None:
    config_util._config_['sms_limit'] = orig
"
```

预期输出：`OK   配置缺失时回退值 = 2 (期望 2)`

- [ ] **Step 5: 提交**

只提交 `sms.py`，config.json 被 gitignore 无法提交。

```bash
cd /Users/yaha/git-projects/nsrc-open-message
git add app/controller/sms.py
git commit -m "feat[sms]: 新增国际号码每日发送计数器

按 sms#intl#daily#{phone} 记录当日成功发送数，TTL 锁定当日 24:00。
上限读自 sms_limit.intl_daily_per_phone，缺失时回退默认值 2。
国内号码不计数、不受限。"
```

---

### Task 4: 接入 send() 发送流程

**Files:**
- Modify: `app/controller/sms.py`

**Interfaces:**
- Consumes: `_intl_daily_exceeded(phone) -> bool`、`_intl_daily_incr(phone) -> None`、`_intl_daily_limit() -> int`（Task 3）
- Produces: 无（终端任务）

- [ ] **Step 1: 号码归一化**

在 `send()` 中，把：

```python
    phone = data.get("phone","")
```

改为：

```python
    phone = data.get("phone","").strip()
```

避免 `"85295667799"` 与 `" 85295667799"` 生成两个不同计数 key、凭空多出一倍额度。

- [ ] **Step 2: 插入日限检查**

在 `send()` 中找到：

```python
        if _rate_limit(phone):
```

在**这一行之前**插入：

```python
        if _intl_daily_exceeded(phone):
            logger.warning("sms intl daily limit exceeded: {}".format(phone))
            return json.dumps({
                "code": 400,
                "message": "该号码今日发送次数已达上限（{} 条），请次日再试".format(
                    _intl_daily_limit()),
                "data": None
            })
```

顺序不可调换：日限检查是纯读、无副作用，而 `_rate_limit()` 会**写入**分钟窗口 key。先跑日限可避免超限请求白白占掉该号码的分钟额度。

- [ ] **Step 3: 成功后自增计数**

找到成功分支：

```python
        if success:
            cache_util.set("sms#code#{}".format(phone), template_param.get("code"), ttl=600)
```

在 `cache_util.set(...)` 那一行**之后**插入：

```python
            _intl_daily_incr(phone)
```

放在成功分支内是「只计成功」的落地点：发送失败不消耗额度。

- [ ] **Step 4: 在 docstring 中补充限制说明**

在 `send()` 的 docstring 末尾（`template_code，与国内签名模板不通用。` 之后、`"""` 之前）追加：

```
    限制：国际/港澳台号码每个号码每个自然日最多成功发送 2 条
          （阈值见 config.json 的 sms_limit.intl_daily_per_phone），
          每日 00:00 重置。发送失败不消耗额度。大陆号码不受此限。
```

- [ ] **Step 5: 语法检查**

```bash
cd /Users/yaha/git-projects/nsrc-open-message
venv/bin/python -c "
import ast
ast.parse(open('app/controller/sms.py', encoding='utf-8').read())
print('语法检查通过')
"
```

预期输出：`语法检查通过`

- [ ] **Step 6: 端到端验证超限拦截**

本步骤**不会真正调用阿里云**：计数预置到上限后，请求会在日限检查处被拦截并直接返回，永远走不到 `_send_sms()`。因此不产生费用、不发真实短信。

```bash
cd /Users/yaha/git-projects/nsrc-open-message
venv/bin/python -c "
import json
from main import create_app
from app.utils import cache_util

app = create_app()
client = app.test_client()
hk = '85295667799'
token = 'cnsda__Cq8F0vg7Pdpjl39O'

def post(phone):
    r = client.post('/sms/send', json={
        'phone': phone, 'sign': '数据蜂',
        'template_code': 'SMS_TEST', 'token': token})
    return json.loads(r.data.decode())

bad = 0
def check(desc, got, expect):
    global bad
    ok = got == expect
    bad += 0 if ok else 1
    print(('OK  ' if ok else 'FAIL'), desc, '->', repr(got))

# 预置计数到上限，请求应在触达阿里云前被拦截
cache_util.set('sms#intl#daily#' + hk, 2, ttl=3600)
resp = post(hk)
check('港号超限被拦截 code', resp.get('code'), 400)
check('超限提示文案', '今日发送次数已达上限' in resp.get('message', ''), True)

# 带空格的同一号码应命中同一计数 key，同样被拦截
resp = post('  ' + hk + '  ')
check('带空格视为同一号码', resp.get('code'), 400)

print()
print('全部通过' if bad == 0 else '{} 项不符预期'.format(bad))
" 2>&1 | grep -v "^WARNING\|^INFO"
```

预期输出最后一行为 `全部通过`，三项均为 `OK`。

若「带空格」那项返回的不是 400，说明 Step 1 的 `.strip()` 未生效。

- [ ] **Step 7: 验证发送失败不消耗额度**

「只计成功」是本功能的核心决策，必须验证失败路径确实不计数。本步骤用桩函数替换 `_send_sms`，模拟阿里云返回失败，**同样不会真正调用阿里云**。

```bash
cd /Users/yaha/git-projects/nsrc-open-message
venv/bin/python -c "
import json
from main import create_app
from app.utils import cache_util
import app.controller.sms as sms

app = create_app()
client = app.test_client()
mo = '85377712345'   # 用全新号码，避免与前面验证的计数互相污染
token = 'cnsda__Cq8F0vg7Pdpjl39O'

class FakeFail:
    code = 'isv.SMS_SIGNATURE_ILLEGAL'
    message = '签名不合法'

# 桩掉真实发送，模拟阿里云返回失败
sms._send_sms = lambda *a, **kw: FakeFail()

r = client.post('/sms/send', json={
    'phone': mo, 'sign': '数据蜂', 'template_code': 'SMS_TEST', 'token': token})
resp = json.loads(r.data.decode())
count = cache_util.get('sms#intl#daily#' + mo)

bad = 0
def check(desc, got, expect):
    global bad
    ok = got == expect
    bad += 0 if ok else 1
    print(('OK  ' if ok else 'FAIL'), desc, '->', repr(got), '(期望', repr(expect), ')')

check('失败响应 code', resp.get('code'), 500)
check('失败后未写入计数', count, None)

print()
print('全部通过' if bad == 0 else '{} 项不符预期'.format(bad))
" 2>&1 | grep -v "^WARNING\|^INFO"
```

预期输出最后一行为 `全部通过`。

若「失败后未写入计数」返回的不是 `None`，说明 Step 3 把 `_intl_daily_incr(phone)` 放到了成功分支之外，需要修正位置。

- [ ] **Step 8: 提交**

```bash
cd /Users/yaha/git-projects/nsrc-open-message
git add app/controller/sms.py
git commit -m "feat[sms]: 国际/港澳台号码接入每日发送限制

日限检查前置于分钟级限流（纯读无副作用，避免超限请求占用分钟额度），
计数仅在发送成功后自增。入口对 phone 做 strip 归一化，防止空格导致
同一号码命中不同计数 key。"
```

---

## 未覆盖事项

以下内容**不在本计划范围内**，如需处理请另行安排：

- **真实发送链路未验证**。Step 6 只验证了「超限拦截」路径，因为它不触达阿里云。「发送成功后计数 +1」的路径需要真实的国际签名与模板才能端到端验证，而 `app/conf/config.json` 中当前配置的签名均为国内类型。建议在具备国际签名后手工补验一次。
- **容器重启导致计数归零**，见设计文档「已知局限」。
- **美加 `+1` 号码绕过限制**，为已接受的取舍。
- **自动化测试**，本次按用户要求不引入。
