合并自建歌曲源支线(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 例)
This commit is contained in:
zijunchiang
2026-10-14 02:06:28 +08:00
parent e060ae5ebe
commit 87aa979abd
68 changed files with 21305 additions and 105 deletions
+520
View File
@@ -0,0 +1,520 @@
"""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