Files
zijunchiang 87aa979abd 合并自建歌曲源支线(selfhost-source)+ 版本号按约定修正为 1.6.0
版本号:
- 1.5.11 越界(规则 3 规定 patch ≤ 10)→ 修正为 1.6.0(进位到 minor、patch 归 0)
- versionCode 43 不变:43 从未对外发布,本次修正只是把命名拉回约定

合并支线(原在工作区根目录、App 仓库之外,DESIGN.md 自称"支线"):
- selfhost-source/server/   自建歌曲源服务器(纯 Python 标准库、零 pip 依赖)
- selfhost-source/deploy/、DESIGN.md、LIBRARY.md、build_inventory.py
- selfhost-source/inventory/ 小清单(playlists.csv、song-stats.md 等)
- ⚠️ inventory 生成物(songs-*.csv / singers-top.csv,共 84MB)不入库,
  已写入 .gitignore;需要时用 build_inventory.py 依据 muse.db 重新生成

同时入库的 App 侧支线改动(此前为工作树未提交状态):
- Whoami.kt(whoami 设备心跳)、DeviceIdentity.kt(设备身份伪装)
- PushDownload.kt / PushDownloadClient.kt(点歌推送)
- AdVideoFilter 带 query/fragment 的地址闸门;KTV_BRIDGE_API 11 → 13
- 修 ktv_api.js 的 global.KtvBridgeApiVersion(12 → 13,与注释及 Kotlin 两处校验一致)
- 对应单测:WhoamiTest / DeviceIdentityTest / PushDownloadTest / AdVideoFilterTest(共 39 例)

文档:
- DESIGN.md 的"在 App 仓库之外"约定改为"已并入仓库统一版本化"
- AGENTS.md 版本号行更新为 1.6.0/43,并新增支线并入说明

验证:assembleRelease + testReleaseUnitTest 全过(39 例)
2026-10-14 02:06:28 +08:00

521 lines
25 KiB
Python
Raw Permalink 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.
"""App 令牌(默认 10 分钟时效)与**双站点反代**的宿主隔离。
背景(为什么要有这一层):
* 已发布的官方 App 直接调 `GET /i.php` 与 `GET /music/do.php`,**不带**我们的令牌;
所以令牌校验默认**只"带了就校验"**(`app_token_enforce=false`),绝不能强制 ——
否则老 App 立刻全部 401(这是硬红线,见 `service.py` 顶部与 `deploy/iis/README.md`)。
* 未来的自有 App 想用一个短时效随机串换地址:先 `GET /token` 拿令牌,再带
`X-App-Token: <令牌>` 调 `/music/do.php` / `/push_download`。把 `app_token_enforce`
打开即变成"必须带令牌"。
* 反代拆两个站点(`mdmuse.kasugano.cn` 走 API、`md.kasugano.cn:4433` 只下文件)后,
后端要能**只认宿主**把两类流量分开:媒体宿主不许调 API、API 宿主不许批量下文件。
三个接口(都在**服务端口** 6677):
* `GET/POST /token` —— 签发一个随机令牌(`secrets.token_urlsafe(32)`);
* `GET /token/status` —— 运维核对:累计签发/校验失败/限流计数 + 未汇总的当前分钟计数;
* 其余受保护路径的校验发生在 `web.Router.guard`(**路由匹配之前**,见 `make_guard`)。
配置(都在 `ktv/config.py` 的 DEFAULTS 里,改完**立刻生效**、无需重启):
| 键 | 默认 | 说明 |
|---|---|---|
| `app_token_ttl_sec` | `600` | 有效期(秒),钳制 30~86400 |
| `app_token_bind_ip` | `True` | 绑定**签发时的真实客户端 IP**(配合 `trusted_proxies`/`trust_proxy_headers` 取真实 IP);换 IP 用同一令牌一律拒绝 |
| `app_token_single_use` | `False` | 用过即失效(媒体地址是多次调用,**默认必须是 False**) |
| `app_token_persist` | `False` | `True` = 令牌落 `meta` 表(重启后仍有效);`False` = 纯内存(默认) |
| `app_token_paths` | `"/music/do.php,/push_download"` | 受令牌管理的**路径前缀**列表(逗号分隔) |
| `app_token_enforce` | `False` | `True` = 上面那些路径**必须**带令牌;`False` = 只"带了就校验" |
| `token_rate_limit_per_min` | `60` | 同一真实客户端 IP 每分钟最多签发几个(0 = 关限流) |
| `media_hosts` | `[]` | 媒体宿主(如 `["md.kasugano.cn"]`);非空才启用宿主隔离 |
| `api_hosts` | `[]` | API 宿主(如 `["mdmuse.kasugano.cn"]`) |
宿主隔离规则(`media_hosts` 为空 = 完全不校验,行为与改造前一致):
* `/media/*` 之外的路径若来自**媒体宿主** → 404(媒体站只能下文件);
* `/media/*` 若来自**非媒体宿主**且 `api_hosts` 非空 → 404(API 站不对外提供批量下载);
* 唯一豁免 `GET /api/client_ip`(排错入口,它会告诉你判定成了哪个宿主)。
安全前提:宿主判定只采信**经过 `ktv/proxy.py` 可信校验**的 `X-Forwarded-Host`,
不可信时退回 `Host` 头(见 `proxy.effective_host`)—— 否则谁都能自己塞一个
`X-Forwarded-Host: md.kasugano.cn:4433` 绕过隔离,或塞 API 宿主名去偷媒体。
日志口径(**这个项目很在意日志量**):
* 令牌**成功签发/成功校验不逐笔记**,只计数;
* "签发 N 个"与"校验失败 N 次"按**自然分钟聚合成一条**,分钟翻页时写出(与
`ktv/pushdl.py` 的重复/拒绝聚合同一个套路);当前分钟还没汇总的计数在
`GET /token/status` 的 `pending_log` 里也能看到,不会丢。
⚠️ 守卫(`make_guard`)里的任何异常都**不吞**:`web.py` 会统一回 500 并记日志。
宁可响亮地失败,也不要在"强制令牌"打开时静默放行(fail-open 等于没有鉴权)。
"""
from __future__ import annotations
import hmac
import json
import re
import secrets
import threading
import time
from typing import Any, Callable
from . import proxy, web
# 默认受管路径(与 config.DEFAULTS["app_token_paths"] 同一份口径;消费方两种写法都认)
DEFAULT_PATHS = ("/music/do.php", "/push_download")
# 老 App 必走、且"带了令牌就校验"的入口(`/i.php` 默认不在 app_token_paths 里:
# `app_token_enforce=true` 时它**不强制**令牌,只有显式把它加进 app_token_paths 才强制)
LEGACY_CHECK_PATHS = ("/i.php",)
# `/i.php` 与 `/music/do.php` 的 `token` 参数是**老协议自己的**(= i.php 返回的 token),
# 不是我们的 App 令牌 —— 详见 `_is_legacy_param`。
LEGACY_PROTO_PATHS = ("/i.php", "/music/do.php")
TTL_MIN, TTL_MAX = 30, 86400
AUTH_ERROR = "令牌无效或已过期,请重新获取"
META_KEY = "app_tokens_v1" # app_token_persist=true 时令牌落在 meta 表的这一行
MAX_IPS = 512 # 限流窗口最多保留多少个来源 IP(防内存被撑爆)
MAX_TOKENS = 4096 # 内存里最多保留多少个未过期令牌(防签发洪水撑爆内存)
_SAMPLE_MAX = 160
# cfg 挂不上属性时的兜底注册表:id(cfg) → (cfg, TokenService)(持有强引用,见 registry_for)
_FALLBACK: dict[int, tuple[Any, "TokenService"]] = {}
def normalize_paths(value: Any) -> list[str]:
"""把 `app_token_paths` 归一化成 `["/music/do.php", …]`。
接受字符串(逗号/空白/分号分隔)或数组;缺前导 `/` 自动补上(配置层会给警告)。
"""
if value is None:
return []
if isinstance(value, str):
items = [x for x in re.split(r"[,\s;]+", value) if x]
elif isinstance(value, (list, tuple, set)):
items = [str(x) for x in value]
else:
items = [str(value)]
out: list[str] = []
for raw in items:
p = str(raw).strip()
if not p:
continue
out.append(p if p.startswith("/") else "/" + p)
return out
def _int(value: Any, default: int) -> int:
try:
return int(value)
except Exception:
return default
def _bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in ("1", "true", "yes", "on")
def _no_store(resp: web.Response) -> web.Response:
"""响应**禁止任何缓存**(令牌是短时效凭证,被中间层缓存住会直接变成安全事故)。"""
resp.headers["Cache-Control"] = "no-store"
resp.headers["Pragma"] = "no-cache"
return resp
def _not_found(req: web.Request) -> web.Response:
"""宿主隔离命中时的 404 —— 与"路径本来就不存在"**长得一模一样**(不透露隔离规则)。"""
return web.json_response({"error": "not found", "path": req.path}, 404)
def unauthorized() -> web.Response:
"""令牌无效/过期/换 IP/没带(且已强制)时的统一 401 响应。"""
return web.json_response({"ok": False, "error": AUTH_ERROR}, 401)
# ══════════════════════ 宿主隔离 ══════════════════════
def host_guard(cfg, req: web.Request) -> web.Response | None:
"""双站点宿主隔离:命中即 404,放行返回 None。
`media_hosts` 为空(默认)= **完全不校验**(单站点部署行为不变)。
⚠️ 唯一豁免:`GET /api/client_ip`(排错入口)。理由:宿主配错时其它路径全是 404,
若把排错口也 404 掉,就只能在服务端日志里猜"判定成了哪个宿主";
它自己会返回 `host` / `host_source` / `media_host_match` / `api_host_match`,一眼定位。
它**仍然不建议对外暴露**(会返回内网代理地址列表)—— `deploy/iis/api-site/web.config`
里这条规则默认是注释状态。
"""
if str(req.path or "").startswith("/api/client_ip"):
return None
media = proxy.normalize_hosts(cfg.get("media_hosts"))
if not media:
return None
api = proxy.normalize_hosts(cfg.get("api_hosts"))
host, _src = proxy.effective_host(req)
in_media_host = proxy.host_matches(host, media)
path = str(req.path or "")
if path.startswith("/media/"):
# 媒体只能从媒体宿主下载;配了 api_hosts 时,其它宿主(含 API 站/未知宿主)一律 404
if api and not in_media_host:
return _not_found(req)
return None
if in_media_host:
# 媒体宿主只许下文件:API、探活、推送、管理……统统 404
return _not_found(req)
return None
# ══════════════════════ 令牌服务 ══════════════════════
class TokenService:
"""`/token` 的签发/校验/限流/计数(一个实例挂在**同一份 cfg** 上,见 `registry_for`)。
状态全在**内存**(进程内 dict):令牌是 10 分钟量级的短时效凭证,重启后让 App 重新
取一个即可;`app_token_persist=true` 时才把整份令牌表落到 `meta` 表(**一行**,
不是"每个令牌写一次库"),具备重启后仍有效的能力。
"""
def __init__(self, cfg, store=None, clock: Callable[[], float] | None = None):
self.cfg = cfg
self.store = store
self._clock = clock or time.time # 可注入时钟(单测用极小 TTL,不真等 10 分钟)
self._lock = threading.RLock()
self._tokens: dict[str, dict] = {} # token -> {"exp": float, "ip": str}
self._hits: dict[str, list[float]] = {} # 来源 IP -> 最近一分钟内的签发时刻
self._buckets: dict[int, dict] = {} # 自然分钟 -> {"issued": n, "invalid": n, "sample": s}
self.counters = {"issued": 0, "accepted": 0, "invalid": 0, "rate_limited": 0}
self.last_at = 0
self._loaded = False # 持久化令牌只加载一次
# ── 配置(每次现读 → 改设置立刻生效)──
def ttl(self) -> int:
"""有效期(秒),钳制 30~86400;填错按默认 600。"""
v = _int(self.cfg.get("app_token_ttl_sec"), 600)
return max(TTL_MIN, min(TTL_MAX, v))
def bind_ip(self) -> bool:
return _bool(self.cfg.get("app_token_bind_ip"), True)
def single_use(self) -> bool:
return _bool(self.cfg.get("app_token_single_use"), False)
def persist(self) -> bool:
return _bool(self.cfg.get("app_token_persist"), False)
def enforce(self) -> bool:
return _bool(self.cfg.get("app_token_enforce"), False)
def paths(self) -> list[str]:
raw = self.cfg.get("app_token_paths")
return normalize_paths(DEFAULT_PATHS if raw is None else raw)
def rate_per_min(self) -> int:
"""同一 IP 每分钟最多签发几个;`0` = 关闭限流。填错按默认 60。"""
try:
raw = self.cfg.get("token_rate_limit_per_min")
return max(0, _int(60 if raw is None else raw, 60))
except Exception:
return 60
# ── 持久化(app_token_persist=true 才用;默认全程内存,一次库都不写)──
def _load_locked(self, now: float) -> None:
if self._loaded:
return
self._loaded = True
if not self.persist() or self.store is None:
return
try:
raw = self.store.get_meta(META_KEY, "")
data = json.loads(raw) if raw else {}
toks = data.get("tokens") if isinstance(data, dict) else None
except Exception:
return
if not isinstance(toks, dict):
return
for tok, rec in toks.items():
try:
exp = float(rec.get("exp") or 0)
except Exception:
continue
if exp > now:
self._tokens[str(tok)] = {"exp": exp, "ip": str(rec.get("ip") or "")}
def _save_locked(self, now: float) -> None:
if not self.persist() or self.store is None:
return
try:
live = {t: {"exp": float(r["exp"]), "ip": str(r.get("ip") or "")}
for t, r in self._tokens.items() if float(r["exp"]) > now}
self.store.set_meta(META_KEY, json.dumps({"tokens": live}, ensure_ascii=False))
except Exception:
pass # 落盘失败不影响内存里已经签发的令牌
# ── 内存清理 ──
def _prune_locked(self, now: float) -> int:
"""清掉过期令牌(顺带把超过上限的最老几个丢掉),返回清掉的数量。"""
dead = [t for t, r in self._tokens.items() if float(r.get("exp") or 0) <= now]
for t in dead:
self._tokens.pop(t, None)
if len(self._tokens) > MAX_TOKENS:
order = sorted(self._tokens, key=lambda t: float(self._tokens[t].get("exp") or 0))
for t in order[:len(self._tokens) - MAX_TOKENS]:
self._tokens.pop(t, None)
dead.append(t)
return len(dead)
# ── 签发 ──
def issue(self, ip: str = "") -> dict:
"""签发一个令牌并返回 `{"ok","token","expires_in","expires_at"}`。"""
now = self._clock()
ttl = self.ttl()
token = secrets.token_urlsafe(32) # 43 字符 urlsafe(256 bit 熵)
rec = {"exp": now + ttl, "ip": str(ip or "") if self.bind_ip() else ""}
with self._lock:
self._load_locked(now)
self._prune_locked(now) # 顺手清过期令牌(同一个锁内,避免并发写坏表)
self._tokens[token] = rec
self.counters["issued"] = int(self.counters.get("issued", 0)) + 1
self.last_at = int(now)
bucket = self._buckets.setdefault(int(now // 60), {})
bucket["issued"] = int(bucket.get("issued", 0)) + 1
self._save_locked(now) # 只在**签发**(与清理)时落盘,不逐次校验写库
lines = self._flush_locked(now)
self._emit(lines)
return {"ok": True, "token": token, "expires_in": ttl, "expires_at": int(rec["exp"])}
# ── 校验 ──
def _lookup_locked(self, token: str, ip: str, now: float) -> str:
"""返回空串 = 有效;否则是失败原因(`not_found` / `expired` / `ip_mismatch`)。"""
rec = self._tokens.get(str(token or ""))
if rec is None:
return "not_found"
if now >= float(rec.get("exp") or 0):
return "expired"
if self.bind_ip() and str(rec.get("ip") or "") and str(rec.get("ip")) != str(ip or ""):
return "ip_mismatch"
return ""
def verify(self, token: str, ip: str = "") -> bool:
"""校验令牌(成功会记一次计数;失败按分钟聚合记一条 WARN 事件)。"""
now = self._clock()
tok = str(token or "").strip()
with self._lock:
self._load_locked(now)
self._prune_locked(now)
why = self._lookup_locked(tok, ip, now)
if why == "":
self.counters["accepted"] = int(self.counters.get("accepted", 0)) + 1
self.last_at = int(now)
if self.single_use():
self._tokens.pop(tok, None)
self._save_locked(now)
else:
self.counters["invalid"] = int(self.counters.get("invalid", 0)) + 1
self.last_at = int(now)
bucket = self._buckets.setdefault(int(now // 60), {})
bucket["invalid"] = int(bucket.get("invalid", 0)) + 1
if not bucket.get("sample"):
bucket["sample"] = f"来源IP={ip or '未知'} 原因={why}"[:_SAMPLE_MAX]
lines = self._flush_locked(now)
self._emit(lines)
return why == ""
# ── 限流(同一来源 IP 的滑动 60 秒窗口;与 ktv/pushdl.py 同一套路)──
def rate_exceeded(self, ip: str) -> int:
"""返回还需等待的秒数(0 = 放行,并记一次签发时刻)。"""
limit = self.rate_per_min()
if limit <= 0:
return 0
now = self._clock()
with self._lock:
hits = [t for t in self._hits.get(ip, []) if now - t < 60.0]
if len(hits) >= limit:
self._hits[ip] = hits
self.counters["rate_limited"] = int(self.counters.get("rate_limited", 0)) + 1
bucket = self._buckets.setdefault(int(now // 60), {})
bucket["rate_limited"] = int(bucket.get("rate_limited", 0)) + 1
return max(1, int(60.0 - (now - hits[0])) + 1)
hits.append(now)
self._hits[ip] = hits
if len(self._hits) > MAX_IPS: # 淘汰最久没活动的来源
overflow = len(self._hits) - MAX_IPS
for k in sorted(self._hits, key=lambda x: self._hits[x][-1])[:overflow]:
self._hits.pop(k, None)
return 0
# ── 聚合日志(按自然分钟;成功签发/成功校验**不逐笔记**)──
def _flush_locked(self, now: float) -> list[tuple[str, str]]:
out: list[tuple[str, str]] = []
cur = int(now // 60)
for minute in sorted([m for m in self._buckets if m < cur]):
b = self._buckets.pop(minute)
if int(b.get("issued", 0)) > 0:
out.append(("INFO", f"App 令牌:签发 {int(b['issued'])} 个"
f"(每个有效期 {self.ttl()} 秒;不逐笔记录)"))
if int(b.get("invalid", 0)) > 0 or int(b.get("rate_limited", 0)) > 0:
tail = f",示例:{b['sample']}" if b.get("sample") else ""
out.append(("WARN", f"App 令牌:校验失败 {int(b.get('invalid', 0))} 次"
f"(其中触发签发限流 {int(b.get('rate_limited', 0))} 次){tail}"))
return out
def _emit(self, lines: list[tuple[str, str]]) -> None:
for level, text in lines:
try:
self.store.event(level, text)
except Exception:
pass # store 不可用(单测传 None)时静默
def status(self) -> dict:
"""运维核对:累计计数 + 生效口径 + **未汇总的当前分钟计数**。"""
now = self._clock()
with self._lock:
lines = self._flush_locked(now)
bucket = self._buckets.get(int(now // 60)) or {}
pending = {k: int(bucket.get(k, 0)) for k in ("issued", "invalid", "rate_limited")
if int(bucket.get(k, 0)) > 0}
if bucket.get("sample"):
pending["sample"] = str(bucket["sample"])
data = dict(self.counters)
data.update({
"ok": True, "last_at": int(self.last_at),
"ttl_sec": self.ttl(), "rate_limit_per_min": self.rate_per_min(),
"bind_ip": self.bind_ip(), "single_use": self.single_use(),
"enforce": self.enforce(), "paths": self.paths(),
"live_tokens": len(self._tokens), "pending_log": pending,
})
self._emit(lines)
return data
# ── 守卫(挂在 web.Router.guard 上,路由匹配之前跑)──
def _in_paths(self, path: str) -> bool:
return any(path.startswith(p) for p in self.paths())
def _push_entry(self, path: str) -> bool:
"""`/push_download` 本体(**不含** `/push_download/status`)。"""
return path in ("/push_download", "/push_download/")
def _is_legacy_param(self, path: str, token: str) -> bool:
"""query/body 里的 `token` 是不是**老协议自己的令牌**(若是则不当 App 令牌看)。
必须有这层区分,否则会踩两个坑:
① 老 App 的 `do.php?...&token=local`(= `/i.php` 返回的 token)会被当成
"错误的 App 令牌" → 全部 401,直接破坏已发布 App(**红线**);
② `/push_download` 的 body `token` 本来就是 `push_token`(ktv/pushdl.py 校验),
当 App 令牌去校验会把点歌推送全部打成 401。
自有 App 请统一用**请求头 `X-App-Token`**(那条通道没有任何歧义)。
"""
if self._push_entry(path):
return True
if any(path.startswith(p) for p in LEGACY_PROTO_PATHS):
legacy = str(self.cfg.get("service_token") or "local")
return hmac.compare_digest(str(token or "").encode("utf-8"), legacy.encode("utf-8"))
return False
def _presented(self, req: web.Request) -> tuple[str, str]:
"""取出请求里"作为 App 令牌"的那一个值(返回 `(token, 来源)`)。
`X-App-Token` 优先(明确通道);其次才是 query/body 的 `token`
(且必须不是老协议令牌,见 `_is_legacy_param`)。
"""
header_tok = str(req.header("x-app-token") or "").strip()
if header_tok:
return header_tok, "x-app-token"
path = str(req.path or "")
param = str(req.query.get("token") or "").strip()
if not param:
try:
param = str((req.form() or {}).get("token") or "").strip()
except Exception:
param = ""
if param and not self._is_legacy_param(path, param):
return param, "token"
return "", ""
def check_request(self, req: web.Request) -> web.Response | None:
"""令牌守卫:返回 401 响应 = 拒绝;None = 放行(并在放行时置 `req.app_token_ok`)。"""
path = str(req.path or "")
if not (self._in_paths(path) or any(path.startswith(p) for p in LEGACY_CHECK_PATHS)):
return None # 不在受管范围:一个字节都不碰
token, _src = self._presented(req)
if token:
if self.verify(token, str(req.client_ip or "")):
req.app_token_ok = True
return None
return unauthorized()
# 没带 App 令牌
if self._push_entry(path):
# `/push_download`:还有 `push_token` 那条合法通道,交给 ktv/pushdl.py 判
# (它会给 401「鉴权失败」,口径保持不变)
return None
if self.enforce() and self._in_paths(path):
return unauthorized()
return None
# ══════════════════════ 注册表 / 路由 / 守卫装配 ══════════════════════
def registry_for(cfg, store=None) -> TokenService:
"""取(或建)**这份 cfg 对应**的令牌服务实例。
⚠️ 为什么挂在 cfg 上而不是模块级单例:单测在一个进程里会建很多份互不相干的
Config(各自临时目录),模块级单例会把它们的令牌串在一起;挂 cfg 上天然隔离,
且服务端口与管理端口拿到的是**同一个**实例(同一份 cfg)。
极端情况下 cfg 不允许挂属性(`__slots__` 之类):退回一张以 `id(cfg)` 为键、
**同时持有 cfg 强引用**(防止 id 被复用)的兜底表 —— 关键是"签发"和"校验"必须
拿到同一个实例,否则刚发的令牌永远校验不过(静默的严重故障)。
"""
svc = getattr(cfg, "_app_token_service", None)
if svc is None:
svc = TokenService(cfg, store)
try:
setattr(cfg, "_app_token_service", svc)
except Exception: # pragma: no cover - 只读对象兜底
key = id(cfg)
hit = _FALLBACK.get(key)
if hit is None or hit[0] is not cfg:
svc = TokenService(cfg, store)
_FALLBACK[key] = (cfg, svc)
else:
svc = hit[1]
elif store is not None and svc.store is None:
svc.store = store # 先被 web 层建出来、后拿到 store 时补上
return svc
def make_guard(cfg, store=None) -> Callable[[web.Request], web.Response | None]:
"""装配 `web.Router.guard`:先宿主隔离,再令牌校验。"""
svc = registry_for(cfg, store)
def guard(req: web.Request) -> web.Response | None:
early = host_guard(cfg, req)
if early is not None:
return early
return svc.check_request(req)
return guard
def build_router(cfg, store=None) -> web.Router:
"""`GET/POST /token` + `GET /token/status`(挂在**服务端口**)。"""
r = web.Router()
svc = registry_for(cfg, store)
def token_endpoint(req: web.Request):
ip = str(req.client_ip or "") or "unknown"
wait = svc.rate_exceeded(ip)
if wait > 0:
resp = web.json_response({
"ok": False, "retry_after": wait, "client_ip": ip,
"error": f"获取令牌过于频繁:来源 {ip} 每分钟最多 {svc.rate_per_min()} 个,"
f"请 {wait} 秒后再试"}, 429)
resp.headers["Retry-After"] = str(wait)
return _no_store(resp)
# 令牌**绑定签发时的真实客户端 IP**(经可信代理时是设备 IP,见 ktv/proxy.py)
return _no_store(web.json_response(svc.issue(str(req.client_ip or ""))))
r.add("GET", "/token", token_endpoint)
r.add("POST", "/token", token_endpoint)
r.add("GET", "/token/status", lambda req: web.json_response(svc.status()))
return r