"""App 点歌推送:把 App 取到的**歌曲下载地址**同步进本服务器的下载队列(服务端口接口)。 为什么要有它:App 点歌时已经向上游取到了一条可用的媒体地址(`…/<编号>.ts?token=…`)。 把这条地址顺带推给自建服务器,服务器就能**省掉一次上游取址**(取址有 5~10 秒的全局串行节奏、 还会被上游风控),直接按同一个地址下载入库。 接口(都在**服务端口**,即 App 面向的命名空间): * `POST /push_download` —— 接收一条推送(JSON 或 `application/x-www-form-urlencoded`) * `GET /push_download/status` —— 运维核对计数:accepted / duplicate / rejected / rate_limited / last_at 处理顺序(**绝不盲信 App**): ① 鉴权:**两条通道任一即可** —— 合法的 App 令牌(请求头 `X-App-Token`,由 `ktv/apptoken.py` 的守卫在路由之前验过并置 `req.app_token_ok`)**或** `push_token` (请求头 `X-Push-Token` 或 body 的 `token`,默认 "local");两者都不满足 → 401; ⚠️ `/push_download` 的 body `token` 永远是 `push_token` 口径(守卫不会把它当 App 令牌, 见 `apptoken.TokenService._is_legacy_param`),自有 App 请用 `X-App-Token` 头。 ② 限流:同一**真实客户端 IP** 每分钟最多 `push_rate_limit_per_min` 条(默认 60),超出 429(0 = 关闭)。 "真实客户端 IP" = 直连对端;反代部署(IIS/ARR)下由 `trusted_proxies` + `trust_proxy_headers` 认定(见 ktv/proxy.py)—— 否则所有设备会共用代理地址的一份额度; ③ 校验:`tid` 必须是正整数;`url` 必须是 http(s) 且必须通过服务端既有闸门 `resolver.is_trusted_url(url, tid)`(域名而非裸 IP、剥离 query 后路径以 `/.ts` 结尾、 不在 wb66/demo/ac16.vip/ad_files/my_ad_video/pub-*.r2.dev/ss 黑名单)—— 不通过一律 400 且**绝不入库**; ④ 去重(核心,按 `tid` 判,命中任一种即视为重复、**不重复下载**): a) `keeps.state='done'` 且媒体文件确实在(`already_done`); b) `keeps.state in ('want','downloading')` 或 `downloads.state in ('pending','downloading')` (`already_queued`); c) 同一 tid 在过去 `push_dedupe_sec` 秒内已被推送过(`recently_pushed`); ⑤ 接受:登记歌曲表(`state='want'`、`user='app'`、`note='App 点歌推送'`;**曲库里没有该编号也允许**, `matched=false`)+ 用推送来的 URL 入队下载(`downloads.source='app-push'`、`push_url=`)。 事件日志口径(**这个项目很在意日志量**): * **接受**:每 tid 一条 INFO(`App 点歌推送:接受 编号=…`)—— 真实点歌事件,量小、可追溯; * **重复 / 拒绝 / 限流**:按**自然分钟**聚合,分钟翻页(下一次推送或 status 查询)时汇总成一条 (`App 点歌推送:重复跳过 N 首(already_done=a/already_queued=b/recently_pushed=c)`、 `App 点歌推送:拒绝 N 条(其中触发限流 M 条),示例:…`)。这三类才可能被刷屏,所以刻意合并成一条; 拒绝那条保留**一条示例明细**(编号 + 原因),便于定位坏地址从哪来。尚未汇总的当前分钟计数在 `/push_download/status` 的 `pending_log` 里也能看到,不会丢。 ⚠️ 本模块只是"入口 + 判定":真正的队列写入在 `Store.push_enqueue()`, 固定地址的下载与失败回退在 `Downloader._download()`(见 ktv/downloader.py)。 """ from __future__ import annotations import hmac import os import re import threading import time from . import web from .resolver import is_trusted_url # 三种"重复"原因(响应里的 reason 取值) DUP_DONE = "already_done" DUP_QUEUED = "already_queued" DUP_RECENT = "recently_pushed" _KINDS = ("accepted", "duplicate", "rejected", "rate_limited") # 重复原因 → 聚合日志里的细分键 _DUP_KEYS = {DUP_DONE: "done", DUP_QUEUED: "queued", DUP_RECENT: "recent"} class PushDownloadService: """`/push_download` 的判定与计数(一个实例挂在服务端口路由上)。 全部状态都在**内存**里(计数 / 按 IP 的限流窗口 / 按 tid 的近期推送): * 计数是运维核对用的,重启归零,**不落盘**(避免每来一条推送就写一次库); * 限流窗口(60 秒)与近期推送(`push_dedupe_sec`)天然是短时窗,不需要持久化。 """ MAX_IPS = 512 # 限流窗口最多保留多少个来源 IP(防内存被撑爆) def __init__(self, cfg, store, downloader=None): self.cfg = cfg self.store = store self.downloader = downloader self._lock = threading.Lock() self.counters = {k: 0 for k in _KINDS} self.last_at = 0 self._hits: dict[str, list[float]] = {} # 来源 IP → 最近一分钟内的请求时刻 self._recent: dict[int, float] = {} # tid → 上次**接受**推送的时刻 self._buckets: dict[int, dict] = {} # 自然分钟 → 重复/拒绝/限流计数(聚合日志) # ── 配置(每次现读,改设置后立刻生效)── def token(self) -> str: """鉴权串:空值回落到 "local"(与 `service_token` 同口径,绝不等于"谁都能推")。""" return str(self.cfg.get("push_token") or "local") def rate_per_min(self) -> int: """同一 IP 每分钟的推送上限;`0` = 关闭限流。填错按默认 60。""" try: raw = self.cfg.get("push_rate_limit_per_min") return max(0, int(60 if raw is None else raw)) except Exception: return 60 def dedupe_sec(self) -> int: """同一编号的推送去重窗口(秒);`0` = 关闭(永远判 recently_pushed 为否)。""" try: raw = self.cfg.get("push_dedupe_sec") return max(0, int(300 if raw is None else raw)) except Exception: return 300 # ── 计数与聚合日志 ── def _bump(self, kind: str, sub: str = "", sample: str = "") -> None: """计数 +1 并刷新 `last_at`。 * `accepted` **不进桶**(由调用方每 tid 写一条 INFO,真实点歌事件、可追溯); * `duplicate/rejected/rate_limited` 进"当前分钟桶",分钟翻页时汇总成**一条**事件 (这三类才可能被刷屏)。`sample` 只记本分钟第一条的明细,便于定位坏地址从哪来。 ⚠️ 事件落库放在**释放锁之后**(`_flush` 只生成文本),避免持锁写库。 """ now = time.time() with self._lock: self.counters[kind] = int(self.counters.get(kind, 0)) + 1 self.last_at = int(now) if kind != "accepted": bucket = self._buckets.setdefault(int(now // 60), {}) bucket[kind] = int(bucket.get(kind, 0)) + 1 if sub: bucket[sub] = int(bucket.get(sub, 0)) + 1 if sample and not bucket.get("sample"): bucket["sample"] = str(sample)[:200] # 每次推送都看一眼"上一分钟有没有攒着没写的汇总"(接受也要看:否则下一分钟的 # 第一条是接受时,上一分钟的重复汇总要等到再有重复/拒绝才写出来) lines = self._flush(now) self._emit(lines) def _flush(self, now: float) -> list[tuple[str, str]]: """挑出**已经过去的**分钟桶,生成汇总事件文本(**不写库、不改计数**)。 只汇总"已经翻页"的分钟(`minute < 当前分钟`):写当前分钟会碎成一行一条。 返回 `[(level, text), …]`,由调用方在释放锁后交给 `store.event()`。 """ 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 sum(int(b.get(k, 0)) for k in _KINDS) <= 0: continue if b.get("duplicate"): out.append(("INFO", f"App 点歌推送:重复跳过 {b['duplicate']} 首" f"(already_done={b.get('done', 0)}" f"/already_queued={b.get('queued', 0)}" f"/recently_pushed={b.get('recent', 0)})")) if b.get("rejected") or b.get("rate_limited"): tail = f",示例:{b['sample']}" if b.get("sample") else "" out.append(("WARN", f"App 点歌推送:拒绝 {b.get('rejected', 0)} 条" f"(其中触发限流 {b.get('rate_limited', 0)} 条){tail}")) return out def _emit(self, lines: list[tuple[str, str]]) -> None: for level, text in lines: self.store.event(level, text) # ── 限流(同 IP 滑动 60 秒窗口)── def _rate_exceeded(self, ip: str) -> int: """返回还需等待的秒数(0 = 放行,并记一次请求时刻)。""" limit = self.rate_per_min() if limit <= 0: return 0 now = time.time() 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 return max(1, int(60.0 - (now - hits[0])) + 1) hits.append(now) self._hits[ip] = hits if len(self._hits) > self.MAX_IPS: # 淘汰最久没活动的来源 overflow = len(self._hits) - self.MAX_IPS for k in sorted(self._hits, key=lambda x: self._hits[x][-1])[:overflow]: self._hits.pop(k, None) return 0 # ── 去重 ── def _media_exists(self, tid: int) -> bool: """媒体文件是否真的在(`/.ts`)。只 stat 一个确定路径,不列举目录。""" try: return os.path.isfile(os.path.join(str(self.cfg.media_dir), f"{tid}.ts")) except Exception: return False def duplicate_reason(self, tid: int) -> str: """命中返回 `already_done|already_queued|recently_pushed`,都不命中返回空串。""" keeps = self.store.q1("SELECT state FROM keeps WHERE tid=?", (int(tid),)) state = str(keeps["state"]) if keeps else "" if state == "done" and self._media_exists(tid): return DUP_DONE if state in ("want", "downloading"): return DUP_QUEUED row = self.store.q1("SELECT state FROM downloads WHERE tid=?", (int(tid),)) if row is not None and str(row["state"]) in ("pending", "downloading"): return DUP_QUEUED with self._lock: last = self._recent.get(int(tid)) if last and (time.time() - last) < self.dedupe_sec(): return DUP_RECENT return "" # ── 接口 ── def handle_push(self, req: web.Request) -> web.Response: form = req.form() # ⚠️ 来源 IP 必须是**真实客户端 IP**(`req.client_ip` 由 web.py 按 trusted_proxies 认定): # 反向代理(IIS)后面若还用 TCP 对端,所有设备会共用代理地址的一份限流额度。 ip = str(req.client_ip or "") or "unknown" # ① 鉴权(用 bytes 比较:令牌可能含中文,`compare_digest` 对非 ASCII str 会抛异常) # **两条合法通道任一即可**:① 合法的 App 令牌(`X-App-Token`,由 ktv/apptoken.py # 的守卫在路由之前验过并置 `req.app_token_ok`);② 既有的 `push_token`。 # 两者都没有/都不对 → 401「鉴权失败」(口径不变,老 App 不受影响)。 got = str(req.header("x-push-token") or form.get("token") or "") if not bool(getattr(req, "app_token_ok", False)) \ and not hmac.compare_digest(got.encode("utf-8"), self.token().encode("utf-8")): self._bump("rejected", sample=f"来源IP={ip}(鉴权失败)") return web.json_response({"ok": False, "error": "鉴权失败"}, 401) # ② 限流(同 IP 每分钟) wait = self._rate_exceeded(ip) if wait > 0: self._bump("rate_limited", sample=f"来源IP={ip}(触发限流,{wait} 秒后可再试)") resp = web.json_response( {"ok": False, "retry_after": wait, "client_ip": ip, "error": f"推送过于频繁:来源 {ip} 每分钟最多 {self.rate_per_min()} 条,请 {wait} 秒后再试"}, 429) resp.headers["Retry-After"] = str(wait) return resp # ③ 校验(绝不盲信 App) tid, err = self._parse_tid(form.get("tid")) if err: self._bump("rejected", sample=f"来源IP={ip} 编号={form.get('tid')!r}({err})") return web.json_response({"ok": False, "error": err}, 400) url = str(form.get("url") or "").strip() # ⚠️ `url` 必须是**已编码的表单字段值**(App 侧 PushDownload.buildForm() 用 URLEncoder 整体编码): # 真源地址自带 `?token=…&t=…`,不编码会被 parse_qs 拆成额外字段、只存下被截断的地址。 # 这里无法可靠识别"被截断的地址"—— 那种行会下载失败并自动回退取址(不会下错内容)。 if not re.match(r"^https?://", url, re.I): self._bump("rejected", sample=f"来源IP={ip} 编号={tid}(url 不是 http(s))") return web.json_response( {"ok": False, "error": "url 必须是以 http:// 或 https:// 开头的下载地址"}, 400) ok, why = is_trusted_url(url, tid) if not ok: self._bump("rejected", sample=f"来源IP={ip} 编号={tid}(地址未通过闸门:{why})") return web.json_response({"ok": False, "error": f"地址未通过闸门:{why}"}, 400) # ④ 去重三态 reason = self.duplicate_reason(tid) if reason: self._bump("duplicate", _DUP_KEYS.get(reason, "")) return web.json_response({"ok": True, "tid": tid, "duplicate": True, "reason": reason, "queued": False}) # ⑤ 接受:登记歌曲表 + 用推送来的 URL 入队(source='app-push') # `name` 只是 App 捎带的歌名(可选):**不入库**(歌名由曲库 songs 提供),仅写进事件便于核对。 name = str(form.get("name") or "")[:120] self.store.push_enqueue(tid, url) with self._lock: self._recent[int(tid)] = time.time() self._bump("accepted") self.store.event("INFO", f"App 点歌推送:接受 编号={tid} 来源IP={ip}" f"{(' 歌名=' + name) if name else ''}(已入队,使用推送地址)") return web.json_response({"ok": True, "tid": tid, "duplicate": False, "queued": True}) def handle_status(self, req: web.Request) -> web.Response: """运维核对:累计计数 + 最近一次推送时间 + 未汇总的当前分钟计数 + 生效的限流/去重口径。""" with self._lock: lines = self._flush(time.time()) bucket = self._buckets.get(int(time.time() // 60)) or {} pending = {k: v for k, v in bucket.items() if k in _KINDS} if bucket.get("sample"): # 当前分钟的第一条明细也放出来(含**真实客户端 IP**),便于立刻定位是哪台设备 pending["sample"] = str(bucket["sample"]) data = dict(self.counters) data.update({"ok": True, "last_at": int(self.last_at), "rate_limit_per_min": self.rate_per_min(), "dedupe_sec": self.dedupe_sec(), "pending_log": pending}) self._emit(lines) # 顺手把已翻页的分钟汇总写掉(不额外制造调用) return web.json_response(data) @staticmethod def _parse_tid(raw) -> tuple[int, str]: """`tid` 必须是**正整数**(字符串数字也接受:表单/JSON 两种 body 都可能)。""" if isinstance(raw, bool): return 0, "tid 必须是正整数" text = str(raw if raw is not None else "").strip() if not text: return 0, "缺少 tid(必须是正整数)" try: tid = int(text) except Exception: return 0, "tid 必须是正整数" if tid <= 0: return 0, "tid 必须是正整数" return tid, "" def build_router(cfg, store, downloader=None, service: PushDownloadService | None = None) -> web.Router: """把两个推送接口挂到**服务端口**的路由(由 `service.build_router` 调用)。""" r = web.Router() svc = service or PushDownloadService(cfg, store, downloader) @r.post("/push_download") def push_download(req: web.Request): return svc.handle_push(req) @r.get("/push_download/status") def push_download_status(req: web.Request): return svc.handle_status(req) return r