From 48037f03fdf449ea0b90f567a276735b50b2c580 Mon Sep 17 00:00:00 2001 From: guke Date: Mon, 20 Jul 2026 18:55:38 +0800 Subject: [PATCH 01/42] =?UTF-8?q?docs:=20OpenObserve=20=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=20QPS/=E8=80=97=E6=97=B6=E5=8F=AF=E8=A7=82=E6=B5=8B=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=20spec=20(#145)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openobserve上报 --------- Co-authored-by: guke Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/145 --- .env.example | 16 + app/core/config.py | 25 + app/core/observe.py | 110 +++ app/core/observe_worker.py | 128 +++ app/main.py | 10 + deploy/nginx/app-api.shaguabijia.com.conf | 6 +- deploy/nginx/observe.shaguabijia.com.conf | 57 ++ deploy/openobserve/.gitignore | 4 + deploy/openobserve/README.md | 126 +++ deploy/openobserve/dashboard-api-metrics.json | 302 ++++++ deploy/openobserve/docker-compose.prod.yml | 33 + deploy/openobserve/docker-compose.yml | 16 + .../2026-07-06-openobserve-api-metrics.md | 897 ++++++++++++++++++ ...26-07-06-openobserve-api-metrics-design.md | 236 +++++ tests/test_observe.py | 234 +++++ 15 files changed, 2199 insertions(+), 1 deletion(-) create mode 100644 app/core/observe.py create mode 100644 app/core/observe_worker.py create mode 100644 deploy/nginx/observe.shaguabijia.com.conf create mode 100644 deploy/openobserve/.gitignore create mode 100644 deploy/openobserve/README.md create mode 100644 deploy/openobserve/dashboard-api-metrics.json create mode 100644 deploy/openobserve/docker-compose.prod.yml create mode 100644 deploy/openobserve/docker-compose.yml create mode 100644 docs/superpowers/plans/2026-07-06-openobserve-api-metrics.md create mode 100644 docs/superpowers/specs/2026-07-06-openobserve-api-metrics-design.md create mode 100644 tests/test_observe.py diff --git a/.env.example b/.env.example index 110e939..b228f6b 100644 --- a/.env.example +++ b/.env.example @@ -137,3 +137,19 @@ PANGLE_REPORT_SECURITY_KEY= # GroMore AppId(报表 site_id 维度)→ 应用环境;默认取现网两个应用,按需覆盖。 PANGLE_REPORT_SITE_ID_PROD=5830519 PANGLE_REPORT_SITE_ID_TEST=5832303 + +# ===== 可观测(OpenObserve 接口指标)===== +# 采集每个接口 QPS + 耗时 + 错误率,批量直采到 OpenObserve(本地 Docker,见 deploy/openobserve/)。 +# 默认关;开启需 ENABLED=true 且填 USER/PASSWORD(与 docker-compose 里 root 账号一致)。 +# 未开/缺凭证 → 中间件透传、worker 不启动,整套 no-op,不影响业务。 +OBSERVE_ENABLED=false +OBSERVE_ENDPOINT=http://localhost:5080 +OBSERVE_ORG=default +OBSERVE_STREAM=app_requests +OBSERVE_USER=admin@shaguabijia.local +OBSERVE_PASSWORD=Complexpass#123 +# 进阶(一般不用改):攒批间隔秒 / 单批最大条数 / 有界队列上限(满则丢) / 上报超时秒 +OBSERVE_FLUSH_INTERVAL_SEC=5 +OBSERVE_BATCH_MAX=200 +OBSERVE_QUEUE_MAX=10000 +OBSERVE_TIMEOUT_SEC=5 diff --git a/app/core/config.py b/app/core/config.py index bc3d1c7..9a88f00 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -347,6 +347,31 @@ class Settings(BaseSettings): return [] return [o.strip() for o in self.CORS_ALLOW_ORIGINS.split(",") if o.strip()] + # ===== 可观测(OpenObserve 接口指标)===== + # 采集每个接口的 QPS + 耗时 + 错误率,批量直采到 OpenObserve(本地 Docker)。 + # 默认关(prod 安全):未开启 → 中间件透传、worker 不启动,整套 no-op。 + # 开启需 ENABLED=true 且 ENDPOINT/USER/PASSWORD 齐全(见 observe_configured)。 + OBSERVE_ENABLED: bool = False + OBSERVE_ENDPOINT: str = "http://localhost:5080" # OpenObserve base URL + OBSERVE_ORG: str = "default" # 组织名 + OBSERVE_STREAM: str = "app_requests" # stream 名(首次上报自动建) + OBSERVE_USER: str = "" # Basic auth 邮箱 + OBSERVE_PASSWORD: str = "" # Basic auth 密码/token + OBSERVE_FLUSH_INTERVAL_SEC: float = 5.0 # worker 最长攒批间隔 + OBSERVE_BATCH_MAX: int = 200 # 单批最大事件数 + OBSERVE_QUEUE_MAX: int = 10000 # 有界队列上限,满则丢 + OBSERVE_TIMEOUT_SEC: float = 5.0 # 上报 HTTP 超时 + + @property + def observe_configured(self) -> bool: + """观测上报可用 = 总开关开 且 endpoint/账号/密码齐全(缺则整套 no-op)。""" + return bool( + self.OBSERVE_ENABLED + and self.OBSERVE_ENDPOINT + and self.OBSERVE_USER + and self.OBSERVE_PASSWORD + ) + @property def is_prod(self) -> bool: return self.APP_ENV == "prod" diff --git a/app/core/observe.py b/app/core/observe.py new file mode 100644 index 0000000..6c8deb1 --- /dev/null +++ b/app/core/observe.py @@ -0,0 +1,110 @@ +"""接口指标埋点:有界事件队列 + 纯 ASGI 中间件。 + +每个 HTTP 请求测总耗时、抓路由模板 + 状态码,非阻塞塞进有界队列;由 observe_worker +后台批量上报到 OpenObserve。请求路径上无任何 I/O。未配置观测时中间件直接透传。 +""" +from __future__ import annotations + +import asyncio +import os +import time + +from starlette.routing import Match + +from app.core.config import settings + +# 不采集的路径(纯噪音):健康检查。 +_SKIP_PATHS = frozenset({"/health"}) +# 未匹配路由(404/扫描器)归一到此,防维度爆炸。 +_UNMATCHED = "__unmatched__" +# service 字段:与 logging.py 同源(LOG_SERVICE_NAME),默认 app-server。 +_SERVICE = os.getenv("LOG_SERVICE_NAME", "app-server") + +# 有界事件队列(懒创建,见 get_queue):首次取用时在运行中的 loop 里建,避免 import 期 +# 无 loop 的边角问题;put_nowait/get_nowait 不需运行中的 loop → 可在无 loop 下测试。 +_queue: asyncio.Queue[dict] | None = None +# 队列满时的丢弃计数,worker 定期取出打日志。 +_dropped = 0 + + +def get_queue() -> asyncio.Queue[dict]: + """返回全局有界事件队列(懒创建)。测试可 monkeypatch 模块级 _queue 换成小队列。""" + global _queue + if _queue is None: + _queue = asyncio.Queue(maxsize=settings.OBSERVE_QUEUE_MAX) + return _queue + + +def take_dropped() -> int: + """取出并清零累计丢弃数(供 worker 打点)。""" + global _dropped + n, _dropped = _dropped, 0 + return n + + +def record_event(event: dict) -> None: + """非阻塞入队;队列满则丢弃当前事件并计数。永不抛异常、永不阻塞请求。""" + global _dropped + try: + get_queue().put_nowait(event) + except asyncio.QueueFull: + _dropped += 1 + + +def _resolve_route(scope) -> str: + """从 scope 取路由模板(如 /things/{tid})。优先 scope['route'](现代 Starlette + 路由后写入);取不到则手动匹配一次(老版本兜底);仍无 → __unmatched__(404/扫描器)。""" + route = scope.get("route") + path = getattr(route, "path", None) + if path: + return path + app_ = scope.get("app") + router = getattr(app_, "router", None) + for candidate in getattr(router, "routes", []): + try: + match, _ = candidate.matches(scope) + except Exception: # noqa: BLE001 - 匹配兜底,任一路由异常不影响整体 + continue + if match == Match.FULL and getattr(candidate, "path", None): + return candidate.path + return _UNMATCHED + + +class RequestMetricsMiddleware: + """纯 ASGI 中间件:测每个 http 请求耗时,记 method/route/status/duration。 + + 放在最外层(main.py 里 CORS 之后 add),测到含 CORS 的完整耗时。未配置观测 → 透传。 + """ + + def __init__(self, app) -> None: + self.app = app + + async def __call__(self, scope, receive, send) -> None: + if scope["type"] != "http" or not settings.observe_configured: + await self.app(scope, receive, send) + return + if scope.get("path") in _SKIP_PATHS: + await self.app(scope, receive, send) + return + + start = time.perf_counter() + status_holder = {"status": 500} # 下游异常未产出 response 时兜底 500 + + async def send_wrapper(message) -> None: + if message["type"] == "http.response.start": + status_holder["status"] = message["status"] + await send(message) + + try: + await self.app(scope, receive, send_wrapper) + finally: + duration_ms = (time.perf_counter() - start) * 1000.0 + record_event({ + "_timestamp": int(time.time() * 1_000_000), # µs,OpenObserve 时间列 + "service": _SERVICE, + "env": settings.APP_ENV, + "method": scope.get("method", ""), + "route": _resolve_route(scope), + "status": status_holder["status"], + "duration_ms": round(duration_ms, 3), + }) diff --git a/app/core/observe_worker.py b/app/core/observe_worker.py new file mode 100644 index 0000000..aa41723 --- /dev/null +++ b/app/core/observe_worker.py @@ -0,0 +1,128 @@ +"""接口指标后台上报 worker:批量 drain 事件队列 → POST 到 OpenObserve。 + +对齐 heartbeat_monitor_worker 等的 start_*/stop_* 形态。best-effort 遥测:catch 全部 +异常,上报失败直接丢批不重试。未配置观测 → start 返回 None(不启动),整套 no-op。 +""" +from __future__ import annotations + +import asyncio +import contextlib +import logging + +import httpx + +from app.core.config import settings +from app.core.observe import get_queue, take_dropped + +logger = logging.getLogger("shagua.observe") + +# 上报用的 httpx client,start 时建、stop 时关。 +_client: httpx.AsyncClient | None = None + + +async def _collect_batch() -> list[dict]: + """等到 ≥1 条(或到 flush 间隔)后,连抽到 BATCH_MAX 条或抽空。超时且空 → 返回 []。""" + queue = get_queue() + batch: list[dict] = [] + try: + first = await asyncio.wait_for( + queue.get(), timeout=settings.OBSERVE_FLUSH_INTERVAL_SEC + ) + except asyncio.TimeoutError: # noqa: UP041 - 3.10 兼容:该版 wait_for 抛的 asyncio.TimeoutError ≠ 内置 TimeoutError + return batch + batch.append(first) + while len(batch) < settings.OBSERVE_BATCH_MAX: + try: + batch.append(queue.get_nowait()) + except asyncio.QueueEmpty: + break + return batch + + +async def _post_batch(client: httpx.AsyncClient, batch: list[dict]) -> None: + """POST 一批事件到 OpenObserve 的 _json ingest 端点。非 2xx 仅告警。""" + url = f"/api/{settings.OBSERVE_ORG}/{settings.OBSERVE_STREAM}/_json" + resp = await client.post(url, json=batch) + if resp.status_code >= 300: + logger.warning( + "observe ingest failed status=%s body=%s", + resp.status_code, + resp.text[:200], + ) + + +async def _run_loop(client: httpx.AsyncClient) -> None: + try: + while True: + batch = await _collect_batch() + dropped = take_dropped() + if dropped: + logger.warning("observe dropped %d events (queue full)", dropped) + if not batch: + continue + try: + await _post_batch(client, batch) + except Exception: # noqa: BLE001 - best-effort 遥测,失败丢批不重试、不退出 + logger.warning( + "observe post batch failed, dropped %d events", + len(batch), + exc_info=True, + ) + except asyncio.CancelledError: + logger.info("observe worker stopped") + raise + + +def start_observe_worker() -> asyncio.Task | None: + """启动上报 worker。未配置观测 → 返回 None(no-op)。约定每进程只调一次(lifespan)。""" + global _client + if not settings.observe_configured: + return None + if _client is not None: + # 约定 start 每进程只调一次;已启动则不重复建 client(避免泄漏旧连接池)。 + logger.warning("observe worker already started; ignoring duplicate start") + return None + _client = httpx.AsyncClient( + base_url=settings.OBSERVE_ENDPOINT, + auth=(settings.OBSERVE_USER, settings.OBSERVE_PASSWORD), + timeout=settings.OBSERVE_TIMEOUT_SEC, + ) + logger.info( + "observe worker started endpoint=%s org=%s stream=%s", + settings.OBSERVE_ENDPOINT, + settings.OBSERVE_ORG, + settings.OBSERVE_STREAM, + ) + return asyncio.create_task(_run_loop(_client), name="observe-worker") + + +async def stop_observe_worker(task: asyncio.Task | None) -> None: + """收尾:cancel worker → best-effort 发最后一批 → 关 client。""" + global _client + if task is None: + return + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + dropped = take_dropped() # 收口:补记最后一个 flush 窗口累计的丢弃数,不让账丢在关停期 + if dropped: + logger.warning("observe dropped %d events (queue full) before shutdown", dropped) + if _client is not None: + # worker 已停,安全 drain 剩余并 best-effort 发最后一批(短超时,不卡关停); + # 超过一批(BATCH_MAX)的剩余直接丢,不做多轮 flush(best-effort,关停从速)。 + try: + queue = get_queue() + final: list[dict] = [] + while len(final) < settings.OBSERVE_BATCH_MAX: + try: + final.append(queue.get_nowait()) + except asyncio.QueueEmpty: + break + if final: + await asyncio.wait_for( + _post_batch(_client, final), timeout=settings.OBSERVE_TIMEOUT_SEC + ) + except Exception: # noqa: BLE001 - 关停期尽力而为,失败忽略 + pass + await _client.aclose() + _client = None diff --git a/app/main.py b/app/main.py index f99611e..6b3ec04 100644 --- a/app/main.py +++ b/app/main.py @@ -54,6 +54,11 @@ from app.core.inactivity_reset_worker import ( stop_inactivity_reset_worker, ) from app.core.logging import setup_logging +from app.core.observe import RequestMetricsMiddleware +from app.core.observe_worker import ( + start_observe_worker, + stop_observe_worker, +) from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client from app.core.withdraw_reconcile_worker import ( start_withdraw_reconcile_worker, @@ -84,6 +89,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: reconcile_task = start_withdraw_reconcile_worker() heartbeat_task = start_heartbeat_monitor() daily_exchange_task = start_daily_exchange_worker() + observe_task = start_observe_worker() inactivity_task = start_inactivity_reset_worker() try: yield @@ -91,6 +97,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: await stop_heartbeat_monitor(heartbeat_task) await stop_withdraw_reconcile_worker(reconcile_task) await stop_daily_exchange_worker(daily_exchange_task) + await stop_observe_worker(observe_task) await stop_inactivity_reset_worker(inactivity_task) await aclose_pricebot_client() logger.info("shutting down") @@ -113,6 +120,9 @@ if settings.cors_origins_list: allow_headers=["*"], ) +# 接口指标埋点(放在 CORS 之后 = 最外层:测到含 CORS 的完整耗时)。未配置观测时中间件自 no-op。 +app.add_middleware(RequestMetricsMiddleware) + @app.get("/health", tags=["meta"]) def health() -> dict[str, str]: diff --git a/deploy/nginx/app-api.shaguabijia.com.conf b/deploy/nginx/app-api.shaguabijia.com.conf index dcef0f7..4e72656 100644 --- a/deploy/nginx/app-api.shaguabijia.com.conf +++ b/deploy/nginx/app-api.shaguabijia.com.conf @@ -19,7 +19,11 @@ server { ssl_ciphers HIGH:!aNULL:!MD5; ssl_session_cache shared:SSL:10m; - client_max_body_size 4m; + # 上传接口(反馈/上报截图、头像)业务上限 = 最多 6 张 × 每张 5MB + # (见 app _MAX_IMAGES / AVATAR_MAX_BYTES)≈ 30MB,留余量设 32m。 + # 低于此值时带截图的反馈会在到达 uvicorn 前就被 nginx 413,表现为「提交经常失败」 + # (纯文字反馈体积小、不受影响 → 呈现为「时好时坏」)。根治仍需客户端上传前压缩。 + client_max_body_size 32m; location / { proxy_pass http://127.0.0.1:8770; diff --git a/deploy/nginx/observe.shaguabijia.com.conf b/deploy/nginx/observe.shaguabijia.com.conf new file mode 100644 index 0000000..0c9e274 --- /dev/null +++ b/deploy/nginx/observe.shaguabijia.com.conf @@ -0,0 +1,57 @@ +# OpenObserve 监控台反代(observe.shaguabijia.com)。证书走 Certbot/Let's Encrypt,与 admin-web 一致。 +# +# 前置(一次性): +# 1) DNS: observe.shaguabijia.com A 记录 → 本服务器公网 IP +# 2) 证书: sudo certbot certonly --nginx -d observe.shaguabijia.com +# (options-ssl-nginx.conf / ssl-dhparams.pem 首次跑 certbot 时已生成,admin-web 在用即已存在) +# 3) OpenObserve 只绑 127.0.0.1:5080(见 docker-compose.prod.yml),本文件把它反代出公网 +# 4) nginx -t 通过后 systemctl reload nginx +# +# 安全:OO 有自身登录。监控台不必对全网裸开——本机办公网无固定出口 IP,故在 nginx 层加 Basic Auth 兜底; +# 将来有固定 IP 可改用【IP 白名单】块(更省事,可去掉 Basic Auth)。 + +server { + server_name observe.shaguabijia.com; + + client_max_body_size 10m; + + # —— IP 白名单:办公网无固定出口 IP,暂不用;将来有固定 IP 可改用这块(比 Basic Auth 省事)—— + # allow 1.2.3.4; # ← 换成你的真实出口 IP,可多行 + # deny all; + + # —— Basic Auth:无固定 IP 的兜底密码(生成 .htpasswd_observe 的命令见 README/下方)—— + auth_basic "OpenObserve"; + auth_basic_user_file /etc/nginx/conf.d/.htpasswd_observe; + + location / { + proxy_pass http://127.0.0.1:5080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # OpenObserve 有实时/流式面板,需透传 WebSocket + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 300s; + } + + # IPv6 这行不带 ipv6only=on:该选项对 [::]:443 全局只能设一次,admin-web 那个 server 块已设(否则 nginx 报 duplicate listen options) + listen [::]:443 ssl; # managed by Certbot + listen 443 ssl; # managed by Certbot + ssl_certificate /etc/letsencrypt/live/observe.shaguabijia.com/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/observe.shaguabijia.com/privkey.pem; # managed by Certbot + include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot +} + +server { + if ($host = observe.shaguabijia.com) { + return 301 https://$host$request_uri; + } # managed by Certbot + + listen 80; + listen [::]:80; + server_name observe.shaguabijia.com; + return 404; # managed by Certbot +} diff --git a/deploy/openobserve/.gitignore b/deploy/openobserve/.gitignore new file mode 100644 index 0000000..bcf98ee --- /dev/null +++ b/deploy/openobserve/.gitignore @@ -0,0 +1,4 @@ +# OpenObserve 落盘数据(parquet/索引/元数据),运行时产生,不入库。 +data/ +# 生产 compose 的密码文件(OO_ROOT_PASSWORD),含机密,不入库。 +.env diff --git a/deploy/openobserve/README.md b/deploy/openobserve/README.md new file mode 100644 index 0000000..70925f4 --- /dev/null +++ b/deploy/openobserve/README.md @@ -0,0 +1,126 @@ +# OpenObserve 本地部署(接口 QPS / 耗时可观测) + +app-server 通过中间件采集每个接口的 QPS + 耗时 + 错误率,批量上报到这里。 +设计见 [../../docs/superpowers/specs/2026-07-06-openobserve-api-metrics-design.md](../../docs/superpowers/specs/2026-07-06-openobserve-api-metrics-design.md)。 + +## 启动 + +```bash +cd deploy/openobserve +docker compose up -d +``` + +- Web UI:http://localhost:5080 +- 登录:`admin@shaguabijia.local` / `Complexpass#123`(见 `docker-compose.yml`) +- 数据落 `deploy/openobserve/data/`(已挂卷持久化;该目录已 gitignore) + +## 让 app-server 上报 + +在项目根的 `.env` 打开观测(`OBSERVE_*`,账号密码与 compose 里 root 一致): + +```dotenv +OBSERVE_ENABLED=true +OBSERVE_ENDPOINT=http://localhost:5080 +OBSERVE_ORG=default +OBSERVE_STREAM=app_requests +OBSERVE_USER=admin@shaguabijia.local +OBSERVE_PASSWORD=Complexpass#123 +``` + +重启 app-server,随便打几个接口。stream `app_requests` **首次上报自动创建**, +在 UI 的 Logs → 选 `app_requests` 就能看到逐条请求事件(字段:`method` / `route` / +`status` / `duration_ms` / `service` / `env`)。 + +> 未开 `OBSERVE_ENABLED` 或缺账号密码时,中间件透传、worker 不启动,整套 no-op,不影响业务。 + +## 查询(Logs 页 SQL,或建 Dashboard 面板) + +各接口 QPS(1 分钟分桶,面板里再除 60 得每秒): + +```sql +SELECT route, histogram(_timestamp, '1 minute') AS ts, count(*) AS cnt +FROM app_requests GROUP BY route, ts ORDER BY ts +``` + +各接口 P95 耗时(毫秒): + +```sql +SELECT route, approx_percentile_cont(duration_ms, 0.95) AS p95_ms +FROM app_requests GROUP BY route ORDER BY p95_ms DESC +``` + +各接口错误率(5xx 占比): + +```sql +SELECT route, + count(*) FILTER (WHERE status >= 500) * 100.0 / count(*) AS err_pct +FROM app_requests GROUP BY route ORDER BY err_pct DESC +``` + +## 一键导入现成仪表盘(QPS / P95 / 分位 / 错误率) + +备好了 [dashboard-api-metrics.json](dashboard-api-metrics.json),4 个面板:各接口每分钟请求数(QPS 源)、 +P95 耗时折线、P50/P95/P99 分位表、5xx 错误率表。 + +- **UI 导入**:Dashboards → 右上 **Import** → 选该 JSON 文件 → Import(每次导入新建,不覆盖)。 +- **或 API 导入**: + ```bash + curl -u admin@shaguabijia.local:Complexpass#123 -H 'Content-Type: application/json' \ + -X POST 'http://localhost:5080/api/default/dashboards?folder=default' \ + --data-binary @deploy/openobserve/dashboard-api-metrics.json + ``` + +导入后进仪表盘,右上角时间调到「最近 15 分钟 / 1 小时」、开自动刷新即可。低流量下 QPS 面板看「每分钟请求数」比「每秒」直观。 + +## 停止 / 清数据 + +```bash +docker compose down # 停止(保留数据) +docker compose down -v && rm -rf data # 停止并清空数据 +``` + +## 生产部署(单机)+ UI 访问 + +前提:app-server 与 OpenObserve **同机**,app→OO 走 localhost(`127.0.0.1:5080`)、不出网、无需 TLS。 +唯一要防的是**别把 :5080 裸暴露公网**。硬化版编排见 [docker-compose.prod.yml](docker-compose.prod.yml)。 + +### 部署步骤 + +```bash +# 1) 密码文件(本目录,已 gitignore) +echo "OO_ROOT_PASSWORD=$(python -c 'import secrets;print(secrets.token_urlsafe(24))')" > deploy/openobserve/.env + +# 2) 起 OpenObserve(只绑 127.0.0.1、命名卷持久化、mem 1g) +cd deploy/openobserve && docker compose -f docker-compose.prod.yml up -d +sudo systemctl enable docker # 开机自起 +``` + +3) app-server 的 `.env` 打开观测并**重启**(用非 root 的专用 ingest 账号): +```dotenv +OBSERVE_ENABLED=true +OBSERVE_ENDPOINT=http://127.0.0.1:5080 +OBSERVE_ORG=default +OBSERVE_STREAM=app_requests +OBSERVE_USER=ingest@shaguabijia.com # UI → Users 建的非 root 账号 +OBSERVE_PASSWORD=<该账号密码> +``` +```bash +sudo systemctl restart shaguabijia-app-server # 日志出现 "observe worker started" 即生效 +``` + +4) 两个必做收口(磁盘/安全): +- **保留期**:UI → Streams → `app_requests` → Data Retention 设 14/30 天(一请求一行,不封顶迟早撑爆盘)。 +- **专用账号**:UI → Users 建非 root 账号给 app 上报,root 只留人工登 UI。 + +### UI 访问(二选一) + +**A. SSH 隧道(推荐,零暴露、不用域名/证书):** +```bash +ssh -L 5080:127.0.0.1:5080 用户@服务器IP +# 然后本机浏览器开 http://localhost:5080 +``` + +**B. nginx 子域名反代(要固定 URL / 团队常看):** 见 [../nginx/observe.shaguabijia.com.conf](../nginx/observe.shaguabijia.com.conf)。 +需 DNS `observe.shaguabijia.com` → 本机 + 证书放 `/etc/nginx/ssl/`;含 IP 白名单 + TLS + WebSocket 透传。 + +> ⚠️ prod compose 必须保持 `127.0.0.1:5080:5080`;写成 `5080:5080`(绑 0.0.0.0)= 裸暴露公网,这是唯一真正的坑。 diff --git a/deploy/openobserve/dashboard-api-metrics.json b/deploy/openobserve/dashboard-api-metrics.json new file mode 100644 index 0000000..2563ad1 --- /dev/null +++ b/deploy/openobserve/dashboard-api-metrics.json @@ -0,0 +1,302 @@ +{ + "version": 8, + "dashboardId": "api-metrics", + "title": "接口监控 (QPS / 耗时 / 错误率)", + "description": "app-server 接口 QPS、P50/P95/P99 耗时、5xx 错误率。数据流 app_requests。", + "role": "", + "tabs": [ + { + "tabId": "default", + "name": "Default", + "panels": [ + { + "id": "panel_qps", + "type": "line", + "title": "各接口 每分钟请求数 (QPS 源)", + "description": "", + "config": { + "show_legends": true, + "legends_position": null, + "decimals": 2.0, + "axis_border_show": false, + "base_map": null, + "map_view": null + }, + "queryType": "sql", + "queries": [ + { + "query": "SELECT histogram(_timestamp, '1 minute') as ts, route, count(*) as reqs FROM app_requests GROUP BY ts, route ORDER BY ts", + "vrlFunctionQuery": "", + "customQuery": true, + "fields": { + "stream": "app_requests", + "stream_type": "logs", + "x": [ + { + "label": "ts", + "alias": "ts", + "column": "ts", + "color": null, + "sortBy": "ASC" + } + ], + "y": [ + { + "label": "reqs", + "alias": "reqs", + "column": "reqs", + "color": null + } + ], + "z": [], + "breakdown": [ + { + "label": "route", + "alias": "route", + "column": "route", + "color": null + } + ], + "filter": { + "filterType": "group", + "logicalOperator": "AND", + "conditions": [] + } + }, + "config": { + "promql_legend": "", + "layer_type": "scatter", + "weight_fixed": 1.0 + } + } + ], + "layout": { + "x": 0, + "y": 0, + "w": 24, + "h": 9, + "i": 1 + } + }, + { + "id": "panel_p95", + "type": "line", + "title": "各接口 P95 耗时 (ms)", + "description": "", + "config": { + "show_legends": true, + "legends_position": null, + "decimals": 2.0, + "axis_border_show": false, + "base_map": null, + "map_view": null + }, + "queryType": "sql", + "queries": [ + { + "query": "SELECT histogram(_timestamp, '1 minute') as ts, route, approx_percentile_cont(duration_ms, 0.95) as p95_ms FROM app_requests GROUP BY ts, route ORDER BY ts", + "vrlFunctionQuery": "", + "customQuery": true, + "fields": { + "stream": "app_requests", + "stream_type": "logs", + "x": [ + { + "label": "ts", + "alias": "ts", + "column": "ts", + "color": null, + "sortBy": "ASC" + } + ], + "y": [ + { + "label": "p95_ms", + "alias": "p95_ms", + "column": "p95_ms", + "color": null + } + ], + "z": [], + "breakdown": [ + { + "label": "route", + "alias": "route", + "column": "route", + "color": null + } + ], + "filter": { + "filterType": "group", + "logicalOperator": "AND", + "conditions": [] + } + }, + "config": { + "promql_legend": "", + "layer_type": "scatter", + "weight_fixed": 1.0 + } + } + ], + "layout": { + "x": 24, + "y": 0, + "w": 24, + "h": 9, + "i": 2 + } + }, + { + "id": "panel_pctl", + "type": "table", + "title": "各接口 耗时分位 P50/P95/P99 (ms)", + "description": "", + "config": { + "show_legends": true, + "legends_position": null, + "decimals": 2.0, + "axis_border_show": false, + "base_map": null, + "map_view": null + }, + "queryType": "sql", + "queries": [ + { + "query": "SELECT route, approx_percentile_cont(duration_ms,0.5) as p50, approx_percentile_cont(duration_ms,0.95) as p95, approx_percentile_cont(duration_ms,0.99) as p99, count(*) as cnt FROM app_requests GROUP BY route ORDER BY p95 DESC", + "vrlFunctionQuery": "", + "customQuery": true, + "fields": { + "stream": "app_requests", + "stream_type": "logs", + "x": [ + { + "label": "route", + "alias": "route", + "column": "route", + "color": null + } + ], + "y": [ + { + "label": "p50", + "alias": "p50", + "column": "p50", + "color": null + }, + { + "label": "p95", + "alias": "p95", + "column": "p95", + "color": null + }, + { + "label": "p99", + "alias": "p99", + "column": "p99", + "color": null + }, + { + "label": "cnt", + "alias": "cnt", + "column": "cnt", + "color": null + } + ], + "z": [], + "breakdown": [], + "filter": { + "filterType": "group", + "logicalOperator": "AND", + "conditions": [] + } + }, + "config": { + "promql_legend": "", + "layer_type": "scatter", + "weight_fixed": 1.0 + } + } + ], + "layout": { + "x": 0, + "y": 9, + "w": 24, + "h": 9, + "i": 3 + } + }, + { + "id": "panel_err", + "type": "table", + "title": "各接口 错误率 (5xx %)", + "description": "", + "config": { + "show_legends": true, + "legends_position": null, + "decimals": 2.0, + "axis_border_show": false, + "base_map": null, + "map_view": null + }, + "queryType": "sql", + "queries": [ + { + "query": "SELECT route, count(*) FILTER (WHERE status >= 500) * 100.0 / count(*) as err_pct, count(*) as cnt FROM app_requests GROUP BY route ORDER BY err_pct DESC", + "vrlFunctionQuery": "", + "customQuery": true, + "fields": { + "stream": "app_requests", + "stream_type": "logs", + "x": [ + { + "label": "route", + "alias": "route", + "column": "route", + "color": null + } + ], + "y": [ + { + "label": "err_pct", + "alias": "err_pct", + "column": "err_pct", + "color": null + }, + { + "label": "cnt", + "alias": "cnt", + "column": "cnt", + "color": null + } + ], + "z": [], + "breakdown": [], + "filter": { + "filterType": "group", + "logicalOperator": "AND", + "conditions": [] + } + }, + "config": { + "promql_legend": "", + "layer_type": "scatter", + "weight_fixed": 1.0 + } + } + ], + "layout": { + "x": 24, + "y": 9, + "w": 24, + "h": 9, + "i": 4 + } + } + ] + } + ], + "variables": { + "list": [] + } +} \ No newline at end of file diff --git a/deploy/openobserve/docker-compose.prod.yml b/deploy/openobserve/docker-compose.prod.yml new file mode 100644 index 0000000..ddf4b10 --- /dev/null +++ b/deploy/openobserve/docker-compose.prod.yml @@ -0,0 +1,33 @@ +# 生产用 OpenObserve(单机)。相对本地版 docker-compose.yml 的区别: +# - 端口只绑 127.0.0.1 → 公网/外网都到不了(UI 访问走 SSH 隧道或 nginx 反代,见 README) +# - root 密码走环境变量(放同目录 .env,已 gitignore,勿提交) +# - 数据 bind-mount 到宿主 /data 分区(需预建目录 + 确认容器可写)+ CPU/内存上限(与 app/PG 共存防抢内存) +# +# 用法: +# 1) 本目录建 .env(已 gitignore): +# OO_ROOT_PASSWORD=<强随机串> # 生成: python -c "import secrets;print(secrets.token_urlsafe(24))" +# 2) docker compose -f docker-compose.prod.yml up -d +# 3) 开机自起: sudo systemctl enable docker +services: + openobserve: + image: public.ecr.aws/zinclabs/openobserve:v0.91.2 + container_name: openobserve + ports: + - "127.0.0.1:5080:5080" # 只绑本机,安全 + environment: + ZO_ROOT_USER_EMAIL: "admin@shaguabijia.com" + ZO_ROOT_USER_PASSWORD: "${OO_ROOT_PASSWORD:?请先在 deploy/openobserve/.env 里设 OO_ROOT_PASSWORD}" + ZO_DATA_DIR: "/data" + ZO_COMPACT_DATA_RETENTION_DAYS: "30" # 超 30 天自动删,防爆盘(默认 3650 天=10年) + ZO_TELEMETRY: "false" # 关匿名遥测(内网自用);变量名是 ZO_TELEMETRY,不是 *_ENABLED + volumes: + - /data/openobserve/data:/data # 绑定挂载到宿主机的 /data/openobserve/data 目录(建议该目录所在分区有 20G+ 空间) + restart: unless-stopped + deploy: + resources: + limits: # 硬上限:防 OO 查询/ingest 抢爆 CPU/内存,拖垮同机 PG+app + cpus: '2.0' + memory: 3G + logging: # 容器 stdout 日志上限,防爆盘 + driver: json-file + options: { max-size: "10m", max-file: "3" } diff --git a/deploy/openobserve/docker-compose.yml b/deploy/openobserve/docker-compose.yml new file mode 100644 index 0000000..f4cc61a --- /dev/null +++ b/deploy/openobserve/docker-compose.yml @@ -0,0 +1,16 @@ +# 本地开发用 OpenObserve(单容器 = local 模式)。用于接收 app-server 的接口指标(QPS/耗时/错误率)。 +# 启动: cd deploy/openobserve && docker compose up -d +# Web UI: http://localhost:5080 (账号见下方 env) +services: + openobserve: + image: public.ecr.aws/zinclabs/openobserve:latest + container_name: openobserve + ports: + - "5080:5080" + environment: + ZO_ROOT_USER_EMAIL: "admin@shaguabijia.local" + ZO_ROOT_USER_PASSWORD: "Complexpass#123" + ZO_DATA_DIR: "/data" + volumes: + - ./data:/data + restart: unless-stopped diff --git a/docs/superpowers/plans/2026-07-06-openobserve-api-metrics.md b/docs/superpowers/plans/2026-07-06-openobserve-api-metrics.md new file mode 100644 index 0000000..ee596ae --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-openobserve-api-metrics.md @@ -0,0 +1,897 @@ +# 接口 QPS + 耗时可观测(OpenObserve)实现计划 + +> **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:** 给 app-server 每个接口采集 QPS + 耗时 + 错误率,经轻量 ASGI 中间件 + 后台 worker 批量直采到本地 Docker 的 OpenObserve。 + +**Architecture:** 纯 ASGI 中间件测每请求耗时/抓路由模板+状态码 → 非阻塞入有界队列(满则丢、绝不阻塞)→ 后台 asyncio worker 批量 POST 到 OpenObserve `_json` ingest 端点。请求路径零 I/O;未配置观测则整套 no-op;上报失败丢批不重试。 + +**Tech Stack:** FastAPI / Starlette ASGI 中间件、`asyncio.Queue`、`httpx.AsyncClient`(已有依赖)、pydantic-settings、OpenObserve(Docker)。 + +参考 spec:[docs/superpowers/specs/2026-07-06-openobserve-api-metrics-design.md](2026-07-06-openobserve-api-metrics-design.md) + +--- + +## 文件结构 + +| 文件 | 职责 | +|---|---| +| `app/core/config.py`(改) | 新增 `OBSERVE_*` 配置 + `observe_configured` 门槛属性 | +| `app/core/observe.py`(新) | 有界事件队列 + `record_event` + 路由模板解析 + `RequestMetricsMiddleware` | +| `app/core/observe_worker.py`(新) | 后台批量上报 worker:`_collect_batch` / `_post_batch` / `start_*` / `stop_*` | +| `app/main.py`(改) | 挂中间件(最外层)+ lifespan 启停 worker | +| `.env.example`(改) | 新增 `OBSERVE_*` 注释段 | +| `deploy/openobserve/docker-compose.yml`(新) | 本地 OpenObserve 容器 | +| `deploy/openobserve/README.md`(新) | 部署步骤 + 查询/仪表盘 SQL | +| `tests/test_observe.py`(新) | 配置门槛 / 队列 / 中间件 / worker 单测 | + +**关键接口契约(跨任务一致,勿改名):** +- `app.core.observe.get_queue() -> asyncio.Queue[dict]` +- `app.core.observe.record_event(event: dict) -> None` +- `app.core.observe.take_dropped() -> int` +- `app.core.observe.RequestMetricsMiddleware`(ASGI class,`__init__(self, app)`) +- 事件字段:`_timestamp`(µs int) / `service` / `env` / `method` / `route` / `status` / `duration_ms`(float) +- `app.core.observe_worker.start_observe_worker() -> asyncio.Task | None` +- `app.core.observe_worker.stop_observe_worker(task) -> None` +- `settings.observe_configured -> bool` + +--- + +## Task 1: 配置项 `OBSERVE_*` + `observe_configured` + +**Files:** +- Modify: `app/core/config.py`(在 `cors_origins_list` property 之后、`is_prod` property 之前插入) +- Test: `tests/test_observe.py`(新建) + +- [ ] **Step 1: 写失败测试** + +新建 `tests/test_observe.py`: + +```python +"""接口指标可观测(observe)单测:配置门槛 / 队列 / 中间件 / worker。 + +沿用仓库约定:TestClient + monkeypatch,绝不打真网络。observe 默认关(conftest 未设 +OBSERVE_*),需要开启的用例用 monkeypatch 改 settings 单例属性。 +""" +from __future__ import annotations + +from app.core.config import settings + + +def test_observe_configured_requires_switch_and_creds(monkeypatch): + # 开关开 + endpoint(默认 localhost)+ user + password 齐全 → True + monkeypatch.setattr(settings, "OBSERVE_ENABLED", True) + monkeypatch.setattr(settings, "OBSERVE_USER", "u") + monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "p") + assert settings.observe_configured is True + + # 缺密码 → False + monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "") + assert settings.observe_configured is False + + # 开关关 → False(即便凭证齐全) + monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "p") + monkeypatch.setattr(settings, "OBSERVE_ENABLED", False) + assert settings.observe_configured is False +``` + +- [ ] **Step 2: 运行,确认失败** + +Run: `pytest tests/test_observe.py::test_observe_configured_requires_switch_and_creds -q` +Expected: FAIL —— `AttributeError`(`settings` 无 `OBSERVE_ENABLED` / 无 `observe_configured`) + +- [ ] **Step 3: 实现配置** + +在 `app/core/config.py` 的 `cors_origins_list` property 之后、`is_prod` property 之前插入: + +```python + # ===== 可观测(OpenObserve 接口指标)===== + # 采集每个接口的 QPS + 耗时 + 错误率,批量直采到 OpenObserve(本地 Docker)。 + # 默认关(prod 安全):未开启 → 中间件透传、worker 不启动,整套 no-op。 + # 开启需 ENABLED=true 且 ENDPOINT/USER/PASSWORD 齐全(见 observe_configured)。 + OBSERVE_ENABLED: bool = False + OBSERVE_ENDPOINT: str = "http://localhost:5080" # OpenObserve base URL + OBSERVE_ORG: str = "default" # 组织名 + OBSERVE_STREAM: str = "app_requests" # stream 名(首次上报自动建) + OBSERVE_USER: str = "" # Basic auth 邮箱 + OBSERVE_PASSWORD: str = "" # Basic auth 密码/token + OBSERVE_FLUSH_INTERVAL_SEC: float = 5.0 # worker 最长攒批间隔 + OBSERVE_BATCH_MAX: int = 200 # 单批最大事件数 + OBSERVE_QUEUE_MAX: int = 10000 # 有界队列上限,满则丢 + OBSERVE_TIMEOUT_SEC: float = 5.0 # 上报 HTTP 超时 + + @property + def observe_configured(self) -> bool: + """观测上报可用 = 总开关开 且 endpoint/账号/密码齐全(缺则整套 no-op)。""" + return bool( + self.OBSERVE_ENABLED + and self.OBSERVE_ENDPOINT + and self.OBSERVE_USER + and self.OBSERVE_PASSWORD + ) +``` + +- [ ] **Step 4: 运行,确认通过** + +Run: `pytest tests/test_observe.py::test_observe_configured_requires_switch_and_creds -q` +Expected: PASS + +- [ ] **Step 5: 提交** + +```bash +git add app/core/config.py tests/test_observe.py +git commit -m "feat(observe): 加 OBSERVE_* 配置与 observe_configured 门槛" +``` + +--- + +## Task 2: 事件队列 + `record_event` + `take_dropped` + +**Files:** +- Create: `app/core/observe.py` +- Test: `tests/test_observe.py`(追加) + +- [ ] **Step 1: 写失败测试** + +在 `tests/test_observe.py` 顶部 import 区补 `import asyncio` 和 `from app.core import observe`,并追加: + +```python +def test_record_event_enqueues(monkeypatch): + q = asyncio.Queue(maxsize=10) + monkeypatch.setattr(observe, "_queue", q) + observe.record_event({"route": "/x"}) + assert q.get_nowait() == {"route": "/x"} + + +def test_record_event_drops_when_full(monkeypatch): + q = asyncio.Queue(maxsize=1) + monkeypatch.setattr(observe, "_queue", q) + monkeypatch.setattr(observe, "_dropped", 0) + observe.record_event({"n": 1}) # 占满 + observe.record_event({"n": 2}) # 满 → 丢弃当前,不抛异常 + assert observe.take_dropped() == 1 + assert observe.take_dropped() == 0 # 取出后清零 + assert q.get_nowait() == {"n": 1} # 保留的是先到的 +``` + +- [ ] **Step 2: 运行,确认失败** + +Run: `pytest tests/test_observe.py -q -k record_event` +Expected: FAIL —— `ModuleNotFoundError: app.core.observe` 或无 `record_event` + +- [ ] **Step 3: 实现 `app/core/observe.py`(先只放队列部分)** + +> 注意:本步只放队列相关代码。中间件用到的 `os`/`time`/`Match` 及 `_SKIP_PATHS`/`_UNMATCHED`/`_SERVICE` 常量放到 Task 3 一并加入——否则本步提交时 ruff 会报 F401 未用导入。 + +新建 `app/core/observe.py`: + +```python +"""接口指标埋点:有界事件队列 + 纯 ASGI 中间件。 + +每个 HTTP 请求测总耗时、抓路由模板 + 状态码,非阻塞塞进有界队列;由 observe_worker +后台批量上报到 OpenObserve。请求路径上无任何 I/O。未配置观测时中间件直接透传。 +""" +from __future__ import annotations + +import asyncio + +from app.core.config import settings + +# 有界事件队列(懒创建,见 get_queue):首次取用时在运行中的 loop 里建,避免 import 期 +# 无 loop 的边角问题;put_nowait/get_nowait 不需运行中的 loop → 可在无 loop 下测试。 +_queue: asyncio.Queue[dict] | None = None +# 队列满时的丢弃计数,worker 定期取出打日志。 +_dropped = 0 + + +def get_queue() -> asyncio.Queue[dict]: + """返回全局有界事件队列(懒创建)。测试可 monkeypatch 模块级 _queue 换成小队列。""" + global _queue + if _queue is None: + _queue = asyncio.Queue(maxsize=settings.OBSERVE_QUEUE_MAX) + return _queue + + +def take_dropped() -> int: + """取出并清零累计丢弃数(供 worker 打点)。""" + global _dropped + n, _dropped = _dropped, 0 + return n + + +def record_event(event: dict) -> None: + """非阻塞入队;队列满则丢弃当前事件并计数。永不抛异常、永不阻塞请求。""" + global _dropped + try: + get_queue().put_nowait(event) + except asyncio.QueueFull: + _dropped += 1 +``` + +- [ ] **Step 4: 运行,确认通过** + +Run: `pytest tests/test_observe.py -q -k record_event` +Expected: PASS + +- [ ] **Step 5: 提交** + +```bash +git add app/core/observe.py tests/test_observe.py +git commit -m "feat(observe): 加有界事件队列与 record_event(满则丢)" +``` + +--- + +## Task 3: `RequestMetricsMiddleware`(路由模板 + 状态码 + 耗时) + +**Files:** +- Modify: `app/core/observe.py`(追加 `_resolve_route` 和中间件 class) +- Test: `tests/test_observe.py`(追加) + +- [ ] **Step 1: 写失败测试** + +在 `tests/test_observe.py` 顶部 import 区补: + +```python +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +``` + +并追加: + +```python +def _make_probe_app() -> FastAPI: + """独立最小 app:只挂中间件 + 两个无鉴权路由,不碰真业务 DB/auth。""" + app = FastAPI() + app.add_middleware(observe.RequestMetricsMiddleware) + + @app.get("/things/{tid}") + def get_thing(tid: str): + return {"tid": tid} + + @app.get("/health") + def health(): + return {"ok": True} + + return app + + +@pytest.fixture +def observe_on(monkeypatch): + """开启观测 + 换一个干净小队列,返回该队列供断言。""" + monkeypatch.setattr(settings, "OBSERVE_ENABLED", True) + monkeypatch.setattr(settings, "OBSERVE_USER", "u") + monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "p") + q = asyncio.Queue(maxsize=100) + monkeypatch.setattr(observe, "_queue", q) + return q + + +def test_middleware_records_route_template(observe_on): + client = TestClient(_make_probe_app()) + r = client.get("/things/42") + assert r.status_code == 200 + evt = observe_on.get_nowait() + assert evt["route"] == "/things/{tid}" # 模板,不是 /things/42 + assert evt["method"] == "GET" + assert evt["status"] == 200 + assert evt["duration_ms"] >= 0 + assert evt["service"] and "env" in evt and isinstance(evt["_timestamp"], int) + + +def test_middleware_skips_health(observe_on): + client = TestClient(_make_probe_app()) + client.get("/health") + assert observe_on.empty() + + +def test_middleware_unmatched_route_is_normalized(observe_on): + client = TestClient(_make_probe_app()) + r = client.get("/definitely-not-a-route") + assert r.status_code == 404 + evt = observe_on.get_nowait() + assert evt["route"] == "__unmatched__" + assert evt["status"] == 404 + + +def test_middleware_noop_when_disabled(monkeypatch): + monkeypatch.setattr(settings, "OBSERVE_ENABLED", False) + q = asyncio.Queue(maxsize=100) + monkeypatch.setattr(observe, "_queue", q) + client = TestClient(_make_probe_app()) + client.get("/things/1") + assert q.empty() # 未配置观测 → 零入队 +``` + +- [ ] **Step 2: 运行,确认失败** + +Run: `pytest tests/test_observe.py -q -k middleware` +Expected: FAIL —— `AttributeError: module 'app.core.observe' has no attribute 'RequestMetricsMiddleware'` + +- [ ] **Step 3a: 给 `app/core/observe.py` 补中间件用的导入与常量** + +把顶部 import 段从 + +```python +from __future__ import annotations + +import asyncio + +from app.core.config import settings +``` + +改成 + +```python +from __future__ import annotations + +import asyncio +import os +import time + +from starlette.routing import Match + +from app.core.config import settings + +# 不采集的路径(纯噪音):健康检查。 +_SKIP_PATHS = frozenset({"/health"}) +# 未匹配路由(404/扫描器)归一到此,防维度爆炸。 +_UNMATCHED = "__unmatched__" +# service 字段:与 logging.py 同源(LOG_SERVICE_NAME),默认 app-server。 +_SERVICE = os.getenv("LOG_SERVICE_NAME", "app-server") +``` + +(`_queue` / `_dropped` / `get_queue` / `take_dropped` / `record_event` 保持不动。) + +- [ ] **Step 3b: 实现中间件(追加到 `app/core/observe.py` 末尾)** + +```python +def _resolve_route(scope) -> str: + """从 scope 取路由模板(如 /things/{tid})。优先 scope['route'](现代 Starlette + 路由后写入);取不到则手动匹配一次(老版本兜底);仍无 → __unmatched__(404/扫描器)。""" + route = scope.get("route") + path = getattr(route, "path", None) + if path: + return path + app_ = scope.get("app") + router = getattr(app_, "router", None) + for candidate in getattr(router, "routes", []): + try: + match, _ = candidate.matches(scope) + except Exception: # noqa: BLE001 - 匹配兜底,任一路由异常不影响整体 + continue + if match == Match.FULL and getattr(candidate, "path", None): + return candidate.path + return _UNMATCHED + + +class RequestMetricsMiddleware: + """纯 ASGI 中间件:测每个 http 请求耗时,记 method/route/status/duration。 + + 放在最外层(main.py 里 CORS 之后 add),测到含 CORS 的完整耗时。未配置观测 → 透传。 + """ + + def __init__(self, app) -> None: + self.app = app + + async def __call__(self, scope, receive, send) -> None: + if scope["type"] != "http" or not settings.observe_configured: + await self.app(scope, receive, send) + return + if scope.get("path") in _SKIP_PATHS: + await self.app(scope, receive, send) + return + + start = time.perf_counter() + status_holder = {"status": 500} # 下游异常未产出 response 时兜底 500 + + async def send_wrapper(message) -> None: + if message["type"] == "http.response.start": + status_holder["status"] = message["status"] + await send(message) + + try: + await self.app(scope, receive, send_wrapper) + finally: + duration_ms = (time.perf_counter() - start) * 1000.0 + record_event({ + "_timestamp": int(time.time() * 1_000_000), # µs,OpenObserve 时间列 + "service": _SERVICE, + "env": settings.APP_ENV, + "method": scope.get("method", ""), + "route": _resolve_route(scope), + "status": status_holder["status"], + "duration_ms": round(duration_ms, 3), + }) +``` + +- [ ] **Step 4: 运行,确认通过** + +Run: `pytest tests/test_observe.py -q -k middleware` +Expected: PASS(4 个中间件用例全过) + +> 若 `test_middleware_records_route_template` 拿到的是 `/things/42` 而非模板,说明该 Starlette 版本未在 `scope["route"]` 写模板——此时 `_resolve_route` 的手动匹配兜底应已生效并返回模板;若仍不对,检查兜底分支是否被 import 顺序影响。 + +- [ ] **Step 5: 提交** + +```bash +git add app/core/observe.py tests/test_observe.py +git commit -m "feat(observe): 加 RequestMetricsMiddleware(路由模板+状态码+耗时)" +``` + +--- + +## Task 4: 后台上报 worker + +**Files:** +- Create: `app/core/observe_worker.py` +- Test: `tests/test_observe.py`(追加) + +- [ ] **Step 1: 写失败测试** + +在 `tests/test_observe.py` 顶部 import 区补: + +```python +import httpx +from app.core import observe_worker +``` + +并追加: + +```python +async def test_collect_batch_drains_up_to_batch_max(monkeypatch): + q = asyncio.Queue(maxsize=100) + monkeypatch.setattr(observe, "_queue", q) + monkeypatch.setattr(settings, "OBSERVE_FLUSH_INTERVAL_SEC", 0.1) + monkeypatch.setattr(settings, "OBSERVE_BATCH_MAX", 200) + for i in range(3): + q.put_nowait({"n": i}) + batch = await observe_worker._collect_batch() + assert [e["n"] for e in batch] == [0, 1, 2] + + +async def test_collect_batch_timeout_returns_empty(monkeypatch): + q = asyncio.Queue(maxsize=100) + monkeypatch.setattr(observe, "_queue", q) + monkeypatch.setattr(settings, "OBSERVE_FLUSH_INTERVAL_SEC", 0.05) + batch = await observe_worker._collect_batch() + assert batch == [] + + +async def test_post_batch_hits_json_ingest_url(monkeypatch): + monkeypatch.setattr(settings, "OBSERVE_ORG", "default") + monkeypatch.setattr(settings, "OBSERVE_STREAM", "app_requests") + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["json"] = request.content + return httpx.Response(200, json={"code": 200}) + + client = httpx.AsyncClient( + base_url="http://oo", transport=httpx.MockTransport(handler) + ) + await observe_worker._post_batch(client, [{"route": "/x", "status": 200}]) + await client.aclose() + assert captured["url"] == "http://oo/api/default/app_requests/_json" + assert b"/x" in captured["json"] + + +def test_start_observe_worker_noop_when_not_configured(monkeypatch): + monkeypatch.setattr(settings, "OBSERVE_ENABLED", False) + assert observe_worker.start_observe_worker() is None +``` + +- [ ] **Step 2: 运行,确认失败** + +Run: `pytest tests/test_observe.py -q -k "collect_batch or post_batch or start_observe"` +Expected: FAIL —— `ModuleNotFoundError: app.core.observe_worker` + +- [ ] **Step 3: 实现 `app/core/observe_worker.py`** + +新建 `app/core/observe_worker.py`: + +```python +"""接口指标后台上报 worker:批量 drain 事件队列 → POST 到 OpenObserve。 + +对齐 heartbeat_monitor_worker 等的 start_*/stop_* 形态。best-effort 遥测:catch 全部 +异常,上报失败直接丢批不重试。未配置观测 → start 返回 None(不启动),整套 no-op。 +""" +from __future__ import annotations + +import asyncio +import contextlib +import logging + +import httpx + +from app.core.config import settings +from app.core.observe import get_queue, take_dropped + +logger = logging.getLogger("shagua.observe") + +# 上报用的 httpx client,start 时建、stop 时关。 +_client: httpx.AsyncClient | None = None + + +async def _collect_batch() -> list[dict]: + """等到 ≥1 条(或到 flush 间隔)后,连抽到 BATCH_MAX 条或抽空。超时且空 → 返回 []。""" + queue = get_queue() + batch: list[dict] = [] + try: + first = await asyncio.wait_for( + queue.get(), timeout=settings.OBSERVE_FLUSH_INTERVAL_SEC + ) + except asyncio.TimeoutError: + return batch + batch.append(first) + while len(batch) < settings.OBSERVE_BATCH_MAX: + try: + batch.append(queue.get_nowait()) + except asyncio.QueueEmpty: + break + return batch + + +async def _post_batch(client: httpx.AsyncClient, batch: list[dict]) -> None: + """POST 一批事件到 OpenObserve 的 _json ingest 端点。非 2xx 仅告警。""" + url = f"/api/{settings.OBSERVE_ORG}/{settings.OBSERVE_STREAM}/_json" + resp = await client.post(url, json=batch) + if resp.status_code >= 300: + logger.warning( + "observe ingest failed status=%s body=%s", + resp.status_code, + resp.text[:200], + ) + + +async def _run_loop(client: httpx.AsyncClient) -> None: + try: + while True: + batch = await _collect_batch() + dropped = take_dropped() + if dropped: + logger.warning("observe dropped %d events (queue full)", dropped) + if not batch: + continue + try: + await _post_batch(client, batch) + except Exception: # noqa: BLE001 - best-effort 遥测,失败丢批不重试、不退出 + logger.warning( + "observe post batch failed, dropped %d events", + len(batch), + exc_info=True, + ) + except asyncio.CancelledError: + logger.info("observe worker stopped") + raise + + +def start_observe_worker() -> asyncio.Task | None: + """启动上报 worker。未配置观测 → 返回 None(no-op)。""" + global _client + if not settings.observe_configured: + return None + _client = httpx.AsyncClient( + base_url=settings.OBSERVE_ENDPOINT, + auth=(settings.OBSERVE_USER, settings.OBSERVE_PASSWORD), + timeout=settings.OBSERVE_TIMEOUT_SEC, + ) + logger.info( + "observe worker started endpoint=%s org=%s stream=%s", + settings.OBSERVE_ENDPOINT, + settings.OBSERVE_ORG, + settings.OBSERVE_STREAM, + ) + return asyncio.create_task(_run_loop(_client), name="observe-worker") + + +async def stop_observe_worker(task: asyncio.Task | None) -> None: + """收尾:cancel worker → best-effort 发最后一批 → 关 client。""" + global _client + if task is None: + return + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + if _client is not None: + # worker 已停,安全 drain 剩余并 best-effort 发最后一批(短超时,不卡关停)。 + try: + queue = get_queue() + final: list[dict] = [] + while len(final) < settings.OBSERVE_BATCH_MAX: + try: + final.append(queue.get_nowait()) + except asyncio.QueueEmpty: + break + if final: + await asyncio.wait_for( + _post_batch(_client, final), timeout=settings.OBSERVE_TIMEOUT_SEC + ) + except Exception: # noqa: BLE001 - 关停期尽力而为,失败忽略 + pass + await _client.aclose() + _client = None +``` + +- [ ] **Step 4: 运行,确认通过** + +Run: `pytest tests/test_observe.py -q -k "collect_batch or post_batch or start_observe"` +Expected: PASS + +- [ ] **Step 5: 提交** + +```bash +git add app/core/observe_worker.py tests/test_observe.py +git commit -m "feat(observe): 加后台批量上报 worker(失败丢批不重试)" +``` + +--- + +## Task 5: 接线到 `app/main.py`(挂中间件 + lifespan 启停) + +**Files:** +- Modify: `app/main.py`(import 区、lifespan、CORS 之后) +- Test: `tests/test_observe.py`(追加) + +- [ ] **Step 1: 写失败测试** + +在 `tests/test_observe.py` 追加: + +```python +def test_app_has_metrics_middleware(): + from app.main import app + names = [m.cls.__name__ for m in app.user_middleware] + assert "RequestMetricsMiddleware" in names +``` + +- [ ] **Step 2: 运行,确认失败** + +Run: `pytest tests/test_observe.py::test_app_has_metrics_middleware -q` +Expected: FAIL —— 断言失败(中间件尚未挂载) + +- [ ] **Step 3: 实现接线** + +3a. 在 `app/main.py` import 区(`withdraw_reconcile_worker` import 块之后)加: + +```python +from app.core.observe import RequestMetricsMiddleware +from app.core.observe_worker import ( + start_observe_worker, + stop_observe_worker, +) +``` + +3b. lifespan 里加启停(现有 `daily_exchange_task = start_daily_exchange_worker()` 之后、`try:` 之前加一行;`finally` 里在 `stop_daily_exchange_worker` 之后加一行): + +```python + daily_exchange_task = start_daily_exchange_worker() + observe_task = start_observe_worker() + try: + yield + finally: + await stop_heartbeat_monitor(heartbeat_task) + await stop_withdraw_reconcile_worker(reconcile_task) + await stop_daily_exchange_worker(daily_exchange_task) + await stop_observe_worker(observe_task) + await aclose_pricebot_client() + logger.info("shutting down") +``` + +3c. 挂中间件——在 CORS 的 `if settings.cors_origins_list:` 整块之后加(使其成为最外层,测到含 CORS 的完整耗时): + +```python +# 接口指标埋点(最外层:测含 CORS 的完整耗时)。未配置观测时中间件自 no-op。 +app.add_middleware(RequestMetricsMiddleware) +``` + +- [ ] **Step 4: 运行,确认通过** + +Run: `pytest tests/test_observe.py::test_app_has_metrics_middleware -q` +Expected: PASS + +- [ ] **Step 5: 跑整套 observe 测试 + 全量回归,确认无破坏** + +Run: `pytest tests/test_observe.py -q && pytest -q` +Expected: 全 PASS(现有用例不受影响:conftest 未设 `OBSERVE_*` → 观测关 → worker no-op、中间件透传) + +- [ ] **Step 6: 提交** + +```bash +git add app/main.py tests/test_observe.py +git commit -m "feat(observe): main.py 挂中间件 + lifespan 启停上报 worker" +``` + +--- + +## Task 6: OpenObserve 本地部署(compose + README + .env.example) + +**Files:** +- Create: `deploy/openobserve/docker-compose.yml` +- Create: `deploy/openobserve/README.md` +- Modify: `.env.example`(追加 `OBSERVE_*` 段) + +- [ ] **Step 1: 写 docker-compose** + +新建 `deploy/openobserve/docker-compose.yml`: + +```yaml +# 本地开发用 OpenObserve(单容器 = local 模式)。用于接收 app-server 的接口指标。 +# 启动: cd deploy/openobserve && docker compose up -d +# Web UI: http://localhost:5080 (账号见下方 env) +services: + openobserve: + image: public.ecr.aws/zinclabs/openobserve:latest + container_name: openobserve + ports: + - "5080:5080" + environment: + ZO_ROOT_USER_EMAIL: "admin@shaguabijia.local" + ZO_ROOT_USER_PASSWORD: "Complexpass#123" + ZO_DATA_DIR: "/data" + volumes: + - ./data:/data + restart: unless-stopped +``` + +- [ ] **Step 2: 写 README** + +新建 `deploy/openobserve/README.md`: + +````markdown +# OpenObserve 本地部署(接口 QPS / 耗时可观测) + +app-server 通过中间件采集每个接口的 QPS + 耗时 + 错误率,批量上报到这里。 + +## 启动 + +```bash +cd deploy/openobserve +docker compose up -d +``` + +- Web UI:http://localhost:5080 +- 登录:`admin@shaguabijia.local` / `Complexpass#123`(见 `docker-compose.yml`) +- 数据落 `deploy/openobserve/data/`(已挂卷持久化;`data/` 建议 gitignore) + +## 让 app-server 上报 + +在项目根的 `.env` 打开观测(`OBSERVE_*`,账号密码与 compose 里 root 一致): + +```dotenv +OBSERVE_ENABLED=true +OBSERVE_ENDPOINT=http://localhost:5080 +OBSERVE_ORG=default +OBSERVE_STREAM=app_requests +OBSERVE_USER=admin@shaguabijia.local +OBSERVE_PASSWORD=Complexpass#123 +``` + +重启 app-server,随便打几个接口。stream `app_requests` **首次上报自动创建**, +在 UI 的 Logs → 选 `app_requests` 就能看到逐条请求事件。 + +## 查询(Logs 页 SQL,或建 Dashboard 面板) + +各接口 QPS(1 分钟分桶,面板里再除 60 得每秒): + +```sql +SELECT route, histogram(_timestamp, '1 minute') AS ts, count(*) AS cnt +FROM app_requests GROUP BY route, ts ORDER BY ts +``` + +各接口 P95 耗时(毫秒): + +```sql +SELECT route, approx_percentile_cont(duration_ms, 0.95) AS p95_ms +FROM app_requests GROUP BY route ORDER BY p95_ms DESC +``` + +各接口错误率(5xx 占比): + +```sql +SELECT route, + count(*) FILTER (WHERE status >= 500) * 100.0 / count(*) AS err_pct +FROM app_requests GROUP BY route ORDER BY err_pct DESC +``` + +## 停止 / 清数据 + +```bash +docker compose down # 停止(保留数据) +docker compose down -v && rm -rf data # 停止并清空数据 +``` + +> 生产部署(持久化规格、独立 ingest 账号、鉴权收紧)见 spec 第 9 节,本期不做。 +```` + +- [ ] **Step 3: 追加 `.env.example`** + +在 `.env.example` 末尾追加: + +```dotenv + +# ===== 可观测(OpenObserve 接口指标)===== +# 采集每个接口 QPS + 耗时 + 错误率,批量直采到 OpenObserve(本地 Docker,见 deploy/openobserve/)。 +# 默认关;开启需 ENABLED=true 且填 USER/PASSWORD(与 docker-compose 里 root 账号一致)。 +OBSERVE_ENABLED=false +OBSERVE_ENDPOINT=http://localhost:5080 +OBSERVE_ORG=default +OBSERVE_STREAM=app_requests +OBSERVE_USER=admin@shaguabijia.local +OBSERVE_PASSWORD=Complexpass#123 +# 进阶(一般不用改):攒批间隔秒 / 单批最大条数 / 有界队列上限(满则丢) / 上报超时秒 +OBSERVE_FLUSH_INTERVAL_SEC=5 +OBSERVE_BATCH_MAX=200 +OBSERVE_QUEUE_MAX=10000 +OBSERVE_TIMEOUT_SEC=5 +``` + +- [ ] **Step 4: 校验 compose 语法(不需真拉镜像)** + +Run: `docker compose -f deploy/openobserve/docker-compose.yml config` +Expected: 打印规整后的配置、无报错(若本机无 docker,可跳过,标注为手动验证项) + +- [ ] **Step 5: 提交** + +```bash +git add deploy/openobserve/docker-compose.yml deploy/openobserve/README.md .env.example +git commit -m "feat(observe): 加 OpenObserve 本地 compose + README + .env.example" +``` + +--- + +## Task 7: 端到端手动验证 + 全量 lint/test 收尾 + +**Files:** 无(验证 + 收尾) + +- [ ] **Step 1: 起 OpenObserve** + +Run: `cd deploy/openobserve && docker compose up -d` +Expected: 容器起来,浏览器打开 http://localhost:5080 能登录 + +- [ ] **Step 2: 本地开观测起 app-server** + +在根 `.env` 设 `OBSERVE_ENABLED=true` + `OBSERVE_USER/PASSWORD`(同 compose),然后: + +Run: `./run.sh`(Windows 用 `python -m uvicorn app.main:app --port 8770 --reload --reload-dir app`) +Expected: 启动日志出现 `observe worker started endpoint=http://localhost:5080 ...` + +- [ ] **Step 3: 打几个接口产生数据** + +Run: `curl http://localhost:8770/health && curl http://localhost:8770/things-does-not-exist -i`(或正常业务接口若干) +Expected: 稍等 ≤5s(flush 间隔),OpenObserve UI 的 Logs → `app_requests` 出现事件;`/health` 不应出现;不存在的路径 route 为 `__unmatched__` + +- [ ] **Step 4: 验证三条查询** + +在 OpenObserve UI 分别粘贴 README 里的 QPS / P95 / 错误率 SQL,确认能出数。 + +- [ ] **Step 5: lint(仅本改动涉及文件)+ 全量测试** + +> 说明:仓库基线有 ~558 个既有 ruff 错误、且未强制 ruff 通过。不要去清历史欠债(范围蔓延)。只要求**本次新增/改动的文件**零 ruff 错误。 + +Run: `ruff check app/core/observe.py app/core/observe_worker.py tests/test_observe.py && python -m pytest -q` +Expected: 上述三个新文件 ruff 无错;测试里 `tests/test_observe.py` 全 PASS,且**全量失败数不超过基线的 4 个**(test_compare_proxy ×2 / test_coupon_proxy ×1 / test_invite ×1,均与本功能无关)。 +额外确认我对既有文件的改动没有引入**新的** ruff 错误:`ruff check app/core/config.py app/main.py`(数量应与基线一致,不因本改动增加)。 + +- [ ] **Step 6: 关观测复跑一次,确认降级** + +把 `.env` 的 `OBSERVE_ENABLED` 改回 `false`,`ruff check .` 不涉及,直接 `pytest -q` +Expected: 全 PASS(验证 observe 关闭时零副作用) + +- [ ] **Step 7: 收尾提交(如有 .env 之外的改动)** + +```bash +git add -A +git commit -m "chore(observe): 端到端验证与收尾" --allow-empty +``` + +> `.env` 不入 git(已 gitignore);本任务只验证,不提交 `.env`。 + +--- + +## Self-Review(写完计划后自查) + +- **Spec 覆盖**:Docker 部署→Task 6/7;事件 schema→Task 3(`record_event` 事件字段);中间件→Task 3;worker→Task 4;配置→Task 1;main 接线→Task 5;查询/仪表盘→Task 6 README;测试→Task 1-5;决策(a)队列满丢→Task 2;(b)失败不重试→Task 4;(c)跳过 /health→Task 3。全覆盖。 +- **占位符**:无 TBD/TODO;每个代码步骤含完整代码。 +- **类型/命名一致**:`get_queue` / `record_event` / `take_dropped` / `RequestMetricsMiddleware` / `start_observe_worker` / `stop_observe_worker` / `observe_configured` / 事件字段名,跨 Task 1-5 与文件结构表一致。 diff --git a/docs/superpowers/specs/2026-07-06-openobserve-api-metrics-design.md b/docs/superpowers/specs/2026-07-06-openobserve-api-metrics-design.md new file mode 100644 index 0000000..bd8f0d0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-openobserve-api-metrics-design.md @@ -0,0 +1,236 @@ +# 接口 QPS + 耗时可观测(OpenObserve)设计 + +- **日期**:2026-07-06 +- **状态**:已评审通过,待写实现计划 +- **范围**:仅 app-server(8770);admin(8771)暂不接入 +- **方案**:A —— 轻量自研 ASGI 中间件 + 后台 worker 批量直采到 OpenObserve + +## 1. 背景与目标 + +app-server 目前除 CORS 外无任何中间件,也无接口级可观测。需要按**每个接口**采集: + +- **QPS**(每秒请求数,可按接口/时间分桶) +- **耗时**(P50/P95/P99 等分位) + +顺带低成本拿到**错误率**(`status >= 500` 占比)。落地目标是:本地 Docker 跑一个 OpenObserve 实例接收数据,服务侧加埋点上报,在 OpenObserve 仪表盘上看各接口 QPS + 耗时。 + +### 非目标(YAGNI) + +- 不做分布式 trace / span 关联(只要接口聚合指标)。 +- 不引入 OpenTelemetry / Prometheus 客户端等重依赖。 +- 不采集请求体 / query / 用户身份等,任何 PII 都不进上报。 +- admin(8771)本期不接(中间件写成可复用,未来一行挂载即可)。 +- 上报失败不做持久化重试 / 落盘补偿(best-effort)。 + +## 2. 方案选型 + +对比过三条路(详见评审记录): + +- **A 轻量自研中间件 + JSON 直采**(选中):零新依赖(`httpx` 已在依赖里),完全贴合本仓库「后台 worker + JSON 事件 + `*_configured` 优雅降级」的既有习惯,恰好满足「每接口 QPS + 耗时 + 错误率」并保留原始事件下钻能力。 +- B OpenTelemetry 自动埋点 + OTLP:行业标准、顺带 trace,但多 5–6 个依赖、概念多、数据量/成本高于需求,与精简代码库风格相悖。 +- C Prometheus 进程内聚合 + remote_write/抓取:数据量最小,但 remote_write 编码复杂或需额外抓取进程,丢失单请求下钻,最不贴合 OpenObserve 的 log-first 强项。 + +**结论:A。** + +## 3. 架构与数据流 + +``` +每个 HTTP 请求 + → RequestMetricsMiddleware(最外层:测总耗时 / 抓路由模板 + 状态码) + → record_event() 非阻塞入队(有界队列,满则丢最旧,绝不阻塞、绝不 OOM) + → observe_worker(后台 asyncio.Task,随 lifespan 启停)批量 drain + → httpx POST {ENDPOINT}/api/{ORG}/{STREAM}/_json → OpenObserve + → 仪表盘 SQL 聚合出 QPS / 分位耗时 / 错误率 +``` + +**核心不变量**: + +1. 请求路径上只做「测时 + 构建一个小 dict + `put_nowait`」,**无任何网络/磁盘 I/O**。 +2. 所有上报 I/O 在后台 worker;worker 捕获全部异常,绝不让埋点影响请求。 +3. 未配置观测(`observe_configured=False`)→ 中间件透传、worker 不启动,整套 no-op。 +4. OpenObserve 不可用 → 队列填满后丢弃事件 + 限流告警,业务零影响。 + +## 4. 组件设计 + +### 4.1 OpenObserve 本地部署 —— `deploy/openobserve/docker-compose.yml`(新增) + +```yaml +services: + openobserve: + image: public.ecr.aws/zinclabs/openobserve:latest + container_name: openobserve + ports: ["5080:5080"] + environment: + ZO_ROOT_USER_EMAIL: "admin@shaguabijia.local" + ZO_ROOT_USER_PASSWORD: "Complexpass#123" + ZO_DATA_DIR: "/data" + volumes: ["./data:/data"] + restart: unless-stopped +``` + +- `docker compose up -d` 启动;Web UI `http://localhost:5080`,用上面邮箱/密码登录。 +- 单容器 = local 模式,数据落 `./data`(已挂卷持久化)。 +- **stream 首次上报自动创建**,无需预建 `app_requests`。 +- 上报鉴权:HTTP Basic auth(`email:password`),本地直接用 root 账号;生产应另建仅具 ingest 权限的用户/服务账号(本期不涉及)。 + +### 4.2 事件 schema(一请求一行 JSON) + +```json +{ + "_timestamp": 1720000000000000, // 微秒(µs)整数,请求完成时刻。OpenObserve 默认时间列 _timestamp 以微秒计 + "service": "app-server", // 取 LOG_SERVICE_NAME / 固定值 + "env": "dev", // settings.APP_ENV + "method": "POST", + "route": "/api/v1/coupon/step", // 路由模板(非实际 path) + "status": 200, + "duration_ms": 42.7 // float 毫秒 +} +``` + +- **只存路由模板**(如 `/c/{code}`、`/media` 静态归一),避免 path 参数把维度打爆。 +- 未匹配路由(404 / 扫描器)归一到常量 `__unmatched__`。 +- 只采 method / route / status / duration —— 无 body、无 query、无 PII。 + +### 4.3 埋点中间件 —— `app/core/observe.py`(新增) + +**纯 ASGI 中间件**(比 `BaseHTTPMiddleware` 开销低;能可靠读到路由与最终状态码;scope 按引用透传,内层 router 的 `scope["route"]` 外层可见)。 + +职责: + +1. 非 `http` 请求、或 `not settings.observe_configured` → 直接透传,不测。 +2. `perf_counter()` 记起点;包一层 `send` 抓 `http.response.start` 的 `status`(默认兜底 500,覆盖下游抛异常未产出 response 的情况)。 +3. `finally` 里算 `duration_ms`,从 `scope` 取路由模板(见下),构建事件,调 `record_event()`。 +4. 跳过路径集合 `_SKIP_PATHS = {"/health"}`(纯噪音)。 + +**路由模板解析(跨 Starlette 版本稳健)**: + +```python +route = scope.get("route") +template = getattr(route, "path", None) +if template is None: # 未匹配 / 老版本未写 scope["route"] + template = "__unmatched__" +``` + +(若实测某 Starlette 版本不写 `scope["route"]`,回退用 `request.app.router.routes` 逐个 `route.matches(scope)==Match.FULL` 找模板;实现时以实际版本为准,优先 `scope["route"]`。) + +**入队(`record_event`)**:模块级 `asyncio.Queue(maxsize=OBSERVE_QUEUE_MAX)`。用 `put_nowait`,`QueueFull` 则丢弃并累加一个 `_dropped` 计数(每累计 N 条限流打一条 WARNING)。**永不 `await put()`、永不阻塞请求**。 + +> 决策(a):队列满 → **丢弃**(不阻塞请求)。 + +### 4.4 上报 worker —— `app/core/observe_worker.py`(新增) + +对齐现有 `heartbeat_monitor_worker.py` / `daily_exchange_worker.py` / `withdraw_reconcile_worker.py` 的 `start_*` / `stop_*` 形态。 + +- `start_observe_worker() -> asyncio.Task | None` + - `not settings.observe_configured` → 返回 `None`(no-op)。 + - 否则建专用 `httpx.AsyncClient`(`base_url=ENDPOINT`,`auth=(USER, PASSWORD)`,`timeout=OBSERVE_TIMEOUT_SEC`),起 `_run_loop` task。 +- `_run_loop()`:循环 + 1. `_collect_batch()`:`await asyncio.wait_for(queue.get(), timeout=FLUSH_INTERVAL)` 拿到首条(超时且空 → 返回空,continue);再 `get_nowait()` 连抽到 `BATCH_MAX` 条或抽空。 + 2. `POST /api/{ORG}/{STREAM}/_json`,body 为事件数组。 + 3. **catch 所有异常**:失败限流打 WARNING,**直接丢弃该批,不重试**。 +- `stop_observe_worker(task)`:best-effort 收尾 flush(短超时)→ `task.cancel()` → `await`(吞 `CancelledError`)→ 关 client。 + +> 决策(b):上报失败 → **直接丢弃,不重试**(best-effort 遥测)。 + +### 4.5 配置 —— `app/core/config.py`(改) + +新增一段 `# ===== 可观测(OpenObserve 接口指标)=====`,默认全关(prod 安全): + +| 配置 | 默认 | 说明 | +|---|---|---| +| `OBSERVE_ENABLED` | `False` | 总开关;默认关,opt-in | +| `OBSERVE_ENDPOINT` | `http://localhost:5080` | OpenObserve base URL | +| `OBSERVE_ORG` | `default` | 组织名 | +| `OBSERVE_STREAM` | `app_requests` | stream 名 | +| `OBSERVE_USER` | `""` | Basic auth 邮箱 | +| `OBSERVE_PASSWORD` | `""` | Basic auth 密码/token | +| `OBSERVE_FLUSH_INTERVAL_SEC` | `5.0` | worker 最长攒批间隔 | +| `OBSERVE_BATCH_MAX` | `200` | 单批最大事件数 | +| `OBSERVE_QUEUE_MAX` | `10000` | 有界队列上限,满则丢 | +| `OBSERVE_TIMEOUT_SEC` | `5.0` | 上报 HTTP 超时 | + +```python +@property +def observe_configured(self) -> bool: + return bool(self.OBSERVE_ENABLED and self.OBSERVE_ENDPOINT + and self.OBSERVE_USER and self.OBSERVE_PASSWORD) +``` + +`.env.example` 同步补一段带注释的 `OBSERVE_*`(沿用该文件重注释风格),`OBSERVE_ENABLED=false`。 + +### 4.6 接线 —— `app/main.py`(改) + +- import `RequestMetricsMiddleware`、`start_observe_worker` / `stop_observe_worker`。 +- `app.add_middleware(RequestMetricsMiddleware)`:放在 CORS `add_middleware` **之后** → 成为最外层,测到含 CORS 的完整耗时。无条件挂载(内部自 no-op)。 +- `lifespan`:启动 `observe_task = start_observe_worker()`;`finally` 里 `await stop_observe_worker(observe_task)`,与现有 worker 并列。 + +### 4.7 OpenObserve 查询 / 仪表盘 —— `deploy/openobserve/README.md`(新增) + +含:compose 启停、登录、stream 自动创建说明、`.env` 接线,以及可直接粘的示例 SQL: + +- **各接口 QPS**(1 分钟分桶): + ```sql + SELECT route, histogram(_timestamp, '1 minute') AS ts, count(*) AS cnt + FROM app_requests GROUP BY route, ts ORDER BY ts + ``` + (面板按 `cnt/60` 展示每秒;或用 OpenObserve 图表的 rate 能力。) +- **各接口 P95 耗时**: + ```sql + SELECT route, approx_percentile_cont(duration_ms, 0.95) AS p95_ms + FROM app_requests GROUP BY route ORDER BY p95_ms DESC + ``` +- **各接口错误率**: + ```sql + SELECT route, + count(*) FILTER (WHERE status >= 500) * 100.0 / count(*) AS err_pct + FROM app_requests GROUP BY route ORDER BY err_pct DESC + ``` + +## 5. 关键设计决策汇总 + +- **(a) 队列满 → 丢弃**(不阻塞请求):遥测让路于业务可用性。 +- **(b) 上报失败 → 不重试**:best-effort;避免 poison batch 堆积与队列无限增长。 +- **(c) 跳过 `/health`**:健康检查是纯噪音,硬编码在 `_SKIP_PATHS`。 +- **只存路由模板 + `__unmatched__`**:防维度爆炸。 +- **默认 OFF、opt-in**:prod 安全默认;开启后仍全异步 + 有界。 +- **纯 ASGI 中间件 + `perf_counter`**:请求路径开销微秒级,无 I/O。 + +## 6. 安全 / 性能保证 + +- 请求路径新增开销 ≈ 一次 `perf_counter` 差 + 一个小 dict + 一次 `put_nowait`(微秒级),无锁竞争的显著热点。 +- 失败隔离:入队丢弃 + worker 全异常捕获;OpenObserve 宕机不影响任何请求。 +- 有界内存:队列 `maxsize` 封顶,最坏丢事件不涨内存。 +- 无 PII:仅 method / route / status / duration。 + +## 7. 测试策略 —— `tests/test_observe.py`(新增) + +沿用仓库约定(`TestClient` + `monkeypatch`,绝不打真网络;`conftest` 在 import 前设 env): + +1. 埋点入队字段正确:模板路由、`status`、`duration_ms > 0`。 +2. 参数化路由 → 取到**模板**而非实际 path。 +3. 未匹配路径(404)→ `route == "__unmatched__"`。 +4. `OBSERVE_ENABLED=false` → 零入队、零 HTTP(现有测试不受影响)。 +5. 队列满 → `record_event` 不抛异常(走丢弃分支)。 +6. worker 批量 POST 的 URL / payload 正确(monkeypatch httpx client / `_post`,不打网络)。 +7. `/health` 被跳过 → 不入队。 + +> `settings` 是 `lru_cache` 单例;需要开启观测的用例通过 monkeypatch `settings` 属性或直接调 `record_event` / 中间件并 patch `observe_configured` 实现,避免全局 env 改动波及他用例。 + +## 8. 文件清单 + +| 文件 | 动作 | +|---|---| +| `deploy/openobserve/docker-compose.yml` | 新增(OpenObserve 容器)| +| `deploy/openobserve/README.md` | 新增(部署步骤 + 查询/仪表盘)| +| `app/core/observe.py` | 新增(中间件 + 有界队列 + `record_event` + 路由解析)| +| `app/core/observe_worker.py` | 新增(后台批量上报 worker)| +| `app/core/config.py` | 改(`OBSERVE_*` + `observe_configured`)| +| `app/main.py` | 改(挂中间件 + lifespan 启停 worker)| +| `.env.example` | 改(新增 `OBSERVE_*` 注释段)| +| `tests/test_observe.py` | 新增 | + +## 9. 未来工作(本期不做) + +- admin(8771)接入同一套中间件(`service` 字段区分)。 +- 生产部署 OpenObserve(持久化、独立 ingest 账号、资源规格、鉴权收紧)。 +- 上报字段扩展(如按 user/设备维度、上游 pricebot 透传耗时拆分)。 diff --git a/tests/test_observe.py b/tests/test_observe.py new file mode 100644 index 0000000..3ca966b --- /dev/null +++ b/tests/test_observe.py @@ -0,0 +1,234 @@ +"""接口指标可观测(observe)单测:配置门槛 / 队列 / 中间件 / worker。 + +沿用仓库约定:TestClient + monkeypatch,绝不打真网络。observe 默认关(conftest 未设 +OBSERVE_*),需要开启的用例用 monkeypatch 改 settings 单例属性。 +""" +from __future__ import annotations + +import asyncio + +import httpx +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.core import observe, observe_worker +from app.core.config import settings + + +def test_observe_configured_requires_switch_and_creds(monkeypatch): + # 开关开 + endpoint(默认 localhost)+ user + password 齐全 → True + monkeypatch.setattr(settings, "OBSERVE_ENABLED", True) + monkeypatch.setattr(settings, "OBSERVE_USER", "u") + monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "p") + assert settings.observe_configured is True + + # 缺密码 → False + monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "") + assert settings.observe_configured is False + + # 缺用户名 → False + monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "p") + monkeypatch.setattr(settings, "OBSERVE_USER", "") + assert settings.observe_configured is False + + # 开关关 → False(即便凭证齐全) + monkeypatch.setattr(settings, "OBSERVE_USER", "u") + monkeypatch.setattr(settings, "OBSERVE_ENABLED", False) + assert settings.observe_configured is False + + +def test_record_event_enqueues(monkeypatch): + q = asyncio.Queue(maxsize=10) + monkeypatch.setattr(observe, "_queue", q) + observe.record_event({"route": "/x"}) + assert q.get_nowait() == {"route": "/x"} + + +def test_record_event_drops_when_full(monkeypatch): + q = asyncio.Queue(maxsize=1) + monkeypatch.setattr(observe, "_queue", q) + monkeypatch.setattr(observe, "_dropped", 0) + observe.record_event({"n": 1}) # 占满 + observe.record_event({"n": 2}) # 满 → 丢弃当前,不抛异常 + assert observe.take_dropped() == 1 + assert observe.take_dropped() == 0 # 取出后清零 + assert q.get_nowait() == {"n": 1} # 保留的是先到的 + + +def _make_probe_app() -> FastAPI: + """独立最小 app:只挂中间件 + 两个无鉴权路由,不碰真业务 DB/auth。""" + app = FastAPI() + app.add_middleware(observe.RequestMetricsMiddleware) + + @app.get("/things/{tid}") + def get_thing(tid: str): + return {"tid": tid} + + @app.get("/health") + def health(): + return {"ok": True} + + return app + + +@pytest.fixture +def observe_on(monkeypatch): + """开启观测 + 换一个干净小队列,返回该队列供断言。""" + monkeypatch.setattr(settings, "OBSERVE_ENABLED", True) + monkeypatch.setattr(settings, "OBSERVE_USER", "u") + monkeypatch.setattr(settings, "OBSERVE_PASSWORD", "p") + q = asyncio.Queue(maxsize=100) + monkeypatch.setattr(observe, "_queue", q) + return q + + +def test_middleware_records_route_template(observe_on): + client = TestClient(_make_probe_app()) + r = client.get("/things/42") + assert r.status_code == 200 + evt = observe_on.get_nowait() + assert evt["route"] == "/things/{tid}" # 模板,不是 /things/42 + assert evt["method"] == "GET" + assert evt["status"] == 200 + assert evt["duration_ms"] >= 0 + assert evt["service"] and "env" in evt and isinstance(evt["_timestamp"], int) + + +def test_middleware_skips_health(observe_on): + client = TestClient(_make_probe_app()) + client.get("/health") + assert observe_on.empty() + + +def test_middleware_unmatched_route_is_normalized(observe_on): + client = TestClient(_make_probe_app()) + r = client.get("/definitely-not-a-route") + assert r.status_code == 404 + evt = observe_on.get_nowait() + assert evt["route"] == "__unmatched__" + assert evt["status"] == 404 + + +def test_middleware_noop_when_disabled(monkeypatch): + monkeypatch.setattr(settings, "OBSERVE_ENABLED", False) + q = asyncio.Queue(maxsize=100) + monkeypatch.setattr(observe, "_queue", q) + client = TestClient(_make_probe_app()) + client.get("/things/1") + assert q.empty() # 未配置观测 → 零入队 + + +async def test_collect_batch_drains_up_to_batch_max(monkeypatch): + q = asyncio.Queue(maxsize=100) + monkeypatch.setattr(observe, "_queue", q) + monkeypatch.setattr(settings, "OBSERVE_FLUSH_INTERVAL_SEC", 0.1) + monkeypatch.setattr(settings, "OBSERVE_BATCH_MAX", 200) + for i in range(3): + q.put_nowait({"n": i}) + batch = await observe_worker._collect_batch() + assert [e["n"] for e in batch] == [0, 1, 2] + + +async def test_collect_batch_timeout_returns_empty(monkeypatch): + q = asyncio.Queue(maxsize=100) + monkeypatch.setattr(observe, "_queue", q) + monkeypatch.setattr(settings, "OBSERVE_FLUSH_INTERVAL_SEC", 0.05) + batch = await observe_worker._collect_batch() + assert batch == [] + + +async def test_post_batch_hits_json_ingest_url(monkeypatch): + monkeypatch.setattr(settings, "OBSERVE_ORG", "default") + monkeypatch.setattr(settings, "OBSERVE_STREAM", "app_requests") + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["json"] = request.content + return httpx.Response(200, json={"code": 200}) + + client = httpx.AsyncClient( + base_url="http://oo", transport=httpx.MockTransport(handler) + ) + await observe_worker._post_batch(client, [{"route": "/x", "status": 200}]) + await client.aclose() + assert captured["url"] == "http://oo/api/default/app_requests/_json" + assert b"/x" in captured["json"] + + +def test_start_observe_worker_noop_when_not_configured(monkeypatch): + monkeypatch.setattr(settings, "OBSERVE_ENABLED", False) + assert observe_worker.start_observe_worker() is None + + +async def test_run_loop_survives_post_failure(monkeypatch): + """_post_batch 抛异常时,loop 不崩溃、继续处理后续批次(best-effort 契约)。""" + q = asyncio.Queue(maxsize=100) + monkeypatch.setattr(observe, "_queue", q) + monkeypatch.setattr(settings, "OBSERVE_FLUSH_INTERVAL_SEC", 0.02) + monkeypatch.setattr(settings, "OBSERVE_BATCH_MAX", 200) + seen: list[list[int]] = [] + + async def boom(client, batch): + seen.append([e["n"] for e in batch]) + raise RuntimeError("boom") + + monkeypatch.setattr(observe_worker, "_post_batch", boom) + + q.put_nowait({"n": 1}) + task = asyncio.create_task(observe_worker._run_loop(None)) + try: + for _ in range(50): # 轮询直到第 1 批被处理(失败),最多等 0.5s + await asyncio.sleep(0.01) + if seen: + break + q.put_nowait({"n": 2}) + for _ in range(50): # 第 2 批被处理 → 证明失败后 loop 仍存活 + await asyncio.sleep(0.01) + if len(seen) >= 2: + break + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + assert seen == [[1], [2]] + + +async def test_stop_flushes_remaining_and_closes_client(monkeypatch): + """stop:cancel 后把剩余事件 best-effort 发出最后一批,并关闭 + 置空 client。""" + q = asyncio.Queue(maxsize=100) + monkeypatch.setattr(observe, "_queue", q) + monkeypatch.setattr(settings, "OBSERVE_ORG", "default") + monkeypatch.setattr(settings, "OBSERVE_STREAM", "app_requests") + monkeypatch.setattr(settings, "OBSERVE_BATCH_MAX", 200) + q.put_nowait({"n": 1}) + q.put_nowait({"n": 2}) + posted: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + posted["body"] = request.content + return httpx.Response(200, json={"code": 200}) + + client = httpx.AsyncClient( + base_url="http://oo", transport=httpx.MockTransport(handler) + ) + monkeypatch.setattr(observe_worker, "_client", client) + + async def _noop() -> None: + return None + + task = asyncio.create_task(_noop()) + await observe_worker.stop_observe_worker(task) + + assert b'"n"' in posted["body"] # 关停时把剩余事件发了出去 + assert observe_worker._client is None # client 已关闭并置空 + + +def test_app_has_metrics_middleware(): + from app.main import app + + names = [m.cls.__name__ for m in app.user_middleware] + assert "RequestMetricsMiddleware" in names From ed26935b148be927505fce3f16c031e44d9fa2cd Mon Sep 17 00:00:00 2001 From: linkeyu Date: Tue, 21 Jul 2026 10:11:39 +0800 Subject: [PATCH 02/42] =?UTF-8?q?feat(admin):=20=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=A4=A7=E7=9B=98=E6=AF=94=E4=BB=B7=E6=8C=87=E6=A0=87=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=90=8E=E7=AB=AF=E8=81=9A=E5=90=88=20(#146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 修改内容 - 数据大盘比价指标改由后端按日期区间聚合 - 增加完成数、中途退出数、成功率、中位数、P95 和 TOKEN 总成本 - 成功率分母排除中途退出,耗时仅统计 success/failed - 增加后端聚合口径测试 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/146 Co-authored-by: linkeyu Co-committed-by: linkeyu --- app/admin/repositories/stats.py | 111 +++++++++++++++++++++++++++++--- app/admin/schemas/dashboard.py | 7 +- tests/test_admin_read.py | 46 +++++++++++++ 3 files changed, 154 insertions(+), 10 deletions(-) diff --git a/app/admin/repositories/stats.py b/app/admin/repositories/stats.py index 260c159..fbc3c7c 100644 --- a/app/admin/repositories/stats.py +++ b/app/admin/repositories/stats.py @@ -7,7 +7,7 @@ from __future__ import annotations from collections import Counter from datetime import date, datetime, time, timedelta, timezone -from decimal import Decimal, InvalidOperation +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation from sqlalchemy import case, func, select from sqlalchemy.orm import Session @@ -109,6 +109,23 @@ def _date_range(date_from: date, date_to: date) -> list[date]: return [date_from + timedelta(days=i) for i in range(days + 1)] +def _duration_percentile(sorted_values: list[int], q: float) -> int | None: + """Linear-interpolated percentile with the same half-up rounding as Math.round.""" + if not sorted_values: + return None + if len(sorted_values) == 1: + return sorted_values[0] + index = (len(sorted_values) - 1) * q + lower = int(index) + upper = min(lower + 1, len(sorted_values) - 1) + fraction = Decimal(str(index - lower)) + value = ( + Decimal(sorted_values[lower]) * (Decimal(1) - fraction) + + Decimal(sorted_values[upper]) * fraction + ) + return int(value.quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + + def _id_set(db: Session, stmt) -> set[int]: return {int(v) for v in db.execute(stmt).scalars().all() if v is not None} @@ -242,16 +259,46 @@ def dashboard_overview( ComparisonRecord.created_at >= start_local, ComparisonRecord.created_at < end_local, ) - period_comparison_total = _count(ComparisonRecord, *period_comparison_conds) - period_comparison_success = _count( - ComparisonRecord, - *period_comparison_conds, - ComparisonRecord.status == "success", + period_comparison_stats = db.execute( + select( + func.count(ComparisonRecord.id), + func.coalesce( + func.sum( + case( + (ComparisonRecord.status.in_(("success", "failed")), 1), + else_=0, + ) + ), + 0, + ), + func.coalesce( + func.sum( + case((ComparisonRecord.status == "cancelled", 1), else_=0) + ), + 0, + ), + func.coalesce( + func.sum(case((ComparisonRecord.status == "success", 1), else_=0)), + 0, + ), + func.coalesce(func.sum(ComparisonRecord.llm_cost_yuan), 0.0), + ).where(*period_comparison_conds) + ).one() + period_comparison_total = int(period_comparison_stats[0]) + period_comparison_completed = int(period_comparison_stats[1]) + period_comparison_cancelled = int(period_comparison_stats[2]) + period_comparison_success = int(period_comparison_stats[3]) + period_comparison_token_cost_yuan = float(period_comparison_stats[4]) + period_comparison_success_denominator = ( + period_comparison_total - period_comparison_cancelled ) period_comparison_success_rate = ( - round(period_comparison_success / period_comparison_total, 4) - if period_comparison_total - else 0.0 + round( + period_comparison_success / period_comparison_success_denominator, + 4, + ) + if period_comparison_success_denominator > 0 + else None ) period_saved_positive_count = _count( ComparisonRecord, @@ -282,6 +329,47 @@ def dashboard_overview( if period_avg_duration_ms is not None else None ) + completed_duration_conds = ( + *period_comparison_conds, + ComparisonRecord.status.in_(("success", "failed")), + ComparisonRecord.total_ms.is_not(None), + ) + if db.bind is not None and db.bind.dialect.name == "postgresql": + period_median_duration_ms, period_p95_duration_ms = db.execute( + select( + func.percentile_cont(0.5).within_group(ComparisonRecord.total_ms), + func.percentile_cont(0.95).within_group(ComparisonRecord.total_ms), + ).where(*completed_duration_conds) + ).one() + period_median_duration_ms = ( + int( + Decimal(str(period_median_duration_ms)).quantize( + Decimal("1"), rounding=ROUND_HALF_UP + ) + ) + if period_median_duration_ms is not None + else None + ) + period_p95_duration_ms = ( + int( + Decimal(str(period_p95_duration_ms)).quantize( + Decimal("1"), rounding=ROUND_HALF_UP + ) + ) + if period_p95_duration_ms is not None + else None + ) + else: + # SQLite 测试环境没有 percentile_cont;仅回退读取耗时单列,不加载完整记录。 + completed_durations = list( + db.execute( + select(ComparisonRecord.total_ms) + .where(*completed_duration_conds) + .order_by(ComparisonRecord.total_ms) + ).scalars() + ) + period_median_duration_ms = _duration_percentile(completed_durations, 0.5) + period_p95_duration_ms = _duration_percentile(completed_durations, 0.95) ordered_exists = ( select(SavingsRecord.id) @@ -622,11 +710,16 @@ def dashboard_overview( }, "comparison": { "total": period_comparison_total, + "completed": period_comparison_completed, + "cancelled": period_comparison_cancelled, "success": period_comparison_success, "success_rate": period_comparison_success_rate, "ordered": period_ordered_count, "average_duration_ms": period_avg_duration_ms, + "median_duration_ms": period_median_duration_ms, + "p95_duration_ms": period_p95_duration_ms, "average_saved_cents": period_avg_saved_cents, + "token_cost_total_yuan": period_comparison_token_cost_yuan, }, "coupon": { "started": coupon_started, diff --git a/app/admin/schemas/dashboard.py b/app/admin/schemas/dashboard.py index b3e008a..bb8a281 100644 --- a/app/admin/schemas/dashboard.py +++ b/app/admin/schemas/dashboard.py @@ -53,11 +53,16 @@ class DashboardPeriodUsers(BaseModel): class DashboardPeriodComparison(BaseModel): total: int + completed: int + cancelled: int success: int - success_rate: float + success_rate: float | None = None ordered: int average_duration_ms: int | None = None + median_duration_ms: int | None = None + p95_duration_ms: int | None = None average_saved_cents: int | None = None + token_cost_total_yuan: float = 0.0 class DashboardPeriodCoupon(BaseModel): diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index 5f5313f..75145e4 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -1,12 +1,15 @@ """Admin M2 读接口测试:大盘聚合 + 用户/流水/提现/反馈列表 + 鉴权拦截。""" from __future__ import annotations +from datetime import datetime + import pytest from fastapi.testclient import TestClient from app.admin.main import admin_app from app.admin.repositories import admin_user as admin_repo from app.db.session import SessionLocal +from app.models.comparison import ComparisonRecord from app.models.feedback import Feedback from app.models.wallet import CashTransaction, WithdrawOrder from app.repositories import user as user_repo @@ -69,6 +72,49 @@ def test_dashboard_overview(admin_client: TestClient, admin_token: str) -> None: assert "jd_order_count" in data["cps"] +def test_dashboard_period_comparison_is_aggregated_by_backend( + admin_client: TestClient, admin_token: str +) -> None: + created_at = datetime(2037, 1, 15, 12) + rows = [ + ("dashboard-aggregate-success", "success", 101, 0.1), + ("dashboard-aggregate-failed", "failed", 200, 0.2), + ("dashboard-aggregate-cancelled", "cancelled", 300, 0.3), + ("dashboard-aggregate-running", "running", 400, 0.4), + ] + db = SessionLocal() + try: + for trace_id, status, total_ms, llm_cost_yuan in rows: + db.add( + ComparisonRecord( + trace_id=trace_id, + status=status, + total_ms=total_ms, + llm_cost_yuan=llm_cost_yuan, + created_at=created_at, + ) + ) + db.commit() + finally: + db.close() + + response = admin_client.get( + "/admin/api/stats/overview", + params={"date_from": "2037-01-15", "date_to": "2037-01-15"}, + headers=_auth(admin_token), + ) + assert response.status_code == 200, response.text + comparison = response.json()["period"]["comparison"] + assert comparison["total"] == 4 + assert comparison["completed"] == 2 + assert comparison["cancelled"] == 1 + assert comparison["success"] == 1 + assert comparison["success_rate"] == 0.3333 + assert comparison["median_duration_ms"] == 151 + assert comparison["p95_duration_ms"] == 195 + assert comparison["token_cost_total_yuan"] == pytest.approx(1.0) + + def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> None: uid = _seed_user_with_data("13800000002") r = admin_client.get("/admin/api/users", headers=_auth(admin_token)) From c53ce896f7e297f01cbe83b6c36f52badbbb02ea Mon Sep 17 00:00:00 2001 From: zuochenyong Date: Tue, 21 Jul 2026 10:43:58 +0800 Subject: [PATCH 03/42] =?UTF-8?q?feat(huawei-review):=20=E5=8D=8E=E4=B8=BA?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E5=BC=80=E5=85=B3(admin=20=E5=8F=AF=E5=88=87?= =?UTF-8?q?=20+=20=E5=AE=A2=E6=88=B7=E7=AB=AF=E4=B8=8B=E5=8F=91=20+=20?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1)=20(#147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 华为应用市场审核要求新手引导的「快速设置」权限步必须可被用户关闭,平时又要 保住权限开启率,故做成后台可切的两态开关,送审期间切开、过审后收回。 - app_config 新增 huawei_review 行(default / review),空库与脏值一律回退 default = 上线至今的现状,宁可不给退出按钮也不误放开 - admin: GET/PATCH /admin/api/huawei-review,权限 operator/tech,切换写审计 - 客户端: GET /api/v1/platform/huawei-review 不鉴权(引导页在登录前就展示), 下发 onboarding_closable;机型 gate 由客户端做,故此处不判 ROM Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: 左辰勇 Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/147 Co-authored-by: zuochenyong Co-committed-by: zuochenyong --- app/admin/main.py | 2 + app/admin/permissions.py | 6 +- app/admin/routers/huawei_review.py | 58 +++++++++ app/admin/schemas/huawei_review.py | 19 +++ app/api/v1/platform.py | 10 ++ app/repositories/app_config.py | 44 +++++++ app/schemas/platform.py | 11 ++ docs/api/README.md | 2 + docs/api/platform/platform-huawei-review.md | 36 ++++++ docs/database/app_config.md | 9 ++ tests/test_admin_roles.py | 3 +- tests/test_huawei_review.py | 130 ++++++++++++++++++++ 12 files changed, 327 insertions(+), 3 deletions(-) create mode 100644 app/admin/routers/huawei_review.py create mode 100644 app/admin/schemas/huawei_review.py create mode 100644 docs/api/platform/platform-huawei-review.md create mode 100644 tests/test_huawei_review.py diff --git a/app/admin/main.py b/app/admin/main.py index 7345803..c869487 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -30,6 +30,7 @@ from app.admin.routers.analytics_health import router as analytics_health_router from app.admin.routers.event_logs import router as event_logs_router from app.admin.routers.feedback import router as feedback_router from app.admin.routers.feedback_qr import router as feedback_qr_router +from app.admin.routers.huawei_review import router as huawei_review_router from app.admin.routers.onboarding import router as onboarding_router from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router from app.admin.routers.price_report import router as price_report_router @@ -109,4 +110,5 @@ admin_app.include_router(cps_router) admin_app.include_router(coupon_data_router) admin_app.include_router(ad_audit_router) admin_app.include_router(ad_config_router) +admin_app.include_router(huawei_review_router) admin_app.include_router(ad_revenue_router) diff --git a/app/admin/permissions.py b/app/admin/permissions.py index ca0ee33..c0b4829 100644 --- a/app/admin/permissions.py +++ b/app/admin/permissions.py @@ -31,6 +31,7 @@ PERMISSION_CATALOG: list[dict] = [ {"group": "数据配置", "pages": [ {"key": "config", "label": "系统配置"}, {"key": "ad-revenue", "label": "广告配置"}, + {"key": "huawei-review", "label": "华为审核开关"}, {"key": "users", "label": "用户管理"}, ]}, {"group": "其他", "pages": [ @@ -51,13 +52,14 @@ BUILTIN_ROLES: list[dict] = [ {"name": SUPER_ADMIN_ROLE, "label": "管理员", "pages": []}, {"name": "operator", "label": "运营", "pages": [ "dashboard", "coupon-data", "ad-revenue-report", "comparison-records", - "cps", "device-liveness", "price-reports", "feedbacks", + "cps", "device-liveness", "price-reports", "feedbacks", "huawei-review", ]}, {"name": "finance", "label": "财务", "pages": [ "dashboard", "ad-revenue-report", "cps", "withdraws", ]}, {"name": "tech", "label": "技术", "pages": [ - "dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs", + "dashboard", "device-liveness", "config", "ad-revenue", "huawei-review", + "event-logs", "audit-logs", ]}, ] diff --git a/app/admin/routers/huawei_review.py b/app/admin/routers/huawei_review.py new file mode 100644 index 0000000..b23fb18 --- /dev/null +++ b/app/admin/routers/huawei_review.py @@ -0,0 +1,58 @@ +"""admin 华为审核开关:控制新手引导页(快速设置)在华为 ROM 客户端能否被用户关闭。 + +存在 app_config 表的 huawei_review dict(见 repositories/app_config.get_huawei_review/set_huawei_review)。 +客户端经 /api/v1/platform/huawei-review 拉取(且只有华为 ROM 机型会去拉)。权限 operator/tech + 审计。 +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.orm import Session + +from app.admin.audit import write_audit +from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role +from app.admin.schemas.huawei_review import HuaweiReviewOut, HuaweiReviewUpdate +from app.models.admin import AdminUser +from app.models.app_config import AppConfig +from app.repositories import app_config + +router = APIRouter( + prefix="/admin/api/huawei-review", + tags=["admin-huawei-review"], + dependencies=[Depends(get_current_admin)], +) + + +def _out(db: Session) -> HuaweiReviewOut: + row = db.get(AppConfig, app_config.HUAWEI_REVIEW_KEY) + return HuaweiReviewOut( + mode=app_config.get_huawei_review(db)["mode"], + updated_at=row.updated_at.isoformat() if row is not None else None, + ) + + +@router.get("", response_model=HuaweiReviewOut, summary="华为审核开关当前状态") +def get_huawei_review(db: AdminDb) -> HuaweiReviewOut: + return _out(db) + + +@router.patch("", response_model=HuaweiReviewOut, summary="切换华为审核开关(带审计)") +def update_huawei_review( + body: HuaweiReviewUpdate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator", "tech"))], + db: AdminDb, +) -> HuaweiReviewOut: + before = app_config.get_huawei_review(db)["mode"] + try: + app_config.set_huawei_review(db, body.mode, admin_id=admin.id, commit=False) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + write_audit( + db, admin, action="huawei_review.set", target_type="huawei_review", + target_id=app_config.HUAWEI_REVIEW_KEY, + detail={"before": before, "after": body.mode}, ip=get_client_ip(request), commit=False, + ) + db.commit() + return _out(db) diff --git a/app/admin/schemas/huawei_review.py b/app/admin/schemas/huawei_review.py new file mode 100644 index 0000000..198a321 --- /dev/null +++ b/app/admin/schemas/huawei_review.py @@ -0,0 +1,19 @@ +"""admin 华为审核开关 schemas(两态:default / review)。""" +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel + + +class HuaweiReviewOut(BaseModel): + """当前开关状态。updated_at 给后台展示「谁什么时候切的」提供时间锚点。""" + + mode: Literal["default", "review"] + updated_at: str | None = None # ISO 字符串;从未切过为 None + + +class HuaweiReviewUpdate(BaseModel): + """切换开关。整值覆盖,不做部分更新(就一个字段)。""" + + mode: Literal["default", "review"] diff --git a/app/api/v1/platform.py b/app/api/v1/platform.py index c7a5873..7ee5be4 100644 --- a/app/api/v1/platform.py +++ b/app/api/v1/platform.py @@ -20,6 +20,7 @@ from app.schemas.platform import ( AdConfigPublicOut, AppFlagsOut, AppVersionOut, + HuaweiReviewOut, PlatformStatsOut, SavingsFeedItem, SavingsFeedOut, @@ -72,6 +73,15 @@ def ad_config(db: DbSession) -> AdConfigPublicOut: ) +@router.get("/huawei-review", response_model=HuaweiReviewOut, summary="华为审核开关(不鉴权)") +def huawei_review(db: DbSession) -> HuaweiReviewOut: + """客户端进新手引导前拉一次,决定「快速设置」权限步左上角要不要给退出按钮。 + 不鉴权:引导页在登录之前就展示,此时必然没有 token。空库回退 default(=不给退出按钮,维持现状)。 + 只有华为 ROM 客户端会来拉(荣耀 MagicOS 不拉),故这里不做机型判断,由客户端自己 gate。""" + mode = app_config.get_huawei_review(db)["mode"] + return HuaweiReviewOut(mode=mode, onboarding_closable=(mode == "review")) + + @router.get("/app-version", response_model=AppVersionOut, summary="最新 App 版本(OTA 检查更新,不鉴权)") def app_version(db: DbSession) -> AppVersionOut: """客户端启动 / 手动检查更新时拉取。不鉴权:版本信息非敏感,且检查更新可能在登录前。 diff --git a/app/repositories/app_config.py b/app/repositories/app_config.py index 2cca9d0..3454ac9 100644 --- a/app/repositories/app_config.py +++ b/app/repositories/app_config.py @@ -142,3 +142,47 @@ def set_ad_config(db: Session, data: dict, *, admin_id: int, commit: bool = True else: db.flush() return row + + +# ── 华为审核开关(admin 可切,仅华为 ROM 客户端拉)──────────────────────────────── +# 同 ad_config:复用 AppConfig 表但不进 CONFIG_DEFS——它由「华为审核开关」专用页管理, +# 有自己的两态语义,混进通用系统配置页只会显示成一个没头没尾的 on/off。 +# default → 新手引导页(快速设置)强制展示,用户无法关闭(= 上线至今的现状) +# review → 快速设置的权限步左上角出现退出按钮,可直接进首页(过华为应用市场审核用) +# 空库 = default = 行为完全不变。客户端经 /api/v1/platform/huawei-review 拉取。 +HUAWEI_REVIEW_KEY = "huawei_review" +HUAWEI_REVIEW_MODES = ("default", "review") +_HUAWEI_REVIEW_DEFAULTS: dict[str, Any] = { + "mode": "default", +} + + +def get_huawei_review(db: Session) -> dict: + """读华为审核开关。DB 无 / 脏值一律回退 default(宁可不给退出按钮,也不误放开)。""" + row = db.get(AppConfig, HUAWEI_REVIEW_KEY) + merged = dict(_HUAWEI_REVIEW_DEFAULTS) + if row is not None and isinstance(row.value, dict): + merged.update(row.value) + if merged.get("mode") not in HUAWEI_REVIEW_MODES: + merged["mode"] = _HUAWEI_REVIEW_DEFAULTS["mode"] + return merged + + +def set_huawei_review(db: Session, mode: str, *, admin_id: int, commit: bool = True) -> AppConfig: + """admin 切换华为审核开关。非法 mode 抛 ValueError(路由转 400)。""" + if mode not in HUAWEI_REVIEW_MODES: + raise ValueError(f"invalid mode: {mode} (expected one of {list(HUAWEI_REVIEW_MODES)})") + row = db.get(AppConfig, HUAWEI_REVIEW_KEY) + value = {"mode": mode} + if row is None: + row = AppConfig(key=HUAWEI_REVIEW_KEY, value=value, updated_by_admin_id=admin_id) + db.add(row) + else: + row.value = value + row.updated_by_admin_id = admin_id + if commit: + db.commit() + db.refresh(row) + else: + db.flush() + return row diff --git a/app/schemas/platform.py b/app/schemas/platform.py index 13ca209..e56b035 100644 --- a/app/schemas/platform.py +++ b/app/schemas/platform.py @@ -46,6 +46,17 @@ class AdConfigPublicOut(BaseModel): withdrawal_ad_enabled: bool # 提现激励视频开关(关=客户端直接放行提现) +class HuaweiReviewOut(BaseModel): + """华为审核开关下发给客户端(不鉴权,引导页在登录前就要展示)。 + + 客户端只需读 onboarding_closable 决策;mode 仅供排查问题时看后台切成了哪态。 + 只有华为 ROM(HarmonyOS/EMUI,不含荣耀 MagicOS)的客户端才会来拉这个端点。 + """ + + mode: str = "default" # default | review + onboarding_closable: bool = False # 快速设置权限步是否允许用户退出(mode == review) + + class AppVersionOut(BaseModel): """最新 App 版本信息(OTA 检查更新,不鉴权)。 diff --git a/docs/api/README.md b/docs/api/README.md index 4ade4e6..7d29d6f 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -115,6 +115,7 @@ | 40a | `GET /api/v1/platform/flags` | 无 | [详情](./platform/platform-flags.md)(客户端运营 feature flag,比价/领券期广告开关等,拉取后缓存) | | 40b | `GET /api/v1/platform/ad-config` | 无 | [详情](./platform/platform-ad-config.md)(客户端拉广告配置:穿山甲 app_id+各位ID+各场景开关;不含验签密钥) | | 40c | `GET /api/v1/platform/app-version` | 无 | [详情](./platform/platform-app-version.md)(最新 App 版本,OTA 检查更新;与本机 versionCode 比) | +| 40d | `GET /api/v1/platform/huawei-review` | 无 | [详情](./platform/platform-huawei-review.md)(华为审核开关:快速设置权限步能否被用户关闭;仅华为 ROM 客户端拉) | | **微信支付回调**(前缀 `/api/v1/wxpay`) ||| | W1 | `POST /api/v1/wxpay/transfer-auth-notify` | 无 | 免确认收款授权结果通知(一期 stub:仅应答 200 不验签不改账,授权状态靠主动查询兜底)(无单独文档) | | **CPS 群发短链落地**(**无前缀**,挂域名根;公网不鉴权) ||| @@ -153,6 +154,7 @@ | A12 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin/ad/admin-ad-revenue-report.md)(广告收益报表:分页/场景/`app_env` 筛 + **DAU/ARPU** #120;真实收益侧接穿山甲日表 #92) | | A13 | `GET / PATCH /admin/api/ad-config` | operator/finance | 广告配置(穿山甲 ID/验签密钥/各场景开关;C 端只读版见 40b)(无单独文档,见 `app/admin/routers/ad_config.py`) | | A14 | `GET /admin/api/config`、`PATCH /config/{key}` | operator/finance | 运营可配置项([app_config](../database/app_config.md):奖励常量/提现地板价等;#117 修系统配置下发)(无单独文档,见 `app/admin/routers/config.py`) | +| A16 | `GET / PATCH /admin/api/huawei-review` | operator/tech | 华为审核开关(快速设置权限步能否被用户关闭,落 `app_config.huawei_review`;C 端只读版见 40d)(无单独文档,见 `app/admin/routers/huawei_review.py`) | | **A·管理员与角色**(super_admin):`GET`/`POST` `/admins`、`PATCH`/`DELETE` `/admins/{id}`(#126 删除+`pages_override`)、`GET`/`POST` `/roles`、`GET /roles/catalog`、`PATCH`/`DELETE` `/roles/{id}`(#117/#126 自定义角色) ||| [列表](./admin/admins/admin-admins-list.md) / [建](./admin/admins/admin-admin-create.md) / [改+删](./admin/admins/admin-admin-update.md) / [角色](./admin/admin-roles.md) | | A15 | `GET /admin/api/audit-logs` | admin | [详情](./admin/admin-audit-logs.md) | | **A·CPS 运营台**:群/活动 CRUD、`POST /referral-links`、`POST /orders/reconcile`(美团+京东 #90)、`GET /orders`、`/stats`、群 `timeseries`/`daily`/`wx-users`/`day-users`(#79) ||| [详情](./admin/admin-cps.md) | diff --git a/docs/api/platform/platform-huawei-review.md b/docs/api/platform/platform-huawei-review.md new file mode 100644 index 0000000..b2fa0eb --- /dev/null +++ b/docs/api/platform/platform-huawei-review.md @@ -0,0 +1,36 @@ +# GET /api/v1/platform/huawei-review — 华为审核开关 + +> 所属:Platform 组(前缀 `/api/v1/platform`) | 鉴权:无 | [← 返回 API 索引](../README.md) + +华为应用市场审核要求:新手引导的**「快速设置」权限步必须可被用户关闭**(引导视频页不在要求内)。本端点把运营后台配的开关下发给客户端,决定该步左上角是否出现退出按钮。 + +**不鉴权**:引导页在登录之前就展示,此时客户端必然没有 token。 + +值来自 `app_config` 表的 `huawei_review` 行(admin 页 `GET / PATCH /admin/api/huawei-review` 可改),空库回退 `default`。 + +## 入参 + +无。 + +## 出参 + +响应 `200`:`HuaweiReviewOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `mode` | string | `default`(强制展示,不可关闭 = 上线至今的现状)/ `review`(可关闭,过审用)。仅供排查时看后台切成了哪态 | +| `onboarding_closable` | bool | 快速设置权限步是否允许用户退出(= `mode == "review"`)。**客户端只读这一个字段决策** | + +Mock 出参: +```json +{ + "mode": "review", + "onboarding_closable": true +} +``` + +## 说明 +- **只有华为 ROM 客户端会来拉**(HarmonyOS / EMUI;荣耀 MagicOS 不拉)。机型判断在客户端做(`OemDetector`),服务端不看 UA,也就不用维护机型名单。 +- 客户端在进新手引导前拉一次并本地缓存;请求失败/超时用上次缓存值,从未拉到过则按 `onboarding_closable=false`(宁可不给退出按钮,也不误放开)。 +- 脏值兜底:DB 里 `mode` 不在枚举内时服务端一律回退 `default`。 +- 切回 `default` 即可一键收回退出按钮(审核通过后无需发版)。 diff --git a/docs/database/app_config.md b/docs/database/app_config.md index 95e7aac..1f3a32d 100644 --- a/docs/database/app_config.md +++ b/docs/database/app_config.md @@ -29,3 +29,12 @@ ## 注意 - 不缓存:配置读频率低(每次福利操作读一次,主键查极快),admin 改了立即生效、跨进程一致(多 worker 也对)。 - 新增可配项 = 在 `CONFIG_DEFS` 加一条 + 业务处改用 `app_config.get_value(db, key)` 读;不需要建迁移(行是动态插的,表结构不变)。 + +## 专用 key(借表不进 CONFIG_DEFS) +有自己的语义与专用管理页的配置,复用本表但**不注册进 `CONFIG_DEFS`**——混进通用「系统配置」页只会显示成一个没头没尾的 on/off。它们各有一对 `get_*` / `set_*` 函数(仍在 `repositories/app_config.py`),`value` 存 dict,空行回退各自的模块内默认值。 + +| key | 管理页 / admin 端点 | C 端读取 | 说明 | +|---|---|---|---| +| `ad_config` | `GET/PATCH /admin/api/ad-config` | `GET /api/v1/platform/ad-config`(去密钥) | 穿山甲 app_id / 各代码位 / 各场景开关 | +| `app_version` | 内部写入(`X-Internal-Secret`) | `GET /api/v1/platform/app-version` | OTA 最新版本信息 | +| `huawei_review` | `GET/PATCH /admin/api/huawei-review` | `GET /api/v1/platform/huawei-review` | 华为审核开关:`{"mode": "default"|"review"}`,决定新手引导「快速设置」权限步能否被用户关闭。脏值/空行一律回退 `default`(不给退出按钮) | diff --git a/tests/test_admin_roles.py b/tests/test_admin_roles.py index 85ab010..fc1603f 100644 --- a/tests/test_admin_roles.py +++ b/tests/test_admin_roles.py @@ -123,7 +123,8 @@ def test_builtin_roles_labels_and_pages(admin_client, super_token) -> None: # 页集对齐 Prototypes/dashboard/permissions.md 的 ROLES assert set(roles["finance"]["pages"]) == {"dashboard", "ad-revenue-report", "cps", "withdraws"} assert set(roles["tech"]["pages"]) == { - "dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs", + "dashboard", "device-liveness", "config", "ad-revenue", "huawei-review", + "event-logs", "audit-logs", } diff --git a/tests/test_huawei_review.py b/tests/test_huawei_review.py new file mode 100644 index 0000000..76c97b2 --- /dev/null +++ b/tests/test_huawei_review.py @@ -0,0 +1,130 @@ +"""华为审核开关:admin 读写 + 客户端公开端点 + 审计 + 空库回退。 + +背景:华为应用市场审核要求新手引导的「快速设置」权限步必须可被用户关闭。开关切到 review 后 +客户端(仅华为 ROM)在该步左上角显示退出按钮。默认 default = 上线至今的现状(不可关闭)。 + +autouse 清理每个用例后清空 app_config,避免污染其他文件里假设默认值的用例(同 test_admin_config)。 +""" +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import delete, select + +from app.admin.main import admin_app +from app.admin.repositories import admin_user as admin_repo +from app.db.session import SessionLocal +from app.models.admin import AdminAuditLog +from app.models.app_config import AppConfig + + +@pytest.fixture() +def admin_client() -> TestClient: + return TestClient(admin_app) + + +@pytest.fixture() +def token() -> str: + db = SessionLocal() + try: + if admin_repo.get_by_username(db, "hw_admin") is None: + admin_repo.create_admin( + db, username="hw_admin", password="hwpass123", role="super_admin" + ) + finally: + db.close() + c = TestClient(admin_app) + return c.post( + "/admin/api/auth/login", json={"username": "hw_admin", "password": "hwpass123"} + ).json()["access_token"] + + +@pytest.fixture(autouse=True) +def _clean_config() -> Iterator[None]: + yield + db = SessionLocal() + try: + db.execute(delete(AppConfig)) + # 审计行同样要清:同库跨用例累积会让 test_switch_writes_audit 数到前面用例写的行 + # (按 action 限定,不碰其他模块可能已写入的审计)。 + db.execute(delete(AdminAuditLog).where(AdminAuditLog.action == "huawei_review.set")) + db.commit() + finally: + db.close() + + +def _auth(t: str) -> dict: + return {"Authorization": f"Bearer {t}"} + + +def test_public_default_not_closable(client: TestClient) -> None: + """空库(从未切过)→ 客户端拿到 default / 不可关闭 = 维持现状;且不需要鉴权。""" + r = client.get("/api/v1/platform/huawei-review") + assert r.status_code == 200, r.text + assert r.json() == {"mode": "default", "onboarding_closable": False} + + +def test_admin_get_default(admin_client: TestClient, token: str) -> None: + r = admin_client.get("/admin/api/huawei-review", headers=_auth(token)) + assert r.status_code == 200, r.text + body = r.json() + assert body["mode"] == "default" + assert body["updated_at"] is None # 从未切过 + + +def test_switch_to_review_takes_effect( + admin_client: TestClient, client: TestClient, token: str +) -> None: + """admin 切 review → 公开端点立刻下发可关闭(跨进程/跨 app 一致,因为落在 DB 而非内存)。""" + r = admin_client.patch( + "/admin/api/huawei-review", json={"mode": "review"}, headers=_auth(token) + ) + assert r.status_code == 200, r.text + assert r.json()["mode"] == "review" + assert r.json()["updated_at"] is not None + + pub = client.get("/api/v1/platform/huawei-review").json() + assert pub == {"mode": "review", "onboarding_closable": True} + + # 切回 default → 客户端恢复不可关闭(审核过了要能一键收回) + admin_client.patch( + "/admin/api/huawei-review", json={"mode": "default"}, headers=_auth(token) + ) + assert client.get("/api/v1/platform/huawei-review").json()["onboarding_closable"] is False + + +def test_switch_writes_audit(admin_client: TestClient, token: str) -> None: + admin_client.patch( + "/admin/api/huawei-review", json={"mode": "review"}, headers=_auth(token) + ) + db = SessionLocal() + try: + logs = db.execute( + select(AdminAuditLog).where(AdminAuditLog.action == "huawei_review.set") + ).scalars().all() + assert len(logs) == 1 + assert logs[0].detail == {"before": "default", "after": "review"} + finally: + db.close() + + +def test_invalid_mode_rejected(admin_client: TestClient, token: str) -> None: + """mode 是 Literal,非法值由 FastAPI 校验挡在 422(不会落库)。""" + r = admin_client.patch( + "/admin/api/huawei-review", json={"mode": "nope"}, headers=_auth(token) + ) + assert r.status_code == 422, r.text + db = SessionLocal() + try: + assert db.get(AppConfig, "huawei_review") is None + finally: + db.close() + + +def test_requires_admin_auth(admin_client: TestClient) -> None: + assert admin_client.get("/admin/api/huawei-review").status_code == 401 + assert admin_client.patch( + "/admin/api/huawei-review", json={"mode": "review"} + ).status_code == 401 From 1f874819fddae2d7c6e0bf3c8c9108dc8136e011 Mon Sep 17 00:00:00 2001 From: linkeyu Date: Tue, 21 Jul 2026 13:41:28 +0800 Subject: [PATCH 04/42] =?UTF-8?q?fix(ad):=20=E5=A4=B1=E8=B4=A5=E9=A2=86?= =?UTF-8?q?=E5=88=B8=E4=BB=BB=E5=8A=A1=E4=B8=8D=E5=86=8D=E5=BD=92=E5=B1=9E?= =?UTF-8?q?=E8=BF=9F=E5=88=B0=E5=B9=BF=E5=91=8A=E6=94=B6=E7=9B=8A=20(#149)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 修复内容 - coupon 广告上报到达时校验对应领券 session 状态 - session 已 failed 时保留全局广告收益记录,但清空 trace 归属,失败明细不再显示收益 - 增加失败 trace、其他场景和未知 trace 的回归测试 ## 验证 - 相关 pytest:7 passed - Ruff:通过 - compileall:通过 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/149 Co-authored-by: linkeyu Co-committed-by: linkeyu --- app/api/v1/ad.py | 10 +++++++- app/repositories/ad_ecpm.py | 19 +++++++++++++++ tests/test_ad_ecpm_trace_revenue.py | 36 ++++++++++++++++++++++++++++- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index 3b9cbaf..5093d87 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -280,13 +280,21 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm Bearer 鉴权,user_id 取自 JWT(不信 body)。best-effort:落库即 ok,客户端 fire-and-forget, 丢一两条不影响业务(穿山甲后台报表是结算权威)。eCPM 与发奖(S2S)是两条独立流,不逐条关联。 """ + attributed_trace_id = crud_ecpm.attributable_trace_id( + db, feed_scene=payload.feed_scene, trace_id=payload.trace_id + ) + if payload.trace_id and attributed_trace_id is None: + logger.info( + "detach late coupon ad impression from failed trace user_id=%d trace=%s session=%s", + user.id, payload.trace_id, payload.ad_session_id, + ) crud_ecpm.create_ecpm_record( db, user.id, ad_type=payload.ad_type, ecpm_raw=payload.ecpm, ad_session_id=payload.ad_session_id, adn=payload.adn, slot_id=payload.slot_id, feed_scene=payload.feed_scene, - trace_id=payload.trace_id, + trace_id=attributed_trace_id, app_env=payload.app_env, our_code_id=payload.our_code_id, ) logger.info( diff --git a/app/repositories/ad_ecpm.py b/app/repositories/ad_ecpm.py index c8a1fbe..65bc604 100644 --- a/app/repositories/ad_ecpm.py +++ b/app/repositories/ad_ecpm.py @@ -13,6 +13,25 @@ from sqlalchemy.orm import Session from app.core import rewards from app.core.rewards import cn_today from app.models.ad_ecpm import AdEcpmRecord +from app.models.coupon_state import CouponSession + + +def attributable_trace_id( + db: Session, *, feed_scene: str | None, trace_id: str | None +) -> str | None: + """返回广告展示允许归属的业务 trace。 + + 领券任务可能在 Draw 广告异步加载完成前已经失败或被用户放弃。非完成终态先落库、 + 广告回调后到时,收益仍需保留在总广告报表中,但不能再挂到该死亡领券明细, + 因此清空关联 trace。其它场景、找不到 session、进行中或已完成状态保持原值, + 由客户端生命周期修复负责主防线。 + """ + if feed_scene != "coupon" or not trace_id: + return trace_id + session_status = db.execute( + select(CouponSession.status).where(CouponSession.trace_id == trace_id) + ).scalar_one_or_none() + return None if session_status in {"failed", "abandoned"} else trace_id def create_ecpm_record( diff --git a/tests/test_ad_ecpm_trace_revenue.py b/tests/test_ad_ecpm_trace_revenue.py index cd800e0..a88d3e2 100644 --- a/tests/test_ad_ecpm_trace_revenue.py +++ b/tests/test_ad_ecpm_trace_revenue.py @@ -1,12 +1,13 @@ """ad_ecpm_record.trace_id 落库 + 按 trace 聚合广告收益(元)。""" from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, date, datetime from sqlalchemy import delete from app.db.session import SessionLocal from app.models.ad_ecpm import AdEcpmRecord +from app.models.coupon_state import CouponSession from app.repositories import ad_ecpm as crud_ecpm @@ -57,6 +58,39 @@ def test_revenue_yuan_by_trace_empty() -> None: db.close() +def test_terminal_coupon_trace_is_not_attributable_to_late_impression() -> None: + """领券失败或被放弃后才到达的广告展示保留收益记录,但不再关联死亡 trace。""" + db = SessionLocal() + try: + db.add_all([ + CouponSession( + trace_id="failed-before-ad", device_id="d-late-ad", status="failed", app_env="prod", + started_at=datetime(2020, 1, 2, tzinfo=UTC), started_date=date(2020, 1, 2), + ), + CouponSession( + trace_id="abandoned-before-ad", device_id="d-late-ad", status="abandoned", app_env="prod", + started_at=datetime(2020, 1, 2, tzinfo=UTC), started_date=date(2020, 1, 2), + ), + ]) + db.flush() + + assert crud_ecpm.attributable_trace_id( + db, feed_scene="coupon", trace_id="failed-before-ad" + ) is None + assert crud_ecpm.attributable_trace_id( + db, feed_scene="coupon", trace_id="abandoned-before-ad" + ) is None + assert crud_ecpm.attributable_trace_id( + db, feed_scene="comparison", trace_id="failed-before-ad" + ) == "failed-before-ad" + assert crud_ecpm.attributable_trace_id( + db, feed_scene="coupon", trace_id="unknown-trace" + ) == "unknown-trace" + finally: + db.rollback() + db.close() + + def test_create_ecpm_record_persists_trace_id() -> None: """create_ecpm_record 落 trace_id。""" db = SessionLocal() From f39467ec0809ab7a126072b21584e81d9b756a7b Mon Sep 17 00:00:00 2001 From: guke Date: Tue, 21 Jul 2026 13:52:40 +0800 Subject: [PATCH 05/42] =?UTF-8?q?docs(welfare):=2015=E5=A4=A9=E4=B8=8D?= =?UTF-8?q?=E6=B4=BB=E8=B7=83=E6=B8=85=E9=9B=B6=E9=87=91=E5=B8=81/?= =?UTF-8?q?=E7=8E=B0=E9=87=91=20=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3(spec)?= =?UTF-8?q?=20(#151)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对齐前端首页可见事件home_visible --------- Co-authored-by: guke Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/151 --- ...ytics_active_idx_active_composite_index.py | 6 ++-- app/admin/repositories/queries.py | 2 +- app/models/analytics_event.py | 5 ++-- app/repositories/activity.py | 20 +++++-------- .../2026-07-16-inactivity-reset-design.md | 28 +++++++++---------- scripts/seed_inactivity_cases.py | 7 +++-- tests/test_inactivity_reset.py | 24 ++++++++-------- 7 files changed, 45 insertions(+), 47 deletions(-) diff --git a/alembic/versions/analytics_active_idx_active_composite_index.py b/alembic/versions/analytics_active_idx_active_composite_index.py index 8cdaa5c..9a2b75a 100644 --- a/alembic/versions/analytics_active_idx_active_composite_index.py +++ b/alembic/versions/analytics_active_idx_active_composite_index.py @@ -5,8 +5,10 @@ Revises: 135e79414fd0 Create Date: 2026-07-18 17:35:00.000000 给 analytics_event 加活跃口径热点复合索引 (event, page, user_id, created_at): -activity.active_event_condition 按 (event=show & page=home) ∪ 比价 ∪ 领券 过滤后 -group by user_id、max(created_at)。覆盖索引让该聚合走 index-only,避免高频 show 事件全表扫。 +activity.active_event_condition 按 event IN (home_visible ∪ 比价 ∪ 领券) 过滤后 +group by user_id、max(created_at)。覆盖索引让该聚合走 index-only,避免高频活跃事件全表扫。 +(历史:早期首页可见用 event=show+page=home 组合,故索引含 page 列;现改单一 home_visible、 +不再按 page 过滤 → page 列成冗余,索引仍靠 event 前缀生效;如需更优可后续新迁移瘦成 (event,user_id,created_at)。) ⚠️ 本分支迁移树有**既有多头**:135e79414fd0(不活跃两表)与 phone_rebind_log 同从 comparison_llm_cost 分叉,`alembic upgrade head` 会多头报错。本迁移挂在 135e79414fd0 diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index c9e0b74..7bbe522 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -145,7 +145,7 @@ def list_users( 代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。 日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。""" # 最近活跃 = max(注册时间, 最近行为事件, 最近领券发起)。baseline 由 last_login_at 改为 created_at - #(登录不代表在用 App;口径统一到 activity.py,含 home_view + 比价 + 领券,见 activity.ACTIVE_EVENTS)。 + #(登录不代表在用 App;口径统一到 activity.py,含 home_visible + 比价 + 领券,见 activity.ACTIVE_EVENTS)。 # 未命中侧 coalesce 到 created_at(恒非空基线)。派生表 1:1,outerjoin 不放大行数。 ev_agg, eng_agg = activity.last_active_subqueries(db) last_active = activity.last_active_expr( diff --git a/app/models/analytics_event.py b/app/models/analytics_event.py index 21f03c5..394f4ff 100644 --- a/app/models/analytics_event.py +++ b/app/models/analytics_event.py @@ -25,8 +25,9 @@ class AnalyticsEvent(Base): __tablename__ = "analytics_event" __table_args__ = ( # 活跃口径聚合热点(activity.active_event_condition + last_active_subqueries): - # 按 (event,page) 过滤 首页可见(show/home)∪比价∪领券,再 group by user_id 取 - # max(created_at)。覆盖索引 → 该聚合走 index-only,避免高频 show 事件全表扫。 + # 按 event IN (home_visible∪比价∪领券) 过滤,再 group by user_id 取 max(created_at)。 + # 覆盖索引 → 该聚合走 index-only。注:page 列是早期 show+home 组合的遗留,现不再按 page + # 过滤(索引靠 event 前缀仍生效);后续可新迁移瘦成 (event,user_id,created_at)。 Index("ix_analytics_event_active", "event", "page", "user_id", "created_at"), ) diff --git a/app/repositories/activity.py b/app/repositories/activity.py index f3e83b1..f016db6 100644 --- a/app/repositories/activity.py +++ b/app/repositories/activity.py @@ -1,6 +1,6 @@ """活跃口径唯一真源:worker(不活跃清零)与 admin(最近活跃/DAU)共用,防两处漂移。 -口径 = max(User.created_at, AnalyticsEvent[首页可见 show/home + 比价 + 领券], CouponPromptEngagement[claim_started])。 +口径 = max(User.created_at, AnalyticsEvent[首页可见 home_visible + 比价 + 领券], CouponPromptEngagement[claim_started])。 **不含 last_login_at**(登录/re-login 不代表在用 App);created_at 为恒非空基线。 清零/预警按北京自然日 0 点对齐(见 reset_cutoff)。 """ @@ -8,7 +8,7 @@ from __future__ import annotations from datetime import date, datetime, timedelta, timezone -from sqlalchemy import and_, func, or_, select +from sqlalchemy import func, select from sqlalchemy.orm import Session from app.core.rewards import CN_TZ, cn_today @@ -16,24 +16,18 @@ from app.models.analytics_event import AnalyticsEvent from app.models.coupon_state import CouponPromptEngagement # —— 活跃口径事件(与"用户管理"口径一致)—— -# 首页可见:前端埋点 event=show + page=home(组合判定,单个 event 名不足以区分,见 -# active_event_condition);其余为纯 event 名。 -HOME_VIEW_EVENT = "show" -HOME_VIEW_PAGE = "home" +# 首页可见:前端埋点确认 event=home_visible(首页进入可视区时触发,单一 event 名即可判定)。 +HOME_VISIBLE_EVENT = "home_visible" COMPARE_START_EVENT = "real_compare_start" # 发起比价(含浮窗触发) COUPON_START_EVENT = "real_coupon_start" # 发起领券 -# 纯 event 名即可判定的活跃事件(首页可见是 event+page 组合、不在此列) -ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT) +ACTIVE_EVENTS = (HOME_VISIBLE_EVENT, COMPARE_START_EVENT, COUPON_START_EVENT) ACTIVE_ENGAGE_TYPE = "claim_started" # coupon_prompt_engagement 一键领取 def active_event_condition(): - """analytics_event 中算"活跃"的行为过滤:首页可见(event=show & page=home) + """analytics_event 中算"活跃"的行为过滤:首页可见(event=home_visible) ∪ 发起比价 ∪ 发起领券。worker 子查询与 admin 展示共用,单一真源。""" - return or_( - and_(AnalyticsEvent.event == HOME_VIEW_EVENT, AnalyticsEvent.page == HOME_VIEW_PAGE), - AnalyticsEvent.event.in_(ACTIVE_EVENTS), - ) + return AnalyticsEvent.event.in_(ACTIVE_EVENTS) def as_utc(value: datetime) -> datetime: diff --git a/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md b/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md index 98de060..62df6a5 100644 --- a/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md +++ b/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md @@ -38,8 +38,8 @@ | 决策点 | 结论 | 理由 | |---|---|---| -| **活跃口径** | 与"用户管理"一致:`max(首页可见 show/home, 比价, 领券)`,**不含 last_login_at**;无任何信号时以 `created_at` 为非空基线 | 比价可从**浮窗**触发、不进首页;`last_login_at` 只在登录/换绑动作更新(re-login 也算),代表不了"在用 App",故彻底排除 | -| **"进首页"信号落地** | **方案 A:前端上报 `home_view` 埋点**(复用 `/analytics/events`),非新接口 | 三个活跃信号统一为同类埋点事件;零新接口零新列;与 admin 口径天然一致。B(鉴权接口 + 列)"更权威"的优势是假的——比价/领券仍是端上报事件,最弱环决定整体可信度 | +| **活跃口径** | 与"用户管理"一致:`max(首页可见 home_visible, 比价, 领券)`,**不含 last_login_at**;无任何信号时以 `created_at` 为非空基线 | 比价可从**浮窗**触发、不进首页;`last_login_at` 只在登录/换绑动作更新(re-login 也算),代表不了"在用 App",故彻底排除 | +| **"进首页"信号落地** | **方案 A:前端上报 `home_visible` 埋点**(复用 `/analytics/events`),非新接口 | 三个活跃信号统一为同类埋点事件;零新接口零新列;与 admin 口径天然一致。B(鉴权接口 + 列)"更权威"的优势是假的——比价/领券仍是端上报事件,最弱环决定整体可信度 | | **清零范围** | **金币 + 折算现金**(**邀请现金不清**——产品红线,仅快照入审计) | 对应"账户里的金币和现金";邀请奖励金与金币现金物理隔离、不可累加,见 `wallet.CoinAccount` 注释 | | **预警推送** | **可插拔通知器 + 日志占位**(v1),后续接 JPush/短信 | 现状无真实推送能力;先把清零主流程 + 审计做扎实,不阻塞 | | **预警时机** | **完全可配置**(提前天数列表 + 次数 + 执行点 + 通道) | R5 | @@ -76,7 +76,7 @@ last_active = max( ### 模块内容 - 常量: - - **首页可见活跃信号已定名:`event=show` + `page=home`**(前端确认,原占位 `home_view`;下文出现的 `home_view` 均指此信号)。活跃行为过滤见 `activity.active_event_condition()`:首页可见 ∪ 比价 `real_compare_start` ∪ 领券 `real_coupon_start`;`ACTIVE_EVENTS` 仅含后两个纯 event 名(首页可见是 event+page 组合、单列)。 + - **首页可见活跃信号已定名:`event=home_visible`**(前端最终确认;曾用过渡期 `show`+`page=home` 组合,已废弃)。活跃行为过滤见 `activity.active_event_condition()`:首页可见 `home_visible` ∪ 比价 `real_compare_start` ∪ 领券 `real_coupon_start`——三者均为纯 event 名,全部收进 `ACTIVE_EVENTS`。 - `ACTIVE_ENGAGE_TYPE = "claim_started"` - `last_active_subqueries(db)` —— 复刻现 admin `queries._last_active_parts()`:两个按 `user_id` 的 `GROUP BY max(created_at)` 聚合子查询。 - `last_active_expr(base_col, ev_sub, eng_sub, dialect)` —— 生成 `greatest`/`max`(PG `func.greatest`/SQLite `func.max`);子聚合缺失时 `coalesce(子聚合, User.created_at)` 兜底(注册基线恒非空,**替代原 last_login_at**)。 @@ -210,7 +210,7 @@ INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现 ## 9. 幂等与重新活跃 -- **重新活跃自动退出**:`inactive_days` 由 §4 口径**实时算**。用户一有 `home_view`/比价/领券(**登录本身不算**),`last_active` 前移,自动移出预警与清零队列。**无需**显式"重置标记"。 +- **重新活跃自动退出**:`inactive_days` 由 §4 口径**实时算**。用户一有 `home_visible`/比价/领券(**登录本身不算**),`last_active` 前移,自动移出预警与清零队列。**无需**显式"重置标记"。 - **预警去重**:`inactivity_notification_log` 中存在 `stage==k 且 created_at > last_active` 的行 ⟹ 本 streak 已推过档 `k`,不重推。用户回归后 `last_active` 前移,旧预警行自然"失效",开启新 streak。 - **清零幂等**:阶段 B 只处理三桶非全 0 者;清完 = 0,次日不再匹配。worker 重启 / 多次唤醒 / 补跑均安全,不产生重复清零或重复流水。 - **稳健补发**:worker 漏跑数天后,某用户可能同时满足多档;只补发**最紧急的未推档**(最小 `k`),避免一次刷屏。 @@ -221,7 +221,7 @@ INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现 | 场景 | 处理 | |---|---| -| 新用户 | `created_at` 作活跃基线(恒非空)→ 注册即"第 1 日活跃";注册后连续 15 天无 home_view/比价/领券 才清 | +| 新用户 | `created_at` 作活跃基线(恒非空)→ 注册即"第 1 日活跃";注册后连续 15 天无 home_visible/比价/领券 才清 | | 在途提现 | 提现申请时现金已扣入 `WithdrawOrder`,当前余额已不含在途;只清当前余额、不动提现单。提现失败退款到已清账户 = 用户的钱,正常 | | 与 `daily_auto_exchange` 并存 | 各自逐用户幂等;金币多已日结折现金,三桶全清正好覆盖 | | 时区/日界 | 统一北京(`rewards.cn_today()`/`CN_TZ`);**清零/预警按北京自然日 0 点对齐**(末次活跃记为第 1 日 → 第 16 日 0 点清零,见 §4),非滚动 24h;流水 `created_at` 沿用北京 wall-clock naive | @@ -229,11 +229,11 @@ INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现 --- -## 11. 前端依赖:`home_view` 埋点(跨仓 — Android) +## 11. 前端依赖:`home_visible` 埋点(跨仓 — Android) -- **Android 端**(`shaguabijia-app-android`)需在**首页可见**(`onResume`/Tab 切入)时,向现有 `POST /api/v1/analytics/events` 批量上报里加一条 `event=<首页可见事件名>`(名称明天加埋点时定,暂记 `"home_view"`) 的事件,**携带登录后的 `user_id`**。 +- **Android 端**(`shaguabijia-app-android`)需在**首页可见**(`onResume`/Tab 切入)时,向现有 `POST /api/v1/analytics/events` 批量上报里加一条 `event=home_visible`(前端已定名)的事件,**携带登录后的 `user_id`**。 - 客户端按会话/前台去重即可(服务端只取 `max(created_at)`,多报无害)。 -- **上线顺序依赖**:`home_view` 全量覆盖前,"进首页"信号缺失,只有比价/领券能推进活跃、其余落到 `created_at` 基线("只开首页不操作"且注册满 15 天的用户会被误清)—— 故**开真清(`ENABLED=true`)必须待 `home_view` 铺满后再开**(§13);dry-run 只记名单不动钱、可先开着看。 +- **上线顺序依赖**:`home_visible` 全量覆盖前,"进首页"信号缺失,只有比价/领券能推进活跃、其余落到 `created_at` 基线("只开首页不操作"且注册满 15 天的用户会被误清)—— 故**开真清(`ENABLED=true`)必须待 `home_visible` 铺满后再开**(§13);dry-run 只记名单不动钱、可先开着看。 --- @@ -241,25 +241,25 @@ INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现 - `app/admin/repositories/queries.py`:删本地 `_ACTIVE_EVENTS`/`_last_active_parts()`,改用 `activity.py` 的常量与子查询构造;`list_users` 的 `greatest(...)` 排序/筛选、`_attach_last_active` 均改走共享构造器。 - `app/admin/repositories/stats.py`:`COMPARE_START_EVENT`/`COUPON_START_EVENT`/活跃用户集(`:138-146`)改用共享常量与口径。 -- **行为变化(预期内、需产品知会)**:admin 的"最近活跃 / DAU"口径变化——**移除 `last_login_at`(登录不再计为活跃)、以 `created_at` 为基线、纳入 `home_view`**。net:`home_view` 铺满后更准(真正把"开首页"算进活跃);铺满前"只登录不操作"的用户活跃度会下降。 -- **回归底线**:现有 admin 用户列表 / stats 测试按新口径**更新预期**(last_login_at 移除 + created_at 基线 + home_view 纳入);非活跃口径部分行为不变。 +- **行为变化(预期内、需产品知会)**:admin 的"最近活跃 / DAU"口径变化——**移除 `last_login_at`(登录不再计为活跃)、以 `created_at` 为基线、纳入 `home_visible`**。net:`home_visible` 铺满后更准(真正把"开首页"算进活跃);铺满前"只登录不操作"的用户活跃度会下降。 +- **回归底线**:现有 admin 用户列表 / stats 测试按新口径**更新预期**(last_login_at 移除 + created_at 基线 + home_visible 纳入);非活跃口径部分行为不变。 --- ## 13. 灰度与上线顺序(安全优先) 1. **后端先行**:合入共享模块 + 两表 + worker + 通知器,`INACTIVITY_RESET_ENABLED=False`;活跃口径以 `created_at` 为非空基线、**不含 last_login_at**。 -2. **Android 发版**:上报 `home_view`;观察 analytics 覆盖率。 +2. **Android 发版**:上报 `home_visible`;观察 analytics 覆盖率。 3. **dry-run 灰度(默认即是)**:`INACTIVITY_RESET_ENABLED=False` 时 worker 常驻只写审计名单(`reason=inactive_Nd_dryrun`)、不动钱、不预警;核对名单准确。 4. **开真清**:确认无误后置 `INACTIVITY_RESET_ENABLED=True`(转为真清 + 预警)。 -5. **收尾/监控**:持续观察 `home_view` 覆盖率与预警/清零名单;发现"活跃却被判不活跃"的漏报即回查埋点覆盖(口径已不含 last_login_at,登录不再兜底)。 +5. **收尾/监控**:持续观察 `home_visible` 覆盖率与预警/清零名单;发现"活跃却被判不活跃"的漏报即回查埋点覆盖(口径已不含 last_login_at,登录不再兜底)。 --- ## 14. 测试计划 -- **活跃口径(共享模块)**:`home_view`/比价/领券 各单独命中都算活跃;**纯登录不算**;无信号用户以 `created_at` 计;`max` 取最新;naive/aware 混算不崩。 -- **admin 回归**:用户列表 / stats 按新口径更新预期(移除 last_login_at + created_at 基线 + home_view)。 +- **活跃口径(共享模块)**:`home_visible`/比价/领券 各单独命中都算活跃;**纯登录不算**;无信号用户以 `created_at` 计;`max` 取最新;naive/aware 混算不崩。 +- **admin 回归**:用户列表 / stats 按新口径更新预期(移除 last_login_at + created_at 基线 + home_visible)。 - **不活跃判定**:`last_active` 分别 `<15d / =15d / >15d` × 有/无余额 的命中矩阵。 - **清零**:三桶归零;`inactivity_reset_log` 清前值正确;三条流水 `biz_type=inactivity_reset`、`balance_after=0`、`ref_id=log.id`;`total_coin_earned` 不变。 - **预警**:命中窗口调 notifier + 写 `notification_log`;同 streak 不重推;回归后 `last_active` 前移可再次预警;漏跑补发最紧急档。 diff --git a/scripts/seed_inactivity_cases.py b/scripts/seed_inactivity_cases.py index a1293d6..e8c9963 100644 --- a/scripts/seed_inactivity_cases.py +++ b/scripts/seed_inactivity_cases.py @@ -33,6 +33,7 @@ from app.models.wallet import ( # noqa: E402 CoinTransaction, InviteCashTransaction, ) +from app.repositories import activity # noqa: E402 from app.repositories import wallet as wallet_repo # noqa: E402 MARK = "vcase" # username 前缀,用于清理 @@ -47,7 +48,7 @@ CASES = [ ("6 只有现金", 30, 0, 200, 0, None, "清 cash;审计1行+1流水"), ("7 只有邀请(红线)", 30, 0, 0, 300, None, "不选中/不清/无审计/无流水;invite=300 原封"), ("8 预警窗(10天)", 10, 50, 60, 70, None, "不清;发 T-7 预警;notification_log 1行;余额不动"), - ("9 活跃兜底", 30, 100, 200, 300, 1, "昨日 home_view→last_active 近→不清不警"), + ("9 活跃兜底", 30, 100, 200, 300, 1, "昨日 home_visible→last_active 近→不清不警"), ("10 新用户(3天)", 3, 100, 200, 0, None, "created_at 近→不清不警"), ] @@ -86,8 +87,8 @@ def seed(db) -> None: acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents = coin, cash, invite acc.total_coin_earned = coin if ev_days is not None: - db.add(AnalyticsEvent( # 首页可见 = event=show + page=home - event="show", page="home", device_id=MARK, user_id=u.id, + db.add(AnalyticsEvent( # 首页可见 = event=home_visible(单一 event 名,见 activity.ACTIVE_EVENTS) + event=activity.HOME_VISIBLE_EVENT, device_id=MARK, user_id=u.id, client_ts=0, created_at=now - timedelta(days=ev_days), )) db.flush() diff --git a/tests/test_inactivity_reset.py b/tests/test_inactivity_reset.py index 33fb217..e19d2b5 100644 --- a/tests/test_inactivity_reset.py +++ b/tests/test_inactivity_reset.py @@ -42,9 +42,9 @@ def test_reset_cutoff_is_cn_midnight_of_today_minus_days_minus_1() -> None: def test_active_event_constants() -> None: - # 首页可见 = event=show + page=home 组合,不在纯 event 名集合里 - assert activity.HOME_VIEW_EVENT == "show" and activity.HOME_VIEW_PAGE == "home" - assert activity.HOME_VIEW_EVENT not in activity.ACTIVE_EVENTS + # 首页可见:前端埋点确认 event=home_visible,单一 event 名,在 ACTIVE_EVENTS 中 + assert activity.HOME_VISIBLE_EVENT == "home_visible" + assert activity.HOME_VISIBLE_EVENT in activity.ACTIVE_EVENTS assert "real_compare_start" in activity.ACTIVE_EVENTS assert "real_coupon_start" in activity.ACTIVE_EVENTS assert activity.ACTIVE_ENGAGE_TYPE == "claim_started" @@ -133,15 +133,15 @@ def test_last_active_expr_takes_max_of_baseline_and_events() -> None: db.close() -def test_home_signal_uses_show_event_on_home_page() -> None: - """首页可见活跃口径 = event=show + page=home 组合;show 但非 home 页不算活跃。""" +def test_home_signal_uses_home_visible_event() -> None: + """首页可见活跃口径 = event=home_visible(单一事件名,前端埋点已确认);其他事件不算活跃。""" from sqlalchemy import select db = SessionLocal() try: base = datetime(2026, 1, 1, tzinfo=timezone.utc) - seen = _new_user(db, created_at=base) # show/home → 活跃 - _add_event(db, seen, "show", datetime(2026, 1, 10, tzinfo=timezone.utc), page="home") - other = _new_user(db, created_at=base) # show/其他页 → 不算活跃 + seen = _new_user(db, created_at=base) # home_visible → 活跃 + _add_event(db, seen, "home_visible", datetime(2026, 1, 10, tzinfo=timezone.utc)) + other = _new_user(db, created_at=base) # 其他事件 → 不算活跃 _add_event(db, other, "show", datetime(2026, 1, 10, tzinfo=timezone.utc), page="coupon") db.commit() @@ -156,8 +156,8 @@ def test_home_signal_uses_show_event_on_home_page() -> None: .where(User.id == uid)) return activity.norm_utc(db.execute(stmt).scalar_one()) - assert last_active(seen) == datetime(2026, 1, 10, tzinfo=timezone.utc) # show/home 算 - assert last_active(other) == base # show/其他页 不算 + assert last_active(seen) == datetime(2026, 1, 10, tzinfo=timezone.utc) # home_visible 算 + assert last_active(other) == base # 其他事件不算 finally: db.rollback() db.close() @@ -194,9 +194,9 @@ def test_run_reset_clears_coin_and_cash_but_preserves_invite_cash() -> None: # 末次活跃 = created_at 基线 = 1/10(距 today 22 天 → 应清) old = _new_user(db, created_at=datetime(2026, 1, 10, tzinfo=timezone.utc), coin=100, cash=200, invite=300) - # 活跃用户:昨天有 home_view → 不清 + # 活跃用户:昨天有 home_visible → 不清 fresh = _new_user(db, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), coin=50) - _add_event(db, fresh, "show", datetime(2026, 1, 31, tzinfo=timezone.utc), page="home") + _add_event(db, fresh, "home_visible", datetime(2026, 1, 31, tzinfo=timezone.utc)) db.commit() stats = inactivity.run_reset_once(db, reset_days=15, today=today) From beadce31ed3c2b3a7e3d076a2d90b490a5915622 Mon Sep 17 00:00:00 2001 From: linkeyu Date: Tue, 21 Jul 2026 13:53:10 +0800 Subject: [PATCH 06/42] =?UTF-8?q?fix(ad):=20=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=E4=B8=8D=E8=B6=B3=E4=B8=80=E7=A7=92=E6=9B=9D?= =?UTF-8?q?=E5=85=89=E6=94=B6=E7=9B=8A=E4=B8=BA=E9=9B=B6=20(#150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 改动 - eCPM 上报新增可选 exposure_ms,兼容旧客户端 - exposure_ms < 1000 时保留展示记录并强制有效 eCPM 为 0 - 失败领券任务允许保留短曝光零收益 trace,后台显示 0 而不是未填充 - 其他失败后的迟到曝光仍按原规则解绑 trace ## 验证 - 相关 pytest:10 passed - Ruff:通过 - compileall:通过 依赖:先合并 Server #149。 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/150 Co-authored-by: linkeyu Co-committed-by: linkeyu --- app/api/v1/ad.py | 10 ++++++--- app/repositories/ad_ecpm.py | 25 +++++++++++++++++++--- app/schemas/ad.py | 6 ++++++ tests/test_ad_ecpm_trace_revenue.py | 32 +++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index 5093d87..cff56ee 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -281,7 +281,10 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm 丢一两条不影响业务(穿山甲后台报表是结算权威)。eCPM 与发奖(S2S)是两条独立流,不逐条关联。 """ attributed_trace_id = crud_ecpm.attributable_trace_id( - db, feed_scene=payload.feed_scene, trace_id=payload.trace_id + db, + feed_scene=payload.feed_scene, + trace_id=payload.trace_id, + exposure_ms=payload.exposure_ms, ) if payload.trace_id and attributed_trace_id is None: logger.info( @@ -296,11 +299,12 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm feed_scene=payload.feed_scene, trace_id=attributed_trace_id, app_env=payload.app_env, our_code_id=payload.our_code_id, + exposure_ms=payload.exposure_ms, ) logger.info( - "ad ecpm report user_id=%d type=%s scene=%s session=%s ecpm=%s adn=%s slot=%s app=%s code=%s", + "ad ecpm report user_id=%d type=%s scene=%s session=%s ecpm=%s exposure_ms=%s adn=%s slot=%s app=%s code=%s", user.id, payload.ad_type, payload.feed_scene, payload.ad_session_id, payload.ecpm, - payload.adn, payload.slot_id, payload.app_env, payload.our_code_id, + payload.exposure_ms, payload.adn, payload.slot_id, payload.app_env, payload.our_code_id, ) return EcpmReportOut(ok=True) diff --git a/app/repositories/ad_ecpm.py b/app/repositories/ad_ecpm.py index 65bc604..9013cae 100644 --- a/app/repositories/ad_ecpm.py +++ b/app/repositories/ad_ecpm.py @@ -15,9 +15,22 @@ from app.core.rewards import cn_today from app.models.ad_ecpm import AdEcpmRecord from app.models.coupon_state import CouponSession +MIN_REVENUE_EXPOSURE_MS = 1000 + + +def effective_ecpm_raw(ecpm_raw: str, exposure_ms: int | None) -> str: + """曝光不足一秒时保留展示记录,但把该条有效 eCPM 归零。""" + if exposure_ms is not None and exposure_ms < MIN_REVENUE_EXPOSURE_MS: + return "0" + return ecpm_raw + def attributable_trace_id( - db: Session, *, feed_scene: str | None, trace_id: str | None + db: Session, + *, + feed_scene: str | None, + trace_id: str | None, + exposure_ms: int | None = None, ) -> str | None: """返回广告展示允许归属的业务 trace。 @@ -31,7 +44,12 @@ def attributable_trace_id( session_status = db.execute( select(CouponSession.status).where(CouponSession.trace_id == trace_id) ).scalar_one_or_none() - return None if session_status in {"failed", "abandoned"} else trace_id + if session_status not in {"failed", "abandoned"}: + return trace_id + # 已真实上墙但不足一秒的曝光要在终态明细中明确显示 0,而不是被误判成“未填充”。 + if exposure_ms is not None and exposure_ms < MIN_REVENUE_EXPOSURE_MS: + return trace_id + return None def create_ecpm_record( @@ -47,6 +65,7 @@ def create_ecpm_record( trace_id: str | None = None, app_env: str | None = None, our_code_id: str | None = None, + exposure_ms: int | None = None, ) -> AdEcpmRecord: """落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。 @@ -67,7 +86,7 @@ def create_ecpm_record( trace_id=trace_id, app_env=app_env, our_code_id=our_code_id, - ecpm_raw=ecpm_raw, + ecpm_raw=effective_ecpm_raw(ecpm_raw, exposure_ms), report_date=cn_today().isoformat(), ) db.add(rec) diff --git a/app/schemas/ad.py b/app/schemas/ad.py index ceb74af..7a6feb4 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -69,6 +69,12 @@ class EcpmReportIn(BaseModel): description="本次比价/领券 trace_id(信息流场景带上):把这条展示收益归属到对应比价/领券," "供领券数据/比价记录看板聚合本场广告收益;激励视频/福利为空", ) + exposure_ms: int | None = Field( + None, + ge=0, + le=86_400_000, + description="本条广告真实在屏曝光毫秒数;小于 1000ms 时收益强制按 0 计算。旧客户端不传则保持原口径", + ) app_env: str | None = Field( None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)" ) diff --git a/tests/test_ad_ecpm_trace_revenue.py b/tests/test_ad_ecpm_trace_revenue.py index a88d3e2..c3601c0 100644 --- a/tests/test_ad_ecpm_trace_revenue.py +++ b/tests/test_ad_ecpm_trace_revenue.py @@ -58,6 +58,32 @@ def test_revenue_yuan_by_trace_empty() -> None: db.close() +def test_short_exposure_keeps_record_with_zero_revenue() -> None: + """不足一秒仍落展示记录,以便后台显示 0 而不是未填充。""" + db = SessionLocal() + try: + rec = crud_ecpm.create_ecpm_record( + db, 1, ad_type="draw", ecpm_raw="350", + ad_session_id="sess-short-exposure", feed_scene="coupon", + trace_id="trace-short-exposure", exposure_ms=999, + ) + assert rec.ecpm_raw == "0" + assert crud_ecpm.revenue_yuan_by_trace(db, ["trace-short-exposure"]) == { + "trace-short-exposure": 0.0 + } + finally: + db.execute(delete(AdEcpmRecord).where( + AdEcpmRecord.ad_session_id == "sess-short-exposure" + )) + db.commit() + db.close() + + +def test_one_second_exposure_keeps_original_ecpm() -> None: + assert crud_ecpm.effective_ecpm_raw("350", 1000) == "350" + assert crud_ecpm.effective_ecpm_raw("350", None) == "350" + + def test_terminal_coupon_trace_is_not_attributable_to_late_impression() -> None: """领券失败或被放弃后才到达的广告展示保留收益记录,但不再关联死亡 trace。""" db = SessionLocal() @@ -80,6 +106,12 @@ def test_terminal_coupon_trace_is_not_attributable_to_late_impression() -> None: assert crud_ecpm.attributable_trace_id( db, feed_scene="coupon", trace_id="abandoned-before-ad" ) is None + assert crud_ecpm.attributable_trace_id( + db, feed_scene="coupon", trace_id="failed-before-ad", exposure_ms=999 + ) == "failed-before-ad" + assert crud_ecpm.attributable_trace_id( + db, feed_scene="coupon", trace_id="abandoned-before-ad", exposure_ms=999 + ) == "abandoned-before-ad" assert crud_ecpm.attributable_trace_id( db, feed_scene="comparison", trace_id="failed-before-ad" ) == "failed-before-ad" From f9a62bffbe43091978ee551354e61a608171864b Mon Sep 17 00:00:00 2001 From: linkeyu Date: Wed, 22 Jul 2026 10:41:52 +0800 Subject: [PATCH 07/42] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E5=B9=BF?= =?UTF-8?q?=E5=91=8A=E6=94=B6=E7=9B=8A=E6=94=AF=E6=8C=81=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E4=B8=8E=E4=B8=9A=E5=8A=A1=E4=BB=A3=E7=A0=81=E4=BD=8D=E7=AD=9B?= =?UTF-8?q?=E9=80=89=20(#155)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 修改内容 - 客户端预估与穿山甲汇总统一支持正式、测试、全部环境筛选 - 支持业务代码位与全部代码位两种对账范围 - 保留已上线正式业务代码位,避免配置切换后历史报表漏数 - 补充接口文档和筛选口径测试 ## 本地验证 - 相关后端测试 10 项通过 - Ruff 检查通过 - 本地后台页面联调通过 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/155 Co-authored-by: linkeyu Co-committed-by: linkeyu --- app/admin/repositories/ad_revenue.py | 47 ++++++- app/admin/routers/ad_revenue.py | 10 +- app/repositories/ad_pangle_revenue.py | 4 + docs/api/admin/ad/admin-ad-revenue-report.md | 4 +- tests/test_admin_ad_revenue_scope.py | 129 +++++++++++++++++++ 5 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 tests/test_admin_ad_revenue_scope.py diff --git a/app/admin/repositories/ad_revenue.py b/app/admin/repositories/ad_revenue.py index b1e2162..6d39879 100644 --- a/app/admin/repositories/ad_revenue.py +++ b/app/admin/repositories/ad_revenue.py @@ -33,7 +33,29 @@ from app.admin.repositories import stats as admin_stats from app.core import rewards from app.models.ad_ecpm import AdEcpmRecord from app.models.user import User -from app.repositories import ad_pangle_revenue +from app.repositories import ad_pangle_revenue, app_config + +# 已上线过的正式业务代码位要永久保留,避免运营切换当前配置后,历史报表把旧业务位误判成测试流量。 +_KNOWN_PROD_BUSINESS_CODE_IDS = frozenset({"104098712", "104099389"}) + +# 测试应用中实际承载业务链路的代码位。广告测试 demo 的插屏/半屏/信息流测试位不在这里, +# 避免“业务口径”把开发诊断曝光混进客户端与穿山甲对账。 +_TEST_BUSINESS_CODE_IDS = frozenset({"104127529", "104127626", "104137445"}) + + +def _business_code_ids(db: Session, app_env: str | None) -> set[str]: + """返回指定应用环境下可用于业务收益对账的 GroMore 聚合代码位。""" + prod_config = app_config.get_ad_config(db) + prod_ids = set(_KNOWN_PROD_BUSINESS_CODE_IDS) | { + str(prod_config.get(key) or "").strip() + for key in ("reward_code_id", "compare_draw_code_id", "coupon_draw_code_id") + } + prod_ids.discard("") + if app_env == "prod": + return prod_ids + if app_env == "test": + return set(_TEST_BUSINESS_CODE_IDS) + return prod_ids | set(_TEST_BUSINESS_CODE_IDS) def _cn_hour(dt: datetime) -> int: @@ -82,6 +104,7 @@ def ad_revenue_report( ad_type: str | None = None, feed_scene: str | None = None, app_env: str | None = None, + revenue_scope: str = "all", granularity: str = "day", limit: int = 500, offset: int = 0, @@ -277,14 +300,18 @@ def ad_revenue_report( if feed_scene is not None: events = [e for e in events if e.get("feed_scene") == feed_scene] - # app_env 过滤(2026-06-29 新增能力,修隐患:测试应用上报的假 eCPM 如 ¥678 CPM 会污染正式收益合计/平均): - # 显式传 "prod"/"test" 只看该环境;不传=全部(维持现状)。**不擅自把默认改成排除 test**——本地 dev 库多为 - # test 数据、默认排除会使本地报表空,且「正式报表是否含 test」属产品口径。建议前端报表页加 app_env 筛选器 - # (默认选 prod),或产品确认后再把默认改成排除 test。注:穿山甲后台收益列(total_pangle_*)暂未联动此过滤 - # (它是独立对照列,且 pangle 的 test 是真实小额、非客户端那种假值)。 + # app_env 过滤:显式传 "prod"/"test" 只看该环境;不传=全部。该参数也会传给下方穿山甲聚合, + # 保证客户端预估与 GroMore 汇总使用同一应用环境口径。 if app_env is not None: events = [e for e in events if e.get("app_env") == app_env] + # 业务口径仅保留正式配置/测试业务链路实际使用的代码位。穿山甲“全量”还包含广告测试 + # demo、插屏等没有客户端收益上报的曝光,两边直接比较会天然产生假差额。 + business_code_ids: set[str] | None = None + if revenue_scope == "business": + business_code_ids = _business_code_ids(db, app_env) + events = [e for e in events if e.get("our_code_id") in business_code_ids] + # 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排; # 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。 if sort == "ecpm": @@ -336,7 +363,13 @@ def ad_revenue_report( total_pangle_revenue_yuan: float | None = None total_pangle_api_revenue_yuan: float | None = None if pangle_filterable: - pangle_aggs = ad_pangle_revenue.aggregate_by_date(db, date_from=date_from, date_to=date_to) + pangle_aggs = ad_pangle_revenue.aggregate_by_date( + db, + date_from=date_from, + date_to=date_to, + app_env=app_env, + our_code_ids=business_code_ids, + ) if pangle_aggs: by_date = {a["date"]: a for a in pangle_aggs} for d in daily: diff --git a/app/admin/routers/ad_revenue.py b/app/admin/routers/ad_revenue.py index 77685d4..c958e31 100644 --- a/app/admin/routers/ad_revenue.py +++ b/app/admin/routers/ad_revenue.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import date as _date -from typing import Annotated +from typing import Annotated, Literal from fastapi import APIRouter, Depends, HTTPException, Query @@ -63,6 +63,13 @@ def get_ad_revenue_report( "建议正式收益报表选 prod,避免测试应用的假 eCPM 污染收益合计/平均" ), ] = None, + revenue_scope: Annotated[ + Literal["business", "all"], + Query( + description="business=仅业务代码位(用于客户端与穿山甲同口径对账)/ " + "all=穿山甲应用全部代码位(包含广告测试等非业务曝光)" + ), + ] = "all", granularity: Annotated[ str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day") ] = "day", @@ -83,6 +90,7 @@ def get_ad_revenue_report( result = ad_revenue.ad_revenue_report( db, date_from=d_from.isoformat(), date_to=d_to.isoformat(), user_id=user_id, ad_type=ad_type, feed_scene=feed_scene, app_env=app_env, + revenue_scope=revenue_scope, granularity=granularity, limit=limit, offset=offset, sort=sort, ) return AdRevenueReportOut( diff --git a/app/repositories/ad_pangle_revenue.py b/app/repositories/ad_pangle_revenue.py index 51ad39e..78fde1f 100644 --- a/app/repositories/ad_pangle_revenue.py +++ b/app/repositories/ad_pangle_revenue.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +from collections.abc import Collection from typing import Any, TypedDict from sqlalchemy import func, select @@ -73,6 +74,7 @@ def aggregate_by_date( date_to: str, app_env: str | None = None, our_code_id: str | None = None, + our_code_ids: Collection[str] | None = None, ) -> list[PangleDateAgg]: """按日期汇总穿山甲收益(闭区间,北京时间),供报表趋势 + 合计。 @@ -97,6 +99,8 @@ def aggregate_by_date( stmt = stmt.where(AdPangleDailyRevenue.app_env == app_env) if our_code_id is not None: stmt = stmt.where(AdPangleDailyRevenue.our_code_id == our_code_id) + if our_code_ids is not None: + stmt = stmt.where(AdPangleDailyRevenue.our_code_id.in_(our_code_ids)) out: list[PangleDateAgg] = [] for report_date, rev, api_rev, imp in db.execute(stmt).all(): diff --git a/docs/api/admin/ad/admin-ad-revenue-report.md b/docs/api/admin/ad/admin-ad-revenue-report.md index b4c2b07..a1c1132 100644 --- a/docs/api/admin/ad/admin-ad-revenue-report.md +++ b/docs/api/admin/ad/admin-ad-revenue-report.md @@ -28,6 +28,8 @@ | `user_id` | int | 全部 | 只看某用户;不传=所有用户 | | `ad_type` | string | 全部 | `reward_video` / `feed` / `draw`;不传=全部类型 | | `feed_scene` | string | 全部 | `comparison`(比价)/ `coupon`(领券)/ `welfare`(福利);**全局筛选**,同时作用于明细 / 合计 / `daily`·`hourly` 趋势;不传=全部场景 | + | `app_env` | string | 全部 | `prod`=正式应用 / `test`=测试应用;同时过滤客户端预估与穿山甲汇总 | + | `revenue_scope` | string | `all` | `business`=仅业务代码位,用于同口径对账 / `all`=应用全部代码位,包含广告测试等非业务曝光 | | `granularity` | string | `day` | `day`=按天 / `hour`=按小时(聚合键再加北京时间小时 0–23);**区间>1 天建议用 day** | | `limit` | int(1~1000) | 500 | **每页条数**(分页大小);`total`/`total_*`/`daily`/`hourly` 按全量统计不受分页影响 | | `offset` | int(≥0) | 0 | 分页偏移(已跳过条数)=(页码−1)×`limit` | @@ -131,5 +133,5 @@ - **历史 Draw 不可拆**:迁移(Draw→普通信息流)前,Draw 发奖混在 `ad_feed_reward_record` 且无类型标记,金币侧统一记 `feed`;迁移后 Draw 不再产生新数据。展示侧 `ad_type` 由客户端上报区分,故 `draw` 桶基本为空。 - **来源字段从上线起齐全**:`app_env`/`our_code_id` 是本期新增列,历史记录为 NULL(报表来源列留空)。 - **逐条/明细的收益是预估**:`items[].revenue_yuan` 基于客户端上报的 eCPM 折算,非穿山甲后台结算值。 -- **穿山甲后台收益(汇总/趋势级)**:`total_pangle_revenue_yuan`(预估 `revenue`)与 `total_pangle_api_revenue_yuan`(收益Api `api_revenue`,更接近结算)来自穿山甲 **GroMore 数据 API**(`integrations/pangle_report` + `scripts/sync_pangle_revenue` 按天 T+1 拉取入 [ad_pangle_daily_revenue](../database/ad_pangle_daily_revenue.md))。穿山甲**不提供分用户/设备/类型/场景维度**(官方明确),最细到 日期×应用×代码位,故只用于汇总与按天趋势的对照,**不挂到逐条事件行**;且仅在全量视图(未按 user/类型/场景过滤)展示。配置见 `.env` 的 `PANGLE_REPORT_*`。 +- **穿山甲后台收益(汇总/趋势级)**:`total_pangle_revenue_yuan`(预估 `revenue`)与 `total_pangle_api_revenue_yuan`(收益Api `api_revenue`,更接近结算)来自穿山甲 **GroMore 数据 API**(`integrations/pangle_report` + `scripts/sync_pangle_revenue` 按天 T+1 拉取入 [ad_pangle_daily_revenue](../database/ad_pangle_daily_revenue.md))。穿山甲**不提供分用户/设备/类型/场景维度**(官方明确),最细到 日期×应用×代码位,故只用于汇总与按天趋势的对照,**不挂到逐条事件行**;且仅在未按 user/类型/场景过滤时展示。`app_env` 与 `revenue_scope` 会同时过滤客户端和穿山甲数据,其中 `business` 排除广告测试等非业务代码位。配置见 `.env` 的 `PANGLE_REPORT_*`。 - **对账聚合级 + 逐条下钻**:行级 `matched` 给出该组(用户×类型×应用×代码位)应发是否==实发;**展开 `records` 即可看该组逐条明细**(eCPM/因子1/份数/LT/因子2/应发/实发/一致)定位到具体记录。独立逐条审计接口 [admin-ad-coin-audit](./admin-ad-coin-audit.md) 仍保留(同一复算口径,可全局按场景/只看不符筛选)。 diff --git a/tests/test_admin_ad_revenue_scope.py b/tests/test_admin_ad_revenue_scope.py new file mode 100644 index 0000000..b01c92a --- /dev/null +++ b/tests/test_admin_ad_revenue_scope.py @@ -0,0 +1,129 @@ +"""广告收益报表的环境与业务代码位口径。""" +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import delete + +from app.admin.repositories import ad_revenue +from app.db.session import SessionLocal +from app.models.ad_ecpm import AdEcpmRecord +from app.models.ad_pangle_revenue import AdPangleDailyRevenue +from app.models.user import User + +REPORT_DATE = "2040-02-03" + + +def test_business_scope_filters_client_and_pangle_by_env_and_code(monkeypatch) -> None: + db = SessionLocal() + try: + user = User( + phone="18800009991", + username="29999999991", + register_channel="sms", + ) + db.add(user) + db.flush() + db.add_all([ + AdEcpmRecord( + user_id=user.id, ad_type="reward_video", ad_session_id="scope-prod-business", + app_env="prod", our_code_id="prod-reward", ecpm_raw="10000", + report_date=REPORT_DATE, created_at=datetime(2040, 2, 3, tzinfo=UTC), + ), + AdEcpmRecord( + user_id=user.id, ad_type="draw", ad_session_id="scope-prod-demo", + app_env="prod", our_code_id="prod-demo", ecpm_raw="20000", + report_date=REPORT_DATE, created_at=datetime(2040, 2, 3, tzinfo=UTC), + ), + AdEcpmRecord( + user_id=user.id, ad_type="draw", ad_session_id="scope-prod-known-business", + app_env="prod", our_code_id="104098712", ecpm_raw="40000", + report_date=REPORT_DATE, created_at=datetime(2040, 2, 3, tzinfo=UTC), + ), + AdEcpmRecord( + user_id=user.id, ad_type="reward_video", ad_session_id="scope-test-business", + app_env="test", our_code_id="104127529", ecpm_raw="30000", + report_date=REPORT_DATE, created_at=datetime(2040, 2, 3, tzinfo=UTC), + ), + AdPangleDailyRevenue( + report_date=REPORT_DATE, app_env="prod", our_code_id="prod-reward", + adn="", revenue_yuan=1.5, api_revenue_yuan=1.2, impressions=10, + ), + AdPangleDailyRevenue( + report_date=REPORT_DATE, app_env="prod", our_code_id="prod-demo", + adn="", revenue_yuan=8.0, api_revenue_yuan=7.0, impressions=40, + ), + AdPangleDailyRevenue( + report_date=REPORT_DATE, app_env="prod", our_code_id="104098712", + adn="", revenue_yuan=2.5, api_revenue_yuan=2.0, impressions=20, + ), + AdPangleDailyRevenue( + report_date=REPORT_DATE, app_env="test", our_code_id="104127529", + adn="", revenue_yuan=9.0, api_revenue_yuan=8.0, impressions=50, + ), + ]) + db.commit() + monkeypatch.setattr( + ad_revenue.app_config, + "get_ad_config", + lambda _db: { + "reward_code_id": "prod-reward", + "compare_draw_code_id": "prod-draw", + "coupon_draw_code_id": "prod-draw", + }, + ) + + business = ad_revenue.ad_revenue_report( + db, + date_from=REPORT_DATE, + date_to=REPORT_DATE, + app_env="prod", + revenue_scope="business", + ) + assert business["total_impressions"] == 2 + assert business["total_revenue_yuan"] == 0.5 + assert business["total_pangle_revenue_yuan"] == 4.0 + assert business["total_pangle_api_revenue_yuan"] == 3.2 + + all_codes = ad_revenue.ad_revenue_report( + db, + date_from=REPORT_DATE, + date_to=REPORT_DATE, + app_env="prod", + revenue_scope="all", + ) + assert all_codes["total_impressions"] == 3 + assert all_codes["total_revenue_yuan"] == 0.7 + assert all_codes["total_pangle_revenue_yuan"] == 12.0 + assert all_codes["total_pangle_api_revenue_yuan"] == 10.2 + + test_business = ad_revenue.ad_revenue_report( + db, + date_from=REPORT_DATE, + date_to=REPORT_DATE, + app_env="test", + revenue_scope="business", + ) + assert test_business["total_impressions"] == 1 + assert test_business["total_revenue_yuan"] == 0.3 + assert test_business["total_pangle_revenue_yuan"] == 9.0 + assert test_business["total_pangle_api_revenue_yuan"] == 8.0 + + all_env_business = ad_revenue.ad_revenue_report( + db, + date_from=REPORT_DATE, + date_to=REPORT_DATE, + app_env=None, + revenue_scope="business", + ) + assert all_env_business["total_impressions"] == 3 + assert all_env_business["total_revenue_yuan"] == 0.8 + assert all_env_business["total_pangle_revenue_yuan"] == 13.0 + assert all_env_business["total_pangle_api_revenue_yuan"] == 11.2 + finally: + db.rollback() + db.execute(delete(AdPangleDailyRevenue).where(AdPangleDailyRevenue.report_date == REPORT_DATE)) + db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.report_date == REPORT_DATE)) + db.execute(delete(User).where(User.phone == "18800009991")) + db.commit() + db.close() From 9286b82b6d1f79fc7eaa4c7b97ac4c1f83f1de0e Mon Sep 17 00:00:00 2001 From: linkeyu Date: Wed, 22 Jul 2026 10:42:24 +0800 Subject: [PATCH 08/42] =?UTF-8?q?feat(admin):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=AF=94=E4=BB=B7=E8=AE=B0=E5=BD=95=E6=A6=82=E8=A7=88=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E4=B8=8E=E5=8A=A0=E8=BD=BD=20(#152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 变更内容 - 新增比价记录概览聚合接口,主耗时均值及 P5/P50/P95/P99 仅统计 success - 比价列表支持按北京自然日过滤,概览和列表复用同一筛选口径 - 后端聚合成功率、成本、低价率及中途退出指标 - 修复单条样本分位数错误返回 0 的边界问题 - 增加成功/失败/退出及日期分页回归测试 ## 验证 - Python compileall - SQLite 聚合、日期边界和分页检查 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/152 Co-authored-by: linkeyu Co-committed-by: linkeyu --- app/admin/repositories/queries.py | 152 +++++++++++++++++++++---- app/admin/routers/comparison.py | 33 +++++- app/admin/schemas/comparison.py | 21 ++++ tests/test_comparison_admin_summary.py | 65 +++++++++++ 4 files changed, 251 insertions(+), 20 deletions(-) create mode 100644 tests/test_comparison_admin_summary.py diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index 7bbe522..26a36db 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -5,7 +5,7 @@ """ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, time, timedelta, timezone from zoneinfo import ZoneInfo from sqlalchemy import Select, asc, case, desc, func, or_, select @@ -209,6 +209,45 @@ def _attach_user_info(db: Session, records: list[ComparisonRecord | Feedback | P r.nickname = nick +def _comparison_conditions( + *, + user_id: int | None = None, + phone: str | None = None, + status: str | None = None, + business_type: str | None = None, + store: str | None = None, + product: str | None = None, + date_from: date | None = None, + date_to: date | None = None, +) -> list: + """比价列表与概览共用筛选条件;日期按北京自然日闭区间解释。""" + conditions = [] + if user_id is not None: + conditions.append(ComparisonRecord.user_id == user_id) + if phone: + conditions.append( + ComparisonRecord.user_id.in_( + select(User.id).where(User.phone.like(f"{phone}%")) + ) + ) + if status: + conditions.append(ComparisonRecord.status == status) + if business_type: + conditions.append(ComparisonRecord.business_type == business_type) + if store: + conditions.append(ComparisonRecord.store_name.like(f"%{store}%")) + if product: + conditions.append(ComparisonRecord.product_names.like(f"%{product}%")) + beijing = ZoneInfo("Asia/Shanghai") + if date_from is not None: + start_utc = datetime.combine(date_from, time.min, tzinfo=beijing).astimezone(timezone.utc) + conditions.append(ComparisonRecord.created_at >= start_utc) + if date_to is not None: + end_utc = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=beijing).astimezone(timezone.utc) + conditions.append(ComparisonRecord.created_at < end_utc) + return conditions + + def list_comparison_records( db: Session, *, @@ -218,30 +257,19 @@ def list_comparison_records( business_type: str | None = None, store: str | None = None, product: str | None = None, + date_from: date | None = None, + date_to: date | None = None, limit: int = 20, cursor: int | None = None, ) -> tuple[list[ComparisonRecord], int | None, int]: """admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛, store(店名)/product(商品名)子串模糊匹配,offset 分页(创建时间倒序、id 兜底)。 join User 取 phone/nickname 瞬态挂记录上。""" - stmt = select(ComparisonRecord) - if user_id is not None: - stmt = stmt.where(ComparisonRecord.user_id == user_id) - if phone: - stmt = stmt.where( - ComparisonRecord.user_id.in_( - select(User.id).where(User.phone.like(f"{phone}%")) - ) - ) - if status: - stmt = stmt.where(ComparisonRecord.status == status) - if business_type: - stmt = stmt.where(ComparisonRecord.business_type == business_type) - if store: - stmt = stmt.where(ComparisonRecord.store_name.like(f"%{store}%")) - if product: - # 商品名搜 product_names 派生文本列(非 items JSON:SQLite 下 JSON 中文被转义无法直接 LIKE)。 - stmt = stmt.where(ComparisonRecord.product_names.like(f"%{product}%")) + conditions = _comparison_conditions( + user_id=user_id, phone=phone, status=status, business_type=business_type, + store=store, product=product, date_from=date_from, date_to=date_to, + ) + stmt = select(ComparisonRecord).where(*conditions) items, next_cursor, total = offset_paginate( db, stmt, (desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)), @@ -256,6 +284,92 @@ def list_comparison_records( return items, next_cursor, total +def _comparison_percentile(sorted_values: list[int], q: float) -> int | None: + """线性插值分位数(非负毫秒值四舍五入;单条数据返回自身)。""" + if not sorted_values: + return None + if len(sorted_values) == 1: + return sorted_values[0] + index = (len(sorted_values) - 1) * q + lower = int(index) + upper = min(lower + 1, len(sorted_values) - 1) + value = sorted_values[lower] * (upper - index) + sorted_values[upper] * (index - lower) + return int(value + 0.5) + + +def comparison_records_summary( + db: Session, + *, + user_id: int | None = None, + phone: str | None = None, + status: str | None = None, + business_type: str | None = None, + store: str | None = None, + product: str | None = None, + date_from: date | None = None, + date_to: date | None = None, +) -> dict: + """比价记录页概览聚合;主耗时均值及分位数只取成功记录。""" + conditions = _comparison_conditions( + user_id=user_id, phone=phone, status=status, business_type=business_type, + store=store, product=product, date_from=date_from, date_to=date_to, + ) + row = db.execute( + select( + func.count(ComparisonRecord.id), + func.sum(case((ComparisonRecord.status.in_(("success", "failed")), 1), else_=0)), + func.sum(case((ComparisonRecord.status == "success", 1), else_=0)), + func.avg(ComparisonRecord.llm_cost_yuan), + func.sum(case(( + (ComparisonRecord.status == "success") + & (ComparisonRecord.saved_amount_cents > 0), 1 + ), else_=0)), + func.sum(case((ComparisonRecord.status == "cancelled", 1), else_=0)), + ).where(*conditions) + ).one() + started = int(row[0] or 0) + completed = int(row[1] or 0) + success = int(row[2] or 0) + lower_price = int(row[4] or 0) + cancelled = int(row[5] or 0) + success_durations = sorted(db.execute( + select(ComparisonRecord.total_ms).where( + *conditions, + ComparisonRecord.status == "success", + ComparisonRecord.total_ms.is_not(None), + ) + ).scalars().all()) + cancelled_durations = sorted(db.execute( + select(ComparisonRecord.total_ms).where( + *conditions, + ComparisonRecord.status == "cancelled", + ComparisonRecord.total_ms.is_not(None), + ) + ).scalars().all()) + success_rate_denominator = started - cancelled + return { + "started": started, + "completed": completed, + "success": success, + "success_rate": success / success_rate_denominator if success_rate_denominator else None, + "avg_token_cost": float(row[3]) if row[3] is not None else None, + "lower_price_rate": lower_price / success if success else None, + "avg_duration_ms": ( + int(sum(success_durations) / len(success_durations) + 0.5) + if success_durations else None + ), + "p5_duration_ms": _comparison_percentile(success_durations, 0.05), + "p50_duration_ms": _comparison_percentile(success_durations, 0.5), + "p95_duration_ms": _comparison_percentile(success_durations, 0.95), + "p99_duration_ms": _comparison_percentile(success_durations, 0.99), + "cancelled": cancelled, + "cancelled_rate": cancelled / started if started else None, + "cancelled_p5_ms": _comparison_percentile(cancelled_durations, 0.05), + "cancelled_p50_ms": _comparison_percentile(cancelled_durations, 0.5), + "cancelled_p95_ms": _comparison_percentile(cancelled_durations, 0.95), + } + + def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None: """admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。""" rec = db.get(ComparisonRecord, record_id) diff --git a/app/admin/routers/comparison.py b/app/admin/routers/comparison.py index 38dc242..8876dc0 100644 --- a/app/admin/routers/comparison.py +++ b/app/admin/routers/comparison.py @@ -5,6 +5,7 @@ trace_url 无条件下发——admin 是内部 debug 工具,不走 C 端 user.de """ from __future__ import annotations +from datetime import date from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query @@ -12,7 +13,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query from app.admin.deps import AdminDb, get_current_admin from app.admin.repositories import queries from app.admin.schemas.common import CursorPage -from app.admin.schemas.comparison import AdminComparisonDetail, AdminComparisonListItem +from app.admin.schemas.comparison import ( + AdminComparisonDetail, + AdminComparisonListItem, + AdminComparisonSummary, +) router = APIRouter( prefix="/admin/api/comparison-records", @@ -34,12 +39,15 @@ def list_comparison_records( business_type: Annotated[str | None, Query()] = None, store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None, product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None, + date_from: Annotated[date | None, Query(description="北京自然日起始日")] = None, + date_to: Annotated[date | None, Query(description="北京自然日结束日")] = None, limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[AdminComparisonListItem]: items, next_cursor, total = queries.list_comparison_records( db, user_id=user_id, phone=phone, status=status, business_type=business_type, store=store, product=product, + date_from=date_from, date_to=date_to, limit=limit, cursor=cursor, ) return CursorPage( @@ -49,6 +57,29 @@ def list_comparison_records( ) +@router.get( + "/summary", + response_model=AdminComparisonSummary, + summary="比价记录概览聚合", +) +def comparison_records_summary( + db: AdminDb, + user_id: Annotated[int | None, Query()] = None, + phone: Annotated[str | None, Query(description="手机号前缀")] = None, + status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None, + business_type: Annotated[str | None, Query()] = None, + store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None, + product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None, + date_from: Annotated[date | None, Query(description="北京自然日起始日")] = None, + date_to: Annotated[date | None, Query(description="北京自然日结束日")] = None, +) -> AdminComparisonSummary: + return AdminComparisonSummary(**queries.comparison_records_summary( + db, user_id=user_id, phone=phone, status=status, + business_type=business_type, store=store, product=product, + date_from=date_from, date_to=date_to, + )) + + @router.get( "/{record_id}", response_model=AdminComparisonDetail, diff --git a/app/admin/schemas/comparison.py b/app/admin/schemas/comparison.py index ec1d3aa..13c49ff 100644 --- a/app/admin/schemas/comparison.py +++ b/app/admin/schemas/comparison.py @@ -47,6 +47,27 @@ class AdminComparisonListItem(BaseModel): created_at: datetime +class AdminComparisonSummary(BaseModel): + """比价记录页概览;主耗时指标仅统计 status=success。""" + + started: int + completed: int + success: int + success_rate: float | None = None + avg_token_cost: float | None = None + lower_price_rate: float | None = None + avg_duration_ms: int | None = None + p5_duration_ms: int | None = None + p50_duration_ms: int | None = None + p95_duration_ms: int | None = None + p99_duration_ms: int | None = None + cancelled: int + cancelled_rate: float | None = None + cancelled_p5_ms: int | None = None + cancelled_p50_ms: int | None = None + cancelled_p95_ms: int | None = None + + class AdminComparisonDetail(AdminComparisonListItem): """详情:概要 + 全量明细(逐平台对比 / LLM 每次调用 / 原始 payload)。""" diff --git a/tests/test_comparison_admin_summary.py b/tests/test_comparison_admin_summary.py new file mode 100644 index 0000000..5006fd6 --- /dev/null +++ b/tests/test_comparison_admin_summary.py @@ -0,0 +1,65 @@ +"""比价记录页后端分页与概览聚合。""" +from __future__ import annotations + +from datetime import UTC, date, datetime + +import pytest + +from app.admin.repositories import queries +from app.db.session import SessionLocal +from app.models.comparison import ComparisonRecord + + +def test_summary_uses_only_success_durations_and_filters_beijing_date() -> None: + db = SessionLocal() + try: + rows = [ + ("summary-success-a", "success", 1000, 1.0, 100), + ("summary-success-b", "success", 3000, 2.0, 0), + ("summary-failed", "failed", 100_000, 3.0, 0), + ("summary-cancelled", "cancelled", 5000, 4.0, 0), + ] + for trace_id, status, total_ms, cost, saved in rows: + db.add(ComparisonRecord( + trace_id=trace_id, + status=status, + total_ms=total_ms, + llm_cost_yuan=cost, + saved_amount_cents=saved, + created_at=datetime(2038, 1, 15, 12, tzinfo=UTC), + )) + db.add(ComparisonRecord( + trace_id="summary-outside-day", + status="success", + total_ms=999_999, + created_at=datetime(2038, 1, 16, 16, tzinfo=UTC), + )) + db.flush() + + summary = queries.comparison_records_summary( + db, date_from=date(2038, 1, 15), date_to=date(2038, 1, 15) + ) + + assert summary["started"] == 4 + assert summary["completed"] == 3 + assert summary["success"] == 2 + assert summary["success_rate"] == pytest.approx(2 / 3) + assert summary["avg_token_cost"] == pytest.approx(2.5) + assert summary["lower_price_rate"] == 0.5 + assert summary["avg_duration_ms"] == 2000 + assert summary["p5_duration_ms"] == 1100 + assert summary["p50_duration_ms"] == 2000 + assert summary["p95_duration_ms"] == 2900 + assert summary["p99_duration_ms"] == 2980 + assert summary["cancelled"] == 1 + assert summary["cancelled_rate"] == 0.25 + assert summary["cancelled_p50_ms"] == 5000 + + items, _next_cursor, total = queries.list_comparison_records( + db, date_from=date(2038, 1, 15), date_to=date(2038, 1, 15), limit=20 + ) + assert total == 4 + assert {item.trace_id for item in items} == {row[0] for row in rows} + finally: + db.rollback() + db.close() From 73970087ffadbce994f1e543e5bda37130a45895 Mon Sep 17 00:00:00 2001 From: zuochenyong Date: Wed, 22 Jul 2026 10:53:21 +0800 Subject: [PATCH 09/42] =?UTF-8?q?feat(ad):=20=E8=86=A8=E8=83=80=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E6=94=B9=E7=94=A8=E6=9C=8D=E5=8A=A1=E7=AB=AF=E6=9D=83?= =?UTF-8?q?=E5=A8=81=E9=87=91=E9=A2=9D=20+=20=E6=9C=AC=E8=BD=AE=E7=B4=AF?= =?UTF-8?q?=E8=AE=A1=E5=8F=A3=E5=BE=84,=E4=B8=8B=E7=BA=BF=20signin=5Fboost?= =?UTF-8?q?=20(#154)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: guke Co-authored-by: 左辰勇 Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/154 Co-authored-by: zuochenyong Co-committed-by: zuochenyong --- alembic/versions/ad_reward_boost_round_id.py | 47 ++++ alembic/versions/drop_signin_boost_record.py | 56 ++++ alembic/versions/merge_signin_boost_main.py | 32 +++ app/admin/repositories/stats.py | 11 +- app/api/v1/ad.py | 175 ++++++------ app/api/v1/signin.py | 47 +--- app/core/config_schema.py | 5 - app/core/rewards.py | 6 +- app/models/__init__.py | 2 +- app/models/ad_reward.py | 10 +- app/models/signin.py | 32 +-- app/repositories/ad_reward.py | 75 +++++- app/repositories/signin.py | 81 +----- app/schemas/ad.py | 40 ++- app/schemas/welfare.py | 10 - docs/api/README.md | 2 +- docs/api/ad/ad-pangle-callback.md | 12 +- docs/api/ad/ad-reward-result.md | 66 +++++ docs/api/ad/ad-test-grant.md | 5 +- docs/api/admin/admin-stats-overview.md | 4 +- docs/api/signin/signin-boost.md | 33 --- docs/database/OVERVIEW.md | 13 +- docs/database/README.md | 1 - docs/database/ad_reward_record.md | 13 +- docs/database/app_config.md | 2 +- docs/database/coin_transaction.md | 4 +- docs/database/signin_boost_record.md | 22 -- docs/database/user.md | 2 +- docs/database/数据表字典-产品参考.md | 15 +- docs/guides/看广告赚金币上线清单.md | 2 +- scripts/reset_onboarding.py | 138 ++++++++++ scripts/reset_signin_today.py | 218 +++++++++++++++ tests/test_ad_reward.py | 269 +++++++++++++++++++ tests/test_welfare.py | 43 --- 34 files changed, 1082 insertions(+), 411 deletions(-) create mode 100644 alembic/versions/ad_reward_boost_round_id.py create mode 100644 alembic/versions/drop_signin_boost_record.py create mode 100644 alembic/versions/merge_signin_boost_main.py create mode 100644 docs/api/ad/ad-reward-result.md delete mode 100644 docs/api/signin/signin-boost.md delete mode 100644 docs/database/signin_boost_record.md create mode 100644 scripts/reset_onboarding.py create mode 100644 scripts/reset_signin_today.py diff --git a/alembic/versions/ad_reward_boost_round_id.py b/alembic/versions/ad_reward_boost_round_id.py new file mode 100644 index 0000000..d63afa6 --- /dev/null +++ b/alembic/versions/ad_reward_boost_round_id.py @@ -0,0 +1,47 @@ +"""ad_reward_record.boost_round_id(金币膨胀「本轮累计」标签) + +看完一条激励视频后客户端要弹「本轮累计获得 N 金币」,N 必须等于这一轮实际到账之和(否则用户 +会认为少发了钱)。单条到账额 reward-result 已经能给,但「一轮」的边界只有客户端知道(点「放弃 +赚钱」才算结束),客户端自己累加又会在进程被杀后丢失。 + +解法:客户端把轮次 id 随 mediaExtra 透传,穿山甲 S2S 原样带回,发奖时打在记录上; +reward-result 按 (user_id, boost_round_id) 对 granted 记录求和返回 round_coin。 + +本列是**纯标签**:不参与发奖判定,发多少/发不发完全不受影响。客户端就算一直复用同一个 id, +也只是把展示数字滚大,不产生任何新入账(求和的是已发生的发奖记录),无资损风险。 + +Revision ID: ad_reward_boost_round_id +Revises: comparison_llm_cost +Create Date: 2026-07-20 +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "ad_reward_boost_round_id" +down_revision: str | Sequence[str] | None = "comparison_llm_cost" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # 可空、无默认:SQLite 原生支持 ADD COLUMN,不需要 batch_alter_table(同 comparison_llm_cost)。 + # 存量行留 NULL = 「不属于任何一轮」,求和时天然不参与,老客户端行为不变。 + op.add_column( + "ad_reward_record", + sa.Column("boost_round_id", sa.String(length=64), nullable=True), + ) + # 求和恒带 user_id(轮 id 是客户端生成的,不能跨用户信任),故建复合索引而非单列 + op.create_index( + "ix_ad_reward_user_boost_round", + "ad_reward_record", + ["user_id", "boost_round_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_ad_reward_user_boost_round", table_name="ad_reward_record") + op.drop_column("ad_reward_record", "boost_round_id") diff --git a/alembic/versions/drop_signin_boost_record.py b/alembic/versions/drop_signin_boost_record.py new file mode 100644 index 0000000..0447661 --- /dev/null +++ b/alembic/versions/drop_signin_boost_record.py @@ -0,0 +1,56 @@ +"""下线签到膨胀:drop signin_boost_record + +产品 2026-07 确认「固定 3000 金币的签到膨胀」从来不是设计内的口径 —— 奖励只有「签到」和 +「看视频」两种。签到弹窗里的「看广告膨胀」改与福利页看视频走同一条 reward_video 路径 +(按 eCPM 公式发,记在 ad_reward_record),signin_boost 场景整体摘除。 + +⚠️ **只 drop 这张表,不动 coin_transaction**:`biz_type='signin_boost'` 的金币流水是真发过的 +钱,账必须留得住(admin 大盘的 signin_boost_coin_total / signin_boost_watch_count 改为从 +coin_transaction 统计,继续能查回历史)。本表只是「哪天膨胀过」的业务留痕,金额与去向都能 +从流水还原,drop 掉不影响对账。 + +downgrade 只重建空表结构,**不恢复数据** —— 真要回滚得先从备份捞行。 + +Revision ID: drop_signin_boost_record +Revises: ad_reward_boost_round_id +Create Date: 2026-07-20 +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "drop_signin_boost_record" +down_revision: str | Sequence[str] | None = "ad_reward_boost_round_id" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.batch_alter_table("signin_boost_record", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_signin_boost_record_user_id")) + op.drop_table("signin_boost_record") + + +def downgrade() -> None: + # 只还结构不还数据(见模块 docstring) + op.create_table( + "signin_boost_record", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("signin_date", sa.Date(), nullable=False), + sa.Column("coin_awarded", sa.Integer(), nullable=False), + sa.Column("ad_ref_id", sa.String(length=64), nullable=True), + sa.Column( + "created_at", sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False, + ), + sa.ForeignKeyConstraint(["user_id"], ["user.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "signin_date", name="uq_signin_boost_user_date"), + ) + with op.batch_alter_table("signin_boost_record", schema=None) as batch_op: + batch_op.create_index( + batch_op.f("ix_signin_boost_record_user_id"), ["user_id"], unique=False + ) diff --git a/alembic/versions/merge_signin_boost_main.py b/alembic/versions/merge_signin_boost_main.py new file mode 100644 index 0000000..8838a4e --- /dev/null +++ b/alembic/versions/merge_signin_boost_main.py @@ -0,0 +1,32 @@ +"""合并两个 alembic head:drop_signin_boost_record(本分支)+ merge_active_phone(main)。 + +两条线同从 comparison_llm_cost 分叉——本分支的 ad_reward_boost_round_id → drop_signin_boost_record +走「金币膨胀本轮累计 + 下线签到膨胀」;main 侧的 phone_rebind_log / analytics_active_idx 两支已由 +merge_active_phone 收敛。88f2380 把 main 合进本分支后,两条迁移线在 git 上汇合了、在 alembic 图上 +却没有,于是 `alembic upgrade head`(单数)报 "Multiple head revisions are present"——按 CLAUDE.md +run.sh 启动即自动迁移,app server 会直接起不来。 + +本迁移仅把二者收敛成单 head;**不含任何表结构 / 数据改动**(纯 merge)。 + +Revision ID: merge_signin_boost_main +Revises: drop_signin_boost_record, merge_active_phone +Create Date: 2026-07-21 00:00:00.000000 +""" + +from collections.abc import Sequence + +revision: str = "merge_signin_boost_main" +down_revision: str | Sequence[str] | None = ( + "drop_signin_boost_record", + "merge_active_phone", +) +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """纯合并 head,无 schema 改动。""" + + +def downgrade() -> None: + """拆回两个 head,无 schema 改动。""" diff --git a/app/admin/repositories/stats.py b/app/admin/repositories/stats.py index fbc3c7c..fc6a83a 100644 --- a/app/admin/repositories/stats.py +++ b/app/admin/repositories/stats.py @@ -25,7 +25,7 @@ from app.models.coupon_state import ( from app.models.cps_order import CpsOrder from app.models.feedback import Feedback from app.models.savings import SavingsRecord -from app.models.signin import SigninBoostRecord, SigninRecord +from app.models.signin import SigninRecord from app.models.user import User from app.models.wallet import CoinTransaction, WithdrawOrder @@ -678,7 +678,14 @@ def dashboard_overview( CoinTransaction.amount > 0, CoinTransaction.biz_type == "signin_boost", ), - "signin_boost_watch_count": _count(SigninBoostRecord), + # 签到膨胀 2026-07 已下线,signin_boost_record 表随之 drop。这两项保留为**历史口径** + # (钱是真发过的,账要能查回)。次数改数金币流水:一次膨胀 = 一笔 signin_boost 流水, + # 与原来数 signin_boost_record 行数等价。 + "signin_boost_watch_count": _count( + CoinTransaction, + CoinTransaction.biz_type == "signin_boost", + CoinTransaction.amount > 0, + ), }, "cash": { "withdraw_success_cents": _sum( diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index cff56ee..bf98e80 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -3,6 +3,8 @@ 路由前缀 `/api/v1/ad`: GET /pangle-callback 穿山甲 S2S 发奖回调(**无 JWT,靠验签**),穿山甲服务器调 GET /reward-status 客户端查今日看广告发奖进度(Bearer) + GET /reward-result/{ad_session_id} + 客户端按会话查本次广告实发金币(Bearer,只读,弹窗金额用) 发奖走服务端:激励视频播完穿山甲回调本接口,验签通过后幂等发金币。客户端只负责 看完后刷新余额,不参与发奖,被破解也刷不到钱。 @@ -13,7 +15,7 @@ import json import logging import uuid -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Path, Request, status from app.api.deps import CurrentUser, DbSession from app.core import rewards @@ -25,8 +27,8 @@ from app.repositories import ad_feed_reward as crud_feed from app.repositories import ad_reward as crud_ad from app.repositories import ad_watch as crud_watch from app.repositories import app_config -from app.repositories import signin as crud_signin from app.schemas.ad import ( + AdRewardResultOut, AdRewardStatusOut, EcpmReportIn, EcpmReportOut, @@ -52,11 +54,14 @@ REASON_BAD_PARAMS = 1 # 验签过但缺 trans_id / user_id 非数字 REASON_UNKNOWN_USER = 2 # user_id 不存在(可能伪造) REWARD_SCENE_REWARD_VIDEO = "reward_video" -REWARD_SCENE_SIGNIN_BOOST = "signin_boost" # 提现看视频:看完才能提现的「硬门槛」广告,**不发金币**,只记一条幂等记录(收益由 eCPM 上报口径 # ad_type="withdrawal_video" 单独统计)。故意不放进 SUPPORTED_REWARD_SCENES——它不走发币分支。 REWARD_SCENE_WITHDRAWAL_AD = "withdrawal_ad" -SUPPORTED_REWARD_SCENES = {REWARD_SCENE_REWARD_VIDEO, REWARD_SCENE_SIGNIN_BOOST} +# 2026-07 下线 signin_boost(签到膨胀):它按固定 3000 金币发,与广告实际收益脱钩,产品确认 +# 从来不是设计内的口径。签到弹窗里的「看广告膨胀」现在与福利页看视频走同一条 reward_video +# 路径(按 eCPM 公式发),奖励只剩「签到」+「看视频」两种。历史发币流水(coin_transaction +# .biz_type='signin_boost')保留不动——钱是真发过的,账必须留。 +SUPPORTED_REWARD_SCENES = {REWARD_SCENE_REWARD_VIDEO} def _parse_extra(raw_extra: str | None) -> dict[str, str]: @@ -118,6 +123,11 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut: extra.update(_parse_extra(params.get(extra_key))) reward_scene = extra.get("reward_scene") or REWARD_SCENE_REWARD_VIDEO ad_session_id = extra.get("ad_session_id") + # 「这条广告属于哪一轮膨胀」。纯标签:不参与发奖判定,只让 reward-result 能把同一轮求和成 + # 弹窗要显示的累计值(见 crud_ad.round_coin_total)。老客户端不带 → NULL → 累计值返 null。 + boost_round_id = (extra.get("boost_round_id") or None) + if boost_round_id is not None: + boost_round_id = boost_round_id[:64] ecpm = params.get("ecpm") # 环境隔离:激励视频 mediaExtra 里带「这次观看属于哪个后端环境」(srv_env=dev/prod,客户端按 @@ -169,50 +179,11 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut: user_id, trans_id, reward_scene, ) return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS) - if reward_scene == REWARD_SCENE_SIGNIN_BOOST: - try: - boost, _balance = crud_signin.boost_today_signin( - db, user_id, ad_ref_id=trans_id, commit=False - ) - except crud_signin.NotSignedTodayError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user_id, trans_id, coin=0, reward_scene=reward_scene, - ad_session_id=ad_session_id, ecpm=ecpm, - reward_name=params.get("reward_name"), raw=raw[:1024], - status="not_signed", - ) - except crud_signin.AlreadyBoostedError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user_id, trans_id, coin=0, reward_scene=reward_scene, - ad_session_id=ad_session_id, ecpm=ecpm, - reward_name=params.get("reward_name"), raw=raw[:1024], - status="already_boosted", - ) - except crud_signin.LastCycleDayBoostBlockedError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user_id, trans_id, coin=0, reward_scene=reward_scene, - ad_session_id=ad_session_id, ecpm=ecpm, - reward_name=params.get("reward_name"), raw=raw[:1024], - status="last_day", - ) - else: - rec = crud_ad.record_external_reward( - db, user_id, trans_id, coin=boost.coin_awarded, - reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm=ecpm, - reward_name=params.get("reward_name"), raw=raw[:1024], - commit=False, - ) - db.commit() - db.refresh(rec) - else: - rec = crud_ad.grant_ad_reward( - db, user_id, trans_id, ecpm=ecpm, ad_session_id=ad_session_id, - reward_scene=REWARD_SCENE_REWARD_VIDEO, - reward_name=params.get("reward_name"), raw=raw[:1024], - ) + rec = crud_ad.grant_ad_reward( + db, user_id, trans_id, ecpm=ecpm, ad_session_id=ad_session_id, + reward_scene=REWARD_SCENE_REWARD_VIDEO, boost_round_id=boost_round_id, + reward_name=params.get("reward_name"), raw=raw[:1024], + ) except crud_ad.UnknownUserError: logger.warning("pangle callback unknown user_id=%d trans_id=%s", user_id, trans_id) return PangleCallbackOut(is_verify=False, reason=REASON_UNKNOWN_USER) @@ -242,6 +213,46 @@ def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut: ) +@router.get( + "/reward-result/{ad_session_id}", + response_model=AdRewardResultOut, + summary="按 ad_session_id 查本次广告的权威发奖结果", + dependencies=[Depends(rate_limit(120, 60, "ad-reward-result"))], +) +def reward_result( + user: CurrentUser, + db: DbSession, + ad_session_id: str = Path(..., min_length=8, max_length=64, description="本次广告会话 id"), +) -> AdRewardResultOut: + """客户端看完激励视频后轮询本接口拿**本次实发金币 + 本轮累计**用于弹窗,不再用余额差 / + coin_per_ad 估算(修「弹窗数值与真实金币对不上」)。 + + round_coin 是「恭喜累计获得奖励」弹窗真正显示的数:本轮(= 客户端的 boost_round_id)所有 + granted 记录之和。由服务端求和而不是客户端自己累加——客户端进程被杀/重建后本地累计会丢, + 发奖记录不会。取不到轮 id(pending / 老客户端 / extra 丢失)时为 null,客户端退回显示单条。 + + S2S 回调异步:查不到记录 = 回调还没到 → 返 200 + status='pending' 让客户端继续重试, + **不返 404**(404 只表示路由不存在)。纯只读:发奖仍只由验签过的 S2S 回调完成, + 这里不写库、不产生任何奖励,被刷也只是查自己的记录。 + """ + rec = crud_ad.find_by_session(db, user.id, ad_session_id) + if rec is None: + # 连记录都没有 → 不知道属于哪一轮,round_coin 一并为 null(不是 0,0 会被当成"本轮没赚到") + return AdRewardResultOut( + ad_session_id=ad_session_id, status="pending", coin=None, round_coin=None, + ) + # 本条不是 granted 时**仍返本轮累计**(这条按 0 计):第 3 条撞每日上限那下,客户端的限额 + # toast 要显示的是前两条已到账的总额,不是空。 + round_coin = ( + crud_ad.round_coin_total(db, user.id, rec.boost_round_id) + if rec.boost_round_id + else None + ) + return AdRewardResultOut( + ad_session_id=ad_session_id, status=rec.status, coin=rec.coin, round_coin=round_coin, + ) + + @router.post( "/watch-report", response_model=WatchReportOut, @@ -329,55 +340,27 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N if reward_scene not in SUPPORTED_REWARD_SCENES: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="bad reward_scene") - # 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限/今日膨胀一次) + # 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限) trans_id = f"test-{user.id}-{uuid.uuid4().hex}" - if reward_scene == REWARD_SCENE_SIGNIN_BOOST: - try: - boost, _balance = crud_signin.boost_today_signin( - db, user.id, ad_ref_id=trans_id, commit=False - ) - except crud_signin.NotSignedTodayError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user.id, trans_id, coin=0, reward_scene=reward_scene, - raw="client debug test-grant signin_boost", status="not_signed", - ) - except crud_signin.AlreadyBoostedError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user.id, trans_id, coin=0, reward_scene=reward_scene, - raw="client debug test-grant signin_boost", status="already_boosted", - ) - except crud_signin.LastCycleDayBoostBlockedError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user.id, trans_id, coin=0, reward_scene=reward_scene, - raw="client debug test-grant signin_boost", status="last_day", - ) - else: - rec = crud_ad.record_external_reward( - db, user.id, trans_id, coin=boost.coin_awarded, - reward_scene=reward_scene, reward_name="测试签到膨胀", - raw="client debug test-grant signin_boost", commit=False, - ) - db.commit() - db.refresh(rec) - else: - # 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式); - # 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。 - ad_session_id = payload.ad_session_id if payload is not None else None - ecpm_val = "200" - if ad_session_id: - ecpm_rec = crud_ecpm.find_by_session(db, user_id=user.id, ad_session_id=ad_session_id) - if ecpm_rec is not None and rewards.parse_ecpm_fen(ecpm_rec.ecpm_raw) > 0: - ecpm_val = ecpm_rec.ecpm_raw - try: - rec = crud_ad.grant_ad_reward( - db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id, - reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}", - ) - except crud_ad.UnknownUserError as e: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e + # 正式链路的轮次 id 走 S2S 的 mediaExtra;本接口不经 S2S,只能由 body 补,否则 debug 包 + # 的 reward-result 恒返 round_coin=null,「弹窗 40 → 60」那套累计验收在本地跑不起来。 + boost_round_id = (payload.boost_round_id if payload is not None else None) or None + # 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式); + # 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。 + ad_session_id = payload.ad_session_id if payload is not None else None + ecpm_val = "200" + if ad_session_id: + ecpm_rec = crud_ecpm.find_by_session(db, user_id=user.id, ad_session_id=ad_session_id) + if ecpm_rec is not None and rewards.parse_ecpm_fen(ecpm_rec.ecpm_raw) > 0: + ecpm_val = ecpm_rec.ecpm_raw + try: + rec = crud_ad.grant_ad_reward( + db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id, + boost_round_id=boost_round_id, + reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}", + ) + except crud_ad.UnknownUserError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e (used, limit, coin_per, round_count, cooldown_until, _watched, _watch_limit) = crud_ad.today_status(db, user.id) diff --git a/app/api/v1/signin.py b/app/api/v1/signin.py index 45f3646..02848e0 100644 --- a/app/api/v1/signin.py +++ b/app/api/v1/signin.py @@ -1,9 +1,12 @@ """签到 endpoint。 路由前缀 `/api/v1/signin`: - GET /status 今日签到状态 + 14 天档位 + GET /status 今日签到状态 + 7 天档位 POST / 执行今日签到 - POST /boost 签到后看广告膨胀金币 + +2026-07 下线 `POST /boost`(签到膨胀):它按固定 3000 金币补发、与广告实际收益脱钩。 +签到弹窗里的「看广告膨胀」改与福利页看视频走同一条 reward_video 路径(按 eCPM 发, +`/ad/pangle-callback` → `/ad/reward-result` 取金额),奖励只剩「签到」+「看视频」两种。 """ from __future__ import annotations @@ -12,15 +15,8 @@ import logging from fastapi import APIRouter, HTTPException, status from app.api.deps import CurrentUser, DbSession -from app.repositories import ad_reward as crud_ad from app.repositories import signin as crud_signin -from app.repositories import wallet as crud_wallet -from app.schemas.welfare import ( - SigninBoostRequest, - SigninBoostResultOut, - SigninResultOut, - SigninStatusOut, -) +from app.schemas.welfare import SigninResultOut, SigninStatusOut logger = logging.getLogger("shagua.signin") @@ -50,34 +46,3 @@ def do_signin(user: CurrentUser, db: DbSession) -> SigninResultOut: streak=record.streak, coin_balance=balance, ) - - -@router.post("/boost", response_model=SigninBoostResultOut, summary="签到后看广告膨胀金币") -def boost_signin( - payload: SigninBoostRequest, user: CurrentUser, db: DbSession -) -> SigninBoostResultOut: - if not payload.ad_ref_id: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="ad reward required") - ad_rec = crud_ad.find_by_trans(db, payload.ad_ref_id) - if ( - ad_rec is None - or ad_rec.user_id != user.id - or ad_rec.reward_scene != "signin_boost" - or ad_rec.status != "granted" - ): - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="ad reward not verified") - record = crud_signin.boost_by_ad_ref(db, user.id, payload.ad_ref_id) - if record is None: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="signin boost not granted") - acc = crud_wallet.get_or_create_account(db, user.id) - balance = acc.coin_balance - - logger.info( - "signin boost ok user_id=%d date=%s coin=%d", - user.id, record.signin_date, record.coin_awarded, - ) - return SigninBoostResultOut( - coin_awarded=record.coin_awarded, - coin_balance=balance, - signin_date=record.signin_date.isoformat(), - ) diff --git a/app/core/config_schema.py b/app/core/config_schema.py index 9edb043..a46e96f 100644 --- a/app/core/config_schema.py +++ b/app/core/config_schema.py @@ -66,11 +66,6 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { "default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)", "group": "看广告", "type": "int", "help": "点击退出广告后,下次点击观看前的冷却时间,默认 3 秒。", }, - "signin_boost_coin": { - "default": r.SIGNIN_BOOST_COIN, "label": "签到膨胀固定金币", - "group": "签到", "type": "int", - "help": "Day1-Day6 签到后看完激励视频额外发放的固定金币;Day7 不展示也不允许膨胀。", - }, "comparing_ad_enabled": { "default": True, "label": "比价/领券期信息流广告", "group": "看广告", "type": "bool", "hidden": True, diff --git a/app/core/rewards.py b/app/core/rewards.py index eccdc25..284dd97 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -239,8 +239,8 @@ def calculate_ad_reward_coin(ecpm: str | int | float | None, count_after_this: i return max(0, round(yuan * COIN_PER_YUAN)) -# 签到看广告膨胀:S2S 固定补发(原型 2026-06 由 2000 提到 3000,对应 CTA「看广告最高膨胀至3000金币」)。 -SIGNIN_BOOST_COIN: int = 3000 +# 签到膨胀(SIGNIN_BOOST_COIN,固定 3000)已于 2026-07 下线:它与广告实际收益脱钩,产品确认 +# 非设计内口径。签到弹窗的「看广告膨胀」现与福利页看视频同走 calculate_ad_reward_coin。 # ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)===== @@ -334,5 +334,3 @@ def get_ad_cooldown_sec(db) -> int: # noqa: ANN001 return int(_cfg(db, "ad_cooldown_sec")) -def get_signin_boost_coin(db) -> int: # noqa: ANN001 - return int(_cfg(db, "signin_boost_coin")) diff --git a/app/models/__init__.py b/app/models/__init__.py index f2c2fc0..c9a046b 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -42,7 +42,7 @@ from app.models.ops_stat_config import OpsStatConfig # noqa: F401 from app.models.price_observation import PriceObservation # noqa: F401 from app.models.price_report import PriceReport # noqa: F401 from app.models.savings import SavingsRecord # noqa: F401 -from app.models.signin import SigninBoostRecord, SigninRecord # noqa: F401 +from app.models.signin import SigninRecord # noqa: F401 from app.models.store_mapping import StoreMapping # noqa: F401 from app.models.task import UserTask # noqa: F401 from app.models.user import User # noqa: F401 diff --git a/app/models/ad_reward.py b/app/models/ad_reward.py index d45be76..ac9ab06 100644 --- a/app/models/ad_reward.py +++ b/app/models/ad_reward.py @@ -8,7 +8,7 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, Integer, String, func +from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, func from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -16,6 +16,10 @@ from app.db.base import Base class AdRewardRecord(Base): __tablename__ = "ad_reward_record" + __table_args__ = ( + # 「本轮膨胀累计发了多少」= SUM(coin) WHERE user_id=? AND boost_round_id=? AND status='granted' + Index("ix_ad_reward_user_boost_round", "user_id", "boost_round_id"), + ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) # 穿山甲交易号,幂等键(同号回调不重复发奖) @@ -31,6 +35,10 @@ class AdRewardRecord(Base): reward_scene: Mapped[str] = mapped_column(String(32), nullable=False, default="reward_video") # 客户端生成并通过 extra 透传的广告会话 id ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + # 客户端生成并通过 extra 透传的「膨胀轮」id:一轮 = 用户点「去膨胀」到点「放弃赚钱」之间连看的 + # 若干条广告。纯标签,不影响发多少/发不发,只用于把同一轮的发奖记录求和成弹窗要显示的累计值。 + # 轮次边界完全由客户端定(它才知道用户点了放弃);老客户端/extra 丢失时为 NULL → 累计值返 null。 + boost_round_id: Mapped[str | None] = mapped_column(String(64), nullable=True) # 本次发奖采用的 eCPM 原始值(回调自带或按 ad_session_id 匹配的客户端上报) ecpm_raw: Mapped[str | None] = mapped_column(String(32), nullable=True) # 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx。 diff --git a/app/models/signin.py b/app/models/signin.py index 0a94ea0..1fadf2c 100644 --- a/app/models/signin.py +++ b/app/models/signin.py @@ -1,6 +1,10 @@ """签到记录表。 每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次。 + +2026-07 下线 `signin_boost_record`(签到膨胀):膨胀按固定 3000 金币补发、与广告实际收益 +脱钩,产品确认非设计内口径。签到弹窗的「看广告膨胀」改走 reward_video(按 eCPM 发,记在 +`ad_reward_record`)。历史发币流水 `coin_transaction.biz_type='signin_boost'` 保留不动。 - cycle_day: 1..7,7 天循环里今天落在第几档,决定发多少金币;断签后重置回 1 (周期长度 = rewards.SIGNIN_CYCLE_LEN,2026-06 由 14 天改 7 天一轮)。 - streak: 连续签到天数(不封顶),用于"已连续签到 N 天"展示;断签后重置回 1。 @@ -9,7 +13,7 @@ from __future__ import annotations from datetime import date, datetime -from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, UniqueConstraint, func +from sqlalchemy import Date, DateTime, ForeignKey, Integer, UniqueConstraint, func from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -42,29 +46,3 @@ class SigninRecord(Base): ) -class SigninBoostRecord(Base): - """签到后看广告膨胀记录。 - - 一天最多膨胀一次,补发金额等于当天签到原始奖励。独立表用于防并发重复补发, - 后续接入真实 S2S 广告 session 时可把 ad_ref_id 回填为广告会话/交易号。 - """ - - __tablename__ = "signin_boost_record" - __table_args__ = ( - UniqueConstraint("user_id", "signin_date", name="uq_signin_boost_user_date"), - ) - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - user_id: Mapped[int] = mapped_column( - Integer, ForeignKey("user.id"), index=True, nullable=False - ) - signin_date: Mapped[date] = mapped_column(Date, nullable=False) - coin_awarded: Mapped[int] = mapped_column(Integer, nullable=False) - ad_ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True) - - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), nullable=False - ) - - def __repr__(self) -> str: # pragma: no cover - return f"" diff --git a/app/repositories/ad_reward.py b/app/repositories/ad_reward.py index f9fb16f..3854f12 100644 --- a/app/repositories/ad_reward.py +++ b/app/repositories/ad_reward.py @@ -41,6 +41,61 @@ def find_by_trans(db: Session, trans_id: str) -> AdRewardRecord | None: return _find_by_trans(db, trans_id) +def find_by_session(db: Session, user_id: int, ad_session_id: str) -> AdRewardRecord | None: + """按广告会话 id 查该用户本次广告的发奖记录,供客户端轮询弹窗金额(reward-result)。 + + 同一 ad_session_id 可能命中多条,**必须显式优先 granted**,不能只取最近一条: + - 客户端先上报 closed_early、S2S 随后才姗姗来迟 → 两条,granted 反而是后写的; + - record_reward_noshow 只在写入前查 granted,挡不住这种后到的竞态; + - 本地联调重复调 test-grant → 同 session 多条 granted(trans_id 各不相同)。 + granted 是唯一「真发了钱」的状态,取它才是权威金额;都没有再取最近一条,让客户端 + 知道没发的原因(capped/closed_early…)。按 user_id 收窄,防止拿别人的 session 探测结果。 + """ + granted = db.execute( + select(AdRewardRecord) + .where( + AdRewardRecord.user_id == user_id, + AdRewardRecord.ad_session_id == ad_session_id, + AdRewardRecord.status == "granted", + ) + .order_by(AdRewardRecord.created_at.desc()) + .limit(1) + ).scalars().first() + if granted is not None: + return granted + return db.execute( + select(AdRewardRecord) + .where( + AdRewardRecord.user_id == user_id, + AdRewardRecord.ad_session_id == ad_session_id, + ) + .order_by(AdRewardRecord.created_at.desc()) + .limit(1) + ).scalars().first() + + +def round_coin_total(db: Session, user_id: int, boost_round_id: str) -> int: + """本轮膨胀累计已发金币 = 该轮所有 granted 记录的 coin 之和(含刚发的这条)。 + + 客户端弹窗要显示的就是它:第 1 条弹 40、第 2 条弹 60(=40+20),点「放弃赚钱」后余额涨 60, + 三个数必须相等。之所以由服务端求和而不是客户端自己累加——客户端进程被杀/低内存重建后 + 本地累计就丢了,而发奖记录不会丢。 + + **必须带 user_id**:boost_round_id 是客户端生成的,不带 user_id 就等于让任何人拿别人的 + 轮 id 查别人发了多少。未发奖的状态(capped/closed_early/ecpm_missing)coin 本就是 0, + 这里按 status 过滤只是让意图显式。 + """ + return int( + db.execute( + select(func.coalesce(func.sum(AdRewardRecord.coin), 0)).where( + AdRewardRecord.user_id == user_id, + AdRewardRecord.boost_round_id == boost_round_id, + AdRewardRecord.status == "granted", + ) + ).scalar_one() + ) + + def _granted_today(db: Session, user_id: int, reward_date: str) -> int: return db.execute( select(func.count()) @@ -78,8 +133,12 @@ def grant_ad_reward( reward_scene: str = "reward_video", reward_name: str | None = None, raw: str | None = None, + boost_round_id: str | None = None, ) -> AdRewardRecord: - """福利页激励视频发奖(幂等 + 每日限额 + eCPM 公式)。""" + """福利页激励视频发奖(幂等 + 每日限额 + eCPM 公式)。 + + boost_round_id 只是随记录存下的标签(见 round_coin_total),**不参与任何发奖判定**。 + """ # #2 幂等:同 trans_id 已处理过 → 原样返回,不重复发 existing = _find_by_trans(db, trans_id) if existing is not None: @@ -112,7 +171,7 @@ def grant_ad_reward( trans_id=trans_id, user_id=user_id, coin=0, status="capped", reward_date=today, reward_name=reward_name, raw=raw, reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm, - app_env=src_app_env, our_code_id=src_code_id, + app_env=src_app_env, our_code_id=src_code_id, boost_round_id=boost_round_id, ) return _commit_record(db, rec, trans_id) @@ -123,7 +182,7 @@ def grant_ad_reward( trans_id=trans_id, user_id=user_id, coin=0, status="ecpm_missing", reward_date=today, reward_name=reward_name, raw=raw, reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=None, - app_env=src_app_env, our_code_id=src_code_id, + app_env=src_app_env, our_code_id=src_code_id, boost_round_id=boost_round_id, ) return _commit_record(db, rec, trans_id) @@ -140,7 +199,7 @@ def grant_ad_reward( trans_id=trans_id, user_id=user_id, coin=coin, status="granted", reward_date=today, reward_name=reward_name, raw=raw, reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm_raw, - app_env=src_app_env, our_code_id=src_code_id, + app_env=src_app_env, our_code_id=src_code_id, boost_round_id=boost_round_id, ) return _commit_record(db, rec, trans_id) @@ -206,8 +265,13 @@ def record_external_reward( raw: str | None = None, status: str = "granted", commit: bool = True, + boost_round_id: str | None = None, ) -> AdRewardRecord: - """记录非普通看视频场景的 S2S 回调幂等,发币由调用方业务仓储完成。""" + """记录非普通看视频场景的 S2S 回调幂等,发币由调用方业务仓储完成。 + + boost_round_id 同 grant_ad_reward:纯标签。签到膨胀场景的 coin 也会计入本轮累计 + (它的 coin 就是实发额),所以这里也要存,否则一轮里混了膨胀就会漏算。 + """ existing = _find_by_trans(db, trans_id) if existing is not None: return existing @@ -224,6 +288,7 @@ def record_external_reward( reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm, + boost_round_id=boost_round_id, ) db.add(rec) if commit: diff --git a/app/repositories/signin.py b/app/repositories/signin.py index c65ce38..067fa7a 100644 --- a/app/repositories/signin.py +++ b/app/repositories/signin.py @@ -11,12 +11,11 @@ from dataclasses import dataclass from datetime import timedelta from sqlalchemy import select -from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.core import rewards from app.core.rewards import SIGNIN_CYCLE_LEN, cn_today -from app.models.signin import SigninBoostRecord, SigninRecord +from app.models.signin import SigninRecord from app.repositories import wallet as crud_wallet @@ -24,18 +23,6 @@ class AlreadySignedError(Exception): """今天已经签过了。""" -class NotSignedTodayError(Exception): - """今天尚未签到,不能膨胀。""" - - -class AlreadyBoostedError(Exception): - """今天签到奖励已经膨胀过。""" - - -class LastCycleDayBoostBlockedError(Exception): - """循环最后一天(第 SIGNIN_CYCLE_LEN 天)不允许签到膨胀。""" - - @dataclass class SigninStep: day: int # 1..14 @@ -141,69 +128,3 @@ def do_signin(db: Session, user_id: int) -> tuple[SigninRecord, int]: db.commit() db.refresh(record) return record, acc.coin_balance - - -def _today_record(db: Session, user_id: int) -> SigninRecord | None: - today = cn_today() - return db.execute( - select(SigninRecord).where( - SigninRecord.user_id == user_id, - SigninRecord.signin_date == today, - ) - ).scalar_one_or_none() - - -def boost_by_ad_ref( - db: Session, user_id: int, ad_ref_id: str -) -> SigninBoostRecord | None: - """按广告交易号查签到膨胀记录。S2S 发奖后客户端确认用。""" - return db.execute( - select(SigninBoostRecord).where( - SigninBoostRecord.user_id == user_id, - SigninBoostRecord.ad_ref_id == ad_ref_id, - ) - ).scalar_one_or_none() - - -def boost_today_signin( - db: Session, user_id: int, *, ad_ref_id: str | None = None, commit: bool = True -) -> tuple[SigninBoostRecord, int]: - """签到后看广告膨胀:固定补发配置金币。返回 (膨胀记录, 补发后余额)。""" - record = _today_record(db, user_id) - if record is None: - raise NotSignedTodayError - if record.cycle_day == SIGNIN_CYCLE_LEN: - raise LastCycleDayBoostBlockedError - - today = record.signin_date - existing = db.execute( - select(SigninBoostRecord).where( - SigninBoostRecord.user_id == user_id, - SigninBoostRecord.signin_date == today, - ) - ).scalar_one_or_none() - if existing is not None: - raise AlreadyBoostedError - - boost = SigninBoostRecord( - user_id=user_id, - signin_date=today, - coin_awarded=rewards.get_signin_boost_coin(db), - ad_ref_id=ad_ref_id, - ) - db.add(boost) - try: - acc, _ = crud_wallet.grant_coins( - db, user_id, boost.coin_awarded, - biz_type="signin_boost", ref_id=ad_ref_id or today.isoformat(), - remark=f"签到膨胀 第{record.cycle_day}天", - ) - if commit: - db.commit() - else: - db.flush() - except IntegrityError as e: - db.rollback() - raise AlreadyBoostedError from e - db.refresh(boost) - return boost, acc.coin_balance diff --git a/app/schemas/ad.py b/app/schemas/ad.py index 7a6feb4..9a6df95 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -43,6 +43,35 @@ class AdRewardStatusOut(BaseModel): watch_seconds_remaining: int = Field(0, description="今日剩余可观看秒数;limit=0 时客户端不据此拦截") +class AdRewardResultOut(BaseModel): + """按 ad_session_id 查本次广告的**权威发奖结果**(福利页看完视频的弹窗金额只认它)。 + + S2S 回调是异步的,客户端看完广告立刻轮询多半还查不到记录 —— 这种「还没到账」返回 + 200 + status='pending' 让客户端重试,**不返 404**:404 只应表示路由不存在,两者混在 + 一起客户端没法区分「后端没部署」和「再等等」。纯只读,不产生任何奖励。 + """ + + ad_session_id: str = Field(..., description="回显请求的广告会话 id") + status: str = Field( + ..., + description="pending(S2S 未到账,客户端应继续轮询) / granted(已发奖) / capped(当日超限未发) / " + "ecpm_missing(缺 eCPM 未发) / closed_early(提前关闭未发);其余同 AdRewardRecord.status。" + "客户端只在 granted 且 coin>0 时弹窗,其它一律不弹(不显示假数字)", + ) + coin: int | None = Field( + None, + description="本次实发金币:granted 为真实到账额;未发奖的状态为 0;pending 为 null", + ) + round_coin: int | None = Field( + None, + description="**本轮膨胀累计已发金币**(含本条)——客户端「恭喜累计获得奖励」弹窗显示的就是它。" + "轮 = 用户点「去膨胀」到点「放弃赚钱」之间连看的若干条广告,边界由客户端的 boost_round_id 定。" + "本条不是 granted(capped/closed_early/…)时**仍返本轮累计**,只是这条按 0 计。" + "pending(没记录,取不到轮 id)、或该记录没有 boost_round_id(老客户端 / extra 丢失)时为 null," + "客户端见 null 退回只显示单条 coin", + ) + + class EcpmReportIn(BaseModel): """客户端上报一次广告展示的 eCPM(内部收益统计/对账)。 @@ -111,13 +140,20 @@ class TestGrantIn(BaseModel): reward_scene: str = Field( "reward_video", - description="模拟发奖场景:reward_video(普通激励视频) / signin_boost(签到膨胀)", + description="模拟发奖场景。当前只支持 reward_video(普通激励视频);signin_boost(签到膨胀)" + "已于 2026-07 下线,传它会 422", ) ad_session_id: str | None = Field( None, min_length=8, max_length=64, description="本次广告会话 id(与 ecpm-report 同值)。reward_video 场景下据此查回客户端" "已上报的真实 eCPM 来按公式发奖;查不到或 eCPM≤0 时兜底 200,保证本地联调仍出非零金币", ) + boost_round_id: str | None = Field( + None, max_length=64, + description="本次广告属于哪一轮膨胀。正式链路走穿山甲 S2S 的 mediaExtra,test-grant 不经 S2S、" + "拿不到 extra,故在 body 里补一个——不传的话 debug 包 reward-result 的 round_coin 恒为 null," + "「弹窗 40 → 60 → toast +60」那套验收在本地跑不起来", + ) class TestGrantOut(BaseModel): @@ -125,7 +161,7 @@ class TestGrantOut(BaseModel): granted: bool = Field(..., description="本次是否真的发了金币(达每日上限则 False)") status: str = Field( - ..., description="granted / capped / not_signed / already_boosted / last_day / unknown_scene" + ..., description="granted / capped / ecpm_missing / unknown_scene" ) coin: int = Field(..., description="本次发放金币(capped 时为 0)") used_today: int = Field(..., description="今日已成功发奖次数") diff --git a/app/schemas/welfare.py b/app/schemas/welfare.py index d6ba63a..a9aa811 100644 --- a/app/schemas/welfare.py +++ b/app/schemas/welfare.py @@ -223,16 +223,6 @@ class SigninResultOut(BaseModel): coin_balance: int = Field(..., description="签到后金币余额") -class SigninBoostRequest(BaseModel): - ad_ref_id: str | None = Field(None, description="广告会话/交易号。当前开发期可空,后续接 S2S 时回填") - - -class SigninBoostResultOut(BaseModel): - coin_awarded: int = Field(..., description="本次膨胀补发金币") - coin_balance: int = Field(..., description="膨胀补发后金币余额") - signin_date: str = Field(..., description="被膨胀的签到日期 YYYY-MM-DD") - - # ===== 任务 ===== class TaskOut(BaseModel): diff --git a/docs/api/README.md b/docs/api/README.md index 7d29d6f..57c3a62 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -78,7 +78,6 @@ | **签到**(前缀 `/api/v1/signin`) ||| | 25 | `GET /api/v1/signin/status` | Bearer | [详情](./signin/signin-status.md) | | 26 | `POST /api/v1/signin` | Bearer | [详情](./signin/signin-do.md) | -| 26a | `POST /api/v1/signin/boost` | Bearer | [详情](./signin/signin-boost.md) | | **任务**(前缀 `/api/v1/tasks`) ||| | 27 | `GET /api/v1/tasks` | Bearer | [详情](./tasks/tasks-list.md) | | 28 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks/tasks-claim.md) | @@ -89,6 +88,7 @@ | **看广告发奖**(前缀 `/api/v1/ad`) ||| | 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad/ad-pangle-callback.md) | | 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad/ad-reward-status.md) | +| 33a | `GET /api/v1/ad/reward-result/{ad_session_id}` | Bearer | [详情](./ad/ad-reward-result.md)(本次实发金币 + 本轮膨胀累计 `round_coin`,弹窗数字用它) | | 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad/ad-test-grant.md) | | 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad/ad-ecpm-report.md) | | 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad/ad-feed-reward.md) | diff --git a/docs/api/ad/ad-pangle-callback.md b/docs/api/ad/ad-pangle-callback.md index b059afd..5233cc9 100644 --- a/docs/api/ad/ad-pangle-callback.md +++ b/docs/api/ad/ad-pangle-callback.md @@ -15,7 +15,15 @@ GroMore 以 GET 回调,关键参数: | `trans_id` | string | 交易号(**幂等键** + **唯一参与签名的字段**) | | `reward_name` | string | 奖励名(广告位配置,入库备注) | | `ecpm` | string\|null | GroMore 回调携带的 eCPM。普通激励视频优先用它计算金币 | -| `extra` / `gromoreExtra` / `gromore_extra` | string | 客户端透传 JSON。支持 `ad_session_id`、`reward_scene`;`reward_scene=signin_boost` 表示签到膨胀 | +| `extra` / `gromoreExtra` / `gromore_extra` | string | 客户端透传 JSON。支持 `ad_session_id`、`reward_scene`、`srv_env`、`boost_round_id` | + +### `extra` 里的 `boost_round_id` + +客户端生成的「这条广告属于哪一轮膨胀」标签(32 位十六进制,同 `ad_session_id` 格式),随发奖记录存进 `ad_reward_record.boost_round_id`。 + +**它不参与任何发奖判定** —— 发多少、发不发完全不受影响,只是让 [`/ad/reward-result`](./ad-reward-result.md) 能把同一轮的 granted 记录求和成 `round_coin`(客户端「恭喜累计获得奖励」弹窗显示的数)。 + +轮次边界由客户端定(只有它知道用户点没点「放弃赚钱」):点「去膨胀」新生成一个 → 点「继续看视频膨胀」复用同一个 → 点「放弃赚钱」/ ✕ / 返回 / 到每日上限 / 跨天 则丢弃。不带此字段(老客户端 / GroMore 偶发丢 extra)时存 NULL,`round_coin` 返 `null`。 | `mediation_rit` | string | 代码位 ID(GroMore 带,目前仅入 raw 备查) | | `prime_rit` | string | 广告位 ID(同上) | | `adn_name` | string | 实际出广告的 ADN 名(同上,可用于收益分析) | @@ -40,6 +48,6 @@ GroMore 以 GET 回调,关键参数: **发奖唯一可信入口**:验签 → 取 `user_id`/`extra` → 按 `reward_scene` 分流 → 幂等处理(按 `trans_id` 去重)。客户端不直接发奖,被破解也刷不到钱。 - `reward_scene=reward_video` 或缺省:普通激励视频。金币按 `eCPM / 1000 * eCPM因子 * 当日次数因子 * 10000` 计算;若回调没有 `ecpm`,会按 `extra.ad_session_id` 查客户端 `/ad/ecpm-report` 的上报值;两边都没有 eCPM 时不发币,记录 `status=ecpm_missing`。 -- `reward_scene=signin_boost`:签到膨胀。要求用户当天已签到且不是 Day14;看完视频固定发 `2000` 金币,写 `signin_boost_record` 与 `coin_transaction.biz_type=signin_boost`。 +- ~~`reward_scene=signin_boost`~~(签到膨胀):**2026-07 已下线**。它按固定 3000 金币发、与广告实际收益脱钩,产品确认非设计内口径。签到弹窗的「看广告膨胀」现与福利页看视频同走 `reward_video`。现在传 `signin_boost` 会落到「未知场景」分支(不发币,`status=unknown_scene`)。 - 未知 `reward_scene`:不发币,记录 `status=unknown_scene`,返回 `is_verify=false/reason=1`。 - 验签过但参数缺/坏或 user 不存在 → 不发(`is_verify=false` + `reason`);granted / capped / ecpm_missing / 业务不满足已记录 → `is_verify=true` + `reason=0`。 diff --git a/docs/api/ad/ad-reward-result.md b/docs/api/ad/ad-reward-result.md new file mode 100644 index 0000000..273e16a --- /dev/null +++ b/docs/api/ad/ad-reward-result.md @@ -0,0 +1,66 @@ +# GET /api/v1/ad/reward-result/{ad_session_id} — 查本次广告的权威发奖结果 + 本轮累计 + +客户端看完激励视频后轮询本接口,拿**本次实发金币**和**本轮累计**用于「恭喜累计获得奖励」弹窗。不再用余额差 / `coin_per_ad` 估算。 + +**纯只读**:发奖仍只由验签过的 S2S 回调完成,本接口不写库、不产生任何奖励。按 `user_id` 收窄,被刷也只能查到自己的记录。 + +## 鉴权 + +需要 Bearer token。 + +## 路径参数 + +| 参数 | 类型 | 约束 | 说明 | +|---|---|---:|---| +| `ad_session_id` | string | 长度 8~64 | 本次广告会话 id,客户端生成,与 `mediaExtra` / `ecpm-report` 同值 | + +## 响应 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `ad_session_id` | string | 回显请求值 | +| `status` | string | `pending`(S2S 未到账,继续轮询) / `granted` / `capped`(当日超限) / `ecpm_missing` / `closed_early`(提前关闭) | +| `coin` | int \| null | **本条**实发金币。granted 为真实到账额;未发奖的状态为 0;pending 为 `null` | +| `round_coin` | int \| null | **本轮累计已发金币**(含本条) ← 弹窗显示的就是它 | + +```json +{ "ad_session_id": "3f2a9c1b7e4d8a60", "status": "granted", "coin": 20, "round_coin": 60 } +``` + +### `round_coin` 的口径 + +「轮」= 用户点「去膨胀」到点「放弃赚钱」之间连看的若干条广告,边界由客户端的 `boost_round_id` 定(见 [ad-pangle-callback](./ad-pangle-callback.md))。 + +```sql +SELECT COALESCE(SUM(coin), 0) FROM ad_reward_record +WHERE user_id = :user_id -- 恒带,轮 id 是客户端生成的不可跨用户信任 + AND boost_round_id = :该会话记录的 boost_round_id + AND status = 'granted' +``` + +由服务端求和而非客户端自己累加:客户端进程被杀 / 低内存重建后本地累计会丢,发奖记录不会。 + +**要守住的不变量:弹窗数字 == 本轮实际到账之和 == 用户看到的余额涨幅。** 三者对不上,用户就会认为少发了钱。 + +| 情形 | `round_coin` | +|---|---| +| 本条 `granted` | 本轮累计(含本条) | +| 本条 `capped` / `closed_early` / `ecpm_missing` | **仍返本轮累计**,该条按 0 计(撞上限那下的 toast 要能显示前几条的总额,不能是空) | +| `status=pending`(没记录) | `null` —— 连属于哪一轮都不知道。**不是 0**,0 会被读成「本轮没赚到」 | +| 该记录没有 `boost_round_id`(老客户端 / extra 丢失) | `null`,客户端退回只显示单条 `coin` | + +## 错误 + +- `401`: 未登录 +- `422`: `ad_session_id` 长度不在 8~64 + +**查不到记录不返 404**,而是 200 + `status="pending"`。404 只应表示路由不存在;两者混在一起客户端没法区分「后端没部署」和「再等等」。 + +## 实现注意 + +同一 `ad_session_id` 可能有多条记录,取值时**显式优先 `granted`**,不能只取最近一条: + +- 客户端先报 `closed_early`、S2S 随后姗姗来迟 → 两条,`granted` 反而是后写的 +- 本地联调重复调 `test-grant` → 同 session 多条 `granted`(`trans_id` 各不相同) + +都没有 `granted` 才取最近一条,让客户端知道没发的原因。 diff --git a/docs/api/ad/ad-test-grant.md b/docs/api/ad/ad-test-grant.md index 490f96b..60891e8 100644 --- a/docs/api/ad/ad-test-grant.md +++ b/docs/api/ad/ad-test-grant.md @@ -9,7 +9,8 @@ | 字段 | 类型 | 必填 | 默认 | 说明 | |---|---|---|---|---| -| `reward_scene` | string | 否 | `reward_video` | 模拟发奖场景。`reward_video`=普通激励视频;`signin_boost`=签到膨胀 | +| `reward_scene` | string | 否 | `reward_video` | 模拟发奖场景。当前**只支持** `reward_video`;`signin_boost`(签到膨胀)已于 2026-07 下线,传它返 `422` | +| `boost_round_id` | string | 否 | `null` | 本次广告属于哪一轮膨胀。正式链路走 S2S 的 `mediaExtra`,本接口不经 S2S 拿不到 extra,故由 body 补。**不传的话 debug 包 `/ad/reward-result` 的 `round_coin` 恒为 `null`**,「弹窗 40 → 60 → toast +60」那套累计验收在本地跑不起来 | | `ad_session_id` | string(8~64) \| null | 否 | null | 本次广告会话 id(与 [ecpm-report](./ad-ecpm-report.md) 同值)。**仅 `reward_video` 场景生效**:据此查回客户端已上报的真实 eCPM,走与正式发奖相同的公式发奖;查不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍出非零金币 | ## 出参 @@ -33,4 +34,4 @@ `reward_scene=reward_video` 时按上面 `ad_session_id` 查回的真实 eCPM 走金币公式发奖(取不到兜底 200)——便于本地用 [admin 金币审计](./admin-ad-coin-audit.md) 核对「看广告→金币」是否按公式计算。 -`reward_scene=signin_boost` 时复用签到膨胀业务规则:必须当天已签到、非第 14 天、当天未膨胀过,成功后写入 `signin_boost` 金币流水。它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启**。 +它让已登录客户端能自助发奖 = 绕过反作弊,**严禁在生产开启**。 diff --git a/docs/api/admin/admin-stats-overview.md b/docs/api/admin/admin-stats-overview.md index 6165445..2c7f089 100644 --- a/docs/api/admin/admin-stats-overview.md +++ b/docs/api/admin/admin-stats-overview.md @@ -37,8 +37,8 @@ | `feed_ad_watch_count` | int | 信息流广告有效完成视频数(`ad_feed_reward_record.status=granted`) | | `signin_coin_total` | int | 签到累计发放金币(`biz_type=signin`) | | `signin_count` | int | 签到次数(`signin_record`) | -| `signin_boost_coin_total` | int | 签到膨胀累计发放金币(`biz_type=signin_boost`) | -| `signin_boost_watch_count` | int | 签到膨胀有效视频数(`signin_boost_record`) | +| `signin_boost_coin_total` | int | **历史口径**:签到膨胀累计发放金币(`biz_type=signin_boost`)。功能已下线,数字不再增长,保留供对账 | +| `signin_boost_watch_count` | int | **历史口径**:签到膨胀次数。膨胀 2026-07 已下线、`signin_boost_record` 表已 drop,改数 `coin_transaction.biz_type='signin_boost'` 的入账笔数(一次膨胀 = 一笔,与原口径等价),只会停在历史值不再增长 | **DashboardCash** | 字段 | 类型 | 说明 | diff --git a/docs/api/signin/signin-boost.md b/docs/api/signin/signin-boost.md deleted file mode 100644 index a1ec64c..0000000 --- a/docs/api/signin/signin-boost.md +++ /dev/null @@ -1,33 +0,0 @@ -# POST /api/v1/signin/boost — 签到后看广告膨胀金币 - -用户 Day1-Day13 当天已签到后,看完一条激励视频,由穿山甲 S2S 回调固定补发 2000 金币。本接口只用于 S2S 发奖后的确认。 - -## 鉴权 - -需要 Bearer token。 - -## 请求体 - -| 字段 | 类型 | 必填 | 说明 | -|---|---|---:|---| -| `ad_ref_id` | string | 是 | 穿山甲 S2S 回调的 `trans_id`。回调需先以 `extra.reward_scene=signin_boost` 完成发奖 | - -## 响应 - -| 字段 | 类型 | 说明 | -|---|---|---| -| `coin_awarded` | int | 本次膨胀补发金币 | -| `coin_balance` | int | 补发后的金币余额 | -| `signin_date` | string | 被膨胀的签到日期,格式 `YYYY-MM-DD` | - -## 错误 - -- `401`: 未登录 -- `409`: 缺少/无效广告回调记录,非本人广告,回调未发奖,当天未签到,Day14,或当天已经膨胀过 - -## 数据写入 - -- 本接口不直接发奖;实际写入发生在 `/ad/pangle-callback` 的 `reward_scene=signin_boost` 分支。 -- 回调写 `signin_boost_record` 新增一行,用 `(user_id, signin_date)` 唯一约束防重复。 -- 回调使 `coin_account` 增加固定 `2000` 金币。 -- 回调写入 `coin_transaction.biz_type=signin_boost`。 diff --git a/docs/database/OVERVIEW.md b/docs/database/OVERVIEW.md index 5f5f525..edbfd1d 100644 --- a/docs/database/OVERVIEW.md +++ b/docs/database/OVERVIEW.md @@ -35,7 +35,7 @@ | 资产卡 / 钱包余额 | [`coin_account`](./coin_account.md) | 一用户一行的金币+现金余额快照 | | 金币明细 | [`coin_transaction`](./coin_transaction.md) | 每次金币变动一笔流水 | | 现金明细 | [`cash_transaction`](./cash_transaction.md) | 每次现金变动一笔流水(分) | -| 每日签到 | [`signin_record`](./signin_record.md) + [`signin_boost_record`](./signin_boost_record.md) | 7 天循环发币;签到后看广告可膨胀一次 | +| 每日签到 | [`signin_record`](./signin_record.md) | 7 天循环发币。签到弹窗的「看广告膨胀」2026-07 起走 `reward_video`(按 eCPM 发,记 `ad_reward_record`),不再有独立的膨胀表 | | 一次性任务(开消息提醒等) | [`user_task`](./user_task.md) | 领一次发币 | | 看激励视频赚金币 | [`ad_reward_record`](./ad_reward_record.md) + [`ad_watch_log`](./ad_watch_log.md) + [`ad_ecpm_record`](./ad_ecpm_record.md) | 独立数据流:发奖 / 旧版观看时长 / 收益对账 | | 信息流/Draw 广告结算 | [`ad_feed_reward_record`](./ad_feed_reward_record.md) | 每展示满 10 秒累计一份奖励,完成后一次性入账;`ad_type`(feed/draw)+`feed_scene`(compare/coupon)分形态/场景 | @@ -102,12 +102,11 @@ | 注销 `DELETE /user` | `user` | U(软删:`phone→deleted_`、`status=deleted`) | | 绑/解绑微信 `POST /wallet/bind-wechat`、`/unbind-wechat` | `user`.wechat_* | U | | 签到 `POST /signin/do` | `signin_record`(C) + `coin_account`(U) + `coin_transaction`(C `signin`) | 同事务 | -| 签到膨胀 `POST /signin/boost` | `signin_boost_record`(C) + `coin_account`(U) + `coin_transaction`(C `signin_boost`) | 同事务;同日一次 | | 领任务 `POST /tasks/claim` | `user_task`(C) + `coin_account`(U) + `coin_transaction`(C `task_`) | 同事务 | | 金币兑现金 `POST /wallet/exchange` | `coin_account`(U) + `coin_transaction`(C `exchange_out` −) + `cash_transaction`(C `exchange_in` +) | 同事务 | | 发起提现 `POST /wallet/withdraw` | `withdraw_order`(C `reviewing`,记 `source`) + `coin_account`(U 按 source 扣对应余额) + 流水(C −:`cash_transaction.withdraw` 或 `invite_cash_transaction.invite_withdraw`) | 同事务,**不打款**;#121 按 `source` 分账 | | 查提现状态 / 用户取消 `GET /wallet/withdraw/status` | `withdraw_order`(U) + 失败→对应账本退款流水(C `withdraw_refund` / `invite_withdraw_refund` +) | | -| 穿山甲发奖 S2S 回调 `POST /ad/pangle-callback` | `ad_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `reward_video`/`signin_boost`) | `trans_id` 幂等 | +| 穿山甲发奖 S2S 回调 `POST /ad/pangle-callback` | `ad_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `reward_video`) | `trans_id` 幂等 | | 看广告时长上报 `POST /ad/watch-report` | `ad_watch_log`(C) | | | 广告 eCPM 上报 `POST /ad/ecpm-report` | `ad_ecpm_record`(C) | | | 信息流广告结算 `POST /ad/feed-reward` | `ad_feed_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `feed_ad_reward`) | `client_event_id` 幂等 | @@ -175,7 +174,7 @@ ## 三、表间关系 & Join Key ### 硬外键(数据库 FK 约束) -- **19 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction`、`cash_transaction`、`invite_cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`(同时是 PK)、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`(2026-07 起 `user_id` **可空**——harvest 帧0 建行时软鉴权可能拿不到)、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback`、`device_liveness`。 +- **18 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction`、`cash_transaction`、`invite_cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`(同时是 PK)、`signin_record`、`user_task`、`comparison_record`(2026-07 起 `user_id` **可空**——harvest 帧0 建行时软鉴权可能拿不到)、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback`、`device_liveness`。 - `admin_audit_log.admin_id` → `admin_user.id`。 - `price_report.comparison_record_id` → `comparison_record.id`(可空:关联记录被删后仍留上报历史)。 - **邀请两表** → `user.id`:`invite_relation.inviter_user_id`、`invite_relation.invitee_user_id`(唯一)、`invite_fingerprint.inviter_user_id`——注意 FK 列名是 `inviter`/`invitee_user_id`,不是 `user_id`。 @@ -187,7 +186,7 @@ | biz_type | ref_id 指向 | amount 符号 | |---|---|---| | `signin` | 当天日期串(= `signin_record.signin_date` 的 ISO `YYYY-MM-DD`) | + | - | `signin_boost` | 当天日期串(= `signin_boost_record.signin_date` 的 ISO `YYYY-MM-DD`) | + | + | `signin_boost`(**历史,2026-07 已下线**) | 当时的广告 `trans_id`,无则当天日期 ISO 串。不再产生新行,存量保留供对账 | + | | `task_` | `user_task.task_key` | + | | `reward_video` / `ad_reward`(历史) | `ad_reward_record.trans_id` | + | | `feed_ad_reward` | `ad_feed_reward_record.client_event_id` | + | @@ -225,7 +224,7 @@ user ─1:1─ coin_account user ─1:1─ wechat_transfer_authorization user ─1:N─ { coin_transaction, cash_transaction, invite_cash_transaction, withdraw_order, - signin_record, signin_boost_record, user_task, comparison_record(user_id 可空), + signin_record, user_task, comparison_record(user_id 可空), comparison_milestone_claim, savings_record, ad_reward_record, ad_watch_log, ad_ecpm_record, ad_feed_reward_record, price_report, feedback, device_liveness } (device_liveness 硬 FK; (user_id,device_id) 唯一) @@ -255,7 +254,7 @@ launch_confirm_sample (独立, 无硬 FK; 都上报不去 1. **余额快照** `coin_account`:`coin_balance`(金币个数)+ `cash_balance_cents`(现金分)+ `invite_cash_balance_cents`(邀请奖励金分,#82),一用户一行,读取展示用。 2. **流水账本** `coin_transaction` / `cash_transaction` / `invite_cash_transaction`:每次变动写一笔,`balance_after*` 记变动后余额,可逐笔回溯对账。**现金与邀请奖励金是两本物理隔离的账**——发放口径与提现对账各自独立。 -3. **唯一变动入口**:金币走 `repositories/wallet.grant_coins`,邀请奖励金走 `grant_invite_cash`——都是「更新快照 + 写流水,**不 commit**,由调用方同一事务 commit」。signin / signin_boost / task / ad_reward / feed_ad_reward / exchange / admin 走 `grant_coins`;`invite_reward` / admin 调整走 `grant_invite_cash`,靠 `biz_type` 区分来源。 +3. **唯一变动入口**:金币走 `repositories/wallet.grant_coins`,邀请奖励金走 `grant_invite_cash`——都是「更新快照 + 写流水,**不 commit**,由调用方同一事务 commit」。signin / task / ad_reward / feed_ad_reward / exchange / admin 走 `grant_coins`(`signin_boost` 2026-07 已下线,存量流水保留);`invite_reward` / admin 调整走 `grant_invite_cash`,靠 `biz_type` 区分来源。 - **汇率**:`10000 金币 = 1 元 = 100 分`(`rewards.COIN_PER_YUAN`);兑换额必须是整分倍数。 - **提现状态机**:`reviewing`(发起即原子扣款、待人工审核、**不打款**)→ 审核通过 `pending`(微信转账在途)→ `success` / `failed`(失败自动退款);审核拒绝 `rejected`(退款)。**按 `withdraw_order.source` 分账**(#121):`coin_cash` 单的扣款/退款写 `cash_transaction`,`invite_cash` 单写 `invite_cash_transaction`;`out_bill_no` 幂等,孤儿 pending 单由 `reconcile_pending_withdraws` 对账兜底,admin `withdraws/ledger-check` 分账校验「单 ↔ 流水」。 diff --git a/docs/database/README.md b/docs/database/README.md index 5f93769..ef191e2 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -35,7 +35,6 @@ | `withdraw_order` | 提现单(现金→微信零钱,含人工审核态;`source` 分账 coin_cash/invite_cash) | `models/wallet.py` | [详情](./withdraw_order.md) | | `wechat_transfer_authorization` | 微信免确认转账授权(一用户一行) | `models/wallet.py` | [详情](./wechat_transfer_authorization.md) | | `signin_record` | 签到记录(7 天循环) | `models/signin.py` | [详情](./signin_record.md) | -| `signin_boost_record` | 签到后看广告膨胀记录 | `models/signin.py` | [详情](./signin_boost_record.md) | | `user_task` | 一次性任务领取去重 | `models/task.py` | [详情](./user_task.md) | | `ad_reward_record` | 看激励视频发奖记录(S2S 回调,trans_id 幂等) | `models/ad_reward.py` | [详情](./ad_reward_record.md) | | `ad_watch_log` | 看广告观看时长(旧版兼容字段) | `models/ad_watch_log.py` | [详情](./ad_watch_log.md) | diff --git a/docs/database/ad_reward_record.md b/docs/database/ad_reward_record.md index dc6eaf9..c628e7a 100644 --- a/docs/database/ad_reward_record.md +++ b/docs/database/ad_reward_record.md @@ -2,7 +2,7 @@ > 模型 `app/models/ad_reward.py` · 仓库 `app/repositories/ad_reward.py` · 接口 [ad-pangle-callback](../api/ad-pangle-callback.md) / [ad-reward-status](../api/ad-reward-status.md) / [ad-test-grant](../api/ad-test-grant.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -每条 = 穿山甲一次**服务端激励回调**。`trans_id` 唯一做幂等键(穿山甲会重试,同号只处理一次)。`reward_scene` 区分普通激励视频、签到膨胀等场景;`reward_date`(北京时间日期串)给普通激励视频"每日上限"计数用。 +每条 = 穿山甲一次**服务端激励回调**。`trans_id` 唯一做幂等键(穿山甲会重试,同号只处理一次)。`reward_scene` 区分普通激励视频、提现看视频等场景;`reward_date`(北京时间日期串)给普通激励视频"每日上限"计数用。 ## 用在哪 / 增删改查 - **C(插入)**:`POST /ad/pangle-callback`(穿山甲 S2S,经 SHA256 验签;`grant_ad_reward` 或场景业务处理)或 `POST /ad/test-grant`(本地联调)。普通激励视频三道闸:① 验签不过 → API 层 403,不进库;② `trans_id` 已存在 → 原样返回不重复发;③ **当日发奖次数(`DAILY_AD_REWARD_LIMIT`,默认 500)到顶** → 记一行 `status='capped'`、`coin=0`、不发币。否则按 eCPM 公式发币。另:`POST /ad/reward-noshow`(`record_reward_noshow`,Bearer)在用户提前关/未发奖时记一行 `status='closed_early'`、`coin=0` 留痕(同 session 已 granted 则跳过)。 @@ -13,15 +13,16 @@ | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | | -| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=reward_video/signin_boost 等)。`closed_early` 留痕记录无 S2S 交易号,用合成键 `noreward:{ad_session_id}` | +| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=reward_video 等)。`closed_early` 留痕记录无 S2S 交易号,用合成键 `noreward:{ad_session_id}` | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(回调 media_extra 带回;不存在抛 UnknownUserError) | -| `reward_scene` | String(32) | NOT NULL, default `reward_video` | 奖励场景:`reward_video` 普通激励视频;`signin_boost` 签到膨胀 | +| `reward_scene` | String(32) | NOT NULL, default `reward_video` | 奖励场景:`reward_video` 普通激励视频(当前唯一发币场景);`withdrawal_ad` 提现门槛视频(只留痕不发币);`signin_boost` **历史值,2026-07 已下线** | | `ad_session_id` | String(64) | index, nullable | 客户端广告会话 ID,来自 `extra.ad_session_id`;用于匹配 `ad_ecpm_record` | +| `boost_round_id` | String(64) | nullable | 「这条广告属于哪一轮膨胀」,来自 `extra.boost_round_id`。一轮 = 用户点「去膨胀」到点「放弃赚钱」之间连看的若干条。**纯标签,不参与发奖判定**;仅供 `/ad/reward-result` 求和出 `round_coin`(弹窗显示的累计值)。老客户端 / extra 丢失时 NULL | | `ecpm_raw` | String(32) | nullable | 本次发奖采用的 eCPM 原始值;可来自 S2S `ecpm` 或客户端上报 | | `app_env` | String(16) | nullable | 来源应用 `prod`(傻瓜比价)/`test`(测试);S2S 不带,发奖时按 `ad_session_id` 匹配 `ad_ecpm_record` 回填,查不到 NULL。广告收益报表金币侧按它聚合 | | `our_code_id` | String(64) | nullable | 我们配置的代码位 104xxx(同上回填) | | `coin` | Integer | NOT NULL, default 0 | 实发金币;`capped`/`ecpm_missing`/`closed_early`/业务不满足时为 0 | -| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `closed_early`(展示了但用户提前关/跳过,未发奖,客户端 reward-noshow 留痕)/ `not_signed`/`already_boosted`/`last_day`/ `unknown_scene`(回调 `reward_scene` 不在支持集合,只留痕不发) | +| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `closed_early`(展示了但用户提前关/跳过,未发奖,客户端 reward-noshow 留痕)/ `unknown_scene`(回调 `reward_scene` 不在支持集合,只留痕不发) | | `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值统计当日发奖次数 | | `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) | | `raw` | String(1024) | nullable | 回调原始参数(审计排查) | @@ -34,8 +35,10 @@ ## 索引与约束 - PK `id`;UNIQUE+index `trans_id`;index `user_id`、`reward_date`、`created_at`、`ad_session_id`。 +- 复合 index `ix_ad_reward_user_boost_round` = `(user_id, boost_round_id)`:算「本轮累计已发」用。**求和恒带 `user_id`** —— `boost_round_id` 是客户端生成的,不带 `user_id` 等于让任何人拿别人的轮 id 查别人发了多少。 ## 注意 - 普通激励视频按 eCPM 公式发奖;若 S2S 与客户端会话上报都缺 eCPM,记录 `status='ecpm_missing'`、`coin=0`,不发币。 -- 签到膨胀复用本表记录 S2S 幂等,实发固定 `2000` 金币由 `signin_boost_record`/`coin_transaction.biz_type=signin_boost` 承载。 +- 签到膨胀(`reward_scene=signin_boost`)2026-07 已下线,存量行保留供对账;签到弹窗的「看广告膨胀」现与福利页看视频同走 `reward_video`(按 eCPM 公式发)。 +- **膨胀轮累计**:`SUM(coin) WHERE user_id=? AND boost_round_id=? AND status='granted'`,由 `/ad/reward-result` 返回为 `round_coin`。客户端就算一直复用同一个轮 id,也只是把展示数字滚大 —— 求和的是**已发生**的发奖记录,不产生任何新入账,无资损风险。 - 并发同 `trans_id` 撞唯一约束 → catch IntegrityError 回滚返回已存在那条(幂等兜底)。 diff --git a/docs/database/app_config.md b/docs/database/app_config.md index 1f3a32d..dbe151f 100644 --- a/docs/database/app_config.md +++ b/docs/database/app_config.md @@ -14,7 +14,7 @@ ## 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| -| `key` | String(64) | **PK** | 配置标识,取值见 `config_schema.CONFIG_DEFS`:`signin_rewards` / `min_exchange_coin` / `withdraw_min_cents` / `withdraw_max_cents` / `task_rewards` / `record_milestones` / `ad_reward_coin` / `ad_daily_limit` / `ad_max_coin` / `ad_round_count` / `ad_cooldown_sec` / `signin_boost_coin` / `withdraw_auto_reconcile_enabled` / `comparing_ad_enabled` | +| `key` | String(64) | **PK** | 配置标识,取值见 `config_schema.CONFIG_DEFS`:`signin_rewards` / `min_exchange_coin` / `withdraw_min_cents` / `withdraw_max_cents` / `task_rewards` / `record_milestones` / `ad_reward_coin` / `ad_daily_limit` / `ad_max_coin` / `ad_round_count` / `ad_cooldown_sec` / `withdraw_auto_reconcile_enabled` / `comparing_ad_enabled` | | `value` | JSON(PG: JSONB) | NOT NULL | 配置值,类型随 key(`int` / `int_list` 如签到 14 档 / `dict_str_int` 如 task_rewards / `bool` 如 withdraw_auto_reconcile_enabled / comparing_ad_enabled) | | `updated_by_admin_id` | Integer | nullable | 最后修改的管理员 id(= `admin_user.id`,软引用,无 FK) | | `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最后修改时间 | diff --git a/docs/database/coin_transaction.md b/docs/database/coin_transaction.md index f29958d..a2778a2 100644 --- a/docs/database/coin_transaction.md +++ b/docs/database/coin_transaction.md @@ -10,7 +10,7 @@ | 动作 / endpoint | `biz_type` | `amount` | `ref_id` 指向 | |---|---|---|---| | 签到 `POST /signin/do` | `signin` | + | 当天日期串(= `signin_record.signin_date` ISO) | - | 签到后看广告膨胀 `POST /signin/boost` | `signin_boost` | + | 广告 `trans_id`(= `signin_boost_record.ad_ref_id`);无 ad_ref_id 时回退当天日期 ISO 串 | + | ~~签到后看广告膨胀~~(**2026-07 已下线**) | `signin_boost` | + | 历史行:当时的广告 `trans_id`,无则当天日期 ISO 串。不再产生新行;签到弹窗的看广告改走 `reward_video` | | 领任务 `POST /tasks/claim` | `task_`(如 `task_enable_notification`) | + | 一次性任务=`user_task.task_key`;可重复任务(`enable_notification`)=带序号 `task_key:N` | | 普通激励视频 S2S 回调 `POST /ad/pangle-callback` | `reward_video`(历史兼容:`ad_reward`) | + | `ad_reward_record.trans_id` | | 信息流广告结算 `POST /ad/feed-reward` | `feed_ad_reward` | + | `ad_feed_reward_record.client_event_id` | @@ -36,7 +36,7 @@ ## 关系 / Join Key - `user_id` → `user.id`(多对一)。 -- `ref_id` 是**软关联**(无 FK),目标随 `biz_type`:`signin`→签到日(`signin_record.signin_date` ISO) / `signin_boost`→`signin_boost_record.ad_ref_id`(无则当天日期) / `task_`→一次性任务=`user_task.task_key`、可重复任务=`task_key:N` / `reward_video`/`ad_reward`→`ad_reward_record.trans_id` / `feed_ad_reward`→`ad_feed_reward_record.client_event_id` / 其余 null。 +- `ref_id` 是**软关联**(无 FK),目标随 `biz_type`:`signin`→签到日(`signin_record.signin_date` ISO) / `signin_boost`(历史)→当时的广告 `trans_id`(无则当天日期) / `task_`→一次性任务=`user_task.task_key`、可重复任务=`task_key:N` / `reward_video`/`ad_reward`→`ad_reward_record.trans_id` / `feed_ad_reward`→`ad_feed_reward_record.client_event_id` / 其余 null。 ## 索引与约束 - PK `id`;index `user_id`、`created_at`。 diff --git a/docs/database/signin_boost_record.md b/docs/database/signin_boost_record.md deleted file mode 100644 index ea71341..0000000 --- a/docs/database/signin_boost_record.md +++ /dev/null @@ -1,22 +0,0 @@ -# signin_boost_record — 签到膨胀记录 - -App 用户当天签到后,看完激励视频可固定膨胀一次(默认 3000 金币,`rewards.SIGNIN_BOOST_COIN`,运营后台 `app_config.signin_boost_coin` 可改)。循环最后一天(`cycle_day == SIGNIN_CYCLE_LEN`,即 7 天循环的第 7 天)不展示也不允许膨胀。本表记录膨胀动作,并用唯一约束防重复补发。 - -## 字段 - -| 字段 | 类型 | 约束 | 说明 | -|---|---|---|---| -| `id` | Integer | PK | 自增主键 | -| `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 | -| `signin_date` | Date | NOT NULL | 被膨胀的签到日期,北京时间 | -| `coin_awarded` | Integer | NOT NULL | 本次补发金币,默认固定 3000(`rewards.get_signin_boost_coin`) | -| `ad_ref_id` | String(64) | nullable | 穿山甲 S2S 回调 `trans_id` | -| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间 | - -## 约束 - -- `UNIQUE(user_id, signin_date)` = `uq_signin_boost_user_date`:同一用户同一天只能膨胀一次。 - -## 关联 - -- 膨胀成功时写 `coin_transaction.biz_type=signin_boost`,`ref_id = ad_ref_id`(无 ad_ref_id 时回退当天日期 ISO 串)。 diff --git a/docs/database/user.md b/docs/database/user.md index ce33696..d6b0d7e 100644 --- a/docs/database/user.md +++ b/docs/database/user.md @@ -29,7 +29,7 @@ | `last_login_at` | DateTime(tz) | 应用层 default utcnow | 最近登录时间(每次登录更新) | ## 关系 / Join Key -- **被引用方(本表是 1,对方是 N/1)**:`coin_account`、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback` 的 `user_id` 均 → `user.id`;`invite_relation` 的 `inviter_user_id` / `invitee_user_id` 均 → `user.id`。 +- **被引用方(本表是 1,对方是 N/1)**:`coin_account`、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`、`signin_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback` 的 `user_id` 均 → `user.id`;`invite_relation` 的 `inviter_user_id` / `invitee_user_id` 均 → `user.id`。 - 与 `admin_user` **无任何关联**(C 端用户 vs 后台管理员,两套体系)。 ## 索引与约束 diff --git a/docs/database/数据表字典-产品参考.md b/docs/database/数据表字典-产品参考.md index fa59348..822d4c7 100644 --- a/docs/database/数据表字典-产品参考.md +++ b/docs/database/数据表字典-产品参考.md @@ -27,7 +27,6 @@ | 钱包 | `withdraw_order` | 提现单 | 现金→微信零钱提现 | | 钱包 | `wechat_transfer_authorization` | 微信转账免确认授权 | 用户授权后转账免逐笔确认 | | 激励 | `signin_record` | 签到记录 | 每日签到 | -| 激励 | `signin_boost_record` | 签到膨胀记录 | 签到后看广告翻倍补发 | | 激励 | `user_task` | 一次性任务完成 | 只能领一次的任务 | | 激励 | `comparison_milestone_claim` | 比价战绩领取 | 比价次数里程碑奖励 | | 比价 | `comparison_record` | 比价记录 | 用户视角「我的比价记录」 | @@ -181,18 +180,6 @@ App 用户主表。两种登录(极光一键 / 短信验证码)都映射到 | coin_awarded | 整数 | 本次发放金币 | | created_at | 时间 | 时间 | -## `signin_boost_record` — 签到膨胀记录 - -签到后看广告「膨胀」翻倍,一天最多一次,补发金额=当天签到原始奖励。 - -| 字段 | 类型 | 说明 | -|---|---|---| -| id | 整数 | **主键** | -| user_id | 整数 | **外键→user** | -| signin_date | 日期 | 与 user_id 组成**唯一**(防并发重复补发) | -| coin_awarded | 整数 | 补发金币 | -| ad_ref_id | 字符串 | 广告会话/交易号,可空 | -| created_at | 时间 | 时间 | ## `user_task` — 一次性任务完成记录 @@ -379,7 +366,7 @@ profile「累计帮你省了」「省钱战绩」的唯一数据源。(user_id, | user_id | 整数 | **外键→user** | | coin | 整数 | 实发金币(超限为 0) | | status | 字符串 | granted(已发)/ capped(当日超限)/ ecpm_missing(缺 eCPM) | -| reward_scene | 字符串 | reward_video(福利页看视频)/ signin_boost(签到膨胀) | +| reward_scene | 字符串 | reward_video(福利页看视频,当前唯一发币场景)/ withdrawal_ad(提现门槛视频,不发币)/ signin_boost(**历史值,2026-07 已下线**) | | ad_session_id | 字符串 | 广告会话 id,可空 | | ecpm_raw | 字符串 | 本次发奖采用的 eCPM 原始值,可空 | | app_env | 字符串 | 应用环境 prod/test(回填),可空 | diff --git a/docs/guides/看广告赚金币上线清单.md b/docs/guides/看广告赚金币上线清单.md index 2ef65a4..04efae1 100644 --- a/docs/guides/看广告赚金币上线清单.md +++ b/docs/guides/看广告赚金币上线清单.md @@ -70,7 +70,7 @@ ## C. 部署 + 包名 - [ ] **后端部署到公网**(由服务器管理员;`/opt/shaguabijia-app-server`,uvicorn 127.0.0.1:8770,nginx 反代) -- [ ] **跑迁移**:`alembic upgrade head`(包含 `ad_reward_record`、`signin_boost_record`、`ad_feed_reward_record` 等表) +- [ ] **跑迁移**:`alembic upgrade head`(包含 `ad_reward_record`、`ad_feed_reward_record` 等表) - [ ] **包名定稿**:当前 `com.jishisongfu.shaguabijia`。穿山甲(APP_ID 5830519)、极光、微信都绑"包名 + 签名",定了再上,别再换 - 微信提现链路当前因复用 elderhelper 的 appid + 包名切换已 dead,要恢复需申请傻瓜比价自己的微信 appid(另见客户端 build.gradle 注释) - [ ] (可选,提升真实填充)集成 **MSA OAID SDK**:申请证书(绑包名、审核几天)。App 侧当前 `getDevOaid=null`,有 OAID 后投放匹配 + 填充会明显改善 diff --git a/scripts/reset_onboarding.py b/scripts/reset_onboarding.py new file mode 100644 index 0000000..0837543 --- /dev/null +++ b/scripts/reset_onboarding.py @@ -0,0 +1,138 @@ +"""重置指定用户的新手引导完成标记,让这个账号重新进新手引导页,方便反复测试引导流程。 + +原理:是否跳过引导只由 onboarding_completion 表里 (user_id, device_id) 那一行决定 +(见 app/models/onboarding.py)。删掉该用户的行 → 登录响应 onboarding_completed=false、 +GET /api/v1/user/onboarding/status 也返 false → 客户端下次登录/启动重走引导。 +本地 SharedPreferences 标记卸载即丢、以后端为准,所以删这一行就够,不用重装 App。 + +默认删该用户**所有设备**的记录(换机/多设备一起放开);只想放开某一台用 --device-id +(device_id = 客户端硬件级 ANDROID_ID,与登录 / onboarding/complete 传的是同一个值)。 + +与已有两个入口的分工: + - admin「设备维度引导管理」按**设备**重置(该设备上所有账号一起),本脚本按**账号**; + - POST /api/v1/user/onboarding/reset 要客户端自己带 device_id 调,本脚本从库里反查设备。 + +用法(在项目根、已 pip install -e . 的环境里跑): + python scripts/reset_onboarding.py # 默认测试号 11111111111 + python scripts/reset_onboarding.py 13800138000 # 指定手机号 + python scripts/reset_onboarding.py --user-id 5 # 直接指定 user_id + python scripts/reset_onboarding.py --dry-run # 预览(照常执行再回滚),不落库 + python scripts/reset_onboarding.py --device-id abc123 # 只放开这一台设备,其余设备照旧跳过 + +走 SessionLocal 连 DATABASE_URL(SQLite / Postgres 都行),因此**默认只允许 APP_ENV=dev 改库** +(--dry-run 只读,任何环境都能跑)。线上确实要给某个用户开引导时加 --force —— 这张表只存 +"引导走过没"的标记,删了最坏结果是用户多看一次引导,不涉及金额/账目。 +""" +from __future__ import annotations + +import argparse +import sys + +from sqlalchemy import delete, select + +from app.core.config import settings +from app.db.session import SessionLocal, engine +from app.models.onboarding import OnboardingCompletion +from app.models.user import User + +# Windows 控制台默认 GBK,强制 UTF-8 否则中文输出乱码。stderr 也要设: +# SystemExit(如"用户不存在")的中文提示走的是 stderr。 +for _stream in (sys.stdout, sys.stderr): + if hasattr(_stream, "reconfigure"): + _stream.reconfigure(encoding="utf-8") + +# dev 下 engine 是 echo=True(APP_DEBUG),几十行 SQL 会把前后对比刷没。echo 走 SQLAlchemy 自己的 +# InstanceLogger,不吃 logging.setLevel,只能改 engine.echo。 +engine.echo = False + +DEFAULT_PHONE = "11111111111" + + +def resolve_user(db, phone: str, user_id: int | None) -> User: + if user_id is not None: + user = db.get(User, user_id) + if user is None: + raise SystemExit(f"user_id={user_id} 不存在") + return user + user = db.execute(select(User).where(User.phone == phone)).scalar_one_or_none() + if user is None: + raise SystemExit(f"手机号 {phone} 没有对应用户(注意 phone 才是登录账号,username 是展示 ID)") + return user + + +def print_state(db, user: User, device_id: str | None, label: str) -> None: + """打印该用户当前的引导完成标记。--device-id 时只看那一台,便于确认没误伤别的设备。""" + stmt = ( + select(OnboardingCompletion.device_id, OnboardingCompletion.completed_at) + .where(OnboardingCompletion.user_id == user.id) + .order_by(OnboardingCompletion.completed_at.desc()) + ) + if device_id: + stmt = stmt.where(OnboardingCompletion.device_id == device_id) + rows = db.execute(stmt).all() + + print(f"--- {label} ---") + scope = f"device_id={device_id}" if device_id else "全部设备" + if not rows: + print(f" onboarding_completion({scope}): (无) → 该用户会走引导") + return + print(f" onboarding_completion({scope}): {len(rows)} 条 → 这些设备上会跳过引导") + for did, at in rows: + print(f" device_id={did} 完成于 {at}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="重置指定用户的新手引导,让其重新进引导页") + parser.add_argument("phone", nargs="?", default=DEFAULT_PHONE, + help=f"手机号(默认 {DEFAULT_PHONE})") + parser.add_argument("--user-id", type=int, default=None, help="直接按 user_id 定位,优先于 phone") + parser.add_argument("--device-id", default=None, + help="只重置这一台设备(硬件级 ANDROID_ID);默认重置该用户所有设备") + parser.add_argument("--dry-run", action="store_true", help="预览,最后回滚不落库") + parser.add_argument("--force", action="store_true", help="非 dev 环境也允许改库(仅删引导标记,不涉及账目)") + args = parser.parse_args() + + if not args.dry_run and settings.APP_ENV != "dev" and not args.force: + raise SystemExit( + f"APP_ENV={settings.APP_ENV},默认只有 dev 能改库。确认要在该环境重置请加 --force" + "(--dry-run 只读,任意环境可跑)" + ) + + db = SessionLocal() + try: + user = resolve_user(db, args.phone, args.user_id) + print(f"DB: {settings.DATABASE_URL} APP_ENV: {settings.APP_ENV}") + print(f"用户: id={user.id} phone={user.phone} username={user.username}") + print_state(db, user, args.device_id, "before") + + stmt = delete(OnboardingCompletion).where(OnboardingCompletion.user_id == user.id) + if args.device_id: + stmt = stmt.where(OnboardingCompletion.device_id == args.device_id) + deleted = db.execute(stmt).rowcount + + if not deleted: + # 没记录本来就会走引导 —— 常见于:换了新设备、或运营/客户端已经重置过一次。 + print("该用户(该范围内)本来就没有完成标记,已经会走引导了,无需处理。") + db.rollback() + else: + print_state(db, user, args.device_id, "after") + if args.dry_run: + db.rollback() + print(f"(dry-run:以上 after 为预览,已回滚,库没动;真跑会删 {deleted} 条)") + return + db.commit() + print(f"完成:删掉 {deleted} 条完成标记,{user.phone} 下次登录会重走新手引导。") + + # 测试号无论库里有没有记录都恒走引导(见 app/core/test_account.py),提醒一句免得白跑 + if settings.test_account_phone and user.phone == settings.test_account_phone: + print(f"提示:{user.phone} 是配置的测试账号(TEST_ACCOUNT_PHONE)," + "登录响应 onboarding_completed 恒为 false、本就每次都走引导,无需重置。") + + print("提醒:客户端是在登录响应 / 启动时查 onboarding/status 的,已经在首页的 App 不会自动跳转," + "退出登录重进(或杀掉重开)才会看到引导页。") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/reset_signin_today.py b/scripts/reset_signin_today.py new file mode 100644 index 0000000..afff914 --- /dev/null +++ b/scripts/reset_signin_today.py @@ -0,0 +1,218 @@ +"""撤销指定用户「今天已签到」的状态,让今天可以重新签到,方便反复测试签到流程。 + +与 reset_signin.py 的区别:那个删**全部**签到历史(连续天数从头再来);本脚本只精确撤销 +**今天**这一次,昨天及以前的记录原样保留 —— 所以重签后 cycle_day / streak 会接着昨天继续, +7 天循环的档位不会被打乱,可以连着好几天测「第 N 档」的奖励。 + +默认是**完整撤销**(等于今天这次签到从没发生过): + 1. 删 signin_record 今天这行 → 今天变回未签到 + 2. 删今天的 signin 金币流水,并把金币从 coin_account 余额 / 累计收益里扣回 + +金币默认要退:签到流水**没有**唯一索引拦重复(ux_coin_transaction_task_ref 只覆盖 +biz_type LIKE 'task%'),不退的话每测一轮余额就白涨一次奖励,coin_transaction 里还会堆出 +同一 ref_id(日期)的重复流水,收益明细页会看到两条今天的签到。真想留着奖励用 --keep-coins。 + +例外:签到的金币若已被兑换成现金(余额已不够退),**自动跳过退款**并保留今天的签到流水。 +因为 coin_balance 必须恒等于流水总和,硬退会把余额退成负数 —— 夹到 0 又会吃掉别处赚的金币, +两种做法都会让账对不上。这时重签会再发一次奖励,余额多涨一档,属可接受的测试噪音。 + +用法(在项目根、已 pip install -e . 的环境里跑): + python scripts/reset_signin_today.py # 默认测试号 11111111111 + python scripts/reset_signin_today.py 13800138000 # 指定手机号 + python scripts/reset_signin_today.py --user-id 5 # 直接指定 user_id + python scripts/reset_signin_today.py --dry-run # 预览(照常执行再回滚),不落库 + python scripts/reset_signin_today.py --keep-coins # 只删签到记录,保留已发金币 + +「今天」直接复用 app.core.rewards.cn_today(北京时间),与签到判重同源,不自己算时区。 +走 SessionLocal 连 DATABASE_URL(SQLite / Postgres 都行),因此**只允许 APP_ENV=dev 时改库** +(--dry-run 只读,任何环境都能跑)。 +""" +from __future__ import annotations + +import argparse +import sys + +from sqlalchemy import select + +from app.core.config import settings +from app.core.rewards import cn_today +from app.db.session import SessionLocal, engine +from app.models.signin import SigninRecord +from app.models.user import User +from app.models.wallet import CoinAccount, CoinTransaction +from app.repositories import signin as crud_signin + +# Windows 控制台默认 GBK,强制 UTF-8 否则中文输出乱码 +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +# dev 下 engine 是 echo=True(APP_DEBUG),几十行 SQL 会把前后对比刷没。echo 走 SQLAlchemy 自己的 +# InstanceLogger,不吃 logging.setLevel,只能改 engine.echo。 +engine.echo = False + +DEFAULT_PHONE = "11111111111" + + +def resolve_user(db, phone: str, user_id: int | None) -> User: + if user_id is not None: + user = db.get(User, user_id) + if user is None: + raise SystemExit(f"user_id={user_id} 不存在") + return user + user = db.execute(select(User).where(User.phone == phone)).scalar_one_or_none() + if user is None: + raise SystemExit(f"手机号 {phone} 没有对应用户(注意 phone 才是登录账号,username 是展示 ID)") + return user + + + +def print_state(db, user: User, today, label: str) -> None: + print(f"--- {label} ---") + rec = db.execute( + select(SigninRecord).where( + SigninRecord.user_id == user.id, SigninRecord.signin_date == today + ) + ).scalar_one_or_none() + if rec is None: + print(f" signin_record {today}: (无)") + else: + print(f" signin_record {today}: 第{rec.cycle_day}档 连续{rec.streak}天 +{rec.coin_awarded}金币") + + last = db.execute( + select(SigninRecord.signin_date) + .where(SigninRecord.user_id == user.id) + .order_by(SigninRecord.signin_date.desc()) + .limit(1) + ).scalar_one_or_none() + print(f" 最近一次签到: {last or '(从未签到)'}") + + + rows = db.execute( + select(CoinTransaction).where( + CoinTransaction.user_id == user.id, + CoinTransaction.biz_type == "signin", + CoinTransaction.ref_id == today.isoformat(), + ) + ).scalars().all() + print(f" coin_transaction(signin, 今天): {len(rows)} 条 / {sum(r.amount for r in rows)} 金币") + + acc = db.get(CoinAccount, user.id) + if acc is None: + print(" coin_account: (无)") + else: + print(f" coin_account: coin={acc.coin_balance} earned={acc.total_coin_earned}") + + # 用 App 自己的 get_status 复核,而不是脚本里重算一遍规则 —— 这行就是客户端会看到的 + st = crud_signin.get_status(db, user.id) + print(f" [签到接口] can_claim={st.can_claim} today_signed={st.today_signed} " + f"今天第{st.today_cycle_day}档({st.today_coin}金币) 已连续{st.consecutive_days}天") + + +def refund_today(db, user_id: int, today) -> None: + """退回今天签到(含膨胀)发的金币:删流水 + 扣余额。 + + 不变量:coin_balance 必须恒等于流水总和。所以余额不够退时**整笔跳过**,而不是硬退成 + 负数、或夹到 0 —— 夹到 0 会吃掉用户在别处赚的金币,两种做法都会让余额和流水对不上。 + """ + rows = list(db.execute( + select(CoinTransaction).where( + CoinTransaction.user_id == user_id, + CoinTransaction.biz_type == "signin", + CoinTransaction.ref_id == today.isoformat(), + ) + ).scalars().all()) + if not rows: + return + acc = db.get(CoinAccount, user_id) + if acc is None: + return + + # 从最近一笔往回退,退到余额兜不住为止:正常情况下今天只有一笔,整笔退掉 = 干净的撤销。 + # 少数情况今天堆了多笔(上一轮测试时金币已被兑换、退不掉而留下的),这样也能保证 + # 「本轮新发的那笔」一定被退掉 —— 否则每测一轮余额就永久多涨一档。 + rows.sort(key=lambda r: r.id, reverse=True) + refundable: list[CoinTransaction] = [] + total = 0 + for r in rows: + if total + r.amount > acc.coin_balance: + break + refundable.append(r) + total += r.amount + + for r in refundable: + db.delete(r) + if total: + acc.coin_balance -= total + acc.total_coin_earned = max(0, acc.total_coin_earned - total) + print(f" 已退回 {total} 金币({len(refundable)}/{len(rows)} 笔)") + + stuck = len(rows) - len(refundable) + if stuck: + # 典型场景:签完就把金币兑换成现金了(exchange_out),这笔奖励已经变成 cash_balance_cents, + # 余额里已经没有它了。硬退会把余额退成负数 / 夹到 0 又会吃掉别处赚的金币,两者都会让账对不上。 + print(f" ⚠️ 还有 {stuck} 笔今天的签到流水退不掉(金币已被兑换/花掉,余额 {acc.coin_balance} 兜不住)," + f"原样保留 —— 硬退会让余额和流水总和对不上。") + print(" → 收益明细今天会多出几条签到记录,不影响签到功能测试;想彻底清干净用 reset_signin.py --with-coins。") + + +def main() -> None: + parser = argparse.ArgumentParser(description="撤销用户今天的签到,让今天能重新签") + parser.add_argument("phone", nargs="?", default=DEFAULT_PHONE, + help=f"手机号(默认 {DEFAULT_PHONE})") + parser.add_argument("--user-id", type=int, default=None, help="直接按 user_id 定位,优先于 phone") + parser.add_argument("--keep-coins", action="store_true", + help="不退已发金币(余额会越测越高,且留下重复流水)") + parser.add_argument("--dry-run", action="store_true", help="预览,最后回滚不落库") + args = parser.parse_args() + + if not args.dry_run and settings.APP_ENV != "dev": + raise SystemExit(f"APP_ENV={settings.APP_ENV},拒绝改库(只有 dev 能改;--dry-run 可任意环境)") + + today = cn_today() + db = SessionLocal() + try: + user = resolve_user(db, args.phone, args.user_id) + print(f"DB: {settings.DATABASE_URL}") + print(f"用户: id={user.id} phone={user.phone} 今天(北京): {today} keep_coins: {args.keep_coins}") + print_state(db, user, today, "before") + + # 今天的签到记录 —— 只删今天,昨天及以前保留,重签后 streak 接着涨 + rec = db.execute( + select(SigninRecord).where( + SigninRecord.user_id == user.id, SigninRecord.signin_date == today + ) + ).scalar_one_or_none() + if rec is not None: + db.delete(rec) + + if rec is None: + print("今天本来就没签到,无需处理。") + db.rollback() + return + + # 退金币 + if args.keep_coins: + print("(--keep-coins:保留已发金币,流水和余额不动)") + else: + refund_today(db, user.id, today) + # 注:更早流水的 balance_after 是当时的快照,不回改 —— 收益明细里历史行的 + # 余额列会与现余额对不上,dev 测试库无妨。 + + # SessionLocal 是 autoflush=False,不 flush 的话下面 print_state 的 select + # 读到的还是删之前的旧行,"after" 会骗人 + db.flush() + print_state(db, user, today, "after") + + if args.dry_run: + db.rollback() + print("(dry-run:以上 after 为预览,已回滚,库没动)") + return + db.commit() + print(f"完成:{user.phone} 今天({today})可以重新签到了。" + f"提醒:App 内存状态不会自动同步,杀掉重进福利页(当天未签到)会重新自动弹签到弹窗。") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/tests/test_ad_reward.py b/tests/test_ad_reward.py index ebc0c98..1b1603b 100644 --- a/tests/test_ad_reward.py +++ b/tests/test_ad_reward.py @@ -356,3 +356,272 @@ def test_callback_disabled_returns_503(client, monkeypatch) -> None: # 503 发生在验签/发奖之前,不需要真实用户 r = _callback(client, _signed(1, "trans_disabled")) assert r.status_code == 503, r.text + + +# ===== 按 ad_session_id 查权威发奖结果(GET /reward-result/{ad_session_id})===== +# 客户端看完广告轮询它拿弹窗金额:只认 status='granted' 且 coin>0,其余一律不弹。 + + +def _reward_result(client, token: str, session_id: str): + return client.get(f"/api/v1/ad/reward-result/{session_id}", headers=_auth(token)) + + +def _session_extra(session_id: str, **kv: str) -> str: + return json.dumps({"ad_session_id": session_id, **kv}) + + +def test_reward_result_pending_when_s2s_not_arrived(client) -> None: + """S2S 还没回调 → 200 + pending(**不是 404**),客户端据此继续轮询。""" + token = _login(client, "13800003601") + + r = _reward_result(client, token, "sess-not-yet-arrived") + assert r.status_code == 200, r.text + assert r.json() == { + "ad_session_id": "sess-not-yet-arrived", + "status": "pending", + "coin": None, + # 没记录 → 连属于哪一轮都不知道,累计值一并为 null(不是 0,0 会被读成"本轮没赚到") + "round_coin": None, + } + + +def test_reward_result_returns_granted_coin(client) -> None: + """S2S 发奖后按会话查 → granted + 本次真实到账额(与钱包入账一致)。""" + phone = "13800003602" + token = _login(client, phone) + uid = _user_id(phone) + session_id = "sess-granted-1" + + r = _callback( + client, + _signed(uid, "trans_rr_1", ecpm="200", extra=_session_extra(session_id)), + ) + assert r.json() == {"is_verify": True, "reason": 0} + + expected = calculate_ad_reward_coin("200", 1) + body = _reward_result(client, token, session_id).json() + assert body["status"] == "granted" + assert body["coin"] == expected + # 弹窗金额必须等于真实入账,这正是本接口存在的意义(不用余额差估算) + assert _coin_balance(client, token) == expected + + +def test_reward_result_prefers_granted_over_earlier_noshow(client) -> None: + """竞态:客户端先报 closed_early、S2S 随后才到 → 同一会话两条记录,必须返回 granted 那条。 + + 只按 created_at 取最近一条是不够的(SQLite 下两条可能同一时间戳),故仓储层显式优先 granted。 + """ + phone = "13800003603" + token = _login(client, phone) + uid = _user_id(phone) + session_id = "sess-race-noshow" + + # 1) 客户端以为没发奖,先留痕 + r = client.post( + "/api/v1/ad/reward-noshow", + json={"ad_session_id": session_id, "watched_seconds": 3}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + assert r.json()["status"] == "closed_early" + assert _reward_result(client, token, session_id).json()["status"] == "closed_early" + + # 2) S2S 姗姗来迟,真发了钱 + _callback(client, _signed(uid, "trans_rr_race", ecpm="200", extra=_session_extra(session_id))) + + body = _reward_result(client, token, session_id).json() + assert body["status"] == "granted" + assert body["coin"] == calculate_ad_reward_coin("200", 1) + + +def test_reward_result_capped_reports_zero_not_popup(client) -> None: + """达每日上限 → capped + coin=0;客户端不弹「获得 0 金币」。""" + phone = "13800003604" + token = _login(client, phone) + uid = _user_id(phone) + session_id = "sess-capped-1" + + db = SessionLocal() + try: + db.add( + AdRewardRecord( + trans_id="trans_rr_capped", user_id=_user_id(phone), coin=0, status="capped", + reward_scene="reward_video", ad_session_id=session_id, reward_date="2026-07-17", + ) + ) + db.commit() + finally: + db.close() + assert uid # 记录挂在该用户名下 + + body = _reward_result(client, token, session_id).json() + assert body["status"] == "capped" + assert body["coin"] == 0 + + +def test_reward_result_scoped_to_owner(client) -> None: + """别人的会话查不到(按 user_id 收窄)→ pending,不泄漏他人发奖结果。""" + phone_a = "13800003605" + token_a = _login(client, phone_a) + uid_a = _user_id(phone_a) + token_b = _login(client, "13800003606") + session_id = "sess-owner-only" + + _callback(client, _signed(uid_a, "trans_rr_owner", ecpm="200", extra=_session_extra(session_id))) + + assert _reward_result(client, token_a, session_id).json()["status"] == "granted" + assert _reward_result(client, token_b, session_id).json()["status"] == "pending" + + +def test_reward_result_requires_auth(client) -> None: + """无 Bearer → 401,不裸奔。""" + assert client.get("/api/v1/ad/reward-result/sess-anon-1").status_code == 401 + + +# ===== 膨胀轮累计(boost_round_id → reward-result.round_coin)===== +# 不变量:弹窗数字 == 本轮实际到账之和 == 余额涨幅。三者对不上用户就认为少发了钱。 + + +def _round_extra(session_id: str, round_id: str | None = None, **kv: str) -> str: + data = {"ad_session_id": session_id, **kv} + if round_id is not None: + data["boost_round_id"] = round_id + return json.dumps(data) + + +def test_round_coin_accumulates_across_ads_in_same_round(client) -> None: + """一轮连看两条 → round_coin 逐条累计,且等于余额涨幅(第七节验收 1、2 步)。""" + phone = "13800003701" + token = _login(client, phone) + uid = _user_id(phone) + round_id = "b7e1c93a4f6d802b" + + _callback(client, _signed( + uid, "trans_round_1", ecpm="200", extra=_round_extra("sess-r1-a", round_id))) + first = calculate_ad_reward_coin("200", 1) + body = _reward_result(client, token, "sess-r1-a").json() + assert body["coin"] == first + assert body["round_coin"] == first # 第 1 条:本轮累计 == 本条 + + _callback(client, _signed( + uid, "trans_round_2", ecpm="200", extra=_round_extra("sess-r1-b", round_id))) + second = calculate_ad_reward_coin("200", 2) # LT 因子递减,第 2 条比第 1 条少 + body = _reward_result(client, token, "sess-r1-b").json() + assert body["coin"] == second + assert body["round_coin"] == first + second # 累计 = 两条之和 + + # 弹窗数字必须等于真实余额涨幅 —— 这条不变量是整个方案的目的 + assert _coin_balance(client, token) == first + second + + +def test_new_round_restarts_accumulation(client) -> None: + """换新轮 id → round_coin 从头累计,不接着上一轮往上加(第七节最后一句验收)。""" + phone = "13800003702" + token = _login(client, phone) + uid = _user_id(phone) + + _callback(client, _signed(uid, "trans_r2_old", ecpm="200", extra=_round_extra("sess-r2-a", "round-old"))) + old = _reward_result(client, token, "sess-r2-a").json()["round_coin"] + assert old > 0 + + _callback(client, _signed(uid, "trans_r2_new", ecpm="200", extra=_round_extra("sess-r2-b", "round-new"))) + body = _reward_result(client, token, "sess-r2-b").json() + assert body["round_coin"] == body["coin"] # 新轮 = 只有本条 + assert body["round_coin"] != old + body["coin"] + + +def test_round_coin_null_without_round_id(client) -> None: + """extra 没带 boost_round_id(老客户端 / GroMore 丢字段)→ round_coin=null,客户端退回显示单条。""" + phone = "13800003703" + token = _login(client, phone) + uid = _user_id(phone) + + _callback(client, _signed(uid, "trans_r3", ecpm="200", extra=_round_extra("sess-r3-noround", None))) + body = _reward_result(client, token, "sess-r3-noround").json() + assert body["coin"] == calculate_ad_reward_coin("200", 1) + assert body["round_coin"] is None + + +def test_round_coin_null_when_pending(client) -> None: + """S2S 未到账 → 没有记录 → 连轮 id 都不知道,round_coin 也是 null(不是 0)。""" + token = _login(client, "13800003704") + body = _reward_result(client, token, "sess-r4-pending").json() + assert body == { + "ad_session_id": "sess-r4-pending", + "status": "pending", + "coin": None, + "round_coin": None, + } + + +def test_round_coin_returned_on_capped(client) -> None: + """撞每日上限那条不是 granted,但 round_coin **仍返本轮累计**(该条按 0 计)。 + + 客户端的限额 toast 要显示前面几条已到账的总额,不能是空。 + """ + phone = "13800003705" + token = _login(client, phone) + uid = _user_id(phone) + round_id = "round-capped" + + _callback(client, _signed(uid, "trans_cap_ok", ecpm="200", extra=_round_extra("sess-cap-a", round_id))) + earned = _reward_result(client, token, "sess-cap-a").json()["round_coin"] + assert earned > 0 + + # 手插一条同轮的 capped 记录(跑满 500 次太慢),模拟第 N 条撞上限 + db = SessionLocal() + try: + db.add(AdRewardRecord( + trans_id="trans_cap_hit", user_id=uid, coin=0, status="capped", + reward_scene="reward_video", ad_session_id="sess-cap-b", + reward_date="2026-07-20", boost_round_id=round_id, + )) + db.commit() + finally: + db.close() + + body = _reward_result(client, token, "sess-cap-b").json() + assert body["status"] == "capped" + assert body["coin"] == 0 # 这条没发钱 + assert body["round_coin"] == earned # 但本轮累计照常返回 + + +def test_round_coin_scoped_to_owner(client) -> None: + """轮 id 是客户端生成的,不能跨用户信任:拿别人的轮 id 查不到别人的金币。""" + phone_a = "13800003706" + token_a = _login(client, phone_a) + uid_a = _user_id(phone_a) + phone_b = "13800003707" + token_b = _login(client, phone_b) + uid_b = _user_id(phone_b) + shared_round = "round-collision" + + _callback(client, _signed(uid_a, "trans_own_a", ecpm="200", extra=_round_extra("sess-own-a", shared_round))) + a_total = _reward_result(client, token_a, "sess-own-a").json()["round_coin"] + + # B 用同一个轮 id(伪造或碰撞)看一条:B 的累计里不能混进 A 的钱 + _callback(client, _signed(uid_b, "trans_own_b", ecpm="200", extra=_round_extra("sess-own-b", shared_round))) + b_body = _reward_result(client, token_b, "sess-own-b").json() + assert b_body["round_coin"] == b_body["coin"] + assert b_body["round_coin"] < a_total + b_body["coin"] + + +def test_test_grant_accepts_boost_round_id(client, monkeypatch) -> None: + """debug 的 test-grant 不经 S2S、拿不到 mediaExtra,轮 id 由 body 补 → 本地也能验累计。""" + monkeypatch.setattr(settings, "AD_REWARD_TEST_GRANT_ENABLED", True) + token = _login(client, "13800003708") + round_id = "round-testgrant" + + coins = [] + for i in range(2): + r = client.post( + "/api/v1/ad/test-grant", + json={"reward_scene": "reward_video", "boost_round_id": round_id, + "ad_session_id": f"sess-tg-{i}-padding"}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + coins.append(r.json()["coin"]) + + body = _reward_result(client, token, "sess-tg-1-padding").json() + assert body["round_coin"] == sum(coins) diff --git a/tests/test_welfare.py b/tests/test_welfare.py index 07af7bc..bd99334 100644 --- a/tests/test_welfare.py +++ b/tests/test_welfare.py @@ -11,7 +11,6 @@ from app.core.rewards import ( COIN_PER_CENT, COIN_PER_YUAN, MIN_EXCHANGE_COIN, - SIGNIN_BOOST_COIN, SIGNIN_REWARDS, TASK_ENABLE_NOTIFICATION, TASK_REWARDS, @@ -106,48 +105,6 @@ def test_signin_flow(client) -> None: assert txn["balance_after"] == SIGNIN_REWARDS[0] -def test_signin_boost_flow(client) -> None: - """签到后看广告膨胀 → S2S 固定补发 2000 金币,每天只能膨胀一次。""" - phone = "13800001011" - token = _login(client, phone) - - r = client.post("/api/v1/signin/boost", json={}, headers=_auth(token)) - assert r.status_code == 409 - - r = client.post("/api/v1/signin", headers=_auth(token)) - assert r.status_code == 200, r.text - first_coin = r.json()["coin_awarded"] - - with SessionLocal() as db: - user = get_user_by_phone(db, phone) - assert user is not None - uid = user.id - - extra = json.dumps({"reward_scene": "signin_boost", "ad_session_id": "signin-session-1"}) - r = client.get( - "/api/v1/ad/pangle-callback", - params=_signed_ad_callback(uid, "signin-boost-trans-1", extra=extra, ecpm="200"), - ) - assert r.status_code == 200, r.text - assert r.json() == {"is_verify": True, "reason": 0} - - r = client.post("/api/v1/signin/boost", json={"ad_ref_id": "signin-boost-trans-1"}, headers=_auth(token)) - assert r.status_code == 200, r.text - body = r.json() - assert body["coin_awarded"] == SIGNIN_BOOST_COIN - assert body["coin_balance"] == first_coin + SIGNIN_BOOST_COIN - - r = client.get( - "/api/v1/ad/pangle-callback", - params=_signed_ad_callback(uid, "signin-boost-trans-2", extra=extra, ecpm="200"), - ) - assert r.status_code == 200, r.text - - r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token)) - types = [t["biz_type"] for t in r.json()["items"]] - assert "signin" in types - assert "signin_boost" in types - def test_task_claim_flow(client) -> None: """打开消息提醒=可重复任务:每次领取金额减半(750/375/188),claimed 恒 False,余额累加。""" From 510df176b31e0d3ff8f3f4b1bb3f8db16d9fd3e9 Mon Sep 17 00:00:00 2001 From: linkeyu Date: Wed, 22 Jul 2026 11:46:00 +0800 Subject: [PATCH 10/42] =?UTF-8?q?feat(admin):=20=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E9=80=90=E5=9C=BA=E9=A2=86=E5=88=B8=E7=82=B9=E4=BD=8D=E5=88=86?= =?UTF-8?q?=E6=95=B0=E4=B8=8E=E6=98=8E=E7=BB=86=20(#153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 变更内容 - 按 trace_id 批量统计每场领券成功数/尝试数 - success、already_claimed 计成功,failed 计尝试,skipped 排除 - 返回每个点位的名称、ID、状态和失败原因 - 无有效逐券埋点时返回空值,不伪造 0/0 - 用户领券记录抽屉同步返回点位分数及明细 ## 性能 - 当前页全部 trace_id 一次批量查询,不产生逐行请求 ## 验证 - 16 项后端测试通过 - 覆盖成功、已领、失败、跳过及失败原因 --------- Co-authored-by: guke Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/153 Co-authored-by: linkeyu Co-committed-by: linkeyu --- app/admin/repositories/coupon_data.py | 81 +++++++++++-- app/admin/routers/coupon_data.py | 18 +++ app/admin/schemas/coupon_data.py | 22 ++++ tests/test_coupon_point_score.py | 161 ++++++++++++++++++++++++++ 4 files changed, 275 insertions(+), 7 deletions(-) create mode 100644 tests/test_coupon_point_score.py diff --git a/app/admin/repositories/coupon_data.py b/app/admin/repositories/coupon_data.py index 0450404..422136e 100644 --- a/app/admin/repositories/coupon_data.py +++ b/app/admin/repositories/coupon_data.py @@ -21,6 +21,9 @@ from app.models.user import User from app.repositories import ad_ecpm as crud_ecpm from app.repositories.coupon_state import DEFAULT_PLATFORMS, coupon_id_to_platform +_SLOT_OK = ("success", "already_claimed") +_SLOT_TRIED = ("success", "already_claimed", "failed") + def _cn_hour(dt: datetime) -> int: """started_at(UTC 口径)→ 北京时间小时(0–23)。naive 当 UTC(sqlite),tz-aware 直接换算(pg)。""" @@ -86,7 +89,13 @@ def _success_rates(rows: list) -> dict: } -def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad_revenue_yuan: float = 0.0) -> dict: +def _session_to_row( + r, + phone: str | None = None, + nickname: str | None = None, + ad_revenue_yuan: float = 0.0, + point_stats: dict | None = None, +) -> dict: """CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。""" return { "id": r.id, @@ -104,11 +113,60 @@ def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad "app_env": r.app_env, "started_at": r.started_at, "claimed_count": r.claimed_count, + "point_success_count": point_stats["succeeded"] if point_stats else None, + "point_total_count": point_stats["tried"] if point_stats else None, "trace_url": r.trace_url, "ad_revenue_yuan": ad_revenue_yuan, } +def _point_scores_by_trace(db: Session, trace_ids: list[str]) -> dict[str, dict[str, int]]: + """聚合查询批量返回逐场点位分数,不加载逐券明细。""" + if not trace_ids: + return {} + succeeded = func.sum(case((CouponClaimRecord.status.in_(_SLOT_OK), 1), else_=0)) + rows = db.execute( + select( + CouponClaimRecord.trace_id, + succeeded.label("succeeded"), + func.count().label("tried"), + ) + .where( + CouponClaimRecord.trace_id.in_(trace_ids), + CouponClaimRecord.status.in_(_SLOT_TRIED), + ) + .group_by(CouponClaimRecord.trace_id) + ).all() + return { + trace_id: {"succeeded": int(success_count or 0), "tried": int(tried or 0)} + for trace_id, success_count, tried in rows + if trace_id is not None + } + + +def coupon_point_details(db: Session, *, trace_id: str) -> list[dict]: + """按单个 trace 查询逐券结果;仅在后台用户点击分数时调用。""" + rows = db.execute( + select( + CouponClaimRecord.coupon_id, + CouponClaimRecord.coupon_name, + CouponClaimRecord.status, + CouponClaimRecord.reason, + ) + .where(CouponClaimRecord.trace_id == trace_id) + .order_by(CouponClaimRecord.id) + ).all() + return [ + { + "coupon_id": coupon_id, + "coupon_name": coupon_name, + "status": status, + "reason": reason, + } + for coupon_id, coupon_name, status, reason in rows + ] + + def _empty_result() -> dict: return { "summary": { @@ -249,10 +307,17 @@ def coupon_data_report( ).all() } rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in page]) + point_stats_map = _point_scores_by_trace(db, [r.trace_id for r in page]) items = [] for r in page: phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None) - items.append(_session_to_row(r, phone, nickname, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0))) + items.append(_session_to_row( + r, + phone, + nickname, + ad_revenue_yuan=rev_map.get(r.trace_id, 0.0), + point_stats=point_stats_map.get(r.trace_id), + )) return { "summary": summary, @@ -276,15 +341,17 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict: ).scalar_one() rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows]) return { - "items": [_session_to_row(r, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)) for r in rows], + "items": [ + _session_to_row( + r, + ad_revenue_yuan=rev_map.get(r.trace_id, 0.0), + ) + for r in rows + ], "total": int(total), } -_SLOT_OK = ("success", "already_claimed") -_SLOT_TRIED = ("success", "already_claimed", "failed") - - def coupon_slot_report( db: Session, *, date_from: str, date_to: str, app_env: str | None = None ) -> dict: diff --git a/app/admin/routers/coupon_data.py b/app/admin/routers/coupon_data.py index 82a882d..89a23e2 100644 --- a/app/admin/routers/coupon_data.py +++ b/app/admin/routers/coupon_data.py @@ -18,6 +18,8 @@ from app.admin.schemas.coupon_data import ( CouponDataOut, CouponDataRow, CouponDataSummary, + CouponPointDetail, + CouponPointDetailsOut, CouponSlotRow, CouponSlotsOut, CouponUserRecordsOut, @@ -122,6 +124,22 @@ def get_coupon_slots( ) +@router.get( + "/point-details", + response_model=CouponPointDetailsOut, + summary="按 trace 查询单次领券任务的逐券点位明细", +) +def get_coupon_point_details( + db: AdminDb, + trace_id: Annotated[str, Query(min_length=1, max_length=64, description="领券 trace_id")], +) -> CouponPointDetailsOut: + items = coupon_data.coupon_point_details(db, trace_id=trace_id) + return CouponPointDetailsOut( + trace_id=trace_id, + items=[CouponPointDetail(**item) for item in items], + ) + + @router.get( "/user-records", response_model=CouponUserRecordsOut, diff --git a/app/admin/schemas/coupon_data.py b/app/admin/schemas/coupon_data.py index c560397..f295650 100644 --- a/app/admin/schemas/coupon_data.py +++ b/app/admin/schemas/coupon_data.py @@ -49,6 +49,15 @@ class CouponDataHourly(BaseModel): avg_elapsed_ms: int | None = None +class CouponPointDetail(BaseModel): + """一次领券任务中的单券点位结果。""" + + coupon_id: str + coupon_name: str | None = None + status: str = Field(..., description="success / already_claimed / failed / skipped") + reason: str | None = None + + class CouponDataRow(BaseModel): """一条领券明细(一次领券任务)。""" @@ -69,6 +78,12 @@ class CouponDataRow(BaseModel): app_env: str | None = None started_at: datetime = Field(..., description="发起时刻(明细「时间」列)") claimed_count: int | None = None + point_success_count: int | None = Field( + None, description="本次成功券点位数(success+already_claimed);无逐券埋点为空" + ) + point_total_count: int | None = Field( + None, description="本次尝试券点位数(success+already_claimed+failed,不含 skipped);无逐券埋点为空" + ) trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id") ad_revenue_yuan: float = Field( 0.0, description="本次领券看的信息流广告预估收益(元);按 trace_id 聚合 ad_ecpm_record" @@ -89,6 +104,13 @@ class CouponDataOut(BaseModel): items: list[CouponDataRow] = Field(..., description="逐条领券明细(当前页)") +class CouponPointDetailsOut(BaseModel): + """单次领券任务的逐券点位结果,供点击分数时按需加载。""" + + trace_id: str + items: list[CouponPointDetail] = Field(default_factory=list) + + class CouponUserRecordsOut(BaseModel): """某用户全部领券记录(点手机号抽屉用):total=该用户领券总次数,items=记录列表(UserRecordsDrawer 渲染)。""" diff --git a/tests/test_coupon_point_score.py b/tests/test_coupon_point_score.py new file mode 100644 index 0000000..5108fcf --- /dev/null +++ b/tests/test_coupon_point_score.py @@ -0,0 +1,161 @@ +"""admin 领券明细逐场点位分数与按需明细。""" +from datetime import UTC, date, datetime + +from fastapi.testclient import TestClient +from sqlalchemy import delete + +from app.admin.main import admin_app +from app.admin.repositories import admin_user as admin_repo +from app.admin.repositories.coupon_data import ( + _point_scores_by_trace, + coupon_data_report, + coupon_point_details, +) +from app.admin.security import create_admin_token +from app.db.session import SessionLocal +from app.models.coupon_state import CouponClaimRecord, CouponSession + + +def test_point_scores_by_trace() -> None: + """已领算成功、失败算尝试、跳过不进分母。""" + db = SessionLocal() + trace = "point-score-trace" + try: + db.add_all([ + CouponClaimRecord( + device_id="score-device", + coupon_id=f"mt-score-{status}", + claim_date=date(2020, 1, 2), + status=status, + coupon_name=f"测试点位-{status}", + reason="测试失败" if status == "failed" else None, + trace_id=trace, + ) + for status in ("success", "already_claimed", "failed", "skipped") + ]) + db.flush() + + stats = _point_scores_by_trace(db, [trace])[trace] + assert stats["succeeded"] == 2 + assert stats["tried"] == 3 + details = coupon_point_details(db, trace_id=trace) + assert [item["status"] for item in details] == [ + "success", "already_claimed", "failed", "skipped" + ] + assert details[2]["reason"] == "测试失败" + finally: + db.rollback() + db.close() + + +def test_skipped_detail_does_not_create_a_score() -> None: + """仅有 skipped 时按需明细仍可查到,但列表没有虚假的 0/0 分数。""" + db = SessionLocal() + trace = "point-score-skipped" + try: + db.add(CouponClaimRecord( + device_id="score-device-skipped", + coupon_id="mt-score-skipped-only", + claim_date=date(2020, 1, 2), + status="skipped", + trace_id=trace, + )) + db.flush() + + scores = _point_scores_by_trace(db, [trace, "missing-trace"]) + assert trace not in scores + assert "missing-trace" not in scores + assert coupon_point_details(db, trace_id=trace)[0]["status"] == "skipped" + finally: + db.rollback() + db.close() + + +def test_coupon_data_report_returns_scores_without_embedding_details() -> None: + """主列表只返回聚合分数,逐券记录必须走按 trace 的明细查询。""" + db = SessionLocal() + trace = "point-score-report" + report_date = date(2020, 1, 4) + try: + db.add(CouponSession( + trace_id=trace, + device_id="score-report-device", + status="completed", + app_env="prod", + platforms=["meituan-waimai"], + started_at=datetime(2020, 1, 4, tzinfo=UTC), + started_date=report_date, + )) + db.add_all([ + CouponClaimRecord( + device_id="score-report-device", + coupon_id=f"mt-report-{status}", + claim_date=report_date, + status=status, + trace_id=trace, + ) + for status in ("success", "failed") + ]) + db.flush() + + report = coupon_data_report( + db, + date_from=report_date.isoformat(), + date_to=report_date.isoformat(), + app_env="prod", + ) + row = next(item for item in report["items"] if item["trace_id"] == trace) + assert row["point_success_count"] == 1 + assert row["point_total_count"] == 2 + assert "point_details" not in row + assert len(coupon_point_details(db, trace_id=trace)) == 2 + finally: + db.rollback() + db.close() + + +def test_coupon_point_details_endpoint() -> None: + """前端点击使用的接口按约定返回 trace_id 和逐券 items。""" + db = SessionLocal() + trace = "point-details-endpoint" + try: + admin = admin_repo.get_by_username(db, "point_details_admin") + if admin is None: + admin = admin_repo.create_admin( + db, + username="point_details_admin", + password="pass1234", + role="super_admin", + ) + token, _expires_at = create_admin_token(admin_id=admin.id, role=admin.role) + db.add(CouponClaimRecord( + device_id="point-details-endpoint-device", + coupon_id="mt-point-details-endpoint", + coupon_name="接口测试券", + claim_date=date(2020, 1, 5), + status="failed", + reason="接口测试失败", + trace_id=trace, + )) + db.commit() + + response = TestClient(admin_app).get( + "/admin/api/coupon-data/point-details", + params={"trace_id": trace}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200, response.text + assert response.json() == { + "trace_id": trace, + "items": [{ + "coupon_id": "mt-point-details-endpoint", + "coupon_name": "接口测试券", + "status": "failed", + "reason": "接口测试失败", + }], + } + finally: + db.rollback() + db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.trace_id == trace)) + db.commit() + db.close() From 2eb36b44c86300397458756324f3e72a23d5ae5f Mon Sep 17 00:00:00 2001 From: linkeyu Date: Wed, 22 Jul 2026 12:09:06 +0800 Subject: [PATCH 11/42] =?UTF-8?q?fix(admin):=20=E6=8C=89=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E7=99=BD=E5=90=8D=E5=8D=95=E8=81=9A=E5=90=88=E5=B8=B8=E8=A7=84?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E9=87=91=E5=B8=81=20(#157)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景 大盘“常规任务金币”原先采用“全部正向金币减排除清单”的反向口径。线上新增 `feed_ad_reward_coupon` / `feed_ad_reward_comparison` 后未同步加入排除清单,导致领券和比价奖励误计入常规任务金币。 ## 修改 - 改为明确白名单:`signin`、历史 `signin_boost`、全部 `task_` 任务、`price_report_reward`、`feedback_reward` - 未知新 `biz_type` 默认不进入常规任务桶 - 增加覆盖领券、比价、广告、邀请、管理员及未知类型的回归测试 - 顺带修复改动文件已有的 Ruff `UP017` ## 验证 - 线上只读 PostgreSQL:新口径全量为 108,022,领券/比价误计差额为 450,675 - Ruff:通过 - `pytest tests/test_admin_read.py tests/test_cps_admin.py -q`:23 passed --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/157 Co-authored-by: linkeyu Co-committed-by: linkeyu --- app/admin/repositories/stats.py | 30 +++++++++-------- tests/test_admin_read.py | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 14 deletions(-) diff --git a/app/admin/repositories/stats.py b/app/admin/repositories/stats.py index fc6a83a..e82f5b6 100644 --- a/app/admin/repositories/stats.py +++ b/app/admin/repositories/stats.py @@ -6,10 +6,10 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索 from __future__ import annotations from collections import Counter -from datetime import date, datetime, time, timedelta, timezone +from datetime import UTC, date, datetime, time, timedelta, timezone from decimal import ROUND_HALF_UP, Decimal, InvalidOperation -from sqlalchemy import case, func, select +from sqlalchemy import case, func, or_, select from sqlalchemy.orm import Session from app.admin.repositories.coupon_data import _percentile @@ -37,14 +37,13 @@ REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward") # ad_reward 是激励视频,单独成桶、不再混进领券奖励(历史误并会把激励视频金币双计进领券)。 COUPON_REWARD_BIZ_TYPES = ("coupon", "coupon_reward") COMPARISON_REWARD_BIZ_TYPES = ("comparison", "compare_reward", "comparison_reward") -EXCLUDED_REWARD_BIZ_TYPES = ("invite_inviter", "invite_invitee", "admin_grant") -UNCLASSIFIED_FEED_BIZ_TYPES = ("feed_ad_reward",) -REGULAR_TASK_EXCLUDED_BIZ_TYPES = ( - *REWARD_VIDEO_BIZ_TYPES, - *COUPON_REWARD_BIZ_TYPES, - *COMPARISON_REWARD_BIZ_TYPES, - *EXCLUDED_REWARD_BIZ_TYPES, - *UNCLASSIFIED_FEED_BIZ_TYPES, +# 常规任务必须按明确来源相加;不能从全部正向流水反减排除项,否则新增广告/运营 +# biz_type 时会在排除清单更新前自动混入该桶。task_ 前缀在查询处单独覆盖现有及未来任务。 +REGULAR_TASK_EXACT_BIZ_TYPES = ( + "signin", + "signin_boost", + "price_report_reward", + "feedback_reward", ) MEITUAN_CPS_INVALID_STATUSES = ("4", "5") MEITUAN_CPS_SETTLED_STATUS = "6" @@ -61,7 +60,7 @@ def _beijing_today_start_utc() -> datetime: """北京时间今天 0 点对应的 UTC 时刻(DAU / 今日新增按北京时区切天)。""" now_bj = datetime.now(_BEIJING) start_bj = now_bj.replace(hour=0, minute=0, second=0, microsecond=0) - return start_bj.astimezone(timezone.utc) + return start_bj.astimezone(UTC) def today_dau(db: Session) -> int: @@ -94,8 +93,8 @@ def _period_bounds(date_from: date, date_to: date) -> tuple[datetime, datetime, """ start_bj = datetime.combine(date_from, time.min, tzinfo=_BEIJING) end_bj = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=_BEIJING) - start_utc = start_bj.astimezone(timezone.utc) - end_utc = end_bj.astimezone(timezone.utc) + start_utc = start_bj.astimezone(UTC) + end_utc = end_bj.astimezone(UTC) return ( start_utc, end_utc, @@ -505,7 +504,10 @@ def dashboard_overview( period_regular_task_coin_total = _sum( CoinTransaction.amount, *period_coin_conds, - CoinTransaction.biz_type.notin_(REGULAR_TASK_EXCLUDED_BIZ_TYPES), + or_( + CoinTransaction.biz_type.in_(REGULAR_TASK_EXACT_BIZ_TYPES), + CoinTransaction.biz_type.like(r"task\_%", escape="\\"), + ), ) period_cps_orders = list( db.execute( diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index 75145e4..d2b6526 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -455,3 +455,62 @@ def test_period_coupon_reward_excludes_reward_video( coins = r.json()["period"]["coins"] assert coins["coupon_reward_coin_total"] == 0 # 激励视频不计入领券奖励 assert coins["reward_video_coin_total"] == 50 # 仍计入激励视频卡 + + +def test_period_regular_task_coin_uses_explicit_allowlist( + admin_client: TestClient, admin_token: str +) -> None: + """常规任务金币只加明确任务来源,领券/比价及未知新类型不得自动混入。""" + from datetime import datetime + + from app.models.wallet import CoinTransaction + + d = "2021-06-18" + included = { + "signin": 100, + "signin_boost": 200, + "task_enable_notification": 300, + "task_other": 400, + "price_report_reward": 500, + "feedback_reward": 600, + } + excluded = { + "feed_ad_reward_coupon": 700, + "feed_ad_reward_comparison": 800, + "feed_ad_reward": 900, + "reward_video": 1000, + "admin_grant": 1100, + "invite_inviter": 1200, + "future_unknown_reward": 1300, + } + db = SessionLocal() + try: + uid = user_repo.upsert_user_for_login( + db, phone="13800008804", register_channel="sms" + ).id + balance = 0 + rows = [] + for index, (biz_type, amount) in enumerate((included | excluded).items(), start=1): + balance += amount + rows.append(CoinTransaction( + user_id=uid, + amount=amount, + balance_after=balance, + biz_type=biz_type, + ref_id=f"regular-task-{index}", + created_at=datetime(2021, 6, 18, 12, 0, index), + )) + db.add_all(rows) + db.commit() + finally: + db.close() + + response = admin_client.get( + "/admin/api/stats/overview", + params={"date_from": d, "date_to": d}, + headers=_auth(admin_token), + ) + assert response.status_code == 200, response.text + coins = response.json()["period"]["coins"] + assert coins["regular_task_coin_total"] == sum(included.values()) + assert coins["task_coin_total"] == 700 From 0717c097212f7d9806b4fba053bc219310d44faf Mon Sep 17 00:00:00 2001 From: Ghost <> Date: Wed, 22 Jul 2026 15:42:25 +0800 Subject: [PATCH 12/42] =?UTF-8?q?=E5=9F=BA=E4=BA=8E=20main=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E5=90=84=E5=8E=82=E5=95=86=E7=9B=B4=E6=8E=A8=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AB=AF=20(#118)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动:新增厂商推送配置、设备 push_vendor/push_token 字段、device push-test 接口、心跳超时厂商直推发送逻辑和对应测试。 验证:python -m pytest tests/test_device_push.py tests/test_auth.py tests/test_health.py 通过。 --------- Co-authored-by: guke Co-authored-by: 左辰勇 Co-authored-by: lowmaster-chen <1119780489@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/118 Co-authored-by: Ghost <> Co-committed-by: Ghost <> --- .env.example | 50 +- AGENTS.md | 116 ++++ ..._merge_direct_vendor_push_and_feedback_.py | 26 + alembic/versions/direct_vendor_push_fields.py | 30 + alembic/versions/notification_table.py | 68 ++ app/admin/routers/feedback.py | 12 +- app/admin/routers/price_report.py | 6 +- app/admin/schemas/device.py | 4 +- app/api/v1/device.py | 97 ++- app/api/v1/notifications.py | 124 ++++ app/api/v1/push.py | 187 ++++++ app/core/config.py | 54 +- app/core/heartbeat_monitor_worker.py | 49 +- app/core/notification_catalog.py | 241 ++++++++ app/integrations/vendor_push.py | 584 ++++++++++++++++++ app/main.py | 6 + app/models/__init__.py | 1 + app/models/device.py | 14 +- app/models/notification.py | 95 +++ app/repositories/device.py | 78 ++- app/repositories/invite.py | 10 +- app/repositories/notification.py | 318 ++++++++++ app/repositories/wallet.py | 12 + app/schemas/device.py | 23 +- app/schemas/notification.py | 128 ++++ app/schemas/push.py | 99 +++ app/services/notification_events.py | 269 ++++++++ docs/api/README.md | 20 +- docs/api/notifications.md | 132 ++++ docs/api/push-vendor-test.md | 103 +++ run.bat | 6 +- run.sh | 5 +- scripts/fire_push_events.py | 141 +++++ scripts/seed_mock_notifications.py | 420 +++++++++++++ scripts/seed_push_admin_test.py | 135 ++++ scripts/show_device_regids.bat | 13 + scripts/show_device_regids.py | 136 ++++ scripts/test_push_invite_order_reward.bat | 9 + scripts/test_push_invite_order_reward.py | 110 ++++ scripts/test_push_withdraw_failed.bat | 9 + scripts/test_push_withdraw_failed.py | 106 ++++ scripts/test_push_withdraw_success.bat | 9 + scripts/test_push_withdraw_success.py | 94 +++ tests/test_device_push.py | 317 ++++++++++ tests/test_notification_events.py | 358 +++++++++++ tests/test_notifications.py | 251 ++++++++ tests/test_push_center.py | 454 ++++++++++++++ 47 files changed, 5497 insertions(+), 32 deletions(-) create mode 100644 AGENTS.md create mode 100644 alembic/versions/1a924c274fce_merge_direct_vendor_push_and_feedback_.py create mode 100644 alembic/versions/direct_vendor_push_fields.py create mode 100644 alembic/versions/notification_table.py create mode 100644 app/api/v1/notifications.py create mode 100644 app/api/v1/push.py create mode 100644 app/core/notification_catalog.py create mode 100644 app/integrations/vendor_push.py create mode 100644 app/models/notification.py create mode 100644 app/repositories/notification.py create mode 100644 app/schemas/notification.py create mode 100644 app/schemas/push.py create mode 100644 app/services/notification_events.py create mode 100644 docs/api/notifications.md create mode 100644 docs/api/push-vendor-test.md create mode 100644 scripts/fire_push_events.py create mode 100644 scripts/seed_mock_notifications.py create mode 100644 scripts/seed_push_admin_test.py create mode 100644 scripts/show_device_regids.bat create mode 100644 scripts/show_device_regids.py create mode 100644 scripts/test_push_invite_order_reward.bat create mode 100644 scripts/test_push_invite_order_reward.py create mode 100644 scripts/test_push_withdraw_failed.bat create mode 100644 scripts/test_push_withdraw_failed.py create mode 100644 scripts/test_push_withdraw_success.bat create mode 100644 scripts/test_push_withdraw_success.py create mode 100644 tests/test_device_push.py create mode 100644 tests/test_notification_events.py create mode 100644 tests/test_notifications.py create mode 100644 tests/test_push_center.py diff --git a/.env.example b/.env.example index b228f6b..c3ba1d8 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,55 @@ JG_PRIVATE_KEY_PATH=./secrets/jverify_rsa_private.pem JG_VERIFY_ENDPOINT=https://api.verification.jpush.cn/v1/web/loginTokenVerify JG_REQUEST_TIMEOUT_SEC=15 -# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)===== +# ===== 厂商直推(无障碍保护存活告警 + 消息中心 13 类通知)===== +# 敏感密钥只放 .env / 服务器环境变量,不要提交到 git。 +# 各厂商配置状态可随时 GET /api/v1/push/vendors 查看(缺哪些键一目了然)。 +ANDROID_PACKAGE_NAME=com.jishisongfu.shaguabijia +PUSH_REQUEST_TIMEOUT_SEC=15 +PUSH_TIME_TO_LIVE_SEC=86400 + +HONOR_PUSH_APP_ID= +HONOR_PUSH_CLIENT_ID= +HONOR_PUSH_CLIENT_SECRET= +HONOR_PUSH_TOKEN_ENDPOINT=https://iam.developer.honor.com/auth/token +HONOR_PUSH_SEND_ENDPOINT_TEMPLATE=https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage + +# 华为 Push Kit:AGC 控制台 → 项目设置 → 常规 → 应用,AppId + AppSecret +HUAWEI_PUSH_APP_ID= +HUAWEI_PUSH_APP_SECRET= +HUAWEI_PUSH_TOKEN_ENDPOINT=https://oauth-login.cloud.huawei.com/oauth2/v3/token +HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE=https://push-api.cloud.huawei.com/v1/{app_id}/messages:send + +VIVO_PUSH_APP_ID= +VIVO_PUSH_APP_KEY= +VIVO_PUSH_APP_SECRET= +VIVO_PUSH_AUTH_ENDPOINT=https://api-push.vivo.com.cn/message/auth +VIVO_PUSH_SEND_ENDPOINT=https://api-push.vivo.com.cn/message/send +# vivo 未上架测试时可用 push_mode=1; 上架正式推送改为 0。 +VIVO_PUSH_MODE=1 +VIVO_PUSH_NOTIFY_TYPE=4 +VIVO_PUSH_CATEGORY=DEVICE_REMINDER + +XIAOMI_PUSH_APP_SECRET= +XIAOMI_PUSH_SEND_ENDPOINT=https://api.xmpush.xiaomi.com/v3/message/regid +XIAOMI_PUSH_CHANNEL_ID= +XIAOMI_PUSH_TEMPLATE_ID= +XIAOMI_PUSH_TEMPLATE_TITLE= +XIAOMI_PUSH_TEMPLATE_DESCRIPTION= +# 可选: JSON 字符串,支持 {title}/{alert} 占位符,例如 {"title":"{title}","content":"{alert}"} +XIAOMI_PUSH_TEMPLATE_PARAM_JSON= + +OPPO_PUSH_APP_KEY= +OPPO_PUSH_MASTER_SECRET= +OPPO_PUSH_AUTH_ENDPOINT=https://api.push.oppomobile.com/server/v1/auth +OPPO_PUSH_SEND_ENDPOINT=https://api.push.oppomobile.com/server/v1/message/notification/unicast +# OPPO 新消息分类(2024-11-20 后创建的应用必须携带 category;channel_id 为后台「通道ID」; +# notify_level 0=不传走默认,内容营销类仅支持 1/2) +OPPO_PUSH_CHANNEL_ID= +OPPO_PUSH_CATEGORY= +OPPO_PUSH_NOTIFY_LEVEL=0 + +# ===== 无障碍保护存活监控(推送 + pull 后置兜底)===== HEARTBEAT_MONITOR_ENABLED=true HEARTBEAT_TIMEOUT_MINUTES=60 HEARTBEAT_SCAN_INTERVAL_SEC=60 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..196cc17 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,116 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Project overview + +Shaguabijia (傻瓜比价) App backend — FastAPI + SQLAlchemy 2.0 + JWT. Covers user auth (Jiguang one-click / SMS), welfare wallet (coins/cash/signin/tasks/savings), WeChat Pay withdrawals, ad-reward callbacks (Pangle/GroMore S2S), Meituan CPS (coupon forwarding / price comparison), and an admin backend. + +## Commands + +```bash +# Install +pip install -e ".[dev]" + +# Run app server (port 8770, auto-migrates, auto-reload) +./run.sh # or: uvicorn app.main:app --reload --port 8770 + +# Run admin server (port 8771, separate process) +uvicorn app.admin.main:admin_app --reload --port 8771 + +# Database +alembic upgrade head # apply all migrations (idempotent) +alembic revision --autogenerate -m "description" # generate new migration + +# Tests +pytest -q # all tests +pytest tests/test_auth.py -q # single file +pytest -k "test_sms_login" -q # single test by name + +# Lint +ruff check . +ruff check --fix . +``` + +## Architecture: two FastAPI apps + +This repo runs **two separate FastAPI processes** sharing the same `app/` codebase (models, repositories, integrations, config): + +| | App server | Admin server | +|---|---|---| +| Entry | `app/main.py` → `app:app` | `app/admin/main.py` → `admin_app` | +| Port | 8770 | 8771 | +| Auth | User JWT (`JWT_SECRET_KEY`) | Admin JWT (`ADMIN_JWT_SECRET`, independent) | +| Audience | Mobile app clients | Internal admin dashboard | +| Docs | `/docs` (non-prod only) | `/admin/docs` (non-prod only) | + +The two apps are intentionally decoupled — `app.main` never imports `app.admin`. Admin has its own auth chain (`app/admin/deps.py`, `app/admin/security.py`), role-based guards (`require_role`), and routers under `app/admin/routers/`. + +## Layered request flow + +``` +api/v1/ (thin: parse → delegate → respond + HTTP errors) + ├── integrations/ (external SDKs: signature, encryption, HTTP calls) + └── repositories/ (data access + transactions) + └── models/ (SQLAlchemy ORM, DeclarativeBase) +``` + +- **`api/v1/`**: Route handlers. Keep these thin — parse request, call repository or integration, return response. Never put business logic or external HTTP here. +- **`api/deps.py`**: Shared FastAPI dependencies — `get_current_user` (Bearer JWT → User ORM object), `get_db` (request-scoped session). +- **`integrations/`**: All external service logic — Jiguang REST + RSA decryption, WeChat Pay V3 signing/encryption, Meituan CPS gateway signing, Pangle callback signature verification, SMS sending. This is the layer you change when swapping vendors. +- **`repositories/`**: Data access. Each file wraps SQLAlchemy queries + transactions for one domain (user, wallet, signin, savings, ad_reward, etc.). Some repositories also call integrations (e.g., `wallet.py` calls `integrations/wxpay.py` for withdrawals). +- **`models/`**: ORM table definitions (SQLAlchemy 2.0 `Mapped` style, `DeclarativeBase`). Every new model must be imported in `app/models/__init__.py` so Alembic can discover it. +- **`schemas/`**: Pydantic request/response contracts. +- **`core/`**: Infrastructure — config (`pydantic-settings`), JWT (`security.py`), in-memory rate limiter (`ratelimit.py`), reward constants (`rewards.py`), logging setup, pricebot router (consistent-hash load balancing), withdraw reconcile worker. + +## Internal (server-to-server) endpoints + +Endpoints under `app/api/internal/` are for server-to-server communication (pricebot → app-server), NOT for clients. They use a shared secret header `X-Internal-Secret` (compared via `hmac.compare_digest`) instead of user JWT. If `INTERNAL_API_SECRET` is empty, these endpoints return 503. + +## Auth system + +- **User login**: Jiguang one-click (`integrations/jiguang.py` — REST token verification + RSA decryption with multi-padding retry) or SMS code (mock by default; `SMS_MOCK=true`). +- **Tokens**: JWT access (2h) + refresh (30d). Both are JWT with `typ` claim (`"access"` vs `"refresh"`) to prevent refresh-as-access. See `core/security.py`. +- **Admin auth**: Separate JWT secret (`ADMIN_JWT_SECRET`), 12h expiry, no refresh. Username + bcrypt password login. Role-based access via `require_role()` guard in `app/admin/deps.py` (`super_admin` bypasses all role checks). +- **Rate limiting**: In-memory fixed-window by client IP (`core/ratelimit.py`). Single-worker only; disabled in tests via `RATE_LIMIT_ENABLED=false`. + +## Database + +- **Dev**: SQLite (`sqlite:///./data/app.db`), `check_same_thread=False`, no connection pool. +- **Prod**: PostgreSQL — just change `DATABASE_URL` in `.env`. Pool size 10 + max overflow 20, pool_recycle 3600. +- **Migrations**: Alembic with `render_as_batch` for SQLite compatibility. ~60+ migration files in `alembic/versions/` (filenames are descriptive, not hex prefixes). Migration chain uses `down_revision` within each file. +- **New models**: Define in `app/models/`, import in `app/models/__init__.py`, then run `alembic revision --autogenerate`. + +## Config + +All config via `pydantic-settings` in `app/core/config.py`. Single `Settings` class with env vars / `.env` file. Access anywhere via `from app.core.config import settings`. Key patterns: +- `*_configured` properties gate features gracefully (e.g., `mt_cps_configured`, `wxpay_configured`, `pangle_callback_configured`) — missing credentials → endpoints return empty/503 rather than crashing at startup. +- Prod validation: `_enforce_prod_secrets` model validator blocks startup if `APP_ENV=prod` with weak JWT secrets. + +## Testing + +- `tests/conftest.py`: Sets env vars BEFORE imports, creates temp SQLite file, builds all tables with `Base.metadata.create_all()`, tears down with `drop_all()` + unlink. +- External integrations are monkeypatched in tests (e.g., WeChat Pay, Jiguang, Pangle callbacks) — tests never make real HTTP calls. +- `TestClient` from FastAPI is used for all tests. Rate limiting is disabled globally in tests. + +## Key integration details + +- **Jiguang one-click login**: REST call to verify `loginToken`, then RSA decrypt the returned phone number. Multiple padding schemes tried in order (PKCS1v15, OAEP with SHA1/SHA256) because Jiguang's encryption padding varies. +- **WeChat Pay withdrawals**: V3 API merchant transfer to user WeChat balance. Lazy-loads merchant certificates from `secrets/`. Withdrawal flow: bind WeChat → create withdraw order → auto-reconcile worker polls pending orders. +- **Pangle ad rewards**: S2S callback verification via SHA256 signature. Multiple `m-key` secrets supported (one per ad placement). Callback is idempotent by `trans_id`. Test grant endpoint (`AD_REWARD_TEST_GRANT_ENABLED`) for local debugging — must be false in prod. +- **Meituan CPS**: Gateway signature-based API calls. Proxy support (`MT_CPS_PROXY`) for local dev (direct connection causes SSL EOF). Coupon endpoints gracefully return empty when credentials are missing. +- **Pricebot forwarding**: `/api/v1/coupon/step` and `/api/v1/compare/*` proxy to pricebot-backend. Multi-instance support with consistent-hash routing by `trace_id` (see `core/pricebot_router.py`). +- **CPS redirect**: `/c/{code}` is a public (no auth) short-link redirect — records a click then 302s to Meituan. Click recording failure never blocks the redirect. + +## Money and units + +All monetary amounts are in **cents** (`*_cents` fields). Coins/gold have their own unit. Conversion constants are in `core/rewards.py`. + +## Scripts + +Key operational scripts in `scripts/`: +- `migrate.sh` — run migrations standalone +- `create_admin.py` — create admin user +- `daily_auto_exchange.py` — auto-convert coins to cash (triggered by systemd timer) +- `reconcile_withdraws.py` — reconcile withdrawal orders with WeChat Pay +- `sim_pangle_callback.py` — simulate Pangle S2S callback for testing diff --git a/alembic/versions/1a924c274fce_merge_direct_vendor_push_and_feedback_.py b/alembic/versions/1a924c274fce_merge_direct_vendor_push_and_feedback_.py new file mode 100644 index 0000000..eb0144b --- /dev/null +++ b/alembic/versions/1a924c274fce_merge_direct_vendor_push_and_feedback_.py @@ -0,0 +1,26 @@ +"""merge direct_vendor_push and feedback_type_reply heads + +Revision ID: 1a924c274fce +Revises: direct_vendor_push_fields, feedback_type_reply +Create Date: 2026-07-14 18:53:02.856979 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '1a924c274fce' +down_revision: Union[str, Sequence[str], None] = ('direct_vendor_push_fields', 'feedback_type_reply') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/direct_vendor_push_fields.py b/alembic/versions/direct_vendor_push_fields.py new file mode 100644 index 0000000..ec33ed2 --- /dev/null +++ b/alembic/versions/direct_vendor_push_fields.py @@ -0,0 +1,30 @@ +"""add direct vendor push fields + +Revision ID: direct_vendor_push_fields +Revises: jd_cps_order_fields +Create Date: 2026-07-01 16:30:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "direct_vendor_push_fields" +down_revision = "jd_cps_order_fields" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("device_liveness") as batch_op: + batch_op.add_column(sa.Column("push_vendor", sa.String(length=32), nullable=True)) + batch_op.add_column(sa.Column("push_token", sa.String(length=256), nullable=True)) + batch_op.create_index("ix_device_liveness_push_vendor", ["push_vendor"]) + + +def downgrade() -> None: + with op.batch_alter_table("device_liveness") as batch_op: + batch_op.drop_index("ix_device_liveness_push_vendor") + batch_op.drop_column("push_token") + batch_op.drop_column("push_vendor") diff --git a/alembic/versions/notification_table.py b/alembic/versions/notification_table.py new file mode 100644 index 0000000..aff72a6 --- /dev/null +++ b/alembic/versions/notification_table.py @@ -0,0 +1,68 @@ +"""notification table (消息通知中心 站内消息) + +Revision ID: notification_table +Revises: 1a924c274fce +Create Date: 2026-07-15 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = 'notification_table' +down_revision: Union[str, Sequence[str], None] = '1a924c274fce' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# PG 用 JSONB,SQLite 退化为通用 JSON(与 models/notification._JSON 一致)。 +_JSON = sa.JSON().with_variant(postgresql.JSONB(), 'postgresql') + + +def upgrade() -> None: + op.create_table( + 'notification', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('type', sa.String(length=32), nullable=False), + sa.Column('coins', sa.Integer(), nullable=True), + sa.Column('cash_cents', sa.Integer(), nullable=True), + sa.Column('info_rows', _JSON, nullable=False), + sa.Column('extra', _JSON, nullable=False), + sa.Column('is_read', sa.Boolean(), nullable=False), + sa.Column('read_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('dedup_key', sa.String(length=64), nullable=True), + sa.Column('sent_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + ) + with op.batch_alter_table('notification', schema=None) as batch_op: + batch_op.create_index('ix_notification_type', ['type'], unique=False) + # 列表分页:按用户取 + sent_at 倒序 + batch_op.create_index('ix_notification_user_sent', ['user_id', 'sent_at'], unique=False) + # 铃铛角标:count where user_id=? and is_read=false —— 部分索引只覆盖未读行 + batch_op.create_index( + 'ix_notification_user_unread', ['user_id'], unique=False, + sqlite_where=sa.text('is_read = 0'), + postgresql_where=sa.text('is_read = false'), + ) + # 去重/合并:同一 (user, type, dedup_key) 未读期间只允许一条(已读后可再生成) + batch_op.create_index( + 'uq_notification_user_type_dedup', ['user_id', 'type', 'dedup_key'], unique=True, + sqlite_where=sa.text('dedup_key IS NOT NULL AND is_read = 0'), + postgresql_where=sa.text('dedup_key IS NOT NULL AND is_read = false'), + ) + + +def downgrade() -> None: + with op.batch_alter_table('notification', schema=None) as batch_op: + batch_op.drop_index('uq_notification_user_type_dedup') + batch_op.drop_index('ix_notification_user_unread') + batch_op.drop_index('ix_notification_user_sent') + batch_op.drop_index('ix_notification_type') + op.drop_table('notification') diff --git a/app/admin/routers/feedback.py b/app/admin/routers/feedback.py index f177ebd..987c76b 100644 --- a/app/admin/routers/feedback.py +++ b/app/admin/routers/feedback.py @@ -19,6 +19,7 @@ from app.admin.schemas.feedback import ( from app.models.admin import AdminUser from app.models.feedback import Feedback from app.repositories import wallet as wallet_repo +from app.services import notification_events router = APIRouter( prefix="/admin/api/feedbacks", @@ -134,7 +135,11 @@ def approve_feedback( ) db.commit() db.refresh(fb) - return FeedbackOut.model_validate(fb) + out = FeedbackOut.model_validate(fb) + # PRD #10 反馈奖励:采纳发金币后通知用户(站内 + push,必带官方留言)。 + # 业务已 commit,通知失败只 log 不影响审核结果。 + notification_events.notify_feedback_reward(db, fb) + return out @router.post("/{feedback_id}/reject", response_model=FeedbackOut, summary="拒绝采纳反馈") @@ -179,4 +184,7 @@ def reject_feedback( ) db.commit() db.refresh(fb) - return FeedbackOut.model_validate(fb) + out = FeedbackOut.model_validate(fb) + # PRD #9 官方回复:未采纳也回复了用户(原因/留言用户端可见),通知去反馈历史页查看。 + notification_events.notify_feedback_reply(db, fb) + return out diff --git a/app/admin/routers/price_report.py b/app/admin/routers/price_report.py index daf3e81..9f65545 100644 --- a/app/admin/routers/price_report.py +++ b/app/admin/routers/price_report.py @@ -3,7 +3,8 @@ 数据由客户端 POST /api/v1/report 写入 price_report 表(提交即 pending);本路由是运营后台 对它的人工审核窗口。**通过** → 给上报用户钱包发固定金币(PRICE_REPORT_REWARD_COINS): 改状态 + 发金币(wallet.grant_coins)+ 审计同一事务一起 commit(原子,仿 users.grant_user_coins), -绝不只改状态不发钱或反之。客户端轮询 GET /api/v1/report/records 自动看到结果(无需推送)。 +绝不只改状态不发钱或反之。通过后下发「爆料审核通过」通知(站内 + push,PRD #11); +客户端也可轮询 GET /api/v1/report/records 看到结果。 """ from __future__ import annotations @@ -24,6 +25,7 @@ from app.core.rewards import PRICE_REPORT_REWARD_COINS from app.models.admin import AdminUser from app.models.price_report import PriceReport from app.repositories import wallet as wallet_repo +from app.services import notification_events router = APIRouter( prefix="/admin/api/price-reports", @@ -84,6 +86,8 @@ def approve_price_report( detail={"reward_coins": coins, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False, ) db.commit() + # PRD #11 爆料审核通过:发金币后通知用户(站内 + push)。业务已 commit,通知失败只 log。 + notification_events.notify_report_approved(db, rep) return OkResponse() diff --git a/app/admin/schemas/device.py b/app/admin/schemas/device.py index da3fe60..68568b9 100644 --- a/app/admin/schemas/device.py +++ b/app/admin/schemas/device.py @@ -24,7 +24,9 @@ class DeviceLivenessItem(BaseModel): device_model: str | None = None # 由 device_id 解析(device_<机型>_);非 DB 列 platform: str app_version: str | None = None - registration_id: str | None = None # 非空 = 拿到极光 token、可推送 + registration_id: str | None = None # 旧极光字段,仅兼容历史数据 + push_vendor: str | None = None + push_token: str | None = None ever_protected: bool # 是否开过无障碍(=该设备对功能有意义) first_protected_at: datetime | None = None # 首次开无障碍时刻(老设备为 null) diff --git a/app/api/v1/device.py b/app/api/v1/device.py index 56a5a85..893c6c5 100644 --- a/app/api/v1/device.py +++ b/app/api/v1/device.py @@ -1,19 +1,22 @@ """设备注册 / 心跳 endpoint(无障碍保护存活检测)。 路由前缀 /api/v1/device,需 Bearer 鉴权(设备绑登录用户)。 - POST /register 注册设备 / 更新 registration_id(App 前台、拿到 push token 时调) + POST /register 注册设备 / 更新厂商 push token(App 前台、拿到 push token 时调) POST /heartbeat 上报心跳(无障碍服务存活时周期调,刷新存活) + POST /push-test 开发验收:延迟发送厂商通道测试推送 -后端 heartbeat_monitor_worker 据此发现心跳超时的设备并极光推送告警。 +后端 heartbeat_monitor_worker 据此发现心跳超时的设备并厂商直推告警。 见 spec: spec/accessibility-liveness-push.md。 """ from __future__ import annotations import logging +import time -from fastapi import APIRouter +from fastapi import APIRouter, BackgroundTasks, HTTPException, status from app.api.deps import CurrentUser, DbSession +from app.integrations import vendor_push from app.repositories import device as device_repo from app.schemas.device import ( DeviceOut, @@ -22,6 +25,8 @@ from app.schemas.device import ( LivenessAckRequest, LivenessOut, OkResponse, + PushTestOut, + PushTestRequest, ) logger = logging.getLogger("shagua.device") @@ -29,6 +34,37 @@ logger = logging.getLogger("shagua.device") router = APIRouter(prefix="/api/v1/device", tags=["device"]) +def _send_push_test_after_delay( + push_vendor: str, + push_token: str, + delay_seconds: int, + user_id: int, + device_id: str, +) -> None: + if delay_seconds > 0: + time.sleep(delay_seconds) + try: + vendor_push.send_accessibility_disabled( + push_vendor, + push_token, + title="测试推送", + alert="这是一条厂商通道测试推送。收到它说明 App 被划掉后仍可通过系统通知栏触达。", + ) + logger.info( + "push test sent user_id=%d device_id=%s delay=%ds", + user_id, + device_id, + delay_seconds, + ) + except vendor_push.VendorPushError as e: + logger.warning( + "push test failed user_id=%d device_id=%s error=%s", + user_id, + device_id, + e, + ) + + @router.post("/register", response_model=DeviceOut, summary="注册设备/更新推送token") def register_device( req: DeviceRegisterRequest, @@ -40,13 +76,17 @@ def register_device( user_id=user.id, device_id=req.device_id, registration_id=req.registration_id, + push_vendor=req.push_vendor, + push_token=req.push_token, platform=req.platform, app_version=req.app_version, ) logger.info( - "device register user_id=%d device_id=%s reg=%s", + "device register user_id=%d device_id=%s vendor=%s token=%s legacy_reg=%s", user.id, req.device_id, + req.push_vendor, + bool(req.push_token), bool(req.registration_id), ) return DeviceOut.model_validate(device) @@ -64,10 +104,59 @@ def report_heartbeat( device_id=req.device_id, accessibility_enabled=req.accessibility_enabled, registration_id=req.registration_id, + push_vendor=req.push_vendor, + push_token=req.push_token, ) return OkResponse() +@router.post("/push-test", response_model=PushTestOut, summary="延迟发送厂商通道测试推送") +def request_push_test( + req: PushTestRequest, + background_tasks: BackgroundTasks, + user: CurrentUser, + db: DbSession, +) -> PushTestOut: + """开发验收用:App 内点一次,服务端延迟发厂商直推,验证离线通道。""" + push_vendor = req.push_vendor.strip() if req.push_vendor else None + push_token = req.push_token.strip() if req.push_token else None + if push_vendor and push_token: + device_repo.register_or_update( + db, + user_id=user.id, + device_id=req.device_id, + registration_id=req.registration_id, + push_vendor=push_vendor, + push_token=push_token, + ) + else: + device = device_repo.get_device(db, user_id=user.id, device_id=req.device_id) + push_vendor = device.push_vendor if device is not None else None + push_token = device.push_token if device is not None else None + + if not push_vendor or not push_token: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="push vendor token not ready", + ) + + background_tasks.add_task( + _send_push_test_after_delay, + push_vendor, + push_token, + req.delay_seconds, + user.id, + req.device_id, + ) + logger.info( + "push test scheduled user_id=%d device_id=%s delay=%ds", + user.id, + req.device_id, + req.delay_seconds, + ) + return PushTestOut(delay_seconds=req.delay_seconds, has_push_token=True) + + @router.get("/liveness", response_model=LivenessOut, summary="查询本机掉线告警(后置检测)") def get_liveness( device_id: str, diff --git a/app/api/v1/notifications.py b/app/api/v1/notifications.py new file mode 100644 index 0000000..5a49197 --- /dev/null +++ b/app/api/v1/notifications.py @@ -0,0 +1,124 @@ +"""消息通知中心 endpoint(PRD《消息通知中心》)。 + +路由前缀 `/api/v1/notifications`,需 Bearer 鉴权(消息按用户隔离)。 + GET / 消息列表(分页;全列表时间倒序,不分组——PRD 原文的分组已取消) + GET /unread-count 未读总数(首页铃铛角标) + POST /read 标记已读({ids:[...]} 单条/多条 或 {all:true} 全量清零) + +数据落库 `notification` 表(repositories/notification.py,按用户隔离)。业务事件(奖励过期、 +提现回执、反馈回复……)调 `create_notification` 下发;未接入业务前列表为空,可用 +`/api/v1/push/test` 的 createNotification 造联调数据。 + +⚠️ 字段命名:本组接口对外为 **camelCase**(sentAt / isRead / pageSize…,PRD 前端契约), +详见 schemas/notification.py 顶部说明。 +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, HTTPException, Query + +from app.api.deps import CurrentUser, DbSession +from app.core import notification_catalog as catalog +from app.models.notification import Notification +from app.repositories import notification as notif_repo +from app.schemas.notification import ( + InfoRow, + MarkReadOut, + MarkReadRequest, + NotificationItem, + NotificationListOut, + UnreadCountOut, +) + +logger = logging.getLogger("shagua.notifications") + +router = APIRouter(prefix="/api/v1/notifications", tags=["notifications"]) + + +def _to_item(n: Notification) -> NotificationItem: + """通知行 + 类型静态目录 → 接口出参。""" + ntype = catalog.get_type(n.type) + return NotificationItem( + id=n.id, + category=ntype.category, + category_label=catalog.category_label(ntype.category), + type=ntype.key, + card_style=ntype.card_style, + title=ntype.card_title, + coins=n.coins, + cash_cents=n.cash_cents, + cash_yuan=notif_repo.cash_yuan(n.cash_cents), + info_rows=[InfoRow(**row) for row in n.info_rows], + action_text=ntype.action_text, + extra=n.extra, + sent_at=notif_repo.as_cst(n.sent_at), + is_read=n.is_read, + ) + + +@router.get("", response_model=NotificationListOut, summary="消息列表(分页)") +def list_notifications( + user: CurrentUser, + db: DbSession, + page: int = Query(default=1, ge=1, description="页码,1 起"), + page_size: int = Query( + default=20, ge=1, le=100, alias="pageSize", description="每页条数,默认 20,最大 100" + ), +) -> NotificationListOut: + """通知中心消息列表。 + + - 排序服务端已做好:**全列表按时间倒序**(最新在前,不做分类分组;PRD §1 的 + "按分类分组"为笔误,已与需求方确认取消),前端按返回顺序渲染即可。 + - 每条的字段构成与各版式说明见 NotificationItem schema。 + - 响应同时带 unreadCount,进页面时可顺手刷新角标。 + - 无消息时返回空列表(total=0);数据由业务事件下发,联调可用 /push/test 造。 + """ + items, total, unread = notif_repo.list_notifications( + db, user.id, page=page, page_size=page_size + ) + return NotificationListOut( + items=[_to_item(n) for n in items], + page=page, + page_size=page_size, + total=total, + has_more=page * page_size < total, + unread_count=unread, + ) + + +@router.get("/unread-count", response_model=UnreadCountOut, summary="未读总数(铃铛角标)") +def get_unread_count(user: CurrentUser, db: DbSession) -> UnreadCountOut: + """首页铃铛角标数据源。刷新时机(PRD §4):进入首页时、从通知中心/其他页面返回首页时。 + + - count:精确未读条数; + - badgeText:直接可展示的角标文案——超过 99 返回 "99+",等于 0 返回 null(隐藏整个角标)。 + """ + count = notif_repo.unread_count(db, user.id) + badge = None if count == 0 else ("99+" if count > 99 else str(count)) + return UnreadCountOut(count=count, badge_text=badge) + + +@router.post("/read", response_model=MarkReadOut, summary="标记已读(单条/多条/全量)") +def mark_read(req: MarkReadRequest, user: CurrentUser, db: DbSession) -> MarkReadOut: + """红点消除(PRD §4),两种调用模式: + + 1. `{"ids": [90001]}` —— 点击某张消息卡片(无论点击后是跳转/弹窗/无动作都算已读); + 用户点击 push 直达落地页时,客户端也用它把对应站内消息同步置读(push extras 里带 + notificationId); + 2. `{"all": true}` —— 进入通知中心自动清零(只是浏览列表就消红点,无需逐条点击)。 + + 幂等:不存在或已读的 id 忽略;重复调用 markedCount 为 0、不报错。 + 响应带 unreadCount(处理后剩余未读),可直接刷新铃铛角标。 + """ + if not req.all and not req.ids: + raise HTTPException(status_code=400, detail="ids 与 all 至少传一个:{ids:[...]} 或 {all:true}") + marked, unread = notif_repo.mark_read(db, user.id, ids=req.ids, mark_all=req.all) + logger.info( + "notifications read user_id=%d mode=%s marked=%d unread_left=%d", + user.id, + "all" if req.all else f"ids×{len(req.ids or [])}", + marked, + unread, + ) + return MarkReadOut(ok=True, marked_count=marked, unread_count=unread) diff --git a/app/api/v1/push.py b/app/api/v1/push.py new file mode 100644 index 0000000..128b46c --- /dev/null +++ b/app/api/v1/push.py @@ -0,0 +1,187 @@ +"""厂商推送 测试/联调 endpoint。 + +路由前缀 `/api/v1/push`,需 Bearer 鉴权。围绕「消息中心 13 类通知的厂商直推」提供三件套: + GET /vendors 5 个厂商(荣耀/华为/小米/OPPO/vivo)服务端凭据配置状态,缺哪些键一目了然 + GET /templates 13 种通知类型的 push 标题/正文模板 + PRD 示例渲染效果 + POST /test 测试发送:默认 mock(不真调厂商 API,回显渲染结果);mock=false 真发到手机 + +与 `/api/v1/device/push-test`(无障碍召回通道的延迟自测)互补:本组面向消息中心 13 类 +push 的文案/参数/厂商通道联调。真实业务触发统一走 services/notification_events +(提现回执/反馈审核/爆料通过/好友下单已接入),底层与本测试端点同一条 +integrations.vendor_push.send_notification 发送链路。 +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, HTTPException, status + +from app.api.deps import CurrentUser, DbSession +from app.core import notification_catalog as catalog +from app.integrations import vendor_push +from app.repositories import device as device_repo +from app.repositories import notification as notif_repo +from app.schemas.push import ( + PushTemplateOut, + PushTemplatesOut, + PushTestOut, + PushTestRequest, + PushVendorsOut, + PushVendorStatus, +) + +logger = logging.getLogger("shagua.push") + +router = APIRouter(prefix="/api/v1/push", tags=["push"]) + +# /vendors 的展示顺序(荣耀/华为/小米/OPPO/vivo) +_VENDOR_ORDER = ("honor", "huawei", "xiaomi", "oppo", "vivo") + +_GENERIC_TEST_TITLE = "傻瓜比价测试推送" +_GENERIC_TEST_BODY = "这是一条{label}通道的测试推送,收到说明服务端 → {label}厂商通道已打通。" + + +@router.get("/vendors", response_model=PushVendorsOut, summary="厂商推送配置状态") +def vendor_status(user: CurrentUser) -> PushVendorsOut: + """检查 5 个厂商的服务端推送凭据是否配齐(读 .env,不打厂商接口)。 + + missingKeys 列出的即还需要在 .env 里补的配置键;全空说明该厂商随时可真发。 + mock 测试(POST /test 默认模式)不依赖任何凭据。 + """ + return PushVendorsOut( + vendors=[ + PushVendorStatus( + vendor=v, + label=vendor_push.VENDOR_LABELS[v], + configured=not vendor_push.missing_settings(v), + missing_keys=vendor_push.missing_settings(v), + ) + for v in _VENDOR_ORDER + ] + ) + + +@router.get("/templates", response_model=PushTemplatesOut, summary="13 类通知的 push 模板预览") +def push_templates(user: CurrentUser) -> PushTemplatesOut: + """PRD §5 的 13 条 push 文案模板 + 用示例值渲染后的效果,联调对文案用。 + + 标题固定(≤11 字不带变量);正文里 {var} 为变量,POST /test 的 vars 字段可覆盖。 + """ + templates: list[PushTemplateOut] = [] + for key, ntype in catalog.TYPES.items(): + title, body_sample = catalog.render_push(key) + templates.append( + PushTemplateOut( + type=key, + category=ntype.category, + category_label=catalog.category_label(ntype.category), + card_style=ntype.card_style, + push_title=title, + push_body_sample=body_sample, + push_body_template=ntype.push_body_template, + variables=catalog.push_variable_names(key), + sample_vars=ntype.sample_vars, + ) + ) + return PushTemplatesOut(templates=templates) + + +@router.post("/test", response_model=PushTestOut, summary="测试发送厂商推送(默认 mock)") +def send_test_push(req: PushTestRequest, user: CurrentUser, db: DbSession) -> PushTestOut: + """向指定厂商 token(或本用户已注册设备)发一条测试 push。 + + - **mock=true(默认)**:不真调厂商 API——校验参数、渲染文案后原样返回,并在 + missingKeys 里提示真发前还缺哪些配置。虚拟数据阶段随便打,不会骚扰真机。 + - **mock=false**:真发。要求该厂商凭据已配置(缺则 400 报缺失键);厂商 API 报错回 502。 + 注意 vivo 未上架前是测试推送模式(VIVO_PUSH_MODE=1),目标手机要先在 vivo 后台加为测试设备。 + - **createNotification=true**:同时往消息中心(notification 表)插一条同类型未读通知并把 + notificationId 放进 push extras → 客户端点击 push 后调 POST /notifications/read + {ids:[notificationId]} 即可闭环验证 PRD §4 的 push 已读联动。 + """ + # ---- 1. 解析推送目标(vendor + token):直填优先,缺则按 deviceId 反查已注册设备 ---- + vendor_raw = req.vendor.strip() + push_token = req.push_token.strip() + if (not vendor_raw or not push_token) and req.device_id.strip(): + device = device_repo.get_device(db, user_id=user.id, device_id=req.device_id.strip()) + if device is not None: + vendor_raw = vendor_raw or (device.push_vendor or "") + push_token = push_token or (device.push_token or "") + + vendor = vendor_push.normalize_vendor(vendor_raw) + if not vendor or vendor not in vendor_push.SUPPORTED_VENDORS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"vendor 无效或无法从设备推断,支持: {', '.join(_VENDOR_ORDER)}", + ) + if not push_token: + # mock 模式给个占位 token,让「只想看看渲染结果」的调用免造数据;真发必须给真 token。 + if req.mock: + push_token = "mock-token" + else: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="push token 未知:请直传 pushToken,或先用该设备调 /api/v1/device/register 上报", + ) + + # ---- 2. 组装文案与 extras:直填 > type 模板 > 通用测试文案 ---- + extras: dict[str, str] = {} + notification_id: int | None = None + if req.type: + try: + title, body = catalog.render_push(req.type, req.vars or None) + except catalog.UnknownNotificationType as e: + raise HTTPException(status_code=400, detail=str(e)) from e + extras["type"] = req.type + if req.create_notification: + item = notif_repo.insert_sample(db, user.id, req.type) + notification_id = item.id + extras.update({str(k): str(v) for k, v in item.extra.items()}) + extras["notificationId"] = str(item.id) + else: + label = vendor_push.VENDOR_LABELS[vendor] + title = _GENERIC_TEST_TITLE + body = _GENERIC_TEST_BODY.format(label=label) + extras["type"] = "push_test" + if req.title.strip(): + title = req.title.strip() + if req.content.strip(): + body = req.content.strip() + + # ---- 3. 发送(mock / 真发) ---- + missing = vendor_push.missing_settings(vendor) + vendor_response = None + if req.mock: + vendor_push.send_notification( + vendor, push_token, title=title, body=body, extras=extras, mock=True + ) + else: + if missing: + raise HTTPException( + status_code=400, + detail=f"{vendor_push.VENDOR_LABELS[vendor]}推送凭据未配置,先在 .env 补上: " + f"{', '.join(missing)}", + ) + try: + vendor_response = vendor_push.send_notification( + vendor, push_token, title=title, body=body, extras=extras + ) + except vendor_push.VendorPushError as e: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail=f"厂商推送失败: {e}" + ) from e + + logger.info( + "push test user_id=%d vendor=%s type=%s mock=%s notification_id=%s", + user.id, vendor, req.type or "generic", req.mock, notification_id, + ) + return PushTestOut( + ok=True, + mock=req.mock, + vendor=vendor, + title=title, + body=body, + extras=extras, + notification_id=notification_id, + missing_keys=missing, + vendor_response=vendor_response, + ) diff --git a/app/core/config.py b/app/core/config.py index 9a88f00..2f13587 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -71,7 +71,59 @@ class Settings(BaseSettings): JG_VERIFY_ENDPOINT: str = "https://api.verification.jpush.cn/v1/web/loginTokenVerify" JG_REQUEST_TIMEOUT_SEC: int = 15 - # 无障碍保护存活监控后台任务(pull 后置检测;本期不接推送) + # ===== 厂商直推(无障碍保护存活告警)===== + ANDROID_PACKAGE_NAME: str = "com.jishisongfu.shaguabijia" + PUSH_REQUEST_TIMEOUT_SEC: int = 15 + PUSH_TIME_TO_LIVE_SEC: int = 86400 + + HONOR_PUSH_APP_ID: str = "" + HONOR_PUSH_CLIENT_ID: str = "" + HONOR_PUSH_CLIENT_SECRET: str = "" + HONOR_PUSH_TOKEN_ENDPOINT: str = "https://iam.developer.honor.com/auth/token" + HONOR_PUSH_SEND_ENDPOINT_TEMPLATE: str = ( + "https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage" + ) + + # 华为 Push Kit:AGC 控制台 → 项目设置 → 常规 → 应用,取 AppId + AppSecret + # (OAuth 换 token 时 client_id 即 AppId)。发送走 v1 messages:send,成功码 80000000。 + HUAWEI_PUSH_APP_ID: str = "" + HUAWEI_PUSH_APP_SECRET: str = "" + HUAWEI_PUSH_TOKEN_ENDPOINT: str = "https://oauth-login.cloud.huawei.com/oauth2/v3/token" + HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE: str = ( + "https://push-api.cloud.huawei.com/v1/{app_id}/messages:send" + ) + + VIVO_PUSH_APP_ID: str = "" + VIVO_PUSH_APP_KEY: str = "" + VIVO_PUSH_APP_SECRET: str = "" + VIVO_PUSH_AUTH_ENDPOINT: str = "https://api-push.vivo.com.cn/message/auth" + VIVO_PUSH_SEND_ENDPOINT: str = "https://api-push.vivo.com.cn/message/send" + VIVO_PUSH_MODE: int = 1 # 0=正式推送,1=测试推送(未上架 vivo 时用) + VIVO_PUSH_NOTIFY_TYPE: int = 4 # 1=无,2=响铃,3=振动,4=响铃+振动 + VIVO_PUSH_CATEGORY: str = "DEVICE_REMINDER" + + XIAOMI_PUSH_APP_SECRET: str = "" + XIAOMI_PUSH_SEND_ENDPOINT: str = "https://api.xmpush.xiaomi.com/v3/message/regid" + XIAOMI_PUSH_CHANNEL_ID: str = "" + XIAOMI_PUSH_TEMPLATE_ID: str = "" + XIAOMI_PUSH_TEMPLATE_TITLE: str = "" + XIAOMI_PUSH_TEMPLATE_DESCRIPTION: str = "" + XIAOMI_PUSH_TEMPLATE_PARAM_JSON: str = "" + + OPPO_PUSH_APP_KEY: str = "" + OPPO_PUSH_MASTER_SECRET: str = "" + OPPO_PUSH_AUTH_ENDPOINT: str = "https://api.push.oppomobile.com/server/v1/auth" + OPPO_PUSH_SEND_ENDPOINT: str = ( + "https://api.push.oppomobile.com/server/v1/message/notification/unicast" + ) + # OPPO 新消息分类(2024-11-20 后创建的应用必须携带,否则可能被拒/限): + # channel_id=通知栏通道(OPPO 后台「通道ID」),category=消息分类 code(如 MARKETING 内容营销)。 + # notify_level=提醒方式(0=不传走 OPPO 默认;内容营销类仅支持 1 通知栏/2 通知栏+锁屏)。 + OPPO_PUSH_CHANNEL_ID: str = "" + OPPO_PUSH_CATEGORY: str = "" + OPPO_PUSH_NOTIFY_LEVEL: int = 0 + + # 无障碍保护存活监控后台任务(推送 + pull 后置兜底) HEARTBEAT_MONITOR_ENABLED: bool = True # 总开关 HEARTBEAT_TIMEOUT_MINUTES: int = 60 # 多久没心跳算掉线(1 小时,避免短暂离线误判被杀) HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期 diff --git a/app/core/heartbeat_monitor_worker.py b/app/core/heartbeat_monitor_worker.py index 6072d26..be27124 100644 --- a/app/core/heartbeat_monitor_worker.py +++ b/app/core/heartbeat_monitor_worker.py @@ -1,7 +1,7 @@ """无障碍保护存活监控后台任务。 周期扫描「曾经保护过、当前 alive、心跳超时」的设备 = App 被彻底杀掉/无障碍已停(心跳断了), -**命中即在服务器终端打印告警**(本期先不接推送,工程量大,用终端打印代替真实通知);并把状态机 +**命中即在服务器终端打印告警并尝试厂商直推**;并把状态机 推进到 notified 防每轮重复打印(心跳恢复时由 repositories.device.touch_heartbeat 重置回 alive)。 结构仿 withdraw_reconcile_worker(单实例锁 + asyncio 轮询 + 优雅退出)。 @@ -22,6 +22,7 @@ from sqlalchemy.exc import SQLAlchemyError from app.core.config import settings from app.db.session import SessionLocal +from app.integrations import vendor_push from app.repositories import device as device_repo logger = logging.getLogger("shagua.heartbeat_monitor") @@ -71,32 +72,66 @@ def _silent_seconds(last: datetime | None) -> int | None: """距上次心跳的秒数(兼容 sqlite 取回的 naive datetime)。""" if last is None: return None - ref = datetime.now(timezone.utc) if last.tzinfo is not None else datetime.utcnow() + ref = datetime.now(timezone.utc) if last.tzinfo is not None else datetime.utcnow() # noqa: UP017 return int((ref - last).total_seconds()) def _scan_once(timeout_minutes: int) -> dict: - """扫描一轮:找出心跳超时(App 被彻底杀掉/无障碍已停)的设备,在**服务器终端打印**告警代替真实推送。 + """扫描一轮:找出心跳超时(App 被彻底杀掉/无障碍已停)的设备并召回。 - 本期不接推送(极光/厂商通道工程量大),只做服务端掉线检测:命中即 logger.warning 打印到终端, - 并把状态机推进到 notified 防每轮重复打印(心跳恢复时 touch_heartbeat 会重置回 alive)。 + 有 push_vendor + push_token 时先发厂商直推,无 token 或推送失败时仍置 + kill_alert_pending,客户端下次进 App 继续走后置提醒兜底。 """ notified = 0 + pushed = 0 + push_failed = 0 with SessionLocal() as db: overdue = device_repo.list_overdue(db, timeout_minutes=timeout_minutes) for device in overdue: silent = _silent_seconds(device.last_heartbeat_at) logger.warning( "[掉线检测] user_id=%s device_id=%s 已 %s 秒无心跳(阈值 %d 分钟)" - " → 判定 App 已被杀/无障碍已停。【已置 kill_alert_pending: 用户下次进 App 将弹「开启自启动」引导(后置检测);推送本期未接】", + " → 判定 App 已被杀/无障碍已停。", device.user_id, device.device_id, silent if silent is not None else "?", timeout_minutes, ) + if device.push_vendor and device.push_token: + try: + vendor_push.send_accessibility_disabled( + device.push_vendor, + device.push_token, + ) + pushed += 1 + logger.info( + "[掉线检测] push sent user_id=%s device_id=%s vendor=%s", + device.user_id, + device.device_id, + device.push_vendor, + ) + except vendor_push.VendorPushError as e: + push_failed += 1 + logger.warning( + "[掉线检测] push failed user_id=%s device_id=%s error=%s", + device.user_id, + device.device_id, + e, + ) + else: + logger.info( + "[掉线检测] device has no push vendor/token, skip push user_id=%s device_id=%s", + device.user_id, + device.device_id, + ) device_repo.mark_notified(db, device_id_pk=device.id) notified += 1 - return {"checked": len(overdue), "notified": notified} + return { + "checked": len(overdue), + "notified": notified, + "pushed": pushed, + "push_failed": push_failed, + } async def _run_loop() -> None: diff --git a/app/core/notification_catalog.py b/app/core/notification_catalog.py new file mode 100644 index 0000000..f8125f5 --- /dev/null +++ b/app/core/notification_catalog.py @@ -0,0 +1,241 @@ +"""消息通知中心:13 种通知类型的静态目录 + Push 文案模板。 + +对应 PRD《消息通知中心》:§1 类型清单 / §3 字段元素 / §5 Push 文案。 +这里只放**静态定义**(分类、版式、标题、操作行、Push 模板),供两处消费: + - repositories/notification.py 消息中心列表按 type 派生分类/版式/标题/操作行 + - api/v1/push.py 渲染 13 类 push 标题/文案(厂商推送 + 测试端点) + +PRD 文案规范(§5):push 标题 ≤11 字、固定文案不带变量;变量只出现在正文里且尽量前置。 +模板变量用 `{name}` 占位,渲染时缺省回退 sample_vars(PRD 示例值),保证 mock 阶段随时可发。 +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +# --------------------------------------------------------------------------- +# 分类(仅作卡片头部的分类标签展示;列表不按分类分组——PRD §1 的分组已确认取消,全表时间倒序) +# --------------------------------------------------------------------------- + +CATEGORY_WITHDRAW = "withdraw_assistant" +CATEGORY_SYSTEM = "system" +CATEGORY_FEEDBACK = "feedback" +CATEGORY_REPORT = "report" +CATEGORY_INVITE = "invite" + +# key → 中文标签 +CATEGORIES: dict[str, str] = { + CATEGORY_WITHDRAW: "提现助手", + CATEGORY_SYSTEM: "系统通知", + CATEGORY_FEEDBACK: "我的反馈", + CATEGORY_REPORT: "我的爆料", + CATEGORY_INVITE: "好友邀请", +} + + +def category_label(key: str) -> str: + return CATEGORIES[key] + + +# --------------------------------------------------------------------------- +# 卡片版式(PRD §3「版式」列;前端按此渲染五种卡) +# --------------------------------------------------------------------------- + +CARD_DUAL_AMOUNT = "dual_amount" # 双金额卡(金币数 + 现金数) +CARD_WITHDRAW = "withdraw" # 提现卡(¥金额) +CARD_PLAIN_TEXT = "plain_text" # 纯文本卡(无数值) +CARD_COIN_REWARD = "coin_reward" # 金币奖励卡(金币数 + 单位「金币」) +CARD_FRIEND_CASH = "friend_cash" # 好友现金卡(¥金额) + + +@dataclass(frozen=True) +class NotificationType: + """一种通知类型的静态定义(卡片元数据 + Push 模板)。""" + + key: str # 类型 key(接口 type 字段;前端按它决定点击跳转,见 PRD §2) + category: str # 分类 key(CATEGORIES 之一) + card_style: str # 卡片版式(CARD_* 之一) + card_title: str # 卡片标题(PRD §3「标题」列) + action_text: str | None # 操作行文案;None = 无操作行(如「提现成功」) + push_title: str # push 标题(≤11 字固定文案,PRD §5) + push_body_template: str # push 正文模板,`{var}` 为变量 + sample_vars: dict[str, str] = field(default_factory=dict) # PRD 示例值,渲染缺省回退 + + +# 13 种类型,编号/文案与 PRD §1/§3/§5 一一对应(插入顺序 = PRD 编号顺序)。 +TYPES: dict[str, NotificationType] = { + t.key: t + for t in [ + # -- 提现助手 ------------------------------------------------------- + NotificationType( + key="reward_expiring", + category=CATEGORY_WITHDRAW, + card_style=CARD_DUAL_AMOUNT, + card_title="金币现金奖励即将失效", + action_text="立即激活您的收益", + push_title="您的奖励即将失效", + push_body_template="{coins}金币和{cash}元现金{days}天后失效,完成快来激活收益", + sample_vars={"coins": "86", "cash": "12.80", "days": "3"}, + ), + NotificationType( + key="reward_expired", + category=CATEGORY_WITHDRAW, + card_style=CARD_DUAL_AMOUNT, + card_title="金币现金奖励已失效", + action_text="立即赚取新收益", + push_title="您的奖励已失效", + push_body_template="{coins}金币和{cash}元现金已过期,完成一次一键领券或一键比价可赚取新收益", + sample_vars={"coins": "35", "cash": "0.60"}, + ), + NotificationType( + key="withdraw_success", + category=CATEGORY_WITHDRAW, + card_style=CARD_WITHDRAW, + card_title="提现成功", + action_text=None, # PRD §3:提现成功卡无操作行,点击也无跳转、仅消红点 + push_title="提现到账提醒", + push_body_template="¥{amount}已存入您的微信钱包,点击查看到账详情", + sample_vars={"amount": "0.50"}, + ), + NotificationType( + key="withdraw_failed", + category=CATEGORY_WITHDRAW, + card_style=CARD_WITHDRAW, + card_title="提现失败,款项已退回", + action_text="重新提现", + push_title="提现失败,款项已退回", + push_body_template="¥{amount}因{reason}退回现金余额,点击重新提现", + sample_vars={"amount": "3.50", "reason": "微信零钱未实名"}, + ), + # -- 系统通知(权限异常 ×4;标题里的功能名按类型写死,见 PRD §1/§3)---- + NotificationType( + key="perm_accessibility", + category=CATEGORY_SYSTEM, + card_style=CARD_PLAIN_TEXT, + card_title="检测到您的比价功能已失效", + action_text="去开启", + push_title="检测到您的比价功能已失效", + push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启", + ), + NotificationType( + key="perm_battery", + category=CATEGORY_SYSTEM, + card_style=CARD_PLAIN_TEXT, + card_title="检测到您的比价续航保护已失效", + action_text="去开启", + push_title="检测到您的比价续航保护已失效", + push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启", + ), + NotificationType( + key="perm_autostart", + category=CATEGORY_SYSTEM, + card_style=CARD_PLAIN_TEXT, + card_title="检测到您的比价启动保护已失效", + action_text="去开启", + push_title="检测到您的比价启动保护已失效", + push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启", + ), + NotificationType( + key="perm_overlay", + category=CATEGORY_SYSTEM, + card_style=CARD_PLAIN_TEXT, + card_title="检测到您的比价按钮已失效", + action_text="去开启", + push_title="检测到您的比价按钮已失效", + push_body_template="未开启将导致核心功能不可用,请尽快来傻瓜比价开启", + ), + # -- 我的反馈 ------------------------------------------------------- + NotificationType( + key="feedback_reply", + category=CATEGORY_FEEDBACK, + card_style=CARD_PLAIN_TEXT, + card_title="傻瓜比价官方回复了您的反馈", + action_text="查看详情", + push_title="您的反馈有回复啦", + push_body_template="您提的建议我们认真看过了,来看看我们的回复吧~", + ), + NotificationType( + key="feedback_reward", + category=CATEGORY_FEEDBACK, + card_style=CARD_COIN_REWARD, + card_title="反馈奖励", + action_text="查看反馈详情", + push_title="反馈奖励已到账", + push_body_template="谢谢您帮傻瓜比价变得更好,{coins}金币已到账,还有一条给您的留言~", + sample_vars={"coins": "300"}, + ), + # -- 我的爆料 ------------------------------------------------------- + NotificationType( + key="report_approved", + category=CATEGORY_REPORT, + card_style=CARD_COIN_REWARD, + card_title="爆料审核通过", + action_text="查看爆料详情", + push_title="爆料审核通过", + push_body_template="您爆料的「{store}」更低价审核通过,{coins}金币已到账,感谢您的分享", + sample_vars={"store": "蜀大侠火锅", "coins": "1000"}, + ), + # -- 好友邀请 ------------------------------------------------------- + NotificationType( + key="invite_order_reward", + category=CATEGORY_INVITE, + card_style=CARD_FRIEND_CASH, + card_title="好友比价成功,现金已到账", + action_text="邀请更多好友赚现金", + push_title="您的邀请奖励已到账", + push_body_template="您的好友「{nickname}」完成首次下单,{amount}元现金已到账", + sample_vars={"nickname": "柚子", "amount": "2"}, + ), + NotificationType( + key="invite_remind", + category=CATEGORY_INVITE, + card_style=CARD_PLAIN_TEXT, + card_title="你邀请的好友还差一步", + action_text="去提醒 TA", + push_title="提醒好友完成比价的奖励", + push_body_template="您的好友「{nickname}」还没完成比价下单,提醒TA完成,您可得{amount}元现金", + sample_vars={"nickname": "阿泽", "amount": "2"}, + ), + ] +} + + +class UnknownNotificationType(ValueError): + """type key 不在 13 种类型之内。""" + + +def get_type(type_key: str) -> NotificationType: + ntype = TYPES.get(type_key) + if ntype is None: + raise UnknownNotificationType( + f"unknown notification type: {type_key!r} (可选: {', '.join(TYPES)})" + ) + return ntype + + +def render_push(type_key: str, variables: dict[str, str] | None = None) -> tuple[str, str]: + """渲染某类型的 push (标题, 正文)。 + + variables 覆盖模板变量;缺的变量回退 sample_vars(PRD 示例值)——保证虚拟数据 + 阶段不传变量也能发出完整文案。多余的变量忽略。 + """ + ntype = get_type(type_key) + merged = {**ntype.sample_vars, **(variables or {})} + + class _Fallback(dict): + def __missing__(self, key: str) -> str: # 模板变量既没传也没示例值 → 保留 {key} 原样 + return "{" + key + "}" + + body = ntype.push_body_template.format_map(_Fallback(merged)) + return ntype.push_title, body + + +def push_variable_names(type_key: str) -> list[str]: + """列出模板里出现的变量名(给 /push/templates 预览用)。""" + import string + + ntype = get_type(type_key) + return [ + fname + for _, fname, _, _ in string.Formatter().parse(ntype.push_body_template) + if fname + ] diff --git a/app/integrations/vendor_push.py b/app/integrations/vendor_push.py new file mode 100644 index 0000000..54c0e0e --- /dev/null +++ b/app/integrations/vendor_push.py @@ -0,0 +1,584 @@ +"""厂商直推集成(荣耀 / 华为 / 小米 / OPPO / vivo)。 + +服务端不经由 JPush Push API,而是按客户端上报的 push_vendor + push_token +分发到各手机厂商的服务端 API。 + +对外两个入口: + - send_notification() 通用:任意标题/正文/extras(消息中心 13 类推送走这里), + mock=True 时不真调厂商、返回渲染结果(虚拟数据联调用) + - send_accessibility_disabled() 旧:无障碍掉线召回(heartbeat_monitor_worker 在用), + 已改为 send_notification 的薄封装,行为不变 + +各厂商鉴权方式:荣耀/华为 OAuth client_credentials 换 access_token(进程内缓存); +vivo/OPPO 签名换 authToken(缓存 24h);小米直接 AppSecret 走 Authorization 头。 +""" +from __future__ import annotations + +import hashlib +import json +import logging +import time +import uuid +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any +from urllib.parse import quote + +import httpx + +from app.core.config import settings + +logger = logging.getLogger("shagua.vendor_push") + +TYPE_ACCESSIBILITY_DISABLED = "accessibility_disabled" +SUPPORTED_VENDORS = frozenset({"honor", "huawei", "vivo", "xiaomi", "oppo"}) + +# vendor key → 中文名(测试/配置状态接口展示用) +VENDOR_LABELS: dict[str, str] = { + "honor": "荣耀", + "huawei": "华为", + "xiaomi": "小米", + "oppo": "OPPO", + "vivo": "vivo", +} + +# 各厂商真发推送所需的 settings 键(缺任一即视为未配置;/api/v1/push/vendors 据此报缺) +REQUIRED_SETTINGS: dict[str, tuple[str, ...]] = { + "honor": ("HONOR_PUSH_APP_ID", "HONOR_PUSH_CLIENT_ID", "HONOR_PUSH_CLIENT_SECRET"), + "huawei": ("HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"), + "xiaomi": ("XIAOMI_PUSH_APP_SECRET",), + "oppo": ("OPPO_PUSH_APP_KEY", "OPPO_PUSH_MASTER_SECRET"), + "vivo": ("VIVO_PUSH_APP_ID", "VIVO_PUSH_APP_KEY", "VIVO_PUSH_APP_SECRET"), +} + + +def missing_settings(vendor: str) -> list[str]: + """该厂商还缺哪些配置键(全配齐返回空列表)。vendor 需已 normalize。""" + return [key for key in REQUIRED_SETTINGS.get(vendor, ()) if not getattr(settings, key, "")] + + +class VendorPushError(Exception): + """厂商推送调用失败。""" + + +@dataclass +class _CachedToken: + value: str + expires_at: float + + +_token_cache: dict[str, _CachedToken] = {} + + +def normalize_vendor(push_vendor: str | None) -> str | None: + if not push_vendor: + return None + vendor = push_vendor.strip().lower() + aliases = { + "hihonor": "honor", + "荣耀": "honor", + "hms": "huawei", + "华为": "huawei", + "harmony": "huawei", + "harmonyos": "huawei", + "mi": "xiaomi", + "小米": "xiaomi", + "oneplus": "oppo", + "realme": "oppo", + } + return aliases.get(vendor, vendor) + + +def send_notification( + push_vendor: str, + push_token: str, + *, + title: str, + body: str, + extras: dict[str, str] | None = None, + mock: bool = False, +) -> dict[str, Any]: + """按厂商 token 向单台设备发送一条通知(通用入口)。 + + - extras:透传给客户端的自定义键值(值统一 string,兼容各厂商限制)。消息中心推送约定 + 至少带 {"type": <13 种类型 key>, "notificationId": <站内消息 id>},客户端据此 + 深链落地 + 调 /notifications/read 同步置读(PRD §4 push 联动)。 + - mock=True:不真调厂商 API,校验参数后原样返回渲染结果(虚拟数据阶段联调/自动化测试用)。 + """ + vendor = normalize_vendor(push_vendor) + token = push_token.strip() if push_token else "" + if not vendor or vendor not in SUPPORTED_VENDORS: + raise VendorPushError(f"unsupported push vendor: {push_vendor}") + if not token: + raise VendorPushError("push token is empty") + extras = {str(k): str(v) for k, v in (extras or {}).items()} + + if mock: + logger.info( + "[mock push] vendor=%s token=%s... title=%s body=%s extras=%s", + vendor, token[:12], title, body, extras, + ) + return { + "mock": True, + "vendor": vendor, + "title": title, + "body": body, + "extras": extras, + } + + dispatch: dict[str, Callable[[str, str, str, dict[str, str]], dict[str, Any]]] = { + "honor": _send_honor, + "huawei": _send_huawei, + "vivo": _send_vivo, + "xiaomi": _send_xiaomi, + "oppo": _send_oppo, + } + return dispatch[vendor](token, title, body, extras) + + +def send_accessibility_disabled( + push_vendor: str, + push_token: str, + *, + title: str = "保护已关闭", + alert: str = "傻瓜比价的无障碍保护被关了,点此重新开启,继续帮你自动比价省钱。", +) -> dict[str, Any]: + """按厂商 token 向单台设备发送无障碍掉线通知(heartbeat_monitor_worker 在用,行为不变)。""" + return send_notification( + push_vendor, + push_token, + title=title, + body=alert, + extras={"type": TYPE_ACCESSIBILITY_DISABLED}, + ) + + +def _require(value: str, name: str) -> str: + if not value: + raise VendorPushError(f"{name} not configured") + return value + + +def _request_json( + method: str, + url: str, + *, + expected_status: tuple[int, ...] = (200,), + **kwargs: Any, +) -> dict[str, Any]: + try: + resp = httpx.request( + method, + url, + timeout=settings.PUSH_REQUEST_TIMEOUT_SEC, + **kwargs, + ) + except httpx.HTTPError as e: + raise VendorPushError(f"push http error: {e}") from e + + if resp.status_code not in expected_status: + logger.error("vendor push http failed url=%s http=%s body=%s", url, resp.status_code, resp.text[:500]) + raise VendorPushError(f"push http {resp.status_code}") + try: + return resp.json() + except ValueError as e: + raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e + + +def _request_form( + method: str, + url: str, + *, + expected_status: tuple[int, ...] = (200,), + **kwargs: Any, +) -> dict[str, Any]: + try: + resp = httpx.request( + method, + url, + timeout=settings.PUSH_REQUEST_TIMEOUT_SEC, + **kwargs, + ) + except httpx.HTTPError as e: + raise VendorPushError(f"push http error: {e}") from e + + if resp.status_code not in expected_status: + logger.error("vendor push http failed url=%s http=%s body=%s", url, resp.status_code, resp.text[:500]) + raise VendorPushError(f"push http {resp.status_code}") + try: + return resp.json() + except ValueError as e: + raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e + + +def _cache_get(key: str) -> str | None: + cached = _token_cache.get(key) + if cached and cached.expires_at > time.time() + 60: + return cached.value + return None + + +def _cache_put(key: str, value: str, expires_in: int | float | None) -> str: + ttl = int(expires_in or 3600) + _token_cache[key] = _CachedToken(value=value, expires_at=time.time() + max(60, ttl - 60)) + return value + + +def _honor_access_token() -> str: + cache_key = "honor" + cached = _cache_get(cache_key) + if cached: + return cached + client_id = _require(settings.HONOR_PUSH_CLIENT_ID, "HONOR_PUSH_CLIENT_ID") + client_secret = _require(settings.HONOR_PUSH_CLIENT_SECRET, "HONOR_PUSH_CLIENT_SECRET") + data = _request_form( + "POST", + settings.HONOR_PUSH_TOKEN_ENDPOINT, + data={ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + token = data.get("access_token") + if not token: + raise VendorPushError(f"honor auth failed: {data}") + return _cache_put(cache_key, str(token), data.get("expires_in")) + + +def _send_honor(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]: + app_id = _require(settings.HONOR_PUSH_APP_ID, "HONOR_PUSH_APP_ID") + access_token = _honor_access_token() + payload = { + # clickAction type=3(打开应用首页)时,荣耀点击会把 data JSON 的键值对注入启动 intent 的 + # extras(与 HMS 同机制)→ MainActivity.consumeNavTarget 读 notif_id/notif_type 直达落地。 + "data": json.dumps(_click_extras(extras), ensure_ascii=False), + "notification": {"title": title, "body": body}, + "android": { + "ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s", + "targetUserType": 1, + "notification": { + "title": title, + "body": body, + "clickAction": {"type": 3}, + "importance": "NORMAL", + }, + }, + "token": [token], + } + data = _request_json( + "POST", + settings.HONOR_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id), + json=payload, + headers={ + "Content-Type": "application/json; charset=UTF-8", + "Authorization": f"Bearer {access_token}", + "timestamp": str(int(time.time() * 1000)), + }, + ) + code = data.get("code") + if code is not None and int(code) != 200: + raise VendorPushError(f"honor push failed: {data}") + return data + + +def _huawei_access_token() -> str: + """华为 OAuth2 client_credentials 换 access_token(client_id 即 AGC 应用的 AppId)。""" + cache_key = "huawei" + cached = _cache_get(cache_key) + if cached: + return cached + app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID") + app_secret = _require(settings.HUAWEI_PUSH_APP_SECRET, "HUAWEI_PUSH_APP_SECRET") + data = _request_form( + "POST", + settings.HUAWEI_PUSH_TOKEN_ENDPOINT, + data={ + "grant_type": "client_credentials", + "client_id": app_id, + "client_secret": app_secret, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + token = data.get("access_token") + if not token: + raise VendorPushError(f"huawei auth failed: {data}") + return _cache_put(cache_key, str(token), data.get("expires_in")) + + +def _send_huawei(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]: + """华为 Push Kit 下行消息(v1 messages:send)。成功码 '80000000'; + '80100000' 为部分成功(单 token 场景仍视为失败,错误里带原始响应便于排障)。""" + app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID") + access_token = _huawei_access_token() + payload = { + "validate_only": False, + "message": { + # click_action type=3(打开应用首页)时,HMS 点击会把 data JSON 的键值对注入启动 intent + # 的 extras → MainActivity.consumeNavTarget 读 notif_id/notif_type 直达落地。 + "data": json.dumps(_click_extras(extras), ensure_ascii=False), + "android": { + "ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s", + "notification": { + "title": title, + "body": body, + "click_action": {"type": 3}, + "importance": "NORMAL", + }, + }, + "token": [token], + }, + } + data = _request_json( + "POST", + settings.HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id), + json=payload, + headers={ + "Content-Type": "application/json; charset=UTF-8", + "Authorization": f"Bearer {access_token}", + }, + ) + if str(data.get("code", "")) != "80000000": + raise VendorPushError(f"huawei push failed: {data}") + return data + + +def _vivo_auth_token() -> str: + cache_key = "vivo" + cached = _cache_get(cache_key) + if cached: + return cached + app_id = _require(settings.VIVO_PUSH_APP_ID, "VIVO_PUSH_APP_ID") + app_key = _require(settings.VIVO_PUSH_APP_KEY, "VIVO_PUSH_APP_KEY") + app_secret = _require(settings.VIVO_PUSH_APP_SECRET, "VIVO_PUSH_APP_SECRET") + timestamp = str(int(time.time() * 1000)) + sign = hashlib.md5(f"{app_id}{app_key}{timestamp}{app_secret}".encode()).hexdigest() # noqa: S324 + data = _request_json( + "POST", + settings.VIVO_PUSH_AUTH_ENDPOINT, + json={ + "appId": app_id, + "appKey": app_key, + "timestamp": timestamp, + "sign": sign, + }, + headers={"Content-Type": "application/json"}, + ) + if int(data.get("result", -1)) != 0: + raise VendorPushError(f"vivo auth failed: {data}") + token = data.get("authToken") + if not token: + raise VendorPushError(f"vivo auth missing authToken: {data}") + return _cache_put(cache_key, str(token), 24 * 3600) + + +def _send_vivo(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]: + app_id = _require(settings.VIVO_PUSH_APP_ID, "VIVO_PUSH_APP_ID") + auth_token = _vivo_auth_token() + payload: dict[str, Any] = { + "appId": app_id, + "regId": token, + "notifyType": settings.VIVO_PUSH_NOTIFY_TYPE, + "title": title, + "content": body, + "timeToLive": settings.PUSH_TIME_TO_LIVE_SEC, + "requestId": uuid.uuid4().hex, + "pushMode": settings.VIVO_PUSH_MODE, + "clientCustomMap": extras, + } + # 点击落地:消息中心推送(带 notificationId)→ skipType=4 + skipContent=intent uri,由 vivo + # 系统直启 MainActivity 并携带 S. extras(与小米 notify_effect=2 同机制)。不依赖客户端 + # VivoPushReceiver.onNotificationMessageClicked 里的后台 startActivity——Android 10+ BAL + # 会静默拦掉,receiver 路径仅作兜底。无 notificationId 的召回类保持 skipType=1 仅打开首页。 + if extras.get("notificationId"): + payload["skipType"] = 4 + payload["skipContent"] = _click_intent_uri(extras) + else: + payload["skipType"] = 1 + if settings.VIVO_PUSH_CATEGORY: + payload["category"] = settings.VIVO_PUSH_CATEGORY + data = _request_json( + "POST", + settings.VIVO_PUSH_SEND_ENDPOINT, + json=payload, + headers={ + "Content-Type": "application/json", + "authToken": auth_token, + }, + ) + if int(data.get("result", -1)) != 0: + raise VendorPushError(f"vivo push failed: {data}") + return data + + +def _send_xiaomi(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]: + app_secret = _require(settings.XIAOMI_PUSH_APP_SECRET, "XIAOMI_PUSH_APP_SECRET") + message_title = settings.XIAOMI_PUSH_TEMPLATE_TITLE.strip() or title + message_description = settings.XIAOMI_PUSH_TEMPLATE_DESCRIPTION.strip() or body + form = { + "registration_id": token, + "restricted_package_name": settings.ANDROID_PACKAGE_NAME, + "title": message_title, + "description": message_description, + "payload": json.dumps(extras, ensure_ascii=False), + "pass_through": "0", + "notify_type": "-1", + "time_to_live": str(settings.PUSH_TIME_TO_LIVE_SEC * 1000), + } + # 点击落地:带 notificationId 的消息中心推送 → notify_effect=2 + intent_uri,MiPush 直接打开 + # MainActivity 并把 extras 作为 String extra 传入(客户端 MainActivity.consumeNavTarget 读 + # notif_id/notif_type,兜底 notificationId/type)→ 置读 + 刷角标 + 按 type 直达对应页(PRD §5)。 + # ⚠️ 早前用 notify_effect=1(仅打开 Launcher),小米自身不会把 payload 拆成普通 extra、而是塞进 + # 序列化的 MiPushMessage(key_message),客户端读不到 → 点击后停在首页「没反应」。 + # 无 notificationId 的系统召回类(如无障碍掉线)保持 notify_effect=1 仅拉起 App,行为不变。 + if extras.get("notificationId"): + form["extra.notify_effect"] = "2" + form["extra.intent_uri"] = _click_intent_uri(extras) + else: + form["extra.notify_effect"] = "1" + if settings.XIAOMI_PUSH_CHANNEL_ID: + form["extra.channel_id"] = settings.XIAOMI_PUSH_CHANNEL_ID.strip() + if settings.XIAOMI_PUSH_TEMPLATE_ID: + form["extra.template_id"] = settings.XIAOMI_PUSH_TEMPLATE_ID.strip() + if settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON: + form["extra.template_param"] = _xiaomi_template_param(title, body) + data = _request_form( + "POST", + settings.XIAOMI_PUSH_SEND_ENDPOINT, + data=form, + headers={"Authorization": f"key={app_secret}"}, + ) + code = data.get("code") + if code not in (0, "0", None): + raise VendorPushError(f"xiaomi push failed: {data}") + if str(data.get("result", "ok")).lower() not in ("ok", "success"): + raise VendorPushError(f"xiaomi push failed: {data}") + return data + + +def _click_extras(extras: dict[str, str]) -> dict[str, str]: + """点击落地参数:消息中心推送(extras 带 notificationId)补 notif_id/notif_type 别名—— + 客户端 MainActivity.consumeNavTarget 首选这两个键(厂商 receiver 路径的历史约定),原始键 + (notificationId/type/feedbackId/reportId/…)保留作兜底与业务跳转参数。 + 无 notificationId(如无障碍召回)原样返回,不喂点击路由参数。""" + if not extras.get("notificationId"): + return dict(extras) + merged = dict(extras) + merged.setdefault("notif_id", extras["notificationId"]) + if extras.get("type"): + merged.setdefault("notif_type", extras["type"]) + return merged + + +def _click_intent_uri(extras: dict[str, str]) -> str: + """构造「系统直启 MainActivity 并带 extras」的 intent uri(小米 notify_effect=2 的 + extra.intent_uri、vivo skipType=4 的 skipContent 共用):点击后厂商系统用 Intent.parseUri + 解析并 startActivity,extras 作为 String extra 原样送达。 + + - component 显式指向本包 MainActivity(exported=true、singleTask)→ 已运行则走 onNewIntent、 + 未运行则 onCreate,两条都会执行 consumeNavTarget。 + - 参数 = _click_extras(补 notif_id/notif_type 别名 + 透传 feedbackId/reportId 等跳转参数)。 + - 值按 Android Uri.encode 规则百分号编码(quote(safe="")):中文/分号/等号都不会破坏 intent uri + 结构;客户端 Intent.parseUri 侧 Uri.decode 无损还原。表单/JSON 传输层的编码与本层相互独立、 + 各自解码,不会双重转义(2026-07-15 小米联调结论)。 + """ + pkg = settings.ANDROID_PACKAGE_NAME + parts = ["intent:#Intent", f"component={pkg}/{pkg}.MainActivity"] + parts += [f"S.{key}={quote(str(value), safe='')}" for key, value in _click_extras(extras).items()] + parts.append("end") + return ";".join(parts) + + +def _xiaomi_template_param(title: str, alert: str) -> str: + rendered = ( + settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON + .replace("{title}", title) + .replace("{alert}", alert) + ) + try: + payload = json.loads(rendered) + except ValueError as e: + raise VendorPushError("XIAOMI_PUSH_TEMPLATE_PARAM_JSON invalid json") from e + if not isinstance(payload, dict): + raise VendorPushError("XIAOMI_PUSH_TEMPLATE_PARAM_JSON must be a json object") + for key, value in payload.items(): + if not isinstance(key, str) or not isinstance(value, str): + raise VendorPushError("xiaomi template params must be string key-value pairs") + if not value.strip() or len(value) > 128: + raise VendorPushError("xiaomi template param value length must be 1-128") + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + + +def _oppo_auth_token() -> str: + cache_key = "oppo" + cached = _cache_get(cache_key) + if cached: + return cached + app_key = _require(settings.OPPO_PUSH_APP_KEY, "OPPO_PUSH_APP_KEY") + master_secret = _require(settings.OPPO_PUSH_MASTER_SECRET, "OPPO_PUSH_MASTER_SECRET") + timestamp = str(int(time.time() * 1000)) + sign = hashlib.sha256(f"{app_key}{timestamp}{master_secret}".encode()).hexdigest() + data = _request_form( + "POST", + settings.OPPO_PUSH_AUTH_ENDPOINT, + data={ + "app_key": app_key, + "timestamp": timestamp, + "sign": sign, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if int(data.get("code", -1)) != 0: + raise VendorPushError(f"oppo auth failed: {data}") + token = (data.get("data") or {}).get("auth_token") or data.get("auth_token") + if not token: + raise VendorPushError(f"oppo auth missing auth_token: {data}") + return _cache_put(cache_key, str(token), 24 * 3600) + + +def _send_oppo(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]: + auth_token = _oppo_auth_token() + ttl_hours = max(1, min(72, settings.PUSH_TIME_TO_LIVE_SEC // 3600)) + notification: dict[str, Any] = { + "app_message_id": f"{extras.get('type', 'notify')}_{uuid.uuid4().hex}", + "title": title, + "content": body, + "off_line": True, + "off_line_ttl": ttl_hours, + "action_parameters": json.dumps(_click_extras(extras), ensure_ascii=False), + } + # 点击落地:OPPO SDK 没有点击回调,参数只能靠服务端点击动作配置送达——action_parameters 的 + # 键值对仅在 click_action_type=1/4 时才会注入目标 Activity 的 intent extras(type=0「启动应用」 + # 会忽略它,extras 全丢 → 点了没反应,与小米 notify_effect=1 同款坑)。 + # 消息中心推送(带 notificationId)→ type=4(打开应用内页面,Activity 全路径,exported=true); + # 无 notificationId 的召回类保持 type=0 仅打开应用。 + if extras.get("notificationId"): + notification["click_action_type"] = 4 + notification["click_action_activity"] = f"{settings.ANDROID_PACKAGE_NAME}.MainActivity" + else: + notification["click_action_type"] = 0 + # 新消息分类(2024-11-20 后创建的 OPPO 应用必须带 category,否则可能被拒收/降级) + if settings.OPPO_PUSH_CHANNEL_ID.strip(): + notification["channel_id"] = settings.OPPO_PUSH_CHANNEL_ID.strip() + if settings.OPPO_PUSH_CATEGORY.strip(): + notification["category"] = settings.OPPO_PUSH_CATEGORY.strip() + if settings.OPPO_PUSH_NOTIFY_LEVEL: + notification["notify_level"] = settings.OPPO_PUSH_NOTIFY_LEVEL + message = { + "target_type": 2, + "target_value": token, + "notification": notification, + } + data = _request_form( + "POST", + settings.OPPO_PUSH_SEND_ENDPOINT, + data={ + "auth_token": auth_token, + "message": json.dumps(message, ensure_ascii=False), + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if int(data.get("code", -1)) != 0: + raise VendorPushError(f"oppo push failed: {data}") + return data diff --git a/app/main.py b/app/main.py index 6b3ec04..0a1fcd1 100644 --- a/app/main.py +++ b/app/main.py @@ -31,8 +31,10 @@ from app.api.v1.device import router as device_router from app.api.v1.feedback import router as feedback_router from app.api.v1.invite import router as invite_router from app.api.v1.meituan import router as meituan_router +from app.api.v1.notifications import router as notifications_router from app.api.v1.order import router as order_router from app.api.v1.platform import router as platform_router +from app.api.v1.push import router as push_router from app.api.v1.report import router as report_router from app.api.v1.savings import router as savings_router from app.api.v1.signin import router as signin_router @@ -148,6 +150,10 @@ app.include_router(savings_router) app.include_router(ad_router) app.include_router(order_router) app.include_router(report_router) +# 消息通知中心(PRD;数据落库 notification 表,见 repositories/notification.py) +app.include_router(notifications_router) +# 厂商推送测试三件套(配置状态/模板预览/测试发送,支持 mock 与真发) +app.include_router(push_router) # 内部(server→server)端点:pricebot 上报价格观测 / 店铺映射,靠共享密钥头校验,不对客户端开放。 app.include_router(internal_price_router) app.include_router(internal_store_router) diff --git a/app/models/__init__.py b/app/models/__init__.py index c9a046b..5ed8a6b 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -35,6 +35,7 @@ from app.models.invite import InviteRelation # noqa: F401 from app.models.invite_fingerprint import InviteFingerprint # noqa: F401 from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401 from app.models.meituan_coupon import MeituanCoupon # noqa: F401 +from app.models.notification import Notification # noqa: F401 from app.models.onboarding import OnboardingCompletion # noqa: F401 from app.models.phone_rebind_log import PhoneRebindLog # noqa: F401 from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401 diff --git a/app/models/device.py b/app/models/device.py index e1d1261..324e176 100644 --- a/app/models/device.py +++ b/app/models/device.py @@ -1,9 +1,9 @@ -"""设备表(无障碍保护存活检测 + 极光推送)。 +"""设备表(无障碍保护存活检测 + 厂商直推)。 每条 = 一个用户的一台设备(per-install,device_id 由客户端 DeviceId.get() 生成)。 客户端的无障碍服务存活时周期上报心跳刷新 last_heartbeat_at;App 前台/登录时上报 -registration_id(极光推送目标)。后端 heartbeat_monitor_worker 扫描「曾经保护过、 -现在心跳超时」的设备,通过极光推送提醒用户重开无障碍。 +push_vendor + push_token(厂商推送目标)。后端 heartbeat_monitor_worker 扫描「曾经保护过、 +现在心跳超时」的设备,通过厂商直推提醒用户重开无障碍。 liveness_state 状态机(防刷屏,一次掉线只推一条): unknown → alive(收到 service 心跳)→ silent/notified(扫描发现超时并已推送) @@ -30,7 +30,7 @@ from app.db.base import Base class DeviceLiveness(Base): # 表名不叫 device:device 易被当成「设备信息(品牌/型号/系统)」表;本表实为**无障碍存活监控状态** - # (心跳 last_heartbeat_at + liveness_state + kill_alert_pending + 推送目标 registration_id),故名 device_liveness。 + # (心跳 last_heartbeat_at + liveness_state + kill_alert_pending + 厂商推送目标),故名 device_liveness。 __tablename__ = "device_liveness" __table_args__ = ( UniqueConstraint("user_id", "device_id", name="uq_device_liveness_user_device"), @@ -42,8 +42,12 @@ class DeviceLiveness(Base): ) # 客户端 DeviceId.get() 生成的 per-install id(如 device_Pixel_ab12cd34) device_id: Mapped[str] = mapped_column(String(128), index=True, nullable=False) - # 极光推送 registration id;拿到才填(JCollectionAuth 同意后才下发) + # 旧极光推送 registration id,仅为兼容历史客户端/数据保留;新链路使用 push_vendor + push_token。 registration_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + # 厂商推送类型:honor/vivo/xiaomi/oppo 等;客户端按实际 SDK token 来源上报。 + push_vendor: Mapped[str | None] = mapped_column(String(32), nullable=True) + # 厂商 push token / regId / registration_id;不同厂商命名不同,后端统一存这里。 + push_token: Mapped[str | None] = mapped_column(String(256), nullable=True) platform: Mapped[str] = mapped_column(String(16), nullable=False, default="android") app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) diff --git a/app/models/notification.py b/app/models/notification.py new file mode 100644 index 0000000..34493ab --- /dev/null +++ b/app/models/notification.py @@ -0,0 +1,95 @@ +"""消息通知中心:站内消息表(一行 = 一条下发给某用户的站内消息)。 + +13 类通知的**静态定义**(分类 / 版式 / 标题 / 操作行 / push 模板)在 +`app/core/notification_catalog.py`,是代码常量,**不入库**;本表只存**每条消息的动态部分** +(与接口 NotificationItem 的动态字段一一对应):type + 金额 + 信息行 + extra + 已读态 + 时间。 +category / card_style / title / action_text 都由 `type` 经 catalog 派生,不冗余存库。 + +- 写:`repositories/notification.create_notification`(业务事件下发站内消息的统一入口)。 +- 读:`api/v1/notifications.py`(列表 / 未读数 / 标记已读),均按 user 隔离、sent_at 倒序。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import ( + JSON, + Boolean, + DateTime, + ForeignKey, + Index, + Integer, + String, + func, + text, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + +# PG 用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 comparison_record.raw_payload 等)。 +_JSON = JSON().with_variant(JSONB(), "postgresql") + + +class Notification(Base): + __tablename__ = "notification" + __table_args__ = ( + # 列表分页:按用户取 + sent_at 倒序(核心查询,覆盖 user_id 前缀查找,故不再单独索引 user_id) + Index("ix_notification_user_sent", "user_id", "sent_at"), + # 铃铛角标:count where user_id=? and is_read=false —— 部分索引只覆盖未读行 + Index( + "ix_notification_user_unread", + "user_id", + sqlite_where=text("is_read = 0"), + postgresql_where=text("is_read = false"), + ), + # 去重/合并:同一 (user, type, dedup_key) 未读期间只允许一条(perm_* 权限异常、 + # reward_expiring 同批次即用它);消息一旦已读即离开索引,之后可再生成新的未读消息。 + Index( + "uq_notification_user_type_dedup", + "user_id", + "type", + "dedup_key", + unique=True, + sqlite_where=text("dedup_key IS NOT NULL AND is_read = 0"), + postgresql_where=text("dedup_key IS NOT NULL AND is_read = false"), + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("user.id"), nullable=False) + # 13 类之一(catalog.TYPES 的 key);category/card_style/title/action_text 由它派生,不入库 + type: Mapped[str] = mapped_column(String(32), nullable=False, index=True) + # 金币数(dual_amount / coin_reward 卡);其余类型 None + coins: Mapped[int | None] = mapped_column(Integer, nullable=True) + # 现金,单位【分】(dual_amount / withdraw / friend_cash 卡);其余 None + cash_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) + # 信息行 [{label, value}](已渲染好文案,前端逐行展示) + info_rows: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) + # 点击跳转/联动参数(feedbackId / withdrawId / permission / inviteeNickname / batchId …) + extra: Mapped[dict] = mapped_column(_JSON, nullable=False, default=dict) + is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + # 置读时刻(未读时为 None;埋点/分析用) + read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + # 去重键(可空):perm_*→permission、reward_expiring→batchId 等;配合部分唯一索引防重复未读 + dedup_key: Mapped[str | None] = mapped_column(String(64), nullable=True) + # 下发/业务时间;列表排序与展示都用它(带 +08:00 下发) + sent_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + def __repr__(self) -> str: # pragma: no cover + return ( + f"" + ) diff --git a/app/repositories/device.py b/app/repositories/device.py index c3e9047..e151738 100644 --- a/app/repositories/device.py +++ b/app/repositories/device.py @@ -21,17 +21,23 @@ def register_or_update( *, user_id: int, device_id: str, - registration_id: str | None, + registration_id: str | None = None, + push_vendor: str | None = None, + push_token: str | None = None, platform: str = "android", app_version: str | None = None, ) -> DeviceLiveness: - """注册设备或更新其 registration_id / 元信息。upsert by (user_id, device_id)。""" + """注册设备或更新其厂商 push token / 元信息。upsert by (user_id, device_id)。""" + normalized_vendor = _normalize_push_vendor(push_vendor) + normalized_token = push_token.strip() if push_token else None device = _get(db, user_id=user_id, device_id=device_id) if device is None: device = DeviceLiveness( user_id=user_id, device_id=device_id, registration_id=registration_id, + push_vendor=normalized_vendor, + push_token=normalized_token, platform=platform or "android", app_version=app_version, ) @@ -39,6 +45,10 @@ def register_or_update( else: if registration_id: device.registration_id = registration_id + if normalized_vendor: + device.push_vendor = normalized_vendor + if normalized_token: + device.push_token = normalized_token if platform: device.platform = platform if app_version: @@ -54,7 +64,9 @@ def touch_heartbeat( user_id: int, device_id: str, accessibility_enabled: bool, - registration_id: str | None, + registration_id: str | None = None, + push_vendor: str | None = None, + push_token: str | None = None, ) -> DeviceLiveness: """处理一次心跳(心跳也能自注册)。 @@ -69,6 +81,12 @@ def touch_heartbeat( if registration_id: device.registration_id = registration_id + normalized_vendor = _normalize_push_vendor(push_vendor) + normalized_token = push_token.strip() if push_token else None + if normalized_vendor: + device.push_vendor = normalized_vendor + if normalized_token: + device.push_token = normalized_token device.last_report_protection_on = accessibility_enabled if accessibility_enabled: @@ -87,7 +105,7 @@ def touch_heartbeat( def list_overdue(db: Session, *, timeout_minutes: int) -> list[DeviceLiveness]: """掉线设备:曾经保护过、当前 alive、心跳超时。 - 本期只做终端打印检测、不推送 → 不再要求有 registration_id(没接极光 token 的设备也要检出)。 + 即使没有厂商 token 也要检出,后续由 kill_alert_pending 走客户端进 App 后兜底提醒。 """ cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes) stmt = select(DeviceLiveness).where( @@ -124,3 +142,55 @@ def ack_kill_alert(db: Session, *, user_id: int, device_id: str) -> None: if device is not None and device.kill_alert_pending: device.kill_alert_pending = False db.commit() + + +def has_push_target(device: DeviceLiveness | None) -> bool: + """是否已有厂商直推所需的 vendor + token。""" + return bool(device and device.push_vendor and device.push_token) + + +def list_push_targets(db: Session, *, user_id: int) -> list[DeviceLiveness]: + """该用户全部可用厂商推送目标(push_vendor + push_token 双非空),最近更新在前。 + + 同 (vendor, token) 只留最新一行:同一台手机重装 App 后 device_id 会变、 + 留下 token 相同的旧行,去重防一次业务事件对同一台手机重复推送。 + """ + stmt = ( + select(DeviceLiveness) + .where( + DeviceLiveness.user_id == user_id, + DeviceLiveness.push_vendor.is_not(None), + DeviceLiveness.push_token.is_not(None), + ) + .order_by(DeviceLiveness.updated_at.desc(), DeviceLiveness.id.desc()) + ) + seen: set[tuple[str, str]] = set() + targets: list[DeviceLiveness] = [] + for dev in db.execute(stmt).scalars(): + if not dev.push_vendor or not dev.push_token: # 空串兜底(旧数据) + continue + key = (dev.push_vendor, dev.push_token) + if key in seen: + continue + seen.add(key) + targets.append(dev) + return targets + + +def _normalize_push_vendor(push_vendor: str | None) -> str | None: + if not push_vendor: + return None + vendor = push_vendor.strip().lower() + aliases = { + "honor": "honor", + "hihonor": "honor", + "荣耀": "honor", + "vivo": "vivo", + "xiaomi": "xiaomi", + "mi": "xiaomi", + "小米": "xiaomi", + "oppo": "oppo", + "oneplus": "oppo", + "realme": "oppo", + } + return aliases.get(vendor, vendor) diff --git a/app/repositories/invite.py b/app/repositories/invite.py index 5af08c5..78ce13a 100644 --- a/app/repositories/invite.py +++ b/app/repositories/invite.py @@ -25,6 +25,7 @@ from app.models.invite import InviteRelation from app.models.invite_fingerprint import InviteFingerprint from app.models.user import User from app.repositories import wallet as crud_wallet +from app.services import notification_events # 邀请码字符集:去掉易混字符(0/O/1/I/L/B/8/S/5/Z/2),用户口述/手输不易错 _CODE_ALPHABET = "ACDEFGHJKMNPQRTUVWXY34679" @@ -197,12 +198,13 @@ def try_reward_on_compare(db: Session, invitee_user_id: int) -> CompareRewardRes return CompareRewardResult("inviter_inactive", rel.inviter_user_id) reward = rewards.INVITE_COMPARE_REWARD_CENTS + inviter_id = inviter.id rel.compare_reward_granted = True rel.compare_reward_cents = reward rel.compare_rewarded_at = datetime.now(timezone.utc) # 发邀请奖励金到邀请人的独立账户(与金币隔离),ref_id 指向被邀请人便于对账 crud_wallet.grant_invite_cash( - db, inviter.id, reward, + db, inviter_id, reward, biz_type="invite_reward", ref_id=str(invitee_user_id), remark="好友比价奖励", ) try: @@ -210,7 +212,11 @@ def try_reward_on_compare(db: Session, invitee_user_id: int) -> CompareRewardRes except Exception: db.rollback() raise - return CompareRewardResult("granted", inviter.id, reward) + # PRD #12 好友下单到账:发奖已 commit,通知邀请人(站内 + push;失败只 log 不影响发奖) + notification_events.notify_invite_order_reward( + db, inviter_user_id=inviter_id, invitee_user_id=invitee_user_id, cash_cents=reward + ) + return CompareRewardResult("granted", inviter_id, reward) def get_stats(db: Session, inviter_id: int) -> tuple[int, int]: diff --git a/app/repositories/notification.py b/app/repositories/notification.py new file mode 100644 index 0000000..cc84101 --- /dev/null +++ b/app/repositories/notification.py @@ -0,0 +1,318 @@ +"""消息通知中心 数据仓库(落库版,查/写 `notification` 表)。 + +沿用原 notification_mock 的同名函数(list_notifications / unread_count / mark_read / +insert_sample),由内存 mock 迁到落库,**API 契约不变**。 + +- 读:按 user 隔离、sent_at 倒序;未读数 / 标记已读同口径。 +- 写:`create_notification` 是落库统一入口。**业务事件请走 services/notification_events** + (站内消息 + 厂商 push 一起下发,已接入提现回执/反馈审核/爆料通过/好友下单); + `build_sample_card` / `insert_sample` 按类型造样例内容,供 + `/api/v1/push/test` 的 createNotification 做「push → 站内已读联动」联调。 + +排序规则:全列表按 sent_at 倒序(最新在前;同秒再按 id 倒序稳定化),不分组。 +""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.core import notification_catalog as catalog +from app.models.notification import Notification + +# 北京时间:sent_at 统一带 +08:00 下发,前端直接按本地时区渲染「今天/昨天/M月D日」。 +_CST = timezone(timedelta(hours=8)) + + +def cash_yuan(cents: int | None) -> str | None: + """分 → 保留两位小数的元字符串(PRD §3:现金/提现金额保留两位小数)。""" + if cents is None: + return None + return f"{cents // 100}.{cents % 100:02d}" + + +def as_cst(dt: datetime) -> datetime: + """把库里取出的时间归一到北京时间(+08:00)再下发,保证接口 sentAt 恒带 +08:00。 + + SQLite 的 DateTime 不存时区,取出为 naive(存的就是写入时的 CST 墙上时间)→ 直接贴 +08:00; + PostgreSQL 的 timestamptz 取出为 aware(通常 UTC)→ 转到 +08:00。两端下发口径一致。 + """ + if dt.tzinfo is None: + return dt.replace(tzinfo=_CST) + return dt.astimezone(_CST) + + +def _fmt_time(dt: datetime) -> str: + """信息行里「到账时间」等 value 的展示格式。""" + return dt.strftime("%Y-%m-%d %H:%M") + + +# --------------------------------------------------------------------------- +# 读:列表 / 未读数 / 标记已读 +# --------------------------------------------------------------------------- + + +def _unread_count(db: Session, user_id: int) -> int: + return int( + db.execute( + select(func.count()) + .select_from(Notification) + .where(Notification.user_id == user_id, Notification.is_read.is_(False)) + ).scalar_one() + ) + + +def list_notifications( + db: Session, user_id: int, *, page: int, page_size: int +) -> tuple[list[Notification], int, int]: + """分页取通知列表。返回 (当前页条目, 总条数, 未读条数)。""" + total = int( + db.execute( + select(func.count()) + .select_from(Notification) + .where(Notification.user_id == user_id) + ).scalar_one() + ) + unread = _unread_count(db, user_id) + rows = ( + db.execute( + select(Notification) + .where(Notification.user_id == user_id) + .order_by(Notification.sent_at.desc(), Notification.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + .scalars() + .all() + ) + return list(rows), total, unread + + +def unread_count(db: Session, user_id: int) -> int: + """未读总数(首页铃铛角标)。""" + return _unread_count(db, user_id) + + +def mark_read( + db: Session, user_id: int, *, ids: list[int] | None = None, mark_all: bool = False +) -> tuple[int, int]: + """标记已读。mark_all=True 全量清零,否则按 ids 逐条置读(不存在的 id 忽略,幂等)。 + + 返回 (本次实际由未读→已读的条数, 剩余未读数)。 + """ + if not mark_all: + wanted = set(ids or []) + if not wanted: + return 0, _unread_count(db, user_id) + + stmt = select(Notification).where( + Notification.user_id == user_id, Notification.is_read.is_(False) + ) + if not mark_all: + stmt = stmt.where(Notification.id.in_(wanted)) + + now = datetime.now(timezone.utc) + marked = 0 + for n in db.execute(stmt).scalars().all(): + n.is_read = True + n.read_at = now + marked += 1 + db.commit() + return marked, _unread_count(db, user_id) + + +# --------------------------------------------------------------------------- +# 写:业务下发入口 +# --------------------------------------------------------------------------- + + +def create_notification( + db: Session, + *, + user_id: int, + type_key: str, + coins: int | None = None, + cash_cents: int | None = None, + info_rows: list[dict[str, str]] | None = None, + extra: dict[str, str] | None = None, + sent_at: datetime | None = None, + dedup_key: str | None = None, +) -> Notification: + """下发一条站内消息(业务事件统一入口)。type_key 必须是 catalog 的 13 类之一。 + + dedup_key 非空时受部分唯一索引约束(同 user+type+dedup_key 未读期间仅一条); + 需要「同批次/同权限只保留一条未读」的调用方,应捕获 IntegrityError 或先查已存在的未读再决定 + 更新 sent_at,而非重复插入(见 models/notification 的 uq_notification_user_type_dedup)。 + """ + catalog.get_type(type_key) # 校验类型合法(未知类型抛 UnknownNotificationType) + row = Notification( + user_id=user_id, + type=type_key, + coins=coins, + cash_cents=cash_cents, + info_rows=info_rows or [], + extra=extra or {}, + sent_at=sent_at or datetime.now(_CST), + dedup_key=dedup_key, + ) + db.add(row) + db.commit() + db.refresh(row) + return row + + +# --------------------------------------------------------------------------- +# 样例内容(供 /push/test createNotification 联调;文案对齐 PRD §3) +# --------------------------------------------------------------------------- + + +def _card_reward_expiring(sent_at: datetime, coins: int = 86, cash: int = 1280, days: int = 3) -> dict: + return { + "coins": coins, + "cash_cents": cash, + "info_rows": [ + { + "label": "过期说明", + "value": f"您有{coins}金币和{cash_yuan(cash)}元现金即将失效," + "完成一次一键领券或一键比价即可激活收益", + }, + {"label": "过期时间", "value": f"{days}天后失效"}, + ], + # batchId:同一批次激活成功后不再重复推送(PRD §2 激活逻辑) + "extra": {"batchId": f"batch_{sent_at:%Y%m%d}"}, + } + + +def _card_reward_expired(sent_at: datetime, coins: int = 35, cash: int = 60) -> dict: + return { + "coins": coins, + "cash_cents": cash, + "info_rows": [ + { + "label": "过期说明", + "value": f"您的{coins}金币和{cash_yuan(cash)}元现金已失效," + "完成一次一键领券或一键比价可赚取新收益", + }, + {"label": "过期时间", "value": f"已过期 {sent_at.month}月{sent_at.day}日失效"}, + ], + "extra": {}, # 点击跳赚钱页(tab),无需参数 + } + + +def _card_withdraw_success(sent_at: datetime, cash: int = 50) -> dict: + return { + "cash_cents": cash, + "info_rows": [ + {"label": "到账账户", "value": "微信钱包"}, + {"label": "到账时间", "value": _fmt_time(sent_at)}, + ], + "extra": {}, # 无跳转,仅消红点 + } + + +def _card_withdraw_failed(sent_at: datetime, cash: int = 350, reason: str = "微信零钱未实名") -> dict: + return { + "cash_cents": cash, + "info_rows": [ + {"label": "失败原因", "value": reason}, + {"label": "退回说明", "value": "款项已原路退回现金余额"}, + ], + "extra": {"withdrawId": "88001"}, # 点击跳提现页 + } + + +def _card_permission(permission: str) -> dict: + # permission ∈ accessibility(无障碍)/ battery(省电策略)/ autostart(自启动)/ overlay(悬浮窗) + # 客户端点击时按此 key 实时检测该权限并弹对应开启弹窗(PRD §2 权限逻辑)。 + return { + "info_rows": [ + {"label": "说明文案", "value": "未开启将导致核心功能不可用,请尽快开启"}, + ], + "extra": {"permission": permission}, + } + + +def _card_feedback_reply(feedback_id: str) -> dict: + return { + "info_rows": [ + {"label": "说明文案", "value": "快去看看官方给您的回复吧~"}, + ], + "extra": {"feedbackId": feedback_id}, # 跳反馈历史页并滚动高亮该条(PRD §2) + } + + +def _card_feedback_reward(sent_at: datetime, coins: int = 300, + reply: str = "感谢反馈,您说的问题已经修复上线,送您的金币请查收~") -> dict: + return { + "coins": coins, + "info_rows": [ + {"label": "奖励说明", "value": "感谢您的反馈,您的金币奖励已到账"}, + {"label": "官方留言", "value": reply}, # PRD §3:官方留言必填(发奖励必带留言) + {"label": "到账时间", "value": _fmt_time(sent_at)}, + ], + "extra": {"feedbackId": "3002"}, + } + + +def _card_report_approved(sent_at: datetime, coins: int = 1000, store: str = "蜀大侠火锅") -> dict: + return { + "coins": coins, + "info_rows": [ + {"label": "奖励说明", "value": f"您爆料的「{store}」更低价已通过审核,金币奖励已到账"}, + {"label": "到账时间", "value": _fmt_time(sent_at)}, + ], + "extra": {"reportId": "5001"}, # 跳爆料记录页并滚动高亮该条 + } + + +def _card_invite_order_reward(sent_at: datetime, cash: int = 200, nickname: str = "柚子") -> dict: + return { + "cash_cents": cash, + "info_rows": [ + {"label": "奖励说明", "value": f"好友「{nickname}」完成首次下单"}, + {"label": "到账时间", "value": _fmt_time(sent_at)}, + ], + "extra": {"inviteeNickname": nickname}, # 跳邀请页(welfare/invite.html?from=notifications) + } + + +def _card_invite_remind(nickname: str = "阿泽") -> dict: + return { + "info_rows": [ + { + "label": "说明文案", + "value": f"好友「{nickname}」已注册,还没完成比价下单,提醒TA完成后你可得2元现金", + }, + ], + # scrollTo=remind:跳邀请页并自动滚动到底部「提醒好友」模块(PRD §2 #13) + "extra": {"inviteeNickname": nickname, "scrollTo": "remind"}, + } + + +def build_sample_card(type_key: str, sent_at: datetime | None = None) -> dict: + """按类型生成一份样例卡片内容({coins?, cash_cents?, info_rows, extra}),/push/test 联调用。""" + catalog.get_type(type_key) # 校验 type 合法 + now = sent_at or datetime.now(_CST) + builders = { + "reward_expiring": lambda: _card_reward_expiring(now), + "reward_expired": lambda: _card_reward_expired(now), + "withdraw_success": lambda: _card_withdraw_success(now), + "withdraw_failed": lambda: _card_withdraw_failed(now), + "perm_accessibility": lambda: _card_permission("accessibility"), + "perm_battery": lambda: _card_permission("battery"), + "perm_autostart": lambda: _card_permission("autostart"), + "perm_overlay": lambda: _card_permission("overlay"), + "feedback_reply": lambda: _card_feedback_reply("3001"), + "feedback_reward": lambda: _card_feedback_reward(now), + "report_approved": lambda: _card_report_approved(now), + "invite_order_reward": lambda: _card_invite_order_reward(now), + "invite_remind": lambda: _card_invite_remind(), + } + return builders[type_key]() + + +def insert_sample(db: Session, user_id: int, type_key: str) -> Notification: + """插入一条该类型的样例未读通知并落库(/push/test createNotification 联调:push extras 带上 + 它的 id,客户端点击 push 后调 POST /notifications/read {ids:[id]} 即闭环验证已读联动)。""" + return create_notification(db, user_id=user_id, type_key=type_key, **build_sample_card(type_key)) diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index c5f42d0..5484337 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -29,6 +29,7 @@ from app.models.wallet import ( WechatTransferAuthorization, WithdrawOrder, ) +from app.services import notification_events # 微信转账终态:成功 / 失败(失败/取消/关闭都退款) _WX_STATE_SUCCESS = "SUCCESS" @@ -525,6 +526,8 @@ def _refund_withdraw( order.status = final_status order.fail_reason = reason[:256] db.commit() + # 上次退款后没走完终态(如中途崩溃)的补账路径:这里补发通知(dedup 防重) + notification_events.notify_withdraw_failed(db, order) return bal = _add_cash(db, order.user_id, order.amount_cents, order.source) db.add( @@ -568,6 +571,11 @@ def _refund_withdraw( fresh_order.status = final_status fresh_order.fail_reason = reason[:256] db.commit() + notification_events.notify_withdraw_failed(db, fresh_order) + return + # PRD #4 提现失败通知:所有退款终态(failed/rejected)在此收口下发; + # dedup=out_bill_no,与上面并发路径重复触发时未读期间只落一条。 + notification_events.notify_withdraw_failed(db, order) def _wx_not_found(result: dict) -> bool: @@ -608,6 +616,7 @@ def _settle_after_ambiguous(db: Session, order: WithdrawOrder, reason: str) -> N order.status = "success" order.transfer_bill_no = q["data"].get("transfer_bill_no") db.commit() + notification_events.notify_withdraw_success(db, order) # PRD #3 提现到账 elif state in _WX_STATE_FAILED: _refund_withdraw(db, order, reason=reason) else: @@ -981,6 +990,8 @@ def _apply_transfer_result(db: Session, order: WithdrawOrder, data: dict) -> Wit order.status = "success" db.commit() db.refresh(order) + if order.status == "success": # 免确认转账直接到账 → PRD #3 提现到账 + notification_events.notify_withdraw_success(db, order) return order @@ -1130,6 +1141,7 @@ def refresh_withdraw_status( if state == _WX_STATE_SUCCESS: order.status = "success" db.commit() + notification_events.notify_withdraw_success(db, order) # PRD #3 提现到账 elif state in _WX_STATE_FAILED: _refund_withdraw(db, order, reason=f"微信转账状态 {state}") elif state == _WX_STATE_WAIT_CONFIRM and cancel_if_unconfirmed: diff --git a/app/schemas/device.py b/app/schemas/device.py index dd7a7c4..3f8cda1 100644 --- a/app/schemas/device.py +++ b/app/schemas/device.py @@ -3,12 +3,15 @@ from __future__ import annotations from datetime import datetime -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field class DeviceRegisterRequest(BaseModel): device_id: str + # registration_id 为旧极光字段,新推送链路统一使用 push_vendor + push_token。 registration_id: str | None = None + push_vendor: str | None = None + push_token: str | None = None platform: str = "android" app_version: str | None = None @@ -18,6 +21,8 @@ class HeartbeatRequest(BaseModel): source: str = "service" # service | app accessibility_enabled: bool = True registration_id: str | None = None + push_vendor: str | None = None + push_token: str | None = None class DeviceOut(BaseModel): @@ -26,6 +31,8 @@ class DeviceOut(BaseModel): id: int device_id: str registration_id: str | None + push_vendor: str | None + push_token: str | None ever_protected: bool liveness_state: str last_heartbeat_at: datetime | None @@ -46,3 +53,17 @@ class LivenessOut(BaseModel): class LivenessAckRequest(BaseModel): device_id: str + + +class PushTestRequest(BaseModel): + device_id: str + delay_seconds: int = Field(default=10, ge=0, le=60) + push_vendor: str | None = None + push_token: str | None = None + registration_id: str | None = None + + +class PushTestOut(BaseModel): + ok: bool = True + delay_seconds: int + has_push_token: bool diff --git a/app/schemas/notification.py b/app/schemas/notification.py new file mode 100644 index 0000000..08fcfc9 --- /dev/null +++ b/app/schemas/notification.py @@ -0,0 +1,128 @@ +"""消息通知中心 请求/响应契约。 + +⚠️ 命名约定:本组接口按 PRD 前端契约使用 **camelCase**(sentAt / isRead / pageSize …), +与库内其他 snake_case 接口不同——PRD 与前端原型(notifications.html)按 camelCase 对接, +需求方接口清单亦明确写作 sentAt / isRead,故整组遵循之。响应序列化走 pydantic alias。 + +字段说明都写在 Field(description=...) 里,起服务后打开 /docs 即是给前端的在线文档。 +""" +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + + +class _CamelModel(BaseModel): + """出参统一 camelCase(alias);populate_by_name 允许服务端代码仍用 snake_case 构造。""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class InfoRow(_CamelModel): + """卡片信息行(PRD §3「信息行」列),前端按 label: value 逐行渲染。""" + + label: str = Field(description="行标签,如「过期说明」「到账账户」「失败原因」") + value: str = Field(description="行内容(已按 PRD 文案拼好变量,前端直接展示)") + + +class NotificationItem(_CamelModel): + """一条通知卡片。 + + 卡片头部三要素:categoryLabel(分类标签)+ 未读红点(isRead=false 时展示)+ 时间(sentAt)。 + 时间显示规则(前端处理):今天→「今天」;昨天→「昨天」;当年→「M月D日」;跨年→「YYYY年M月D日」。 + """ + + id: int = Field(description="通知 id(未读消除、push 联动都用它)") + category: str = Field( + description="分类 key:withdraw_assistant=提现助手 / system=系统通知 / " + "feedback=我的反馈 / report=我的爆料 / invite=好友邀请" + ) + category_label: str = Field(description="分类中文标签(卡片头部直接展示)") + type: str = Field( + description="类型 key(13 种,决定点击行为,见 PRD §2):reward_expiring 即将失效 / " + "reward_expired 已失效 / withdraw_success 提现成功 / withdraw_failed 提现失败 / " + "perm_accessibility 无障碍异常 / perm_battery 省电策略异常 / " + "perm_autostart 自启动异常 / perm_overlay 悬浮窗异常 / " + "feedback_reply 官方回复 / feedback_reward 反馈奖励 / " + "report_approved 爆料审核通过 / invite_order_reward 好友下单奖励 / " + "invite_remind 好友催单提醒" + ) + card_style: str = Field( + description="卡片版式:dual_amount 双金额卡 / withdraw 提现卡 / plain_text 纯文本卡 / " + "coin_reward 金币奖励卡 / friend_cash 好友现金卡" + ) + title: str = Field(description="卡片标题(双金额/提现/金币奖励/好友现金卡标题居中)") + coins: int | None = Field( + default=None, + description="金币数(整数,不带小数)。dual_amount / coin_reward 卡有值,其余 null", + ) + cash_cents: int | None = Field( + default=None, + description="现金金额,单位【分】。dual_amount / withdraw / friend_cash 卡有值,其余 null", + ) + cash_yuan: str | None = Field( + default=None, + description="现金金额展示串(元,保留两位小数,如 \"12.80\"),与 cashCents 同源,可直接展示", + ) + info_rows: list[InfoRow] = Field( + description="信息行列表(label: value),内容已按 PRD §3 拼好,前端逐行渲染即可" + ) + action_text: str | None = Field( + default=None, + description="操作行文案(如「立即激活您的收益」「去开启」);null=无操作行(提现成功卡)。" + "注意:点击目标是整张卡片,不区分卡片主体和操作行", + ) + extra: dict[str, Any] = Field( + description="点击跳转所需业务参数,按 type 取用:perm_* → {permission: accessibility|battery|" + "autostart|overlay}(点击时实时检测该权限);feedback_* → {feedbackId};" + "report_approved → {reportId};withdraw_failed → {withdrawId};" + "invite_order_reward → {inviteeNickname};invite_remind → " + "{inviteeNickname, scrollTo:\"remind\"};reward_expiring → {batchId}" + ) + sent_at: datetime = Field(description="下发时间(ISO8601 带 +08:00 时区),前端按显示规则格式化") + is_read: bool = Field(description="是否已读;false 时分类标签右侧展示 6px 红点(#E53935)") + + +class NotificationListOut(_CamelModel): + """GET /api/v1/notifications 出参。列表已按时间倒序排好(最新在前,**不分组**; + PRD §1 的"按分类分组"为笔误,已确认取消),前端无需再排。""" + + items: list[NotificationItem] = Field(description="当前页通知卡片") + page: int = Field(description="当前页码(1 起)") + page_size: int = Field(description="每页条数") + total: int = Field(description="全部通知总条数(含已读)") + has_more: bool = Field(description="是否还有下一页") + unread_count: int = Field(description="当前未读总数(与 /notifications/unread-count 同口径,省一次请求)") + + +class UnreadCountOut(_CamelModel): + """GET /api/v1/notifications/unread-count 出参(首页铃铛角标)。""" + + count: int = Field(description="未读总条数(精确值)") + badge_text: str | None = Field( + description="角标展示文案:超过 99 返回 \"99+\";等于 0 返回 null(整个角标隐藏,不展示空红点)" + ) + + +class MarkReadRequest(_CamelModel): + """POST /api/v1/notifications/read 入参,两种模式二选一: + + - `{"ids": [90001, 90002]}` 单条/多条置读——点击某张卡片、点击 push 落地后同步置读; + - `{"all": true}` 全量清零——进入通知中心(或退出时)自动清零(PRD §4)。 + + 同时传时 all=true 优先;不存在/已读的 id 自动忽略(幂等,可放心重试)。 + """ + + ids: list[int] | None = Field(default=None, description="要置为已读的通知 id 列表") + all: bool = Field(default=False, description="true=清空该用户全部未读") + + +class MarkReadOut(_CamelModel): + """POST /api/v1/notifications/read 出参。""" + + ok: bool = Field(description="固定 true(参数非法时走 400,不会到这里)") + marked_count: int = Field(description="本次实际由未读变为已读的条数(重复请求会是 0)") + unread_count: int = Field(description="处理后的剩余未读总数,可直接刷新铃铛角标") diff --git a/app/schemas/push.py b/app/schemas/push.py new file mode 100644 index 0000000..f688b0a --- /dev/null +++ b/app/schemas/push.py @@ -0,0 +1,99 @@ +"""厂商推送(测试/联调)接口契约。与消息中心同族,出参统一 camelCase。""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + + +class _CamelModel(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class PushVendorStatus(_CamelModel): + vendor: str = Field(description="厂商 key:honor / huawei / xiaomi / oppo / vivo") + label: str = Field(description="厂商中文名") + configured: bool = Field(description="服务端凭据是否齐全(齐全才能真发,mock 不受影响)") + missing_keys: list[str] = Field(description="缺失的 .env 配置键;configured=true 时为空") + + +class PushVendorsOut(_CamelModel): + vendors: list[PushVendorStatus] = Field(description="5 个厂商的配置状态") + + +class PushTemplateOut(_CamelModel): + type: str = Field(description="通知类型 key(13 种,与消息中心 type 一致)") + category: str = Field(description="分类 key") + category_label: str = Field(description="分类中文标签") + card_style: str = Field(description="站内卡片版式") + push_title: str = Field(description="push 标题(≤11 字固定文案,PRD §5)") + push_body_sample: str = Field(description="push 正文示例(模板用 PRD 示例值渲染后的效果)") + push_body_template: str = Field(description="push 正文模板原文,{var} 为变量占位") + variables: list[str] = Field(description="模板变量名列表(调 /push/test 时可在 vars 里覆盖)") + sample_vars: dict[str, str] = Field(description="各变量的 PRD 示例值(vars 未覆盖时的缺省)") + + +class PushTemplatesOut(_CamelModel): + templates: list[PushTemplateOut] = Field(description="13 种通知类型的 push 模板(PRD 编号顺序)") + + +class PushTestRequest(_CamelModel): + """POST /api/v1/push/test 入参。三种发送内容来源(优先级从高到低): + + 1. 直接指定 title + content; + 2. 指定 type(13 种之一)→ 按 PRD §5 模板渲染,vars 可覆盖模板变量; + 3. 都不传 → 发一条通用测试文案。 + + 推送目标:pushToken 直填,或 deviceId 反查该用户已注册设备(/api/v1/device/register 上报过的)。 + """ + + vendor: str = Field( + default="", + description="厂商:honor/huawei/xiaomi/oppo/vivo(中文「华为」「小米」等别名也识别)。" + "留空时用 deviceId 对应设备上报的 push_vendor", + ) + push_token: str = Field(default="", description="厂商 push token / regId;留空则走 deviceId 反查") + device_id: str = Field(default="", description="设备 id(客户端 DeviceId.get());用于反查 token") + type: str = Field( + default="", + description="通知类型 key(13 种,见 GET /push/templates);留空且未直接给 title/content 时发通用测试文案", + ) + vars: dict[str, str] = Field( + default_factory=dict, + description="覆盖 push 模板变量,如 {\"coins\":\"520\",\"cash\":\"6.66\"};缺省用 PRD 示例值", + ) + title: str = Field(default="", description="直接指定标题(优先于 type 模板)") + content: str = Field(default="", description="直接指定正文(优先于 type 模板)") + create_notification: bool = Field( + default=False, + description="true=同时往该用户的消息中心 mock 列表插入一条同类型未读通知,push extras 带上它的" + " notificationId → 可闭环验证「点 push → 落地 → 调 /notifications/read 消红点」联动" + "(仅 type 为 13 种类型之一时生效)", + ) + mock: bool = Field( + default=True, + description="true(默认)=不真调厂商 API,返回渲染结果(联调安全);false=真发,要求该厂商凭据已配置", + ) + + +class PushTestOut(_CamelModel): + ok: bool = Field(description="发送(或 mock 渲染)成功") + mock: bool = Field(description="本次是否 mock(未真调厂商 API)") + vendor: str = Field(description="实际使用的厂商 key(已归一化)") + title: str = Field(description="实际下发的 push 标题") + body: str = Field(description="实际下发的 push 正文") + extras: dict[str, str] = Field( + description="随 push 下发的自定义键值(客户端深链用):type 必有;createNotification=true 时带" + " notificationId 及该通知的业务参数(feedbackId / permission / …)" + ) + notification_id: int | None = Field( + default=None, description="createNotification=true 时新插入的站内 mock 通知 id" + ) + missing_keys: list[str] = Field( + default_factory=list, + description="该厂商仍缺失的配置键(mock 发送时提示「真发前还需配什么」;真发时必为空)", + ) + vendor_response: dict[str, Any] | None = Field( + default=None, description="真发时厂商 API 的原始响应(mock 时为 null)" + ) diff --git a/app/services/notification_events.py b/app/services/notification_events.py new file mode 100644 index 0000000..434fced --- /dev/null +++ b/app/services/notification_events.py @@ -0,0 +1,269 @@ +"""消息通知中心:业务事件 → 站内消息 + 厂商 push 的统一下发口。 + +PRD《消息通知中心》真实业务触发在此收口(替代 /push/test 的样例数据),已接入: + #3 withdraw_success 提现到账(repositories/wallet 各「pending→success」转换点) + #4 withdraw_failed 提现失败/退回(repositories/wallet._refund_withdraw,含审核拒绝) + #9 feedback_reply 官方回复(admin 反馈审核「拒绝」,带用户可见原因/留言) + #10 feedback_reward 反馈奖励(admin 反馈审核「采纳」发金币,必带官方留言) + #11 report_approved 爆料审核通过(admin 上报更低价「通过」发金币) + #12 invite_order_reward 好友下单到账(repositories/invite.try_reward_on_compare 发奖后) + +行为约定(调用方唯一需要知道的两条): + 1. **绝不抛异常**——通知只是业务的副产物,站内消息落库失败/推送失败只 log, + 绝不让提现退款、审核发奖等主流程回滚或报错。 + 2. **必须在业务事务 commit 之后调用**——内部会再 commit(写 notification 表); + 若在业务半途调用,会把调用方未提交的脏状态一并提交。 + +去重:各事件用业务主键做 dedup_key(提现单号/反馈 id/爆料 id/被邀请人 id),配合 +notification 表的部分唯一索引,同一事件并发重复触发时未读期间只落一条、只推一次。 + +推送:向该用户所有已上报厂商 token 的设备直推(integrations/vendor_push); +厂商凭据未配置(本地/测试环境)时自动跳过推送、只落站内消息。extras 按 +PRD §4 约定带 {type, notificationId, ...跳转参数},客户端点击 push 深链落地 +并调 POST /notifications/read 同步置读。 +""" +from __future__ import annotations + +import logging +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core import notification_catalog as catalog +from app.core.rewards import CN_TZ +from app.integrations import vendor_push +from app.models.user import User +from app.repositories import device as device_repo +from app.repositories import notification as notif_repo + +if TYPE_CHECKING: + from app.models.feedback import Feedback + from app.models.notification import Notification + from app.models.price_report import PriceReport + from app.models.wallet import WithdrawOrder + +logger = logging.getLogger("shagua.notification_events") + + +def _fmt_time(dt: datetime) -> str: + """信息行「到账时间」的展示格式(与 repositories/notification 样例卡一致)。""" + return dt.strftime("%Y-%m-%d %H:%M") + + +def _yuan_trim(cents: int) -> str: + """分 → 元,去掉多余的 0(200→"2"、1280→"12.80")。push 正文用(PRD §5 示例口径: + 「{2}元现金已到账」);卡片数值仍走 cash_cents 由前端按两位小数渲染。""" + yuan = cents / 100 + return f"{yuan:.2f}".rstrip("0").rstrip(".") + + +def _display_name(user: User | None) -> str: + """好友昵称展示:昵称 → 微信昵称 → 手机尾号,全无则「好友」。""" + name = ((user.nickname if user else None) or (user.wechat_nickname if user else None) or "").strip() + if not name and user and user.phone: + name = f"用户{user.phone[-4:]}" + return name or "好友" + + +# --------------------------------------------------------------------------- +# 内核:落站内消息 + 厂商推送(全程吞异常) +# --------------------------------------------------------------------------- + + +def _dispatch( + db: Session, + *, + user_id: int, + type_key: str, + coins: int | None = None, + cash_cents: int | None = None, + info_rows: list[dict[str, str]] | None = None, + extra: dict[str, str] | None = None, + dedup_key: str | None = None, + push_vars: dict[str, str] | None = None, +) -> Notification | None: + """落一条站内消息并向该用户设备直推。返回落库行;去重命中/失败返回 None。""" + try: + row = notif_repo.create_notification( + db, + user_id=user_id, + type_key=type_key, + coins=coins, + cash_cents=cash_cents, + info_rows=info_rows, + extra=extra, + dedup_key=dedup_key, + ) + except IntegrityError: + # 同 (user, type, dedup_key) 已有未读消息 = 同一事件并发/重复触发 → 不重复落、不重复推 + db.rollback() + logger.info( + "notification dedup hit user_id=%s type=%s dedup_key=%s", user_id, type_key, dedup_key + ) + return None + except Exception: # noqa: BLE001 — 通知失败绝不影响业务主流程 + logger.exception("create notification failed user_id=%s type=%s", user_id, type_key) + try: + db.rollback() + except Exception: # noqa: BLE001 — 回滚失败也不外抛,session 由请求生命周期兜底 + logger.exception("rollback after notification failure also failed") + return None + + _push_to_user_devices(db, row, push_vars) + return row + + +def _push_to_user_devices(db: Session, row: Notification, push_vars: dict[str, str] | None) -> None: + """向消息归属用户的全部厂商推送目标直推(best-effort,单设备失败不影响其余)。""" + try: + title, body = catalog.render_push(row.type, push_vars) + # PRD §4 push 联动:extras 至少带 type + notificationId,外加该类型的跳转参数(extra 列) + extras: dict[str, str] = {"type": row.type} + extras.update({str(k): str(v) for k, v in (row.extra or {}).items()}) + extras["notificationId"] = str(row.id) + + for dev in device_repo.list_push_targets(db, user_id=row.user_id): + vendor = vendor_push.normalize_vendor(dev.push_vendor) + if not vendor or vendor not in vendor_push.SUPPORTED_VENDORS: + continue + if vendor_push.missing_settings(vendor): + # 本地/测试环境凭据不齐 → 只落站内消息,不发真推送(与 push/vendors 的报缺口径一致) + logger.info( + "skip push (vendor %s not configured) user_id=%s type=%s", + vendor, row.user_id, row.type, + ) + continue + try: + vendor_push.send_notification( + vendor, dev.push_token, title=title, body=body, extras=extras + ) + logger.info( + "push sent user_id=%s type=%s vendor=%s notification_id=%s", + row.user_id, row.type, vendor, row.id, + ) + except vendor_push.VendorPushError as e: + logger.warning( + "push failed user_id=%s type=%s vendor=%s: %s", row.user_id, row.type, vendor, e + ) + except Exception: # noqa: BLE001 — 渲染/查设备等意外失败同样不外抛 + logger.exception("push notification failed user_id=%s type=%s", row.user_id, row.type) + + +# --------------------------------------------------------------------------- +# 六个业务事件(PRD §1/§3/§5 编号见文件头) +# --------------------------------------------------------------------------- + + +def notify_withdraw_success(db: Session, order: WithdrawOrder) -> None: + """#3 提现成功:款项已存入微信零钱。点击无跳转仅消红点(extra 空)。""" + _dispatch( + db, + user_id=order.user_id, + type_key="withdraw_success", + cash_cents=order.amount_cents, + info_rows=[ + {"label": "到账账户", "value": "微信钱包"}, + {"label": "到账时间", "value": _fmt_time(datetime.now(CN_TZ))}, + ], + extra={}, + dedup_key=order.out_bill_no, + push_vars={"amount": notif_repo.cash_yuan(order.amount_cents)}, + ) + + +def notify_withdraw_failed(db: Session, order: WithdrawOrder) -> None: + """#4 提现失败/退回:含微信侧失败、审核拒绝、解绑退回。点击跳提现页重新提现。 + + 失败原因用 order.fail_reason(与 /withdraw/status 下发的用户可读原因同源)。 + """ + reason = (order.fail_reason or "").strip() or "提现未成功" + _dispatch( + db, + user_id=order.user_id, + type_key="withdraw_failed", + cash_cents=order.amount_cents, + info_rows=[ + {"label": "失败原因", "value": reason}, + {"label": "退回说明", "value": "款项已原路退回现金余额"}, + ], + extra={"withdrawId": order.out_bill_no}, + dedup_key=order.out_bill_no, + push_vars={"amount": notif_repo.cash_yuan(order.amount_cents), "reason": reason}, + ) + + +def notify_feedback_reply(db: Session, feedback: Feedback) -> None: + """#9 官方回复:运营审核了反馈且未采纳(用户可见原因/留言落在反馈记录上)。 + 点击跳反馈历史页滚动高亮该条(extra.feedbackId)。""" + _dispatch( + db, + user_id=feedback.user_id, + type_key="feedback_reply", + info_rows=[{"label": "说明文案", "value": "快去看看官方给您的回复吧~"}], + extra={"feedbackId": str(feedback.id)}, + dedup_key=str(feedback.id), + ) + + +def notify_feedback_reward(db: Session, feedback: Feedback) -> None: + """#10 反馈奖励:反馈被采纳,金币已到账。PRD 约定发奖必带官方留言(admin_reply); + 运营漏填时省略该信息行,不硬造文案。""" + coins = int(feedback.reward_coins or 0) + info_rows = [{"label": "奖励说明", "value": "感谢您的反馈,您的金币奖励已到账"}] + reply = (feedback.admin_reply or "").strip() + if reply: + info_rows.append({"label": "官方留言", "value": reply}) + info_rows.append({"label": "到账时间", "value": _fmt_time(datetime.now(CN_TZ))}) + _dispatch( + db, + user_id=feedback.user_id, + type_key="feedback_reward", + coins=coins, + info_rows=info_rows, + extra={"feedbackId": str(feedback.id)}, + dedup_key=str(feedback.id), + push_vars={"coins": str(coins)}, + ) + + +def notify_report_approved(db: Session, report: PriceReport) -> None: + """#11 爆料审核通过:上报的更低价过审,金币已到账。点击跳爆料记录页高亮该条。""" + coins = int(report.reward_coins or 0) + store = (report.store_name or "").strip() or "该店铺" + _dispatch( + db, + user_id=report.user_id, + type_key="report_approved", + coins=coins, + info_rows=[ + {"label": "奖励说明", "value": f"您爆料的「{store}」更低价已通过审核,金币奖励已到账"}, + {"label": "到账时间", "value": _fmt_time(datetime.now(CN_TZ))}, + ], + extra={"reportId": str(report.id)}, + dedup_key=str(report.id), + push_vars={"store": store, "coins": str(coins)}, + ) + + +def notify_invite_order_reward( + db: Session, *, inviter_user_id: int, invitee_user_id: int, cash_cents: int +) -> None: + """#12 好友下单到账:被邀请好友完成首次下单(比价),现金奖励已入邀请人账户。 + 通知发给【邀请人】;每个好友只发一次奖 → dedup 按被邀请人。""" + invitee = db.get(User, invitee_user_id) + nickname = _display_name(invitee) + _dispatch( + db, + user_id=inviter_user_id, + type_key="invite_order_reward", + cash_cents=cash_cents, + info_rows=[ + {"label": "奖励说明", "value": f"好友「{nickname}」完成首次下单"}, + {"label": "到账时间", "value": _fmt_time(datetime.now(CN_TZ))}, + ], + extra={"inviteeNickname": nickname}, + dedup_key=str(invitee_user_id), + push_vars={"nickname": nickname, "amount": _yuan_trim(cash_cents)}, + ) diff --git a/docs/api/README.md b/docs/api/README.md index 57c3a62..03059c9 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -1,9 +1,13 @@ # 傻瓜比价 App 后端 — API 接口文档(索引) > Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770` -> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case** +> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**(⚠️ 例外:消息通知中心 `notifications` 族与厂商推送 `push` 族按 PRD 前端契约用 **camelCase**,见各自文档) > 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer ` +<<<<<<< HEAD +> 最后更新:2026-07-14(新增 **消息通知中心** 3 端点(M1-M3,虚拟数据阶段)与 **厂商推送测试** 3 端点(P1-P3,荣耀/华为/小米/OPPO/vivo);上一次 2026-06-23 补全 device/internal/CPS 短链等整族端点) +======= > 最后更新:2026-07-09(① 比价透传改「软鉴权 + trace_id 签发 + harvest 落库」(#112 尾声帧 `trace/epilogue` 一并补录);② 新端点:`user/onboarding/reset`(#114)、`GET /internal/launch-confirm-samples`(#91);③ 参数更新:提现族 `source` 分账(#82/#121)、`wallet/account` 邀请奖励金余额、美团 feed/top-sales 按城市过滤(#116)、admin 调现金 `account` 目标账户(#95);④ **Admin 索引补全到当前全量**:新家族 roles(#117/#126)/coupon-data(#99)/device-liveness(#80)/event-logs(#83)/price-reports(#94)/CPS 运营台/提现审核族,及 feedbacks 采纳拒绝(#94/#105)、marquee 模式与真实条浏览(#122/#123)等。上一次 2026-07-03) +>>>>>>> origin/main > 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。 --- @@ -103,12 +107,26 @@ | 36c | `POST /api/v1/user/onboarding/reset` | Bearer | [详情](./user/user-onboarding.md)(重置本设备引导标记,下次登录重走,#114) | | 37 | `DELETE /api/v1/user` | Bearer | [详情](./user/user-delete.md) | | **帮助与反馈**(前缀 `/api/v1/feedback`) ||| +<<<<<<< HEAD +| 38 | `POST /api/v1/feedback` | Bearer | [详情](./feedback.md) | +| 38a | `GET /api/v1/feedback/config` | Bearer | 反馈页「加群二维码」卡配置(开关 + 二维码图 + 三行文案)(无单独文档) | +| 38b | `GET /api/v1/feedback/records` | Bearer | 我的反馈历史(pending/adopted/rejected)(无单独文档) | +| **消息通知中心**(前缀 `/api/v1/notifications`;⚠️ 本族对外 **camelCase**;虚拟数据阶段:内存 mock,重启复位) ||| +| M1 | `GET /api/v1/notifications` | Bearer | [详情](./notifications.md)(消息列表,分页;13 类型卡片字段 + sentAt/isRead;服务端已按时间倒序排好,不分组) | +| M2 | `GET /api/v1/notifications/unread-count` | Bearer | [详情](./notifications.md)(未读总数,首页铃铛角标;>99 → "99+",0 → null 隐藏) | +| M3 | `POST /api/v1/notifications/read` | Bearer | [详情](./notifications.md)(标记已读:`{ids:[...]}` 单条/多条 或 `{all:true}` 进通知中心全量清零;幂等) | +| **厂商推送测试**(前缀 `/api/v1/push`;荣耀/华为/小米/OPPO/vivo 五通道联调三件套,同为 camelCase) ||| +| P1 | `GET /api/v1/push/vendors` | Bearer | [详情](./push-vendor-test.md)(5 厂商服务端凭据配置状态,缺哪些 .env 键一目了然) | +| P2 | `GET /api/v1/push/templates` | Bearer | [详情](./push-vendor-test.md)(13 类通知的 push 标题/正文模板 + PRD 示例渲染效果) | +| P3 | `POST /api/v1/push/test` | Bearer | [详情](./push-vendor-test.md)(测试发送:默认 mock 不真发;mock=false 真发;可联动插一条站内 mock 通知闭环验证已读) | +======= | 38 | `POST /api/v1/feedback` | Bearer | [详情](./other/feedback.md) | | 38a | `GET /api/v1/feedback/config` | Bearer | [详情](./other/feedback-config.md)(反馈页「加群二维码」卡配置:开关+二维码图+三行文案) | | 38b | `GET /api/v1/feedback/records` | Bearer | [详情](./other/feedback-records.md)(我的反馈历史,pending/adopted/rejected) | | **埋点 & 订单上报**(前缀分散;全部 Bearer 除 analytics/events 不强制登录) ||| | E1 | `POST /api/v1/analytics/events` | 无 | [详情](./other/analytics-events.md)(批量上报埋点事件,不强制登录,每批最多200条) | | E2 | `POST /api/v1/order/report` | Bearer | [详情](./other/order-report.md)(上报归因订单,比价后5分钟内点链接+支付金额与比价价相差≤1元) | +>>>>>>> origin/main | **首页门面数据 / 客户端配置**(前缀 `/api/v1/platform`;全平台展示数字 + 运营开关,**全部不鉴权**,登录前可读) ||| | 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform/platform-stats.md) | | 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./savings/platform-savings-feed.md) | diff --git a/docs/api/notifications.md b/docs/api/notifications.md new file mode 100644 index 0000000..bef696a --- /dev/null +++ b/docs/api/notifications.md @@ -0,0 +1,132 @@ +# 消息通知中心(notifications 族) + +> 所属:notifications 组(前缀 `/api/v1/notifications`,源 `app/api/v1/notifications.py`) | 鉴权:**全部 Bearer**(消息按用户隔离) | [← 返回 API 索引](./README.md) +> +> 对应 PRD《消息通知中心》(通知类型清单 / 点击跳转 / 字段元素 / 未读红点 / Push 文案)。 +> Push 侧(厂商直推 + 测试)见 [push-vendor-test.md](./push-vendor-test.md)。 +> +> **数据落库**:消息存 `notification` 表(`app/repositories/notification.py`,按用户隔离,`sentAt` 倒序)。业务事件统一走 `app/services/notification_events.py` 下发(站内消息 + 厂商 push 一条链路,业务事务 commit 后触发、失败只 log 不影响业务)。**已接入 6 类真实触发**: +> +> | type | 触发点 | +> |---|---| +> | `withdraw_success` | 提现单转账到账(免确认直达 / 查单归一化 / 对账兜底,`repositories/wallet.py`) | +> | `withdraw_failed` | 提现退款收口 `_refund_withdraw`(微信侧失败、审核拒绝、解绑退回) | +> | `feedback_reply` | admin 反馈审核「拒绝」(原因/留言用户可见,`admin/routers/feedback.py`) | +> | `feedback_reward` | admin 反馈审核「采纳」发金币(必带官方留言) | +> | `report_approved` | admin 上报更低价「通过」发金币(`admin/routers/price_report.py`) | +> | `invite_order_reward` | 被邀请好友首次成功比价 → 邀请人发 2 元(`repositories/invite.try_reward_on_compare`) | +> +> 其余类型(奖励过期 ×2、权限异常 ×4、好友催单)业务侧尚未接入。要造联调数据,用 [POST /api/v1/push/test](./push-vendor-test.md) 的 `createNotification:true` 逐条插入。 +> +> ⚠️ **字段命名**:本组接口(含 push 测试组)对外为 **camelCase**(`sentAt` / `isRead` / `pageSize`…),与库内其他 snake_case 接口不同——按 PRD 前端契约对接,勿混用。 + +## 通知类型速查(13 种) + +列表**服务端已排好序:全列表按时间倒序**(最新在前,**不做分类分组**——PRD §1 的"按分类分组"为笔误,2026-07-14 需求方确认取消),前端按返回顺序渲染即可。category 仅用于卡片头部的分类标签展示。 + +| category | 分类标签 | type | 类型 | cardStyle 版式 | actionText 操作行 | extra 里带什么 | +|---|---|---|---|---|---|---| +| withdraw_assistant | 提现助手 | `reward_expiring` | 金币现金奖励即将失效 | dual_amount 双金额卡 | 立即激活您的收益 | `batchId` | +| withdraw_assistant | 提现助手 | `reward_expired` | 金币现金奖励已失效 | dual_amount 双金额卡 | 立即赚取新收益 | — | +| withdraw_assistant | 提现助手 | `withdraw_success` | 提现成功 | withdraw 提现卡 | **null(无操作行,点击仅消红点)** | — | +| withdraw_assistant | 提现助手 | `withdraw_failed` | 提现失败,款项已退回 | withdraw 提现卡 | 重新提现 | `withdrawId` | +| system | 系统通知 | `perm_accessibility` | 比价功能异常(无障碍) | plain_text 纯文本卡 | 去开启 | `permission:"accessibility"` | +| system | 系统通知 | `perm_battery` | 比价续航保护异常(省电策略) | plain_text 纯文本卡 | 去开启 | `permission:"battery"` | +| system | 系统通知 | `perm_autostart` | 比价启动保护异常(自启动) | plain_text 纯文本卡 | 去开启 | `permission:"autostart"` | +| system | 系统通知 | `perm_overlay` | 比价按钮异常(悬浮窗) | plain_text 纯文本卡 | 去开启 | `permission:"overlay"` | +| feedback | 我的反馈 | `feedback_reply` | 官方回复 | plain_text 纯文本卡 | 查看详情 | `feedbackId` | +| feedback | 我的反馈 | `feedback_reward` | 反馈奖励(必带官方留言行) | coin_reward 金币奖励卡 | 查看反馈详情 | `feedbackId` | +| report | 我的爆料 | `report_approved` | 爆料审核通过 | coin_reward 金币奖励卡 | 查看爆料详情 | `reportId` | +| invite | 好友邀请 | `invite_order_reward` | 好友下单奖励到账 | friend_cash 好友现金卡 | 邀请更多好友赚现金 | `inviteeNickname` | +| invite | 好友邀请 | `invite_remind` | 好友催单提醒 | plain_text 纯文本卡 | 去提醒 TA | `inviteeNickname`, `scrollTo:"remind"` | + +点击跳转逻辑按 PRD §2 由客户端按 `type` 分发;点击目标 = 整张卡片(不区分主体和操作行),任何点击都先调 `POST /read` 消该条红点。 + +## GET /api/v1/notifications — 消息列表(分页) + +**入参(query)** + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `page` | int | ❌ | 页码,1 起,默认 1 | +| `pageSize` | int | ❌ | 每页条数,默认 20,最大 100 | + +**出参** + +```jsonc +{ + "items": [ + { + "id": 90001, + "category": "withdraw_assistant", // 分类 key(5 种,见上表) + "categoryLabel": "提现助手", // 卡片头部左上角分类标签 + "type": "reward_expiring", // 类型 key(13 种,决定点击行为) + "cardStyle": "dual_amount", // 版式:dual_amount/withdraw/plain_text/coin_reward/friend_cash + "title": "金币现金奖励即将失效", // 卡片标题 + "coins": 86, // 金币数,整数;无金币的版式为 null + "cashCents": 1280, // 现金金额(分);无现金的版式为 null + "cashYuan": "12.80", // 现金展示串(元,两位小数),与 cashCents 同源 + "infoRows": [ // 信息行,已按 PRD 拼好文案,逐行 label: value 渲染 + { "label": "过期说明", "value": "您有86金币和12.80元现金即将失效,完成一次一键领券或一键比价即可激活收益" }, + { "label": "过期时间", "value": "3天后失效" } + ], + "actionText": "立即激活您的收益", // 操作行;null = 无操作行(提现成功卡) + "extra": { "batchId": "batch_20260714" }, // 跳转/联动参数,按 type 取用(见上表) + "sentAt": "2026-07-14T14:59:58+08:00", // ISO8601 带时区 + "isRead": false // false → 分类标签右侧显示 6px 红点(#E53935) + } + ], + "page": 1, + "pageSize": 20, + "total": 16, + "hasMore": false, + "unreadCount": 12 // 与 /unread-count 同口径,进页面可顺手刷角标 +} +``` + +**时间显示规则(前端处理 `sentAt`)**:今天 →「今天」;昨天 →「昨天」;当年 →「M月D日」(不补零);跨年 →「YYYY年M月D日」。`sentAt` 恒带 +08:00(服务端已归一,与库底层用 SQLite/PostgreSQL 无关)。 + +**数值约束(PRD §3)**:金币整数不带小数;现金/提现金额两位小数(直接用 `cashYuan`)。 + +## GET /api/v1/notifications/unread-count — 未读总数(首页铃铛角标) + +无入参。**出参**: + +```jsonc +{ "count": 12, "badgeText": "12" } // count>99 时 badgeText="99+";count=0 时 badgeText=null → 整个角标隐藏 +``` + +刷新时机(PRD §4):进入首页时、从通知中心/其他页面返回首页时(原型监听 `pageshow`)。 + +## POST /api/v1/notifications/read — 标记已读 + +**入参(JSON),两种模式二选一(同时传时 `all` 优先)** + +| 模式 | body | 使用场景 | +|---|---|---| +| 单条/多条 | `{ "ids": [90001, 90003] }` | ① 点击某张消息卡片(点击后无论跳转/弹窗/无动作都算已读);② 用户点击 push 直达落地页后,客户端拿 push extras 里的 `notificationId` 同步置读 | +| 全量清零 | `{ "all": true }` | 进入通知中心自动清零(只浏览列表就消红点,无需逐条点击;退出通知中心时也可再调一次兜底) | + +**出参** + +```jsonc +{ "ok": true, "markedCount": 2, "unreadCount": 10 } // unreadCount = 处理后剩余未读,可直接刷新角标 +``` + +幂等:不存在/已读的 id 忽略,重复调用 `markedCount=0` 不报错。 + +**错误**:`400` ids 与 all 都没传(或 ids 为空数组);`401` 未鉴权。 + +## 联调小抄 + +```bash +# 1. 登录拿 token(SMS mock:任意手机号 + 任意 6 位验证码) +curl -X POST :8770/api/v1/auth/sms/send -d '{"phone":"13800001234"}' +curl -X POST :8770/api/v1/auth/sms/login -d '{"phone":"13800001234","code":"123456"}' +# 2. 列表 / 角标 / 置读 +curl ":8770/api/v1/notifications?page=1&pageSize=20" -H "Authorization: Bearer $TOKEN" +curl ":8770/api/v1/notifications/unread-count" -H "Authorization: Bearer $TOKEN" +curl -X POST ":8770/api/v1/notifications/read" -d '{"all":true}' -H "Authorization: Bearer $TOKEN" +``` + +列表初始为空,登录后先用 [POST /api/v1/push/test](./push-vendor-test.md) 的 `createNotification:true` 插几条(可指定 `type` 覆盖不同版式),再验列表 / 角标 / 置读全流程;它同时把 `notificationId` 放进 push extras,可闭环验证「push → 站内已读联动」。 diff --git a/docs/api/push-vendor-test.md b/docs/api/push-vendor-test.md new file mode 100644 index 0000000..db05ebc --- /dev/null +++ b/docs/api/push-vendor-test.md @@ -0,0 +1,103 @@ +# 厂商推送测试三件套(push 族) + +> 所属:push 组(前缀 `/api/v1/push`,源 `app/api/v1/push.py`) | 鉴权:**全部 Bearer** | [← 返回 API 索引](./README.md) +> +> 发送实现:`app/integrations/vendor_push.py`(荣耀 / **华为** / 小米 / OPPO / vivo 五通道, +> `send_notification()` 通用入口)。站内消息中心见 [notifications.md](./notifications.md)。 +> 与 `POST /api/v1/device/push-test`(无障碍召回通道延迟自测)互补:本组面向消息中心 13 类 push 的文案/参数/通道联调。 +> +> 字段命名同 notifications 族:**camelCase**。 + +## 链路总览 + +``` +真实业务事件(提现回执/反馈审核/爆料通过/好友下单 已接入;奖励过期等待接) + └→ services/notification_events(先落 notification 表,再向该用户全部已注册设备直推) + └→ vendor_push.send_notification(vendor, token, title, body, extras) + extras = { type, notificationId, ...业务参数 } ← 客户端深链 + 已读联动的钥匙 +客户端点击 push → 按 extras.type 直达落地页(与站内点击一致) + → 调 POST /notifications/read {ids:[extras.notificationId]} 同步消红点(PRD §4) +``` + +推送目标来源:客户端集成各厂商 push SDK 拿到 regId/token 后,通过 `POST /api/v1/device/register` 上报 `push_vendor` + `push_token`,服务端存 `device_liveness` 表。 + +## GET /api/v1/push/vendors — 厂商配置状态 + +检查 5 家厂商服务端凭据是否配齐(只读 .env,不打厂商接口)。`missingKeys` 即还要补的配置键;mock 测试不依赖任何凭据。 + +```jsonc +{ "vendors": [ + { "vendor": "honor", "label": "荣耀", "configured": false, "missingKeys": ["HONOR_PUSH_APP_ID", "HONOR_PUSH_CLIENT_ID", "HONOR_PUSH_CLIENT_SECRET"] }, + { "vendor": "huawei", "label": "华为", "configured": false, "missingKeys": ["HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"] }, + { "vendor": "xiaomi", "label": "小米", "configured": true, "missingKeys": [] }, + { "vendor": "oppo", "label": "OPPO", "configured": false, "missingKeys": ["OPPO_PUSH_APP_KEY", "OPPO_PUSH_MASTER_SECRET"] }, + { "vendor": "vivo", "label": "vivo", "configured": false, "missingKeys": ["VIVO_PUSH_APP_ID", "VIVO_PUSH_APP_KEY", "VIVO_PUSH_APP_SECRET"] } +] } +``` + +## GET /api/v1/push/templates — 13 类通知的 push 模板预览 + +PRD §5 的 13 条 push 文案(标题固定 ≤11 字不带变量;正文 `{var}` 为变量,示例值即 PRD 示例)。对文案、看变量名用。 + +```jsonc +{ "templates": [ + { + "type": "withdraw_success", + "category": "withdraw_assistant", "categoryLabel": "提现助手", "cardStyle": "withdraw", + "pushTitle": "提现到账提醒", + "pushBodySample": "¥0.50已存入您的微信钱包,点击查看到账详情", // 用示例值渲染后的效果 + "pushBodyTemplate": "¥{amount}已存入您的微信钱包,点击查看到账详情", + "variables": ["amount"], + "sampleVars": { "amount": "0.50" } + } + // ... 共 13 条,顺序即 PRD 编号 +] } +``` + +## POST /api/v1/push/test — 测试发送(默认 mock) + +**入参(JSON)** + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `vendor` | string | ❌* | `honor/huawei/xiaomi/oppo/vivo`,中文「华为」「小米」等别名也识别;留空时用 `deviceId` 设备上报的 vendor | +| `pushToken` | string | ❌* | 厂商 push token/regId;留空则按 `deviceId` 反查已注册设备(*mock 模式两者都缺时用占位 token,只看渲染结果*) | +| `deviceId` | string | ❌ | 客户端 `DeviceId.get()` 的设备 id,用于反查 vendor+token | +| `type` | string | ❌ | 13 种类型 key 之一 → 按 PRD 模板渲染;不传且没直给文案 → 发通用测试文案 | +| `vars` | object | ❌ | 覆盖模板变量,如 `{"coins":"520","cash":"6.66"}`;缺省用 PRD 示例值 | +| `title` / `content` | string | ❌ | 直接指定标题/正文(优先于 type 模板) | +| `createNotification` | bool | ❌ | true = 同时往该用户消息中心插一条同类型未读 mock 通知,extras 带其 `notificationId` → 可闭环验证「点 push → 调 /notifications/read 消红点」(仅 type 合法时生效) | +| `mock` | bool | ❌ | **默认 true = 不真调厂商 API**,回显渲染结果;false = 真发到手机(要求该厂商凭据已配) | + +**出参** + +```jsonc +{ + "ok": true, "mock": true, "vendor": "huawei", + "title": "反馈奖励已到账", + "body": "谢谢您帮傻瓜比价变得更好,300金币已到账,还有一条给您的留言~", + "extras": { "type": "feedback_reward", "feedbackId": "3002", "notificationId": "90017" }, + "notificationId": 90017, // createNotification=true 时的站内 mock 通知 id + "missingKeys": ["HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"], // 真发前还缺的配置(真发成功时必为空) + "vendorResponse": null // 真发时为厂商 API 原始响应 +} +``` + +**错误**:`400` vendor/type 非法、真发但凭据未配(detail 列缺失键);`409` 真发但拿不到 pushToken;`502` 厂商 API 返回失败(detail 带厂商原始错误)。 + +**真发注意**: +- 目标手机必须先装 App 且客户端已集成对应厂商 SDK、`/device/register` 上报过 token; +- vivo 未上架前走测试推送(`VIVO_PUSH_MODE=1`),目标手机需在 vivo 开放平台加入测试设备; +- 小米新设备需在开放平台把签名/包名配好,token 才有效。 + +## 厂商凭据怎么拿(.env 键名) + +| 厂商 | 后台 | 需要的键 | +|---|---|---| +| 华为 | AGC 控制台 → 项目设置 → 常规 → 应用 | `HUAWEI_PUSH_APP_ID`、`HUAWEI_PUSH_APP_SECRET`(OAuth client_id 即 AppId) | +| 荣耀 | 荣耀开发者服务平台 → 推送服务 | `HONOR_PUSH_APP_ID`、`HONOR_PUSH_CLIENT_ID`、`HONOR_PUSH_CLIENT_SECRET` | +| 小米 | 开放平台 → 消息推送 → 应用秘钥 | `XIAOMI_PUSH_APP_SECRET`(服务端只要这个;AppID/AppKey 是客户端 SDK 用) | +| OPPO | 开放平台 → 推送服务 | `OPPO_PUSH_APP_KEY`、`OPPO_PUSH_MASTER_SECRET`(注意是**服务端 MasterSecret**) | +| vivo | 开放平台 → 推送 | `VIVO_PUSH_APP_ID`、`VIVO_PUSH_APP_KEY`、`VIVO_PUSH_APP_SECRET` | + +各家发送协议差异(鉴权方式/成功码/payload 结构)封装在 `integrations/vendor_push.py`,业务侧只面对 `send_notification()`。 diff --git a/run.bat b/run.bat index 379b096..def0a06 100644 --- a/run.bat +++ b/run.bat @@ -38,4 +38,8 @@ if errorlevel 1 ( ) REM Long-running foreground process. Ctrl+C to stop. -"%PY%" -m uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload +REM --timeout-keep-alive 120: real-device debugging over `adb reverse` — uvicorn's default 5s +REM closes idle keep-alive connections, but the adb-reverse pipe doesn't propagate the close, +REM so okhttp reuses a dead connection and the next request fails with "unexpected end of +REM stream" (esp. login / message-center calls after an idle gap). Bump to 120s to avoid it. +"%PY%" -m uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload --timeout-keep-alive 120 diff --git a/run.sh b/run.sh index d7695e9..c934145 100755 --- a/run.sh +++ b/run.sh @@ -23,4 +23,7 @@ mkdir -p data # sqlite 文件所在目录 # --reload 只盯源码目录 app/:别去监视 logs/(日志写入触发"检测→再写日志"回环)和 # data/(sqlite 频繁写)。改 alembic/、.env、本脚本后请手动重启。 -exec "$PY" -m uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload --reload-dir app +# --timeout-keep-alive 120:真机经 adb reverse 联调时,uvicorn 默认 5s 就关闭空闲 keep-alive +# 连接,但 adb reverse 管道不把关闭事件透传回设备侧 → okhttp 复用"已死"的连接、下一次请求 +# 报 "unexpected end of stream"(尤其登录/消息中心等间隔较久的调用)。调大到 120s 规避。 +exec "$PY" -m uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload --reload-dir app --timeout-keep-alive 120 diff --git a/scripts/fire_push_events.py b/scripts/fire_push_events.py new file mode 100644 index 0000000..7948491 --- /dev/null +++ b/scripts/fire_push_events.py @@ -0,0 +1,141 @@ +"""直接触发「消息通知中心」真实推送链路,给指定用户(默认 11111111111)的已注册设备发 push。 + +用于**后台无法驱动**的事件联调(本环境:wxpay 未配 → 提现成功打不通、提现单唯一约束 → +造不了多张待审单、好友下单后台无入口)。本脚本直接调 services/notification_events 的真实 +下发函数,走的就是生产同一条链路:落 notification 表(站内消息) + 厂商直推(honor/huawei/ +xiaomi/oppo/vivo)到该用户 device_liveness 里已注册的 push token。 + +默认只发这 3 类(后台驱动不了的): + #3 withdraw_success 提现到账 + #4 withdraw_failed 提现失败,款项已退回 + #12 invite_order_reward 好友下单奖励到账 +可用 --types 指定;--types all 追加后台能驱动的 #9/#10/#11(注意:这几类的点击跳转 id 是假的, +仅验证「推送到达手机」,真实跳转请走后台审核流程)。 + + .venv\\Scripts\\python.exe scripts\\fire_push_events.py # 3 类各 10 条 + .venv\\Scripts\\python.exe scripts\\fire_push_events.py --count 1 # 各 1 条(先小量验证通道) + .venv\\Scripts\\python.exe scripts\\fire_push_events.py --types withdraw_failed --count 3 + .venv\\Scripts\\python.exe scripts\\fire_push_events.py --types all --count 2 + +推送成败看输出里的 `shagua.vendor_push` 日志(push sent / push failed);2 台设备则每条各推 2 次。 +凭据缺失或 token 失效时 notification_events 只记日志、不抛错(站内消息仍会落库)。 +""" +from __future__ import annotations + +import argparse +import logging +import random +import sys +import uuid + +from app.core.rewards import INVITE_COMPARE_REWARD_CENTS, PRICE_REPORT_REWARD_COINS +from app.db.session import SessionLocal, engine +from app.models.feedback import Feedback +from app.models.price_report import PriceReport +from app.models.wallet import WithdrawOrder +from app.repositories import device as device_repo +from app.repositories import user as user_repo +from app.services import notification_events + +# SQL 回显静音;shagua.* 开到 INFO,好看到「push sent / push failed」结果 +# dev 下 APP_DEBUG=true → engine echo=True,echo 走 InstanceLogger 直写、无视 logger level,只能关 echo 本身 +logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") +logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) +engine.echo = False + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +DEFAULT_PHONE = "11111111111" +DEFAULT_TYPES = ["withdraw_success", "withdraw_failed", "invite_order_reward"] +ADMIN_DRIVEN = ["feedback_reward", "feedback_reply", "report_approved"] # --types all 追加 +ALL_TYPES = DEFAULT_TYPES + ADMIN_DRIVEN + +_FAIL_REASONS = [ + "微信零钱未实名,款项已退回", + "收款账户异常,款项已退回", + "超出微信零钱收款限额,款项已退回", +] + + +def _fire_one(db, uid: int, type_key: str, i: int) -> None: + """构造一条该类型的瞬态业务对象(不落业务表,只为给 notify 函数读字段),触发真实推送。""" + if type_key == "withdraw_success": + order = WithdrawOrder(user_id=uid, out_bill_no=uuid.uuid4().hex, amount_cents=50, source="coin_cash") + notification_events.notify_withdraw_success(db, order) + elif type_key == "withdraw_failed": + order = WithdrawOrder( + user_id=uid, out_bill_no=uuid.uuid4().hex, amount_cents=350, source="coin_cash", + fail_reason=random.choice(_FAIL_REASONS), + ) + notification_events.notify_withdraw_failed(db, order) + elif type_key == "invite_order_reward": + # 假被邀请人 id(> 真实用户范围,避重):昵称回退「好友」。真实昵称请走 API 流程(见文末说明)。 + fake_invitee = random.randint(900000, 999999) + notification_events.notify_invite_order_reward( + db, inviter_user_id=uid, invitee_user_id=fake_invitee, cash_cents=INVITE_COMPARE_REWARD_CENTS + ) + elif type_key == "feedback_reward": + fb = Feedback(user_id=uid, content="(直发)", contact="", status="adopted", + reward_coins=300, admin_reply="感谢反馈,您说的问题已修复上线,金币请查收~") + fb.id = random.randint(900000, 999999) + notification_events.notify_feedback_reward(db, fb) + elif type_key == "feedback_reply": + fb = Feedback(user_id=uid, content="(直发)", contact="", status="rejected", + admin_reply="您的建议我们记录啦,会在后续版本评估~") + fb.id = random.randint(900000, 999999) + notification_events.notify_feedback_reply(db, fb) + elif type_key == "report_approved": + rep = PriceReport( + user_id=uid, reported_platform_id="jd", reported_platform_name="京东外卖", + reported_price_cents=8800, images=[], status="approved", + reward_coins=PRICE_REPORT_REWARD_COINS, store_name=f"测试火锅店{i:02d}", + ) + rep.id = random.randint(900000, 999999) + notification_events.notify_report_approved(db, rep) + else: + raise SystemExit(f"未知类型: {type_key}(可选: {', '.join(ALL_TYPES)})") + + +def main() -> None: + parser = argparse.ArgumentParser(description="直接触发消息通知中心真实推送(后台驱动不了的事件用)") + parser.add_argument("--phone", default=DEFAULT_PHONE, help=f"目标用户手机号(默认 {DEFAULT_PHONE})") + parser.add_argument("--count", type=int, default=10, help="每类发多少条(默认 10)") + parser.add_argument( + "--types", default=",".join(DEFAULT_TYPES), + help=f"逗号分隔的类型;'all' = {', '.join(ALL_TYPES)}。默认 {', '.join(DEFAULT_TYPES)}", + ) + args = parser.parse_args() + + types = ALL_TYPES if args.types.strip() == "all" else [t.strip() for t in args.types.split(",") if t.strip()] + bad = [t for t in types if t not in ALL_TYPES] + if bad: + print(f"❌ 未知类型: {', '.join(bad)}(可选: {', '.join(ALL_TYPES)})") + return + + db = SessionLocal() + try: + user = user_repo.get_user_by_phone(db, args.phone) + if user is None: + print(f"❌ 用户 {args.phone} 不存在。请先用该手机号在 App 登录一次再跑本脚本。") + return + uid = user.id + + targets = device_repo.list_push_targets(db, user_id=uid) + print(f"目标用户 {args.phone}(id={uid});已注册推送设备 {len(targets)} 台:" + f"{[t.push_vendor for t in targets] or '无(手机收不到!先在 App 上报 push token)'}") + print(f"即将触发:{types},每类 {args.count} 条 → 共 {len(types) * args.count} 条\n") + + for t in types: + print(f"── {t} ×{args.count} " + "─" * 30) + for i in range(1, args.count + 1): + _fire_one(db, uid, t, i) + + print(f"\n✅ 已触发完。站内消息已落 notification 表(用 {args.phone} 登录 App 可在消息中心看到);" + "\n 手机推送成败见上方 `shagua.vendor_push` 日志(push sent=成功 / push failed=失败)。") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/seed_mock_notifications.py b/scripts/seed_mock_notifications.py new file mode 100644 index 0000000..233d4a3 --- /dev/null +++ b/scripts/seed_mock_notifications.py @@ -0,0 +1,420 @@ +"""给指定用户(默认手机号 11111111111)造一整套「消息通知中心」联调数据。 + +不只是 notification 本身,还把 13 种类型**点击后要跳转的落地页数据**一起造齐,保证每条都能点开看到真实内容: + + notification 类型 点击落地 需要的业务数据(本脚本一并造) + ───────────────────────────────────────────────────────────────────────────── + reward_expiring/expired 赚钱页(tab) —(金额在通知里,无需外部记录) + withdraw_success 无跳转,仅消红点 — + withdraw_failed 提现页(withdrawId) withdraw_order(failed 一单) + perm_*(4 种) 客户端权限检测弹窗 —(纯客户端) + feedback_reply 我的反馈(feedbackId) feedback(rejected + 官方回复) + feedback_reward 我的反馈(feedbackId) feedback(adopted + 官方留言 + 奖励金币) + report_approved 我的爆料(reportId) price_report(approved + 截图 + 奖励) + invite_order_reward 邀请页 invite_relation + 好友 user(已完成比价) + invite_remind 邀请页(scrollTo) invite_relation + 好友 user(未完成) + +配套还造:钱包余额 + 金币/现金/邀请奖励金流水(让赚钱页 / 金币明细 / 现金明细 / 邀请战绩都有内容)。 + +幂等:每次先清掉该用户上一轮由本脚本造的全部数据(通知 + 上述业务记录 + mock 好友 + mock 截图)再重建。 + .venv\\Scripts\\python.exe scripts\\seed_mock_notifications.py + .venv\\Scripts\\python.exe scripts\\seed_mock_notifications.py --phone 11111111111 + .venv\\Scripts\\python.exe scripts\\seed_mock_notifications.py --clean-only + +时间口径按各域现有约定:notification.sent_at 用东八区(带 +08:00 下发);feedback / withdraw / +钱包流水 / 邀请关系用 naive UTC(= func.now() 在 SQLite 的口径,与真实数据一致);price_report 用 +naive 北京时间(与 report_repo.create_report 一致)。 +""" +from __future__ import annotations + +import argparse +import struct +import sys +import uuid +import zlib +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from sqlalchemy import delete, select + +from app.core import rewards +from app.core.config import settings +from app.db.session import SessionLocal +from app.models.feedback import Feedback +from app.models.invite import InviteRelation +from app.models.notification import Notification +from app.models.price_report import PriceReport +from app.models.user import User +from app.models.wallet import ( + CashTransaction, + CoinAccount, + CoinTransaction, + InviteCashTransaction, + WithdrawOrder, +) +from app.repositories import notification as notif_repo +from app.repositories import user as user_repo + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") # Windows GBK 控制台也能打印中文/¥ + +_CST = timezone(timedelta(hours=8)) + +DEFAULT_PHONE = "11111111111" + +# mock 好友(被邀请人):固定手机号,便于幂等清理。(phone, 昵称, 是否已完成比价) +FRIEND_SPECS = [ + ("12000000001", "柚子", True), # 已完成 → 驱动 invite_order_reward,计入邀请战绩 + ("12000000003", "小美", True), # 已完成 → 让邀请列表 / 战绩更丰满 + ("12000000002", "阿泽", False), # 未完成 → 驱动 invite_remind(去催单) +] +FRIEND_PHONES = [p for p, _, _ in FRIEND_SPECS] + +_REPORT_DIR = Path(settings.MEDIA_ROOT) / "price_report" +_MOCK_IMG_GLOB = "mock_notif_*.png" # 本脚本生成的截图前缀,清理按此删 + + +# --------------------------------------------------------------------------- +# 时间口径小工具 +# --------------------------------------------------------------------------- +def _utc() -> datetime: + """naive UTC now(与 func.now() 在 SQLite 一致:feedback / withdraw / 流水 / 邀请关系用)。""" + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _bj_naive() -> datetime: + """naive 北京 wall-clock(price_report 用,与 report_repo.create_report 一致)。""" + return datetime.now(_CST).replace(tzinfo=None) + + +# --------------------------------------------------------------------------- +# mock 截图(纯色 PNG,无需 Pillow;抄 seed_mock_price_reports 的手写字节法) +# --------------------------------------------------------------------------- +def _solid_png(width: int, height: int, rgb: tuple[int, int, int]) -> bytes: + def _chunk(typ: bytes, data: bytes) -> bytes: + body = typ + data + return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF) + + ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) # RGB truecolor + row = b"\x00" + bytes(rgb) * width + idat = zlib.compress(row * height, 9) + return b"\x89PNG\r\n\x1a\n" + _chunk(b"IHDR", ihdr) + _chunk(b"IDAT", idat) + _chunk(b"IEND", b"") + + +def _write_mock_image(name: str, rgb: tuple[int, int, int]) -> str: + _REPORT_DIR.mkdir(parents=True, exist_ok=True) + (_REPORT_DIR / name).write_bytes(_solid_png(320, 320, rgb)) + return f"{settings.MEDIA_URL_PREFIX}/price_report/{name}" + + +# --------------------------------------------------------------------------- +# 清理(幂等) +# --------------------------------------------------------------------------- +def clean(db, target: User) -> None: + uid = target.id + friend_ids = list( + db.execute(select(User.id).where(User.phone.in_(FRIEND_PHONES))).scalars() + ) + + # 1) 目标用户的通知 + 业务记录 + 钱包 + for model in ( + Notification, Feedback, PriceReport, WithdrawOrder, + CashTransaction, CoinTransaction, InviteCashTransaction, CoinAccount, + ): + db.execute(delete(model).where(model.user_id == uid)) + # 2) 邀请关系(目标作为邀请人 + mock 好友作为被邀请人) + db.execute(delete(InviteRelation).where(InviteRelation.inviter_user_id == uid)) + if friend_ids: + db.execute(delete(InviteRelation).where(InviteRelation.invitee_user_id.in_(friend_ids))) + db.execute(delete(User).where(User.id.in_(friend_ids))) + db.commit() + + # 3) mock 截图文件 + if _REPORT_DIR.exists(): + for f in _REPORT_DIR.glob(_MOCK_IMG_GLOB): + f.unlink(missing_ok=True) + + +# --------------------------------------------------------------------------- +# 造业务记录(通知的点击落地数据) +# --------------------------------------------------------------------------- +def _make_friends(db, inviter: User) -> dict[str, User]: + """建 mock 好友 user + 邀请关系(注册即生效;完成比价的置 compare_reward_granted 并发奖励金)。""" + now = _utc() + friends: dict[str, User] = {} + for i, (phone, nickname, _completed) in enumerate(FRIEND_SPECS): + u = User( + phone=phone, + username=user_repo._gen_unique_username(db), + nickname=nickname, + register_channel="sms", + status="active", + created_at=now - timedelta(days=6 - i), + last_login_at=now - timedelta(hours=2), + ) + db.add(u) + friends[nickname] = u + db.flush() # 拿 friend.id + + for i, (_phone, nickname, completed) in enumerate(FRIEND_SPECS): + f = friends[nickname] + db.add(InviteRelation( + inviter_user_id=inviter.id, + invitee_user_id=f.id, + channel="clipboard", + status="effective", + compare_reward_granted=completed, + compare_reward_cents=rewards.INVITE_COMPARE_REWARD_CENTS if completed else 0, + compare_rewarded_at=(now - timedelta(days=5 - i)) if completed else None, + created_at=now - timedelta(days=6 - i), + )) + return friends + + +def _make_feedbacks(db, uid: int) -> dict[str, Feedback]: + """两条反馈:一条(rejected)带官方回复 → feedback_reply;一条(adopted)带留言+奖励 → feedback_reward。""" + now = _utc() + reply = Feedback( + user_id=uid, + content="比价结果页希望能一键复制到微信分享给朋友。", + contact="", + source="profile", + status="rejected", + admin_reply="您反馈的分享功能我们记录啦,会在后续版本评估上线,感谢支持~", + review_note="需求已进池", + reviewed_at=now - timedelta(hours=5), + created_at=now - timedelta(days=1, hours=2), + ) + reward = Feedback( + user_id=uid, + content="点某些店铺比价偶尔会闪退,机型 Redmi K60。", + contact="", + source="comparison", + scene="compare_slow", + status="adopted", + admin_reply="感谢反馈,您说的闪退问题已修复上线,送您的金币请查收~", + review_note="已修复:比价页空指针", + reward_coins=300, + reviewed_at=now - timedelta(days=1), + created_at=now - timedelta(days=3), + ) + db.add_all([reply, reward]) + db.flush() + return {"reply": reply, "reward": reward} + + +def _make_report(db, uid: int) -> PriceReport: + """一条 approved 上报(带真实可加载截图 + 奖励金币)→ report_approved 点击可看爆料详情。""" + now = _bj_naive() + img = _write_mock_image("mock_notif_report.png", (250, 173, 20)) + rep = PriceReport( + user_id=uid, + comparison_record_id=None, + store_name="蜀大侠火锅(春熙路店)", + dish_summary="招牌牛油锅 × 1、鲜毛肚 × 2", + original_platform_id="meituan-waimai", + original_platform_name="美团外卖", + original_price_cents=13800, + reported_platform_id="jd-waimai", + reported_platform_name="京东外卖", + reported_price_cents=11800, + images=[img], + status="approved", + reward_coins=1000, + reviewed_at=now - timedelta(days=39, hours=-1), + created_at=now - timedelta(days=40), + ) + db.add(rep) + db.flush() + return rep + + +def _make_withdraws(db, uid: int) -> dict[str, WithdrawOrder]: + """两单提现:success(历史)+ failed(驱动 withdraw_failed 点击去提现页)。不造在审单,避活动单唯一约束。""" + now = _utc() + success = WithdrawOrder( + user_id=uid, out_bill_no=uuid.uuid4().hex, amount_cents=500, source="coin_cash", + user_name="测试用户", status="success", wechat_state="SUCCESS", + transfer_bill_no="1330" + str(uuid.uuid4().int)[:26], + created_at=now - timedelta(days=5), updated_at=now - timedelta(days=5), + ) + failed = WithdrawOrder( + user_id=uid, out_bill_no=uuid.uuid4().hex, amount_cents=350, source="coin_cash", + user_name="测试用户", status="failed", wechat_state="FAIL", + transfer_bill_no="1330" + str(uuid.uuid4().int)[:26], + fail_reason="微信实名与提现实名不一致,款项已原路退回现金余额", + created_at=now - timedelta(days=2), updated_at=now - timedelta(days=2) + timedelta(hours=1), + ) + db.add_all([success, failed]) + db.flush() + return {"success": success, "failed": failed} + + +def _make_wallet(db, uid: int, friends: dict[str, User], withdraws: dict[str, WithdrawOrder]) -> None: + """钱包余额 + 三本流水(金币 / 现金 / 邀请奖励金),让赚钱页与各明细页都有内容。""" + now = _utc() + + # 金币流水(只增,链上 balance_after) + coin_events = [ + (2000, "signin", (now - timedelta(days=6)).date().isoformat(), "每日签到"), + (160, "reward_video", uuid.uuid4().hex, "看视频奖励"), + (500, "task_enable_notification", "task_enable_notification", "开启消息提醒奖励"), + (1000, "report_reward", None, "爆料审核通过奖励"), + (300, "feedback_reward", None, "反馈采纳奖励"), + ] + coin_bal = 0 + for amt, biz, ref, remark in coin_events: + coin_bal += amt + db.add(CoinTransaction( + user_id=uid, amount=amt, balance_after=coin_bal, biz_type=biz, + ref_id=ref, remark=remark, created_at=now - timedelta(days=4), + )) + + # 现金流水:兑入 + 两单提现扣款 + 失败退款 → 期末 1500 + cash_events = [ + (now - timedelta(days=10), 2000, "exchange_in", None, "金币兑入"), + (withdraws["success"].created_at, -500, "withdraw", withdraws["success"].out_bill_no, "提现扣款"), + (withdraws["failed"].created_at, -350, "withdraw", withdraws["failed"].out_bill_no, "提现扣款"), + (withdraws["failed"].updated_at, 350, "withdraw_refund", withdraws["failed"].out_bill_no, "提现退款"), + ] + cash_events.sort(key=lambda e: e[0]) + cash_bal = 0 + for t, amt, biz, ref, remark in cash_events: + cash_bal += amt + db.add(CashTransaction( + user_id=uid, amount_cents=amt, balance_after_cents=cash_bal, + biz_type=biz, ref_id=ref, remark=remark, created_at=t, + )) + + # 邀请奖励金流水:每个已完成好友发一笔 → 期末 = 已完成好友数 × 单笔奖励 + invite_bal = 0 + reward_cents = rewards.INVITE_COMPARE_REWARD_CENTS + for i, (_phone, nickname, completed) in enumerate(FRIEND_SPECS): + if not completed: + continue + invite_bal += reward_cents + db.add(InviteCashTransaction( + user_id=uid, amount_cents=reward_cents, balance_after_cents=invite_bal, + biz_type="invite_reward", ref_id=str(friends[nickname].id), + remark="好友比价奖励", created_at=now - timedelta(days=5 - i), + )) + + total_earned = sum(a for a, *_ in coin_events) + db.add(CoinAccount( + user_id=uid, + coin_balance=coin_bal, + cash_balance_cents=cash_bal, + invite_cash_balance_cents=invite_bal, + total_coin_earned=total_earned, + )) + + +# --------------------------------------------------------------------------- +# 造 13 类通知(extra 指向上面真实记录的 id) +# --------------------------------------------------------------------------- +def _notif(uid: int, type_key: str, sent_at: datetime, *, read: bool = False, + extra_override: dict | None = None, dedup_key: str | None = None) -> Notification: + card = notif_repo.build_sample_card(type_key, sent_at=sent_at) + extra = dict(card.get("extra", {})) + if extra_override: + extra.update(extra_override) + return Notification( + user_id=uid, type=type_key, is_read=read, + read_at=(sent_at + timedelta(minutes=5)) if read else None, + sent_at=sent_at, dedup_key=dedup_key, + coins=card.get("coins"), cash_cents=card.get("cash_cents"), + info_rows=card.get("info_rows", []), extra=extra, + ) + + +def _make_notifications( + db, uid: int, fb: dict[str, Feedback], rep: PriceReport, + wd: dict[str, WithdrawOrder], friends: dict[str, User], +) -> list[Notification]: + n = datetime.now(_CST) + + def ago(**kw) -> datetime: + return n - timedelta(**kw) + + rows = [ + # —— 提现助手 ——(金额/现金卡;withdraw_failed 指向真实失败单) + _notif(uid, "reward_expiring", ago(hours=2), dedup_key=f"batch_{n:%Y%m%d}"), + _notif(uid, "reward_expired", ago(days=1, hours=3), read=True), + _notif(uid, "withdraw_success", ago(minutes=10)), + _notif(uid, "withdraw_success", ago(days=3), read=True), # 额外一条(历史,已读) + _notif(uid, "withdraw_failed", ago(days=1, hours=1), + extra_override={"withdrawId": str(wd["failed"].id)}), + # —— 系统通知(权限异常 ×4;dedup_key=权限名,未读期间只保留一条)—— + _notif(uid, "perm_accessibility", ago(hours=1), dedup_key="accessibility"), + _notif(uid, "perm_battery", ago(days=3), read=True, dedup_key="battery"), + _notif(uid, "perm_autostart", ago(days=5), dedup_key="autostart"), + _notif(uid, "perm_overlay", ago(days=6), read=True, dedup_key="overlay"), + # —— 我的反馈(feedbackId 指向真实反馈)—— + _notif(uid, "feedback_reply", ago(hours=4), + extra_override={"feedbackId": str(fb["reply"].id)}), + _notif(uid, "feedback_reward", ago(days=1), + extra_override={"feedbackId": str(fb["reward"].id)}), + _notif(uid, "feedback_reply", ago(days=380), read=True, # 跨年(测「YYYY年M月D日」),已读 + extra_override={"feedbackId": str(fb["reply"].id)}), + # —— 我的爆料(reportId 指向真实上报)—— + _notif(uid, "report_approved", ago(days=40), # 当年(测「M月D日」) + extra_override={"reportId": str(rep.id)}), + # —— 好友邀请(inviteeNickname 指向真实好友)—— + _notif(uid, "invite_order_reward", ago(minutes=20), + extra_override={"inviteeNickname": "柚子"}), + _notif(uid, "invite_remind", ago(days=2), + extra_override={"inviteeNickname": "阿泽", "scrollTo": "remind"}), + ] + db.add_all(rows) + return rows + + +def seed(db, target: User) -> list[Notification]: + uid = target.id + friends = _make_friends(db, target) + fb = _make_feedbacks(db, uid) + rep = _make_report(db, uid) + wd = _make_withdraws(db, uid) + _make_wallet(db, uid, friends, wd) + rows = _make_notifications(db, uid, fb, rep, wd, friends) + db.commit() + return rows + + +# --------------------------------------------------------------------------- +def main() -> None: + parser = argparse.ArgumentParser(description="给指定用户造消息通知中心 + 点击落地页 mock 数据") + parser.add_argument("--phone", default=DEFAULT_PHONE, help=f"目标用户手机号(默认 {DEFAULT_PHONE})") + parser.add_argument("--clean-only", action="store_true", help="只清理,不重建") + args = parser.parse_args() + + db = SessionLocal() + try: + target = user_repo.get_user_by_phone(db, args.phone) + if target is None: + print(f"❌ 用户 {args.phone} 不存在。请先用该手机号在 App 登录一次(SMS mock:任意 6 位验证码)再跑本脚本。") + return + + clean(db, target) + print(f"🧹 已清理用户 {args.phone}(id={target.id})上一轮 mock 通知 + 业务记录 + mock 好友/截图") + if args.clean_only: + print("✅ 仅清理,已完成。") + return + + rows = seed(db, target) + unread = sum(1 for r in rows if not r.is_read) + print(f"\n✅ 已为用户 {args.phone}(id={target.id})生成 {len(rows)} 条通知(未读 {unread}):") + for r in sorted(rows, key=lambda x: x.sent_at, reverse=True): + flag = " " if r.is_read else "●" + print(f" {flag} {r.type:<20} {r.sent_at:%Y-%m-%d %H:%M} extra={r.extra}") + print( + "\n👉 用 11111111111 登录 App(SMS mock:任意 6 位验证码)看消息通知中心;" + "\n 逐条点击验证跳转:反馈→我的反馈、爆料→我的爆料、提现失败→提现页、邀请→邀请页、权限→检测弹窗。" + "\n 后端若没带 --reload,改了数据也无需重启(本脚本直接写库,接口实时读)。" + ) + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/seed_push_admin_test.py b/scripts/seed_push_admin_test.py new file mode 100644 index 0000000..b38d5d9 --- /dev/null +++ b/scripts/seed_push_admin_test.py @@ -0,0 +1,135 @@ +"""给指定用户(默认 11111111111)造「后台可驱动」的推送联调数据。 + +覆盖能从**管理后台点一下就触发手机推送**的 3 类事件,每类 10 条待审记录: + + 事件(PRD #) 后台动作 造的数据 + ──────────────────────────────────────────────────────────────────── + #10 反馈奖励 反馈工单 → 采纳(填回复留言 + 金币) 10 条 pending feedback(标「请采纳」) + #9 官方回复 反馈工单 → 拒绝(填未采纳原因/留言) 10 条 pending feedback(标「请拒绝」) + #11 爆料审核通过 上报更低价 → 通过 10 条 pending price_report + +触发链路:admin 审核 → 发金币/改状态 → services/notification_events 落站内消息 + 厂商直推 +→ 该用户已注册设备(device_liveness)收到 push。 + +其余 3 类(#3 提现成功 / #4 提现失败 / #12 好友下单到账)后台无法在本环境驱动 +(wxpay 未配 / 提现单唯一约束 / 后台无入口),用 scripts/fire_push_events.py 直接触发。 + +幂等:每次先删掉本脚本上一轮造的记录(按内容标记 [PUSH测试] 识别,不动用户真实反馈/爆料),再重建。 + .venv\\Scripts\\python.exe scripts\\seed_push_admin_test.py + .venv\\Scripts\\python.exe scripts\\seed_push_admin_test.py --phone 11111111111 --count 10 + .venv\\Scripts\\python.exe scripts\\seed_push_admin_test.py --clean-only +""" +from __future__ import annotations + +import argparse +import logging +import sys + +from sqlalchemy import delete, select + +from app.db.session import SessionLocal +from app.models.feedback import Feedback +from app.models.price_report import PriceReport +from app.repositories import user as user_repo + +logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) # 静音 SQL 回显,输出更干净 + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +DEFAULT_PHONE = "11111111111" +MARK = "[PUSH测试]" # 本脚本造的数据统一带此标记,幂等清理按它识别(不误删真实数据) + + +def clean(db, uid: int) -> tuple[int, int]: + """删掉本脚本上一轮造的带标记记录(任何状态都删,彻底重置)。返回 (删反馈数, 删爆料数)。""" + fb_ids = list(db.execute( + select(Feedback.id).where(Feedback.user_id == uid, Feedback.content.like(f"{MARK}%")) + ).scalars()) + rep_ids = list(db.execute( + select(PriceReport.id).where( + PriceReport.user_id == uid, PriceReport.store_name.like(f"{MARK}%") + ) + ).scalars()) + if fb_ids: + db.execute(delete(Feedback).where(Feedback.id.in_(fb_ids))) + if rep_ids: + db.execute(delete(PriceReport).where(PriceReport.id.in_(rep_ids))) + db.commit() + return len(fb_ids), len(rep_ids) + + +def seed(db, uid: int, count: int) -> None: + # #10 反馈奖励:采纳这些 → 手机收「反馈奖励已到账」。采纳时记得在后台填「给用户的回复留言」 + # (PRD 要求发奖必带官方留言),否则通知里不带留言行。 + for i in range(1, count + 1): + db.add(Feedback( + user_id=uid, + content=f"{MARK} 请【采纳】我 → 触发 #10 反馈奖励推送。测试反馈内容 {i:02d}:比价页能加个历史记录就好了。", + contact="", + source="profile", + status="pending", + )) + # #9 官方回复:拒绝这些 → 手机收「您的反馈有回复啦」。拒绝时填「未采纳原因」+「回复留言」。 + for i in range(1, count + 1): + db.add(Feedback( + user_id=uid, + content=f"{MARK} 请【拒绝】我 → 触发 #9 官方回复推送。测试反馈内容 {i:02d}:希望支持某某小众平台比价。", + contact="", + source="comparison", + scene="other", + status="pending", + )) + # #11 爆料审核通过:通过这些 → 手机收「爆料审核通过」(发固定金币)。 + for i in range(1, count + 1): + db.add(PriceReport( + user_id=uid, + comparison_record_id=None, + store_name=f"{MARK}测试火锅店{i:02d}", + dish_summary="招牌套餐 × 1", + original_platform_id="meituan-waimai", + original_platform_name="美团外卖", + original_price_cents=9900, + reported_platform_id="jd-waimai", + reported_platform_name="京东外卖", + reported_price_cents=8800, + images=[], + status="pending", + )) + db.commit() + + +def main() -> None: + parser = argparse.ArgumentParser(description="造后台可驱动的推送联调数据(反馈×2 + 爆料)") + parser.add_argument("--phone", default=DEFAULT_PHONE, help=f"目标用户手机号(默认 {DEFAULT_PHONE})") + parser.add_argument("--count", type=int, default=10, help="每类造多少条(默认 10)") + parser.add_argument("--clean-only", action="store_true", help="只清理本脚本造的数据,不重建") + args = parser.parse_args() + + db = SessionLocal() + try: + user = user_repo.get_user_by_phone(db, args.phone) + if user is None: + print(f"❌ 用户 {args.phone} 不存在。请先用该手机号在 App 登录一次再跑本脚本。") + return + uid = user.id + + nf, nr = clean(db, uid) + print(f"🧹 已清理上一轮 [PUSH测试] 数据:反馈 {nf} 条、爆料 {nr} 条") + if args.clean_only: + print("✅ 仅清理,已完成。") + return + + seed(db, uid, args.count) + print(f"\n✅ 已为 {args.phone}(id={uid})造好后台联调数据(每类 {args.count} 条):") + print(f" • 反馈工单「请采纳」× {args.count} → 后台【采纳】(填回复留言+金币)→ 手机收 #10 反馈奖励") + print(f" • 反馈工单「请拒绝」× {args.count} → 后台【拒绝】(填未采纳原因/留言)→ 手机收 #9 官方回复") + print(f" • 上报更低价 × {args.count} → 后台【通过】→ 手机收 #11 爆料审核通过") + print("\n👉 打开管理后台(:8771)对应列表即可看到这些待审记录,逐条审核就会推到手机。") + print(" #3/#4/#12 本环境后台驱动不了,用:.venv\\Scripts\\python.exe scripts\\fire_push_events.py") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/show_device_regids.bat b/scripts/show_device_regids.bat new file mode 100644 index 0000000..20d921d --- /dev/null +++ b/scripts/show_device_regids.bat @@ -0,0 +1,13 @@ +@echo off +REM Show all device_id + push regIds (push_token / registration_id) from the +REM local SQLite DB. Double-click this file to see everything, or run from a +REM console. Optional substring filter: show_device_regids.bat xiaomi +REM Works from ANY directory (locates project root + venv python by itself). +REM ASCII-only comments: cmd parses .bat in the console codepage (GBK); UTF-8 +REM Chinese here gets mangled into bogus commands. +cd /d "%~dp0.." +set "PY=python" +if exist ".venv\Scripts\python.exe" set "PY=.venv\Scripts\python.exe" +"%PY%" "scripts\show_device_regids.py" %* +echo. +pause diff --git a/scripts/show_device_regids.py b/scripts/show_device_regids.py new file mode 100644 index 0000000..600efd3 --- /dev/null +++ b/scripts/show_device_regids.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +"""导出 device_liveness 表里所有 device_id 及其对应的推送 regId,按 push_vendor 分组(本地开发工具)。 + +用法: + 双击 scripts/show_device_regids.bat,或命令行: + python scripts/show_device_regids.py [filter] + + [filter] 可选:大小写不敏感的子串,匹配 device_id / push_vendor / push_token / + registration_id 任一列;不传则列出全部。 + show_device_regids.py xiaomi # 只看小米 + show_device_regids.py device_Pixel # 按 device_id 片段找 + +说明: +- 直接以**只读**方式读 SQLite(server 在跑也不会抢写锁),所以开不开服务器都能用。 +- push_token = 各厂商的 regId/pushToken(新链路,当前在用);registration_id = 旧极光 + regId(历史兼容)。两列都打出来,方便跟 logcat 现役值逐字比对。 +- 输出刻意保持纯 ASCII 排版并竖排展示**完整值**:双击弹出的 cmd 走 GBK 码页,竖排纯 + ASCII 不会乱码;完整值不截断,才能直接复制去比对。 +- 若 DATABASE_URL 不是 sqlite(生产 postgres),这里只提示改用 psql。 +""" +from __future__ import annotations + +import os +import sqlite3 +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _resolve_sqlite_path() -> Path | None: + """按 env > .env > 默认 的顺序拿 DATABASE_URL,解析出 sqlite 文件路径。""" + url = os.environ.get("DATABASE_URL", "").strip() + if not url: + env = REPO_ROOT / ".env" + if env.exists(): + for line in env.read_text(encoding="utf-8", errors="ignore").splitlines(): + s = line.strip() + if s.startswith("DATABASE_URL="): + url = s.split("=", 1)[1].strip().strip('"').strip("'") + break + if not url: + url = "sqlite:///./data/app.db" + if not url.startswith("sqlite"): + print(f"[!] DATABASE_URL not sqlite: {url}") + print(" This tool only reads a local SQLite dev DB. For prod use psql.") + return None + raw = url.split("///", 1)[1] if "///" in url else "./data/app.db" + p = Path(raw) + if not p.is_absolute(): + p = (REPO_ROOT / raw).resolve() + return p + + +def main() -> int: + needle = sys.argv[1].lower() if len(sys.argv) > 1 else None + + db = _resolve_sqlite_path() + if db is None: + return 2 + if not db.exists(): + print(f"[X] DB file not found: {db}") + return 2 + + # 只读打开,避免和正在运行的 server 抢写锁。URI 路径用正斜杠(as_posix)规避 + # Windows 反斜杠/盘符在 file: URI 里的歧义;万一 URI 形式打不开再回退普通连接。 + try: + con = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True) + except sqlite3.OperationalError: + con = sqlite3.connect(str(db)) + con.row_factory = sqlite3.Row + rows = con.execute( + """ + SELECT id, user_id, device_id, push_vendor, push_token, registration_id, + platform, app_version, liveness_state, last_heartbeat_at, updated_at + FROM device_liveness + ORDER BY updated_at DESC + """ + ).fetchall() + con.close() + + if needle: + def hit(r: sqlite3.Row) -> bool: + for k in ("device_id", "push_vendor", "push_token", "registration_id"): + v = r[k] + if v and needle in str(v).lower(): + return True + return False + + rows = [r for r in rows if hit(r)] + + with_token = sum(1 for r in rows if (r["push_token"] or "").strip()) + with_reg = sum(1 for r in rows if (r["registration_id"] or "").strip()) + + # 按 push_vendor 分组;None/空归入 "(none)"。组顺序:设备数多的在前,(none) 垫底; + # 组内沿用 updated_at DESC(rows 查询时已如此排序,dict 保序即可)。 + groups: dict[str, list] = {} + for r in rows: + groups.setdefault(r["push_vendor"] or "(none)", []).append(r) + ordered = sorted( + groups.items(), key=lambda kv: (kv[0] == "(none)", -len(kv[1]), kv[0]) + ) + tally = " ".join(f"{v}={len(items)}" for v, items in ordered) or "-" + + print(f"DB : {db}") + line = f"device_liveness : {len(rows)} row(s)" + if needle: + line += f' filter="{sys.argv[1]}"' + print(line) + print(f"has push_token(vendor regId) : {with_token}" + f" has registration_id(jiguang) : {with_reg}") + print(f"vendors : {tally}") + print("=" * 72) + + if not rows: + print("(no rows)") + return 0 + + for vendor, items in ordered: + print() + print(f"===== vendor={vendor} : {len(items)} device(s) =====") + for r in items: + print(f" [#{r['id']}] user_id={r['user_id']} platform={r['platform']}" + f" state={r['liveness_state']} app={r['app_version'] or '-'}") + print(f" device_id : {r['device_id']}") + print(f" push_token(regId): {r['push_token'] or '(empty)'}") + print(f" registration_id : {r['registration_id'] or '(empty)'}") + print(f" last_heartbeat : {r['last_heartbeat_at'] or '-'}" + f" updated : {r['updated_at']}") + print(" " + "-" * 68) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_push_invite_order_reward.bat b/scripts/test_push_invite_order_reward.bat new file mode 100644 index 0000000..70e531d --- /dev/null +++ b/scripts/test_push_invite_order_reward.bat @@ -0,0 +1,9 @@ +@echo off +REM Push test #12 invite_order_reward (1 notification per run, random amount). +REM Works from ANY directory (locates project root + venv python by itself): +REM scripts\test_push_invite_order_reward.bat +REM Extra args pass through, e.g.: test_push_invite_order_reward.bat --invitee-phone 12000000001 +REM ASCII-only comments: cmd parses .bat in the console codepage (GBK), UTF-8 +REM Chinese here gets mangled into bogus commands. +cd /d "%~dp0.." +".venv\Scripts\python.exe" "scripts\test_push_invite_order_reward.py" %* diff --git a/scripts/test_push_invite_order_reward.py b/scripts/test_push_invite_order_reward.py new file mode 100644 index 0000000..f88cc3c --- /dev/null +++ b/scripts/test_push_invite_order_reward.py @@ -0,0 +1,110 @@ +"""#12 好友下单到账(invite_order_reward)推送联调脚本 —— 每次执行只发 1 条,金额随机。 + +后台没有驱动这个事件的入口(真实链路要好友注册 + 完成首次比价);本脚本直接调 +services/notification_events.notify_invite_order_reward —— 与生产同一条下发链路: +落 notification 表(站内消息)+ 向【邀请人】全部已注册厂商 token 直推(honor/huawei/xiaomi/oppo/vivo)。 + +可重复性: + 默认用随机假「被邀请人 id」做 dedup_key → 永不去重,想跑多少次都行(昵称兜底显示「好友」)。 + 金额默认每次随机(0.01 ~ 99.99 元)→ 手机上按金额认出这条通知;--cents 可固定(线上真实值 200 = 2 元)。 + 带 --invitee-phone 指定真实用户(如种子好友 12000000001 柚子)→ 通知里显示真实昵称; + ⚠️ 但 dedup_key = 被邀请人 id:上一条还未读时重复发会命中去重(日志出现 dedup hit,不落库不推送), + 在 App 里把那条读掉(或换号)即可再次触发。 + +用法(服务端进程无需在跑,脚本自己连库 + 直调厂商接口): + .venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py # 随机金额发 1 条 + .venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py --cents 200 # 固定 2.00 元 + .venv\\Scripts\\python.exe scripts\\test_push_invite_order_reward.py --invitee-phone 12000000001 # 真实昵称 + +结果判读(看输出日志): + push sent = 厂商接口受理成功,手机应弹「好友下单奖励到账」通知 + push failed = 厂商拒绝(原因见日志:token 失效/凭据错误等) + skip push = 该厂商凭据未配置,只落站内消息 +站内消息用 11111111111 登录 App → 消息中心「好友邀请」可见;点击应跳邀请页。 +""" +from __future__ import annotations + +import argparse +import logging +import random +import sys + +from sqlalchemy import func, select + +from app.db.session import SessionLocal, engine +from app.models.notification import Notification +from app.repositories import device as device_repo +from app.repositories import user as user_repo +from app.services import notification_events + +# SQL 回显静音;shagua.* 开到 INFO 才看得到 push sent / push failed / dedup hit +# dev 下 APP_DEBUG=true → engine echo=True,echo 走 InstanceLogger 直写、无视 logger level,只能关 echo 本身 +logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") +logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) +engine.echo = False + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +TYPE_KEY = "invite_order_reward" + + +def _notif_count(db, uid: int) -> int: + return int( + db.execute( + select(func.count()) + .select_from(Notification) + .where(Notification.user_id == uid, Notification.type == TYPE_KEY) + ).scalar_one() + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="#12 好友下单到账 推送联调(每次 1 条,金额默认随机)") + parser.add_argument("--phone", default="11111111111", help="邀请人(收通知方)手机号,默认 11111111111") + parser.add_argument("--cents", type=int, default=None, + help="奖励金额,单位分(默认随机 1~9999;线上真实值 200)") + parser.add_argument("--invitee-phone", default="", + help="被邀请人手机号(可选):显示真实昵称;不传用随机假 id,昵称显示「好友」") + args = parser.parse_args() + + cents = args.cents if args.cents is not None else random.randint(1, 9999) + + db = SessionLocal() + try: + user = user_repo.get_user_by_phone(db, args.phone) + if user is None: + print(f"❌ 用户 {args.phone} 不存在。先用该手机号在 App 登录一次再跑。") + return + + if args.invitee_phone.strip(): + invitee = user_repo.get_user_by_phone(db, args.invitee_phone.strip()) + if invitee is None: + print(f"❌ 被邀请人 {args.invitee_phone} 不存在(种子好友可用 12000000001 柚子 / 12000000003 小美)。") + return + invitee_id = invitee.id + else: + invitee_id = random.randint(900000, 999999) # 假 id,昵称兜底「好友」,永不去重 + + targets = device_repo.list_push_targets(db, user_id=user.id) + vendors = [t.push_vendor for t in targets] + print(f"邀请人 {args.phone}(id={user.id});推送设备 {len(targets)} 台:{vendors or '无 ← 手机收不到!先在 App 上报 push token'}") + + before = _notif_count(db, user.id) + print(f"→ 本次奖励 【{cents / 100:.2f} 元】(invitee_user_id={invitee_id}),手机上按金额认领这条通知") + notification_events.notify_invite_order_reward( + db, inviter_user_id=user.id, invitee_user_id=invitee_id, cash_cents=cents + ) + + created = _notif_count(db, user.id) - before + if created == 1: + verdict = "✅ 已落库 1 条站内消息" + else: + verdict = "⚠️ 未落库(若日志有 dedup hit:该被邀请人上一条还未读,先在 App 里读掉再发)" + print(f"\n{verdict};推送成败见上方 shagua.notification_events 日志。") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/test_push_withdraw_failed.bat b/scripts/test_push_withdraw_failed.bat new file mode 100644 index 0000000..fd6f23f --- /dev/null +++ b/scripts/test_push_withdraw_failed.bat @@ -0,0 +1,9 @@ +@echo off +REM Push test #4 withdraw_failed (1 notification per run, random amount + reason). +REM Works from ANY directory (locates project root + venv python by itself): +REM scripts\test_push_withdraw_failed.bat +REM Extra args pass through, e.g.: test_push_withdraw_failed.bat --cents 350 +REM ASCII-only comments: cmd parses .bat in the console codepage (GBK), UTF-8 +REM Chinese here gets mangled into bogus commands. +cd /d "%~dp0.." +".venv\Scripts\python.exe" "scripts\test_push_withdraw_failed.py" %* diff --git a/scripts/test_push_withdraw_failed.py b/scripts/test_push_withdraw_failed.py new file mode 100644 index 0000000..1f2eff9 --- /dev/null +++ b/scripts/test_push_withdraw_failed.py @@ -0,0 +1,106 @@ +"""#4 提现失败(withdraw_failed)推送联调脚本 —— 每次执行只发 1 条,金额 + 失败原因随机。 + +后台虽能驱动提现拒绝,但受「同一用户同时只能有 1 张待审单」约束,批量测试凑不齐单子; +本脚本直接调 services/notification_events.notify_withdraw_failed —— 与生产同一条下发链路: +落 notification 表(站内消息)+ 向该用户全部已注册厂商 token 直推(honor/huawei/xiaomi/oppo/vivo)。 + +可重复性:每次执行用全新 uuid 单号做 dedup_key,永不命中「未读去重」,想跑多少次都行。 +金额默认每次随机(0.01 ~ 99.99 元)、失败原因随机 → 手机上按金额/原因就能认出这条通知; +--cents / --reason 可固定。 + +用法(服务端进程无需在跑,脚本自己连库 + 直调厂商接口): + .venv\\Scripts\\python.exe scripts\\test_push_withdraw_failed.py # 随机金额+原因发 1 条 + .venv\\Scripts\\python.exe scripts\\test_push_withdraw_failed.py --cents 350 # 固定 3.50 元 + .venv\\Scripts\\python.exe scripts\\test_push_withdraw_failed.py --reason "自定义原因" # 固定失败原因 + +结果判读(看输出日志): + push sent = 厂商接口受理成功,手机应弹「提现失败」通知 + push failed = 厂商拒绝(原因见日志:token 失效/凭据错误等) + skip push = 该厂商凭据未配置,只落站内消息 +站内消息用 11111111111 登录 App → 消息中心「提现助手」可见;点击应跳提现页(extra.withdrawId)。 +""" +from __future__ import annotations + +import argparse +import logging +import random +import sys +import uuid + +from sqlalchemy import func, select + +from app.db.session import SessionLocal, engine +from app.models.notification import Notification +from app.models.wallet import WithdrawOrder +from app.repositories import device as device_repo +from app.repositories import user as user_repo +from app.services import notification_events + +# SQL 回显静音;shagua.* 开到 INFO 才看得到 push sent / push failed +# dev 下 APP_DEBUG=true → engine echo=True,echo 走 InstanceLogger 直写、无视 logger level,只能关 echo 本身 +logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") +logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) +engine.echo = False + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +TYPE_KEY = "withdraw_failed" + +# 失败原因池:不带 --reason 时随机抽一条,模拟不同失败场景(文案与 /withdraw/status 用户可读口径一致) +FAIL_REASONS = [ + "微信零钱未实名,款项已退回", + "收款账户异常,款项已退回", + "超出微信零钱收款限额,款项已退回", + "微信实名与提现实名不一致,款项已原路退回现金余额", +] + + +def _notif_count(db, uid: int) -> int: + return int( + db.execute( + select(func.count()) + .select_from(Notification) + .where(Notification.user_id == uid, Notification.type == TYPE_KEY) + ).scalar_one() + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="#4 提现失败 推送联调(每次 1 条,金额/原因默认随机)") + parser.add_argument("--phone", default="11111111111", help="目标用户手机号(默认 11111111111)") + parser.add_argument("--cents", type=int, default=None, help="退回金额,单位分(默认随机 1~9999)") + parser.add_argument("--reason", default="", help="失败原因(默认从内置原因池随机抽)") + args = parser.parse_args() + + cents = args.cents if args.cents is not None else random.randint(1, 9999) + reason = args.reason.strip() or random.choice(FAIL_REASONS) + + db = SessionLocal() + try: + user = user_repo.get_user_by_phone(db, args.phone) + if user is None: + print(f"❌ 用户 {args.phone} 不存在。先用该手机号在 App 登录一次再跑。") + return + + targets = device_repo.list_push_targets(db, user_id=user.id) + vendors = [t.push_vendor for t in targets] + print(f"目标用户 {args.phone}(id={user.id});推送设备 {len(targets)} 台:{vendors or '无 ← 手机收不到!先在 App 上报 push token'}") + + before = _notif_count(db, user.id) + order = WithdrawOrder( + user_id=user.id, out_bill_no=uuid.uuid4().hex, + amount_cents=cents, source="coin_cash", fail_reason=reason, + ) + print(f"→ 本次金额 【{cents / 100:.2f} 元】,原因【{reason}】(单号 {order.out_bill_no[:8]}…)") + notification_events.notify_withdraw_failed(db, order) + + created = _notif_count(db, user.id) - before + verdict = "✅ 已落库 1 条站内消息" if created == 1 else "⚠️ 未落库(见上方日志)" + print(f"\n{verdict};推送成败见上方 shagua.notification_events 日志。") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/test_push_withdraw_success.bat b/scripts/test_push_withdraw_success.bat new file mode 100644 index 0000000..ed4b9bf --- /dev/null +++ b/scripts/test_push_withdraw_success.bat @@ -0,0 +1,9 @@ +@echo off +REM Push test #3 withdraw_success (1 notification per run, random amount). +REM Works from ANY directory (locates project root + venv python by itself): +REM scripts\test_push_withdraw_success.bat +REM Extra args pass through, e.g.: test_push_withdraw_success.bat --cents 1280 +REM ASCII-only comments: cmd parses .bat in the console codepage (GBK), UTF-8 +REM Chinese here gets mangled into bogus commands. +cd /d "%~dp0.." +".venv\Scripts\python.exe" "scripts\test_push_withdraw_success.py" %* diff --git a/scripts/test_push_withdraw_success.py b/scripts/test_push_withdraw_success.py new file mode 100644 index 0000000..23acba4 --- /dev/null +++ b/scripts/test_push_withdraw_success.py @@ -0,0 +1,94 @@ +"""#3 提现到账(withdraw_success)推送联调脚本 —— 每次执行只发 1 条,金额随机。 + +本环境 wxpay 未配置,后台审核通过发不出微信转账,打不通真实提现链路;本脚本直接调 +services/notification_events.notify_withdraw_success —— 与生产同一条下发链路: +落 notification 表(站内消息)+ 向该用户全部已注册厂商 token 直推(honor/huawei/xiaomi/oppo/vivo)。 + +可重复性:每次执行用全新 uuid 单号做 dedup_key,永不命中「未读去重」,想跑多少次都行。 +金额默认每次随机(0.01 ~ 99.99 元)→ 手机上按金额就能认出这条通知是哪次跑出来的;--cents 可固定。 + +用法(服务端进程无需在跑,脚本自己连库 + 直调厂商接口): + .venv\\Scripts\\python.exe scripts\\test_push_withdraw_success.py # 随机金额发 1 条 + .venv\\Scripts\\python.exe scripts\\test_push_withdraw_success.py --cents 1280 # 固定 12.80 元 + +结果判读(看输出日志): + push sent = 厂商接口受理成功,手机应弹「提现到账」通知 + push failed = 厂商拒绝(原因见日志:token 失效/凭据错误等) + skip push = 该厂商凭据未配置,只落站内消息 +站内消息用 11111111111 登录 App → 消息中心「提现助手」可见。 +""" +from __future__ import annotations + +import argparse +import logging +import random +import sys +import uuid + +from sqlalchemy import func, select + +from app.db.session import SessionLocal, engine +from app.models.notification import Notification +from app.models.wallet import WithdrawOrder +from app.repositories import device as device_repo +from app.repositories import user as user_repo +from app.services import notification_events + +# SQL 回显静音;shagua.* 开到 INFO 才看得到 push sent / push failed +# dev 下 APP_DEBUG=true → engine echo=True,echo 走 InstanceLogger 直写、无视 logger level,只能关 echo 本身 +logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") +logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) +engine.echo = False + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +TYPE_KEY = "withdraw_success" + + +def _notif_count(db, uid: int) -> int: + return int( + db.execute( + select(func.count()) + .select_from(Notification) + .where(Notification.user_id == uid, Notification.type == TYPE_KEY) + ).scalar_one() + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="#3 提现到账 推送联调(每次 1 条,金额默认随机)") + parser.add_argument("--phone", default="11111111111", help="目标用户手机号(默认 11111111111)") + parser.add_argument("--cents", type=int, default=None, help="到账金额,单位分(默认随机 1~9999)") + args = parser.parse_args() + + cents = args.cents if args.cents is not None else random.randint(1, 9999) + + db = SessionLocal() + try: + user = user_repo.get_user_by_phone(db, args.phone) + if user is None: + print(f"❌ 用户 {args.phone} 不存在。先用该手机号在 App 登录一次再跑。") + return + + targets = device_repo.list_push_targets(db, user_id=user.id) + vendors = [t.push_vendor for t in targets] + print(f"目标用户 {args.phone}(id={user.id});推送设备 {len(targets)} 台:{vendors or '无 ← 手机收不到!先在 App 上报 push token'}") + + before = _notif_count(db, user.id) + order = WithdrawOrder( + user_id=user.id, out_bill_no=uuid.uuid4().hex, + amount_cents=cents, source="coin_cash", + ) + print(f"→ 本次金额 【{cents / 100:.2f} 元】(单号 {order.out_bill_no[:8]}…),手机上按金额认领这条通知") + notification_events.notify_withdraw_success(db, order) + + created = _notif_count(db, user.id) - before + verdict = "✅ 已落库 1 条站内消息" if created == 1 else "⚠️ 未落库(见上方日志)" + print(f"\n{verdict};推送成败见上方 shagua.notification_events 日志。") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/tests/test_device_push.py b/tests/test_device_push.py new file mode 100644 index 0000000..a35ec24 --- /dev/null +++ b/tests/test_device_push.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone + +from fastapi.testclient import TestClient + +from app.api.v1 import device as device_api +from app.core import heartbeat_monitor_worker +from app.db.session import SessionLocal +from app.integrations import vendor_push +from app.models.device import DeviceLiveness +from app.repositories import user as user_repo + + +class _Resp: + status_code = 200 + text = "{}" + + def __init__(self, data: dict) -> None: + self._data = data + + def json(self) -> dict: + return self._data + + +def test_xiaomi_accessibility_payload(monkeypatch) -> None: + captured: dict = {} + + def _fake_request(method, url, **kwargs): # noqa: ANN001 + captured.update(method=method, url=url, **kwargs) + return _Resp({"code": 0, "result": "ok", "data": {"id": "xm-msg"}}) + + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_CHANNEL_ID", "") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_ID", "") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_TITLE", "") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_DESCRIPTION", "") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_PARAM_JSON", "") + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + data = vendor_push.send_accessibility_disabled("xiaomi", "xm-regid") + + assert data["data"]["id"] == "xm-msg" + assert captured["method"] == "POST" + assert captured["url"] == vendor_push.settings.XIAOMI_PUSH_SEND_ENDPOINT + assert captured["headers"]["Authorization"] == "key=xiaomi-secret" + body = captured["data"] + assert body["registration_id"] == "xm-regid" + assert body["restricted_package_name"] == "com.jishisongfu.shaguabijia" + assert json.loads(body["payload"]) == {"type": "accessibility_disabled"} + assert "extra.channel_id" not in body + + +def test_xiaomi_payload_with_channel_and_template(monkeypatch) -> None: + captured: dict = {} + + def _fake_request(method, url, **kwargs): # noqa: ANN001 + captured.update(method=method, url=url, **kwargs) + return _Resp({"code": 0, "result": "ok", "data": {"id": "xm-msg"}}) + + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_CHANNEL_ID", "130") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_ID", "1001") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_TITLE", "{$app_name$}提醒") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_DESCRIPTION", "{$content$}") + monkeypatch.setattr( + vendor_push.settings, + "XIAOMI_PUSH_TEMPLATE_PARAM_JSON", + '{"app_name":"傻瓜比价","content":"{alert}"}', + ) + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + vendor_push.send_accessibility_disabled( + "xiaomi", + "xm-regid", + title="测试标题", + alert="测试内容", + ) + + body = captured["data"] + assert body["title"] == "{$app_name$}提醒" + assert body["description"] == "{$content$}" + assert body["extra.channel_id"] == "130" + assert body["extra.template_id"] == "1001" + assert body["extra.template_param"] == '{"app_name":"傻瓜比价","content":"测试内容"}' + + +def test_vivo_auth_and_send_payload(monkeypatch) -> None: + vendor_push._token_cache.clear() + calls: list[dict] = [] + + def _fake_request(method, url, **kwargs): # noqa: ANN001 + calls.append({"method": method, "url": url, **kwargs}) + if url == vendor_push.settings.VIVO_PUSH_AUTH_ENDPOINT: + return _Resp({"result": 0, "authToken": "vivo-auth"}) + return _Resp({"result": 0, "taskId": "vivo-task"}) + + monkeypatch.setattr(vendor_push.settings, "VIVO_PUSH_APP_ID", "106072775") + monkeypatch.setattr(vendor_push.settings, "VIVO_PUSH_APP_KEY", "vivo-key") + monkeypatch.setattr(vendor_push.settings, "VIVO_PUSH_APP_SECRET", "vivo-secret") + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + data = vendor_push.send_accessibility_disabled("vivo", "vivo-regid") + + assert data["taskId"] == "vivo-task" + assert calls[0]["url"] == vendor_push.settings.VIVO_PUSH_AUTH_ENDPOINT + assert calls[0]["json"]["appId"] == "106072775" + assert calls[0]["json"]["sign"] + assert calls[1]["url"] == vendor_push.settings.VIVO_PUSH_SEND_ENDPOINT + assert calls[1]["headers"]["authToken"] == "vivo-auth" + body = calls[1]["json"] + assert body["regId"] == "vivo-regid" + assert body["pushMode"] == vendor_push.settings.VIVO_PUSH_MODE + assert body["clientCustomMap"] == {"type": "accessibility_disabled"} + + +def test_oppo_auth_and_send_payload(monkeypatch) -> None: + vendor_push._token_cache.clear() + calls: list[dict] = [] + + def _fake_request(method, url, **kwargs): # noqa: ANN001 + calls.append({"method": method, "url": url, **kwargs}) + if url == vendor_push.settings.OPPO_PUSH_AUTH_ENDPOINT: + return _Resp({"code": 0, "data": {"auth_token": "oppo-auth"}}) + return _Resp({"code": 0, "data": {"message_id": "oppo-msg"}}) + + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_APP_KEY", "oppo-key") + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_MASTER_SECRET", "oppo-master") + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + data = vendor_push.send_accessibility_disabled("oppo", "oppo-regid") + + assert data["data"]["message_id"] == "oppo-msg" + assert calls[0]["data"]["app_key"] == "oppo-key" + assert calls[0]["data"]["sign"] + message = json.loads(calls[1]["data"]["message"]) + assert calls[1]["data"]["auth_token"] == "oppo-auth" + assert message["target_type"] == 2 + assert message["target_value"] == "oppo-regid" + assert json.loads(message["notification"]["action_parameters"]) == { + "type": "accessibility_disabled" + } + + +def test_honor_auth_and_send_payload(monkeypatch) -> None: + vendor_push._token_cache.clear() + calls: list[dict] = [] + + def _fake_request(method, url, **kwargs): # noqa: ANN001 + calls.append({"method": method, "url": url, **kwargs}) + if url == vendor_push.settings.HONOR_PUSH_TOKEN_ENDPOINT: + return _Resp({"access_token": "honor-access", "expires_in": 3600}) + return _Resp({"code": 200, "message": "successful!", "data": {"sendResult": True}}) + + monkeypatch.setattr(vendor_push.settings, "HONOR_PUSH_APP_ID", "104559789") + monkeypatch.setattr(vendor_push.settings, "HONOR_PUSH_CLIENT_ID", "honor-client") + monkeypatch.setattr(vendor_push.settings, "HONOR_PUSH_CLIENT_SECRET", "honor-secret") + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + data = vendor_push.send_accessibility_disabled("honor", "honor-token") + + assert data["code"] == 200 + assert calls[0]["data"]["client_id"] == "honor-client" + assert calls[1]["headers"]["Authorization"] == "Bearer honor-access" + assert calls[1]["headers"]["timestamp"] + assert calls[1]["url"].endswith("/api/v1/104559789/sendMessage") + body = calls[1]["json"] + assert body["token"] == ["honor-token"] + assert body["android"]["targetUserType"] == 1 + assert body["android"]["notification"]["clickAction"] == {"type": 3} + assert json.loads(body["data"]) == {"type": "accessibility_disabled"} + + +def _seed_overdue_device( + *, + phone: str, + device_id: str, + push_vendor: str | None, + push_token: str | None, +) -> int: + with SessionLocal() as db: + user = user_repo.upsert_user_for_login(db, phone=phone, register_channel="sms") + device = DeviceLiveness( + user_id=user.id, + device_id=device_id, + push_vendor=push_vendor, + push_token=push_token, + platform="android", + ever_protected=True, + last_heartbeat_at=datetime.now(timezone.utc) - timedelta(minutes=30), # noqa: UP017 + last_report_protection_on=True, + liveness_state="alive", + kill_alert_pending=False, + ) + db.add(device) + db.commit() + db.refresh(device) + return device.id + + +def _login(client: TestClient, phone: str) -> str: + client.post("/api/v1/auth/sms/send", json={"phone": phone}) + r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"}) + assert r.status_code == 200, r.text + return r.json()["access_token"] + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def test_heartbeat_monitor_pushes_overdue_device(monkeypatch) -> None: + device_pk = _seed_overdue_device( + phone="13900009001", + device_id="dev-push-honor", + push_vendor="honor", + push_token="honor-token-1", + ) + calls: list[tuple[str, str]] = [] + + def _fake_send(push_vendor: str, push_token: str) -> dict: + calls.append((push_vendor, push_token)) + return {"msg_id": "m1"} + + monkeypatch.setattr( + heartbeat_monitor_worker.vendor_push, + "send_accessibility_disabled", + _fake_send, + ) + + result = heartbeat_monitor_worker._scan_once(timeout_minutes=10) + + assert result["pushed"] >= 1 + assert ("honor", "honor-token-1") in calls + with SessionLocal() as db: + device = db.get(DeviceLiveness, device_pk) + assert device is not None + assert device.liveness_state == "notified" + assert device.kill_alert_pending is True + + +def test_heartbeat_monitor_skips_push_without_vendor_token(monkeypatch) -> None: + device_pk = _seed_overdue_device( + phone="13900009002", + device_id="dev-push-no-token", + push_vendor=None, + push_token=None, + ) + + def _fake_send(push_vendor: str, push_token: str) -> dict: + raise AssertionError(f"should not push without token: {push_vendor}/{push_token}") + + monkeypatch.setattr( + heartbeat_monitor_worker.vendor_push, + "send_accessibility_disabled", + _fake_send, + ) + + result = heartbeat_monitor_worker._scan_once(timeout_minutes=10) + + assert result["checked"] >= 1 + with SessionLocal() as db: + device = db.get(DeviceLiveness, device_pk) + assert device is not None + assert device.liveness_state == "notified" + assert device.kill_alert_pending is True + + +def test_push_test_endpoint_schedules_vendor_push(client: TestClient, monkeypatch) -> None: + token = _login(client, "13900009003") + calls: list[tuple[str, str, str, str]] = [] + + def _fake_send(push_vendor: str, push_token: str, *, title: str, alert: str) -> dict: + calls.append((push_vendor, push_token, title, alert)) + return {"msg_id": "m-test"} + + monkeypatch.setattr(device_api.vendor_push, "send_accessibility_disabled", _fake_send) + + r = client.post( + "/api/v1/device/push-test", + json={ + "device_id": "dev-push-test", + "push_vendor": "honor", + "push_token": "honor-test-token", + "delay_seconds": 0, + }, + headers=_auth(token), + ) + + assert r.status_code == 200, r.text + assert r.json() == { + "ok": True, + "delay_seconds": 0, + "has_push_token": True, + } + assert calls == [ + ( + "honor", + "honor-test-token", + "测试推送", + "这是一条厂商通道测试推送。收到它说明 App 被划掉后仍可通过系统通知栏触达。", + ) + ] + + +def test_push_test_endpoint_requires_vendor_token(client: TestClient) -> None: + token = _login(client, "13900009004") + + r = client.post( + "/api/v1/device/push-test", + json={"device_id": "dev-push-test-no-token", "delay_seconds": 0}, + headers=_auth(token), + ) + + assert r.status_code == 409 + assert r.json()["detail"] == "push vendor token not ready" diff --git a/tests/test_notification_events.py b/tests/test_notification_events.py new file mode 100644 index 0000000..561ecc8 --- /dev/null +++ b/tests/test_notification_events.py @@ -0,0 +1,358 @@ +"""业务事件 → 站内通知 + 厂商推送 联动测试(services/notification_events)。 + +覆盖 PRD 六类真实触发: + #3 提现成功 / #4 提现失败(含审核拒绝) / #9 官方回复 / #10 反馈奖励 / + #11 爆料审核通过 / #12 好友下单到账。 +厂商推送不真发:测试环境无凭据默认跳过;推送链路用 monkeypatch 捕获/注错验证 +「有设备则推、推挂了业务不受影响」。wxpay 网络调用照旧全 monkeypatch。 +""" +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app.admin.main import admin_app +from app.admin.repositories import admin_user as admin_repo +from app.core.rewards import INVITE_COMPARE_REWARD_CENTS, PRICE_REPORT_REWARD_COINS +from app.core.security import decode_token, hash_password +from app.db.session import SessionLocal +from app.models.feedback import Feedback +from app.models.price_report import PriceReport +from app.models.wallet import CoinAccount, WithdrawOrder +from app.repositories import device as device_repo +from app.repositories import wallet as crud_wallet +from app.services import notification_events + +# ===== 用户侧 helpers(同 test_withdraw / test_notifications)===== + +def _login(client: TestClient, phone: str) -> str: + client.post("/api/v1/auth/sms/send", json={"phone": phone}) + r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"}) + assert r.status_code == 200, r.text + return r.json()["access_token"] + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _uid(token: str) -> int: + return int(decode_token(token, expected_type="access")["sub"]) + + +def _notifications(client: TestClient, token: str) -> list[dict]: + r = client.get("/api/v1/notifications?pageSize=50", headers=_auth(token)) + assert r.status_code == 200, r.text + return r.json()["items"] + + +def _seed_cash(client: TestClient, token: str, cents: int) -> None: + """先访问 /account 触发建账户,再直接灌现金余额。""" + client.get("/api/v1/wallet/account", headers=_auth(token)) + with SessionLocal() as db: + acc = db.get(CoinAccount, _uid(token)) + acc.cash_balance_cents = cents + db.commit() + + +def _create_withdraw(client: TestClient, token: str, monkeypatch, cents: int = 50) -> str: + """绑微信 + 发起提现(进入 reviewing),返回 out_bill_no。""" + monkeypatch.setattr( + "app.integrations.wxpay.code_to_userinfo", + lambda code: {"openid": f"openid_{_uid(token)}", "nickname": None, "avatar_url": None, "raw": {}}, + ) + _seed_cash(client, token, cents * 2) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": cents}, headers=_auth(token)) + assert r.status_code == 200, r.text + assert r.json()["status"] == "reviewing" + return r.json()["out_bill_no"] + + +# ===== admin 侧 helpers(同 test_admin_write)===== + +@pytest.fixture() +def admin_client() -> TestClient: + return TestClient(admin_app) + + +@pytest.fixture() +def operator_token() -> str: + with SessionLocal() as db: + a = admin_repo.get_by_username(db, "ne_operator") + if a is None: + admin_repo.create_admin(db, username="ne_operator", password="pass1234", role="operator") + else: + a.password_hash = hash_password("pass1234") + a.role = "operator" + a.status = "active" + db.commit() + c = TestClient(admin_app) + return c.post( + "/admin/api/auth/login", json={"username": "ne_operator", "password": "pass1234"} + ).json()["access_token"] + + +def _seed_feedback(uid: int) -> int: + with SessionLocal() as db: + fb = Feedback(user_id=uid, content="比价按钮找不到", contact="wx123", status="pending") + db.add(fb) + db.commit() + return fb.id + + +def _seed_price_report(uid: int, store: str = "蜀大侠火锅") -> int: + with SessionLocal() as db: + rep = PriceReport( + user_id=uid, + store_name=store, + reported_platform_id="mt", + reported_platform_name="美团", + reported_price_cents=990, + images=[], + status="pending", + ) + db.add(rep) + db.commit() + return rep.id + + +# ===== #4 提现失败(审核拒绝路径,_refund_withdraw 收口)===== + +def test_withdraw_reject_creates_failed_notification(client: TestClient, monkeypatch) -> None: + token = _login(client, "13800005001") + bill = _create_withdraw(client, token, monkeypatch) + + with SessionLocal() as db: + crud_wallet.reject_withdraw(db, bill, "微信零钱未实名") + + items = _notifications(client, token) + failed = [i for i in items if i["type"] == "withdraw_failed"] + assert len(failed) == 1 + n = failed[0] + assert n["cashCents"] == 50 + assert n["cashYuan"] == "0.50" + assert n["title"] == "提现失败,款项已退回" + assert n["extra"]["withdrawId"] == bill + assert n["isRead"] is False + rows = {r["label"]: r["value"] for r in n["infoRows"]} + assert rows["失败原因"] == "微信零钱未实名" + assert rows["退回说明"] == "款项已原路退回现金余额" + + +def test_withdraw_failed_event_dedup_single_unread(client: TestClient, monkeypatch) -> None: + """同一提现单重复触发失败事件(并发查单等)→ 未读期间只落一条。""" + token = _login(client, "13800005002") + bill = _create_withdraw(client, token, monkeypatch) + with SessionLocal() as db: + crud_wallet.reject_withdraw(db, bill, "审核未通过") + order = db.execute( + select(WithdrawOrder).where(WithdrawOrder.out_bill_no == bill) + ).scalar_one() + notification_events.notify_withdraw_failed(db, order) # 人为重复触发 + + items = [i for i in _notifications(client, token) if i["type"] == "withdraw_failed"] + assert len(items) == 1 + + +# ===== #3 提现成功(查单归一化路径)===== + +def test_withdraw_success_notification_on_status_query(client: TestClient, monkeypatch) -> None: + token = _login(client, "13800005003") + monkeypatch.setattr( + "app.integrations.wxpay.create_transfer", + lambda openid, amount_fen, out_bill_no, user_name=None: { + "status_code": 200, + "data": {"state": "WAIT_USER_CONFIRM", "package_info": "pkg", "transfer_bill_no": "tb"}, + }, + ) + bill = _create_withdraw(client, token, monkeypatch) + with SessionLocal() as db: + crud_wallet.approve_withdraw(db, bill) # 审核通过 → 转账进 pending(等用户确认) + + assert [i for i in _notifications(client, token) if i["type"] == "withdraw_success"] == [] + + # 用户确认后查单 → SUCCESS → success + 下发「提现到账」通知 + monkeypatch.setattr( + "app.integrations.wxpay.query_transfer", + lambda out_bill_no: {"status_code": 200, "data": {"state": "SUCCESS"}}, + ) + r = client.get("/api/v1/wallet/withdraw/status", params={"out_bill_no": bill}, headers=_auth(token)) + assert r.json()["status"] == "success" + + ok = [i for i in _notifications(client, token) if i["type"] == "withdraw_success"] + assert len(ok) == 1 + n = ok[0] + assert n["cashCents"] == 50 + assert n["actionText"] is None # PRD:提现成功卡无操作行 + rows = {r["label"]: r["value"] for r in n["infoRows"]} + assert rows["到账账户"] == "微信钱包" + assert "到账时间" in rows + + # 再查一次(已终态,早退)→ 不重复下发 + client.get("/api/v1/wallet/withdraw/status", params={"out_bill_no": bill}, headers=_auth(token)) + assert len([i for i in _notifications(client, token) if i["type"] == "withdraw_success"]) == 1 + + +# ===== #10 反馈奖励(admin 采纳发金币)===== + +def test_feedback_approve_sends_reward_notification( + client: TestClient, admin_client: TestClient, operator_token: str +) -> None: + token = _login(client, "13800005004") + fb_id = _seed_feedback(_uid(token)) + + r = admin_client.post( + f"/admin/api/feedbacks/{fb_id}/approve", + json={"reward_coins": 300, "note": "好建议", "reply": "问题已修复上线,送您的金币请查收~"}, + headers=_auth(operator_token), + ) + assert r.status_code == 200, r.text + + items = [i for i in _notifications(client, token) if i["type"] == "feedback_reward"] + assert len(items) == 1 + n = items[0] + assert n["coins"] == 300 + assert n["extra"]["feedbackId"] == str(fb_id) + rows = {r["label"]: r["value"] for r in n["infoRows"]} + assert rows["官方留言"] == "问题已修复上线,送您的金币请查收~" # PRD:发奖必带官方留言 + assert "到账时间" in rows + + +# ===== #9 官方回复(admin 拒绝,原因/留言用户可见)===== + +def test_feedback_reject_sends_reply_notification( + client: TestClient, admin_client: TestClient, operator_token: str +) -> None: + token = _login(client, "13800005005") + fb_id = _seed_feedback(_uid(token)) + + r = admin_client.post( + f"/admin/api/feedbacks/{fb_id}/reject", + json={"reason": "无法复现", "reply": "麻烦补个录屏,我们再看看~"}, + headers=_auth(operator_token), + ) + assert r.status_code == 200, r.text + + items = [i for i in _notifications(client, token) if i["type"] == "feedback_reply"] + assert len(items) == 1 + n = items[0] + assert n["title"] == "傻瓜比价官方回复了您的反馈" + assert n["extra"]["feedbackId"] == str(fb_id) + assert n["coins"] is None + + +# ===== #11 爆料审核通过(admin 通过发固定金币)===== + +def test_price_report_approve_sends_notification( + client: TestClient, admin_client: TestClient, operator_token: str +) -> None: + token = _login(client, "13800005006") + rep_id = _seed_price_report(_uid(token), store="蜀大侠火锅") + + r = admin_client.post( + f"/admin/api/price-reports/{rep_id}/approve", headers=_auth(operator_token) + ) + assert r.status_code == 200, r.text + + items = [i for i in _notifications(client, token) if i["type"] == "report_approved"] + assert len(items) == 1 + n = items[0] + assert n["coins"] == PRICE_REPORT_REWARD_COINS + assert n["extra"]["reportId"] == str(rep_id) + rows = {r["label"]: r["value"] for r in n["infoRows"]} + assert "蜀大侠火锅" in rows["奖励说明"] + + +# ===== #12 好友下单到账(好友首次成功比价 → 通知邀请人)===== + +def test_invite_compare_reward_sends_notification(client: TestClient) -> None: + a = _login(client, "13800005007") + b = _login(client, "13800005008") + code = client.get("/api/v1/invite/me", headers=_auth(a)).json()["invite_code"] + client.post("/api/v1/invite/bind", json={"invite_code": code}, headers=_auth(b)) + + r = client.post( + "/api/v1/compare/record", + json={"trace_id": "trace-notif-1", "status": "success"}, + headers=_auth(b), + ) + assert r.status_code == 200, r.text + + items = [i for i in _notifications(client, a) if i["type"] == "invite_order_reward"] + assert len(items) == 1 + n = items[0] + assert n["cashCents"] == INVITE_COMPARE_REWARD_CENTS + assert n["extra"]["inviteeNickname"] # 好友昵称兜底(昵称/尾号/「好友」)非空 + # 被邀请人自己不收该通知 + assert [i for i in _notifications(client, b) if i["type"] == "invite_order_reward"] == [] + + # 好友再比价 → 不再发奖也不再通知(发奖幂等 + 通知 dedup 双保险) + client.post( + "/api/v1/compare/record", + json={"trace_id": "trace-notif-2", "status": "success"}, + headers=_auth(b), + ) + assert len([i for i in _notifications(client, a) if i["type"] == "invite_order_reward"]) == 1 + + +# ===== 厂商推送联动(有设备则推;推送失败不伤业务)===== + +def _register_device(uid: int, vendor: str = "xiaomi", token: str = "regid-1") -> None: + with SessionLocal() as db: + device_repo.register_or_update( + db, user_id=uid, device_id=f"dev_{uid}", push_vendor=vendor, push_token=token + ) + + +def test_push_sent_to_registered_device(client: TestClient, monkeypatch) -> None: + token = _login(client, "13800005009") + _register_device(_uid(token)) + + sent: list[dict] = [] + monkeypatch.setattr(notification_events.vendor_push, "missing_settings", lambda vendor: []) + + def _capture(vendor, push_token, *, title, body, extras=None, mock=False): + sent.append({"vendor": vendor, "token": push_token, "title": title, "body": body, "extras": extras}) + return {"ok": True} + + monkeypatch.setattr(notification_events.vendor_push, "send_notification", _capture) + + bill = _create_withdraw(client, token, monkeypatch) + with SessionLocal() as db: + crud_wallet.reject_withdraw(db, bill, "微信零钱未实名") + + assert len(sent) == 1 + p = sent[0] + assert p["vendor"] == "xiaomi" and p["token"] == "regid-1" + assert p["title"] == "提现失败,款项已退回" + assert "0.50" in p["body"] and "微信零钱未实名" in p["body"] + # PRD §4 push 已读联动:extras 带 type + notificationId + 跳转参数 + assert p["extras"]["type"] == "withdraw_failed" + assert p["extras"]["withdrawId"] == bill + nid = int(p["extras"]["notificationId"]) + assert any(i["id"] == nid for i in _notifications(client, token)) + + +def test_push_failure_does_not_break_business(client: TestClient, monkeypatch) -> None: + """推送炸了(哪怕不是 VendorPushError)→ 提现拒绝照常退款,站内消息照常落库。""" + token = _login(client, "13800005010") + _register_device(_uid(token), token="regid-2") + + monkeypatch.setattr(notification_events.vendor_push, "missing_settings", lambda vendor: []) + + def _boom(*args, **kwargs): + raise RuntimeError("vendor api down") + + monkeypatch.setattr(notification_events.vendor_push, "send_notification", _boom) + + bill = _create_withdraw(client, token, monkeypatch) + with SessionLocal() as db: + crud_wallet.reject_withdraw(db, bill, "审核未通过") + + r = client.get("/api/v1/wallet/withdraw/status", params={"out_bill_no": bill}, headers=_auth(token)) + assert r.json()["status"] == "rejected" # 业务不受影响 + r = client.get("/api/v1/wallet/account", headers=_auth(token)) + assert r.json()["cash_balance_cents"] == 100 # 已退回(seed 100 扣 50 退 50) + assert len([i for i in _notifications(client, token) if i["type"] == "withdraw_failed"]) == 1 diff --git a/tests/test_notifications.py b/tests/test_notifications.py new file mode 100644 index 0000000..a5d70b8 --- /dev/null +++ b/tests/test_notifications.py @@ -0,0 +1,251 @@ +"""消息通知中心 3 接口(落库版)。 + +覆盖:空列表(虚拟数据已清除)、列表字段/派生/排序/分页、未读角标、标记已读(ids / all / +幂等 / 参数校验)、鉴权与用户隔离、去重键部分唯一索引。数据直接写 notification 表 +(repositories/notification),不再有内存 mock。 +""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.exc import IntegrityError + +from app.core.security import decode_token +from app.db.session import SessionLocal +from app.repositories import notification as notif_repo + +_CST = timezone(timedelta(hours=8)) + + +def _login(client: TestClient, phone: str) -> str: + client.post("/api/v1/auth/sms/send", json={"phone": phone}) + r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"}) + assert r.status_code == 200, r.text + return r.json()["access_token"] + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _uid(token: str) -> int: + return int(decode_token(token, expected_type="access")["sub"]) + + +def _seed_sample(token: str, type_key: str): + """按类型插一条样例通知(复用 repo 的样例卡片内容),返回落库行。""" + with SessionLocal() as db: + return notif_repo.insert_sample(db, _uid(token), type_key) + + +def _seed(token: str, type_key: str, **kw): + """按显式内容插一条通知(排序/分页用,可指定 sent_at)。""" + with SessionLocal() as db: + return notif_repo.create_notification(db, user_id=_uid(token), type_key=type_key, **kw) + + +def _fetch_all(client: TestClient, token: str) -> dict: + r = client.get("/api/v1/notifications?pageSize=100", headers=_auth(token)) + assert r.status_code == 200, r.text + return r.json() + + +def test_requires_auth(client: TestClient) -> None: + assert client.get("/api/v1/notifications").status_code == 401 + assert client.get("/api/v1/notifications/unread-count").status_code == 401 + assert client.post("/api/v1/notifications/read", json={"all": True}).status_code == 401 + + +def test_fresh_user_has_no_notifications(client: TestClient) -> None: + """虚拟数据已清除:新用户初始为空列表、未读 0、角标隐藏。""" + token = _login(client, "13900010000") + data = _fetch_all(client, token) + assert data["items"] == [] + assert data["total"] == 0 + assert data["unreadCount"] == 0 + assert data["hasMore"] is False + + r = client.get("/api/v1/notifications/unread-count", headers=_auth(token)) + assert r.json() == {"count": 0, "badgeText": None} + + +def test_list_item_fields_camel_case_and_derived(client: TestClient) -> None: + token = _login(client, "13900010001") + _seed_sample(token, "reward_expiring") + _seed_sample(token, "withdraw_success") + _seed_sample(token, "perm_accessibility") + _seed_sample(token, "feedback_reward") + + items = _fetch_all(client, token)["items"] + assert len(items) == 4 + + # 字段按 PRD 契约 camelCase,卡片要素齐全 + first = items[0] + for key in ( + "id", "category", "categoryLabel", "type", "cardStyle", "title", + "coins", "cashCents", "cashYuan", "infoRows", "actionText", + "extra", "sentAt", "isRead", + ): + assert key in first, f"missing field {key}" + assert "+08:00" in first["sentAt"] # 落库后仍恒带 +08:00 + + by_type = {i["type"]: i for i in items} + + # 双金额卡:金币整数 + 现金两位小数;category/cardStyle/title/actionText 均由 catalog 派生 + expiring = by_type["reward_expiring"] + assert expiring["category"] == "withdraw_assistant" + assert expiring["categoryLabel"] == "提现助手" + assert expiring["cardStyle"] == "dual_amount" + assert expiring["title"] == "金币现金奖励即将失效" + assert expiring["actionText"] == "立即激活您的收益" + assert isinstance(expiring["coins"], int) + assert expiring["cashCents"] == 1280 + assert expiring["cashYuan"] == "12.80" + assert [row["label"] for row in expiring["infoRows"]] == ["过期说明", "过期时间"] + + # 提现成功卡:无操作行、无金币 + ws = by_type["withdraw_success"] + assert ws["actionText"] is None + assert ws["coins"] is None + + # 权限异常卡带 permission 参数(客户端点击时实时检测用) + assert by_type["perm_accessibility"]["extra"] == {"permission": "accessibility"} + + # 反馈奖励卡:官方留言必填(PRD §3) + reward = by_type["feedback_reward"] + assert any(row["label"] == "官方留言" and row["value"] for row in reward["infoRows"]) + + # 新插入默认未读 + assert all(i["isRead"] is False for i in items) + + +def test_list_sorted_by_time_desc(client: TestClient) -> None: + """全列表 sent_at 倒序(最新在前),不分组。""" + token = _login(client, "13900010002") + base = datetime(2026, 7, 1, 12, 0, tzinfo=_CST) + _seed(token, "withdraw_success", sent_at=base - timedelta(days=2)) + newest = _seed(token, "invite_order_reward", sent_at=base) + _seed(token, "feedback_reply", sent_at=base - timedelta(days=1)) + + items = _fetch_all(client, token)["items"] + sent_ats = [i["sentAt"] for i in items] + assert sent_ats == sorted(sent_ats, reverse=True), "最新在前" + assert items[0]["id"] == newest.id + assert items[0]["type"] == "invite_order_reward" + + +def test_pagination(client: TestClient) -> None: + token = _login(client, "13900010003") + base = datetime(2026, 7, 1, 12, 0, tzinfo=_CST) + n = 12 + for i in range(n): + _seed(token, "withdraw_success", sent_at=base - timedelta(minutes=i)) + + total = _fetch_all(client, token)["total"] + assert total == n + + page_size = 5 + seen_ids: list[int] = [] + page = 1 + while True: + r = client.get( + f"/api/v1/notifications?page={page}&pageSize={page_size}", headers=_auth(token) + ) + assert r.status_code == 200 + data = r.json() + assert data["page"] == page + assert data["pageSize"] == page_size + assert data["total"] == total + seen_ids.extend(i["id"] for i in data["items"]) + if not data["hasMore"]: + assert len(data["items"]) <= page_size + break + assert len(data["items"]) == page_size + page += 1 + + assert len(seen_ids) == total + assert len(set(seen_ids)) == total, "翻页不重不漏" + + # 超出末页 → 空页而非报错 + r = client.get("/api/v1/notifications?page=99&pageSize=50", headers=_auth(token)) + assert r.status_code == 200 + assert r.json()["items"] == [] + assert r.json()["hasMore"] is False + + +def test_unread_count_and_badge(client: TestClient) -> None: + token = _login(client, "13900010004") + for _ in range(3): + _seed_sample(token, "withdraw_success") + + r = client.get("/api/v1/notifications/unread-count", headers=_auth(token)) + assert r.json() == {"count": 3, "badgeText": "3"} + + # 全部读完 → count=0,badgeText=null(整个角标隐藏) + client.post("/api/v1/notifications/read", json={"all": True}, headers=_auth(token)) + r = client.get("/api/v1/notifications/unread-count", headers=_auth(token)) + assert r.json() == {"count": 0, "badgeText": None} + + +def test_mark_read_by_ids_idempotent(client: TestClient) -> None: + token = _login(client, "13900010005") + ids = [_seed_sample(token, "withdraw_success").id for _ in range(3)] + picked = ids[:2] + + r = client.post("/api/v1/notifications/read", json={"ids": picked}, headers=_auth(token)) + assert r.status_code == 200 + assert r.json() == {"ok": True, "markedCount": 2, "unreadCount": 1} + + # 列表状态同步翻转 + items = {i["id"]: i for i in _fetch_all(client, token)["items"]} + assert all(items[i]["isRead"] for i in picked) + assert items[ids[2]]["isRead"] is False + + # 重复置读 + 不存在的 id → 幂等,不报错 + r = client.post( + "/api/v1/notifications/read", json={"ids": [*picked, 123456789]}, headers=_auth(token) + ) + assert r.status_code == 200 + assert r.json()["markedCount"] == 0 + assert r.json()["unreadCount"] == 1 + + +def test_mark_read_requires_ids_or_all(client: TestClient) -> None: + token = _login(client, "13900010006") + r = client.post("/api/v1/notifications/read", json={}, headers=_auth(token)) + assert r.status_code == 400 + r = client.post("/api/v1/notifications/read", json={"ids": []}, headers=_auth(token)) + assert r.status_code == 400 + + +def test_isolated_between_users(client: TestClient) -> None: + token_a = _login(client, "13900010007") + token_b = _login(client, "13900010008") + _seed_sample(token_a, "withdraw_success") + _seed_sample(token_b, "withdraw_success") + + client.post("/api/v1/notifications/read", json={"all": True}, headers=_auth(token_a)) + assert client.get("/api/v1/notifications/unread-count", headers=_auth(token_a)).json()["count"] == 0 + assert ( + client.get("/api/v1/notifications/unread-count", headers=_auth(token_b)).json()["count"] == 1 + ), "A 清零不影响 B" + + +def test_dedup_key_blocks_duplicate_unread(client: TestClient) -> None: + """同一 (user, type, dedup_key) 未读期间只允许一条(部分唯一索引拦重复未读)。""" + token = _login(client, "13900010009") + uid = _uid(token) + with SessionLocal() as db: + notif_repo.create_notification( + db, user_id=uid, type_key="perm_accessibility", dedup_key="accessibility" + ) + # 同键第二条(仍未读)→ 唯一索引拦截 + with pytest.raises(IntegrityError): + with SessionLocal() as db: + notif_repo.create_notification( + db, user_id=uid, type_key="perm_accessibility", dedup_key="accessibility" + ) + # 只落了一条 + assert _fetch_all(client, token)["total"] == 1 diff --git a/tests/test_push_center.py b/tests/test_push_center.py new file mode 100644 index 0000000..9b44ac1 --- /dev/null +++ b/tests/test_push_center.py @@ -0,0 +1,454 @@ +"""厂商推送(5 家)+ 推送测试三件套。 + +覆盖:华为 Push Kit 发送链路(OAuth + messages:send payload)、send_notification 通用入口 +与 mock 模式、/push/vendors 配置状态、/push/templates 模板渲染、/push/test 的 +mock/真发/变量覆盖/站内联动/设备反查/参数校验。厂商 HTTP 全部 monkeypatch,不真发。 +""" +from __future__ import annotations + +import json + +import pytest +from fastapi.testclient import TestClient + +from app.integrations import vendor_push + +_ALL_VENDOR_SETTINGS = [key for keys in vendor_push.REQUIRED_SETTINGS.values() for key in keys] + + +class _Resp: + status_code = 200 + text = "{}" + + def __init__(self, data: dict) -> None: + self._data = data + + def json(self) -> dict: + return self._data + + +@pytest.fixture() +def _no_vendor_creds(monkeypatch) -> None: + """把 5 家厂商凭据全部清空(隔离本机 .env 里已填的真实密钥,保证用例确定性)。""" + for key in _ALL_VENDOR_SETTINGS: + monkeypatch.setattr(vendor_push.settings, key, "") + + +def _login(client: TestClient, phone: str) -> str: + client.post("/api/v1/auth/sms/send", json={"phone": phone}) + r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"}) + assert r.status_code == 200, r.text + return r.json()["access_token"] + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +# --------------------------------------------------------------------------- +# integrations.vendor_push:华为链路 + 通用入口 +# --------------------------------------------------------------------------- + + +def test_huawei_auth_and_send_payload(monkeypatch) -> None: + vendor_push._token_cache.clear() + calls: list[dict] = [] + + def _fake_request(method, url, **kwargs): # noqa: ANN001 + calls.append({"method": method, "url": url, **kwargs}) + if url == vendor_push.settings.HUAWEI_PUSH_TOKEN_ENDPOINT: + return _Resp({"access_token": "hw-access", "expires_in": 3600}) + return _Resp({"code": "80000000", "msg": "Success", "requestId": "req-1"}) + + monkeypatch.setattr(vendor_push.settings, "HUAWEI_PUSH_APP_ID", "10086001") + monkeypatch.setattr(vendor_push.settings, "HUAWEI_PUSH_APP_SECRET", "hw-secret") + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + data = vendor_push.send_notification( + "huawei", + "hw-token", + title="测试标题", + body="测试内容", + extras={"type": "withdraw_success", "notificationId": "90001"}, + ) + + assert data["code"] == "80000000" + # OAuth:client_id 即 AppId + assert calls[0]["url"] == vendor_push.settings.HUAWEI_PUSH_TOKEN_ENDPOINT + assert calls[0]["data"]["grant_type"] == "client_credentials" + assert calls[0]["data"]["client_id"] == "10086001" + # 发送:v1 messages:send,Bearer 鉴权,token 数组 + data 透传 extras + # (消息中心推送带 notificationId → data 额外补 notif_id/notif_type 点击路由别名, + # 点击时 HMS 把 data 键值对注入启动 intent,客户端首选这两个键落地) + assert calls[1]["url"].endswith("/v1/10086001/messages:send") + assert calls[1]["headers"]["Authorization"] == "Bearer hw-access" + message = calls[1]["json"]["message"] + assert message["token"] == ["hw-token"] + assert message["android"]["notification"]["title"] == "测试标题" + assert message["android"]["notification"]["click_action"] == {"type": 3} + assert json.loads(message["data"]) == { + "type": "withdraw_success", + "notificationId": "90001", + "notif_id": "90001", + "notif_type": "withdraw_success", + } + + +def test_huawei_non_success_code_raises(monkeypatch) -> None: + vendor_push._token_cache.clear() + + def _fake_request(method, url, **kwargs): # noqa: ANN001 + if url == vendor_push.settings.HUAWEI_PUSH_TOKEN_ENDPOINT: + return _Resp({"access_token": "hw-access", "expires_in": 3600}) + return _Resp({"code": "80300007", "msg": "all tokens are invalid"}) + + monkeypatch.setattr(vendor_push.settings, "HUAWEI_PUSH_APP_ID", "10086001") + monkeypatch.setattr(vendor_push.settings, "HUAWEI_PUSH_APP_SECRET", "hw-secret") + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + with pytest.raises(vendor_push.VendorPushError, match="huawei push failed"): + vendor_push.send_notification("huawei", "bad-token", title="t", body="b") + + +def test_vendor_aliases_normalize() -> None: + assert vendor_push.normalize_vendor("华为") == "huawei" + assert vendor_push.normalize_vendor("HMS") == "huawei" + assert vendor_push.normalize_vendor("荣耀") == "honor" + assert vendor_push.normalize_vendor("小米") == "xiaomi" + assert vendor_push.SUPPORTED_VENDORS == {"honor", "huawei", "xiaomi", "oppo", "vivo"} + + +def test_send_notification_mock_skips_http(monkeypatch) -> None: + def _boom(*args, **kwargs): # noqa: ANN001, ANN002, ANN003 + raise AssertionError("mock 模式不应发起任何 HTTP 请求") + + monkeypatch.setattr(vendor_push.httpx, "request", _boom) + + data = vendor_push.send_notification( + "oppo", "any-token", title="标题", body="正文", extras={"type": "push_test"}, mock=True + ) + assert data == { + "mock": True, + "vendor": "oppo", + "title": "标题", + "body": "正文", + "extras": {"type": "push_test"}, + } + + +def test_send_notification_rejects_unknown_vendor() -> None: + with pytest.raises(vendor_push.VendorPushError, match="unsupported push vendor"): + vendor_push.send_notification("nokia", "t", title="a", body="b", mock=True) + with pytest.raises(vendor_push.VendorPushError, match="token is empty"): + vendor_push.send_notification("huawei", " ", title="a", body="b", mock=True) + + +def test_accessibility_wrapper_keeps_legacy_extras(monkeypatch) -> None: + """旧入口 send_accessibility_disabled 仍传 {"type":"accessibility_disabled"}(worker 兼容)。""" + captured: dict = {} + + def _fake_request(method, url, **kwargs): # noqa: ANN001 + captured.update(method=method, url=url, **kwargs) + return _Resp({"code": 0, "result": "ok"}) + + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_PARAM_JSON", "") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_TITLE", "") + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_TEMPLATE_DESCRIPTION", "") + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + vendor_push.send_accessibility_disabled("xiaomi", "regid-1") + + assert json.loads(captured["data"]["payload"]) == {"type": "accessibility_disabled"} + + +def test_oppo_payload_includes_new_message_category(monkeypatch) -> None: + """OPPO 新消息分类:配置了 channel_id/category 时随通知体下发(2024-11 新规必带)。""" + vendor_push._token_cache.clear() + calls: list[dict] = [] + + def _fake_request(method, url, **kwargs): # noqa: ANN001 + calls.append({"method": method, "url": url, **kwargs}) + if url == vendor_push.settings.OPPO_PUSH_AUTH_ENDPOINT: + return _Resp({"code": 0, "data": {"auth_token": "oppo-auth"}}) + return _Resp({"code": 0, "data": {"message_id": "oppo-msg"}}) + + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_APP_KEY", "oppo-key") + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_MASTER_SECRET", "oppo-master") + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_CHANNEL_ID", "push_oplus_category_content") + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_CATEGORY", "MARKETING") + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_NOTIFY_LEVEL", 0) + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + vendor_push.send_notification( + "oppo", "oppo-regid", title="标题", body="正文", extras={"type": "push_test"} + ) + + notification = json.loads(calls[1]["data"]["message"])["notification"] + assert notification["channel_id"] == "push_oplus_category_content" + assert notification["category"] == "MARKETING" + assert "notify_level" not in notification # 0=不传,走 OPPO 默认 + + +def test_oppo_payload_omits_category_when_unconfigured(monkeypatch) -> None: + vendor_push._token_cache.clear() + calls: list[dict] = [] + + def _fake_request(method, url, **kwargs): # noqa: ANN001 + calls.append({"method": method, "url": url, **kwargs}) + if url == vendor_push.settings.OPPO_PUSH_AUTH_ENDPOINT: + return _Resp({"code": 0, "data": {"auth_token": "oppo-auth"}}) + return _Resp({"code": 0, "data": {"message_id": "oppo-msg"}}) + + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_APP_KEY", "oppo-key") + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_MASTER_SECRET", "oppo-master") + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_CHANNEL_ID", "") + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_CATEGORY", "") + monkeypatch.setattr(vendor_push.settings, "OPPO_PUSH_NOTIFY_LEVEL", 0) + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + + vendor_push.send_notification( + "oppo", "oppo-regid", title="标题", body="正文", extras={"type": "push_test"} + ) + + notification = json.loads(calls[1]["data"]["message"])["notification"] + assert "channel_id" not in notification + assert "category" not in notification + + +# --------------------------------------------------------------------------- +# /api/v1/push 三件套 +# --------------------------------------------------------------------------- + + +def test_vendors_status_reports_missing_keys(client: TestClient, monkeypatch, _no_vendor_creds) -> None: + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret") + token = _login(client, "13900011001") + + r = client.get("/api/v1/push/vendors", headers=_auth(token)) + assert r.status_code == 200 + vendors = {v["vendor"]: v for v in r.json()["vendors"]} + assert list(vendors) == ["honor", "huawei", "xiaomi", "oppo", "vivo"] + + assert vendors["xiaomi"]["configured"] is True + assert vendors["xiaomi"]["missingKeys"] == [] + assert vendors["huawei"]["configured"] is False + assert vendors["huawei"]["missingKeys"] == ["HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"] + assert vendors["honor"]["label"] == "荣耀" + assert vendors["vivo"]["missingKeys"] == [ + "VIVO_PUSH_APP_ID", "VIVO_PUSH_APP_KEY", "VIVO_PUSH_APP_SECRET", + ] + + +def test_templates_render_all_13_types(client: TestClient) -> None: + token = _login(client, "13900011002") + r = client.get("/api/v1/push/templates", headers=_auth(token)) + assert r.status_code == 200 + templates = r.json()["templates"] + assert len(templates) == 13 + + by_type = {t["type"]: t for t in templates} + ws = by_type["withdraw_success"] + assert ws["pushTitle"] == "提现到账提醒" + assert ws["pushBodySample"] == "¥0.50已存入您的微信钱包,点击查看到账详情" + assert ws["variables"] == ["amount"] + + expiring = by_type["reward_expiring"] + assert "86金币" in expiring["pushBodySample"] + assert "{coins}" in expiring["pushBodyTemplate"] + assert expiring["sampleVars"]["cash"] == "12.80" + + # 权限类标题按类型写死功能名 + assert by_type["perm_overlay"]["pushTitle"] == "检测到您的比价按钮已失效" + + +def test_push_test_mock_renders_template(client: TestClient, _no_vendor_creds) -> None: + token = _login(client, "13900011003") + r = client.post( + "/api/v1/push/test", + json={"vendor": "华为", "pushToken": "hw-token-1", "type": "withdraw_success"}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["ok"] is True + assert body["mock"] is True + assert body["vendor"] == "huawei" # 中文别名已归一化 + assert body["title"] == "提现到账提醒" + assert body["body"] == "¥0.50已存入您的微信钱包,点击查看到账详情" + assert body["extras"] == {"type": "withdraw_success"} + assert body["missingKeys"] == ["HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"] + assert body["vendorResponse"] is None + + +def test_push_test_vars_override(client: TestClient, _no_vendor_creds) -> None: + token = _login(client, "13900011004") + r = client.post( + "/api/v1/push/test", + json={ + "vendor": "xiaomi", + "pushToken": "xm-1", + "type": "invite_order_reward", + "vars": {"nickname": "小王", "amount": "6.66"}, + }, + headers=_auth(token), + ) + assert r.status_code == 200 + assert r.json()["body"] == "您的好友「小王」完成首次下单,6.66元现金已到账" + + +def test_push_test_generic_copy_without_type(client: TestClient, _no_vendor_creds) -> None: + token = _login(client, "13900011005") + r = client.post( + "/api/v1/push/test", + json={"vendor": "oppo", "pushToken": "op-1"}, + headers=_auth(token), + ) + assert r.status_code == 200 + body = r.json() + assert body["extras"]["type"] == "push_test" + assert "OPPO" in body["body"] + + +def test_push_test_create_notification_links_message_center( + client: TestClient, _no_vendor_creds +) -> None: + token = _login(client, "13900011006") + before = client.get("/api/v1/notifications/unread-count", headers=_auth(token)).json()["count"] + + r = client.post( + "/api/v1/push/test", + json={ + "vendor": "vivo", + "pushToken": "vv-1", + "type": "feedback_reward", + "createNotification": True, + }, + headers=_auth(token), + ) + assert r.status_code == 200 + body = r.json() + nid = body["notificationId"] + assert isinstance(nid, int) + assert body["extras"]["notificationId"] == str(nid) + assert body["extras"]["type"] == "feedback_reward" + assert body["extras"]["feedbackId"] # 业务参数一并带上,客户端可直达反馈详情 + + # 站内多了一条未读;按 push extras 的 id 置读 → 闭环 + after = client.get("/api/v1/notifications/unread-count", headers=_auth(token)).json()["count"] + assert after == before + 1 + r = client.post("/api/v1/notifications/read", json={"ids": [nid]}, headers=_auth(token)) + assert r.json()["markedCount"] == 1 + + +def test_push_test_real_send_requires_credentials(client: TestClient, _no_vendor_creds) -> None: + token = _login(client, "13900011007") + r = client.post( + "/api/v1/push/test", + json={"vendor": "huawei", "pushToken": "hw-1", "mock": False}, + headers=_auth(token), + ) + assert r.status_code == 400 + assert "HUAWEI_PUSH_APP_ID" in r.json()["detail"] + + +def test_push_test_real_send_xiaomi(client: TestClient, monkeypatch, _no_vendor_creds) -> None: + captured: dict = {} + + def _fake_request(method, url, **kwargs): # noqa: ANN001 + captured.update(method=method, url=url, **kwargs) + return _Resp({"code": 0, "result": "ok", "data": {"id": "xm-real"}}) + + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret") + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + token = _login(client, "13900011008") + + r = client.post( + "/api/v1/push/test", + json={ + "vendor": "xiaomi", + "pushToken": "xm-regid-9", + "type": "report_approved", + "mock": False, + }, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["mock"] is False + assert body["missingKeys"] == [] + assert body["vendorResponse"]["data"]["id"] == "xm-real" + assert captured["data"]["registration_id"] == "xm-regid-9" + assert captured["data"]["title"] == "爆料审核通过" + assert "蜀大侠火锅" in captured["data"]["description"] + assert json.loads(captured["data"]["payload"]) == {"type": "report_approved"} + + +def test_push_test_real_send_vendor_error_maps_502( + client: TestClient, monkeypatch, _no_vendor_creds +) -> None: + def _fake_request(method, url, **kwargs): # noqa: ANN001 + return _Resp({"code": 500, "result": "error", "reason": "invalid regid"}) + + monkeypatch.setattr(vendor_push.settings, "XIAOMI_PUSH_APP_SECRET", "xiaomi-secret") + monkeypatch.setattr(vendor_push.httpx, "request", _fake_request) + token = _login(client, "13900011009") + + r = client.post( + "/api/v1/push/test", + json={"vendor": "xiaomi", "pushToken": "bad", "mock": False}, + headers=_auth(token), + ) + assert r.status_code == 502 + assert "厂商推送失败" in r.json()["detail"] + + +def test_push_test_resolves_token_from_registered_device( + client: TestClient, _no_vendor_creds +) -> None: + token = _login(client, "13900011010") + r = client.post( + "/api/v1/device/register", + json={"device_id": "dev-push-center-1", "push_vendor": "honor", "push_token": "honor-t1"}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + + r = client.post( + "/api/v1/push/test", + json={"deviceId": "dev-push-center-1", "type": "perm_accessibility"}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["vendor"] == "honor" + assert body["title"] == "检测到您的比价功能已失效" + + +def test_push_test_validation_errors(client: TestClient, _no_vendor_creds) -> None: + token = _login(client, "13900011011") + + # 未知厂商 + r = client.post( + "/api/v1/push/test", + json={"vendor": "nokia", "pushToken": "t"}, + headers=_auth(token), + ) + assert r.status_code == 400 + + # 未知类型 + r = client.post( + "/api/v1/push/test", + json={"vendor": "xiaomi", "pushToken": "t", "type": "bogus"}, + headers=_auth(token), + ) + assert r.status_code == 400 + assert "unknown notification type" in r.json()["detail"] + + # 真发但没有 token 可用 + r = client.post( + "/api/v1/push/test", + json={"vendor": "xiaomi", "mock": False}, + headers=_auth(token), + ) + assert r.status_code == 409 From 28a86c3b2c8fcac1152a64fe1afd750454a8fda9 Mon Sep 17 00:00:00 2001 From: zuochenyong Date: Wed, 22 Jul 2026 17:18:26 +0800 Subject: [PATCH 13/42] =?UTF-8?q?feat(compare):=20=E6=AF=94=E4=BB=B7?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=88=97=E8=A1=A8=E5=A2=9E=E5=8A=A0=E5=88=86?= =?UTF-8?q?=E9=A1=B5=20(#156)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/v1/compare/records 新增 ordered / keyword 两个查询参数,过滤全部下推到 SQL。 不能分页之后再由客户端 filter —— 一页里可能一条都不命中,列表看着就是空的, 得翻很多页才蹦出一条。 顺带修掉这条链路上几处随数据量线性变慢的地方: - 列表查询 defer raw_payload / llm_calls / llm_price_snapshot 三个重型 JSON 列。 出参 ComparisonRecordOut 根本不读,却是每页几百 KB~几 MB 的白读 + 白反序列化, 是「比价记录/全部记录」页慢的主要来源;详情接口不 defer,raw_payload 照常返回。 - 「已下单」标记改为只按本页店名(≤ limit 条)反查 savings,不再把该用户全部下单 店名捞进内存跟 50 条记录取交集。 - 新增 (user_id, created_at, id) 复合索引:反向扫恰好等于列表的 ORDER BY created_at DESC, id DESC,PG 免排序直接取前 n 条。 迁移走 CREATE INDEX CONCURRENTLY,不阻塞线上 harvest 写入。 - keyword 转义 LIKE 通配符后再匹配,避免搜一个「%」把整表拉回来。 - nginx 对 application/json 开 gzip:此前 gzip off + gzip_types 只含 text/html + gzip_proxied off 三个默认值凑一起,等于所有接口都在裸奔;记录列表这种 字段名和中文店名高度重复的 JSON 压缩比稳定 8~10 倍。 Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: 左辰勇 Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/156 Co-authored-by: zuochenyong Co-committed-by: zuochenyong --- .../versions/comparison_user_created_idx.py | 52 +++++ app/api/v1/compare_record.py | 11 +- app/models/comparison.py | 4 + app/repositories/comparison.py | 91 +++++++- deploy/nginx/app-api.shaguabijia.com.conf | 12 ++ docs/api/compare/compare-records.md | 9 + tests/test_compare_record.py | 204 ++++++++++++++++++ 7 files changed, 374 insertions(+), 9 deletions(-) create mode 100644 alembic/versions/comparison_user_created_idx.py diff --git a/alembic/versions/comparison_user_created_idx.py b/alembic/versions/comparison_user_created_idx.py new file mode 100644 index 0000000..2c2da52 --- /dev/null +++ b/alembic/versions/comparison_user_created_idx.py @@ -0,0 +1,52 @@ +"""add composite index (user_id, created_at, id) on comparison_record + +C 端「我的比价记录」列表(GET /api/v1/compare/records)是 +`WHERE user_id=? ORDER BY created_at DESC, id DESC LIMIT n` —— 原来只有单列 user_id 索引, +过滤完还要把该用户的**全部**记录取出来排序才能拿前 n 条,重度用户随记录数线性变慢。 + +本复合索引的反向扫恰好等于 (created_at DESC, id DESC),规划器直接取前 n 条、免排序。 +列序 (user_id, created_at, id) 与查询一一对应,不要调整。 + +Revision ID: comparison_user_created_idx +Revises: merge_active_phone +Create Date: 2026-07-21 +""" + +from __future__ import annotations + +from alembic import op + +revision = "comparison_user_created_idx" +down_revision = "merge_active_phone" +branch_labels = None +depends_on = None + +INDEX_NAME = "ix_comparison_user_created" +COLUMNS = ["user_id", "created_at", "id"] + + +def upgrade() -> None: + bind = op.get_bind() + if bind.dialect.name == "postgresql": + # 线上 comparison_record 已有数据量,普通 CREATE INDEX 持表写锁会阻塞比价 harvest 写入; + # 用 CONCURRENTLY 不锁表(须脱离事务,autocommit_block 切到自动提交)。 + # 同 comparison_status_created_idx 的做法。 + with op.get_context().autocommit_block(): + op.create_index( + INDEX_NAME, "comparison_record", COLUMNS, + unique=False, postgresql_concurrently=True, + ) + else: + op.create_index(INDEX_NAME, "comparison_record", COLUMNS, unique=False) + + +def downgrade() -> None: + bind = op.get_bind() + if bind.dialect.name == "postgresql": + with op.get_context().autocommit_block(): + op.drop_index( + INDEX_NAME, table_name="comparison_record", + postgresql_concurrently=True, + ) + else: + op.drop_index(INDEX_NAME, table_name="comparison_record") diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index 1c34215..f1d67d6 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -115,13 +115,22 @@ def list_records( db: DbSession, limit: int = Query(20, ge=1, le=100), cursor: int | None = Query(None, description="上一页末条 id"), + ordered: bool | None = Query( + None, + description="true=只看「已下单」(店名命中本人真实下单)的记录;不传=全部", + ), + keyword: str | None = Query( + None, + max_length=64, + description="按店名 / 菜名模糊搜索,忽略大小写;空白串等同不传", + ), include_trace: bool = Query( False, description="客户端开了本机 agent 调试模式时带 true,放行本人记录的 trace_url", ), ) -> ComparisonRecordPage: items, next_cursor = crud_compare.list_records( - db, user.id, limit=limit, cursor=cursor + db, user.id, limit=limit, cursor=cursor, ordered=ordered, keyword=keyword ) outs = [ComparisonRecordOut.model_validate(it) for it in items] # 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它)。 diff --git a/app/models/comparison.py b/app/models/comparison.py index d4078e3..3628438 100644 --- a/app/models/comparison.py +++ b/app/models/comparison.py @@ -45,6 +45,10 @@ class ComparisonRecord(Base): # 首页轮播 / 省钱战绩聚合都按 status='success' 过滤 + created_at 近期排序; # 复合索引避免随数据量增大退化成全表扫(单列 created_at 索引不含 status)。 Index("ix_comparison_status_created", "status", "created_at"), + # C 端「我的比价记录」列表:WHERE user_id=? ORDER BY created_at DESC, id DESC LIMIT n。 + # 单列 user_id 索引只能过滤,排序仍要把该用户全部记录取出来排一遍;这条复合索引的**反向扫** + # 恰好等于 (created_at DESC, id DESC),PG 直接取前 n 条、免排序。列序不能动。 + Index("ix_comparison_user_created", "user_id", "created_at", "id"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) diff --git a/app/repositories/comparison.py b/app/repositories/comparison.py index 5f186aa..7dceeab 100644 --- a/app/repositories/comparison.py +++ b/app/repositories/comparison.py @@ -7,8 +7,8 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import func, select -from sqlalchemy.orm import Session +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session, defer from app.core.rewards import CN_TZ from app.models.ad_feed_reward import AdFeedRewardRecord @@ -375,19 +375,48 @@ def harvest_abort( return rec -def _ordered_shop_names(db: Session, user_id: int) -> set[str]: - """该用户「真实下单」(source='compare')覆盖到的店名集合,用来给比价记录打「已下单」。 +def _ordered_shop_name_select(user_id: int): + """该用户「真实下单」(source='compare')覆盖到的店名 select,给「已下单」筛选当子查询。 + + 口径与 [_ordered_shop_names] 完全一致,只是时机不同:那边是**拿到本页之后**按 candidates + 反查打标;这边是**分页之前**就要过滤,拿不到 candidates,只能整段下推成子查询。 + 没有先捞成集合再展开 IN (...) 字面量 —— 重度用户下单过的店名可能上千,展开会撞 SQLite + 的绑定变量上限,而且又变回了那个「随下单量线性变慢」的老写法。 + """ + return select(SavingsRecord.shop_name).where( + SavingsRecord.user_id == user_id, + SavingsRecord.source == "compare", + SavingsRecord.shop_name.is_not(None), + ) + + +def _like_escape(kw: str) -> str: + """转义 LIKE 通配符(百分号 / 下划线 / 反斜杠),让用户输入只按字面量匹配(配合 escape 参数)。 + + 不转义的话搜一个「%」就等于把整表拉回来。 + """ + return kw.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + +def _ordered_shop_names(db: Session, user_id: int, candidates: set[str]) -> set[str]: + """[candidates] 里哪些店名被该用户「真实下单」(source='compare')覆盖过,用来打「已下单」。 只认 compare(归因命中后真实上报),demo 演示数据不算。下单上报不带 trace_id, 只能按店名对齐——两边店名同源(都来自比价意图识别阶段的门店名 query),精确相等即视为同店。 语义=店级:同一家店比价过多次,这些记录会一并标「已下单」。 + + ⚠️ 只查**本页出现过的店名**(candidates ≤ limit 条),不再把该用户全部下单店名捞回内存: + 老写法随下单量线性增长,重度用户几千行全读一遍只为跟 50 条记录取交集。空集合直接返回 + (避免 IN () 非法)。 """ + if not candidates: + return set() rows = db.execute( select(SavingsRecord.shop_name).where( SavingsRecord.user_id == user_id, SavingsRecord.source == "compare", - SavingsRecord.shop_name.is_not(None), - ) + SavingsRecord.shop_name.in_(candidates), + ).distinct() ).scalars().all() return {s for s in rows if s} @@ -415,17 +444,60 @@ def _ad_coins_by_trace(db: Session, user_id: int, trace_ids: list[str]) -> dict[ return {tid: int(coin) for tid, coin in rows if tid} +# 列表出参(ComparisonRecordOut)根本不读、但 select(ORM) 默认会一并捞回来的重型 JSON 列: +# - raw_payload:done.params 上报体全量,**每条记录都有**(harvest 与 POST 两条写路径都落)。 +# 单条几 KB~几十 KB,一页 50 条就是稳定几百 KB~几 MB 的白读 + 白反序列化。 +# - llm_calls:每次 LLM 调用的 input_messages + output 全文。只有走老客户端 POST /compare/record +# 的记录才有(_backfill_llm_calls 回填;harvest 路径不落),但有的时候单条就能到 MB 级 —— 一页里 +# 混进几条这种记录,整个请求就被它们拖住。 +# - llm_price_snapshot:逐模型单价快照,同样只在回填时落。 +# 三列全部读出来再被 pydantic 丢掉,是「比价记录/全部记录」页慢的主要来源。 +# ⚠️ defer 的列一旦在别处被读到会触发**逐行**懒加载(N+1);列表这条链路(ComparisonRecordOut +# 不声明这三个字段 → 不会 getattr 到)是安全的。详情接口 get_record 不 defer,raw_payload 照常返回。 +_LIST_DEFERRED = ( + ComparisonRecord.raw_payload, + ComparisonRecord.llm_calls, + ComparisonRecord.llm_price_snapshot, +) + + def list_records( db: Session, user_id: int, *, limit: int = 20, cursor: int | None = None, + ordered: bool | None = None, + keyword: str | None = None, ) -> tuple[list[ComparisonRecord], int | None]: """比价记录分页(按创建时间倒序、id 兜底,游标式)。附「已下单」店级标记 + 「看广告赚的金币」(瞬态,不写库)。""" - stmt = select(ComparisonRecord).where(ComparisonRecord.user_id == user_id) + stmt = ( + select(ComparisonRecord) + .where(ComparisonRecord.user_id == user_id) + .options(*(defer(col) for col in _LIST_DEFERRED)) + ) if cursor is not None: stmt = stmt.where(ComparisonRecord.id < cursor) + # 「已下单」tab 与搜索框的过滤都下推到这里,不能留给客户端对整页结果 filter —— + # 分页之后一页里可能一条都不命中,列表看着就是空的/卡住的,得翻很多页才蹦出一条。 + if ordered: + stmt = stmt.where( + ComparisonRecord.store_name.in_(_ordered_shop_name_select(user_id)) + ) + kw = (keyword or "").strip() + if kw: + # product_names 是写路径从 items[].name 派生的普通文本列(items 本身是 JSON,SQLite 下 + # 中文被 ensure_ascii 转义,没法直接 LIKE)—— 搜「菜名」靠的就是它。 + # ilike:PG 原生 ILIKE,SQLite 渲染成 lower() LIKE lower(),两边都忽略大小写。 + pattern = f"%{_like_escape(kw)}%" + stmt = stmt.where( + or_( + ComparisonRecord.store_name.ilike(pattern, escape="\\"), + ComparisonRecord.product_names.ilike(pattern, escape="\\"), + ) + ) + # 排序与 ix_comparison_user_created(user_id, created_at, id)对齐 —— DESC/DESC 正好是该索引的 + # 反向扫,PG 免排序直接取前 limit 条。改排序方向前先想清楚索引还吃不吃得上。 stmt = stmt.order_by(ComparisonRecord.created_at.desc(), ComparisonRecord.id.desc()).limit(limit) items = list(db.execute(stmt).scalars().all()) @@ -433,7 +505,10 @@ def list_records( # 「已下单」标记:本页记录的 store_name 若落在该用户真实下单的店名集合里即 True。 # ordered / ad_coins_earned 均非 ORM 列,仅挂实例上供 ComparisonRecordOut(from_attributes) 读出,不持久化。 - ordered_shops = _ordered_shop_names(db, user_id) + page_shops = {it.store_name for it in items if it.store_name} + # ordered=True 时上面已按同一口径(_ordered_shop_name_select)筛过,本页必然全是已下单, + # 省掉这次反查;其余情况照旧按本页店名反查 savings。 + ordered_shops = page_shops if ordered else _ordered_shop_names(db, user_id, page_shops) # 「本次比价看广告赚的金币」:按本页 trace_id 一次性聚合(同 ordered 范式)。 ad_coins = _ad_coins_by_trace(db, user_id, [it.trace_id for it in items]) for it in items: diff --git a/deploy/nginx/app-api.shaguabijia.com.conf b/deploy/nginx/app-api.shaguabijia.com.conf index 4e72656..9f7ec5a 100644 --- a/deploy/nginx/app-api.shaguabijia.com.conf +++ b/deploy/nginx/app-api.shaguabijia.com.conf @@ -25,6 +25,18 @@ server { # (纯文字反馈体积小、不受影响 → 呈现为「时好时坏」)。根治仍需客户端上传前压缩。 client_max_body_size 32m; + # JSON 响应压缩。nginx 默认 gzip off,且就算 on 了 gzip_types 也只含 text/html、 + # gzip_proxied 默认 off(反代来的响应一律不压)—— 三个默认值凑一起 = 我们所有接口都在裸奔。 + # 比价记录列表这种一次 50 条、字段名 + 中文店名/菜名高度重复的 JSON,gzip 压缩比稳定在 8~10 倍 + # (几百 KB → 几十 KB),弱网下省的就是首屏那几秒。 + # 只压 JSON:APK 直链(/media/shaguabijia.apk)、图片本身已是压缩格式,再压纯浪费 CPU。 + gzip on; + gzip_proxied any; # 反代响应也压(默认 off = 对我们这套反代等于没开) + gzip_types application/json; + gzip_min_length 1024; # 小响应压了反而更大(gzip 头开销),不值当 + gzip_comp_level 5; # 5 是体积/CPU 的常用折中点,再往上收益递减 + gzip_vary on; # 给 CDN/中间缓存正确按 Accept-Encoding 分桶 + location / { proxy_pass http://127.0.0.1:8770; proxy_http_version 1.1; diff --git a/docs/api/compare/compare-records.md b/docs/api/compare/compare-records.md index b1051bf..93e6e42 100644 --- a/docs/api/compare/compare-records.md +++ b/docs/api/compare/compare-records.md @@ -10,6 +10,12 @@ |---|---|---|---|---| | `limit` | int | ❌ | 20 | 1–100 | | `cursor` | int | ❌ | null | 上一页末条 `id`,首页不传 | +| `ordered` | bool | ❌ | null | `true`=只出「已下单」(店名命中本人真实下单)的记录;不传=不筛 | +| `keyword` | string | ❌ | null | 按店名 / 菜名模糊搜索,忽略大小写,≤64 字符;纯空白等同不传 | +| `include_trace` | bool | ❌ | false | 客户端开了本机 agent 调试模式时带 `true`,放行**本人**记录的 `trace_url` | + +`ordered` / `keyword` 都在服务端过滤后再分页,客户端不要拿一页结果自己 filter —— +分页之后一页里可能一条都不命中,列表会看着像空的。 ## 出参 响应 `200`:`{ items: ComparisonRecordOut[], next_cursor: int|null }`(分页见 [索引#游标分页约定](./README.md#游标分页约定)) @@ -38,6 +44,9 @@ | `items` | object[] | 下单菜品 `{name, qty, specs?}` | | `comparison_results` | object[] | 逐平台对比(price 单位元,已按 rank 升序) | | `skipped_dish_names` | string[] | 被跳过的菜名 | +| `ordered` | bool | 「已下单」店级标记:店名命中本人 `source='compare'` 的下单记录即 `true`。**瞬态字段,不在表里**,每次查询现算 | +| `ad_coins_earned` | int | 本次比价看信息流广告实发的金币(按 `trace_id` 聚合)。同为瞬态字段 | +| `trace_url` | string \| null | pricebot 调试链接。未开 `debug_trace_enabled` 且未带 `include_trace=true` 时为 `null` | | `created_at` | datetime | 时间 | ## 错误 diff --git a/tests/test_compare_record.py b/tests/test_compare_record.py index 38548e1..f16798c 100644 --- a/tests/test_compare_record.py +++ b/tests/test_compare_record.py @@ -222,6 +222,210 @@ def test_stats_compare_count_and_saved(client) -> None: assert s2["compare_count"] == 2 # 仍 2(failed 不计) +def test_records_ordered_flag(client) -> None: + """「已下单」店级标记:店名命中该用户 source='compare' 的下单记录才 True。 + + 覆盖 list_records 只按**本页店名**反查 savings 的写法(原来是把该用户全部下单店名捞回内存 + 再取交集,随下单量线性变慢)——两种写法结果必须一致,故这里按店名逐条断言。 + """ + token = _login(client, "13800002010") + + # 两条比价记录:一条海底捞(稍后会有对应下单),一条没下过单的店 + client.post("/api/v1/compare/record", json=_food_payload("ord-1"), headers=_auth(token)) + other = _food_payload("ord-2") + other["store_name"] = "没下过单的店" + client.post("/api/v1/compare/record", json=other, headers=_auth(token)) + + # 下单前:两条都不该带「已下单」 + items = client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"] + assert {it["store_name"]: it["ordered"] for it in items} == { + "海底捞(朝阳店)": False, + "没下过单的店": False, + } + + # 对海底捞真实下单一笔(order/report 写 source='compare' 的 savings_record) + r = client.post( + "/api/v1/order/report", + json={ + "client_event_id": "evt-ordered-flag", + "platform": "美团", + "platform_package": "com.sankuai.meituan", + "pay_channel": "wechat", + "compared_price_cents": 12350, + "paid_amount_cents": 12350, + "shop_name": "海底捞(朝阳店)", + "original_price_cents": 12850, + }, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + + # 下单后:只有同店名那条翻成 True,另一条不受影响 + items = client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"] + assert {it["store_name"]: it["ordered"] for it in items} == { + "海底捞(朝阳店)": True, + "没下过单的店": False, + } + + # 别人的下单不该影响本人标记(_ordered_shop_names 按 user_id 过滤) + token_b = _login(client, "13800002011") + client.post("/api/v1/compare/record", json=_food_payload("ord-b"), headers=_auth(token_b)) + items_b = client.get("/api/v1/compare/records", headers=_auth(token_b)).json()["items"] + assert [it["ordered"] for it in items_b] == [False] + + +def test_records_list_omits_raw_payload(client) -> None: + """列表出参不含 raw_payload(仓库层 defer 掉了重型 JSON 列);详情接口照常返回。 + + defer 的列一旦被 ORM 实例读到会触发逐行懒加载(N+1),而列表 schema 本就不该带 raw_payload + —— 这条同时守住「列表不泄露上报体全量」和「没人不小心把它加回出参」。 + """ + token = _login(client, "13800002012") + rid = client.post( + "/api/v1/compare/record", json=_food_payload("no-raw"), headers=_auth(token) + ).json()["id"] + + items = client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"] + assert len(items) == 1 + assert "raw_payload" not in items[0] + # 概要字段照常齐全(defer 没误伤列表要用的列) + assert items[0]["store_name"] == "海底捞(朝阳店)" + assert items[0]["best_platform_id"] == "meituan" + assert items[0]["comparison_results"] and items[0]["items"] + + # 详情不 defer:raw_payload 全量还在 + d = client.get(f"/api/v1/compare/records/{rid}", headers=_auth(token)).json() + assert d["raw_payload"]["trace_id"] == "no-raw" + + +def test_records_ordered_filter(client) -> None: + """ordered=true 只出「已下单」的记录,且过滤结果自身能翻页。 + + 这个筛选必须在服务端做:客户端早先是对「已经拉回来的那一页」做 filter,分页之后一页里 + 很可能一条已下单都没有 ——「已下单」tab 就会看着像空的,得手动翻很多页才蹦出一条。 + """ + token = _login(client, "13800002013") + + # 3 条「下过单的店」+ 2 条没下过单的店,交错写入,确保过滤不是靠顺序碰巧对上 + for i in range(3): + p = _food_payload(f"of-ordered-{i}") + p["store_name"] = "下过单的店" + client.post("/api/v1/compare/record", json=p, headers=_auth(token)) + if i < 2: + q = _food_payload(f"of-plain-{i}") + q["store_name"] = "没下过单的店" + client.post("/api/v1/compare/record", json=q, headers=_auth(token)) + + client.post( + "/api/v1/order/report", + json={ + "client_event_id": "evt-ordered-filter", + "platform": "美团", + "platform_package": "com.sankuai.meituan", + "pay_channel": "wechat", + "compared_price_cents": 12350, + "paid_amount_cents": 12350, + "shop_name": "下过单的店", + "original_price_cents": 12850, + }, + headers=_auth(token), + ) + + # 不传 ordered:5 条全出(「全部记录」tab 口径不变) + assert len(client.get("/api/v1/compare/records", headers=_auth(token)).json()["items"]) == 5 + + # ordered=true:只出那 3 条,且每条都自带 ordered=True + page = client.get("/api/v1/compare/records?ordered=true", headers=_auth(token)).json() + assert [it["store_name"] for it in page["items"]] == ["下过单的店"] * 3 + assert all(it["ordered"] for it in page["items"]) + assert page["next_cursor"] is None + + # 游标只在「已下单」集合内走 —— 不会把没下单的记录算进一页的 limit 里 + p1 = client.get( + "/api/v1/compare/records?ordered=true&limit=2", headers=_auth(token) + ).json() + assert len(p1["items"]) == 2 + assert p1["next_cursor"] is not None + p2 = client.get( + f"/api/v1/compare/records?ordered=true&limit=2&cursor={p1['next_cursor']}", + headers=_auth(token), + ).json() + assert [it["store_name"] for it in p2["items"]] == ["下过单的店"] + # 两页不重叠,合起来正好 3 条 + assert len({it["id"] for it in p1["items"] + p2["items"]}) == 3 + + # 别人的下单不该让本人记录进「已下单」 + token_b = _login(client, "13800002014") + pb = _food_payload("of-b") + pb["store_name"] = "下过单的店" + client.post("/api/v1/compare/record", json=pb, headers=_auth(token_b)) + assert client.get( + "/api/v1/compare/records?ordered=true", headers=_auth(token_b) + ).json()["items"] == [] + + +def test_records_keyword_search(client) -> None: + """keyword 按店名 / 菜名模糊搜(忽略大小写),LIKE 通配符只当字面量;搜索结果也能翻页。 + + 菜名走写路径派生的 product_names 文本列 —— items 是 JSON,SQLite 下中文被 ensure_ascii + 转义,直接 LIKE 搜不到。 + """ + token = _login(client, "13800002015") + + b = _food_payload("kw-b") + b["store_name"] = "Pizza Hut" + b["items"] = [{"name": "榴莲比萨", "qty": 1}] + client.post("/api/v1/compare/record", json=b, headers=_auth(token)) + + c = _food_payload("kw-c") + c["store_name"] = "100%纯牛肉汉堡" + c["items"] = [{"name": "双层牛肉堡", "qty": 1}] + client.post("/api/v1/compare/record", json=c, headers=_auth(token)) + + # 默认 payload 的店名是「海底捞(朝阳店)」 + client.post("/api/v1/compare/record", json=_food_payload("kw-a"), headers=_auth(token)) + + def _search(kw: str, **extra) -> list[str]: + r = client.get( + "/api/v1/compare/records", + params={"keyword": kw, **extra}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + return [it["store_name"] for it in r.json()["items"]] + + assert _search("海底捞") == ["海底捞(朝阳店)"] # 店名命中 + assert _search("榴莲") == ["Pizza Hut"] # 菜名命中(product_names) + assert _search("pizza") == ["Pizza Hut"] # 忽略大小写 + assert _search("PIZZA") == ["Pizza Hut"] + assert _search("不存在的店") == [] # 没命中就是空 + # 通配符只当普通字符:搜 % 不该把整表拉回来,搜 _ 也不该匹配任意单字符 + assert _search("%") == ["100%纯牛肉汉堡"] + assert _search("_") == [] + # 纯空白等同不传 → 不过滤 + assert len(_search(" ")) == 3 + + # 搜索结果自身可翻页 + for i in range(3): + p = _food_payload(f"kw-page-{i}") + p["store_name"] = "连锁烤鱼店" + client.post("/api/v1/compare/record", json=p, headers=_auth(token)) + p1 = client.get( + "/api/v1/compare/records", + params={"keyword": "烤鱼", "limit": 2}, + headers=_auth(token), + ).json() + assert len(p1["items"]) == 2 + assert p1["next_cursor"] is not None + p2 = client.get( + "/api/v1/compare/records", + params={"keyword": "烤鱼", "limit": 2, "cursor": p1["next_cursor"]}, + headers=_auth(token), + ).json() + assert [it["store_name"] for it in p2["items"]] == ["连锁烤鱼店"] + assert len({it["id"] for it in p1["items"] + p2["items"]}) == 3 + + def test_requires_auth(client) -> None: """不带 token 统一 401。""" assert client.post("/api/v1/compare/record", json={"trace_id": "t"}).status_code == 401 From fda82fe313a279f00c9c637ffaa62f986bdf92ca Mon Sep 17 00:00:00 2001 From: linkeyu Date: Wed, 22 Jul 2026 17:22:09 +0800 Subject: [PATCH 14/42] =?UTF-8?q?=E5=90=8E=E5=8F=B0=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=9B=91=E6=8E=A7=E5=AE=A1=E8=AE=A1=E6=9D=83=E9=99=90?= =?UTF-8?q?=E5=88=86=E7=BB=84=E5=B9=B6=E5=8A=A0=E5=BC=BA=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E9=89=B4=E6=9D=83=20(#159)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 变更 - 权限目录新增一级分组“监控审计”,统一设备存活、埋点成功率、埋点日志和审计日志。 - 补齐 `analytics-health` 页面权限,技术角色默认拥有四项监控审计权限;运营默认仅保留设备存活。 - 新增服务端 `require_page` 守卫,四组 API 不再只依赖前端隐藏导航,直接调用也会校验角色或个人页面权限。 - 增加迁移,为存量技术角色补上 `analytics-health` 权限,并同步接口文档。 ## 验证 - 改动文件 `ruff check` 通过。 - `tests/test_admin_roles.py tests/test_analytics_health.py`: 24 passed。 - Alembic 从空库 upgrade 到 head,再 downgrade 本迁移:通过。 - 全量测试:443 passed、6 failed;6 项失败在干净 `origin/main` 上原样复现(主干为 442 passed、6 failed),与本 PR 无关。 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/159 Co-authored-by: linkeyu Co-committed-by: linkeyu --- alembic/versions/monitoring_audit_rbac.py | 56 +++++++++++++++++++ app/admin/deps.py | 29 ++++++++++ app/admin/permissions.py | 11 ++-- app/admin/routers/analytics_health.py | 4 +- app/admin/routers/audit.py | 6 +-- app/admin/routers/device_liveness.py | 6 +-- app/admin/routers/event_logs.py | 4 +- docs/api/admin/admin-audit-logs.md | 5 +- docs/api/admin/admin-device-liveness.md | 3 +- docs/api/admin/admin-event-logs.md | 3 +- tests/test_admin_roles.py | 65 ++++++++++++++++++++++- 11 files changed, 172 insertions(+), 20 deletions(-) create mode 100644 alembic/versions/monitoring_audit_rbac.py diff --git a/alembic/versions/monitoring_audit_rbac.py b/alembic/versions/monitoring_audit_rbac.py new file mode 100644 index 0000000..397c067 --- /dev/null +++ b/alembic/versions/monitoring_audit_rbac.py @@ -0,0 +1,56 @@ +"""补齐监控审计页面权限。 + +Revision ID: monitoring_audit_rbac +Revises: merge_signin_boost_main +Create Date: 2026-07-22 00:00:00.000000 +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision: str = "monitoring_audit_rbac" +down_revision: str | Sequence[str] | None = "merge_signin_boost_main" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql") +_PAGE = "analytics-health" + + +def _role_table() -> sa.TableClause: + return sa.table( + "admin_role", + sa.column("name", sa.String), + sa.column("pages", _JSON), + ) + + +def upgrade() -> None: + role = _role_table() + conn = op.get_bind() + pages = conn.execute( + sa.select(role.c.pages).where(role.c.name == "tech") + ).scalar_one_or_none() + if pages is not None and _PAGE not in pages: + conn.execute( + role.update() + .where(role.c.name == "tech") + .values(pages=[*pages, _PAGE]) + ) + + +def downgrade() -> None: + role = _role_table() + conn = op.get_bind() + pages = conn.execute( + sa.select(role.c.pages).where(role.c.name == "tech") + ).scalar_one_or_none() + if pages is not None and _PAGE in pages: + conn.execute( + role.update() + .where(role.c.name == "tech") + .values(pages=[page for page in pages if page != _PAGE]) + ) diff --git a/app/admin/deps.py b/app/admin/deps.py index ee3d656..8d346f8 100644 --- a/app/admin/deps.py +++ b/app/admin/deps.py @@ -10,6 +10,8 @@ from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session +from app.admin.permissions import ALL_PAGE_KEYS, CUSTOM_ROLE, SUPER_ADMIN_ROLE, sanitize_pages +from app.admin.repositories import admin_role as role_repo from app.admin.repositories import admin_user as admin_repo from app.admin.security import AdminTokenError, decode_admin_token from app.db.session import get_db @@ -72,6 +74,33 @@ def require_role(*roles: str): return _checker +def require_page(page: str): + """页面权限守卫依赖工厂。 + + 左侧导航隐藏只是 UI,这个守卫确保直接调用 API 也必须持有对应页面权限。 + super_admin 恒通过;custom 读个人 pages_override;其余角色读 admin_role.pages。 + """ + if page not in ALL_PAGE_KEYS: + raise ValueError(f"unknown admin page permission: {page}") + + def _checker(admin: CurrentAdmin, db: AdminDb) -> AdminUser: + if admin.role == SUPER_ADMIN_ROLE: + return admin + pages = ( + sanitize_pages(admin.pages_override) + if admin.role == CUSTOM_ROLE + else role_repo.effective_pages_of(db, admin.role) + ) + if page not in pages: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"page '{page}' not allowed", + ) + return admin + + return _checker + + def get_client_ip(request: Request) -> str: """取客户端 IP(审计日志用)。生产经 nginx 反代,优先 X-Forwarded-For 第一段;否则直连 IP。 diff --git a/app/admin/permissions.py b/app/admin/permissions.py index c0b4829..837e4be 100644 --- a/app/admin/permissions.py +++ b/app/admin/permissions.py @@ -21,7 +21,6 @@ PERMISSION_CATALOG: list[dict] = [ {"key": "ad-revenue-report", "label": "广告收益"}, {"key": "comparison-records", "label": "比价记录"}, {"key": "cps", "label": "CPS收益"}, - {"key": "device-liveness", "label": "设备存活"}, ]}, {"group": "奖励审核", "pages": [ {"key": "withdraws", "label": "提现审核"}, @@ -34,11 +33,15 @@ PERMISSION_CATALOG: list[dict] = [ {"key": "huawei-review", "label": "华为审核开关"}, {"key": "users", "label": "用户管理"}, ]}, - {"group": "其他", "pages": [ - {"key": "admins", "label": "权限管理"}, + {"group": "监控审计", "pages": [ + {"key": "device-liveness", "label": "设备存活"}, + {"key": "analytics-health", "label": "埋点成功率"}, {"key": "event-logs", "label": "埋点日志"}, {"key": "audit-logs", "label": "审计日志"}, ]}, + {"group": "其他", "pages": [ + {"key": "admins", "label": "权限管理"}, + ]}, ] # 全部页面 key(super_admin 有效可见 = 此全集;也用于校验角色 pages 合法性) @@ -58,7 +61,7 @@ BUILTIN_ROLES: list[dict] = [ "dashboard", "ad-revenue-report", "cps", "withdraws", ]}, {"name": "tech", "label": "技术", "pages": [ - "dashboard", "device-liveness", "config", "ad-revenue", "huawei-review", + "dashboard", "device-liveness", "analytics-health", "config", "ad-revenue", "huawei-review", "event-logs", "audit-logs", ]}, ] diff --git a/app/admin/routers/analytics_health.py b/app/admin/routers/analytics_health.py index 517feb3..a3bb85f 100644 --- a/app/admin/routers/analytics_health.py +++ b/app/admin/routers/analytics_health.py @@ -6,7 +6,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, Query -from app.admin.deps import AdminDb, get_current_admin +from app.admin.deps import AdminDb, require_page from app.admin.repositories import analytics_health as repo from app.admin.schemas.analytics_health import ( HealthBreakdownRow, @@ -17,7 +17,7 @@ from app.admin.schemas.analytics_health import ( router = APIRouter( prefix="/admin/api/analytics-health", tags=["admin-analytics-health"], - dependencies=[Depends(get_current_admin)], + dependencies=[Depends(require_page("analytics-health"))], ) diff --git a/app/admin/routers/audit.py b/app/admin/routers/audit.py index dd7cace..3e35609 100644 --- a/app/admin/routers/audit.py +++ b/app/admin/routers/audit.py @@ -1,11 +1,11 @@ -"""admin 操作审计日志查询(所有 admin 可看:谁在何时对什么做了什么)。""" +"""admin 操作审计日志查询(需要 audit-logs 页面权限)。""" from __future__ import annotations from typing import Annotated from fastapi import APIRouter, Depends, Query -from app.admin.deps import AdminDb, get_current_admin +from app.admin.deps import AdminDb, require_page from app.admin.repositories import audit_log as audit_repo from app.admin.schemas.admin import AdminAuditLogOut from app.admin.schemas.common import CursorPage @@ -13,7 +13,7 @@ from app.admin.schemas.common import CursorPage router = APIRouter( prefix="/admin/api/audit-logs", tags=["admin-audit"], - dependencies=[Depends(get_current_admin)], + dependencies=[Depends(require_page("audit-logs"))], ) diff --git a/app/admin/routers/device_liveness.py b/app/admin/routers/device_liveness.py index 879697f..c48f86c 100644 --- a/app/admin/routers/device_liveness.py +++ b/app/admin/routers/device_liveness.py @@ -2,7 +2,7 @@ 数据源 device_liveness 表(心跳 last_heartbeat_at + liveness_state + kill_alert_pending, 见 app/models/device.py)。在线/掉线、掉线时长由 repo 按 HEARTBEAT_TIMEOUT_MINUTES 阈值派生。 -纯读:无写、无审计。任意登录管理员可看(同大盘/设备管理,无角色门)。 +纯读:无写、无审计。需要 device-liveness 页面权限。 """ from __future__ import annotations @@ -10,7 +10,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, Query -from app.admin.deps import AdminDb, get_current_admin +from app.admin.deps import AdminDb, require_page from app.admin.repositories import queries from app.admin.schemas.common import CursorPage from app.admin.schemas.device import DeviceLivenessItem, DeviceLivenessStats @@ -18,7 +18,7 @@ from app.admin.schemas.device import DeviceLivenessItem, DeviceLivenessStats router = APIRouter( prefix="/admin/api/device-liveness", tags=["admin-device-liveness"], - dependencies=[Depends(get_current_admin)], + dependencies=[Depends(require_page("device-liveness"))], ) diff --git a/app/admin/routers/event_logs.py b/app/admin/routers/event_logs.py index eceb155..bc4d008 100644 --- a/app/admin/routers/event_logs.py +++ b/app/admin/routers/event_logs.py @@ -6,7 +6,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, Query -from app.admin.deps import AdminDb, get_current_admin +from app.admin.deps import AdminDb, require_page from app.admin.repositories import queries from app.admin.schemas.analytics import AnalyticsEventOut from app.admin.schemas.common import CursorPage @@ -14,7 +14,7 @@ from app.admin.schemas.common import CursorPage router = APIRouter( prefix="/admin/api/event-logs", tags=["admin-event-logs"], - dependencies=[Depends(get_current_admin)], + dependencies=[Depends(require_page("event-logs"))], ) diff --git a/docs/api/admin/admin-audit-logs.md b/docs/api/admin/admin-audit-logs.md index 0ea9b19..fb7d800 100644 --- a/docs/api/admin/admin-audit-logs.md +++ b/docs/api/admin/admin-audit-logs.md @@ -1,6 +1,6 @@ # GET /admin/api/audit-logs — 审计日志(谁改了什么,游标分页) -> 所属:Admin·Audit 组(前缀 `/admin/api/audit-logs`) | 鉴权:Bearer admin_token(角色:任意已登录 admin) | [← 返回 API 索引](../README.md) +> 所属:Admin·Audit 组(前缀 `/admin/api/audit-logs`) | 鉴权:Bearer admin_token + `audit-logs` 页面权限 | [← 返回 API 索引](../README.md) ## 入参(query) | 字段 | 类型 | 必填 | 默认 | 说明 | @@ -29,7 +29,8 @@ ## 错误码 - `401` 未带 admin token / token 无效或过期 / 管理员被禁用 +- `403` 当前管理员没有 `audit-logs` 页面权限 ## 说明 -- 整组(`/admin/api/audit-logs`)守卫为 `get_current_admin`,任意已登录 admin 均可查看,无角色限制。 +- 整组(`/admin/api/audit-logs`)守卫为 `require_page("audit-logs")`,默认仅超级管理员和技术角色可查看,也可由超管给自定义角色授权。 - 审计日志只增不改不删,任何写操作经 `write_audit` 落一条。数据表见 [admin_audit_log](../database/admin_audit_log.md)。 diff --git a/docs/api/admin/admin-device-liveness.md b/docs/api/admin/admin-device-liveness.md index 2af7bfd..edb0c78 100644 --- a/docs/api/admin/admin-device-liveness.md +++ b/docs/api/admin/admin-device-liveness.md @@ -1,6 +1,6 @@ # /admin/api/device-liveness — 设备存活监控(#80) -> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin | 表 [device_liveness](../../database/device_liveness.md) | [← 返回 API 索引](../README.md) +> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin + `device-liveness` 页面权限 | 表 [device_liveness](../../database/device_liveness.md) | [← 返回 API 索引](../README.md) 无障碍保护存活的后台视角:哪些设备开过保护(`ever_protected`)、现在在线还是掉线(心跳超时,#107 起阈值 1 小时)、首次开启时间(`first_protected_at`)。 @@ -13,3 +13,4 @@ ## 说明 - 「在线」= `last_heartbeat_at` 距今 < 超时阈值;掉线召回链路(worker 置 `kill_alert_pending` → 客户端 pull)见表文档。 +- 无 `device-liveness` 页面权限时返回 `403`。 diff --git a/docs/api/admin/admin-event-logs.md b/docs/api/admin/admin-event-logs.md index 9dd0c69..d39c6e6 100644 --- a/docs/api/admin/admin-event-logs.md +++ b/docs/api/admin/admin-event-logs.md @@ -1,6 +1,6 @@ # /admin/api/event-logs — 埋点日志(#83) -> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin | 表 [analytics_event](../../database/analytics_event.md) | [← 返回 API 索引](../README.md) +> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin + `event-logs` 页面权限 | 表 [analytics_event](../../database/analytics_event.md) | [← 返回 API 索引](../README.md) 客户端埋点(`POST /api/v1/analytics/events` 批量上报)的后台检索页。 @@ -13,3 +13,4 @@ ## 说明 - 纯只读;无聚合报表(要分析导出后自己算)。 - 时间轴用 `client_ts`(事件真实发生时刻),入库时间受客户端攒批影响。 +- 无 `event-logs` 页面权限时返回 `403`。 diff --git a/tests/test_admin_roles.py b/tests/test_admin_roles.py index fc1603f..998a3f3 100644 --- a/tests/test_admin_roles.py +++ b/tests/test_admin_roles.py @@ -44,17 +44,78 @@ def operator_token() -> str: return _token("r_operator", "operator") +@pytest.fixture() +def tech_token() -> str: + return _token("r_tech", "tech") + + def _auth(t: str) -> dict: return {"Authorization": f"Bearer {t}"} def test_super_pages_all_operator_limited(admin_client, super_token, operator_token) -> None: su = admin_client.get("/admin/api/auth/me", headers=_auth(super_token)).json() - assert "admins" in su["pages"] and "dashboard" in su["pages"] # 超管全页 + assert "admins" in su["pages"] and "analytics-health" in su["pages"] # 超管全页 op = admin_client.get("/admin/api/auth/me", headers=_auth(operator_token)).json() assert "dashboard" in op["pages"] and "admins" not in op["pages"] # 运营看不到管理员页 +def test_monitoring_audit_catalog_and_api_permissions( + admin_client, super_token, operator_token, tech_token +) -> None: + catalog = admin_client.get( + "/admin/api/roles/catalog", headers=_auth(super_token) + ).json() + monitoring = next(group for group in catalog if group["group"] == "监控审计") + assert [page["key"] for page in monitoring["pages"]] == [ + "device-liveness", "analytics-health", "event-logs", "audit-logs", + ] + + # 运营默认只能查设备存活,不能绕过导航直调技术/审计接口。 + assert admin_client.get( + "/admin/api/device-liveness/stats", headers=_auth(operator_token) + ).status_code == 200 + for path in ( + "/admin/api/analytics-health/overview?date_from=2026-07-01T00:00:00Z&date_to=2026-07-02T00:00:00Z", + "/admin/api/event-logs", + "/admin/api/audit-logs", + ): + assert admin_client.get(path, headers=_auth(operator_token)).status_code == 403 + + # 技术角色默认拥有监控审计组全部四项权限。 + for path in ( + "/admin/api/device-liveness/stats", + "/admin/api/analytics-health/overview?date_from=2026-07-01T00:00:00Z&date_to=2026-07-02T00:00:00Z", + "/admin/api/event-logs", + "/admin/api/audit-logs", + ): + assert admin_client.get(path, headers=_auth(tech_token)).status_code == 200 + + +def test_custom_admin_api_permission_uses_pages_override(admin_client) -> None: + db = SessionLocal() + try: + admin = admin_repo.get_by_username(db, "r_monitoring_custom") + if admin is None: + admin = admin_repo.create_admin( + db, username="r_monitoring_custom", password="pass1234", role="custom" + ) + admin.password_hash = hash_password("pass1234") + admin.role = "custom" + admin.pages_override = ["event-logs"] + admin.status = "active" + db.commit() + finally: + db.close() + + token = admin_client.post( + "/admin/api/auth/login", + json={"username": "r_monitoring_custom", "password": "pass1234"}, + ).json()["access_token"] + assert admin_client.get("/admin/api/event-logs", headers=_auth(token)).status_code == 200 + assert admin_client.get("/admin/api/audit-logs", headers=_auth(token)).status_code == 403 + + def test_roles_endpoints_super_only(admin_client, super_token, operator_token) -> None: assert admin_client.get("/admin/api/roles", headers=_auth(super_token)).status_code == 200 assert admin_client.get("/admin/api/roles", headers=_auth(operator_token)).status_code == 403 @@ -123,7 +184,7 @@ def test_builtin_roles_labels_and_pages(admin_client, super_token) -> None: # 页集对齐 Prototypes/dashboard/permissions.md 的 ROLES assert set(roles["finance"]["pages"]) == {"dashboard", "ad-revenue-report", "cps", "withdraws"} assert set(roles["tech"]["pages"]) == { - "dashboard", "device-liveness", "config", "ad-revenue", "huawei-review", + "dashboard", "device-liveness", "analytics-health", "config", "ad-revenue", "huawei-review", "event-logs", "audit-logs", } From cb8e8ccc1d6fc175775b5d99efb50c1744126f92 Mon Sep 17 00:00:00 2001 From: linkeyu Date: Wed, 22 Jul 2026 17:38:51 +0800 Subject: [PATCH 15/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=BF=80?= =?UTF-8?q?=E5=8A=B1=E8=A7=86=E9=A2=91=E6=9C=AA=E5=AE=8C=E6=88=90=E6=97=B6?= =?UTF-8?q?=E9=A2=84=E4=BC=B0=E6=94=B6=E7=9B=8A=E5=BD=92=E9=9B=B6=20(#160)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题 激励视频在 onAdShow 时已经上报 eCPM,用户随后提前关闭或播放时长不足时,报表仍按 eCPM/1000 计入预估收益,导致明细、合计、趋势和分类统计虚高。 ## 修改 - reward_video 的 closed_early / too_short 有效预估收益统一归零 - capped / granted 保持原收益口径 - 更新 API 字段说明 - 增加明细、日汇总、小时汇总、类型汇总回归测试 ## 验证 - ruff check(本次修改文件)通过 - pytest tests/test_admin_ad_revenue_scope.py tests/test_admin.py -q:10 passed - 全量 pytest:489 passed,7 个失败已在未修改的 origin/main 基线复现,与本次改动无关 ## 关联前端 https://gitea.shaguabijia.com/WonderableAI/shaguabijia-admin-web/pulls/64 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/160 Co-authored-by: linkeyu Co-committed-by: linkeyu --- app/admin/repositories/ad_revenue.py | 9 ++++ app/admin/schemas/ad_revenue.py | 14 +++-- tests/test_admin_ad_revenue_scope.py | 81 ++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 4 deletions(-) diff --git a/app/admin/repositories/ad_revenue.py b/app/admin/repositories/ad_revenue.py index 6d39879..8aef9c6 100644 --- a/app/admin/repositories/ad_revenue.py +++ b/app/admin/repositories/ad_revenue.py @@ -81,6 +81,10 @@ def _date_range(date_from: str, date_to: str) -> list[str]: # ad_feed_reward_record,由 audit 内部按 ad_type 区分(feed 含历史 NULL,draw 仅 ad_type=="draw")。 _AUDIT_SCENES = {"reward_video", "feed", "draw"} +# 激励视频未满足有效播放条件时不计客户端预估收益。客户端仍会在 onAdShow +# 上报 eCPM,随后才在关闭时补报以下终态,因此必须在展示/发奖合并后修正收益。 +_ZERO_REVENUE_REWARD_VIDEO_STATUSES = frozenset({"closed_early", "too_short"}) + # 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。 _REWARD_DETAIL_KEYS = ( @@ -202,6 +206,11 @@ def ad_revenue_report( "matched": bool(rwd["matched"]), "reward_detail": _reward_detail(rwd), }) + if ( + rec.ad_type == "reward_video" + and rwd["status"] in _ZERO_REVENUE_REWARD_VIDEO_STATUSES + ): + ev["revenue_yuan"] = 0.0 else: # 纯展示(信息流逐条展示、激励视频缺发奖记录):不计对账,matched=True。 ev.update({ diff --git a/app/admin/schemas/ad_revenue.py b/app/admin/schemas/ad_revenue.py index 6f9f49a..8143eff 100644 --- a/app/admin/schemas/ad_revenue.py +++ b/app/admin/schemas/ad_revenue.py @@ -26,7 +26,10 @@ class AdRevenueRecord(BaseModel): record_id: int created_at: datetime - status: str = Field(..., description="granted / capped / ecpm_missing") + status: str = Field( + ..., + description="granted / capped / ecpm_missing / closed_early / too_short", + ) ecpm: str | None = Field(None, description="本次采用的 eCPM 原始值(分/千次展示)") ecpm_factor: float | None = Field(None, description="因子1(eCPM 档);非 granted 为空") units: int = Field(..., description="折算份数:激励视频恒 1;信息流 = 满 10 秒份数") @@ -44,7 +47,7 @@ class AdRevenueDaily(BaseModel): date: str = Field(..., description="北京时间 YYYY-MM-DD") impressions: int = Field(..., description="当天展示条数合计") - revenue_yuan: float = Field(..., description="当天客户端预估收益合计(元;eCPM 折算)") + revenue_yuan: float = Field(..., description="当天客户端有效预估收益合计(元;eCPM 折算)") pangle_revenue_yuan: float | None = Field( None, description="当天穿山甲后台预估收益(元;GroMore revenue);非全量视图/无数据为空" ) @@ -93,7 +96,10 @@ class AdRevenueRow(BaseModel): has_impression: bool = Field(..., description="是否有广告展示(信息流逐条展示=True,纯发奖行=False)") impressions: int = Field(..., description="本行展示条数:有展示=1 / 纯发奖=0(供日汇总、趋势图复用)") ecpm: str | None = Field(None, description="eCPM 原始值(分/千次);展示行取展示值,纯发奖行取发奖采用值") - revenue_yuan: float = Field(..., description="本次展示预估收益(元)= eCPM元 ÷ 1000;纯发奖行=0") + revenue_yuan: float = Field( + ..., + description="本次有效展示预估收益(元)= eCPM元 ÷ 1000;纯发奖、激励视频提前关闭/时长不足=0", + ) row_revenue_yuan: float | None = Field( None, description="主表逐行展示用的预估收益(元):一次比价/领券聚合行=该次发奖广告 eCPM 折算之和;" @@ -150,7 +156,7 @@ class AdRevenueReportOut(BaseModel): total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)") truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)") total_impressions: int = Field(..., description="全量展示条数合计") - total_revenue_yuan: float = Field(..., description="全量客户端预估收益合计(元;eCPM 折算)") + total_revenue_yuan: float = Field(..., description="全量客户端有效预估收益合计(元;eCPM 折算)") total_pangle_revenue_yuan: float | None = Field( None, description="全量穿山甲后台预估收益合计(元;GroMore revenue)。穿山甲无用户/类型/场景维度," diff --git a/tests/test_admin_ad_revenue_scope.py b/tests/test_admin_ad_revenue_scope.py index b01c92a..f495d66 100644 --- a/tests/test_admin_ad_revenue_scope.py +++ b/tests/test_admin_ad_revenue_scope.py @@ -9,9 +9,11 @@ from app.admin.repositories import ad_revenue from app.db.session import SessionLocal from app.models.ad_ecpm import AdEcpmRecord from app.models.ad_pangle_revenue import AdPangleDailyRevenue +from app.models.ad_reward import AdRewardRecord from app.models.user import User REPORT_DATE = "2040-02-03" +PLAYBACK_DATE = "2040-02-04" def test_business_scope_filters_client_and_pangle_by_env_and_code(monkeypatch) -> None: @@ -127,3 +129,82 @@ def test_business_scope_filters_client_and_pangle_by_env_and_code(monkeypatch) - db.execute(delete(User).where(User.phone == "18800009991")) db.commit() db.close() + + +def test_reward_video_incomplete_playback_has_zero_revenue() -> None: + db = SessionLocal() + phone = "18800009992" + sessions = { + "closed_early": "rv-zero-closed", + "too_short": "rv-zero-short", + "capped": "rv-keep-capped", + "granted": "rv-keep-granted", + } + try: + user = User(phone=phone, username="29999999992", register_channel="sms") + db.add(user) + db.flush() + + for index, (status, session_id) in enumerate(sessions.items(), start=1): + created_at = datetime(2040, 2, 4, index, tzinfo=UTC) + db.add(AdEcpmRecord( + user_id=user.id, + ad_type="reward_video", + ad_session_id=session_id, + app_env="prod", + our_code_id="prod-reward", + ecpm_raw="10000", + report_date=PLAYBACK_DATE, + created_at=created_at, + )) + db.add(AdRewardRecord( + trans_id=f"{session_id}-trans", + user_id=user.id, + coin=0, + status=status, + reward_scene="reward_video", + ad_session_id=session_id, + app_env="prod", + our_code_id="prod-reward", + ecpm_raw="10000", + reward_date=PLAYBACK_DATE, + created_at=created_at, + )) + db.commit() + + result = ad_revenue.ad_revenue_report( + db, + date_from=PLAYBACK_DATE, + date_to=PLAYBACK_DATE, + user_id=user.id, + ad_type="reward_video", + app_env="prod", + revenue_scope="all", + granularity="hour", + ) + + revenue_by_status = {row["status"]: row["revenue_yuan"] for row in result["items"]} + assert revenue_by_status == { + "closed_early": 0.0, + "too_short": 0.0, + "capped": 0.1, + "granted": 0.1, + } + assert result["total_impressions"] == 4 + assert result["total_revenue_yuan"] == 0.2 + assert len(result["daily"]) == 1 + assert result["daily"][0]["date"] == PLAYBACK_DATE + assert result["daily"][0]["impressions"] == 4 + assert result["daily"][0]["revenue_yuan"] == 0.2 + assert sum(row["revenue_yuan"] for row in result["hourly"]) == 0.2 + assert result["type_stats"]["reward_video"] == { + "impressions": 4, + "revenue_yuan": 0.2, + } + finally: + db.rollback() + db.execute(delete(AdRewardRecord).where(AdRewardRecord.reward_date == PLAYBACK_DATE)) + db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.report_date == PLAYBACK_DATE)) + db.execute(delete(User).where(User.phone == phone)) + db.commit() + db.close() From 77f772f47cdc49e5a9a5a411ea5ec88c5afa7916 Mon Sep 17 00:00:00 2001 From: linkeyu Date: Thu, 23 Jul 2026 10:35:41 +0800 Subject: [PATCH 16/42] =?UTF-8?q?=E6=80=A7=E8=83=BD=EF=BC=9A=E6=AF=94?= =?UTF-8?q?=E4=BB=B7=E5=92=8C=E9=A2=86=E5=88=B8=E5=88=86=E4=BD=8D=E6=95=B0?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20PostgreSQL=20=E8=81=9A=E5=90=88=20(#161)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景 - 比价记录页和领券记录页前端已经只拉当前页,并直接展示后端 summary。 - 原后端仍会将筛选区间内的全部耗时值取回 Python 计算分位数,生产数据量增大后会放大数据库读取和应用内存开销。 ## 修改内容 - 比价记录:成功耗时 P5/P50/P95/P99、平均耗时以及中途退出耗时 P5/P50/P95 改用 PostgreSQL percentile_cont/AVG 聚合。 - 领券记录:发起数、完成数、平均耗时和完成耗时 P5/P50/P95/P99 合并为一条 PostgreSQL 聚合查询。 - 日期、用户、环境、状态、店铺和商品等筛选条件继续与列表共用,统计口径不变。 - SQLite 不支持 percentile_cont,仅在本地和测试环境回退读取耗时单列;不加载完整业务记录。 - API 字段与前端展示保持不变,无需前端改动。 ## 验证 - 比价/领券及关联广告收益、点位、按券统计测试:26 passed。 - 本次涉及文件 ruff 检查通过。 - PostgreSQL SQL 编译测试确认使用 ordered-set percentile_cont 聚合。 - 全量测试:497 passed;8 个现有失败集中在邀请奖励、提现档位和代理转发等无关模块。 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/161 Co-authored-by: linkeyu Co-committed-by: linkeyu --- app/admin/repositories/coupon_data.py | 97 ++++++++++++++++++++----- app/admin/repositories/queries.py | 98 +++++++++++++++++++------- tests/test_comparison_admin_summary.py | 16 +++++ tests/test_coupon_platform_success.py | 57 ++++++++++++++- 4 files changed, 223 insertions(+), 45 deletions(-) diff --git a/app/admin/repositories/coupon_data.py b/app/admin/repositories/coupon_data.py index 422136e..5ae22d4 100644 --- a/app/admin/repositories/coupon_data.py +++ b/app/admin/repositories/coupon_data.py @@ -1,7 +1,7 @@ """admin「领券数据」看板聚合:发起/完成数、耗时均值与分位、按天/小时趋势、逐条明细。 -数据源 coupon_session(一次领券一行,客户端 /api/v1/coupon/session 两段上报)。量级不大,全量拉 -区间数据后 Python 聚合(分位 SQLite 无 percentile,统一 Python 算,PG 上也一致)。 +数据源 coupon_session(一次领券一行,客户端 /api/v1/coupon/session 两段上报)。生产 PostgreSQL +使用 percentile_cont 聚合耗时分位;SQLite 本地/测试环境回退读取耗时单列计算。 - 发起数 = 区间内全部 session(含 started/completed/failed/abandoned),= 流失统计的基数。 - 完成数 / 耗时均值 / 分位 = 仅 status==completed 子集(成功跑完才有可比的"领券耗时")。 - summary/daily/hourly/total 在全量上算,不受分页;items 为排序后当前页。 @@ -45,6 +45,75 @@ def _percentile(sorted_vals: list[int], q: float) -> int | None: return round(sorted_vals[lo] * (1 - frac) + sorted_vals[hi] * frac) +def _round_duration_ms(value) -> int | None: + """将数据库聚合结果按既有 Python round 口径转为整数毫秒。""" + if value is None: + return None + return int(round(value)) + + +def _coupon_summary_aggregate_stmt(conditions: list): + """PostgreSQL 汇总卡聚合语句;计数、均值与四个分位一次返回。""" + completed = CouponSession.status == "completed" + completed_elapsed = completed & CouponSession.elapsed_ms.is_not(None) + return select( + func.count(CouponSession.id), + func.sum(case((completed, 1), else_=0)), + func.avg(CouponSession.elapsed_ms).filter(completed_elapsed), + *( + func.percentile_cont(q) + .within_group(CouponSession.elapsed_ms) + .filter(completed_elapsed) + for q in (0.05, 0.5, 0.95, 0.99) + ), + ).where(*conditions) + + +def _coupon_summary_aggregates(db: Session, conditions: list) -> dict: + """汇总卡基础指标;生产 PG 全部在数据库内完成,SQLite 仅作测试回退。""" + if db.bind is not None and db.bind.dialect.name == "postgresql": + row = db.execute(_coupon_summary_aggregate_stmt(conditions)).one() + return { + "started_count": int(row[0] or 0), + "completed_count": int(row[1] or 0), + "avg_elapsed_ms": _round_duration_ms(row[2]), + "p5_ms": _round_duration_ms(row[3]), + "p50_ms": _round_duration_ms(row[4]), + "p95_ms": _round_duration_ms(row[5]), + "p99_ms": _round_duration_ms(row[6]), + } + + counts = db.execute( + select( + func.count(CouponSession.id), + func.sum(case((CouponSession.status == "completed", 1), else_=0)), + ).where(*conditions) + ).one() + # SQLite 没有 percentile_cont;本地/测试只回退读取耗时单列,不加载完整记录。 + completed_elapsed = list( + db.execute( + select(CouponSession.elapsed_ms) + .where( + *conditions, + CouponSession.status == "completed", + CouponSession.elapsed_ms.is_not(None), + ) + .order_by(CouponSession.elapsed_ms) + ).scalars() + ) + return { + "started_count": int(counts[0] or 0), + "completed_count": int(counts[1] or 0), + "avg_elapsed_ms": _round_duration_ms( + sum(completed_elapsed) / len(completed_elapsed) + ) if completed_elapsed else None, + "p5_ms": _percentile(completed_elapsed, 5), + "p50_ms": _percentile(completed_elapsed, 50), + "p95_ms": _percentile(completed_elapsed, 95), + "p99_ms": _percentile(completed_elapsed, 99), + } + + def _avg(vals: list[int]) -> int | None: return round(sum(vals) / len(vals)) if vals else None @@ -215,30 +284,22 @@ def coupon_data_report( if not user_ids: return _empty_result() - stmt = select(CouponSession).where( + conditions = [ CouponSession.started_date >= d_from, CouponSession.started_date <= d_to, - ) + ] if app_env is not None: - stmt = stmt.where(CouponSession.app_env == app_env) + conditions.append(CouponSession.app_env == app_env) if statuses: - stmt = stmt.where(CouponSession.status.in_(statuses)) + conditions.append(CouponSession.status.in_(statuses)) if user_ids is not None: - stmt = stmt.where(CouponSession.user_id.in_(user_ids)) + conditions.append(CouponSession.user_id.in_(user_ids)) + stmt = select(CouponSession).where(*conditions) rows = list(db.execute(stmt).scalars()) # ── 汇总卡 ── - completed_elapsed = sorted( - r.elapsed_ms for r in rows if r.status == "completed" and r.elapsed_ms is not None - ) summary = { - "started_count": len(rows), - "completed_count": sum(1 for r in rows if r.status == "completed"), - "avg_elapsed_ms": _avg(completed_elapsed), - "p5_ms": _percentile(completed_elapsed, 5), - "p50_ms": _percentile(completed_elapsed, 50), - "p95_ms": _percentile(completed_elapsed, 95), - "p99_ms": _percentile(completed_elapsed, 99), + **_coupon_summary_aggregates(db, conditions), **_success_rates(rows), } @@ -323,7 +384,7 @@ def coupon_data_report( "summary": summary, "daily": daily, "hourly": hourly, - "total": len(rows), + "total": summary["started_count"], "items": items, } diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index 26a36db..33b510d 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -6,6 +6,7 @@ from __future__ import annotations from datetime import date, datetime, time, timedelta, timezone +from decimal import ROUND_HALF_UP, Decimal from zoneinfo import ZoneInfo from sqlalchemy import Select, asc, case, desc, func, or_, select @@ -297,6 +298,58 @@ def _comparison_percentile(sorted_values: list[int], q: float) -> int | None: return int(value + 0.5) +def _round_duration_ms(value) -> int | None: + """将数据库聚合结果按既有口径四舍五入为整数毫秒。""" + if value is None: + return None + return int(Decimal(str(value)).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + + +def _comparison_duration_aggregate_stmt(conditions: list, status: str, quantiles: tuple[float, ...]): + """PostgreSQL 耗时聚合语句;每种状态只返回一行。""" + return select( + func.avg(ComparisonRecord.total_ms), + *( + func.percentile_cont(q).within_group(ComparisonRecord.total_ms) + for q in quantiles + ), + ).where( + *conditions, + ComparisonRecord.status == status, + ComparisonRecord.total_ms.is_not(None), + ) + + +def _comparison_duration_aggregates( + db: Session, + *, + conditions: list, + status: str, + quantiles: tuple[float, ...], +) -> list[int | None]: + """返回平均值和各分位数;生产 PG 在数据库内聚合,SQLite 仅作测试回退。""" + if db.bind is not None and db.bind.dialect.name == "postgresql": + row = db.execute( + _comparison_duration_aggregate_stmt(conditions, status, quantiles) + ).one() + return [_round_duration_ms(value) for value in row] + + # SQLite 没有 percentile_cont;本地/测试只回退读取耗时单列,不加载完整记录。 + values = list( + db.execute( + select(ComparisonRecord.total_ms) + .where( + *conditions, + ComparisonRecord.status == status, + ComparisonRecord.total_ms.is_not(None), + ) + .order_by(ComparisonRecord.total_ms) + ).scalars() + ) + average = _round_duration_ms(sum(values) / len(values)) if values else None + return [average, *(_comparison_percentile(values, q) for q in quantiles)] + + def comparison_records_summary( db: Session, *, @@ -332,20 +385,18 @@ def comparison_records_summary( success = int(row[2] or 0) lower_price = int(row[4] or 0) cancelled = int(row[5] or 0) - success_durations = sorted(db.execute( - select(ComparisonRecord.total_ms).where( - *conditions, - ComparisonRecord.status == "success", - ComparisonRecord.total_ms.is_not(None), - ) - ).scalars().all()) - cancelled_durations = sorted(db.execute( - select(ComparisonRecord.total_ms).where( - *conditions, - ComparisonRecord.status == "cancelled", - ComparisonRecord.total_ms.is_not(None), - ) - ).scalars().all()) + success_duration_stats = _comparison_duration_aggregates( + db, + conditions=conditions, + status="success", + quantiles=(0.05, 0.5, 0.95, 0.99), + ) + cancelled_duration_stats = _comparison_duration_aggregates( + db, + conditions=conditions, + status="cancelled", + quantiles=(0.05, 0.5, 0.95), + ) success_rate_denominator = started - cancelled return { "started": started, @@ -354,19 +405,16 @@ def comparison_records_summary( "success_rate": success / success_rate_denominator if success_rate_denominator else None, "avg_token_cost": float(row[3]) if row[3] is not None else None, "lower_price_rate": lower_price / success if success else None, - "avg_duration_ms": ( - int(sum(success_durations) / len(success_durations) + 0.5) - if success_durations else None - ), - "p5_duration_ms": _comparison_percentile(success_durations, 0.05), - "p50_duration_ms": _comparison_percentile(success_durations, 0.5), - "p95_duration_ms": _comparison_percentile(success_durations, 0.95), - "p99_duration_ms": _comparison_percentile(success_durations, 0.99), + "avg_duration_ms": success_duration_stats[0], + "p5_duration_ms": success_duration_stats[1], + "p50_duration_ms": success_duration_stats[2], + "p95_duration_ms": success_duration_stats[3], + "p99_duration_ms": success_duration_stats[4], "cancelled": cancelled, "cancelled_rate": cancelled / started if started else None, - "cancelled_p5_ms": _comparison_percentile(cancelled_durations, 0.05), - "cancelled_p50_ms": _comparison_percentile(cancelled_durations, 0.5), - "cancelled_p95_ms": _comparison_percentile(cancelled_durations, 0.95), + "cancelled_p5_ms": cancelled_duration_stats[1], + "cancelled_p50_ms": cancelled_duration_stats[2], + "cancelled_p95_ms": cancelled_duration_stats[3], } diff --git a/tests/test_comparison_admin_summary.py b/tests/test_comparison_admin_summary.py index 5006fd6..2f86e24 100644 --- a/tests/test_comparison_admin_summary.py +++ b/tests/test_comparison_admin_summary.py @@ -4,12 +4,28 @@ from __future__ import annotations from datetime import UTC, date, datetime import pytest +from sqlalchemy.dialects import postgresql from app.admin.repositories import queries from app.db.session import SessionLocal from app.models.comparison import ComparisonRecord +def test_postgresql_duration_summary_uses_ordered_set_aggregates() -> None: + stmt = queries._comparison_duration_aggregate_stmt( + [], "success", (0.05, 0.5, 0.95, 0.99) + ) + sql = str( + stmt.compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ) + + assert sql.count("percentile_cont") == 4 + assert "comparison_record.status = 'success'" in sql + + def test_summary_uses_only_success_durations_and_filters_beijing_date() -> None: db = SessionLocal() try: diff --git a/tests/test_coupon_platform_success.py b/tests/test_coupon_platform_success.py index adc04a9..89ecf9b 100644 --- a/tests/test_coupon_platform_success.py +++ b/tests/test_coupon_platform_success.py @@ -7,8 +7,12 @@ from __future__ import annotations from datetime import UTC, date, datetime from sqlalchemy import delete, func, select +from sqlalchemy.dialects import postgresql -from app.admin.repositories.coupon_data import coupon_data_report +from app.admin.repositories.coupon_data import ( + _coupon_summary_aggregate_stmt, + coupon_data_report, +) from app.db.session import SessionLocal from app.models.coupon_state import CouponSession from app.repositories.coupon_state import ( @@ -19,7 +23,12 @@ from app.repositories.coupon_state import ( def _agg_session( - trace: str, platforms, platform_success, *, status: str = "completed" + trace: str, + platforms, + platform_success, + *, + status: str = "completed", + elapsed_ms: int | None = None, ) -> CouponSession: """构造一条聚合测试用 session(started_date 固定 2020-01-02、app_env=prod,不 commit)。""" return CouponSession( @@ -31,9 +40,53 @@ def _agg_session( platform_success=platform_success, started_at=datetime(2020, 1, 2, tzinfo=UTC), started_date=date(2020, 1, 2), + elapsed_ms=elapsed_ms, ) +def test_postgresql_coupon_summary_uses_ordered_set_aggregates() -> None: + sql = str( + _coupon_summary_aggregate_stmt([]).compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ) + + assert sql.count("percentile_cont") == 4 + assert "FILTER (WHERE coupon_session.status = 'completed'" in sql + + +def test_coupon_duration_summary_uses_only_completed_rows() -> None: + db = SessionLocal() + try: + db.add_all([ + _agg_session("duration-a", [], [], elapsed_ms=1000), + _agg_session("duration-b", [], [], elapsed_ms=3000), + _agg_session( + "duration-failed", [], [], status="failed", elapsed_ms=100_000 + ), + ]) + db.flush() + + summary = coupon_data_report( + db, + date_from="2020-01-02", + date_to="2020-01-02", + app_env="prod", + )["summary"] + + assert summary["started_count"] == 3 + assert summary["completed_count"] == 2 + assert summary["avg_elapsed_ms"] == 2000 + assert summary["p5_ms"] == 1100 + assert summary["p50_ms"] == 2000 + assert summary["p95_ms"] == 2900 + assert summary["p99_ms"] == 2980 + finally: + db.rollback() + db.close() + + def _make_session(db, trace_id: str, **kw) -> CouponSession: row = CouponSession( trace_id=trace_id, From b7cfcf74955f97733758241b150abb1fed58fb13 Mon Sep 17 00:00:00 2001 From: linkeyu Date: Thu, 23 Jul 2026 10:51:20 +0800 Subject: [PATCH 17/42] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E7=BE=8E?= =?UTF-8?q?=E5=9B=A2=E5=92=8C=E4=BA=AC=E4=B8=9C=20CPS=20=E6=AF=8F=E6=97=A5?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=AF=B9=E8=B4=A6=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 需求 - 保留现有后台手动对账逻辑不变 - 每天北京时间 05:00 自动刷新 CPS 对账 - 当前仅处理美团和京东 - 每次按更新时间回拉近 3 天,覆盖延迟更新和订单状态变化 ## 实现 - 新增进程内 CPS 自动对账 worker,并接入应用生命周期 - 美团使用更新时间查询类型 2,京东使用更新时间查询类型 3 - 复用现有仓储层对账及 order_id 幂等更新逻辑 - 美团和京东独立会话、独立异常处理,单个平台失败不阻塞另一平台 - 增加单实例锁、开关、执行小时、回拉天数和轮询间隔配置 - 服务在 05:00 后重启时会补跑当天任务 ## 验证 - `pytest tests/test_cps_reconcile_worker.py tests/test_cps_admin.py tests/test_admin_read.py -q`:27 passed - 相关文件 Ruff 检查通过 - `git diff --check` 通过 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/162 Co-authored-by: linkeyu Co-committed-by: linkeyu --- .env.example | 7 ++ app/core/config.py | 7 ++ app/core/cps_reconcile_worker.py | 175 +++++++++++++++++++++++++++++ app/main.py | 6 + tests/test_cps_reconcile_worker.py | 84 ++++++++++++++ 5 files changed, 279 insertions(+) create mode 100644 app/core/cps_reconcile_worker.py create mode 100644 tests/test_cps_reconcile_worker.py diff --git a/.env.example b/.env.example index c3ba1d8..a7f92f8 100644 --- a/.env.example +++ b/.env.example @@ -113,6 +113,13 @@ JD_UNION_APP_SECRET= JD_UNION_SITE_ID= JD_UNION_AUTH_KEY= +# 美团 + 京东订单每天北京时间 05:00 自动对账;按更新时间回拉近 3 天,重叠防漏单并刷新状态。 +# 手动对账按钮不受该开关影响。通常保持开启;临时停自动任务时设为 false。 +CPS_AUTO_RECONCILE_ENABLED=true +CPS_AUTO_RECONCILE_RUN_HOUR=5 +CPS_AUTO_RECONCILE_LOOKBACK_DAYS=3 +CPS_AUTO_RECONCILE_CHECK_INTERVAL_SEC=60 + # ===== Pricebot 上游 (领券/比价业务透传目标) ===== # 客户端调本服务的 /api/v1/coupon/step 等,我们透传到 pricebot-backend。 # 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。 diff --git a/app/core/config.py b/app/core/config.py index 2f13587..afae3ee 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -188,6 +188,13 @@ class Settings(BaseSettings): """京东联盟订单查询凭证齐全。""" return bool(self.JD_UNION_APP_KEY and self.JD_UNION_APP_SECRET) + # 美团 + 京东 CPS 订单自动对账:进程内 worker 每天北京时间 05:00 后跑一轮。 + # 按更新时间回拉近 N 天(重叠窗口防漏单并刷新状态),order_id 幂等更新;手动接口不受影响。 + CPS_AUTO_RECONCILE_ENABLED: bool = True + CPS_AUTO_RECONCILE_RUN_HOUR: int = 5 + CPS_AUTO_RECONCILE_LOOKBACK_DAYS: int = 3 + CPS_AUTO_RECONCILE_CHECK_INTERVAL_SEC: int = 60 + # ===== 微信服务号(网页授权) ===== # CPS 落地页在微信内拿用户 openid(base 静默)/昵称头像(userinfo),做用户级群统计。 # ⚠️ 区别于 WECHAT_APP_ID(那是 App 移动应用,用于微信支付);这是【已认证服务号】。 diff --git a/app/core/cps_reconcile_worker.py b/app/core/cps_reconcile_worker.py new file mode 100644 index 0000000..9c0e6d2 --- /dev/null +++ b/app/core/cps_reconcile_worker.py @@ -0,0 +1,175 @@ +"""美团、京东 CPS 订单每日自动对账任务。 + +每天北京时间 `CPS_AUTO_RECONCILE_RUN_HOUR`(默认 05:00)后执行一次,按更新时间 +回拉最近若干天订单并复用 admin CPS 仓储层的幂等 upsert。手动对账接口保持独立、不受影响。 +""" +from __future__ import annotations + +import asyncio +import contextlib +import logging +import os +import time +from collections.abc import Iterator +from datetime import date, datetime, timedelta +from pathlib import Path + +from app.admin.repositories import cps as cps_repo +from app.core.config import settings +from app.core.rewards import CN_TZ +from app.db.session import SessionLocal + +logger = logging.getLogger("shagua.cps_reconcile") +_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "cps_reconcile.lock" + + +def _cn_now() -> datetime: + return datetime.now(CN_TZ) + + +def _touch_lock() -> None: + with contextlib.suppress(FileNotFoundError): + os.utime(_LOCK_PATH, None) + + +@contextlib.contextmanager +def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]: + """同机多进程保护:同一时间只允许一个 CPS 自动对账 worker 运行。""" + _LOCK_PATH.parent.mkdir(parents=True, exist_ok=True) + fd: int | None = None + try: + try: + fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except FileExistsError: + try: + age = time.time() - _LOCK_PATH.stat().st_mtime + except FileNotFoundError: + age = stale_after_sec + 1 + if age > stale_after_sec: + with contextlib.suppress(FileNotFoundError): + _LOCK_PATH.unlink() + try: + fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except FileExistsError: + fd = None + + if fd is None: + yield False + return + + os.write(fd, f"pid={os.getpid()} started_at={int(time.time())}\n".encode("ascii")) + yield True + finally: + if fd is not None: + os.close(fd) + with contextlib.suppress(FileNotFoundError): + _LOCK_PATH.unlink() + + +def _empty_result() -> dict: + return {"fetched": 0, "inserted": 0, "updated": 0, "pages": 0} + + +def _reconcile_once(now: datetime | None = None) -> dict: + """独立拉取美团和京东;单平台异常只记日志,不影响另一平台。""" + end = (now or _cn_now()).astimezone(CN_TZ) + lookback_days = max(1, int(settings.CPS_AUTO_RECONCILE_LOOKBACK_DAYS)) + start = end - timedelta(days=lookback_days) + result = { + "window_start": start.isoformat(), + "window_end": end.isoformat(), + "meituan": None, + "jd": None, + "errors": {}, + } + + if settings.mt_cps_configured: + try: + with SessionLocal() as db: + result["meituan"] = cps_repo.reconcile_orders( + db, + start_time=int(start.timestamp()), + end_time=int(end.timestamp()), + query_time_type=2, + ) + except Exception as exc: # noqa: BLE001 - 单平台失败不能阻断另一平台 + result["errors"]["meituan"] = str(exc) + logger.exception("CPS auto reconcile failed platform=meituan") + else: + result["meituan"] = {**_empty_result(), "skipped": "not_configured"} + + _touch_lock() + if settings.jd_union_configured: + try: + with SessionLocal() as db: + result["jd"] = cps_repo.reconcile_jd_orders( + db, + start_time=start, + end_time=end, + query_time_type=3, + ) + except Exception as exc: # noqa: BLE001 - 单平台失败不能阻断另一平台 + result["errors"]["jd"] = str(exc) + logger.exception("CPS auto reconcile failed platform=jd") + else: + result["jd"] = {**_empty_result(), "skipped": "not_configured"} + + return result + + +def _should_run(last_run: date | None, now: datetime, run_hour: int) -> bool: + return last_run != now.date() and now.hour >= run_hour + + +async def _run_loop() -> None: + interval = max(30, int(settings.CPS_AUTO_RECONCILE_CHECK_INTERVAL_SEC)) + run_hour = min(max(int(settings.CPS_AUTO_RECONCILE_RUN_HOUR), 0), 23) + lock_stale_after = max(interval * 3, 1800) + with _single_instance_lock(lock_stale_after) as lock_acquired: + if not lock_acquired: + logger.warning("CPS auto reconcile skipped: another worker owns lock") + return + await _run_locked_loop(interval, run_hour) + + +async def _run_locked_loop(interval: int, run_hour: int) -> None: + logger.info( + "CPS auto reconcile worker started run_hour=%s interval=%ss lookback_days=%s", + run_hour, + interval, + settings.CPS_AUTO_RECONCILE_LOOKBACK_DAYS, + ) + last_run: date | None = None + try: + while True: + try: + _touch_lock() + now = _cn_now() + if _should_run(last_run, now, run_hour): + result = await asyncio.to_thread(_reconcile_once, now) + last_run = now.date() + logger.info("CPS auto reconcile done date=%s result=%s", last_run, result) + except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出 + logger.exception("CPS auto reconcile unexpected error") + await asyncio.sleep(interval) + except asyncio.CancelledError: + logger.info("CPS auto reconcile worker stopped") + raise + + +def start_cps_reconcile_worker() -> asyncio.Task | None: + if not settings.CPS_AUTO_RECONCILE_ENABLED: + logger.info("CPS auto reconcile disabled") + return None + if not settings.mt_cps_configured and not settings.jd_union_configured: + logger.warning("CPS auto reconcile not started: Meituan and JD credentials are missing") + return None + return asyncio.create_task(_run_loop(), name="cps-auto-reconcile") + + +async def stop_cps_reconcile_worker(task: asyncio.Task | None) -> None: + if task is None: + return + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task diff --git a/app/main.py b/app/main.py index 0a1fcd1..dcff43c 100644 --- a/app/main.py +++ b/app/main.py @@ -43,6 +43,10 @@ from app.api.v1.user import router as user_router from app.api.v1.wallet import router as wallet_router from app.api.v1.wxpay import router as wxpay_router from app.core.config import settings +from app.core.cps_reconcile_worker import ( + start_cps_reconcile_worker, + stop_cps_reconcile_worker, +) from app.core.daily_exchange_worker import ( start_daily_exchange_worker, stop_daily_exchange_worker, @@ -89,6 +93,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: except Exception: # noqa: BLE001 logger.exception("reverse_geocoder 预热失败(城市反查将在首个请求时懒加载)") reconcile_task = start_withdraw_reconcile_worker() + cps_reconcile_task = start_cps_reconcile_worker() heartbeat_task = start_heartbeat_monitor() daily_exchange_task = start_daily_exchange_worker() observe_task = start_observe_worker() @@ -98,6 +103,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: finally: await stop_heartbeat_monitor(heartbeat_task) await stop_withdraw_reconcile_worker(reconcile_task) + await stop_cps_reconcile_worker(cps_reconcile_task) await stop_daily_exchange_worker(daily_exchange_task) await stop_observe_worker(observe_task) await stop_inactivity_reset_worker(inactivity_task) diff --git a/tests/test_cps_reconcile_worker.py b/tests/test_cps_reconcile_worker.py new file mode 100644 index 0000000..953533a --- /dev/null +++ b/tests/test_cps_reconcile_worker.py @@ -0,0 +1,84 @@ +"""美团、京东 CPS 每日自动对账 worker。""" +from __future__ import annotations + +from datetime import date, datetime, timedelta + +from app.core import cps_reconcile_worker as worker +from app.core.config import settings +from app.core.rewards import CN_TZ +from app.integrations.meituan import MeituanCpsError + + +def _configure_platforms(monkeypatch) -> None: + monkeypatch.setattr(settings, "MT_CPS_APP_KEY", "mt-key") + monkeypatch.setattr(settings, "MT_CPS_APP_SECRET", "mt-secret") + monkeypatch.setattr(settings, "JD_UNION_APP_KEY", "jd-key") + monkeypatch.setattr(settings, "JD_UNION_APP_SECRET", "jd-secret") + monkeypatch.setattr(settings, "CPS_AUTO_RECONCILE_LOOKBACK_DAYS", 3) + + +def test_reconcile_once_pulls_meituan_and_jd_by_update_time(monkeypatch) -> None: + _configure_platforms(monkeypatch) + calls: dict[str, dict] = {} + + def fake_meituan(db, **kwargs): + calls["meituan"] = kwargs + return {"fetched": 2, "inserted": 1, "updated": 1, "pages": 1} + + def fake_jd(db, **kwargs): + calls["jd"] = kwargs + return {"fetched": 3, "inserted": 2, "updated": 1, "pages": 2} + + monkeypatch.setattr(worker.cps_repo, "reconcile_orders", fake_meituan) + monkeypatch.setattr(worker.cps_repo, "reconcile_jd_orders", fake_jd) + monkeypatch.setattr(worker, "_touch_lock", lambda: None) + now = datetime(2026, 7, 22, 5, 0, tzinfo=CN_TZ) + + result = worker._reconcile_once(now) + + assert result["errors"] == {} + assert result["meituan"]["fetched"] == 2 + assert result["jd"]["fetched"] == 3 + assert calls["meituan"]["query_time_type"] == 2 + assert calls["jd"]["query_time_type"] == 3 + assert calls["meituan"]["end_time"] == int(now.timestamp()) + assert calls["meituan"]["start_time"] == int((now - timedelta(days=3)).timestamp()) + assert calls["jd"]["start_time"] == now - timedelta(days=3) + assert calls["jd"]["end_time"] == now + + +def test_meituan_failure_does_not_block_jd(monkeypatch) -> None: + _configure_platforms(monkeypatch) + jd_called = False + + def fail_meituan(db, **kwargs): + raise MeituanCpsError("temporary failure") + + def fake_jd(db, **kwargs): + nonlocal jd_called + jd_called = True + return {"fetched": 1, "inserted": 1, "updated": 0, "pages": 1} + + monkeypatch.setattr(worker.cps_repo, "reconcile_orders", fail_meituan) + monkeypatch.setattr(worker.cps_repo, "reconcile_jd_orders", fake_jd) + monkeypatch.setattr(worker, "_touch_lock", lambda: None) + + result = worker._reconcile_once(datetime(2026, 7, 22, 5, 0, tzinfo=CN_TZ)) + + assert result["errors"]["meituan"] == "temporary failure" + assert jd_called is True + assert result["jd"]["fetched"] == 1 + + +def test_should_run_once_after_five_am() -> None: + before = datetime(2026, 7, 22, 4, 59, tzinfo=CN_TZ) + at_five = datetime(2026, 7, 22, 5, 0, tzinfo=CN_TZ) + + assert worker._should_run(None, before, 5) is False + assert worker._should_run(None, at_five, 5) is True + assert worker._should_run(date(2026, 7, 22), at_five, 5) is False + + +def test_start_worker_respects_auto_switch(monkeypatch) -> None: + monkeypatch.setattr(settings, "CPS_AUTO_RECONCILE_ENABLED", False) + assert worker.start_cps_reconcile_worker() is None From 31f61f6aad4a40280f307eba119f33fb1891ffb0 Mon Sep 17 00:00:00 2001 From: linkeyu Date: Thu, 23 Jul 2026 11:57:37 +0800 Subject: [PATCH 18/42] =?UTF-8?q?=E5=A2=9E=E5=BC=BA=EF=BC=9ACPS=20?= =?UTF-8?q?=E6=AF=8F=E6=97=A5=E8=87=AA=E5=8A=A8=E5=AF=B9=E8=B4=A6=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=8F=AF=E5=9B=9E=E6=9F=A5=E6=97=A5=E5=BF=97=20(#163)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景 PR #162 已合并。本 PR 为其后续日志增强,方便人工按一次任务完整回查美团、京东每日自动对账。 ## 日志内容 - 每次运行生成唯一 `run_id`,记录 `scheduled` / `startup_catchup` 触发来源 - 记录北京时间计划日期、近 3 天回拉窗口、环境、数据库方言、主机名和 PID - 分平台记录开始、成功、跳过、失败及执行耗时 - 成功结果记录 fetched / inserted / updated / pages / api_requests,京东额外记录小时窗口数 - 京东上游失败记录具体小时窗口、页码和请求序号;美团记录失败页码 - 最终汇总记录 success / partial_success / failed、失败平台、是否需要人工补跑和下次执行时间 - 日志使用现有 `extra` 结构化字段写入 JSON 日志,便于 SLS/人工检索 ## 兼容性 - 手动对账接口、事务和返回模型不变 - 不记录密钥、Token、完整上游响应或订单明细 - 仓储层仅增加可选审计上下文和请求计数;不改变拉取及 upsert 逻辑 ## 验证 - `pytest tests/test_cps_reconcile_worker.py tests/test_cps_admin.py tests/test_admin_read.py tests/test_observe.py -q`:44 passed - 相关文件 Ruff 检查通过(忽略文件原有 UP017 提示) - `git diff --check` 通过 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/163 Co-authored-by: linkeyu Co-committed-by: linkeyu --- app/admin/repositories/cps.py | 87 +++++++-- app/core/cps_reconcile_worker.py | 299 ++++++++++++++++++++++++++--- tests/test_cps_reconcile_worker.py | 117 ++++++++++- 3 files changed, 462 insertions(+), 41 deletions(-) diff --git a/app/admin/repositories/cps.py b/app/admin/repositories/cps.py index 419c62f..7fd177b 100644 --- a/app/admin/repositories/cps.py +++ b/app/admin/repositories/cps.py @@ -5,6 +5,7 @@ """ from __future__ import annotations +import logging from datetime import datetime, timedelta, timezone from decimal import Decimal, InvalidOperation from typing import Any @@ -15,12 +16,14 @@ from sqlalchemy.orm import Session from app.admin.repositories.queries import _as_utc, offset_paginate from app.integrations import jd_union, meituan -from app.repositories import cps_link as cps_link_repo from app.models.cps_activity import CpsActivity from app.models.cps_group import CpsGroup from app.models.cps_link import CpsClick, CpsLink from app.models.cps_order import CpsOrder from app.models.cps_wx_user import CpsWxUser +from app.repositories import cps_link as cps_link_repo + +logger = logging.getLogger("shagua.cps_reconcile") # 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账 _INVALID_STATUS = {"4", "5"} @@ -378,18 +381,35 @@ def effective_commission_cents(order: CpsOrder) -> int: def reconcile_orders( db: Session, *, start_time: int, end_time: int, query_time_type: int = 1, sid: str | None = None, max_pages: int = 200, + audit_context: dict[str, Any] | None = None, ) -> dict: """调美团 query_order 分页拉单 → 按 order_id upsert。返回 {fetched, inserted, updated, pages}。 订单状态会随时间变(付款→完成→结算/退款),重复拉同一单则更新。max_pages 防异常死循环。 """ - fetched = inserted = updated = pages = 0 + fetched = inserted = updated = pages = api_requests = 0 page = 1 while page <= max_pages: - resp = meituan.query_order( - sid=sid, start_time=start_time, end_time=end_time, - query_time_type=query_time_type, page=page, limit=100, - ) + api_requests += 1 + try: + resp = meituan.query_order( + sid=sid, start_time=start_time, end_time=end_time, + query_time_type=query_time_type, page=page, limit=100, + ) + except Exception: # noqa: BLE001 - 记录失败页后保持原异常类型继续抛出 + if audit_context is not None: + logger.exception( + "CPS reconcile upstream request failed platform=meituan page=%s", + page, + extra={ + **audit_context, + "event": "cps_reconcile.request_failed", + "platform": "meituan", + "failed_page": page, + "api_request_number": api_requests, + }, + ) + raise rows = ((resp.get("data") or {}).get("dataList")) or [] if not rows: break @@ -419,30 +439,58 @@ def reconcile_orders( break page += 1 db.commit() - return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages} + return { + "fetched": fetched, + "inserted": inserted, + "updated": updated, + "pages": pages, + "api_requests": api_requests, + } def reconcile_jd_orders( db: Session, *, start_time: datetime, end_time: datetime, query_time_type: int = 3, max_pages: int = 100, + audit_context: dict[str, Any] | None = None, ) -> dict: """调京东 order.row.query 拉单 → 按订单行 upsert。 京东单次查询窗口最多 1 小时,这里按北京自然时间切窗并逐页拉取。 """ - fetched = inserted = updated = pages = 0 + fetched = inserted = updated = pages = api_requests = windows = 0 cur = start_time while cur < end_time: win_end = min(cur + timedelta(hours=1), end_time) + windows += 1 page = 1 while page <= max_pages: - resp = jd_union.query_order_rows( - start_time=cur, - end_time=win_end, - query_time_type=query_time_type, - page_index=page, - page_size=200, - ) + api_requests += 1 + try: + resp = jd_union.query_order_rows( + start_time=cur, + end_time=win_end, + query_time_type=query_time_type, + page_index=page, + page_size=200, + ) + except Exception: # noqa: BLE001 - 记录失败窗口后保持原异常类型继续抛出 + if audit_context is not None: + logger.exception( + "CPS reconcile upstream request failed platform=jd window=%s..%s page=%s", + cur.isoformat(), + win_end.isoformat(), + page, + extra={ + **audit_context, + "event": "cps_reconcile.request_failed", + "platform": "jd", + "failed_window_start": cur.isoformat(), + "failed_window_end": win_end.isoformat(), + "failed_page": page, + "api_request_number": api_requests, + }, + ) + raise rows = resp.get("rows") or [] has_more = bool(resp.get("has_more")) if not rows: @@ -469,7 +517,14 @@ def reconcile_jd_orders( page += 1 cur = win_end db.commit() - return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages} + return { + "fetched": fetched, + "inserted": inserted, + "updated": updated, + "pages": pages, + "api_requests": api_requests, + "windows": windows, + } def list_orders( diff --git a/app/core/cps_reconcile_worker.py b/app/core/cps_reconcile_worker.py index 9c0e6d2..d7c9fe6 100644 --- a/app/core/cps_reconcile_worker.py +++ b/app/core/cps_reconcile_worker.py @@ -9,15 +9,17 @@ import asyncio import contextlib import logging import os +import socket import time -from collections.abc import Iterator +from collections.abc import Callable, Iterator from datetime import date, datetime, timedelta from pathlib import Path +from uuid import uuid4 from app.admin.repositories import cps as cps_repo from app.core.config import settings from app.core.rewards import CN_TZ -from app.db.session import SessionLocal +from app.db.session import SessionLocal, engine logger = logging.getLogger("shagua.cps_reconcile") _LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "cps_reconcile.lock" @@ -27,6 +29,21 @@ def _cn_now() -> datetime: return datetime.now(CN_TZ) +def _new_run_id(now: datetime) -> str: + return f"{now:%Y%m%d-%H%M%S}-{uuid4().hex[:8]}" + + +def _next_run_at(now: datetime, run_hour: int) -> datetime: + scheduled = now.replace(hour=run_hour, minute=0, second=0, microsecond=0) + return scheduled if now < scheduled else scheduled + timedelta(days=1) + + +def _trigger_for_run(worker_started_at: datetime, now: datetime, run_hour: int) -> str: + if worker_started_at.date() == now.date() and worker_started_at.hour >= run_hour: + return "startup_catchup" + return "scheduled" + + def _touch_lock() -> None: with contextlib.suppress(FileNotFoundError): os.utime(_LOCK_PATH, None) @@ -67,52 +84,240 @@ def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]: def _empty_result() -> dict: - return {"fetched": 0, "inserted": 0, "updated": 0, "pages": 0} + return { + "fetched": 0, + "inserted": 0, + "updated": 0, + "pages": 0, + "api_requests": 0, + } -def _reconcile_once(now: datetime | None = None) -> dict: +def _platform_log_fields(platform: str, result: dict) -> dict: + fields = { + "platform": platform, + "platform_status": result["status"], + "duration_ms": result["duration_ms"], + } + for key in ("fetched", "inserted", "updated", "pages", "api_requests", "windows"): + if key in result: + fields[key] = result[key] + return fields + + +def _run_platform( + *, + platform: str, + query_time_type: int, + common: dict, + reconcile: Callable[[], dict], +) -> tuple[dict, str | None]: + started = time.perf_counter() + logger.info( + "CPS auto reconcile platform started run_id=%s platform=%s", + common["run_id"], + platform, + extra={ + **common, + "event": "cps_reconcile.platform_started", + "platform": platform, + "query_time_type": query_time_type, + }, + ) + try: + raw_result = reconcile() + except Exception as exc: # noqa: BLE001 - 单平台失败不能阻断另一平台 + result = { + "status": "failed", + "duration_ms": round((time.perf_counter() - started) * 1000, 3), + "error_type": type(exc).__name__, + "error_summary": str(exc)[:500], + } + logger.exception( + "CPS auto reconcile platform failed run_id=%s platform=%s error_type=%s", + common["run_id"], + platform, + type(exc).__name__, + extra={ + **common, + "event": "cps_reconcile.platform_failed", + **_platform_log_fields(platform, result), + "error_type": type(exc).__name__, + "error_summary": str(exc)[:500], + }, + ) + return result, str(exc) + + result = { + **raw_result, + "status": "success", + "duration_ms": round((time.perf_counter() - started) * 1000, 3), + } + logger.info( + "CPS auto reconcile platform completed run_id=%s platform=%s fetched=%s inserted=%s updated=%s", + common["run_id"], + platform, + result.get("fetched", 0), + result.get("inserted", 0), + result.get("updated", 0), + extra={ + **common, + "event": "cps_reconcile.platform_completed", + **_platform_log_fields(platform, result), + }, + ) + return result, None + + +def _skip_platform(platform: str, common: dict) -> dict: + result = { + **_empty_result(), + "status": "skipped", + "skipped": "not_configured", + "duration_ms": 0.0, + } + logger.warning( + "CPS auto reconcile platform skipped run_id=%s platform=%s reason=not_configured", + common["run_id"], + platform, + extra={ + **common, + "event": "cps_reconcile.platform_skipped", + **_platform_log_fields(platform, result), + "skip_reason": "not_configured", + }, + ) + return result + + +def _reconcile_once( + now: datetime | None = None, + *, + run_id: str | None = None, + trigger: str = "scheduled", +) -> dict: """独立拉取美团和京东;单平台异常只记日志,不影响另一平台。""" end = (now or _cn_now()).astimezone(CN_TZ) + run_id = run_id or _new_run_id(end) lookback_days = max(1, int(settings.CPS_AUTO_RECONCILE_LOOKBACK_DAYS)) start = end - timedelta(days=lookback_days) + task_started = time.perf_counter() + common = { + "run_id": run_id, + "trigger": trigger, + "scheduled_date": end.date().isoformat(), + "app_env": settings.APP_ENV, + "db_dialect": engine.dialect.name, + "hostname": socket.gethostname(), + "pid": os.getpid(), + "window_start": start.isoformat(), + "window_end": end.isoformat(), + "lookback_days": lookback_days, + } result = { + "run_id": run_id, + "trigger": trigger, "window_start": start.isoformat(), "window_end": end.isoformat(), "meituan": None, "jd": None, "errors": {}, } + logger.info( + "CPS auto reconcile started run_id=%s trigger=%s window=%s..%s", + run_id, + trigger, + result["window_start"], + result["window_end"], + extra={**common, "event": "cps_reconcile.started"}, + ) if settings.mt_cps_configured: - try: + def reconcile_meituan() -> dict: with SessionLocal() as db: - result["meituan"] = cps_repo.reconcile_orders( + return cps_repo.reconcile_orders( db, start_time=int(start.timestamp()), end_time=int(end.timestamp()), query_time_type=2, + audit_context=common, ) - except Exception as exc: # noqa: BLE001 - 单平台失败不能阻断另一平台 - result["errors"]["meituan"] = str(exc) - logger.exception("CPS auto reconcile failed platform=meituan") + + result["meituan"], error = _run_platform( + platform="meituan", + query_time_type=2, + common=common, + reconcile=reconcile_meituan, + ) + if error is not None: + result["errors"]["meituan"] = error else: - result["meituan"] = {**_empty_result(), "skipped": "not_configured"} + result["meituan"] = _skip_platform("meituan", common) _touch_lock() if settings.jd_union_configured: - try: + def reconcile_jd() -> dict: with SessionLocal() as db: - result["jd"] = cps_repo.reconcile_jd_orders( + return cps_repo.reconcile_jd_orders( db, start_time=start, end_time=end, query_time_type=3, + audit_context=common, ) - except Exception as exc: # noqa: BLE001 - 单平台失败不能阻断另一平台 - result["errors"]["jd"] = str(exc) - logger.exception("CPS auto reconcile failed platform=jd") + + result["jd"], error = _run_platform( + platform="jd", + query_time_type=3, + common=common, + reconcile=reconcile_jd, + ) + if error is not None: + result["errors"]["jd"] = error else: - result["jd"] = {**_empty_result(), "skipped": "not_configured"} + result["jd"] = _skip_platform("jd", common) + + successes = sum( + platform_result.get("status") == "success" + for platform_result in (result["meituan"], result["jd"]) + ) + if result["errors"]: + task_status = "partial_success" if successes else "failed" + else: + task_status = "success" + completed_at = _cn_now() + duration_ms = round((time.perf_counter() - task_started) * 1000, 3) + next_run_at = _next_run_at( + completed_at, + min(max(int(settings.CPS_AUTO_RECONCILE_RUN_HOUR), 0), 23), + ).isoformat() + result.update({ + "status": task_status, + "duration_ms": duration_ms, + "manual_retry_required": bool(result["errors"]), + "next_run_at": next_run_at, + }) + completion_fields = { + **common, + "event": "cps_reconcile.completed", + "task_status": task_status, + "duration_ms": duration_ms, + "manual_retry_required": result["manual_retry_required"], + "failed_platforms": sorted(result["errors"]), + "next_run_at": next_run_at, + "meituan_result": result["meituan"], + "jd_result": result["jd"], + } + log_method = logger.info if task_status == "success" else logger.warning + log_method( + "CPS auto reconcile completed run_id=%s status=%s duration_ms=%s failed_platforms=%s next_run_at=%s", + run_id, + task_status, + duration_ms, + ",".join(sorted(result["errors"])) or "-", + next_run_at, + extra=completion_fields, + ) return result @@ -127,42 +332,90 @@ async def _run_loop() -> None: lock_stale_after = max(interval * 3, 1800) with _single_instance_lock(lock_stale_after) as lock_acquired: if not lock_acquired: - logger.warning("CPS auto reconcile skipped: another worker owns lock") + logger.warning( + "CPS auto reconcile worker skipped: another worker owns lock", + extra={ + "event": "cps_reconcile.worker_skipped", + "skip_reason": "lock_not_acquired", + "pid": os.getpid(), + }, + ) return await _run_locked_loop(interval, run_hour) async def _run_locked_loop(interval: int, run_hour: int) -> None: + worker_started_at = _cn_now() logger.info( "CPS auto reconcile worker started run_hour=%s interval=%ss lookback_days=%s", run_hour, interval, settings.CPS_AUTO_RECONCILE_LOOKBACK_DAYS, + extra={ + "event": "cps_reconcile.worker_started", + "pid": os.getpid(), + "hostname": socket.gethostname(), + "app_env": settings.APP_ENV, + "db_dialect": engine.dialect.name, + "run_hour": run_hour, + "interval_sec": interval, + "lookback_days": settings.CPS_AUTO_RECONCILE_LOOKBACK_DAYS, + "next_run_at": _next_run_at(worker_started_at, run_hour).isoformat(), + }, ) last_run: date | None = None try: while True: + run_id: str | None = None try: _touch_lock() now = _cn_now() if _should_run(last_run, now, run_hour): - result = await asyncio.to_thread(_reconcile_once, now) + run_id = _new_run_id(now) + trigger = _trigger_for_run(worker_started_at, now, run_hour) + await asyncio.to_thread( + _reconcile_once, + now, + run_id=run_id, + trigger=trigger, + ) last_run = now.date() - logger.info("CPS auto reconcile done date=%s result=%s", last_run, result) except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出 - logger.exception("CPS auto reconcile unexpected error") + logger.exception( + "CPS auto reconcile unexpected worker error", + extra={ + "event": "cps_reconcile.unexpected_failed", + "run_id": run_id or "unassigned", + "pid": os.getpid(), + }, + ) await asyncio.sleep(interval) except asyncio.CancelledError: - logger.info("CPS auto reconcile worker stopped") + logger.info( + "CPS auto reconcile worker stopped", + extra={"event": "cps_reconcile.worker_stopped", "pid": os.getpid()}, + ) raise def start_cps_reconcile_worker() -> asyncio.Task | None: if not settings.CPS_AUTO_RECONCILE_ENABLED: - logger.info("CPS auto reconcile disabled") + logger.info( + "CPS auto reconcile disabled", + extra={ + "event": "cps_reconcile.worker_skipped", + "skip_reason": "disabled", + }, + ) return None if not settings.mt_cps_configured and not settings.jd_union_configured: - logger.warning("CPS auto reconcile not started: Meituan and JD credentials are missing") + logger.warning( + "CPS auto reconcile not started: Meituan and JD credentials are missing", + extra={ + "event": "cps_reconcile.worker_skipped", + "skip_reason": "all_platform_credentials_missing", + }, + ) return None return asyncio.create_task(_run_loop(), name="cps-auto-reconcile") diff --git a/tests/test_cps_reconcile_worker.py b/tests/test_cps_reconcile_worker.py index 953533a..6ff0e54 100644 --- a/tests/test_cps_reconcile_worker.py +++ b/tests/test_cps_reconcile_worker.py @@ -1,11 +1,15 @@ """美团、京东 CPS 每日自动对账 worker。""" from __future__ import annotations +import logging from datetime import date, datetime, timedelta +import pytest + from app.core import cps_reconcile_worker as worker from app.core.config import settings from app.core.rewards import CN_TZ +from app.db.session import SessionLocal from app.integrations.meituan import MeituanCpsError @@ -23,11 +27,24 @@ def test_reconcile_once_pulls_meituan_and_jd_by_update_time(monkeypatch) -> None def fake_meituan(db, **kwargs): calls["meituan"] = kwargs - return {"fetched": 2, "inserted": 1, "updated": 1, "pages": 1} + return { + "fetched": 2, + "inserted": 1, + "updated": 1, + "pages": 1, + "api_requests": 1, + } def fake_jd(db, **kwargs): calls["jd"] = kwargs - return {"fetched": 3, "inserted": 2, "updated": 1, "pages": 2} + return { + "fetched": 3, + "inserted": 2, + "updated": 1, + "pages": 2, + "api_requests": 72, + "windows": 72, + } monkeypatch.setattr(worker.cps_repo, "reconcile_orders", fake_meituan) monkeypatch.setattr(worker.cps_repo, "reconcile_jd_orders", fake_jd) @@ -45,6 +62,10 @@ def test_reconcile_once_pulls_meituan_and_jd_by_update_time(monkeypatch) -> None assert calls["meituan"]["start_time"] == int((now - timedelta(days=3)).timestamp()) assert calls["jd"]["start_time"] == now - timedelta(days=3) assert calls["jd"]["end_time"] == now + assert calls["meituan"]["audit_context"]["run_id"] == result["run_id"] + assert calls["jd"]["audit_context"]["run_id"] == result["run_id"] + assert result["status"] == "success" + assert result["manual_retry_required"] is False def test_meituan_failure_does_not_block_jd(monkeypatch) -> None: @@ -68,6 +89,9 @@ def test_meituan_failure_does_not_block_jd(monkeypatch) -> None: assert result["errors"]["meituan"] == "temporary failure" assert jd_called is True assert result["jd"]["fetched"] == 1 + assert result["meituan"]["status"] == "failed" + assert result["status"] == "partial_success" + assert result["manual_retry_required"] is True def test_should_run_once_after_five_am() -> None: @@ -79,6 +103,95 @@ def test_should_run_once_after_five_am() -> None: assert worker._should_run(date(2026, 7, 22), at_five, 5) is False +def test_trigger_and_next_run_are_beijing_time() -> None: + before_five = datetime(2026, 7, 22, 4, 30, tzinfo=CN_TZ) + after_five = datetime(2026, 7, 22, 6, 0, tzinfo=CN_TZ) + + assert worker._trigger_for_run(before_five, after_five, 5) == "scheduled" + assert worker._trigger_for_run(after_five, after_five, 5) == "startup_catchup" + assert worker._next_run_at(before_five, 5) == datetime(2026, 7, 22, 5, 0, tzinfo=CN_TZ) + assert worker._next_run_at(after_five, 5) == datetime(2026, 7, 23, 5, 0, tzinfo=CN_TZ) + + +def test_reconcile_emits_structured_audit_logs(monkeypatch, caplog) -> None: + _configure_platforms(monkeypatch) + monkeypatch.setattr( + worker.cps_repo, + "reconcile_orders", + lambda db, **kwargs: { + "fetched": 2, + "inserted": 1, + "updated": 1, + "pages": 1, + "api_requests": 1, + }, + ) + monkeypatch.setattr( + worker.cps_repo, + "reconcile_jd_orders", + lambda db, **kwargs: { + "fetched": 3, + "inserted": 2, + "updated": 1, + "pages": 1, + "api_requests": 72, + "windows": 72, + }, + ) + monkeypatch.setattr(worker, "_touch_lock", lambda: None) + monkeypatch.setattr( + worker, + "_cn_now", + lambda: datetime(2026, 7, 22, 5, 1, tzinfo=CN_TZ), + ) + + with caplog.at_level(logging.INFO, logger=worker.logger.name): + result = worker._reconcile_once( + datetime(2026, 7, 22, 5, 0, tzinfo=CN_TZ), + run_id="audit-run-1", + trigger="scheduled", + ) + + records = {record.event: record for record in caplog.records if hasattr(record, "event")} + assert records["cps_reconcile.started"].run_id == "audit-run-1" + assert records["cps_reconcile.started"].lookback_days == 3 + assert records["cps_reconcile.started"].scheduled_date == "2026-07-22" + assert records["cps_reconcile.started"].hostname + assert records["cps_reconcile.completed"].task_status == "success" + assert records["cps_reconcile.completed"].manual_retry_required is False + assert records["cps_reconcile.completed"].meituan_result["fetched"] == 2 + assert records["cps_reconcile.completed"].jd_result["api_requests"] == 72 + assert result["next_run_at"] == "2026-07-23T05:00:00+08:00" + + +def test_jd_request_failure_logs_exact_window(monkeypatch, caplog) -> None: + start = datetime(2026, 7, 20, 5, 0, tzinfo=CN_TZ) + + def fail_query(**kwargs): + raise RuntimeError("upstream timeout") + + monkeypatch.setattr(worker.cps_repo.jd_union, "query_order_rows", fail_query) + with SessionLocal() as db, caplog.at_level(logging.ERROR, logger=worker.logger.name): + with pytest.raises(RuntimeError, match="upstream timeout"): + worker.cps_repo.reconcile_jd_orders( + db, + start_time=start, + end_time=start + timedelta(hours=2), + audit_context={"run_id": "audit-run-2", "trigger": "scheduled"}, + ) + + record = next( + item + for item in caplog.records + if getattr(item, "event", "") == "cps_reconcile.request_failed" + ) + assert record.run_id == "audit-run-2" + assert record.platform == "jd" + assert record.failed_window_start == "2026-07-20T05:00:00+08:00" + assert record.failed_window_end == "2026-07-20T06:00:00+08:00" + assert record.failed_page == 1 + + def test_start_worker_respects_auto_switch(monkeypatch) -> None: monkeypatch.setattr(settings, "CPS_AUTO_RECONCILE_ENABLED", False) assert worker.start_cps_reconcile_worker() is None From ceceeb3458842ef4890841dd1b33995c831a283c Mon Sep 17 00:00:00 2001 From: marco Date: Thu, 23 Jul 2026 17:55:35 +0800 Subject: [PATCH 19/42] =?UTF-8?q?fix(db):=20=E5=90=88=E5=B9=B6=203=20?= =?UTF-8?q?=E4=B8=AA=20alembic=20=E8=BF=81=E7=A7=BB=20head=EF=BC=88?= =?UTF-8?q?=E5=8F=91=E8=BD=A6=200.4.4=20=E5=89=8D=E7=BD=AE=EF=BC=8Cno-op?= =?UTF-8?q?=20merge=20=E8=8A=82=E7=82=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...merge_notification_comparison_user_idx_.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 alembic/versions/8e04cc13a211_merge_notification_comparison_user_idx_.py diff --git a/alembic/versions/8e04cc13a211_merge_notification_comparison_user_idx_.py b/alembic/versions/8e04cc13a211_merge_notification_comparison_user_idx_.py new file mode 100644 index 0000000..7cdb211 --- /dev/null +++ b/alembic/versions/8e04cc13a211_merge_notification_comparison_user_idx_.py @@ -0,0 +1,26 @@ +"""merge notification/comparison_user_idx/monitoring_audit heads + +Revision ID: 8e04cc13a211 +Revises: comparison_user_created_idx, monitoring_audit_rbac, notification_table +Create Date: 2026-07-23 15:37:26.967540 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '8e04cc13a211' +down_revision: Union[str, Sequence[str], None] = ('comparison_user_created_idx', 'monitoring_audit_rbac', 'notification_table') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass From b4a2a8c31ddc850b1ce458d060bcfee20c99449a Mon Sep 17 00:00:00 2001 From: linkeyu Date: Fri, 24 Jul 2026 11:14:31 +0800 Subject: [PATCH 20/42] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E9=99=90?= =?UTF-8?q?=E5=88=B6=E6=AF=8F=E4=BD=8D=E7=94=A8=E6=88=B7=E6=AF=8F=E5=A4=A9?= =?UTF-8?q?=E6=9C=80=E5=A4=9A=E5=8F=91=E8=B5=B7=20100=20=E6=AC=A1=E6=AF=94?= =?UTF-8?q?=E4=BB=B7=20(#165)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 变更内容 - 登录用户按北京时间自然日计算比价发起次数,每人每天最多 100 次。 - 第 101 次起返回 HTTP 429,并提示“今日已比价超过100次,请明天再试”。 - 同一 trace_id 的网络重试按幂等处理,不会重复计数。 - 使用用户行锁串行化同一账号的并发请求,避免并发突破上限。 - 复用现有 comparison_record 的 running 记录,无需新增数据库迁移。 ## 本地验证 - 比价额度专项测试 11 项通过。 - 本次修改涉及文件的 Ruff 检查通过。 - 已覆盖未登录、重复 trace_id、跨自然日、第 100 次放行及第 101 次拒绝。 --------- Co-authored-by: CodexSandboxOffline <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/165 Co-authored-by: linkeyu Co-committed-by: linkeyu --- app/api/v1/compare_record.py | 37 +++++++++ app/repositories/comparison.py | 85 ++++++++++++++++++++- app/schemas/compare_record.py | 15 +++- tests/test_compare_daily_limit.py | 120 ++++++++++++++++++++++++++++++ 4 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 tests/test_compare_daily_limit.py diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index f1d67d6..5afc8ac 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -20,6 +20,8 @@ from app.db.session import SessionLocal from app.models.comparison import ComparisonRecord from app.repositories import comparison as crud_compare from app.schemas.compare_record import ( + CompareStartReserveIn, + CompareStartReserveOut, CompareStatsOut, ComparisonRecordCreatedOut, ComparisonRecordDetailOut, @@ -35,6 +37,41 @@ logger = logging.getLogger("shagua.compare_record") router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"]) +@router.post( + "/start", + response_model=CompareStartReserveOut, + summary="预占一次当日比价发起次数(每人每天最多100次)", +) +def reserve_compare_start( + payload: CompareStartReserveIn, + user: CurrentUser, + db: DbSession, +) -> CompareStartReserveOut: + try: + _, used = crud_compare.reserve_daily_start( + db, + user_id=user.id, + trace_id=payload.trace_id, + business_type=payload.business_type, + device_id=payload.device_id, + ) + except crud_compare.DailyCompareStartLimitExceeded: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="今日已比价超过100次,请明天再试", + ) from None + except crud_compare.ComparisonTraceOwnershipError: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="比价任务标识冲突,请重新发起", + ) from None + return CompareStartReserveOut( + limit=crud_compare.DAILY_COMPARE_START_LIMIT, + used=used, + remaining=max(crud_compare.DAILY_COMPARE_START_LIMIT - used, 0), + ) + + @router.post( "/record", response_model=ComparisonRecordCreatedOut, diff --git a/app/repositories/comparison.py b/app/repositories/comparison.py index 7dceeab..53602de 100644 --- a/app/repositories/comparison.py +++ b/app/repositories/comparison.py @@ -5,7 +5,7 @@ """ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta from sqlalchemy import func, or_, select from sqlalchemy.orm import Session, defer @@ -14,8 +14,19 @@ from app.core.rewards import CN_TZ from app.models.ad_feed_reward import AdFeedRewardRecord from app.models.comparison import ComparisonRecord from app.models.savings import SavingsRecord +from app.models.user import User from app.schemas.compare_record import ComparisonRecordIn +DAILY_COMPARE_START_LIMIT = 100 + + +class DailyCompareStartLimitExceeded(Exception): + """The authenticated user has consumed today's comparison-start quota.""" + + +class ComparisonTraceOwnershipError(Exception): + """A trace id already belongs to a different authenticated user.""" + def _yuan_to_cents(yuan: float | None) -> int | None: """元(float)→ 分(int)。None 透传。""" @@ -243,6 +254,78 @@ def _get_by_trace(db: Session, trace_id: str) -> ComparisonRecord | None: ).scalar_one_or_none() +def reserve_daily_start( + db: Session, + *, + user_id: int, + trace_id: str, + business_type: str = "food", + device_id: str | None = None, + now: datetime | None = None, +) -> tuple[ComparisonRecord, int]: + """Atomically reserve one of a user's 100 Beijing-day comparison starts. + + ``trace_id`` makes client retries idempotent. Locking the user row serializes + concurrent starts for one account, so parallel requests cannot both consume + the final available slot. The reservation is the existing ``running`` + comparison row; later result reporting updates that same row. + """ + db.execute(select(User.id).where(User.id == user_id).with_for_update()).scalar_one() + + existing = _get_by_trace(db, trace_id) + if existing is not None: + if existing.user_id not in (None, user_id): + raise ComparisonTraceOwnershipError + if existing.user_id is None: + existing.user_id = user_id + if existing.device_id is None and device_id: + existing.device_id = device_id + db.commit() + db.refresh(existing) + + existing_at = existing.created_at + if existing_at.tzinfo is not None: + existing_at = existing_at.astimezone(CN_TZ).replace(tzinfo=None) + day_start = existing_at.replace(hour=0, minute=0, second=0, microsecond=0) + day_end = day_start + timedelta(days=1) + used = db.scalar( + select(func.count(ComparisonRecord.id)).where( + ComparisonRecord.user_id == user_id, + ComparisonRecord.created_at >= day_start, + ComparisonRecord.created_at < day_end, + ) + ) or 0 + return existing, int(used) + + current = now or datetime.now(CN_TZ) + if current.tzinfo is not None: + current = current.astimezone(CN_TZ).replace(tzinfo=None) + day_start = current.replace(hour=0, minute=0, second=0, microsecond=0) + day_end = day_start + timedelta(days=1) + used = db.scalar( + select(func.count(ComparisonRecord.id)).where( + ComparisonRecord.user_id == user_id, + ComparisonRecord.created_at >= day_start, + ComparisonRecord.created_at < day_end, + ) + ) or 0 + if used >= DAILY_COMPARE_START_LIMIT: + raise DailyCompareStartLimitExceeded + + rec = ComparisonRecord( + trace_id=trace_id, + user_id=user_id, + business_type=business_type or "food", + device_id=device_id, + status="running", + created_at=current, + ) + db.add(rec) + db.commit() + db.refresh(rec) + return rec, int(used) + 1 + + def harvest_running( db: Session, *, diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py index 91ccd36..3887bf0 100644 --- a/app/schemas/compare_record.py +++ b/app/schemas/compare_record.py @@ -13,7 +13,6 @@ from datetime import datetime from pydantic import BaseModel, ConfigDict, Field, field_validator - # ===== 上报请求 ===== class ComparisonItemIn(BaseModel): @@ -198,6 +197,20 @@ class ComparisonRecordCreatedOut(BaseModel): id: int = Field(..., description="写入(或已存在)的记录 id") +class CompareStartReserveIn(BaseModel): + """Reserve one authenticated comparison start before the agent begins.""" + + trace_id: str = Field(..., min_length=1, max_length=64) + business_type: str = Field(default="food", min_length=1, max_length=16) + device_id: str | None = Field(default=None, max_length=64) + + +class CompareStartReserveOut(BaseModel): + limit: int + used: int + remaining: int + + class CompareStatsOut(BaseModel): """「我的」页省钱战绩卡(比价口径)聚合。""" diff --git a/tests/test_compare_daily_limit.py b/tests/test_compare_daily_limit.py new file mode 100644 index 0000000..a10afec --- /dev/null +++ b/tests/test_compare_daily_limit.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import time +from datetime import datetime, timedelta + +from sqlalchemy import func, select + +from app.core.rewards import CN_TZ +from app.core.security import decode_token +from app.db.session import SessionLocal +from app.models.comparison import ComparisonRecord + + +def _login(client) -> tuple[str, int]: + phone = f"137{int(time.time() * 1000) % 100000000:08d}" + sent = client.post("/api/v1/auth/sms/send", json={"phone": phone}) + assert sent.status_code == 200, sent.text + logged_in = client.post( + "/api/v1/auth/sms/login", + json={"phone": phone, "code": "123456"}, + ) + assert logged_in.status_code == 200, logged_in.text + token = logged_in.json()["access_token"] + return token, int(decode_token(token, expected_type="access")["sub"]) + + +def _headers(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def test_compare_start_requires_login(client) -> None: + response = client.post( + "/api/v1/compare/start", + json={"trace_id": "quota-no-auth", "business_type": "food"}, + ) + assert response.status_code == 401 + + +def test_compare_start_is_idempotent_by_trace_id(client) -> None: + token, user_id = _login(client) + payload = { + "trace_id": f"quota-idempotent-{user_id}", + "business_type": "ecom", + "device_id": "quota-device", + } + + first = client.post("/api/v1/compare/start", json=payload, headers=_headers(token)) + retry = client.post("/api/v1/compare/start", json=payload, headers=_headers(token)) + + assert first.status_code == 200, first.text + assert first.json() == {"limit": 100, "used": 1, "remaining": 99} + assert retry.status_code == 200, retry.text + assert retry.json() == first.json() + with SessionLocal() as db: + count = db.scalar( + select(func.count(ComparisonRecord.id)).where( + ComparisonRecord.trace_id == payload["trace_id"] + ) + ) + record = db.execute( + select(ComparisonRecord).where( + ComparisonRecord.trace_id == payload["trace_id"] + ) + ).scalar_one() + assert count == 1 + assert record.user_id == user_id + assert record.status == "running" + assert record.business_type == "ecom" + assert record.device_id == "quota-device" + + +def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None: + token, user_id = _login(client) + now = datetime.now(CN_TZ).replace(tzinfo=None) + with SessionLocal() as db: + db.add_all( + [ + ComparisonRecord( + user_id=user_id, + trace_id=f"quota-full-{user_id}-{index}", + status="failed", + created_at=now, + ) + for index in range(99) + ] + ) + db.add( + ComparisonRecord( + user_id=user_id, + trace_id=f"quota-yesterday-{user_id}", + status="success", + created_at=now - timedelta(days=1), + ) + ) + db.commit() + + final_allowed_trace = f"quota-final-allowed-{user_id}" + allowed = client.post( + "/api/v1/compare/start", + json={"trace_id": final_allowed_trace, "business_type": "food"}, + headers=_headers(token), + ) + assert allowed.status_code == 200, allowed.text + assert allowed.json() == {"limit": 100, "used": 100, "remaining": 0} + + rejected_trace = f"quota-rejected-{user_id}" + response = client.post( + "/api/v1/compare/start", + json={"trace_id": rejected_trace, "business_type": "food"}, + headers=_headers(token), + ) + + assert response.status_code == 429 + assert response.json()["detail"] == "今日已比价超过100次,请明天再试" + with SessionLocal() as db: + assert db.scalar( + select(func.count(ComparisonRecord.id)).where( + ComparisonRecord.trace_id == rejected_trace + ) + ) == 0 From 66527f6cdc63a13a3627574779a728b8a19aea1a Mon Sep 17 00:00:00 2001 From: linkeyu Date: Fri, 24 Jul 2026 11:15:09 +0800 Subject: [PATCH 21/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E9=A2=86?= =?UTF-8?q?=E5=88=B8=E5=8D=95=E5=88=B8=E6=88=90=E5=8A=9F=E7=8E=87=E6=8C=89?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=8B=AC=E7=AB=8B=E7=BB=9F=E8=AE=A1=20(#166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 修改内容 - 新增按 `trace_id + coupon_id` 幂等的逐次单券事件表。 - 保留每日资产记录,同时独立保存每次领券事件,避免同设备同日重跑串场。 - 后台逐场成功率和单券明细改为读取逐次事件。 - 增加历史数据回填迁移、本地 mock 脚本和回归测试。 ## 验证结果 - 领券相关测试:22 项通过。 - 全新 SQLite 数据库执行 `alembic upgrade head`:通过。 - 全量测试:508 项通过;另外 8 项为现有无关失败。 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/166 Co-authored-by: linkeyu Co-committed-by: linkeyu --- alembic/versions/coupon_claim_event.py | 93 ++++++++++++ app/admin/repositories/coupon_data.py | 24 +-- app/admin/schemas/coupon_data.py | 4 +- app/api/v1/coupon.py | 4 +- app/models/__init__.py | 1 + app/models/coupon_state.py | 34 +++++ app/repositories/coupon_state.py | 41 ++++- scripts/seed_coupon_session_mock.py | 197 +++++++++++++++++++++++++ tests/test_coupon_claim_event.py | 54 +++++++ tests/test_coupon_point_score.py | 20 +-- tests/test_coupon_slots.py | 3 +- 11 files changed, 446 insertions(+), 29 deletions(-) create mode 100644 alembic/versions/coupon_claim_event.py create mode 100644 scripts/seed_coupon_session_mock.py create mode 100644 tests/test_coupon_claim_event.py diff --git a/alembic/versions/coupon_claim_event.py b/alembic/versions/coupon_claim_event.py new file mode 100644 index 0000000..8161330 --- /dev/null +++ b/alembic/versions/coupon_claim_event.py @@ -0,0 +1,93 @@ +"""add per-session coupon claim event table + +Revision ID: coupon_claim_event +Revises: 8e04cc13a211 +Create Date: 2026-07-23 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision: str = "coupon_claim_event" +down_revision: str | Sequence[str] | None = "8e04cc13a211" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql") + + +def upgrade() -> None: + op.create_table( + "coupon_claim_event", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("trace_id", sa.String(length=64), nullable=False), + sa.Column("device_id", sa.String(length=64), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("coupon_id", sa.String(length=64), nullable=False), + sa.Column("claim_date", sa.Date(), nullable=False), + sa.Column("status", sa.String(length=24), nullable=False), + sa.Column("app_env", sa.String(length=16), nullable=True), + sa.Column("vendor", sa.String(length=48), nullable=True), + sa.Column("coupon_name", sa.String(length=128), nullable=True), + sa.Column("claimed_count", sa.Integer(), nullable=True), + sa.Column("reason", sa.String(length=255), nullable=True), + sa.Column("extra", _JSON, nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "trace_id", "coupon_id", + name="uq_coupon_claim_event_trace_coupon", + ), + ) + op.create_index( + "ix_coupon_claim_event_date_env", + "coupon_claim_event", + ["claim_date", "app_env"], + unique=False, + ) + op.create_index( + op.f("ix_coupon_claim_event_app_env"), + "coupon_claim_event", + ["app_env"], + unique=False, + ) + op.create_index( + op.f("ix_coupon_claim_event_trace_id"), + "coupon_claim_event", + ["trace_id"], + unique=False, + ) + op.create_index( + op.f("ix_coupon_claim_event_user_id"), + "coupon_claim_event", + ["user_id"], + unique=False, + ) + + # 旧表只能回填当前仍保留的 trace;历史上已被每日去重覆盖的关联无法恢复。 + op.execute( + """ + INSERT INTO coupon_claim_event ( + trace_id, device_id, user_id, coupon_id, claim_date, status, app_env, + vendor, coupon_name, claimed_count, reason, extra, created_at, updated_at + ) + SELECT + trace_id, device_id, user_id, coupon_id, claim_date, status, app_env, + vendor, coupon_name, claimed_count, reason, extra, created_at, updated_at + FROM coupon_claim_record + WHERE trace_id IS NOT NULL + """ + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_coupon_claim_event_user_id"), table_name="coupon_claim_event") + op.drop_index(op.f("ix_coupon_claim_event_trace_id"), table_name="coupon_claim_event") + op.drop_index(op.f("ix_coupon_claim_event_app_env"), table_name="coupon_claim_event") + op.drop_index("ix_coupon_claim_event_date_env", table_name="coupon_claim_event") + op.drop_table("coupon_claim_event") diff --git a/app/admin/repositories/coupon_data.py b/app/admin/repositories/coupon_data.py index 5ae22d4..f7f8179 100644 --- a/app/admin/repositories/coupon_data.py +++ b/app/admin/repositories/coupon_data.py @@ -16,7 +16,7 @@ from sqlalchemy import case, func, or_, select from sqlalchemy.orm import Session from app.core import rewards -from app.models.coupon_state import CouponClaimRecord, CouponSession +from app.models.coupon_state import CouponClaimEvent, CouponClaimRecord, CouponSession from app.models.user import User from app.repositories import ad_ecpm as crud_ecpm from app.repositories.coupon_state import DEFAULT_PLATFORMS, coupon_id_to_platform @@ -193,18 +193,18 @@ def _point_scores_by_trace(db: Session, trace_ids: list[str]) -> dict[str, dict[ """聚合查询批量返回逐场点位分数,不加载逐券明细。""" if not trace_ids: return {} - succeeded = func.sum(case((CouponClaimRecord.status.in_(_SLOT_OK), 1), else_=0)) + succeeded = func.sum(case((CouponClaimEvent.status.in_(_SLOT_OK), 1), else_=0)) rows = db.execute( select( - CouponClaimRecord.trace_id, + CouponClaimEvent.trace_id, succeeded.label("succeeded"), func.count().label("tried"), ) .where( - CouponClaimRecord.trace_id.in_(trace_ids), - CouponClaimRecord.status.in_(_SLOT_TRIED), + CouponClaimEvent.trace_id.in_(trace_ids), + CouponClaimEvent.status.in_(_SLOT_TRIED), ) - .group_by(CouponClaimRecord.trace_id) + .group_by(CouponClaimEvent.trace_id) ).all() return { trace_id: {"succeeded": int(success_count or 0), "tried": int(tried or 0)} @@ -217,13 +217,13 @@ def coupon_point_details(db: Session, *, trace_id: str) -> list[dict]: """按单个 trace 查询逐券结果;仅在后台用户点击分数时调用。""" rows = db.execute( select( - CouponClaimRecord.coupon_id, - CouponClaimRecord.coupon_name, - CouponClaimRecord.status, - CouponClaimRecord.reason, + CouponClaimEvent.coupon_id, + CouponClaimEvent.coupon_name, + CouponClaimEvent.status, + CouponClaimEvent.reason, ) - .where(CouponClaimRecord.trace_id == trace_id) - .order_by(CouponClaimRecord.id) + .where(CouponClaimEvent.trace_id == trace_id) + .order_by(CouponClaimEvent.id) ).all() return [ { diff --git a/app/admin/schemas/coupon_data.py b/app/admin/schemas/coupon_data.py index f295650..6b19fd7 100644 --- a/app/admin/schemas/coupon_data.py +++ b/app/admin/schemas/coupon_data.py @@ -79,10 +79,10 @@ class CouponDataRow(BaseModel): started_at: datetime = Field(..., description="发起时刻(明细「时间」列)") claimed_count: int | None = None point_success_count: int | None = Field( - None, description="本次成功券点位数(success+already_claimed);无逐券埋点为空" + None, description="本次成功单券数(success+already_claimed);无逐券事件为空" ) point_total_count: int | None = Field( - None, description="本次尝试券点位数(success+already_claimed+failed,不含 skipped);无逐券埋点为空" + None, description="本次尝试单券数(success+already_claimed+failed,不含 skipped);无逐券事件为空" ) trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id") ad_revenue_yuan: float = Field( diff --git a/app/api/v1/coupon.py b/app/api/v1/coupon.py index bd2127a..ecd40a8 100644 --- a/app/api/v1/coupon.py +++ b/app/api/v1/coupon.py @@ -81,7 +81,7 @@ def _record_claims_blocking( device_id: str, user_id: int | None, trace_id: str | None, results: list[dict] ) -> None: with SessionLocal() as db: - # 取本次 session 环境,给 coupon_claim_record 打 app_env 标(每券成功率表按它过滤;设计 §13)。 + # 取本次 session 环境,给每日资产和逐次事件同时打环境标。 app_env = coupon_repo.session_app_env(db, trace_id) coupon_repo.record_claims(db, device_id, user_id, trace_id, results, app_env=app_env) # 顺带把本帧「成功平台」并入 coupon_session.platform_success(admin 领券数据 ②整单/③点位成功率; @@ -176,7 +176,7 @@ async def coupon_step( resp_json = resp.json() - # 领券结果沉淀(资产):中间帧 last_coupon_result + done 帧 coupon_results 幂等写库。 + # 领券结果沉淀:每日资产 + 逐次事件;中间帧和 done 全量帧均幂等写库。 # 当前只记录、不参与"要不要领"判断(MVP 先不去重)。写库失败不影响返回。 if device_id: results = _extract_coupon_results(resp_json) diff --git a/app/models/__init__.py b/app/models/__init__.py index 5ed8a6b..dd4f609 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -21,6 +21,7 @@ from app.models.cps_wx_user import CpsWxUser # noqa: F401 from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401 from app.models.device import DeviceLiveness # noqa: F401 from app.models.coupon_state import ( # noqa: F401 + CouponClaimEvent, CouponClaimRecord, CouponDailyCompletion, CouponPromptEngagement, diff --git a/app/models/coupon_state.py b/app/models/coupon_state.py index d461613..6d52c53 100644 --- a/app/models/coupon_state.py +++ b/app/models/coupon_state.py @@ -96,6 +96,40 @@ class CouponClaimRecord(Base): ) +class CouponClaimEvent(Base): + """一次领券任务中的单券结果,按 ``(trace_id, coupon_id)`` 幂等。""" + + __tablename__ = "coupon_claim_event" + __table_args__ = ( + UniqueConstraint( + "trace_id", "coupon_id", + name="uq_coupon_claim_event_trace_coupon", + ), + Index("ix_coupon_claim_event_date_env", "claim_date", "app_env"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + trace_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + device_id: Mapped[str] = mapped_column(String(64), nullable=False) + user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True) + coupon_id: Mapped[str] = mapped_column(String(64), nullable=False) + claim_date: Mapped[date] = mapped_column(Date, nullable=False) + status: Mapped[str] = mapped_column(String(24), nullable=False) + app_env: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True) + vendor: Mapped[str | None] = mapped_column(String(48), nullable=True) + coupon_name: Mapped[str | None] = mapped_column(String(128), nullable=True) + claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + reason: Mapped[str | None] = mapped_column(String(255), nullable=True) + extra: Mapped[dict | None] = mapped_column(_JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), + nullable=False, + ) + + class CouponDailyCompletion(Base): """按 (device, 自然日) 记"今天是否已跑完整轮领券(到 done 帧)"——首页置灰源。 diff --git a/app/repositories/coupon_state.py b/app/repositories/coupon_state.py index 508da74..04ce173 100644 --- a/app/repositories/coupon_state.py +++ b/app/repositories/coupon_state.py @@ -14,6 +14,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.models.coupon_state import ( + CouponClaimEvent, CouponClaimRecord, CouponDailyCompletion, CouponPromptEngagement, @@ -164,11 +165,12 @@ def record_claims( results: list[dict], app_env: str | None = None, ) -> int: - """一批券领取结果幂等写入,返回写入(新增 + 更新)条数。 + """一批券领取结果同时写入每日资产表和逐次事件表。 results 单项取自 pricebot 的 last_coupon_result / done.coupon_results,识别字段: coupon_id(必需)/ status(必需)/ name / vendor / reason /(display_count)。 - (device, coupon_id, 今天) 唯一:重复上报同张券走更新(status 以最后一次为准)。 + - CouponClaimRecord 按 (device, coupon_id, 今天) 幂等,供每日资产口径使用。 + - CouponClaimEvent 按 (trace_id, coupon_id) 幂等,供 admin 逐场统计使用。 """ today = today_cn() written = 0 @@ -208,6 +210,41 @@ def record_claims( claimed_count=count, trace_id=trace_id, reason=r.get("reason"), extra=r, )) + if trace_id: + event = db.execute( + select(CouponClaimEvent).where( + CouponClaimEvent.trace_id == trace_id, + CouponClaimEvent.coupon_id == coupon_id, + ) + ).scalar_one_or_none() + if event is not None: + event.device_id = device_id + event.status = status + event.reason = r.get("reason") + event.vendor = r.get("vendor") + event.coupon_name = r.get("name") + event.extra = r + if user_id is not None: + event.user_id = user_id + if count is not None: + event.claimed_count = count + if app_env is not None: + event.app_env = app_env + else: + db.add(CouponClaimEvent( + trace_id=trace_id, + device_id=device_id, + user_id=user_id, + coupon_id=coupon_id, + claim_date=today, + status=status, + app_env=app_env, + vendor=r.get("vendor"), + coupon_name=r.get("name"), + claimed_count=count, + reason=r.get("reason"), + extra=r, + )) written += 1 if written == 0: return 0 diff --git a/scripts/seed_coupon_session_mock.py b/scripts/seed_coupon_session_mock.py new file mode 100644 index 0000000..7ce614b --- /dev/null +++ b/scripts/seed_coupon_session_mock.py @@ -0,0 +1,197 @@ +"""生成 admin「领券记录」本地联调数据。 + +用法: + python scripts/seed_coupon_session_mock.py + +脚本只清理 ``mock-coupon-repeat-*`` 前缀的数据并重新生成。打开后台「领券记录」, +日期选今天;分别切换 prod/dev,可验证同一设备同一天多次领券仍各自显示正确分数。 +""" + +from __future__ import annotations + +import sys +from datetime import UTC, datetime +from pathlib import Path +from zoneinfo import ZoneInfo + +from sqlalchemy import delete, select + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from app.db.session import SessionLocal, engine # noqa: E402 +from app.models.coupon_state import ( # noqa: E402 + CouponClaimEvent, + CouponClaimRecord, + CouponSession, +) +from app.models.user import User # noqa: E402 +from app.repositories.coupon_state import record_claims, today_cn # noqa: E402 + +PREFIX = "mock-coupon-repeat-" +CN_TZ = ZoneInfo("Asia/Shanghai") +DEVICE_REPEAT = f"{PREFIX}device" +DEVICE_CONTROL = f"{PREFIX}control-device" +PHONE = "19900009001" +USERNAME = "80000009001" +PLATFORM_ELAPSED = { + "meituan-waimai": 46_800, + "taobao-shanguang": 19_500, + "jd-waimai": 24_000, +} + +FIRST_RESULTS = [ + {"coupon_id": "mt_mock_1", "name": "美团模拟券1", "vendor": "meituan", "status": "success"}, + {"coupon_id": "mt_mock_2", "name": "美团模拟券2", "vendor": "meituan", "status": "success"}, + {"coupon_id": "mt_mock_3", "name": "美团模拟券3", "vendor": "meituan", "status": "success"}, + {"coupon_id": "mt_mock_4", "name": "美团模拟券4", "vendor": "meituan", "status": "failed", "reason": "模拟失败"}, + {"coupon_id": "tb_mock_1", "name": "淘宝模拟券1", "vendor": "taobao", "status": "success"}, + {"coupon_id": "tb_mock_2", "name": "淘宝模拟券2", "vendor": "taobao", "status": "success"}, + {"coupon_id": "tb_mock_3", "name": "淘宝模拟券3", "vendor": "taobao", "status": "success"}, + {"coupon_id": "jd_mock_1", "name": "京东模拟券", "vendor": "jingdong", "status": "success"}, +] + +SECOND_RESULTS = [ + {"coupon_id": "mt_mock_1", "name": "美团模拟券1", "vendor": "meituan", "status": "skipped", "reason": "模拟跳过,不计分母"}, + {"coupon_id": "mt_mock_2", "name": "美团模拟券2", "vendor": "meituan", "status": "already_claimed"}, + {"coupon_id": "mt_mock_3", "name": "美团模拟券3", "vendor": "meituan", "status": "success"}, + {"coupon_id": "mt_mock_4", "name": "美团模拟券4", "vendor": "meituan", "status": "success"}, + {"coupon_id": "tb_mock_1", "name": "淘宝模拟券1", "vendor": "taobao", "status": "success"}, + {"coupon_id": "tb_mock_2", "name": "淘宝模拟券2", "vendor": "taobao", "status": "success"}, + {"coupon_id": "tb_mock_3", "name": "淘宝模拟券3", "vendor": "taobao", "status": "already_claimed"}, + {"coupon_id": "jd_mock_1", "name": "京东模拟券", "vendor": "jingdong", "status": "success"}, +] + + +def _started_at(hour: int, minute: int) -> datetime: + local = datetime.combine(today_cn(), datetime.min.time()).replace( + hour=hour, minute=minute, tzinfo=CN_TZ + ) + return local.astimezone(UTC) + + +def _session( + *, + trace_id: str, + device_id: str, + user_id: int, + app_env: str, + hour: int, + minute: int, + status: str = "completed", + elapsed_ms: int | None = 91_900, + platform_elapsed: dict[str, int] | None = None, +) -> CouponSession: + started_at = _started_at(hour, minute) + return CouponSession( + trace_id=trace_id, + device_id=device_id, + user_id=user_id, + status=status, + app_env=app_env, + platforms=[], + origin_package=None, + device_model="Mock Phone", + rom="MockOS 1", + started_at=started_at, + started_date=today_cn(), + finished_at=started_at if status != "started" else None, + elapsed_ms=elapsed_ms, + platform_elapsed=platform_elapsed, + platform_success=( + ["meituan-waimai", "taobao-shanguang", "jd-waimai"] + if status == "completed" else None + ), + claimed_count=7 if status == "completed" else 0, + ) + + +def main() -> None: + # 本地旧库 Alembic 版本链可能未同步;仅为联调补建新事件表,正式环境仍走 migration。 + CouponClaimEvent.__table__.create(bind=engine, checkfirst=True) + with SessionLocal() as db: + db.execute(delete(CouponClaimEvent).where( + CouponClaimEvent.trace_id.startswith(PREFIX) + )) + db.execute(delete(CouponClaimRecord).where( + CouponClaimRecord.device_id.startswith(PREFIX) + )) + db.execute(delete(CouponSession).where( + CouponSession.trace_id.startswith(PREFIX) + )) + user = db.execute(select(User).where(User.phone == PHONE)).scalar_one_or_none() + if user is None: + user = User( + phone=PHONE, + username=USERNAME, + register_channel="sms", + nickname="领券重复测试", + ) + db.add(user) + db.flush() + + first_trace = f"{PREFIX}dev-first" + second_trace = f"{PREFIX}prod-second" + abandoned_trace = f"{PREFIX}prod-abandoned" + control_trace = f"{PREFIX}prod-control" + db.add_all([ + _session( + trace_id=first_trace, + device_id=DEVICE_REPEAT, + user_id=user.id, + app_env="dev", + hour=10, + minute=0, + platform_elapsed=PLATFORM_ELAPSED, + ), + _session( + trace_id=second_trace, + device_id=DEVICE_REPEAT, + user_id=user.id, + app_env="prod", + hour=15, + minute=0, + platform_elapsed=PLATFORM_ELAPSED, + ), + _session( + trace_id=abandoned_trace, + device_id=DEVICE_REPEAT, + user_id=user.id, + app_env="prod", + hour=16, + minute=0, + status="abandoned", + elapsed_ms=21_500, + platform_elapsed={"meituan-waimai": 20_500}, + ), + _session( + trace_id=control_trace, + device_id=DEVICE_CONTROL, + user_id=user.id, + app_env="prod", + hour=17, + minute=0, + platform_elapsed=PLATFORM_ELAPSED, + ), + ]) + db.commit() + + record_claims( + db, DEVICE_REPEAT, user.id, first_trace, FIRST_RESULTS, app_env="dev" + ) + record_claims( + db, DEVICE_REPEAT, user.id, second_trace, SECOND_RESULTS, app_env="prod" + ) + record_claims( + db, DEVICE_CONTROL, user.id, control_trace, FIRST_RESULTS, app_env="prod" + ) + + print(f"已生成 {today_cn()} 的领券 mock 数据。") + print("筛选用户 19900009001。") + print("prod 应有:7/7(100.0%)、-、7/8(87.5%)三条。") + print("dev 应有:7/8(87.5%)一条。") + + +if __name__ == "__main__": + main() diff --git a/tests/test_coupon_claim_event.py b/tests/test_coupon_claim_event.py new file mode 100644 index 0000000..98316eb --- /dev/null +++ b/tests/test_coupon_claim_event.py @@ -0,0 +1,54 @@ +"""逐次单券事件不能被同设备同日的每日去重记录串场。""" + +from sqlalchemy import delete, select + +from app.admin.repositories.coupon_data import _point_scores_by_trace, coupon_point_details +from app.db.session import SessionLocal +from app.models.coupon_state import CouponClaimEvent, CouponClaimRecord +from app.repositories.coupon_state import record_claims + + +def test_same_device_same_day_keeps_scores_for_each_trace() -> None: + db = SessionLocal() + device = "event-repeat-device" + first_trace = "event-repeat-first" + second_trace = "event-repeat-second" + first_results = [ + {"coupon_id": "mt-repeat", "name": "美团测试券", "status": "success"}, + {"coupon_id": "tb-repeat", "name": "淘宝测试券", "status": "failed"}, + ] + second_results = [ + {"coupon_id": "mt-repeat", "name": "美团测试券", "status": "already_claimed"}, + {"coupon_id": "tb-repeat", "name": "淘宝测试券", "status": "success"}, + ] + try: + record_claims( + db, device, None, first_trace, first_results, app_env="dev" + ) + record_claims( + db, device, None, second_trace, second_results, app_env="prod" + ) + + assets = db.execute( + select(CouponClaimRecord).where(CouponClaimRecord.device_id == device) + ).scalars().all() + assert len(assets) == 2 + assert {row.trace_id for row in assets} == {first_trace} + + events = db.execute( + select(CouponClaimEvent).where(CouponClaimEvent.device_id == device) + ).scalars().all() + assert len(events) == 4 + assert {row.trace_id for row in events} == {first_trace, second_trace} + + scores = _point_scores_by_trace(db, [first_trace, second_trace]) + assert scores[first_trace] == {"succeeded": 1, "tried": 2} + assert scores[second_trace] == {"succeeded": 2, "tried": 2} + assert [row["status"] for row in coupon_point_details( + db, trace_id=second_trace + )] == ["already_claimed", "success"] + finally: + db.execute(delete(CouponClaimEvent).where(CouponClaimEvent.device_id == device)) + db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.device_id == device)) + db.commit() + db.close() diff --git a/tests/test_coupon_point_score.py b/tests/test_coupon_point_score.py index 5108fcf..e238f6d 100644 --- a/tests/test_coupon_point_score.py +++ b/tests/test_coupon_point_score.py @@ -13,7 +13,7 @@ from app.admin.repositories.coupon_data import ( ) from app.admin.security import create_admin_token from app.db.session import SessionLocal -from app.models.coupon_state import CouponClaimRecord, CouponSession +from app.models.coupon_state import CouponClaimEvent, CouponSession def test_point_scores_by_trace() -> None: @@ -22,14 +22,14 @@ def test_point_scores_by_trace() -> None: trace = "point-score-trace" try: db.add_all([ - CouponClaimRecord( + CouponClaimEvent( + trace_id=trace, device_id="score-device", coupon_id=f"mt-score-{status}", claim_date=date(2020, 1, 2), status=status, coupon_name=f"测试点位-{status}", reason="测试失败" if status == "failed" else None, - trace_id=trace, ) for status in ("success", "already_claimed", "failed", "skipped") ]) @@ -53,12 +53,12 @@ def test_skipped_detail_does_not_create_a_score() -> None: db = SessionLocal() trace = "point-score-skipped" try: - db.add(CouponClaimRecord( + db.add(CouponClaimEvent( + trace_id=trace, device_id="score-device-skipped", coupon_id="mt-score-skipped-only", claim_date=date(2020, 1, 2), status="skipped", - trace_id=trace, )) db.flush() @@ -87,12 +87,12 @@ def test_coupon_data_report_returns_scores_without_embedding_details() -> None: started_date=report_date, )) db.add_all([ - CouponClaimRecord( + CouponClaimEvent( + trace_id=trace, device_id="score-report-device", coupon_id=f"mt-report-{status}", claim_date=report_date, status=status, - trace_id=trace, ) for status in ("success", "failed") ]) @@ -128,14 +128,14 @@ def test_coupon_point_details_endpoint() -> None: role="super_admin", ) token, _expires_at = create_admin_token(admin_id=admin.id, role=admin.role) - db.add(CouponClaimRecord( + db.add(CouponClaimEvent( + trace_id=trace, device_id="point-details-endpoint-device", coupon_id="mt-point-details-endpoint", coupon_name="接口测试券", claim_date=date(2020, 1, 5), status="failed", reason="接口测试失败", - trace_id=trace, )) db.commit() @@ -156,6 +156,6 @@ def test_coupon_point_details_endpoint() -> None: } finally: db.rollback() - db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.trace_id == trace)) + db.execute(delete(CouponClaimEvent).where(CouponClaimEvent.trace_id == trace)) db.commit() db.close() diff --git a/tests/test_coupon_slots.py b/tests/test_coupon_slots.py index b4f2a5b..10ce6d3 100644 --- a/tests/test_coupon_slots.py +++ b/tests/test_coupon_slots.py @@ -7,7 +7,7 @@ from sqlalchemy import delete, select from app.admin.repositories.coupon_data import coupon_slot_report from app.db.session import SessionLocal -from app.models.coupon_state import CouponClaimRecord, CouponSession +from app.models.coupon_state import CouponClaimEvent, CouponClaimRecord, CouponSession from app.repositories.coupon_state import record_claims, session_app_env @@ -49,6 +49,7 @@ def test_record_claims_stamps_app_env() -> None: assert row.app_env == "prod" assert row.status == "already_claimed" finally: + db.execute(delete(CouponClaimEvent).where(CouponClaimEvent.trace_id == "t-stamp")) db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.device_id == dev)) db.commit() db.close() From 71aef455f45689d204656fe32008f0f22cece0ee Mon Sep 17 00:00:00 2001 From: linkeyu Date: Fri, 24 Jul 2026 11:16:38 +0800 Subject: [PATCH 22/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=AD=BE?= =?UTF-8?q?=E5=88=B0=E8=86=A8=E8=83=80=E9=87=91=E5=B8=81=E5=BD=92=E5=85=A5?= =?UTF-8?q?=E7=9C=8B=E8=A7=86=E9=A2=91=E5=88=86=E7=B1=BB=20(#169)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 修改内容 - 将历史 `signin_boost` 金币从“常规任务金币”分类移出。 - 将 `signin_boost` 与 `reward_video/ad_reward` 一起计入“看视频金币”。 - 保留独立的 `signin_boost_coin_total` 历史审计字段。 - 增加不重不漏回归测试,确认分类调整前后本期发放总额保持不变。 ## 本地验证 - `tests/test_admin_read.py`:16 项通过。 - Ruff 与 `git diff --check`:通过。 - 全量后端测试:505 项通过;8 项为 `main` 现有无关失败。 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/169 Co-authored-by: linkeyu Co-committed-by: linkeyu --- app/admin/repositories/stats.py | 7 ++--- tests/test_admin_read.py | 50 ++++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/app/admin/repositories/stats.py b/app/admin/repositories/stats.py index e82f5b6..26e13df 100644 --- a/app/admin/repositories/stats.py +++ b/app/admin/repositories/stats.py @@ -30,18 +30,17 @@ from app.models.user import User from app.models.wallet import CoinTransaction, WithdrawOrder _BEIJING = timezone(timedelta(hours=8)) -REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward") +REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward", "signin_boost") # 领券/比价奖励金币的真实来源是信息流广告发奖(ad_feed_reward_record,按 feed_scene 分场景); # coin_transaction 里只有扁平的 feed_ad_reward、biz_type 不分 coupon/comparison,故这俩桶历史从未 -# 被写入,仅留作未来兜底,实际金额在下方按 feed_scene 汇总 ad_feed_reward_record 得出。reward_video/ -# ad_reward 是激励视频,单独成桶、不再混进领券奖励(历史误并会把激励视频金币双计进领券)。 +# 被写入,仅留作未来兜底,实际金额在下方按 feed_scene 汇总 ad_feed_reward_record 得出。 +# reward_video/ad_reward 及历史 signin_boost 均归看视频桶,不再混进领券奖励或常规任务。 COUPON_REWARD_BIZ_TYPES = ("coupon", "coupon_reward") COMPARISON_REWARD_BIZ_TYPES = ("comparison", "compare_reward", "comparison_reward") # 常规任务必须按明确来源相加;不能从全部正向流水反减排除项,否则新增广告/运营 # biz_type 时会在排除清单更新前自动混入该桶。task_ 前缀在查询处单独覆盖现有及未来任务。 REGULAR_TASK_EXACT_BIZ_TYPES = ( "signin", - "signin_boost", "price_report_reward", "feedback_reward", ) diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index d2b6526..a01ca96 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -468,13 +468,13 @@ def test_period_regular_task_coin_uses_explicit_allowlist( d = "2021-06-18" included = { "signin": 100, - "signin_boost": 200, "task_enable_notification": 300, "task_other": 400, "price_report_reward": 500, "feedback_reward": 600, } excluded = { + "signin_boost": 200, "feed_ad_reward_coupon": 700, "feed_ad_reward_comparison": 800, "feed_ad_reward": 900, @@ -514,3 +514,51 @@ def test_period_regular_task_coin_uses_explicit_allowlist( coins = response.json()["period"]["coins"] assert coins["regular_task_coin_total"] == sum(included.values()) assert coins["task_coin_total"] == 700 + assert coins["reward_video_coin_total"] == 1200 + + +def test_period_signin_boost_moves_to_reward_video_without_double_count( + admin_client: TestClient, admin_token: str +) -> None: + """历史签到膨胀归看视频桶,不再进常规任务;本期发放总额不变且不重复计算。""" + from datetime import datetime + + from app.models.wallet import CoinTransaction + + d = "2021-06-19" + rows = [ + ("signin_boost", 200), + ("reward_video", 100), + ("signin", 50), + ] + db = SessionLocal() + try: + uid = user_repo.upsert_user_for_login( + db, phone="13800008805", register_channel="sms" + ).id + balance = 0 + for index, (biz_type, amount) in enumerate(rows, start=1): + balance += amount + db.add(CoinTransaction( + user_id=uid, + amount=amount, + balance_after=balance, + biz_type=biz_type, + ref_id=f"signin-boost-route-{index}", + created_at=datetime(2021, 6, 19, 12, 0, index), + )) + db.commit() + finally: + db.close() + + response = admin_client.get( + "/admin/api/stats/overview", + params={"date_from": d, "date_to": d}, + headers=_auth(admin_token), + ) + assert response.status_code == 200, response.text + coins = response.json()["period"]["coins"] + assert coins["granted_total"] == 350 + assert coins["reward_video_coin_total"] == 300 + assert coins["regular_task_coin_total"] == 50 + assert coins["signin_boost_coin_total"] == 200 From f7a7a49281e1a8aa85a260def05dbc7133137f77 Mon Sep 17 00:00:00 2001 From: guke Date: Fri, 24 Jul 2026 11:17:09 +0800 Subject: [PATCH 23/42] =?UTF-8?q?fix(withdraw):=20=E5=85=8D=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E6=8E=88=E6=9D=83=E5=B7=B2=E5=BC=80=E5=90=AF=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E5=8A=A0=20authorization=5Fid=20=E9=9D=9E=E7=A9=BA,?= =?UTF-8?q?=E4=B8=8E=E6=89=93=E6=AC=BE=E4=B8=80=E8=87=B4=20(#168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 免确认授权已开启判定加 authorization_id 非空,与打款一致 --------- Co-authored-by: guke Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/168 --- app/api/v1/wallet.py | 5 +- app/core/logging.py | 53 +++++- scripts/seed_mock_feedback.py | 277 +++++++++++++++++++++++++++++ scripts/seed_mock_price_reports.py | 4 +- scripts/seed_mock_withdraws.py | 6 +- tests/test_logging_rotation.py | 73 ++++++++ tests/test_withdraw.py | 61 ++++++- 7 files changed, 471 insertions(+), 8 deletions(-) create mode 100644 scripts/seed_mock_feedback.py create mode 100644 tests/test_logging_rotation.py diff --git a/app/api/v1/wallet.py b/app/api/v1/wallet.py index 454ff7c..7b0dac0 100644 --- a/app/api/v1/wallet.py +++ b/app/api/v1/wallet.py @@ -192,7 +192,7 @@ def withdraw_info( wechat_bound=bool(u and u.wechat_openid), wechat_nickname=u.wechat_nickname if u else None, wechat_avatar_url=u.wechat_avatar_url if u else None, - transfer_auth_enabled=bool(auth and auth.state == "active"), + transfer_auth_enabled=bool(auth and auth.state == "active" and auth.authorization_id), tiers=[WithdrawTierOut(**t) for t in crud_wallet.withdraw_tier_states(db, user.id, source)], ) @@ -335,7 +335,8 @@ def open_transfer_auth(user: CurrentUser, db: DbSession) -> TransferAuthResultOu def transfer_auth_status(user: CurrentUser, db: DbSession) -> TransferAuthStatusOut: auth = crud_wallet.sync_transfer_auth(db, user.id) state = auth.state if auth else "none" - return TransferAuthStatusOut(state=state, enabled=(state == "active")) + enabled = bool(auth and auth.state == "active" and auth.authorization_id) + return TransferAuthStatusOut(state=state, enabled=enabled) @router.post( diff --git a/app/core/logging.py b/app/core/logging.py index cb91b5c..c3833bd 100644 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -22,13 +22,13 @@ from __future__ import annotations import json import logging import os +import shutil import sys from contextvars import ContextVar from datetime import datetime from logging.handlers import RotatingFileHandler from pathlib import Path - # 请求级 trace_id:入口(如 compare.py 透传壳)set 之后, 本请求上下文(含 run_in_threadpool # 拷贝出去的线程)内所有日志自动带上。默认空串 = 非请求上下文(启动/后台 worker)。 trace_id_ctx: ContextVar[str] = ContextVar("trace_id", default="") @@ -91,6 +91,55 @@ class TextFormatter(logging.Formatter): return f"{base} trace={tid}" if tid else base +class SafeRotatingFileHandler(RotatingFileHandler): + """Windows 下不会被外部句柄卡死的 RotatingFileHandler。 + + stdlib 轮转靠 rename 活动文件(app-server.log → .1);Windows 只要有别的句柄(IDE 索引、 + app.admin.main 第二进程、残留 --reload worker、杀软扫描)开着它, rename 就 WinError 32, + 轮转永久卡死——文件停在 maxBytes、之后每条日志被丢。这里 Windows 改用 copytruncate:把活动 + 文件拷进备份、再通过自己的句柄原地清空, 从不 rename 活动文件, 故外部句柄开着也能转。 + POSIX(生产 Linux)rename 打开中的文件本就合法, 保留 stdlib 的原子轮转不变。 + + 代价:copytruncate 在“拷贝→清空”极窄窗口内并发写可能丢几行(仅跨进程;同进程 emit 有 + handler 锁串行, 无此问题)。对本地开发日志可接受。 + """ + + def doRollover(self) -> None: + if os.name != "nt": + super().doRollover() + return + if self.stream is None: + self.stream = self._open() + else: + self.stream.flush() + try: + self._copytruncate_backups() + except OSError: + # 备份腾挪是尽力而为:任一备份被占用也绝不能挡住下面的清空, 否则活动文件继续涨、 + # 轮转又卡死——那就白改了。 + pass + # 通过自己独占的句柄原地清空:不涉及 rename, 外部只读句柄不受影响。 + self.stream.seek(0) + self.stream.truncate() + self.stream.flush() + + def _copytruncate_backups(self) -> None: + """把 .N-1→.N 逐级腾挪, 再把活动文件拷到 .1(不动活动文件本身)。""" + if self.backupCount <= 0: + return + for i in range(self.backupCount - 1, 0, -1): + sfn = self.rotation_filename(f"{self.baseFilename}.{i}") + dfn = self.rotation_filename(f"{self.baseFilename}.{i + 1}") + if os.path.exists(sfn): + if os.path.exists(dfn): + os.remove(dfn) + os.replace(sfn, dfn) + dfn = self.rotation_filename(f"{self.baseFilename}.1") + if os.path.exists(dfn): + os.remove(dfn) + shutil.copyfile(self.baseFilename, dfn) + + _CONFIGURED = False @@ -126,7 +175,7 @@ def setup_logging(debug: bool = False) -> None: Path(os.getenv("LOG_DIR", "logs")) / "app-server.log" ) Path(log_file).parent.mkdir(parents=True, exist_ok=True) - file_handler = RotatingFileHandler( + file_handler = SafeRotatingFileHandler( log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8", ) file_handler.setFormatter(JsonFormatter(service)) diff --git a/scripts/seed_mock_feedback.py b/scripts/seed_mock_feedback.py new file mode 100644 index 0000000..2754054 --- /dev/null +++ b/scripts/seed_mock_feedback.py @@ -0,0 +1,277 @@ +"""一次性 mock:造几条不同状态的用户反馈,供运营后台「反馈工单」页联调验收。 + +覆盖: + - 三个状态 tab(待审核 pending / 已采纳 adopted / 未采纳 rejected),重点铺「待审核」; + - 两种反馈类型 source(普通反馈 profile /「我的」页入口、比价反馈 comparison / 比价结果页入口), + 比价反馈带「问题场景」scene(找错商品/优惠不对/比价太慢…); + - 提交端环境快照(app_version / device_model / rom_name / android_version)——新端反馈才有, + 另留 1~2 条历史反馈(env 全 NULL、contact 有值)测「旧数据」展示; + - 截图:在 data/media/feedback/ 生成真实可加载的纯色 PNG(手写字节,无需 Pillow), + 让审核抽屉的图能真加载出来(与 seed_mock_price_reports 同法)。 + - 已采纳条带 reward_coins + admin_reply + review_note;未采纳条带 reject_reason + admin_reply。 + +幂等:每次运行先按固定 mock 手机号清掉旧 mock 用户/反馈 + 删 mock 截图再重建。仅清理用 --clean-only。 + + .venv\\Scripts\\python.exe scripts\\seed_mock_feedback.py + .venv\\Scripts\\python.exe scripts\\seed_mock_feedback.py --clean-only + +看图:前端「反馈工单」页(http://localhost:3001 → 反馈)。图经 NEXT_PUBLIC_MEDIA_BASE +(本地 = http://localhost:8770)由 App 后端 /media 加载——改过 .env.local 后需重启 next dev, +且 App 后端(:8770)要在跑。 +""" +from __future__ import annotations + +import argparse +import struct +import sys +import zlib +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from sqlalchemy import delete, select + +from app.core.config import settings +from app.db.session import SessionLocal +from app.models.feedback import Feedback +from app.models.user import User +from app.repositories.user import _gen_unique_username + +# Windows GBK 控制台下也能正常打印中文/¥(避免 UnicodeEncodeError) +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +# 固定 mock 手机号:脚本只动这些号,便于幂等重建 / 清理。 +# 刻意与 seed_mock_withdraws / seed_mock_price_reports 的号段错开,互不干扰。 +MOCK_PHONES = [ + "13255550001", + "13255550002", + "13255550003", + "13255550004", + "13255550005", +] + +_FEEDBACK_DIR = Path(settings.MEDIA_ROOT) / "feedback" +_MOCK_IMG_GLOB = "mock_fb_*.png" # 本脚本生成的图前缀,清理时按此删 + + +def _naive_utc_now() -> datetime: + """与 func.now() 在 SQLite 的口径一致:naive UTC。反馈 created_at 走 server_default=func.now(), + 这里显式造数据也用 naive UTC,和真实反馈行同源,前端展示口径一致。""" + return datetime.now(UTC).replace(tzinfo=None) + + +def _solid_png(width: int, height: int, rgb: tuple[int, int, int]) -> bytes: + """生成一张纯色 PNG(truecolor RGB)的字节,无需 Pillow。颜色块即可肉眼判断「图加载出来了」。""" + def _chunk(typ: bytes, data: bytes) -> bytes: + body = typ + data + return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF) + + ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) # 8bit/通道, color type 2 = RGB + row = b"\x00" + bytes(rgb) * width # 每行前缀 filter byte 0 + idat = zlib.compress(row * height, 9) + return b"\x89PNG\r\n\x1a\n" + _chunk(b"IHDR", ihdr) + _chunk(b"IDAT", idat) + _chunk(b"IEND", b"") + + +def _write_mock_image(name: str, rgb: tuple[int, int, int]) -> str: + """写一张 mock 截图到 media/feedback/,返回其相对 URL(/media/feedback/)。""" + _FEEDBACK_DIR.mkdir(parents=True, exist_ok=True) + (_FEEDBACK_DIR / name).write_bytes(_solid_png(320, 320, rgb)) + return f"{settings.MEDIA_URL_PREFIX}/feedback/{name}" + + +def clean(db) -> int: + uids = list(db.execute(select(User.id).where(User.phone.in_(MOCK_PHONES))).scalars()) + if uids: + db.execute(delete(Feedback).where(Feedback.user_id.in_(uids))) + db.execute(delete(User).where(User.id.in_(uids))) + db.commit() + # 删 mock 截图文件 + if _FEEDBACK_DIR.exists(): + for f in _FEEDBACK_DIR.glob(_MOCK_IMG_GLOB): + f.unlink(missing_ok=True) + return len(uids) + + +def seed(db) -> list[Feedback]: + now = _naive_utc_now() + + def ago(**kw) -> datetime: + return now - timedelta(**kw) + + # 1) 建 5 个 mock 用户(U5 昵称留空测 "-" 展示) + users_spec = [ + ("13255550001", "反馈小达人"), + ("13255550002", "比价挑刺王"), + ("13255550003", "热心用户阿明"), + ("13255550004", "老用户张姐"), + ("13255550005", None), + ] + users: dict[str, User] = {} + for phone, nickname in users_spec: + u = User( + phone=phone, + username=_gen_unique_username(db), # 列 NOT NULL + unique,仿真实注册生成 + nickname=nickname, + register_channel="sms", + status="active", + created_at=ago(days=15), + last_login_at=ago(hours=1), + ) + db.add(u) + users[phone] = u + db.flush() # 拿 user.id + + # 2) 生成 mock 截图(不同颜色块,便于肉眼区分「都加载出来了」) + palette = [ + (24, 144, 255), # 蓝 + (82, 196, 26), # 绿 + (250, 173, 20), # 橙 + (245, 34, 45), # 红 + ] + imgs = [_write_mock_image(f"mock_fb_{i}.png", palette[i]) for i in range(len(palette))] + + # 3) 造反馈记录 + def fb( + phone: str, + content: str, + *, + source: str = "profile", + scene: str | None = None, + images: list[str] | None = None, + contact: str = "", + status: str = "pending", + reject_reason: str | None = None, + reward_coins: int | None = None, + review_note: str | None = None, + admin_reply: str | None = None, + app_version: str | None = None, + device_model: str | None = None, + rom_name: str | None = None, + android_version: str | None = None, + created: datetime, + ) -> Feedback: + return Feedback( + user_id=users[phone].id, + content=content, + contact=contact, # 列 NOT NULL:新端存空串,历史数据有值 + source=source, + scene=scene, + images=images, + status=status, + reject_reason=reject_reason, + reward_coins=reward_coins, + review_note=review_note, + admin_reply=admin_reply, + app_version=app_version, + device_model=device_model, + rom_name=rom_name, + android_version=android_version, + reviewed_at=(created + timedelta(hours=2)) if status != "pending" else None, + created_at=created, + ) + + feedbacks = [ + # ===== 待审核 pending(默认 tab,重点铺量)===== + # 普通反馈 · 新端(带环境快照)· 无图 + fb("13255550001", "签到金币到账有时候会延迟一两分钟,能不能做成实时到账?", + source="profile", + app_version="2.3.1", device_model="PJA110", rom_name="ColorOS", android_version="14", + created=ago(minutes=6)), + # 比价反馈 · scene=优惠不对 · 新端 · 2 图 + fb("13255550002", "这家店京东外卖的到手价比你们算出来的最低价还低,截图为证,麻烦核实。", + source="comparison", scene="优惠不对", images=[imgs[0], imgs[1]], + app_version="2.3.1", device_model="M2012K11AC", rom_name="MIUI", android_version="13", + created=ago(minutes=22)), + # 比价反馈 · scene=找错商品 · 新端 · 无图 + fb("13255550002", "比价结果里的商品跟我搜的不是同一个规格,数量对不上。", + source="comparison", scene="找错商品", + app_version="2.3.0", device_model="V2309A", rom_name="OriginOS", android_version="14", + created=ago(hours=1)), + # 普通反馈 · 新端 · 1 图(表扬 + 小问题) + fb("13255550003", "提现秒到账,好评!顺手反馈个小 bug:金币记录页偶尔白屏,要退出去重进。", + source="profile", images=[imgs[2]], + app_version="2.3.1", device_model="23078RKD5C", rom_name="MIUI", android_version="14", + created=ago(hours=3)), + # 比价反馈 · scene=比价太慢 · 历史数据(env 全 NULL、contact 有值) + fb("13255550004", "比价转圈太久了,经常要等十几秒才出结果,体验不太好。", + source="comparison", scene="比价太慢", contact="微信 zhangjie_66", + created=ago(days=1, hours=2)), + # 普通反馈 · 历史数据(env 全 NULL、contact 有值)· 无昵称用户 + fb("13255550005", "希望能增加支付宝提现,微信零钱用不太习惯。", + source="profile", contact="QQ 100200300", + created=ago(days=1, hours=8)), + + # ===== 已采纳 adopted(发金币 + 回复)===== + fb("13255550003", "建议在比价结果页加个「一键复制口令」,分享给家人更方便。", + source="profile", images=[imgs[3]], + status="adopted", reward_coins=2000, + review_note="有效产品建议,已排期到 2.4.0", admin_reply="感谢反馈!该功能已在规划中,金币奖励已发放~", + app_version="2.2.8", device_model="PJA110", rom_name="ColorOS", android_version="13", + created=ago(days=2)), + + # ===== 未采纳 rejected(带原因 + 回复)===== + fb("13255550002", "你们算的价格不准,我看到的更便宜。", + source="comparison", scene="价格不准", + status="rejected", reject_reason="截图价格为限时活动价且已过期,不满足「长期可复现更低价」条件,暂不采纳。", + admin_reply="感谢参与,本次未通过,欢迎继续上报有效更低价~", + app_version="2.3.0", device_model="M2012K11AC", rom_name="MIUI", android_version="13", + created=ago(days=3)), + ] + db.add_all(feedbacks) + db.commit() + for f in feedbacks: + db.refresh(f) + return feedbacks + + +def main() -> None: + parser = argparse.ArgumentParser(description="造用户反馈 mock 数据(运营后台反馈工单页联调用)") + parser.add_argument("--clean-only", action="store_true", help="只清理 mock 数据,不重建") + args = parser.parse_args() + + db = SessionLocal() + try: + removed = clean(db) + if removed: + print(f"🧹 已清理旧 mock:{removed} 个用户及其反馈 + mock 截图") + if args.clean_only: + print("✅ 仅清理,已完成。") + return + + feedbacks = seed(db) + + status_label = {"pending": "待审核", "adopted": "已采纳", "rejected": "未采纳"} + source_label = {"profile": "普通反馈", "comparison": "比价反馈"} + by_status: dict[str, list[Feedback]] = {} + for f in feedbacks: + by_status.setdefault(f.status, []).append(f) + + print(f"\n✅ 已生成 {len(feedbacks)} 条反馈(截图落 {_FEEDBACK_DIR}),分布:") + for st in ("pending", "adopted", "rejected"): + lst = by_status.get(st, []) + print(f" {status_label[st]:<4} {len(lst)} 条") + + print("\n 明细(#id | 状态 | 类型/场景 | 图 | 内容):") + uid2phone = dict( + db.execute(select(User.id, User.phone).where(User.phone.in_(MOCK_PHONES))).all() + ) + for f in feedbacks: + src = source_label.get(f.source, f.source) + scene = f"·{f.scene}" if f.scene else "" + nimg = len(f.images or []) + snippet = f.content[:20] + ("…" if len(f.content) > 20 else "") + print( + f" #{f.id} [{status_label.get(f.status, f.status)}] " + f"{src}{scene} {nimg}图 {uid2phone.get(f.user_id, '?')} {snippet}" + ) + print( + "\n👉 打开 http://localhost:3001 → 反馈 查看(默认「待审核」tab)。" + "\n 图加载不出来时排查:① 是否重启过 next dev(读 .env.local 的 NEXT_PUBLIC_MEDIA_BASE)" + " ② App 后端(:8770)是否在跑(它托管 /media)。" + ) + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/seed_mock_price_reports.py b/scripts/seed_mock_price_reports.py index 6ca04fe..5c70e4e 100644 --- a/scripts/seed_mock_price_reports.py +++ b/scripts/seed_mock_price_reports.py @@ -30,6 +30,7 @@ from app.core.config import settings from app.db.session import SessionLocal from app.models.price_report import PriceReport from app.models.user import User +from app.repositories.user import _gen_unique_username if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") # Windows 控制台输出中文/¥ @@ -84,10 +85,11 @@ def seed(db) -> list[PriceReport]: # 1) 建 3 个 mock 用户 users: dict[str, User] = {} for i, (phone, nickname) in enumerate( - zip(MOCK_PHONES, ["省钱小王", "比价老李", "薅羊毛阿珍"]) + zip(MOCK_PHONES, ["省钱小王", "比价老李", "薅羊毛阿珍"], strict=True) ): u = User( phone=phone, + username=_gen_unique_username(db), # 列 NOT NULL + unique,仿真实注册生成 nickname=nickname, register_channel="sms", status="active", diff --git a/scripts/seed_mock_withdraws.py b/scripts/seed_mock_withdraws.py index ad45f29..4902bc8 100644 --- a/scripts/seed_mock_withdraws.py +++ b/scripts/seed_mock_withdraws.py @@ -21,7 +21,7 @@ import argparse import sys import uuid from collections import defaultdict -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from sqlalchemy import delete, select @@ -31,6 +31,7 @@ from app.models.ad_feed_reward import AdFeedRewardRecord from app.models.ad_reward import AdRewardRecord from app.models.user import User from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder +from app.repositories.user import _gen_unique_username # Windows GBK 控制台下也能正常打印中文(避免 UnicodeEncodeError) if hasattr(sys.stdout, "reconfigure"): @@ -51,7 +52,7 @@ REMARK = {"exchange_in": "金币兑入", "withdraw": "提现扣款", "withdraw_r def _naive_utc_now() -> datetime: """与 func.now() 在 SQLite 的口径一致:naive UTC。前端按 UTC 解析再转北京时间。""" - return datetime.now(timezone.utc).replace(tzinfo=None) + return datetime.now(UTC).replace(tzinfo=None) def _mock_transfer_no() -> str: @@ -96,6 +97,7 @@ def seed(db) -> list[WithdrawOrder]: for phone, nickname in users_spec: u = User( phone=phone, + username=_gen_unique_username(db), # 列 NOT NULL + unique,仿真实注册生成 nickname=nickname, register_channel="sms", status="active", diff --git a/tests/test_logging_rotation.py b/tests/test_logging_rotation.py new file mode 100644 index 0000000..0d53a45 --- /dev/null +++ b/tests/test_logging_rotation.py @@ -0,0 +1,73 @@ +"""SafeRotatingFileHandler:Windows 轮转不被外部句柄卡死(WinError 32)。 + +stdlib RotatingFileHandler 靠 rename 活动文件轮转;Windows 上只要有第二个句柄(admin +第二进程、残留 --reload worker、IDE 索引、杀软)开着它, rename 就 WinError 32、轮转永久 +卡死。Safe 版在 Windows 改走 copytruncate(拷贝→就地清空, 从不 rename 活动文件)。 + +这些用例用 monkeypatch 把 os.name 强制成 "nt", 使 copytruncate 分支在任何 OS 的 CI 上都被 +覆盖;在真实 Windows 上则天然命中。 +""" +from __future__ import annotations + +import logging +from pathlib import Path + +from app.core.logging import SafeRotatingFileHandler + + +def _emit(handler: logging.Handler, msg: str) -> None: + handler.emit(logging.LogRecord("t", logging.INFO, __file__, 0, msg, (), None)) + + +def test_rollover_survives_second_open_handle(tmp_path: Path, monkeypatch) -> None: + """第二个句柄开着活动文件时轮转:不抛异常, 且确实转了(生成 .1、活动文件就地清空)。""" + monkeypatch.setattr("app.core.logging.os.name", "nt") + + log_file = tmp_path / "app-server.log" + # maxBytes 放大, 避免 emit 期间自动轮转干扰;本用例手动触发 doRollover。 + handler = SafeRotatingFileHandler( + str(log_file), maxBytes=10**9, backupCount=3, encoding="utf-8", + ) + handler.setFormatter(logging.Formatter("%(message)s")) + try: + for i in range(20): + _emit(handler, f"line-{i:03d}") + handler.flush() + before = log_file.stat().st_size + assert before > 0 + + # 正是 Windows 上 rename 失败的条件:另一个句柄开着活动文件。 + with open(log_file, "a", encoding="utf-8"): + handler.doRollover() # 不应抛 PermissionError / WinError 32 + + backup = tmp_path / "app-server.log.1" + assert backup.exists() + assert backup.stat().st_size == before # 轮转前内容完整进了备份 + assert log_file.stat().st_size == 0 # 活动文件就地清空(不是 rename) + + # 句柄没被 rename 破坏, 仍能继续写。 + _emit(handler, "after-rollover") + handler.flush() + assert log_file.stat().st_size > 0 + finally: + handler.close() + + +def test_backups_shift_and_capped(tmp_path: Path, monkeypatch) -> None: + """多次轮转:.1/.2 逐级腾挪, 超过 backupCount 的丢弃(不出现 .3)。""" + monkeypatch.setattr("app.core.logging.os.name", "nt") + + log_file = tmp_path / "app-server.log" + handler = SafeRotatingFileHandler( + str(log_file), maxBytes=10**9, backupCount=2, encoding="utf-8", + ) + handler.setFormatter(logging.Formatter("%(message)s")) + try: + for _ in range(4): + _emit(handler, "x" * 50) + handler.doRollover() + assert (tmp_path / "app-server.log.1").exists() + assert (tmp_path / "app-server.log.2").exists() + assert not (tmp_path / "app-server.log.3").exists() + finally: + handler.close() diff --git a/tests/test_withdraw.py b/tests/test_withdraw.py index 55a978a..ac395a4 100644 --- a/tests/test_withdraw.py +++ b/tests/test_withdraw.py @@ -9,7 +9,7 @@ from sqlalchemy import select from app.db.session import SessionLocal from app.models.user import User -from app.models.wallet import CoinAccount, WithdrawOrder +from app.models.wallet import CoinAccount, WithdrawOrder, WechatTransferAuthorization from app.repositories import wallet as crud_wallet @@ -426,3 +426,62 @@ def test_withdraw_reject_refunds(client, monkeypatch) -> None: r = client.get("/api/v1/wallet/withdraw/status", params={"out_bill_no": bill}, headers=_auth(token)) assert r.json()["status"] == "rejected" assert r.json()["fail_reason"] == "测试拒绝" + + +# ===== §fix 免确认授权 enabled 判定收严:active+authorization_id 非空才算已授权 ===== + +def _seed_transfer_auth(phone: str, state: str, authorization_id: str | None) -> None: + """直接在库里写/改该用户的免确认授权记录(绕过微信,用于判定测试)。""" + db = SessionLocal() + try: + user = db.execute(select(User).where(User.phone == phone)).scalar_one() + auth = db.get(WechatTransferAuthorization, user.id) + if auth is None: + auth = WechatTransferAuthorization( + user_id=user.id, openid=user.wechat_openid or "openid_test_abc", + out_authorization_no=f"oan_{user.id}", + ) + db.add(auth) + auth.state = state + auth.authorization_id = authorization_id + db.commit() + finally: + db.close() + + +def test_withdraw_info_auth_enabled_requires_authorization_id(client, monkeypatch) -> None: + _patch_userinfo(monkeypatch) + token = _login(client, "13800002051") + # 触发建号 + 绑定微信(withdraw-info 读 openid) + client.get("/api/v1/wallet/withdraw-info", headers=_auth(token)) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c1"}, headers=_auth(token)) + + # active 但无 authorization_id → 视为未授权 + _seed_transfer_auth("13800002051", "active", None) + r = client.get("/api/v1/wallet/withdraw-info", headers=_auth(token)) + assert r.status_code == 200, r.text + assert r.json()["transfer_auth_enabled"] is False + + # active 且有 authorization_id → 已授权 + _seed_transfer_auth("13800002051", "active", "wx_auth_123") + r = client.get("/api/v1/wallet/withdraw-info", headers=_auth(token)) + assert r.status_code == 200, r.text + assert r.json()["transfer_auth_enabled"] is True + + +def test_transfer_auth_status_requires_authorization_id(client, monkeypatch) -> None: + _patch_userinfo(monkeypatch) + token = _login(client, "13800002052") + client.get("/api/v1/wallet/withdraw-info", headers=_auth(token)) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c2"}, headers=_auth(token)) + + _seed_transfer_auth("13800002052", "active", None) + r = client.get("/api/v1/wallet/transfer-auth/status", headers=_auth(token)) + assert r.status_code == 200, r.text + assert r.json()["state"] == "active" + assert r.json()["enabled"] is False + + _seed_transfer_auth("13800002052", "active", "wx_auth_456") + r = client.get("/api/v1/wallet/transfer-auth/status", headers=_auth(token)) + assert r.status_code == 200, r.text + assert r.json()["enabled"] is True From 21a4d0af5b2c451e9d21dabfcc1b584d74a6c800 Mon Sep 17 00:00:00 2001 From: linkeyu Date: Fri, 24 Jul 2026 12:04:41 +0800 Subject: [PATCH 24/42] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=AE=A1=E6=A0=B8?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=89=B9=E9=87=8F=E5=A4=84=E7=90=86=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=20(#164)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 改动 - 新增低价审核与用户反馈的批量通过、批量拒绝接口 - 单条仍保持独立事务、审计、发奖和通知;单项失败不影响同批其它记录 - 批量响应返回每条记录的成功状态或失败原因,供前端保留失败项重试 - 反馈审核补充行锁,降低并发重复发奖风险 ## 验证 - `ruff check`(相关路由、Schema、测试) - `pytest tests/test_admin_write.py -q`:22 passed --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/164 Co-authored-by: linkeyu Co-committed-by: linkeyu --- app/admin/routers/feedback.py | 252 ++++++++++++++++++++---------- app/admin/routers/price_report.py | 136 ++++++++++++---- app/admin/schemas/feedback.py | 50 +++++- app/admin/schemas/price_report.py | 36 +++++ tests/test_admin_write.py | 115 ++++++++++++++ 5 files changed, 470 insertions(+), 119 deletions(-) diff --git a/app/admin/routers/feedback.py b/app/admin/routers/feedback.py index 987c76b..28b5b71 100644 --- a/app/admin/routers/feedback.py +++ b/app/admin/routers/feedback.py @@ -12,6 +12,10 @@ from app.admin.repositories import mutations, queries from app.admin.schemas.common import CursorPage, OkResponse from app.admin.schemas.feedback import ( FeedbackApproveRequest, + FeedbackBulkApproveRequest, + FeedbackBulkItemResult, + FeedbackBulkRejectRequest, + FeedbackBulkResult, FeedbackOut, FeedbackRejectRequest, FeedbackSummary, @@ -33,6 +37,123 @@ def _ensure_pending(fb: Feedback) -> None: raise HTTPException(status_code=400, detail="反馈已审核") +def _approve_feedback( + db: AdminDb, + admin: AdminUser, + feedback_id: int, + payload: FeedbackApproveRequest | FeedbackBulkApproveRequest, + ip: str, + *, + bulk: bool = False, +) -> FeedbackOut: + fb = db.get(Feedback, feedback_id, with_for_update=True) + if fb is None: + raise HTTPException(status_code=404, detail="反馈不存在") + _ensure_pending(fb) + + before = fb.status + mutations.review_feedback( + db, + fb, + status="adopted", + reward_coins=payload.reward_coins, + review_note=payload.note, + admin_reply=payload.reply, + reviewed_by_admin_id=admin.id, + commit=False, + ) + wallet_repo.grant_coins( + db, + fb.user_id, + payload.reward_coins, + biz_type="feedback_reward", + ref_id=str(fb.id), + remark="意见反馈被采纳", + ) + detail = { + "before": before, + "after": "adopted", + "reward_coins": payload.reward_coins, + "note": payload.note, + "reply": payload.reply, + } + if bulk: + detail["bulk"] = True + write_audit( + db, + admin, + action="feedback.approve", + target_type="feedback", + target_id=feedback_id, + detail=detail, + ip=ip, + commit=False, + ) + db.commit() + db.refresh(fb) + out = FeedbackOut.model_validate(fb) + notification_events.notify_feedback_reward(db, fb) + return out + + +def _reject_feedback( + db: AdminDb, + admin: AdminUser, + feedback_id: int, + payload: FeedbackRejectRequest | FeedbackBulkRejectRequest, + ip: str, + *, + bulk: bool = False, +) -> FeedbackOut: + fb = db.get(Feedback, feedback_id, with_for_update=True) + if fb is None: + raise HTTPException(status_code=404, detail="反馈不存在") + _ensure_pending(fb) + + before = fb.status + mutations.review_feedback( + db, + fb, + status="rejected", + reject_reason=payload.reason, + review_note=payload.note, + admin_reply=payload.reply, + reviewed_by_admin_id=admin.id, + commit=False, + ) + detail = { + "before": before, + "after": "rejected", + "reason": payload.reason, + "note": payload.note, + "reply": payload.reply, + } + if bulk: + detail["bulk"] = True + write_audit( + db, + admin, + action="feedback.reject", + target_type="feedback", + target_id=feedback_id, + detail=detail, + ip=ip, + commit=False, + ) + db.commit() + db.refresh(fb) + out = FeedbackOut.model_validate(fb) + notification_events.notify_feedback_reply(db, fb) + return out + + +def _bulk_result(items: list[FeedbackBulkItemResult]) -> FeedbackBulkResult: + success = sum(1 for item in items if item.ok) + return FeedbackBulkResult( + total=len(items), success=success, failed=len(items) - success, items=items, + ) + + @router.get("", response_model=CursorPage[FeedbackOut], summary="反馈工单列表") def list_feedbacks( db: AdminDb, @@ -73,6 +194,50 @@ def feedback_summary(db: AdminDb) -> FeedbackSummary: return FeedbackSummary.model_validate(queries.feedback_summary(db)) +@router.post("/bulk/approve", response_model=FeedbackBulkResult, summary="批量采纳反馈并发金币") +def bulk_approve_feedbacks( + body: FeedbackBulkApproveRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> FeedbackBulkResult: + results: list[FeedbackBulkItemResult] = [] + ip = get_client_ip(request) + for feedback_id in body.ids: + try: + out = _approve_feedback(db, admin, feedback_id, body, ip, bulk=True) + results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status)) + except HTTPException as exc: + db.rollback() + results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail))) + except Exception: # noqa: BLE001 - 单笔失败不打断整批 + db.rollback() + results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常")) + return _bulk_result(results) + + +@router.post("/bulk/reject", response_model=FeedbackBulkResult, summary="批量拒绝采纳反馈") +def bulk_reject_feedbacks( + body: FeedbackBulkRejectRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> FeedbackBulkResult: + results: list[FeedbackBulkItemResult] = [] + ip = get_client_ip(request) + for feedback_id in body.ids: + try: + out = _reject_feedback(db, admin, feedback_id, body, ip, bulk=True) + results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status)) + except HTTPException as exc: + db.rollback() + results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail))) + except Exception: # noqa: BLE001 - 单笔失败不打断整批 + db.rollback() + results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常")) + return _bulk_result(results) + + @router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理") def handle_feedback( feedback_id: int, @@ -93,53 +258,7 @@ def approve_feedback( admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> FeedbackOut: - fb = db.get(Feedback, feedback_id) - if fb is None: - raise HTTPException(status_code=404, detail="反馈不存在") - _ensure_pending(fb) - - before = fb.status - mutations.review_feedback( - db, - fb, - status="adopted", - reward_coins=payload.reward_coins, - review_note=payload.note, - admin_reply=payload.reply, - reviewed_by_admin_id=admin.id, - commit=False, - ) - wallet_repo.grant_coins( - db, - fb.user_id, - payload.reward_coins, - biz_type="feedback_reward", - ref_id=str(fb.id), - remark="意见反馈被采纳", - ) - write_audit( - db, - admin, - action="feedback.approve", - target_type="feedback", - target_id=feedback_id, - detail={ - "before": before, - "after": "adopted", - "reward_coins": payload.reward_coins, - "note": payload.note, - "reply": payload.reply, - }, - ip=get_client_ip(request), - commit=False, - ) - db.commit() - db.refresh(fb) - out = FeedbackOut.model_validate(fb) - # PRD #10 反馈奖励:采纳发金币后通知用户(站内 + push,必带官方留言)。 - # 业务已 commit,通知失败只 log 不影响审核结果。 - notification_events.notify_feedback_reward(db, fb) - return out + return _approve_feedback(db, admin, feedback_id, payload, get_client_ip(request)) @router.post("/{feedback_id}/reject", response_model=FeedbackOut, summary="拒绝采纳反馈") @@ -150,41 +269,4 @@ def reject_feedback( admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> FeedbackOut: - fb = db.get(Feedback, feedback_id) - if fb is None: - raise HTTPException(status_code=404, detail="反馈不存在") - _ensure_pending(fb) - - before = fb.status - mutations.review_feedback( - db, - fb, - status="rejected", - reject_reason=payload.reason, - review_note=payload.note, - admin_reply=payload.reply, - reviewed_by_admin_id=admin.id, - commit=False, - ) - write_audit( - db, - admin, - action="feedback.reject", - target_type="feedback", - target_id=feedback_id, - detail={ - "before": before, - "after": "rejected", - "reason": payload.reason, - "note": payload.note, - "reply": payload.reply, - }, - ip=get_client_ip(request), - commit=False, - ) - db.commit() - db.refresh(fb) - out = FeedbackOut.model_validate(fb) - # PRD #9 官方回复:未采纳也回复了用户(原因/留言用户端可见),通知去反馈历史页查看。 - notification_events.notify_feedback_reply(db, fb) - return out + return _reject_feedback(db, admin, feedback_id, payload, get_client_ip(request)) diff --git a/app/admin/routers/price_report.py b/app/admin/routers/price_report.py index 9f65545..6f656a7 100644 --- a/app/admin/routers/price_report.py +++ b/app/admin/routers/price_report.py @@ -17,6 +17,10 @@ from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_ro from app.admin.repositories import mutations, queries from app.admin.schemas.common import CursorPage, OkResponse from app.admin.schemas.price_report import ( + PriceReportBulkItemResult, + PriceReportBulkRejectRequest, + PriceReportBulkRequest, + PriceReportBulkResult, PriceReportOut, PriceReportRejectRequest, PriceReportSummary, @@ -34,6 +38,59 @@ router = APIRouter( ) +def _approve_price_report( + db: AdminDb, admin: AdminUser, report_id: int, ip: str, *, bulk: bool = False +) -> PriceReport: + rep = db.get(PriceReport, report_id, with_for_update=True) + if rep is None: + raise HTTPException(status_code=404, detail="上报记录不存在") + if rep.status != "pending": + raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作") + coins = PRICE_REPORT_REWARD_COINS + mutations.review_price_report(db, rep, status="approved", reward_coins=coins, commit=False) + wallet_repo.grant_coins( + db, rep.user_id, coins, + biz_type="price_report_reward", ref_id=str(rep.id), remark="上报更低价审核通过", + ) + detail = {"reward_coins": coins, "user_id": rep.user_id} + if bulk: + detail["bulk"] = True + write_audit( + db, admin, action="price_report.approve", target_type="price_report", target_id=report_id, + detail=detail, ip=ip, commit=False, + ) + db.commit() + notification_events.notify_report_approved(db, rep) + return rep + + +def _reject_price_report( + db: AdminDb, admin: AdminUser, report_id: int, reason: str, ip: str, *, bulk: bool = False +) -> PriceReport: + rep = db.get(PriceReport, report_id, with_for_update=True) + if rep is None: + raise HTTPException(status_code=404, detail="上报记录不存在") + if rep.status != "pending": + raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作") + mutations.review_price_report(db, rep, status="rejected", reject_reason=reason, commit=False) + detail = {"reason": reason, "user_id": rep.user_id} + if bulk: + detail["bulk"] = True + write_audit( + db, admin, action="price_report.reject", target_type="price_report", target_id=report_id, + detail=detail, ip=ip, commit=False, + ) + db.commit() + return rep + + +def _bulk_result(items: list[PriceReportBulkItemResult]) -> PriceReportBulkResult: + success = sum(1 for item in items if item.ok) + return PriceReportBulkResult( + total=len(items), success=success, failed=len(items) - success, items=items, + ) + + @router.get("", response_model=CursorPage[PriceReportOut], summary="上报更低价列表(筛选+分页)") def list_price_reports( db: AdminDb, @@ -60,6 +117,50 @@ def price_report_summary(db: AdminDb) -> PriceReportSummary: return PriceReportSummary.model_validate(queries.price_report_summary(db)) +@router.post("/bulk/approve", response_model=PriceReportBulkResult, summary="批量通过上报(发固定金币)") +def bulk_approve_price_reports( + body: PriceReportBulkRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> PriceReportBulkResult: + results: list[PriceReportBulkItemResult] = [] + ip = get_client_ip(request) + for report_id in body.ids: + try: + rep = _approve_price_report(db, admin, report_id, ip, bulk=True) + results.append(PriceReportBulkItemResult(id=report_id, ok=True, status=rep.status)) + except HTTPException as exc: + db.rollback() + results.append(PriceReportBulkItemResult(id=report_id, ok=False, error=str(exc.detail))) + except Exception: # noqa: BLE001 - 单笔失败不打断整批 + db.rollback() + results.append(PriceReportBulkItemResult(id=report_id, ok=False, error="系统异常")) + return _bulk_result(results) + + +@router.post("/bulk/reject", response_model=PriceReportBulkResult, summary="批量拒绝上报") +def bulk_reject_price_reports( + body: PriceReportBulkRejectRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> PriceReportBulkResult: + results: list[PriceReportBulkItemResult] = [] + ip = get_client_ip(request) + for report_id in body.ids: + try: + rep = _reject_price_report(db, admin, report_id, body.reason, ip, bulk=True) + results.append(PriceReportBulkItemResult(id=report_id, ok=True, status=rep.status)) + except HTTPException as exc: + db.rollback() + results.append(PriceReportBulkItemResult(id=report_id, ok=False, error=str(exc.detail))) + except Exception: # noqa: BLE001 - 单笔失败不打断整批 + db.rollback() + results.append(PriceReportBulkItemResult(id=report_id, ok=False, error="系统异常")) + return _bulk_result(results) + + @router.post("/{report_id}/approve", response_model=OkResponse, summary="通过上报(发固定金币)") def approve_price_report( report_id: int, @@ -67,27 +168,7 @@ def approve_price_report( admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> OkResponse: - # 行锁(SELECT FOR UPDATE):并发/连点双请求会都读到 pending → 各发一次金币双倍发奖, - # 锁住该行串行化,第二个请求拿锁后看到 approved → 走 400。SQLite 下 FOR UPDATE 为 no-op。 - rep = db.get(PriceReport, report_id, with_for_update=True) - if rep is None: - raise HTTPException(status_code=404, detail="上报记录不存在") - if rep.status != "pending": - raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作") - coins = PRICE_REPORT_REWARD_COINS - # 改状态 + 发金币 + 审计同一事务(commit=False),最后一起 commit:改了就有痕、发了就留账 - mutations.review_price_report(db, rep, status="approved", reward_coins=coins, commit=False) - wallet_repo.grant_coins( - db, rep.user_id, coins, - biz_type="price_report_reward", ref_id=str(rep.id), remark="上报更低价审核通过", - ) - write_audit( - db, admin, action="price_report.approve", target_type="price_report", target_id=report_id, - detail={"reward_coins": coins, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False, - ) - db.commit() - # PRD #11 爆料审核通过:发金币后通知用户(站内 + push)。业务已 commit,通知失败只 log。 - notification_events.notify_report_approved(db, rep) + _approve_price_report(db, admin, report_id, get_client_ip(request)) return OkResponse() @@ -99,16 +180,5 @@ def reject_price_report( admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> OkResponse: - rep = db.get(PriceReport, report_id, with_for_update=True) # 行锁,同 approve(防并发重复审核) - if rep is None: - raise HTTPException(status_code=404, detail="上报记录不存在") - if rep.status != "pending": - raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作") - reason = body.reason.strip() - mutations.review_price_report(db, rep, status="rejected", reject_reason=reason, commit=False) - write_audit( - db, admin, action="price_report.reject", target_type="price_report", target_id=report_id, - detail={"reason": reason, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False, - ) - db.commit() + _reject_price_report(db, admin, report_id, body.reason, get_client_ip(request)) return OkResponse() diff --git a/app/admin/schemas/feedback.py b/app/admin/schemas/feedback.py index bb588cf..dde6229 100644 --- a/app/admin/schemas/feedback.py +++ b/app/admin/schemas/feedback.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from app.core.rewards import FEEDBACK_REWARD_MAX_COINS @@ -55,6 +55,54 @@ class FeedbackRejectRequest(BaseModel): reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见") +class FeedbackBulkRequest(BaseModel): + ids: list[int] = Field(min_length=1, max_length=50, description="待审核反馈 ID 列表") + + @field_validator("ids") + @classmethod + def _ids_must_be_unique(cls, ids: list[int]) -> list[int]: + if len(ids) != len(set(ids)): + raise ValueError("反馈 ID 不能重复") + return ids + + +class FeedbackBulkApproveRequest(FeedbackBulkRequest): + reward_coins: int = Field( + ge=1, + le=FEEDBACK_REWARD_MAX_COINS, + description="每条采纳反馈发放的金币数", + ) + note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注(内部)") + reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见") + + +class FeedbackBulkRejectRequest(FeedbackBulkRequest): + reason: str = Field(min_length=1, max_length=256, description="批量未采纳原因,用户端可见") + note: str | None = Field(default=None, max_length=256, description="运营内部审核备注") + reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见") + + @field_validator("reason") + @classmethod + def _reason_not_blank(cls, value: str) -> str: + if not value.strip(): + raise ValueError("未采纳原因不能为空") + return value.strip() + + +class FeedbackBulkItemResult(BaseModel): + id: int + ok: bool + status: str | None = None + error: str | None = None + + +class FeedbackBulkResult(BaseModel): + total: int + success: int + failed: int + items: list[FeedbackBulkItemResult] + + class FeedbackSummary(BaseModel): """审核台顶部各状态计数(pending 含历史 new 态)。""" diff --git a/app/admin/schemas/price_report.py b/app/admin/schemas/price_report.py index abb89d9..d67208a 100644 --- a/app/admin/schemas/price_report.py +++ b/app/admin/schemas/price_report.py @@ -56,6 +56,42 @@ class PriceReportRejectRequest(BaseModel): return v.strip() +class PriceReportBulkRequest(BaseModel): + ids: list[int] = Field(min_length=1, max_length=50, description="待审核上报 ID 列表") + + @field_validator("ids") + @classmethod + def _ids_must_be_unique(cls, ids: list[int]) -> list[int]: + if len(ids) != len(set(ids)): + raise ValueError("上报 ID 不能重复") + return ids + + +class PriceReportBulkRejectRequest(PriceReportBulkRequest): + reason: str = Field(min_length=1, max_length=256, description="批量拒绝理由,用户端记录页会看到") + + @field_validator("reason") + @classmethod + def _reason_not_blank(cls, value: str) -> str: + if not value.strip(): + raise ValueError("拒绝理由不能为空") + return value.strip() + + +class PriceReportBulkItemResult(BaseModel): + id: int + ok: bool + status: str | None = None + error: str | None = None + + +class PriceReportBulkResult(BaseModel): + total: int + success: int + failed: int + items: list[PriceReportBulkItemResult] + + class PriceReportSummary(BaseModel): """审核台顶部各状态计数。""" diff --git a/tests/test_admin_write.py b/tests/test_admin_write.py index ec6d39d..fc6b73c 100644 --- a/tests/test_admin_write.py +++ b/tests/test_admin_write.py @@ -14,6 +14,7 @@ from app.core.security import hash_password from app.db.session import SessionLocal from app.models.admin import AdminAuditLog from app.models.feedback import Feedback +from app.models.price_report import PriceReport from app.models.user import User from app.models.wallet import CoinAccount, CoinTransaction, WithdrawOrder from app.repositories import user as user_repo @@ -82,6 +83,26 @@ def _seed_feedback(phone: str) -> int: db.close() +def _seed_price_report(phone: str) -> int: + uid = _seed_user(phone) + db = SessionLocal() + try: + report = PriceReport( + user_id=uid, + store_name="测试门店", + reported_platform_id="eleme", + reported_platform_name="饿了么", + reported_price_cents=2990, + images=[], + status="pending", + ) + db.add(report) + db.commit() + return report.id + finally: + db.close() + + # ===== 调金币 ===== def test_grant_coins_writes_txn_and_audit(admin_client: TestClient, finance_token: str) -> None: @@ -397,6 +418,100 @@ def test_feedback_review_stores_admin_reply( assert r.json()["admin_reply"] == "已收到,后续跟进" +def test_bulk_approve_feedbacks_returns_per_item_results( + admin_client: TestClient, operator_token: str +) -> None: + first_id = _seed_feedback("13900000031") + second_id = _seed_feedback("13900000032") + r = admin_client.post( + "/admin/api/feedbacks/bulk/approve", + json={"ids": [first_id, second_id, 999999], "reward_coins": 600, "note": "批量采纳"}, + headers=_auth(operator_token), + ) + assert r.status_code == 200, r.text + payload = r.json() + assert payload["total"] == 3 and payload["success"] == 2 and payload["failed"] == 1 + assert payload["items"][-1] == {"id": 999999, "ok": False, "status": None, "error": "反馈不存在"} + db = SessionLocal() + try: + for feedback_id in (first_id, second_id): + feedback = db.get(Feedback, feedback_id) + assert feedback is not None and feedback.status == "adopted" + assert db.get(CoinAccount, feedback.user_id).coin_balance == 600 + log = db.execute( + select(AdminAuditLog).where( + AdminAuditLog.action == "feedback.approve", + AdminAuditLog.target_id == str(feedback_id), + ) + ).scalar_one() + assert log.detail["bulk"] is True + finally: + db.close() + + +def test_bulk_approve_price_reports_returns_per_item_results( + admin_client: TestClient, operator_token: str +) -> None: + first_id = _seed_price_report("13900000041") + second_id = _seed_price_report("13900000042") + r = admin_client.post( + "/admin/api/price-reports/bulk/approve", + json={"ids": [first_id, second_id, 999999]}, + headers=_auth(operator_token), + ) + assert r.status_code == 200, r.text + payload = r.json() + assert payload["total"] == 3 and payload["success"] == 2 and payload["failed"] == 1 + assert payload["items"][-1] == {"id": 999999, "ok": False, "status": None, "error": "上报记录不存在"} + db = SessionLocal() + try: + for report_id in (first_id, second_id): + report = db.get(PriceReport, report_id) + assert report is not None and report.status == "approved" + assert report.reward_coins == 1000 + assert db.get(CoinAccount, report.user_id).coin_balance == 1000 + log = db.execute( + select(AdminAuditLog).where( + AdminAuditLog.action == "price_report.approve", + AdminAuditLog.target_id == str(report_id), + ) + ).scalar_one() + assert log.detail["bulk"] is True + finally: + db.close() + + +def test_bulk_reject_review_requests_apply_shared_reason( + admin_client: TestClient, operator_token: str +) -> None: + feedback_id = _seed_feedback("13900000051") + report_id = _seed_price_report("13900000052") + feedback_response = admin_client.post( + "/admin/api/feedbacks/bulk/reject", + json={"ids": [feedback_id], "reason": "信息不足", "reply": "请补充完整截图"}, + headers=_auth(operator_token), + ) + report_response = admin_client.post( + "/admin/api/price-reports/bulk/reject", + json={"ids": [report_id], "reason": "截图无法核实"}, + headers=_auth(operator_token), + ) + assert feedback_response.status_code == 200, feedback_response.text + assert report_response.status_code == 200, report_response.text + assert feedback_response.json()["success"] == 1 + assert report_response.json()["success"] == 1 + db = SessionLocal() + try: + feedback = db.get(Feedback, feedback_id) + report = db.get(PriceReport, report_id) + assert feedback is not None and feedback.status == "rejected" + assert feedback.reject_reason == "信息不足" and feedback.admin_reply == "请补充完整截图" + assert report is not None and report.status == "rejected" + assert report.reject_reason == "截图无法核实" + finally: + db.close() + + # ===== admin 账号管理(super_admin) ===== def test_create_and_update_admin(admin_client: TestClient, super_token: str) -> None: From 9e88ca72d3937052b196407b05fc02c4743e2e01 Mon Sep 17 00:00:00 2001 From: linkeyu Date: Fri, 24 Jul 2026 14:04:04 +0800 Subject: [PATCH 25/42] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8F=90=E7=8E=B0?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E8=AF=A6=E6=83=85=E7=82=B9=E5=87=BB=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=90=8E=E6=8F=90=E7=A4=BA=E6=93=8D=E4=BD=9C=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=20(#171)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题现象 在“提现审核”页点击用户所在行后,提现单主详情可以打开,但用户统计和金币记录区域为空,页面连续提示“操作失败”。 ## 原因说明 打开抽屉时前端会继续请求两个子接口: - `/admin/api/users/{user_id}/reward-stats` - `/admin/api/users/{user_id}/coin-records` 这两个接口原来都使用 `select(AdRewardRecord)` 加载完整 ORM 对象。SQLAlchemy 会把模型映射的所有列自动展开到 SQL 中,其中包括后来新增的 `boost_round_id`。当旧本地数据库或滚动发布中的数据库尚未补齐该列时,即使提现详情本身完全不使用这个字段,查询仍会报 `no such column: ad_reward_record.boost_round_id`,两个接口均返回 500。 前端的统一错误处理只会展示响应 JSON 中字符串类型的 `detail`;该 500 返回的是普通 `Internal Server Error`,因此最终回退成通用文案“操作失败”。本地前端开启了 React Strict Mode,初始化副作用在开发环境会执行两次,所以两个失败接口会形成截图中的四条“操作失败”提示。 ## 修复方案 - 用户奖励统计只查询实际需要的 `ecpm_raw`、`coin` 等字段。 - 金币记录只查询页面展示、排序所需字段。 - 同步缩小信息流广告和签到记录的字段投影,避免将来新增无关 ORM 列再次拖垮详情页。 - 增加 SQL 级回归测试:主动拦截任何包含 `ad_reward_record.boost_round_id` 的详情查询,并验证两个接口仍返回 200。 该改动不会改变统计口径或返回结构。数据库迁移仍应正常执行;这里增加的是旧库及滚动发布期间的向后兼容保护。 ## 验证结果 - `pytest tests/test_admin_read.py -q`:17 passed - 新增回归测试覆盖 `reward-stats` 与 `coin-records` - `git diff --check`:通过 - 本地实际提现用户接口验证:两个接口均返回 200 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/171 Co-authored-by: linkeyu Co-committed-by: linkeyu --- app/admin/repositories/queries.py | 41 ++++++++++++++++++++++--------- tests/test_admin_read.py | 32 +++++++++++++++++++++++- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index 33b510d..75550e7 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -1171,24 +1171,30 @@ def user_reward_stats( acc = db.get(CoinAccount, user_id) # 现金余额:当前快照,不随窗口 cash_balance = acc.cash_balance_cents if acc else 0 - rv = list(db.execute( - select(AdRewardRecord).where( + # 只投影本统计实际使用的列。避免滚动发布或旧本地库尚未补齐无关新列时, + # SQLAlchemy 因 select(ORM) 自动展开整表字段而让提现详情整体 500。 + rv = db.execute( + select(AdRewardRecord.ecpm_raw, AdRewardRecord.coin).where( AdRewardRecord.user_id == user_id, AdRewardRecord.reward_scene == "reward_video", AdRewardRecord.status == "granted", *_window_conds(AdRewardRecord.created_at, date_from, date_to), ) - ).scalars()) + ).all() rv_ecpms = [rewards.parse_ecpm_fen(r.ecpm_raw) for r in rv if r.ecpm_raw] rv_coins = sum(r.coin for r in rv) - feed = list(db.execute( - select(AdFeedRewardRecord).where( + feed = db.execute( + select( + AdFeedRewardRecord.unit_count, + AdFeedRewardRecord.ecpm_raw, + AdFeedRewardRecord.coin, + ).where( AdFeedRewardRecord.user_id == user_id, AdFeedRewardRecord.status == "granted", *_window_conds(AdFeedRewardRecord.created_at, date_from, date_to), ) - ).scalars()) + ).all() feed_ecpms = [rewards.parse_ecpm_fen(f.ecpm_raw) for f in feed if f.ecpm_raw] feed_coins = sum(f.coin for f in feed) @@ -1246,8 +1252,14 @@ def user_coin_records( signin_from = date_from + timedelta(hours=8) if date_from is not None else None signin_to = date_to + timedelta(hours=8) if date_to is not None else None + # 三类来源都只取页面需要的列,避免无关 ORM 新列造成旧库查询失败。 for rec in db.execute( - select(AdRewardRecord) + select( + AdRewardRecord.reward_scene, + AdRewardRecord.created_at, + AdRewardRecord.ecpm_raw, + AdRewardRecord.coin, + ) .where( AdRewardRecord.user_id == user_id, AdRewardRecord.status == "granted", @@ -1255,7 +1267,7 @@ def user_coin_records( ) .order_by(AdRewardRecord.created_at.desc()) .limit(fetch) - ).scalars(): + ).all(): is_video = rec.reward_scene == "reward_video" rows.append({ "source": rec.reward_scene, @@ -1266,7 +1278,12 @@ def user_coin_records( }) for rec in db.execute( - select(AdFeedRewardRecord) + select( + AdFeedRewardRecord.feed_scene, + AdFeedRewardRecord.created_at, + AdFeedRewardRecord.ecpm_raw, + AdFeedRewardRecord.coin, + ) .where( AdFeedRewardRecord.user_id == user_id, AdFeedRewardRecord.status == "granted", @@ -1274,7 +1291,7 @@ def user_coin_records( ) .order_by(AdFeedRewardRecord.created_at.desc()) .limit(fetch) - ).scalars(): + ).all(): rows.append({ "source": "feed", "source_label": _FEED_SCENE_LABEL.get(rec.feed_scene, "信息流广告"), @@ -1284,7 +1301,7 @@ def user_coin_records( }) for rec in db.execute( - select(CoinTransaction) + select(CoinTransaction.created_at, CoinTransaction.amount) .where( CoinTransaction.user_id == user_id, CoinTransaction.biz_type == "signin", @@ -1292,7 +1309,7 @@ def user_coin_records( ) .order_by(CoinTransaction.created_at.desc()) .limit(fetch) - ).scalars(): + ).all(): rows.append({ "source": "signin", "source_label": "签到", diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index a01ca96..06b19aa 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -5,10 +5,11 @@ from datetime import datetime import pytest from fastapi.testclient import TestClient +from sqlalchemy import event from app.admin.main import admin_app from app.admin.repositories import admin_user as admin_repo -from app.db.session import SessionLocal +from app.db.session import SessionLocal, engine from app.models.comparison import ComparisonRecord from app.models.feedback import Feedback from app.models.wallet import CashTransaction, WithdrawOrder @@ -132,6 +133,35 @@ def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> Non assert admin_client.get("/admin/api/users/999999", headers=_auth(admin_token)).status_code == 404 +def test_user_reward_detail_does_not_select_unrelated_new_ad_columns( + admin_client: TestClient, admin_token: str +) -> None: + """旧库缺少无关新列时,提现详情的统计和金币记录仍应可读。""" + uid = _seed_user_with_data("13800000022") + + def reject_full_ad_reward_projection( + _conn, _cursor, statement: str, _parameters, _context, _executemany + ) -> None: + if "ad_reward_record.boost_round_id" in statement: + raise AssertionError("提现详情不应查询未使用的 boost_round_id") + + event.listen(engine, "before_cursor_execute", reject_full_ad_reward_projection) + try: + stats = admin_client.get( + f"/admin/api/users/{uid}/reward-stats", headers=_auth(admin_token) + ) + records = admin_client.get( + f"/admin/api/users/{uid}/coin-records", + params={"limit": 10, "cursor": 0}, + headers=_auth(admin_token), + ) + finally: + event.remove(engine, "before_cursor_execute", reject_full_ad_reward_projection) + + assert stats.status_code == 200, stats.text + assert records.status_code == 200, records.text + + def test_user_filter_by_status(admin_client: TestClient, admin_token: str) -> None: _seed_user_with_data("13800000003") r = admin_client.get("/admin/api/users", params={"status": "active"}, headers=_auth(admin_token)) From 3f7b5167faa9cc22d164fb3c5db70571ea5ca098 Mon Sep 17 00:00:00 2001 From: zuochenyong Date: Fri, 24 Jul 2026 14:48:40 +0800 Subject: [PATCH 26/42] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E6=89=8B=E5=BC=95=E5=AF=BC=E8=A7=86=E9=A2=91=20+=20=E7=BE=8E?= =?UTF-8?q?=E5=9B=A2=E5=88=B8=E9=A6=96=E9=A1=B5=E5=88=86=E9=A1=B5=E7=B4=A2?= =?UTF-8?q?=E5=BC=95=20(#167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: guke Co-authored-by: 左辰勇 Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/167 Co-authored-by: zuochenyong Co-committed-by: zuochenyong --- ...38_merge_guide_video_seq_uq_and_coupon_.py | 26 ++ ...erge_guide_video_and_main_alembic_heads.py | 26 ++ alembic/versions/guide_video_play_table.py | 50 +++ .../guide_video_play_user_seq_unique.py | 40 +++ .../versions/meituan_coupon_feed_indexes.py | 52 ++++ app/admin/main.py | 5 + app/admin/routers/guide_video.py | 101 ++++++ app/admin/schemas/guide_video.py | 25 ++ app/api/v1/guide_video.py | 64 ++++ app/api/v1/meituan.py | 229 +++++++++----- app/core/config.py | 3 + app/core/media.py | 34 ++ app/integrations/meituan.py | 45 ++- app/main.py | 8 + app/models/__init__.py | 1 + app/models/guide_video.py | 73 +++++ app/models/meituan_coupon.py | 22 +- app/repositories/guide_video.py | 290 ++++++++++++++++++ app/schemas/guide_video.py | 36 +++ app/utils/mt_search_cursor.py | 97 ++++++ docs/api/meituan/meituan-feed.md | 3 +- docs/api/meituan/meituan-top-sales.md | 1 + docs/database/meituan_coupon.md | 6 +- scripts/reset_guide_video.py | 216 +++++++++++++ scripts/seed_meituan_coupon_mock.py | 222 ++++++++++++++ tests/test_guide_video.py | 263 ++++++++++++++++ 26 files changed, 1855 insertions(+), 83 deletions(-) create mode 100644 alembic/versions/d8dd2106e438_merge_guide_video_seq_uq_and_coupon_.py create mode 100644 alembic/versions/d9c03cc3ea07_merge_guide_video_and_main_alembic_heads.py create mode 100644 alembic/versions/guide_video_play_table.py create mode 100644 alembic/versions/guide_video_play_user_seq_unique.py create mode 100644 alembic/versions/meituan_coupon_feed_indexes.py create mode 100644 app/admin/routers/guide_video.py create mode 100644 app/admin/schemas/guide_video.py create mode 100644 app/api/v1/guide_video.py create mode 100644 app/models/guide_video.py create mode 100644 app/repositories/guide_video.py create mode 100644 app/schemas/guide_video.py create mode 100644 app/utils/mt_search_cursor.py create mode 100644 scripts/reset_guide_video.py create mode 100644 scripts/seed_meituan_coupon_mock.py create mode 100644 tests/test_guide_video.py diff --git a/alembic/versions/d8dd2106e438_merge_guide_video_seq_uq_and_coupon_.py b/alembic/versions/d8dd2106e438_merge_guide_video_seq_uq_and_coupon_.py new file mode 100644 index 0000000..a8032c9 --- /dev/null +++ b/alembic/versions/d8dd2106e438_merge_guide_video_seq_uq_and_coupon_.py @@ -0,0 +1,26 @@ +"""merge guide_video seq_uq and coupon_claim_event heads + +Revision ID: d8dd2106e438 +Revises: coupon_claim_event, guide_video_user_seq_uq +Create Date: 2026-07-24 11:52:18.290731 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd8dd2106e438' +down_revision: Union[str, Sequence[str], None] = ('coupon_claim_event', 'guide_video_user_seq_uq') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/d9c03cc3ea07_merge_guide_video_and_main_alembic_heads.py b/alembic/versions/d9c03cc3ea07_merge_guide_video_and_main_alembic_heads.py new file mode 100644 index 0000000..0834cae --- /dev/null +++ b/alembic/versions/d9c03cc3ea07_merge_guide_video_and_main_alembic_heads.py @@ -0,0 +1,26 @@ +"""merge guide_video and main alembic heads + +Revision ID: d9c03cc3ea07 +Revises: 8e04cc13a211, guide_video_play_table +Create Date: 2026-07-23 22:57:40.998161 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd9c03cc3ea07' +down_revision: Union[str, Sequence[str], None] = ('8e04cc13a211', 'guide_video_play_table') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/guide_video_play_table.py b/alembic/versions/guide_video_play_table.py new file mode 100644 index 0000000..3c6a62e --- /dev/null +++ b/alembic/versions/guide_video_play_table.py @@ -0,0 +1,50 @@ +"""新手引导视频播放记录表(领券浮层前 N 次替代广告) + +见 app/models/guide_video.py:按账号计次(开播即计数)、play_token 幂等发币。 +配置(开关 / 视频地址 / 次数 / 金币)复用既有 app_config 表,无需建表。 + +Revision ID: guide_video_play_table +Revises: meituan_coupon_feed_indexes +Create Date: 2026-07-23 12:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "guide_video_play_table" +down_revision: Union[str, Sequence[str], None] = "meituan_coupon_feed_indexes" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "guide_video_play", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("play_token", sa.String(length=64), nullable=False), + sa.Column("scene", sa.String(length=16), nullable=False, server_default="coupon"), + sa.Column("seq", sa.Integer(), nullable=False, server_default="1"), + sa.Column("video_url", sa.String(length=512), nullable=True), + sa.Column("coin", sa.Integer(), nullable=False, server_default="0"), + sa.Column("status", sa.String(length=16), nullable=False, server_default="playing"), + sa.Column("completed", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "started_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column("granted_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["user.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("play_token", name="uq_guide_video_play_token"), + ) + op.create_index("ix_guide_video_play_user_id", "guide_video_play", ["user_id"]) + op.create_index("ix_guide_video_play_started_at", "guide_video_play", ["started_at"]) + + +def downgrade() -> None: + op.drop_index("ix_guide_video_play_started_at", table_name="guide_video_play") + op.drop_index("ix_guide_video_play_user_id", table_name="guide_video_play") + op.drop_table("guide_video_play") diff --git a/alembic/versions/guide_video_play_user_seq_unique.py b/alembic/versions/guide_video_play_user_seq_unique.py new file mode 100644 index 0000000..e4c105e --- /dev/null +++ b/alembic/versions/guide_video_play_user_seq_unique.py @@ -0,0 +1,40 @@ +"""guide_video_play 加 (user_id, seq) 唯一约束:堵住并发 /start 绕过次数上限 + +start_play 是无锁 check-then-insert(读 COUNT(*) 算 seq=used+1 再插一行),N 个并发 +/start 会都读到同一个 used、算出同一个 seq、各插一行拿到各自的 play_token,于是 3 次 +上限被绕过、每个 token 都能换 120 金币。加唯一键后并发同 seq 必撞,start_play 捕获 +IntegrityError 降级为 should_play=false(客户端照旧放广告)。 + +用 unique index 而不是 batch_alter_table 加 UniqueConstraint:SQLite 加约束要整表重建, +而 CREATE UNIQUE INDEX 两边都原生支持,回滚也干净。 + +注:若库里已有并发产生的重复 (user_id, seq),建索引会失败 —— 本功能尚未上线,表通常是空的; +真撞上了先按 seq 去重(留 id 最小的一行,多发的金币按 scripts/reset_guide_video.py 的口径退)。 + +Revision ID: guide_video_user_seq_uq +Revises: d9c03cc3ea07 +Create Date: 2026-07-24 10:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "guide_video_user_seq_uq" +down_revision: Union[str, Sequence[str], None] = "d9c03cc3ea07" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_index( + "uq_guide_video_play_user_seq", + "guide_video_play", + ["user_id", "seq"], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index("uq_guide_video_play_user_seq", table_name="guide_video_play") diff --git a/alembic/versions/meituan_coupon_feed_indexes.py b/alembic/versions/meituan_coupon_feed_indexes.py new file mode 100644 index 0000000..c120ca6 --- /dev/null +++ b/alembic/versions/meituan_coupon_feed_indexes.py @@ -0,0 +1,52 @@ +"""meituan_coupon 首页 feed 分页复合索引(销量最高 / 智能推荐) + +「销量最高」「智能推荐」两个 tab 都是 + WHERE city_id = ? [+ 过滤] → DISTINCT ON (dedup_key) ORDER BY dedup_key, <排序键> DESC +的形状。列顺序对齐后 Postgres 可以顺着索引流式去重,免掉「每翻一页就把该城全部券重排一遍」, +这是首页下滑到底越来越慢的根因之一(另一半在 app 层:见 api/v1/meituan.py 的 _paged_dedup_ids)。 + +⚠️ 本文件同时是一个 **merge 迁移**:主干此前有 3 个并行 head +(comparison_user_created_idx / monitoring_audit_rbac / notification_table), +`alembic upgrade head` 会因 multiple heads 报错。这里一并收敛回单 head。 + +Revision ID: meituan_coupon_feed_indexes +Revises: comparison_user_created_idx, monitoring_audit_rbac, notification_table +Create Date: 2026-07-23 10:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "meituan_coupon_feed_indexes" +down_revision: Union[str, Sequence[str], None] = ( + "comparison_user_created_idx", + "monitoring_audit_rbac", + "notification_table", +) +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 销量最高:WHERE city_id=? AND sale_volume_num IS NOT NULL + # ORDER BY dedup_key, sale_volume_num DESC, commission_percent DESC + op.create_index( + "ix_meituan_coupon_city_dedup_sales", + "meituan_coupon", + ["city_id", "dedup_key", sa.text("sale_volume_num DESC"), sa.text("commission_percent DESC")], + ) + # 智能推荐:WHERE city_id=? AND commission_percent>=3.0 + # ORDER BY dedup_key, commission_percent DESC + op.create_index( + "ix_meituan_coupon_city_dedup_comm", + "meituan_coupon", + ["city_id", "dedup_key", sa.text("commission_percent DESC")], + ) + + +def downgrade() -> None: + op.drop_index("ix_meituan_coupon_city_dedup_comm", table_name="meituan_coupon") + op.drop_index("ix_meituan_coupon_city_dedup_sales", table_name="meituan_coupon") diff --git a/app/admin/main.py b/app/admin/main.py index c869487..918b238 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -30,6 +30,7 @@ from app.admin.routers.analytics_health import router as analytics_health_router from app.admin.routers.event_logs import router as event_logs_router from app.admin.routers.feedback import router as feedback_router from app.admin.routers.feedback_qr import router as feedback_qr_router +from app.admin.routers.guide_video import router as guide_video_router from app.admin.routers.huawei_review import router as huawei_review_router from app.admin.routers.onboarding import router as onboarding_router from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router @@ -40,6 +41,7 @@ from app.admin.routers.wallet import router as wallet_router from app.admin.routers.withdraw import router as withdraw_router from app.core.config import settings from app.core.logging import setup_logging +from app.integrations import meituan as mt_meituan setup_logging(debug=settings.APP_DEBUG) logger = logging.getLogger("shagua.admin") @@ -53,6 +55,8 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: settings.DATABASE_URL.split("://", 1)[0], ) yield + # CPS 后台页会打美团(routers/cps.py),那条共享 client 若被建过要在这里关掉连接池 + mt_meituan.close_client() logger.info("admin app shutting down") @@ -101,6 +105,7 @@ admin_app.include_router(feedback_router) admin_app.include_router(event_logs_router) admin_app.include_router(analytics_health_router) admin_app.include_router(feedback_qr_router) +admin_app.include_router(guide_video_router) admin_app.include_router(admins_router) admin_app.include_router(roles_router) admin_app.include_router(audit_router) diff --git a/app/admin/routers/guide_video.py b/app/admin/routers/guide_video.py new file mode 100644 index 0000000..59f75ae --- /dev/null +++ b/app/admin/routers/guide_video.py @@ -0,0 +1,101 @@ +"""admin 新手引导视频配置:读 / 改开关次数金币 / 上传视频 / 删视频(带审计)。 + +整份配置存通用 app_config 表(见 app/repositories/guide_video.py),App 领券等候浮层 +每次展示前调 POST /api/v1/guide-video/start 同步。权限:operator 可改(运营维护), +super 恒可;读为只读(任意已登录 admin)。 + +⚠️ 视频上限 100MB(settings.GUIDE_VIDEO_MAX_BYTES),已在 admin nginx 为本接口单独放宽 +client_max_body_size,见 shaguabijia-admin-web/deploy/nginx/admin.shaguabijia.com.conf。 +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile + +from app.admin.audit import write_audit +from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role +from app.admin.schemas.guide_video import GuideVideoConfigOut, GuideVideoConfigUpdate +from app.core import media +from app.models.admin import AdminUser +from app.repositories import guide_video + +router = APIRouter( + prefix="/admin/api/guide-video", + tags=["admin-guide-video"], + dependencies=[Depends(get_current_admin)], +) + + +def _out(db: AdminDb) -> GuideVideoConfigOut: + """配置 + 播放统计合成响应(四个写接口都以最新状态返回,前端一次同步到位)。""" + return GuideVideoConfigOut(**guide_video.get_config(db), **guide_video.play_stats(db)) + + +@router.get("", response_model=GuideVideoConfigOut, summary="新手引导视频配置(领券浮层)") +def get_config(db: AdminDb) -> GuideVideoConfigOut: + return _out(db) + + +@router.patch("", response_model=GuideVideoConfigOut, summary="改开关/次数/金币(带审计)") +def update_config( + body: GuideVideoConfigUpdate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> GuideVideoConfigOut: + before, after = guide_video.update_config( + db, + enabled=body.enabled, + max_plays=body.max_plays, + reward_coin=body.reward_coin, + admin_id=admin.id, + commit=False, + ) + write_audit( + db, admin, action="guide_video.update", target_type="guide_video", target_id=None, + detail={"before": before, "after": after}, ip=get_client_ip(request), commit=False, + ) + db.commit() + return _out(db) + + +@router.post("/video", response_model=GuideVideoConfigOut, summary="上传新手引导视频(MP4,带审计)") +async def upload_video( + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, + file: UploadFile = File(...), +) -> GuideVideoConfigOut: + data = await file.read() + try: + url = media.save_guide_video(data) + except media.MediaError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + before, after = guide_video.set_video(db, url, admin_id=admin.id, commit=False) + write_audit( + db, admin, action="guide_video.set_video", target_type="guide_video", target_id=None, + detail={"before": before.get("video_url"), "after": url, "bytes": len(data)}, + ip=get_client_ip(request), commit=False, + ) + db.commit() + # 提交成功后再删旧片,避免新片没落库就把旧片丢了 + media.delete_guide_video(before.get("video_url")) + return _out(db) + + +@router.delete("/video", response_model=GuideVideoConfigOut, summary="移除新手引导视频(带审计)") +def delete_video( + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> GuideVideoConfigOut: + """移除后 /guide-video/start 一律返回 should_play=false,领券浮层回到「只放广告」。""" + before, after = guide_video.set_video(db, None, admin_id=admin.id, commit=False) + write_audit( + db, admin, action="guide_video.delete_video", target_type="guide_video", target_id=None, + detail={"before": before.get("video_url")}, ip=get_client_ip(request), commit=False, + ) + db.commit() + media.delete_guide_video(before.get("video_url")) + return _out(db) diff --git a/app/admin/schemas/guide_video.py b/app/admin/schemas/guide_video.py new file mode 100644 index 0000000..721f0a6 --- /dev/null +++ b/app/admin/schemas/guide_video.py @@ -0,0 +1,25 @@ +"""admin 新手引导视频配置 schemas(开关 / 视频地址 / 前几次 / 每次金币)。""" +from __future__ import annotations + +from pydantic import BaseModel, Field + +from app.repositories.guide_video import MAX_PLAYS_LIMIT, REWARD_COIN_LIMIT + + +class GuideVideoConfigOut(BaseModel): + enabled: bool + video_url: str | None = None # 相对地址 /media/guide_video/xxx.mp4;未配片 = None + max_plays: int + reward_coin: int + updated_at: str | None = None + # 只读统计,后台展示用:已有多少次播放、其中已发币多少次。 + total_plays: int = 0 + granted_plays: int = 0 + + +class GuideVideoConfigUpdate(BaseModel): + """部分更新:只改传入(非 None)字段。视频文件走 /video 上传接口。""" + + enabled: bool | None = None + max_plays: int | None = Field(default=None, ge=0, le=MAX_PLAYS_LIMIT) + reward_coin: int | None = Field(default=None, ge=0, le=REWARD_COIN_LIMIT) diff --git a/app/api/v1/guide_video.py b/app/api/v1/guide_video.py new file mode 100644 index 0000000..c38251b --- /dev/null +++ b/app/api/v1/guide_video.py @@ -0,0 +1,64 @@ +"""新手引导视频(领券等候浮层前 N 次替代广告)。 + +路由前缀 `/api/v1/guide-video`(均需 Bearer): + POST /start 这次浮层放引导视频还是放广告?命中则**当场计次**并下发 play_token + POST /reward 播完 / 中途关闭都调,按 play_token 幂等发固定金币 + +发币额度以**服务端配置**为准(运营后台可改),客户端只报"播完/关闭",报不了金额, +所以被破解也刷不到超额金币;次数上限由 guide_video_play 行数(按账号)硬卡。 +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends + +from app.api.deps import CurrentUser, DbSession +from app.core.ratelimit import rate_limit +from app.repositories import guide_video as crud_guide +from app.schemas.guide_video import ( + GuideVideoRewardIn, + GuideVideoRewardOut, + GuideVideoStartIn, + GuideVideoStartOut, +) + +logger = logging.getLogger("shagua.guide_video") + +router = APIRouter(prefix="/api/v1/guide-video", tags=["guide-video"]) + + +@router.post( + "/start", + response_model=GuideVideoStartOut, + summary="领券浮层是否放新手引导视频(命中即计次)", + dependencies=[Depends(rate_limit(60, 60, "guide-video-start"))], +) +def start(payload: GuideVideoStartIn, user: CurrentUser, db: DbSession) -> GuideVideoStartOut: + """开播即计数:返回 should_play=True 时服务端已写下这一次,客户端必须真的播。 + + 没配视频 / 开关关 / 次数用完 → should_play=False,客户端照旧走广告链路(行为不变)。 + """ + result = crud_guide.start_play(db, user.id, scene=payload.scene or "coupon") + logger.info( + "guide video start user_id=%d scene=%s should_play=%s seq=%d remaining=%d", + user.id, payload.scene, result["should_play"], result["seq"], result["remaining"], + ) + return GuideVideoStartOut(**result) + + +@router.post( + "/reward", + response_model=GuideVideoRewardOut, + summary="引导视频发金币(播完/中途关闭都发,play_token 幂等)", + dependencies=[Depends(rate_limit(60, 60, "guide-video-reward"))], +) +def reward(payload: GuideVideoRewardIn, user: CurrentUser, db: DbSession) -> GuideVideoRewardOut: + result = crud_guide.grant_play( + db, user.id, play_token=payload.play_token, completed=payload.completed + ) + logger.info( + "guide video reward user_id=%d token=%s completed=%s granted=%s coin=%d", + user.id, payload.play_token[:12], payload.completed, result["granted"], result["coin"], + ) + return GuideVideoRewardOut(**result) diff --git a/app/api/v1/meituan.py b/app/api/v1/meituan.py index c1d07a1..b3cf1c6 100644 --- a/app/api/v1/meituan.py +++ b/app/api/v1/meituan.py @@ -5,11 +5,12 @@ from __future__ import annotations import logging -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import ThreadPoolExecutor +from typing import TYPE_CHECKING, Any from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import nullslast, select -from sqlalchemy.orm import Session, aliased +from sqlalchemy.orm import Session from app.core.config import settings from app.db.session import get_db @@ -25,8 +26,14 @@ from app.schemas.meituan import ( ReferralLinkResponse, TopSalesRequest, ) +from app.utils import mt_search_cursor from app.utils.meituan_city import get_meituan_city +if TYPE_CHECKING: # 仅供类型标注(本模块已开 from __future__ import annotations) + from collections.abc import Callable + + from sqlalchemy import ColumnElement + logger = logging.getLogger("shagua.meituan") @@ -109,6 +116,86 @@ def _commission_pct(card: CouponCard) -> float: return 0.0 +# ────────────── 离线库分页(智能推荐 / 销量最高 共用) ────────────── +# 去重+排序阶段**只投影这几列**:够 DISTINCT ON 分组、够排序、够回表定位,且全是定长小字段。 +# ⚠️ 关键性能点:`raw` 是整条美团原始返回(JSONB,每行数 KB)。原实现用 select(MeituanCoupon) +# 做子查询,等于把整城几千行连 raw 一起塞进两次排序(DISTINCT ON 一次 + 分页一次), +# 体量轻松超过 work_mem → Postgres 落盘做外部归并排序,而且**每翻一页都要重来一遍**。 +# 拆成「先在小列上排出本页 id,再按 id 回表取 raw」后,排序数据量降到原来的百分之几, +# JSONB 只解析当前页 ~20 行。 +_DEDUP_COLS = ( + MeituanCoupon.id, + MeituanCoupon.dedup_key, + MeituanCoupon.sale_volume_num, + MeituanCoupon.commission_percent, +) + + +def _paged_dedup_ids( + db: Session, + *, + conds: list[ColumnElement[bool]], + dedup_order: list[ColumnElement], + page_order: Callable[[Any], list[ColumnElement]], + page: int, + page_size: int, +) -> tuple[list[int], bool]: + """DISTINCT ON(dedup_key) 跨源去重 → 整体排序 → 分页,返回 (本页 id 列表, 是否还有下一页)。 + + - `dedup_order`:同一个 dedup_key 的多条里留哪条(如销量最高/佣金最高)。 + - `page_order`:接收去重子查询的列集合(`sub.c`),返回去重后的整体排序。 + 多取 1 条用于判断 has_next。 + """ + deduped = ( + select(*_DEDUP_COLS) + .where(*conds) + .distinct(MeituanCoupon.dedup_key) + .order_by(MeituanCoupon.dedup_key, *dedup_order) + .subquery() + ) + ids = db.execute( + select(deduped.c.id) + .order_by(*page_order(deduped.c)) + .offset((page - 1) * page_size) + .limit(page_size + 1) + ).scalars().all() + return list(ids[:page_size]), len(ids) > page_size + + +def _load_raws(db: Session, ids: list[int]) -> list[dict]: + """按给定 id 顺序取 raw(只回表本页 ~20 行)。缺行(被 ETL 清掉)静默跳过。""" + if not ids: + return [] + raw_by_id = { + row_id: raw + for row_id, raw in db.execute( + select(MeituanCoupon.id, MeituanCoupon.raw).where(MeituanCoupon.id.in_(ids)) + ).all() + } + return [raw_by_id[i] for i in ids if i in raw_by_id] + + +def _cards_from_raws(raws: list[dict], *, hide_distance: bool) -> list[CouponCard]: + """raw → CouponCard;解析失败的单条跳过,不整页失败。 + + hide_distance:离线库里的距离是相对「城市默认点」算的,对用户无意义且误导 —— 智能推荐 / + 销量最高两个 tab 一律置空,前端「距离 店名」那行只剩店名、自动顶到最左。 + """ + cards: list[CouponCard] = [] + for raw in raws: + try: + card = CouponCard.from_raw(raw or {}) + except Exception: # noqa: BLE001 + continue + if not card.product_view_sign: + continue + if hide_distance: + card.distance_text = None + card.distance_meters = None + cards.append(card) + return cards + + @router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉);tab=rec智能推荐/distance距离最近") def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse: lon, lat = req.longitude, req.latitude @@ -133,17 +220,21 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse: return [], True # 距离最近:搜索召回(外卖搜"外卖" + 到店搜"美食",都 sortField=6 离我最近)一页页拉。 - # 搜索翻页必须用 searchId(pageNo 翻不动),所以每个 feed 页顺序翻到第 N 页;两路并行、page 1 最快。 - # 无状态、不改 APP(传页码即可);按你位置实时算距离(库里没存 POI 经纬度,只能实时)。 + # 搜索翻页必须用 searchId(pageNo 翻不动),而接口是无状态的(客户端只传页码)—— 原实现因此 + # 每次都从第 1 页顺序重放到第 N 页,取第 N 页要向美团发 N 次请求,越往下滑越慢。 + # 现在把沿途 searchId 记进 [mt_search_cursor],稳态下每翻一页恒定 1 次请求;两路仍并行。 + # 按你位置实时算距离(库里没存 POI 经纬度,只能实时)。 if tab == "distance": lon_i, lat_i = int(lon * 1_000_000), int(lat * 1_000_000) - def _search_page_n(platform: int, biz_line: int | None, keyword: str, n: int) -> tuple[list[dict], bool, bool]: - """顺序翻到第 n 页(搜索须 searchId 续页),返回(第 n 页 items, 是否还有下一页, 是否调用失败)。""" - sid: str | None = None + def _replay( + platform: int, biz_line: int | None, keyword: str, + key: mt_search_cursor.RouteKey, start: int, sid: str | None, n: int, + ) -> tuple[list[dict], bool, bool]: + """从第 start 页(用 sid 取)顺序翻到第 n 页。start==1 时 sid 应为 None(走 pageNo=1)。""" data: list[dict] = [] has_next = False - for pg in range(1, n + 1): + for pg in range(start, n + 1): body: dict = { "platform": platform, "searchText": keyword, "sortField": 6, "longitude": lon_i, "latitude": lat_i, "pageSize": 20, @@ -161,10 +252,27 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse: data = r.get("data") or [] sid = r.get("searchId") has_next = bool(r.get("hasNext")) and bool(data) + # 记下「下一页要用哪个 searchId」;没有下一页就别记,免得存进死游标。 + if sid and has_next: + mt_search_cursor.remember(key, pg + 1, sid) if not data or (not has_next and pg < n): return [], False, False # 没那么多页了(非错误) return data, has_next, False + def _search_page_n(platform: int, biz_line: int | None, keyword: str, n: int) -> tuple[list[dict], bool, bool]: + """取第 n 页,返回(第 n 页 items, 是否还有下一页, 是否调用失败)。 + + 优先用缓存游标一发直达;缓存未命中/过期才从最近的已知页往后重放,并把沿途游标补进缓存。 + """ + key = mt_search_cursor.route_key(lat, lon, platform, keyword) + start, sid = mt_search_cursor.lookup(key, n) + data, has_next, failed = _replay(platform, biz_line, keyword, key, start, sid, n) + # 用缓存游标却打不通,多半是上游 searchId 过期:作废整条路线,回到第 1 页重放一次。 + if failed and start > 1: + mt_search_cursor.drop(key) + data, has_next, failed = _replay(platform, biz_line, keyword, key, 1, None, n) + return data, has_next, failed + with ThreadPoolExecutor(max_workers=2) as pool: f_wm = pool.submit(_search_page_n, 1, None, "外卖", req.page) f_dd = pool.submit(_search_page_n, 2, 1, "美食", req.page) @@ -194,39 +302,25 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse: return FeedResponse(items=[], has_next=False, page=req.page, status="degraded") PAGE = 20 try: - base = select(MeituanCoupon).where( - MeituanCoupon.commission_percent >= 3.0, - MeituanCoupon.city_id == city_id, - ) - deduped = base.distinct(MeituanCoupon.dedup_key).order_by( - MeituanCoupon.dedup_key, - MeituanCoupon.commission_percent.desc(), - ).subquery() - m = aliased(MeituanCoupon, deduped) - start = (req.page - 1) * PAGE - rows = db.execute( - select(m) + ids, has_next = _paged_dedup_ids( + db, + conds=[ + MeituanCoupon.commission_percent >= 3.0, + MeituanCoupon.city_id == city_id, + ], + # 同一去重键留佣金最高那条 + dedup_order=[MeituanCoupon.commission_percent.desc()], # 销量高的优先(无销量档排后),同档佣金高优先,id 兜底稳定分页 - .order_by(nullslast(m.sale_volume_num.desc()), m.commission_percent.desc(), m.id) - .offset(start) - .limit(PAGE + 1) - ).scalars().all() + page_order=lambda c: [ + nullslast(c.sale_volume_num.desc()), c.commission_percent.desc(), c.id, + ], + page=req.page, page_size=PAGE, + ) + raws = _load_raws(db, ids) except Exception: # noqa: BLE001 logger.exception("[feed] rec 库查询失败,降级返空") return FeedResponse(items=[], has_next=False, page=req.page, status="degraded") - has_next = len(rows) > PAGE - cards: list[CouponCard] = [] - for row in rows[:PAGE]: - try: - card = CouponCard.from_raw(row.raw or {}) - except Exception: # noqa: BLE001 - continue - if card.product_view_sign: - # 智能推荐不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导)。 - # 置空后前端"距离 店名"那行只剩店名、自动顶到最左(店名移到原距离的位置)。 - card.distance_text = None - card.distance_meters = None - cards.append(card) + cards = _cards_from_raws(raws, hide_distance=True) if not cards and req.page == 1: # 命中城市却 0 券:该城确无 ≥3% 券,或 ETL 灌的 city_id 与 city_dict 口径不一致。 logger.info("[feed] rec city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id) @@ -282,51 +376,36 @@ def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponList if not city_id: return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded") - # 去重 + 排序 + 分页全在 SQL 做,每页只取并解析当前页 ~20 条。 - # (之前实现每翻一页都全表拉取 + 全量 from_raw 解析,翻页慢 → 客户端滑动卡顿/翻不动。) + # 去重 + 排序 + 分页全在 SQL 做,每页只回表并解析当前页 ~20 条(见 _paged_dedup_ids 的性能说明)。 # 库为空(prod 刚部署 / ETL 未跑完)时返空 + status=empty,不崩;库查询异常降级 degraded。 + conds = [ + MeituanCoupon.sale_volume_num.isnot(None), + MeituanCoupon.city_id == city_id, + ] + if req.platform is not None: + conds.append(MeituanCoupon.platform == req.platform) try: - # 1) DISTINCT ON (dedup_key):每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金) - base = select(MeituanCoupon).where( - MeituanCoupon.sale_volume_num.isnot(None), - MeituanCoupon.city_id == city_id, - ) - if req.platform is not None: - base = base.where(MeituanCoupon.platform == req.platform) - deduped = base.distinct(MeituanCoupon.dedup_key).order_by( - MeituanCoupon.dedup_key, - MeituanCoupon.sale_volume_num.desc(), - MeituanCoupon.commission_percent.desc(), - ).subquery() - - # 2) 对去重结果按销量降序分页;多取 1 条判断 has_next,只对本页做 from_raw - m = aliased(MeituanCoupon, deduped) - start = (req.page - 1) * req.page_size - rows = db.execute( - select(m) + ids, has_next = _paged_dedup_ids( + db, + conds=conds, + # 每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金) + dedup_order=[ + MeituanCoupon.sale_volume_num.desc(), + MeituanCoupon.commission_percent.desc(), + ], # 加 id 作稳定 tiebreaker:同销量同佣金的并列项排序确定,避免跨页重复/漏项 - .order_by(m.sale_volume_num.desc(), m.commission_percent.desc(), m.id) - .offset(start) - .limit(req.page_size + 1) - ).scalars().all() + page_order=lambda c: [ + c.sale_volume_num.desc(), c.commission_percent.desc(), c.id, + ], + page=req.page, page_size=req.page_size, + ) + raws = _load_raws(db, ids) except Exception: # noqa: BLE001 logger.exception("[top-sales] 库查询失败,降级返空") return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded") - has_next = len(rows) > req.page_size - cards: list[CouponCard] = [] - for row in rows[:req.page_size]: - try: - card = CouponCard.from_raw(row.raw or {}) - except Exception: # noqa: BLE001 - continue - if card.product_view_sign: - # 不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导)。 - # 置空后前端"距离 店名"那行只剩店名、自动顶到最左(店名移到原距离的位置)。 - # 逻辑与推荐流保持一致 - card.distance_text = None - card.distance_meters = None - cards.append(card) + # 不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导),与推荐流口径一致。 + cards = _cards_from_raws(raws, hide_distance=True) if not cards and req.page == 1: # 命中城市却 0 券:可能该城确无券,也可能 ETL 灌的 city_id 与 city_dict 口径不一致(静默降级的隐患)。 logger.info("[top-sales] city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id) diff --git a/app/core/config.py b/app/core/config.py index afae3ee..81149db 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -384,6 +384,9 @@ class Settings(BaseSettings): MEDIA_ROOT: str = "./data/media" MEDIA_URL_PREFIX: str = "/media" AVATAR_MAX_BYTES: int = 5 * 1024 * 1024 # 头像最大 5MB + # 运营后台上传的新手引导视频上限。视频比图片大一个量级,单独一档; + # ⚠️ 改大时同步放宽网关 client_max_body_size(实测 QA 4MiB / prod 32MiB),否则 nginx 先挡下。 + GUIDE_VIDEO_MAX_BYTES: int = 100 * 1024 * 1024 # 引导视频最大 100MB # ===== 邀请好友 ===== # 分享落地页(二维码 / 分享链接指向这里;扫码 → 落地页 → 引导浏览器下载 APK)。 diff --git a/app/core/media.py b/app/core/media.py index 192b186..a6e84e4 100644 --- a/app/core/media.py +++ b/app/core/media.py @@ -77,6 +77,35 @@ def save_feedback_qr(data: bytes) -> str: return _save_named("feedback_qr", "qr", data) +def _sniff_video_ext(data: bytes) -> str | None: + """按魔数判定视频类型,返回扩展名;非支持类型返回 None。 + + 只认 MP4 家族(ISO BMFF):`....ftyp` 在偏移 4。Android ExoPlayer 与浏览器