Compare commits

..

2 Commits

Author SHA1 Message Date
marco 29225e9b65 feat(wxpay): 提现 debug 免人工审核直发 + 修复 user_display_name 被微信拒收
- WithdrawRequest 加 skip_review 字段;/withdraw 在 skip_review 且非生产(APP_ENV!=prod)
  时,建单后立即 execute_withdraw_transfer 直发、跳过人工审核。双闸:仅 debug 客户端下发
  + 服务端非 prod 才认,生产恒走人工审核。便于本地/联调直接测提现到账链路。
- 修 _auth_display_name:微信 user_display_name 校验极严,空格和标点也按"控制字符"拒收
  (实测 'wonderable ai' 因空格被 400,'wonderableai'/'周周'/'傻瓜比价用户8888' 才过)。
  原清洗只去星形面字符(ord>0xFFFF),漏掉 BMP emoji/空格/标点/控制符/零宽符。改为白名单:
  只保留 Unicode 文字(L*)+ 数字(N*),其余全剔,清空兜底手机号尾号。修复昵称含空格/emoji
  的用户发起免确认收款授权直接 400、提现打款失败的问题。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:48:31 +08:00
marco 50ed726816 feat(wallet): 微信商家转账「用户授权免确认收款」(免确认到账)
提现从「每次跳微信确认」升级为免确认模式:首次授权一次,之后审核通过直接到账,用户零跳转。
方式一(首单转账顺带授权 pre-transfer-with-authorization)+ 方式二(显式开启 user-confirm-authorization)。

- 新增 wechat_transfer_authorization 表(user 1:1,out_authorization_no/authorization_id/state) + 迁移
- wxpay.py 新增 apply/query/close_transfer_authorization + pre_transfer_with_authorization + transfer_with_authorization
- repositories/wallet.py 授权 CRUD + sync/_refresh_active_auth + execute_withdraw_transfer 三分叉
  (active->免确认转账 / 无授权且配了回调->方式一 / 未配->退化原确认模式);失效回查授权权威判定,绝不盲退
- /wallet/transfer-auth(开启)/status/close 路由 + withdraw-info 增 transfer_auth_enabled
- 授权结果回调 stub /api/v1/wxpay/transfer-auth-notify(一期仅应答不验签,真值靠 query 轮询)
- config 增 WXPAY_AUTH_NOTIFY_URL

一期无回调验签;二期补 V3 平台证书验签 + AEAD 解密。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:40:02 +08:00
39 changed files with 33 additions and 1433 deletions
+2 -8
View File
@@ -41,18 +41,12 @@ MT_CPS_APP_SECRET=
# 默认渠道追踪标识(sid),用于区分不同 app 的 CPS 数据
MT_CPS_DEFAULT_SID=sgbjia
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
# 客户端调本服务的 /api/v1/coupon/step,我们透传到 pricebot-backend。
# ===== Pricebot 上游 (领券业务透传目标) =====
# 客户端调本服务的 /api/v1/coupon/step,我们透传到 pricebot-backend 的 /api/coupon/step
# 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。
PRICEBOT_BASE_URL=http://localhost:8000
# 【多实例】单机多进程部署时,填逗号分隔的实例列表(端口与 pricebot 集群对齐),透传层按
# trace_id 一致性 hash 选实例 → 同一比价所有帧落同一进程(进程内维护 state,无需 Redis)。
# 留空 = 单实例(用上面的 PRICEBOT_BASE_URL)。详见 pricebot-backend/docs/并发部署设计.md
# PRICEBOT_INSTANCES=http://127.0.0.1:8001,http://127.0.0.1:8002,http://127.0.0.1:8003,http://127.0.0.1:8004,http://127.0.0.1:8005,http://127.0.0.1:8006
# 领券单帧最多 wait 6s,加网络往返,30s 兜底
PRICEBOT_REQUEST_TIMEOUT_SEC=30
# 比价(intent/recognize + price/step)透传超时:大上下文 LLM + 逐帧 LLM,给 60s
PRICEBOT_COMPARE_TIMEOUT_SEC=60
# ===== CORS =====
# 逗号分隔,生产留空(只让 app 调,不开放 web)。本地开发可加 http://localhost:5173 之类
+6 -18
View File
@@ -18,7 +18,6 @@ pricebot 协议文档:
"""
from __future__ import annotations
import json
import logging
from typing import Any
@@ -26,7 +25,6 @@ import httpx
from fastapi import APIRouter, HTTPException, Request, status
from app.core.config import settings
from app.core.pricebot_router import pick_pricebot
logger = logging.getLogger("shagua.compare")
@@ -40,35 +38,25 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
打日志。比价单帧是大上下文 / 逐帧 LLM,超时用 PRICEBOT_COMPARE_TIMEOUT_SEC(60s,
比领券的 30s 长)。
"""
# 读原始字节,避免"反序列化→再序列化"的双重 JSON(省 ~一半透传 CPU,让 app-server
# 单 worker 也扛得住高并发)。只 json.loads 一次拿 trace_id 做亲和 + 打日志,转发时
# 直接发原始 bytes(content=raw),不重新 dumps。
raw = await request.body()
try:
meta = json.loads(raw)
body = await request.json()
except Exception as e:
raise HTTPException(status_code=400, detail=f"invalid json body: {e}") from e
if not isinstance(meta, dict):
meta = {}
# 按 trace_id 一致性 hash 选 pricebot 实例(同一比价的所有帧落同一进程,内存维护 state)
base = pick_pricebot(meta.get("trace_id"))
url = f"{base.rstrip('/')}{upstream_path}"
url = f"{settings.PRICEBOT_BASE_URL.rstrip('/')}{upstream_path}"
timeout = settings.PRICEBOT_COMPARE_TIMEOUT_SEC
logger.info(
"compare %s device_id=%s trace_id=%s step=%s",
upstream_path,
meta.get("device_id"),
meta.get("trace_id"),
meta.get("step"),
body.get("device_id"),
body.get("trace_id"),
body.get("step"),
)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}
)
resp = await client.post(url, json=body)
except httpx.RequestError as e:
logger.error("[pricebot] request failed: %s", e)
raise HTTPException(
+6 -17
View File
@@ -10,7 +10,6 @@ pricebot 协议文档:
"""
from __future__ import annotations
import json
import logging
from typing import Any
@@ -18,7 +17,6 @@ import httpx
from fastapi import APIRouter, HTTPException, Request, status
from app.core.config import settings
from app.core.pricebot_router import pick_pricebot
logger = logging.getLogger("shagua.coupon")
@@ -35,33 +33,24 @@ async def coupon_step(
- 透传: 不做 schema 校验,pricebot 自己校验
- 失败: 网络不可达 / pricebot 5xx → 502 + 友好 message
"""
# 读原始字节,避免"反序列化→再序列化"的双重 JSON(省透传 CPU)。只 loads 一次拿
# trace_id 做亲和 + 打日志,转发时直接发原始 bytes,不重新 dumps。
raw = await request.body()
try:
meta = json.loads(raw)
body = await request.json()
except Exception as e:
raise HTTPException(status_code=400, detail=f"invalid json body: {e}") from e
if not isinstance(meta, dict):
meta = {}
# 按 trace_id 一致性 hash 选 pricebot 实例(同一领券任务的所有帧落同一进程)
base = pick_pricebot(meta.get("trace_id"))
url = f"{base.rstrip('/')}/api/coupon/step"
url = f"{settings.PRICEBOT_BASE_URL.rstrip('/')}/api/coupon/step"
timeout = settings.PRICEBOT_REQUEST_TIMEOUT_SEC
logger.info(
"coupon_step device_id=%s trace_id=%s step=%s",
meta.get("device_id"),
meta.get("trace_id"),
meta.get("step"),
body.get("device_id"),
body.get("trace_id"),
body.get("step"),
)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}
)
resp = await client.post(url, json=body)
except httpx.RequestError as e:
logger.error("[pricebot] request failed: %s", e)
raise HTTPException(
-1
View File
@@ -42,7 +42,6 @@ def battle(user: CurrentUser, db: DbSession) -> SavingsBattleOut:
week_saved_cents=b.week_saved_cents,
beat_percent=b.beat_percent,
streak_days=b.streak_days,
compare_count=b.compare_count,
)
+1 -14
View File
@@ -135,28 +135,15 @@ class Settings(BaseSettings):
"""回调开关打开且验签密钥已配,才接受发奖回调。"""
return bool(self.PANGLE_CALLBACK_ENABLED and self.PANGLE_REWARD_SECRET)
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
# ===== Pricebot 上游 (领券业务透传目标) =====
# pricebot-backend 默认跑在 8000。/api/v1/coupon/step 会透传到这里的 /api/coupon/step
PRICEBOT_BASE_URL: str = "http://localhost:8000"
# 多实例(单机多进程)时填逗号分隔的实例列表,例如:
# PRICEBOT_INSTANCES=http://127.0.0.1:8001,http://127.0.0.1:8002,http://127.0.0.1:8003
# 透传层按 trace_id 一致性 hash 选实例(见 app/core/pricebot_router.py),保证同一次
# 比价/领券的所有帧落同一 pricebot 进程,从而用进程内内存维护 session/coordinator,
# 无需 Redis。留空 → 退回单实例 [PRICEBOT_BASE_URL],零改动兼容。
PRICEBOT_INSTANCES: str = ""
# 领券一帧最多 wait 6s,加网络往返,timeout 给 30s 比较稳
PRICEBOT_REQUEST_TIMEOUT_SEC: int = 30
# 比价(intent/recognize + price/step)透传超时:意图识别是大上下文 LLM、
# price/step 每帧也是 LLM,可能 >30s;对齐客户端 agent ApiClient 的 60s 读超时。
PRICEBOT_COMPARE_TIMEOUT_SEC: int = 60
@property
def pricebot_instances(self) -> list[str]:
"""pricebot 上游实例列表。空 → 单实例兜底 [PRICEBOT_BASE_URL]。"""
if not self.PRICEBOT_INSTANCES.strip():
return [self.PRICEBOT_BASE_URL]
return [u.strip() for u in self.PRICEBOT_INSTANCES.split(",") if u.strip()]
# ===== 媒体文件(用户头像上传)=====
# 落盘根目录(data/ 已 gitignore,上传不进库);对外经 StaticFiles 挂在 MEDIA_URL_PREFIX。
# 生产可改由 nginx 直接 serve MEDIA_ROOT,绕过应用进程。
-82
View File
@@ -1,82 +0,0 @@
"""pricebot 上游实例选择:按 trace_id 一致性 hash(ketama 风格)选实例。
单机多进程下,保证同一 trace_id(一次比价/领券的所有帧)落同一 pricebot 进程,
从而用进程内内存维护 session/coordinator,不需要 Redis。
为什么用一致性 hash 而非简单取模(crc32 % N):
加/减实例时,取模会让几乎所有 trace 重新映射(扩缩容全量中断进行中的比价);
一致性 hash(虚拟节点)只重映射约 1/N 的 trace,扩缩容对存量冲击最小。
⚠️ 但内存态下,被重映射的那 1/N trace 仍会丢失 session(状态没外置),
所以扩缩容仍建议挑低峰。
hash 用 md5(纯函数),app-server 多 worker / 多实例算出的环一致,亲和不会因
app-server 自身水平扩展而被破坏。
"""
from __future__ import annotations
import bisect
import hashlib
from typing import Dict, List, Optional
from app.core.config import settings
# 每个真实实例在 hash 环上的虚拟节点数。越多分布越均匀。实例数少时(6~10)需要更多
# 虚拟节点才均匀:实测 150→负载偏差~16%、1000→~6%。环只在启动/实例变更时构建一次,
# 1000×N 个点的构建与 bisect 查找开销都可忽略。
_VNODES_PER_NODE = 1000
def _hash(s: str) -> int:
"""取 md5 前 8 hex(32-bit)做环坐标。确定性、跨进程一致。"""
return int(hashlib.md5(s.encode("utf-8")).hexdigest()[:8], 16)
class _HashRing:
"""ketama 风格一致性 hash 环。不可变,实例列表变化时整体重建(见 _get_ring)。"""
def __init__(self, nodes: List[str]):
self._keys: List[int] = [] # 升序的环坐标
self._key_to_node: Dict[int, str] = {}
for node in nodes:
for v in range(_VNODES_PER_NODE):
h = _hash(f"{node}#{v}")
# 极小概率撞坐标,撞了跳过该虚拟节点(不影响正确性,仅少一个 vnode)
if h not in self._key_to_node:
self._key_to_node[h] = node
self._keys.append(h)
self._keys.sort()
def pick(self, key: str) -> str:
h = _hash(key)
idx = bisect.bisect(self._keys, h)
if idx == len(self._keys):
idx = 0 # 环回绕
return self._key_to_node[self._keys[idx]]
# 按实例列表缓存环,列表变了(改 PRICEBOT_INSTANCES + 重启)才重建。
_ring: Optional[_HashRing] = None
_ring_nodes: tuple = ()
def _get_ring(nodes: List[str]) -> _HashRing:
global _ring, _ring_nodes
key = tuple(nodes)
if _ring is None or key != _ring_nodes:
_ring = _HashRing(nodes)
_ring_nodes = key
return _ring
def pick_pricebot(trace_id: Optional[str]) -> str:
"""按 trace_id 选 pricebot 实例 base url。
- 实例列表来自 settings.pricebot_instances(空则单实例兜底 [PRICEBOT_BASE_URL])
- trace_id 缺失/空 或 单实例 → 直接返回第一个,不进环
"""
nodes = settings.pricebot_instances
if len(nodes) == 1 or not trace_id:
return nodes[0]
# str() 防御:协议保证 trace_id 是 str,但万一传入非 str(如 int)也不至于在
# _hash 的 .encode() 处炸,确定性地选到实例。
return _get_ring(nodes).pick(str(trace_id))
+3 -4
View File
@@ -34,9 +34,8 @@ COIN_PER_YUAN: int = 10000
CENTS_PER_YUAN: int = 100
# 1 分对应多少金币 = 100。兑换金币数必须是它的整数倍,否则会出现不足 1 分的零头。
COIN_PER_CENT: int = COIN_PER_YUAN // CENTS_PER_YUAN
# 单次兑换最少 1 分(=COIN_PER_CENT;产品定:可兑 1 分起,与 step 同粒度)。早先为 1 元(=COIN_PER_YUAN),
# 用户 2026-06 改为 1 分:让"换现金"小额即可用(也方便联调验证资产卡飞金币动画)。
MIN_EXCHANGE_COIN: int = COIN_PER_CENT
# 单次兑换最少 1 元(避免大量 1 分级碎兑)
MIN_EXCHANGE_COIN: int = COIN_PER_YUAN
def coins_to_cents(coin_amount: int) -> int:
@@ -55,7 +54,7 @@ TASK_ENABLE_NOTIFICATION = "enable_notification"
# task_key -> 奖励金币
# 打开消息提醒: 1000 金币(=¥0.1, 客户端原型展示口径; 量级与签到/里程碑相称)。
# 注意: 不再 = 兑换下限(下限已降到 MIN_EXCHANGE_COIN=COIN_PER_CENT=100), test_exchange_flow 改走 grant_coins 直接供款。
# 注意: 不再 = 兑换下限(MIN_EXCHANGE_COIN=10000), test_exchange_flow 改走 grant_coins 直接供款。
TASK_REWARDS: dict[str, int] = {
TASK_ENABLE_NOTIFICATION: 1000,
}
+1 -1
View File
@@ -86,7 +86,7 @@ class ComparisonRecord(Base):
# ===== 明细(JSON,越详细越好)=====
# 下单菜品 [{name, qty, specs?}]
items: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名)
# 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved}](price/coupon_saved 单位:元,原样存)
comparison_results: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
# 目标平台未找到、跳过的菜名
skipped_dish_names: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
+1 -5
View File
@@ -52,7 +52,6 @@ class SavingsBattle:
week_saved_cents: int # 本周(周一起)已省
beat_percent: int # 超过百分之多少用户
streak_days: int # 连续省钱天数
compare_count: int # 累计完成比价次数(= 有效记录数:真实用 compare 记录、否则 demo 兜底)
def _local_date(dt: datetime):
@@ -164,10 +163,7 @@ def get_battle(db: Session, user_id: int) -> SavingsBattle:
beat_percent = _compute_beat_percent(db, user_id)
return SavingsBattle(
week_saved_cents=week_saved,
beat_percent=beat_percent,
streak_days=streak,
compare_count=len(records),
week_saved_cents=week_saved, beat_percent=beat_percent, streak_days=streak
)
-3
View File
@@ -37,9 +37,6 @@ class ComparisonResultIn(BaseModel):
# 必须显式声明: 落库走 model_dump(), pydantic 默认丢未知字段, 不声明这行会被悄悄吞掉。
# 各平台抠到红包即带值(2026-06 起源平台 Phase1 意图识别也抠, 当前仅淘宝源)。见 pricebot 侧 比价红包额留痕-实现方案.md。
coupon_saved: float | None = None
# 优惠**来源名**(展示用, best-effort): 美团"外卖大额神券"/京东"百亿补贴"/淘宝"平台红包"。
# None=没抠到 → 前端走通用"红包"。同样必须显式声明否则上报边界被 pydantic 静默丢弃(pricebot#38 引入)。
coupon_name: str | None = None
class ComparisonRecordIn(BaseModel):
-1
View File
@@ -223,7 +223,6 @@ class SavingsBattleOut(BaseModel):
week_saved_cents: int = Field(..., description="本周已省(分)")
beat_percent: int = Field(..., description="超过百分之多少用户")
streak_days: int = Field(..., description="连续省钱天数")
compare_count: int = Field(..., description="累计完成比价次数(= 比价上报记录数)")
class SavingsRecordOut(BaseModel):
+1 -21
View File
@@ -3,7 +3,7 @@
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
> 最后更新:2026-06-04+ 运营后台 Admin 子应用 A1–A18,见下方「运营后台 Admin」组
> 最后更新:2026-05-31+ 比价战绩里程碑 12d/12e
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
---
@@ -69,26 +69,6 @@
| **静态资源**StaticFiles 挂载,见下方 `/media` 静态服务) |||
| - | `GET /media/avatars/<file>` | 无 | 用户头像;返回二进制图片 |
| - | `GET /media/feedback/<file>` | 无 | 反馈截图;返回二进制图片 |
| **运营后台 Admin**(独立子应用 `app/admin/`,前缀 `/admin/api`,独立进程 + 独立 admin JWT。鉴权列:`admin`=任意已登录管理员,`operator`/`finance`/`super_admin`=需对应角色(`super_admin` 恒通过)) |||
| A1 | `POST /admin/api/auth/login` | 无 | [详情](./admin-auth-login.md) |
| A2 | `GET /admin/api/auth/me` | admin | [详情](./admin-auth-me.md) |
| A3 | `GET /admin/api/stats/overview` | admin | [详情](./admin-stats-overview.md) |
| A4 | `GET /admin/api/users` | admin | [详情](./admin-users-list.md) |
| A5 | `GET /admin/api/users/{user_id}` | admin | [详情](./admin-user-detail.md) |
| A6 | `POST /admin/api/users/{user_id}/status` | operator | [详情](./admin-user-status.md) |
| A7 | `POST /admin/api/users/{user_id}/coins` | finance | [详情](./admin-user-coins.md) |
| A8 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin-wallet-coin-transactions.md) |
| A9 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin-wallet-cash-transactions.md) |
| A10 | `GET /admin/api/withdraws` | admin | [详情](./admin-withdraws-list.md) |
| A11 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin-withdraw-reconcile.md) |
| A12 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin-withdraw-refresh.md) |
| A13 | `GET /admin/api/feedbacks` | admin | [详情](./admin-feedbacks-list.md) |
| A14 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin-feedback-handle.md) |
| A15 | `GET /admin/api/admins` | super_admin | [详情](./admin-admins-list.md) |
| A16 | `POST /admin/api/admins` | super_admin | [详情](./admin-admin-create.md) |
| A17 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin-admin-update.md) |
| A18 | `GET /admin/api/audit-logs` | admin | [详情](./admin-audit-logs.md) |
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
> `coupon/step` 及外卖比价的 `intent/recognize` / `price/step` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见各接口详情)。
-33
View File
@@ -1,33 +0,0 @@
# POST /admin/api/admins — 创建管理员
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins` | 鉴权:Bearer admin_token(角色:super_admin | [← 返回 API 索引](./README.md)
## 入参
**application/json**:
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `username` | string | ✓ | — | 账号,364 字 |
| `password` | string | ✓ | — | 初始密码,872 字(bcrypt ≤72 字节) |
| `role` | string | ✗ | `operator` | 角色,枚举:`super_admin` / `finance` / `operator` |
## 出参
响应 `200`:`AdminOut`(新建的管理员)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 管理员 id |
| `username` | string | 账号 |
| `role` | string | 角色 |
| `status` | string | 状态(新建默认 `active`) |
| `created_at` | datetime | 创建时间(UTC) |
| `last_login_at` | datetime \| null | 上次登录时间(新建为 null) |
## 错误码
- `401` 未带 admin token / token 无效或过期 / 管理员被禁用
- `403` 角色不足(仅 super_admin)
- `409` 用户名已存在
- `422` 缺字段 / `username` 长度不在 364 / `password` 长度不在 872 / `role` 非法枚举
## 说明
- 创建成功后写一条审计:`action=admin.create``target_type=admin``target_id=新管理员 id``detail={username, role}`。见 [admin_audit_log](../database/admin_audit_log.md)。
- 数据表见 [admin_user](../database/admin_user.md)。
-39
View File
@@ -1,39 +0,0 @@
# PATCH /admin/api/admins/{admin_id} — 改角色/启停/重置密码
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins` | 鉴权:Bearer admin_token(角色:super_admin | [← 返回 API 索引](./README.md)
## 入参
**路径参数**:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `admin_id` | int | ✓ | 目标管理员 id |
**application/json**(三字段都可选,只改传了的;至少传一个):
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `role` | string | ✗ | 改角色,枚举:`super_admin` / `finance` / `operator` |
| `status` | string | ✗ | 启停,枚举:`active`(启用)/ `disabled`(禁用) |
| `password` | string | ✗ | 重置密码,872 字(传则覆盖原密码) |
## 出参
响应 `200`:`AdminOut`(更新后的管理员)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 管理员 id |
| `username` | string | 账号 |
| `role` | string | 角色 |
| `status` | string | 状态 |
| `created_at` | datetime | 创建时间(UTC) |
| `last_login_at` | datetime \| null | 上次登录时间 |
## 错误码
- `400` 不能禁用自己(`admin_id == 当前 admin.id``status=disabled`) / 无任何变更字段(三字段全空)
- `401` 未带 admin token / token 无效或过期 / 管理员被禁用
- `403` 角色不足(仅 super_admin)
- `404` 管理员不存在
- `422` `role`/`status` 非法枚举 / `password` 长度不在 872
## 说明
- 更新成功后写一条审计:`action=admin.update``target_type=admin``target_id=admin_id``detail` 为本次实际变更字段(如 `{"role": "...", "status": "...", "password": "reset"}`,密码只记 `reset` 不记明文)。见 [admin_audit_log](../database/admin_audit_log.md)。
- 数据表见 [admin_user](../database/admin_user.md)。
-26
View File
@@ -1,26 +0,0 @@
# GET /admin/api/admins — 管理员列表
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins` | 鉴权:Bearer admin_token(角色:super_admin | [← 返回 API 索引](./README.md)
## 入参
无(按 `id` 升序返回全部,无分页)
## 出参
响应 `200`:`AdminOut[]`
**AdminOut**
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 管理员 id |
| `username` | string | 账号 |
| `role` | string | 角色:`super_admin` / `finance` / `operator` |
| `status` | string | 状态:`active` / `disabled` |
| `created_at` | datetime | 创建时间(UTC) |
| `last_login_at` | datetime \| null | 上次登录时间,从未登录为 null |
## 错误码
- `401` 未带 admin token / token 无效或过期 / 管理员被禁用
- `403` 角色不足(仅 super_admin 可访问,detail 形如 `role 'operator' not allowed (need one of [...])`)
## 说明
账号管理整组(`/admin/api/admins`)的角色守卫为 `require_role()` 无参,即仅 `super_admin` 通过。数据表见 [admin_user](../database/admin_user.md)。
-35
View File
@@ -1,35 +0,0 @@
# GET /admin/api/audit-logs — 审计日志(谁改了什么,游标分页)
> 所属:Admin·Audit 组(前缀 `/admin/api/audit-logs` | 鉴权:Bearer admin_token(角色:任意已登录 admin | [← 返回 API 索引](./README.md)
## 入参(query
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `action` | string | ❌ | null | 按操作类型过滤,如 `admin.create` / `admin.update` |
| `target_type` | string | ❌ | null | 按目标对象类型过滤,如 `admin` |
| `admin_id` | int | ❌ | null | 按操作人(管理员 id)过滤 |
| `limit` | int | ❌ | 50 | 1100 |
| `cursor` | int | ❌ | null | 上一页末条 `id`,首页不传 |
## 出参
响应 `200`:`{ items: AdminAuditLogOut[], next_cursor: int|null }`(按 `id` 倒序;分页见 [索引#游标分页约定](./README.md#游标分页约定)
**AdminAuditLogOut**
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 审计 id(也是游标) |
| `admin_id` | int | 操作人管理员 id |
| `admin_username` | string | 操作人账号(写入时快照) |
| `action` | string | 操作类型,如 `admin.create` / `admin.update` |
| `target_type` | string | 目标对象类型,如 `admin` |
| `target_id` | string \| null | 目标对象 id(字符串) |
| `detail` | object \| null | 操作详情(JSON,如变更字段) |
| `ip` | string \| null | 操作来源 IP(取 XFF 首段或直连 IP,仅记录不鉴权) |
| `created_at` | datetime | 操作时间(UTC) |
## 错误码
- `401` 未带 admin token / token 无效或过期 / 管理员被禁用
## 说明
- 整组(`/admin/api/audit-logs`)守卫为 `get_current_admin`,任意已登录 admin 均可查看,无角色限制。
- 审计日志只增不改不删,任何写操作经 `write_audit` 落一条。数据表见 [admin_audit_log](../database/admin_audit_log.md)。
-40
View File
@@ -1,40 +0,0 @@
# POST /admin/api/auth/login — 管理员登录
> 所属:Admin·Auth 组(前缀 `/admin/api/auth` | 鉴权:无 | [← 返回 API 索引](./README.md)
## 入参
**application/json**:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `username` | string | ✓ | 管理员账号,164 字 |
| `password` | string | ✓ | 密码,1128 字 |
## 出参
响应 `200`:`AdminLoginResponse`
| 字段 | 类型 | 说明 |
|---|---|---|
| `access_token` | string | admin JWT(独立 `ADMIN_JWT_SECRET`、payload `typ=admin`、默认 12h、无 refresh) |
| `token_type` | string | 固定 `Bearer` |
| `expires_in` | int | access_token 剩余秒数(过期需重新登录) |
| `admin` | AdminOut | 当前管理员信息,字段见下 |
**AdminOut**
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 管理员 id |
| `username` | string | 账号 |
| `role` | string | 角色:`super_admin` / `finance` / `operator` |
| `status` | string | 状态:`active` / `disabled` |
| `created_at` | datetime | 创建时间(UTC) |
| `last_login_at` | datetime \| null | 上次登录时间,从未登录为 null |
## 错误码
- `401` 用户名或密码错误(用户名不存在与密码错误统一同文案,防账号枚举)
- `403` 账号已禁用(`status != active`)
- `422``username``password` / 长度超限
- `429` 同 IP 每分钟登录超过 10 次(限流防爆破)
## 说明
- 登录成功后回写 `last_login_at` 为当前时间。
- 后续所有 admin 接口须带 `Authorization: Bearer <access_token>`;admin token 与 App 用户 token 完全隔离。
-24
View File
@@ -1,24 +0,0 @@
# GET /admin/api/auth/me — 当前管理员
> 所属:Admin·Auth 组(前缀 `/admin/api/auth` | 鉴权:Bearer admin_token(角色:任意已登录 admin | [← 返回 API 索引](./README.md)
## 入参
无(身份取自 Header token
## 出参
响应 `200`:`AdminOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 管理员 id |
| `username` | string | 账号 |
| `role` | string | 角色:`super_admin` / `finance` / `operator` |
| `status` | string | 状态:`active` / `disabled` |
| `created_at` | datetime | 创建时间(UTC) |
| `last_login_at` | datetime \| null | 上次登录时间,从未登录为 null |
## 错误码
- `401` 未带 admin token / token 无效或过期 / `typ` 非 admin / 管理员不存在或被禁用(响应头带 `WWW-Authenticate: Bearer`)
## 说明
-23
View File
@@ -1,23 +0,0 @@
# POST /admin/api/feedbacks/{feedback_id}/handle — 标记反馈已处理
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks` | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过,`require_role("operator")` | [← 返回 API 索引](./README.md)
## 入参
- 路径:`feedback_id`(int)
- body:无
## 出参
响应 `200`:`OkResponse` = `{ "ok": true }`
幂等说明:将该反馈 `status` 置为 `handled`(不校验原状态,重复调用结果一致)。
## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
- `403` 角色不足(需 `operator``super_admin`)
- `404` 反馈不存在(`detail: "反馈不存在"`)
- `422` `feedback_id` 非合法 int
## 说明
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action="feedback.handle"``target_type="feedback"``target_id=<feedback_id>``detail={"before": <原 status>, "after": "handled"}``ip=<客户端 IP>`
- 状态变更与审计写入在同一事务(`commit=False` 后统一 `db.commit()`)。
- 关联表 [feedback](../database/feedback.md)。
-34
View File
@@ -1,34 +0,0 @@
# GET /admin/api/feedbacks — 反馈工单列表(游标分页)
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks` | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 `require_role`,仅 `get_current_admin` | [← 返回 API 索引](./README.md)
## 入参(query
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `status` | string | ❌ | null | 反馈状态,精确匹配:`new`(待处理) / `handled`(已处理);传空/不传则不筛 |
| `user_id` | int | ❌ | null | 按提交用户 id 精确筛 |
| `limit` | int | ❌ | 20 | 1100 |
| `cursor` | int | ❌ | null | 上一页 next_cursor(按 feedback id 倒序,查 `id < cursor`) |
## 出参
响应 `200`:`{ items: FeedbackOut[], next_cursor: int|null }`(`next_cursor=null` 表示末页)
**FeedbackOut**
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 反馈 id |
| `user_id` | int | 提交用户 id |
| `content` | string | 反馈内容 |
| `contact` | string | 联系方式(微信/QQ/手机,便于回访) |
| `images` | string[] \| null | 截图相对路径列表(如 `["/media/feedback/u1_ab12.jpg"]`),无图为 null |
| `status` | string | 反馈状态:`new`(待处理) / `handled`(已处理) |
| `created_at` | datetime | 提交时间(UTC) |
## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
- `422` `limit` 超出 1–100 范围 / 字段类型不合法
## 说明
- 游标分页约定:结果按 feedback `id` 倒序;`cursor` 传上一页返回的 `next_cursor`;`next_cursor=null` 即末页。
- `status` / `user_id` 均为精确匹配,可叠加。
- 关联表 [feedback](../database/feedback.md);截图为相对路径,经 `GET /media/feedback/<file>` 静态读。
-69
View File
@@ -1,69 +0,0 @@
# GET /admin/api/stats/overview — 大盘核心指标
> 所属:Admin·数据大盘 组(前缀 `/admin/api/stats` | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role | [← 返回 API 索引](./README.md)
## 入参
## 出参
响应 `200`:`DashboardOverview`(全局只读聚合,六大块嵌套)
| 字段 | 类型 | 说明 |
|---|---|---|
| `users` | DashboardUsers | 用户指标 |
| `coins` | DashboardCoins | 金币指标 |
| `cash` | DashboardCash | 现金/提现指标 |
| `comparison` | DashboardComparison | 比价指标 |
| `feedback` | DashboardFeedback | 反馈指标 |
| `cps` | DashboardCps | CPS 收入(P2 未接入) |
**DashboardUsers**
| 字段 | 类型 | 说明 |
|---|---|---|
| `total` | int | 用户总数 |
| `active` | int | 状态 `active` 数 |
| `disabled` | int | 状态 `disabled` 数 |
| `deleted` | int | 状态 `deleted` 数 |
| `new_today` | int | 今日新增(按北京时区切天的 `created_at`) |
| `dau` | int | 日活(今日有 `last_login_at`,北京时区切天) |
**DashboardCoins**
| 字段 | 类型 | 说明 |
|---|---|---|
| `granted_total` | int | 累计发放金币(coin_transaction 中所有 `amount > 0` 之和;负数兑换/扣减不计) |
**DashboardCash**
| 字段 | 类型 | 说明 |
|---|---|---|
| `withdraw_success_cents` | int | 提现成功累计金额(分,`status=success` 之和) |
| `withdraw_pending_count` | int | 提现 `pending` 单数 |
| `withdraw_success_count` | int | 提现 `success` 单数 |
| `withdraw_failed_count` | int | 提现 `failed` 单数 |
**DashboardComparison**
| 字段 | 类型 | 说明 |
|---|---|---|
| `total` | int | 比价记录总数 |
| `success` | int | 比价成功数(`status=success`) |
| `success_rate` | float | 成功率 = success/total,保留 4 位小数;total 为 0 时返回 `0.0` |
**DashboardFeedback**
| 字段 | 类型 | 说明 |
|---|---|---|
| `new` | int | 待处理反馈数(`status=new`) |
**DashboardCps**
| 字段 | 类型 | 说明 |
|---|---|---|
| `available` | bool | CPS 数据是否可用(当前固定 `false`) |
| `note` | string | 说明文案(当前固定 `"CPS 转化数据未接入(P2)"`) |
## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
## 说明
- 全部为全局只读聚合(count / sum / DAU / 成功率),不改任何数据。
- `new_today` / `dau` 按**北京时区**(UTC+8)切天,其余金额/计数无时区概念。
- 金额单位:`*_cents` 为分;金币(`granted_total`)为个数。
- CPS 收入数据源未接入(referral-link 只换链接,转化/佣金未回收),`cps` 恒为 `{available:false, note:...}`,前端显示"待接入"。
- 关联表:[user](../database/user.md) / [coin_transaction](../database/coin_transaction.md) / [withdraw_order](../database/withdraw_order.md) / [comparison_record](../database/comparison_record.md) / [feedback](../database/feedback.md)。
-29
View File
@@ -1,29 +0,0 @@
# POST /admin/api/users/{user_id}/coins — 手动增减金币(带审计)
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
## 入参
- 路径:`user_id`(int)
- **application/json**:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `amount` | int | ✓ | 金币变动(个数):正=增加,负=扣减;不可为 0 |
| `reason` | string | ✓ | 操作原因,1–128 字(必填,入审计与流水备注) |
## 出参
响应 `200`:`OkResponse` = `{ "ok": true }`
## 错误码
- `400` `amount == 0`(`detail: "amount 不能为 0"`);或负数扣减后余额会变负(`detail: "扣减后金币为负(当前余额 N)"`)
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
- `403` 角色不足(需 `finance``super_admin`)
- `404` 用户不存在(`detail: "用户不存在"`)
- `422``amount`/`reason``reason` 长度不在 1–128、字段类型不合法 / `user_id` 非整数
## 说明
- 金币 = 个数(非现金);本接口只动金币余额,不涉及现金(`*_cents`)。
- 扣减保护:`amount < 0` 时若扣减后金币余额 < 0 直接拒绝(运营误操作保护)。
- 金币变动写流水 [coin_transaction](../database/coin_transaction.md):`biz_type` 增加为 `admin_grant`、扣减为 `admin_deduct`,`remark = admin:<reason>`(截断至 128 字)。
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.coins.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount, balance_after, reason}`,并记录操作 IP。
- 金币变动 + 审计在同一事务原子提交(改钱必留痕)。
- 关联用户表 [user](../database/user.md);金币账户 [coin_account](../database/coin_account.md);金币流水 [coin_transaction](../database/coin_transaction.md)。
-43
View File
@@ -1,43 +0,0 @@
# GET /admin/api/users/{user_id} — 用户 360 详情
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role | [← 返回 API 索引](./README.md)
## 入参
- 路径:`user_id`(int)
## 出参
响应 `200`:`AdminUserOverview`(基础资料 + 钱包余额 + 各项 count;历史明细走各自分页接口)
| 字段 | 类型 | 说明 |
|---|---|---|
| `user` | AdminUserListItem | 用户基础资料,字段见下 |
| `coin_balance` | int | 当前金币余额(个数);无金币账户时为 0 |
| `cash_balance_cents` | int | 当前现金余额(分);无账户时为 0 |
| `total_coin_earned` | int | 累计获得金币(个数);无账户时为 0 |
| `comparison_total` | int | 比价记录总数 |
| `comparison_success` | int | 比价成功数(`status=success`) |
| `withdraw_total` | int | 提现单总数 |
| `withdraw_success_cents` | int | 提现成功累计金额(分,`status=success` 之和) |
| `feedback_total` | int | 反馈总数 |
**AdminUserListItem**(`user` 字段)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 用户 id |
| `phone` | string | 手机号 |
| `nickname` | string \| null | 昵称,可空 |
| `register_channel` | string | 注册渠道 |
| `status` | string | 用户状态:`active` / `disabled` / `deleted` |
| `wechat_openid` | string \| null | 微信 openid,可空 |
| `created_at` | datetime | 注册时间(UTC) |
| `last_login_at` | datetime | 上次登录时间(UTC) |
## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
- `404` 用户不存在(`detail: "用户不存在"`)
- `422` `user_id` 非整数
## 说明
- 金币三项(`coin_balance` / `cash_balance_cents` / `total_coin_earned`)读 [coin_account](../database/coin_account.md);从未发生金币动作(账户不存在)时统一返回 0。
- 各 count 为聚合数,明细历史走带 `user_id` 过滤的分页接口(金币流水 / 现金流水 / 提现 / 比价 / 反馈)。
- 关联用户表 [user](../database/user.md);金币账户 [coin_account](../database/coin_account.md);提现单 [withdraw_order](../database/withdraw_order.md);比价记录 [comparison_record](../database/comparison_record.md);反馈 [feedback](../database/feedback.md)。
-25
View File
@@ -1,25 +0,0 @@
# POST /admin/api/users/{user_id}/status — 封禁/解封用户
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
## 入参
- 路径:`user_id`(int)
- **application/json**:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `status` | string | ✓ | 目标状态,枚举 `active`(解封)/ `disabled`(封禁);注销 `deleted` 不走此接口 |
## 出参
响应 `200`:`OkResponse` = `{ "ok": true }`
## 错误码
- `400` 已注销账号不可改状态(目标用户当前 `status == deleted`,`detail: "已注销账号不可改状态"`)
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
- `403` 角色不足(需 `operator``super_admin`)
- `404` 用户不存在(`detail: "用户不存在"`)
- `422` `status``active`/`disabled` 枚举 / 缺字段 / `user_id` 非整数
## 说明
- 业务写(改用户状态)与审计写在同一事务原子提交:改了就有痕、有痕就真改了。
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.status.set`,`target_type = user`,`target_id = user_id`,`detail = {before, after}`,并记录操作 IP。
- 关联用户表 [user](../database/user.md)。
-37
View File
@@ -1,37 +0,0 @@
# GET /admin/api/users — 用户列表(筛选+分页)
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role | [← 返回 API 索引](./README.md)
## 入参(query
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `phone` | string | ❌ | null | 手机号**前缀**匹配(`phone LIKE '<值>%'`) |
| `register_channel` | string | ❌ | null | 注册渠道,精确匹配 |
| `status` | string | ❌ | null | 用户状态,精确匹配:`active` / `disabled` / `deleted` |
| `limit` | int | ❌ | 20 | 1100 |
| `cursor` | int | ❌ | null | 上一页 next_cursor(按 user id 倒序,查 `id < cursor`) |
## 出参
响应 `200`:`{ items: AdminUserListItem[], next_cursor: int|null }`(`next_cursor=null` 表示末页)
**AdminUserListItem**
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 用户 id |
| `phone` | string | 手机号 |
| `nickname` | string \| null | 昵称,可空 |
| `register_channel` | string | 注册渠道 |
| `status` | string | 用户状态:`active` / `disabled` / `deleted` |
| `wechat_openid` | string \| null | 微信 openid,可空 |
| `created_at` | datetime | 注册时间(UTC) |
| `last_login_at` | datetime | 上次登录时间(UTC) |
## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`)
- `422` `limit` 超出 1–100 范围 / 字段类型不合法
## 说明
- 游标分页约定:结果按 user `id` 倒序;`cursor` 传上一页返回的 `next_cursor`;`next_cursor=null` 即末页。
- `phone` 为前缀匹配(`LIKE '<值>%'`),`register_channel` / `status` 为精确匹配;三者可叠加。
- 关联用户表 [user](../database/user.md)。
- 历史明细(金币流水、提现、比价、反馈等)不在本列表,走各自带 `user_id` 过滤的分页接口。
@@ -1,35 +0,0 @@
# GET /admin/api/wallet/cash-transactions — 现金流水(游标分页)
> 所属:Admin·钱包 组(前缀 `/admin/api/wallet` | 鉴权:Bearer admin_token(角色:任意已登录管理员,仅需 `get_current_admin`,无 `require_role` | [← 返回 API 索引](./README.md)
跨用户查询全量现金流水,可按 `user_id` / `biz_type` 过滤。游标分页(`id` 倒序)。金额单位一律为**分**。
## 入参(query
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `user_id` | int | ❌ | null | 按用户过滤;不传则查全量 |
| `biz_type` | string | ❌ | null | 按业务类型过滤 |
| `limit` | int | ❌ | 20 | 1100 |
| `cursor` | int | ❌ | null | 上一页 next_cursor(查 `id < cursor` |
## 出参
响应 `200`:`{ items: CashTxnOut[], next_cursor: int|null }``next_cursor=null` 表示末页)
**CashTxnOut**
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 流水主键 |
| `user_id` | int | 所属用户 |
| `amount_cents` | int | 现金变动量,单位:**分**(正/负) |
| `balance_after_cents` | int | 本次变动后现金余额,单位:**分** |
| `biz_type` | string | 业务类型 |
| `ref_id` | string \| null | 关联业务 ID |
| `remark` | string \| null | 备注 |
| `created_at` | datetime | 创建时间(ISO 8601 UTC |
## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`
- `422` query 参数校验失败(如 `limit` 越界、类型错误)
## 说明
关联 [cash_transaction](../database/cash_transaction.md)。所有金额字段单位为分。
@@ -1,35 +0,0 @@
# GET /admin/api/wallet/coin-transactions — 金币流水(游标分页)
> 所属:Admin·钱包 组(前缀 `/admin/api/wallet` | 鉴权:Bearer admin_token(角色:任意已登录管理员,仅需 `get_current_admin`,无 `require_role` | [← 返回 API 索引](./README.md)
跨用户查询全量金币流水,可按 `user_id` / `biz_type` 过滤。游标分页(`id` 倒序)。
## 入参(query
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `user_id` | int | ❌ | null | 按用户过滤;不传则查全量 |
| `biz_type` | string | ❌ | null | 按业务类型过滤 |
| `limit` | int | ❌ | 20 | 1100 |
| `cursor` | int | ❌ | null | 上一页 next_cursor(查 `id < cursor` |
## 出参
响应 `200`:`{ items: CoinTxnOut[], next_cursor: int|null }``next_cursor=null` 表示末页)
**CoinTxnOut**
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 流水主键 |
| `user_id` | int | 所属用户 |
| `amount` | int | 金币变动量(正/负,单位:金币个数,非分) |
| `balance_after` | int | 本次变动后金币余额 |
| `biz_type` | string | 业务类型 |
| `ref_id` | string \| null | 关联业务 ID |
| `remark` | string \| null | 备注 |
| `created_at` | datetime | 创建时间(ISO 8601 UTC |
## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`
- `422` query 参数校验失败(如 `limit` 越界、类型错误)
## 说明
关联 [coin_transaction](../database/coin_transaction.md)。金币为个数计量,非分。
-37
View File
@@ -1,37 +0,0 @@
# POST /admin/api/withdraws/reconcile — 批量对账(扫超时 pending 单)
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws` | 鉴权:Bearer admin_token(角色:`finance``super_admin` 恒通过) | [← 返回 API 索引](./README.md)
扫描创建时间超过 `older_than_minutes` 分钟、仍为 `pending` 的提现单,逐单调微信查单并归一化(成功落 `success`;失败/已撤销则退款落 `failed`;查到 `WAIT_USER_CONFIRM` 视为用户放弃,撤单+退款)。用于解开"扣了款但转账没发起/没确认"的孤儿单。单笔失败不影响其余(内部 rollback 后继续,下轮再试)。
> 路由注册顺序上,静态路径 `/reconcile` 必须在路径参数 `/{out_bill_no}/refresh` 之前声明。
## 入参
- 路径:无
- query:
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `older_than_minutes` | int | ❌ | 15 | 只扫创建时间早于「现在 − N 分钟」的 pending 单;`ge=0` |
- body:无
## 出参
响应 `200`:`ReconcileResult`
**ReconcileResult**
| 字段 | 类型 | 说明 |
|---|---|---|
| `checked` | int | 本次扫到的超时 pending 单数量 |
| `resolved` | int | 其中被归一化为终态(success/failed)的数量 |
## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`
- `403` 角色不足(需 `finance``super_admin`
- `422` query 参数校验失败(如 `older_than_minutes < 0`
- `503` 微信支付未配置(捕获 `WxPayNotConfiguredError``detail="微信支付未配置"`
## 说明
- 调用底层 `app.repositories.wallet.reconcile_pending_withdraws`(内部逐单调微信查单/撤单/退款,各自 commit)。
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md)`action="withdraw.reconcile"``detail``{checked, resolved}`,含触发管理员与客户端 IP)。
- 关联 [withdraw_order](../database/withdraw_order.md)。
-39
View File
@@ -1,39 +0,0 @@
# POST /admin/api/withdraws/{out_bill_no}/refresh — 单笔提现重试查单
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws` | 鉴权:Bearer admin_token(角色:`finance``super_admin` 恒通过) | [← 返回 API 索引](./README.md)
对单笔提现单调微信查单并归一化:`SUCCESS``success``FAIL`/`CANCELLED`/`CLOSED`→退款+`failed`;查到 `WAIT_USER_CONFIRM` 视为用户放弃(`cancel_if_unconfirmed=True`),撤单+退款;`ACCEPTED`/`PROCESSING` 等仍在途则保持 `pending`。已是终态的单直接返回、不再查。
## 入参
- 路径:`out_bill_no`string — 商户提现单号
- body:无
## 出参
响应 `200`:`WithdrawOrderOut`(归一化后的最新提现单)
**WithdrawOrderOut**
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 提现单主键 |
| `user_id` | int | 所属用户 |
| `out_bill_no` | string | 商户提现单号 |
| `amount_cents` | int | 提现金额,单位:**分** |
| `status` | string | 单状态(`pending` / `success` / `failed` |
| `wechat_state` | string \| null | 微信侧转账状态 |
| `transfer_bill_no` | string \| null | 微信转账单号 |
| `fail_reason` | string \| null | 失败/退款原因 |
| `created_at` | datetime | 创建时间(ISO 8601 UTC |
| `updated_at` | datetime | 更新时间(ISO 8601 UTC |
## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`
- `403` 角色不足(需 `finance``super_admin`
- `404` 提现单不存在(`detail="提现单不存在"`
- `503` 微信支付未配置(捕获 `WxPayNotConfiguredError``detail="微信支付未配置"`
> 注:微信查单本身失败(非未配置)时不抛错、不改状态,单保持 `pending` 等下次重试,故无 `502`。
## 说明
- 调用底层 `app.repositories.wallet.refresh_withdraw_status(..., cancel_if_unconfirmed=True)`(内部调微信查单/撤单/退款并 commit)。
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md)`action="withdraw.refresh"``target_id=out_bill_no``detail``{status, wechat_state}`,含触发管理员与客户端 IP)。
- 关联 [withdraw_order](../database/withdraw_order.md)。金额单位为分。
-37
View File
@@ -1,37 +0,0 @@
# GET /admin/api/withdraws — 提现单列表(游标分页)
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws` | 鉴权:Bearer admin_token(角色:任意已登录管理员,列表为只读,仅需 `get_current_admin`,无 `require_role` | [← 返回 API 索引](./README.md)
跨用户查询全量提现单,可按 `user_id` / `status` 过滤。游标分页(`id` 倒序)。
## 入参(query
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `user_id` | int | ❌ | null | 按用户过滤;不传则查全量 |
| `status` | string | ❌ | null | 按状态过滤(如 `pending` / `success` / `failed` |
| `limit` | int | ❌ | 20 | 1100 |
| `cursor` | int | ❌ | null | 上一页 next_cursor(查 `id < cursor` |
## 出参
响应 `200`:`{ items: WithdrawOrderOut[], next_cursor: int|null }``next_cursor=null` 表示末页)
**WithdrawOrderOut**
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 提现单主键 |
| `user_id` | int | 所属用户 |
| `out_bill_no` | string | 商户提现单号(业务主键,重试/对账以此为准) |
| `amount_cents` | int | 提现金额,单位:**分** |
| `status` | string | 单状态(`pending` / `success` / `failed` |
| `wechat_state` | string \| null | 微信侧转账状态(如 `ACCEPTED` / `PROCESSING` / `WAIT_USER_CONFIRM` / `SUCCESS` |
| `transfer_bill_no` | string \| null | 微信转账单号 |
| `fail_reason` | string \| null | 失败/退款原因 |
| `created_at` | datetime | 创建时间(ISO 8601 UTC |
| `updated_at` | datetime | 更新时间(ISO 8601 UTC |
## 错误码
- `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`
- `422` query 参数校验失败(如 `limit` 越界、类型错误)
## 说明
关联 [withdraw_order](../database/withdraw_order.md)。金额单位为分。
+1 -2
View File
@@ -31,10 +31,9 @@
**Item**`{ name: string, qty: int=1, specs: string[]\|null }`
**Resultcomparison_results 元素)**`{ platform_id, platform_name, package, price(元,float\|null), is_source(bool), rank(int\|null), coupon_saved(元,float\|null), coupon_name(string\|null) }`
**Resultcomparison_results 元素)**`{ platform_id, platform_name, package, price(元,float\|null), is_source(bool), rank(int\|null), coupon_saved(元,float\|null) }`
- `coupon_saved`:该平台本单**平台主优惠额**(元)——美团红包 / 淘宝平台红包 / 京东优惠券·百亿补贴,**只取那一笔**,不含配送费减免/共减总额。仅外卖目标平台带值,源平台/没用为 `null`,前端记录页据此展示**「已优惠 ¥X」**(null 不展示)。
- `coupon_name`:优惠**来源名**(展示用,best-effort)——美团「外卖大额神券」/ 京东「百亿补贴」/ 淘宝「平台红包」。仅在该行有 `coupon_saved` 时带;`null` 时前端走通用「红包」。非用户选中的具体券名(选中态不可读)。
## 服务端派生(客户端不用算)
+1 -2
View File
@@ -13,7 +13,6 @@
| `week_saved_cents` | int | 本周已省(分) |
| `beat_percent` | int | 超过百分之多少用户 |
| `streak_days` | int | 连续省钱天数 |
| `compare_count` | int | 累计完成比价次数(= 比价上报记录数;有真实 `compare` 记录用真实数,否则 demo 兜底) |
## 说明
「我的」页「省钱战绩」卡数据源(三列:本周已省 / 完成比价 / 连续比价)。`compare_count``/summary``order_count` 同源(均 = 有效记录数),对应原型「完成比价(次)」列。接口已通但无真实业务写入时走 demo 兜底(seed 约 23 条)
「我的」页「省钱战绩」卡数据源。接口已通但无真实业务写入,实际多为 0
+1 -3
View File
@@ -3,7 +3,7 @@
> 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。
> ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。
> 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`。
> 最后更新:2026-06-04+ admin_user / admin_audit_log
> 最后更新:2026-05-31
---
@@ -24,8 +24,6 @@
| `feedback` | 用户帮助与反馈 | `models/feedback.py` | 反馈 | [详情](./feedback.md) |
| `comparison_record` | 比价记录(每次比价完整明细) | `models/comparison.py` | 比价记录 | [详情](./comparison_record.md) |
| `comparison_milestone_claim` | 比价战绩里程碑领取记录 | `models/comparison_milestone.py` | 比价记录/福利 | [详情](./comparison_milestone_claim.md) |
| `admin_user` | 运营后台管理员账号(独立鉴权) | `models/admin.py` | Admin 后台 | [详情](./admin_user.md) |
| `admin_audit_log` | 运营后台操作审计日志(只追加) | `models/admin.py` | Admin 后台 | [详情](./admin_audit_log.md) |
---
-30
View File
@@ -1,30 +0,0 @@
# admin_audit_log — 运营后台操作审计日志
> 模型 `app/models/admin.py` | 关联接口 [admin-audit-logs](../api/admin-audit-logs.md) | [← 表索引](./README.md)
每个**写操作**(改钱/改状态/处理反馈等)落一条,记录"谁在何时、对谁、做了什么、前后值"。仅追加、不可删,用于追溯。`admin_username` / `target_id` 冗余存字符串,即使关联对象被删/改名也能追溯。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `admin_id` | Integer | FK→admin_user.id, index, NOT NULL | 操作者 |
| `admin_username` | String(64) | NOT NULL | 冗余操作者用户名(改名/禁用后仍可追溯) |
| `action` | String(64) | index, NOT NULL | 操作类型,如 `user.coins.grant` / `user.status.set` / `withdraw.refresh` / `feedback.handle` |
| `target_type` | String(32) | NOT NULL | 被操作对象类型,如 `user` / `withdraw` / `feedback` |
| `target_id` | String(64) | nullable | 被操作对象 id(用字符串以兼容 `out_bill_no` 等非整型主键) |
| `detail` | JSON | nullable | 上下文 + 前后值,如 `{"amount":1000,"reason":"...","before":{...},"after":{...}}` |
| `ip` | String(64) | nullable | 操作者客户端 IP(取自 `X-Forwarded-For` 首段,仅记录不鉴权) |
| `created_at` | DateTime(tz) | server_default now(), index, NOT NULL | 操作时间 |
## 索引与约束
- PK: `id`
- index: `ix_admin_audit_log_admin_id`(`admin_id`)、`ix_admin_audit_log_action`(`action`)、`ix_admin_audit_log_created_at`(`created_at`)
## 关系
- `admin_id` → [`admin_user`](./admin_user.md).`id`(多对一)
## 说明
- **JSON 列**:`detail``JSON().with_variant(JSONB(), "postgresql")` —— PG 上 JSONB,SQLite 退化为通用 JSON(同 `comparison_record.raw_payload`)。
- **只追加**:无更新/删除接口,审计不可篡改。
- **IP 可伪造**:`X-Forwarded-For` 可被客户端伪造,nginx 必须用 `proxy_set_header X-Forwarded-For $remote_addr` 覆盖;审计 IP 仅作记录、不参与鉴权。
-28
View File
@@ -1,28 +0,0 @@
# admin_user — 运营后台管理员账号
> 模型 `app/models/admin.py` | 关联接口 [admin-auth-login](../api/admin-auth-login.md) / [admin-admins-list](../api/admin-admins-list.md) | [← 表索引](./README.md)
运营后台的管理员账号,与 App 用户(`user` 表)**完全隔离**:走独立 JWT secret、独立鉴权链(见 `app/admin/`)。密码 bcrypt 存哈希,带角色做权限分级。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `username` | String(64) | unique, index, NOT NULL | 登录名 |
| `password_hash` | String(255) | NOT NULL | bcrypt 哈希(明文不落库) |
| `role` | String(20) | NOT NULL, default `operator` | `super_admin`(全权+管账号)/ `finance`(钱:提现+金币)/ `operator`(用户+反馈+大盘) |
| `status` | String(20) | NOT NULL, default `active` | `active` / `disabled`(禁用后 token 立即失效) |
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间 |
| `last_login_at` | DateTime(tz) | nullable | 最近登录时间(登录成功时更新) |
## 索引与约束
- PK: `id`
- unique index: `ix_admin_user_username`(`username` 唯一)
## 关系
- 被 [`admin_audit_log`](./admin_audit_log.md).`admin_id` 引用(一管理员多条审计)。
## 说明
- **角色权限**:`super_admin` 恒通过所有角色守卫(`require_role`);`finance` 管钱(提现/金币),`operator` 管用户/反馈/大盘。具体守卫见各接口文档。
- **鉴权隔离**:admin token `typ=admin` + 独立 `ADMIN_JWT_SECRET`(≠ App 的 `JWT_SECRET_KEY`),App 用户 token 无法当 admin 用。admin 无 refresh,过期(默认 12h)重新登录。
- **初始化**:首个管理员用 `scripts/create_admin.py` 命令行创建(无自助注册接口)。
+1 -1
View File
@@ -29,7 +29,7 @@
| `status` | String(16) | NOT NULL, default `success` | `success`(拿到有效对比)/ `failed`(出错/没采到目标价) |
| `information` | String(256) | nullable | done 帧文案;成功=摘要,失败=具体原因(前端失败时当原因展示) |
| `items` | JSON(PG: JSONB) | NOT NULL, default [] | 下单菜品 `[{name, qty, specs?}]` |
| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved,coupon_name}]`;`coupon_saved`=该平台主优惠额(元,美团红包/淘宝平台红包/京东优惠券·百亿补贴,只取那一笔,不含配送费/共减总额),各平台抠到红包即带值(2026-06 起源平台 Phase1 意图识别也抠,当前仅淘宝源),没用/没抠到为 null,前端展示「已优惠 ¥X」;`coupon_name`=优惠来源名(展示用 best-effort,美团"外卖大额神券"/京东"百亿补贴"/淘宝"平台红包"),null 走前端通用"红包" |
| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved}]`;`coupon_saved`=该平台主优惠额(元,美团红包/淘宝平台红包/京东优惠券·百亿补贴,只取那一笔,不含配送费/共减总额),各平台抠到红包即带值(2026-06 起源平台 Phase1 意图识别也抠,当前仅淘宝源),没用/没抠到为 null,前端展示「已优惠 ¥X」 |
| `skipped_dish_names` | JSON(PG: JSONB) | NOT NULL, default [] | 被跳过的菜名 |
| `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报(calibration + done.params 全量) |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
+2 -5
View File
@@ -33,8 +33,7 @@ def _food_payload(trace_id: str) -> dict:
],
"comparison_results": [
{"platform_id": "meituan", "platform_name": "美团", "package": "com.sankuai.meituan",
"price": 123.50, "is_source": False, "rank": 1,
"coupon_saved": 7.0, "coupon_name": "外卖大额神券"},
"price": 123.50, "is_source": False, "rank": 1, "coupon_saved": 7.0},
{"platform_id": "taobao_flash", "platform_name": "淘宝闪购", "package": "com.taobao.taobao",
"price": 128.50, "is_source": True, "rank": 2},
{"platform_id": "jd_waimai", "platform_name": "京东外卖", "package": "com.jingdong.app.mall",
@@ -80,7 +79,7 @@ def test_report_and_derive(client) -> None:
def test_coupon_saved_passthrough(client) -> None:
"""红包优惠额 coupon_saved + 来源名 coupon_name 上报→落库→读出;仅目标平台带值,源平台不带(null)。"""
"""红包优惠额 coupon_saved 上报→落库→读出;仅目标平台带值,源平台不带(null)。"""
token = _login(client, "13800002010")
rid = client.post(
"/api/v1/compare/record", json=_food_payload("trace-coupon"), headers=_auth(token)
@@ -88,9 +87,7 @@ def test_coupon_saved_passthrough(client) -> None:
d = client.get(f"/api/v1/compare/records/{rid}", headers=_auth(token)).json()
by_pid = {x["platform_id"]: x for x in d["comparison_results"]}
assert by_pid["meituan"]["coupon_saved"] == 7.0 # 目标平台带值
assert by_pid["meituan"]["coupon_name"] == "外卖大额神券" # 来源名透传(pricebot#38)
assert by_pid["taobao_flash"].get("coupon_saved") is None # 源平台不带/为 null
assert by_pid["taobao_flash"].get("coupon_name") is None # 源平台无来源名
def test_source_is_cheapest_no_saving(client) -> None:
+6 -53
View File
@@ -133,10 +133,10 @@ def test_exchange_info(client) -> None:
def test_exchange_flow(client) -> None:
"""先供款够兑 1 元的金币 → 兑换 1 元 → 金币扣、现金加 → 现金流水有记录。
"""先供款够兑换下限的金币 → 兑换 1 元 → 金币扣、现金加 → 现金流水有记录。
兑换下限已降到 1 分(MIN_EXCHANGE_COIN = COIN_PER_CENT = 100), 但本用例验证兑换 1 元,
直接 grant_coins 注入 COIN_PER_YUAN(= 10000)当种子(够兑 1 元)
打开消息提醒任务已降到 1000 金币(不再 = 兑换下限), 不能再靠领任务供款;
直接 grant_coins 注入 MIN_EXCHANGE_COIN(= COIN_PER_YUAN = 10000)当种子。
"""
phone = "13800001005"
token = _login(client, phone)
@@ -144,7 +144,7 @@ def test_exchange_flow(client) -> None:
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
crud_wallet.grant_coins(db, user.id, COIN_PER_YUAN, biz_type="test_seed", remark="测试供款")
crud_wallet.grant_coins(db, user.id, MIN_EXCHANGE_COIN, biz_type="test_seed", remark="测试供款")
db.commit()
# 兑换 10000 金币 → 100 分
@@ -180,51 +180,6 @@ def test_exchange_flow(client) -> None:
assert page["items"][0]["biz_type"] == "exchange_in"
def test_exchange_min_floor_one_cent(client) -> None:
"""锁定本次下调的兑换下限:正好兑下限额(MIN_EXCHANGE_COIN=100 金币=1 分)应成功。
这是本 PR 的核心新能力(下限 10000→100,可兑 1 分起)。test_exchange_flow 兑的是
1 元(10000),在旧下限下也通过,证明不了新下限;本用例供款并兑换正好等于下限的
100 金币,断言到账 1 分。若有人把 MIN_EXCHANGE_COIN 改回 10000,100 会因低于下限被
判 400,本用例随之失败,从而把新下限值钉死。
"""
phone = "13800001010"
token = _login(client, phone)
# 供款: 正好注入一个下限额度的金币(=COIN_PER_CENT=100)
with SessionLocal() as db:
user = get_user_by_phone(db, phone)
assert user is not None
crud_wallet.grant_coins(
db, user.id, MIN_EXCHANGE_COIN, biz_type="test_seed", remark="测试供款"
)
db.commit()
# 兑换下限额 100 金币 → 1 分
r = client.post(
"/api/v1/wallet/exchange",
json={"coin_amount": MIN_EXCHANGE_COIN},
headers=_auth(token),
)
assert r.status_code == 200, r.text
res = r.json()
assert res["coin_amount"] == MIN_EXCHANGE_COIN
assert res["cash_added_cents"] == 1 # coins_to_cents(100) == 1
assert res["coin_balance"] == 0
assert res["cash_balance_cents"] == 1
# 金币流水有一笔 exchange_out(=-100)
r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token))
out_txn = next(t for t in r.json()["items"] if t["biz_type"] == "exchange_out")
assert out_txn["amount"] == -MIN_EXCHANGE_COIN
# 现金流水有且仅有一笔 exchange_in(=+1 分)
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
cash = r.json()["items"]
assert len(cash) == 1
assert cash[0]["amount_cents"] == 1
assert cash[0]["biz_type"] == "exchange_in"
def test_exchange_insufficient_and_invalid(client) -> None:
token = _login(client, "13800001006")
@@ -236,10 +191,10 @@ def test_exchange_insufficient_and_invalid(client) -> None:
)
assert r.status_code == 409
# 低于最小额(下限现为 1 分=100;取 MIN_EXCHANGE_COIN-1=99,既低于下限又非整分)→ 400
# 低于最小额 → 400
r = client.post(
"/api/v1/wallet/exchange",
json={"coin_amount": MIN_EXCHANGE_COIN - 1},
json={"coin_amount": MIN_EXCHANGE_COIN - COIN_PER_CENT},
headers=_auth(token),
)
assert r.status_code == 400
@@ -271,8 +226,6 @@ def test_savings_summary_and_battle(client) -> None:
assert b["streak_days"] >= 1
assert b["week_saved_cents"] >= 0
assert 0 <= b["beat_percent"] <= 100
# 完成比价次数与 summary 的 order_count 同源(均 = 有效记录数)
assert b["compare_count"] == s["order_count"]
def test_savings_seeder_idempotent(client) -> None:
-494
View File
@@ -1,494 +0,0 @@
"""美团 CPS API Playground —— 本地调参/试 API 的可视化工具。
浏览器填参数 → 本地后端用 meituan.py 的同款签名打美团 → 返回结果,
页面渲染「卡片列表 + 可折叠 JSON 树」。AppSecret 只留在后端,浏览器不接触。
跑法:
/Users/pure/miniconda3/envs/price/bin/python tools/meituan_playground.py
然后开 http://127.0.0.1:8799
只读工具(只调 query 类接口),复用本仓库 .env 里的 MT_CPS 凭证。
"""
from __future__ import annotations
import asyncio
import json
import os
import sys
import time
# 让 `from app...` 可导入 + pydantic-settings 从仓库根读 .env
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
os.chdir(ROOT)
sys.path.insert(0, ROOT)
import httpx # noqa: E402
from fastapi import FastAPI, Request # noqa: E402
from fastapi.responses import HTMLResponse, JSONResponse # noqa: E402
from app.core.config import settings # noqa: E402
from app.integrations.meituan import _content_md5, _sign # noqa: E402
app = FastAPI(title="Meituan CPS Playground")
DEFAULT_PATH = "/cps_open/common/api/v1/query_coupon"
def call_raw(path: str, body_obj: dict) -> dict:
"""用 meituan.py 同款 S-Ca 签名打美团,原样返回(不管 code 是否为 0,方便看错误体)。"""
body = json.dumps(body_obj, ensure_ascii=False).encode("utf-8")
md5 = _content_md5(body)
ts = str(int(time.time() * 1000))
signed = {"S-Ca-App": settings.MT_CPS_APP_KEY, "S-Ca-Timestamp": ts}
sig = _sign(settings.MT_CPS_APP_SECRET, "POST", md5, path, signed)
headers = {
"Content-Type": "application/json",
"Content-MD5": md5,
"S-Ca-App": settings.MT_CPS_APP_KEY,
"S-Ca-Timestamp": ts,
"S-Ca-Signature-Headers": "S-Ca-Timestamp,S-Ca-App",
"S-Ca-Signature": sig,
}
url = f"{settings.MT_CPS_HOST}{path}"
t0 = time.time()
resp = httpx.post(url, content=body, headers=headers, timeout=settings.MT_CPS_TIMEOUT_SEC)
ms = int((time.time() - t0) * 1000)
try:
j = resp.json()
except Exception:
j = {"_raw_text": resp.text[:2000]}
return {"http_status": resp.status_code, "elapsed_ms": ms, "json": j}
def _is_rate_limited(j: dict) -> bool:
code = str(j.get("code"))
msg = str(j.get("message") or j.get("msg") or "")
return code == "402" or "频繁" in msg
@app.post("/api/query")
async def api_query(req: Request):
try:
payload = await req.json()
except Exception as e:
return JSONResponse({"ok": False, "error": f"请求体不是合法 JSON: {e}"})
path = (payload.get("path") or DEFAULT_PATH).strip()
body = payload.get("body")
if not isinstance(body, dict):
return JSONResponse({"ok": False, "error": "body 必须是 JSON 对象"})
if not settings.MT_CPS_APP_KEY or not settings.MT_CPS_APP_SECRET:
return JSONResponse({"ok": False, "error": "MT_CPS_APP_KEY / SECRET 未配置(.env)"})
# 美团这接口很容易 402「调用频繁」,交互式工具自动退避重试 3 次,UX 顺一点
last = None
for a in range(3):
try:
out = call_raw(path, body)
except Exception as e:
return JSONResponse({"ok": False, "error": f"{type(e).__name__}: {e}"})
if _is_rate_limited(out.get("json") or {}) and a < 2:
last = out
await asyncio.sleep(1.5 * (a + 1))
continue
return JSONResponse({"ok": True, **out, "retries": a})
return JSONResponse({"ok": True, **(last or {}), "retries": 2})
@app.get("/", response_class=HTMLResponse)
def index():
return HTML
HTML = r"""<!doctype html>
<html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>美团 CPS Playground</title>
<style>
:root{ --bd:#e3e6ea; --mut:#6b7280; --bg:#f6f7f9; --pri:#ffb000; --pri2:#ff8a00; }
*{box-sizing:border-box}
body{margin:0;font:13px/1.5 -apple-system,"PingFang SC",Segoe UI,Roboto,sans-serif;color:#1f2328;background:var(--bg)}
header{display:flex;align-items:center;gap:10px;padding:8px 14px;background:#fff;border-bottom:1px solid var(--bd);position:sticky;top:0;z-index:5}
header b{font-size:15px}
header .path{flex:1;min-width:200px;font-family:ui-monospace,Menlo,monospace;font-size:12px;padding:5px 8px;border:1px solid var(--bd);border-radius:6px}
#status{font-size:12px;color:var(--mut);white-space:nowrap}
#status .ok{color:#0a7d28;font-weight:600}
#status .err{color:#c0392b;font-weight:600}
main{display:flex;gap:0;height:calc(100vh - 49px)}
section{height:100%;overflow:auto;padding:12px}
#params{width:380px;min-width:340px;border-right:1px solid var(--bd);background:#fff}
#cards{flex:1;min-width:300px;border-right:1px solid var(--bd)}
#jsonpane{flex:1;min-width:300px;background:#fff}
h3{margin:2px 0 8px;font-size:12px;color:var(--mut);text-transform:uppercase;letter-spacing:.04em}
#cards h3{display:flex;align-items:center;gap:8px}
.presets{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px}
.presets button{font-size:12px;padding:4px 8px;border:1px solid var(--bd);background:#fff;border-radius:14px;cursor:pointer}
.presets button:hover{border-color:var(--pri2);color:var(--pri2)}
.cgrp{font-size:12px;font-weight:700;color:#374151;margin:9px 0 4px;padding-left:2px;border-left:3px solid var(--pri);padding-left:7px}
.chtag{display:inline-block;min-width:14px;text-align:center;font-size:10px;font-weight:800;padding:0 3px;border-radius:3px;margin-right:4px}
.ch-r{background:#fff1f0;color:#cf1322}
.ch-b{background:#eef3ff;color:#1d39c4}
.ch-a{background:#fff7e6;color:#d46b08}
.grid{display:grid;grid-template-columns:auto 1fr;gap:6px 8px;align-items:center;margin-bottom:10px}
.grid label{color:var(--mut);font-size:12px;text-align:right}
.grid input,.grid select{width:100%;padding:5px 7px;border:1px solid var(--bd);border-radius:6px;font-size:12px;font-family:inherit}
textarea{width:100%;height:200px;font-family:ui-monospace,Menlo,monospace;font-size:12px;padding:8px;border:1px solid var(--bd);border-radius:6px;resize:vertical;white-space:pre}
.btns{display:flex;gap:8px;margin:8px 0}
button.act{padding:6px 10px;border:1px solid var(--bd);background:#fff;border-radius:6px;cursor:pointer;font-size:12px}
button.send{flex:1;background:linear-gradient(180deg,var(--pri),var(--pri2));border:none;color:#3a2600;font-weight:700;padding:9px;border-radius:7px;cursor:pointer;font-size:13px}
button.send:active{transform:translateY(1px)}
.hint{font-size:11px;color:var(--mut);margin:6px 0}
#pageBtn{margin-left:auto;font-size:12px;padding:4px 12px;border:1px solid var(--bd);background:#fff;border-radius:14px;cursor:pointer}
#pageBtn:hover:not(:disabled){border-color:var(--pri2);color:var(--pri2)}
#pageBtn:disabled{opacity:.4;cursor:not-allowed}
/* cards */
.cardgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px}
.pagediv{grid-column:1/-1;text-align:center;color:#9aa0a6;font-size:12px;border-top:1px dashed var(--bd);padding:9px 0 3px;margin-top:6px}
.card{border:1px solid var(--bd);border-radius:10px;background:#fff;overflow:hidden;display:flex;flex-direction:column}
.card .thumb{width:100%;height:118px;object-fit:cover;background:#f0f1f3}
.card .body{padding:8px 9px;display:flex;flex-direction:column;gap:5px}
.card .brand{display:flex;align-items:center;gap:5px;color:var(--mut);font-size:11px}
.card .brand img{width:15px;height:15px;border-radius:3px;object-fit:cover}
.card .nm{font-weight:600;font-size:13px;line-height:1.3;max-height:2.6em;overflow:hidden}
.card .pr{display:flex;align-items:baseline;gap:6px}
.card .pr .sell{color:#e8420f;font-weight:800;font-size:17px}
.card .pr .sell:before{content:"¥";font-size:12px;font-weight:600}
.card .pr .ori{color:#9aa0a6;text-decoration:line-through;font-size:11px}
.badges{display:flex;flex-wrap:wrap;gap:4px}
.b{font-size:10px;padding:1px 6px;border-radius:8px;background:#f1f3f5;color:#445}
.b.sale{background:#fff2e8;color:#d4380d}
.b.comm{background:#e6fffb;color:#08979c}
.b.poi{background:#f0f5ff;color:#2f54eb}
.b.rank{background:#fff7e6;color:#d46b08}
.b.dist{background:#eaf7ee;color:#0a7d28;font-weight:600}
.pvs{font-family:ui-monospace,Menlo,monospace;font-size:10px;color:#888;display:flex;align-items:center;gap:5px;border-top:1px dashed var(--bd);padding-top:5px;margin-top:2px}
.pvs code{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pvs button{font-size:10px;border:1px solid var(--bd);background:#fff;border-radius:4px;cursor:pointer;padding:1px 5px}
.empty{color:var(--mut);padding:20px;text-align:center}
/* json tree */
.jtools{display:flex;gap:6px;margin-bottom:8px}
.jtools button{font-size:11px;border:1px solid var(--bd);background:#fff;border-radius:5px;cursor:pointer;padding:3px 7px}
#tree{font-family:ui-monospace,Menlo,monospace;font-size:12px;line-height:1.65}
.jrow{padding-left:14px}
.jhead{cursor:pointer;user-select:none}
.jtog{display:inline-block;width:12px;color:#9aa0a6}
.jkey{color:#8250df}
.jsum{color:#9aa0a6}
.jval.str{color:#0a7d28}
.jval.num{color:#0550ae}
.jval.bool,.jval.null{color:#cf222e}
.jkids{border-left:1px dotted #e3e6ea;margin-left:5px}
</style></head>
<body>
<header>
<b>🍔 美团 CPS Playground</b>
<input id="path" class="path" value="/cps_open/common/api/v1/query_coupon">
<span id="status">就绪</span>
</header>
<main>
<section id="params">
<h3>快捷模板</h3>
<div class="hint" style="margin:0 0 4px">渠道:<span class="chtag ch-r">榜</span>榜单&nbsp;&nbsp;<span class="chtag ch-b">搜</span>搜索词&nbsp;&nbsp;<span class="chtag ch-a">供</span>供给</div>
<div class="cgrp">到店 · 团购</div>
<div class="presets" id="presets-dd"></div>
<div class="cgrp">到家 · 外卖</div>
<div class="presets" id="presets-dj"></div>
<h3>测试坐标(点一下填经纬度)</h3>
<div class="presets" id="coords"></div>
<h3>参数(改完点「用表单生成 body」)</h3>
<div class="grid">
<label>召回方式</label>
<select id="channel">
<option value="topic">榜单 listTopiId</option>
<option value="search">搜索 searchText</option>
<option value="supply">多业务供给 multipleSupplyList</option>
<option value="ids">按 ID productViewSignList</option>
</select>
<label>platform</label>
<select id="platform"><option value="1">1 到家/外卖</option><option value="2" selected>2 到店</option></select>
<label>bizLine</label>
<input id="bizLine" value="1" placeholder="到店:1到餐2到综3酒店4门票;外卖填1">
<label>listTopiId</label>
<input id="listTopiId" value="5" placeholder="到店:2必推3热销5实时;到家:1精选2必推3热销">
<label>searchText</label>
<input id="searchText" placeholder="搜索关键词(选搜索时填)">
<label>productViewSignList</label>
<input id="ids" placeholder="逗号分隔的商品ID(按ID查时填)">
<label>sortField</label>
<select id="sortField">
<option value="">(默认/不传)</option>
<option value="1">1 售价</option><option value="2">2 销量</option>
<option value="3">3 佣金</option><option value="6">6 离我最近</option>
</select>
<label>cityId</label>
<input id="cityId" value="WKV2HMXUEK634WP64CUCUQGM64" placeholder="城市编码(默认北京)">
<label>经度</label>
<input id="lon" value="116.404" placeholder="十进制,自动×100万">
<label>纬度</label>
<input id="lat" value="39.928" placeholder="十进制,自动×100万">
<label>pageSize</label>
<input id="pageSize" value="20">
<label>pageNo</label>
<input id="pageNo" value="" placeholder="留空=默认1">
<label>searchId</label>
<input id="searchId" value="" placeholder="翻页用,一般不手填">
</div>
<div class="btns"><button class="act" id="genBtn">⟳ 用表单生成 body</button></div>
<h3>请求 body(可直接手改,发送以这里为准)</h3>
<textarea id="body"></textarea>
<div class="btns"><button class="send" id="sendBtn">▶ 发送请求(第 1 页)</button></div>
<div class="hint">经纬度填十进制(116.404)自动 ×100万;翻页按钮在中间卡片栏右上角(供给/搜索有下一页,榜单没有)。</div>
</section>
<section id="cards">
<h3>卡片列表 <span id="cardCount" style="color:#9aa0a6"></span><button id="pageBtn" disabled>下一页 </button></h3>
<div id="cardlist"><div class="empty">点「发送请求」后,这里渲染商品卡片</div></div>
</section>
<section id="jsonpane">
<h3>原始 JSON(点三角折叠/展开)</h3>
<div class="jtools">
<button id="expandAll">全部展开</button>
<button id="collapseAll">全部折叠</button>
</div>
<div id="tree"><div class="empty">原始响应在这里</div></div>
</section>
</main>
<script>
const $ = id => document.getElementById(id);
const esc = s => String(s==null?"":s).replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;"}[c]));
// 翻页状态
let lastJson = null; // 最近一页响应
let pageNum = 0; // 已展示页数
let totalShown = 0; // 累计卡片数
// ---------- 快捷模板(按 到店/到家 分组 + 渠道标识) ----------
const LON = 116404000, LAT = 39928000, BJ = "WKV2HMXUEK634WP64CUCUQGM64";
const ZLON = 116316000, ZLAT = 39984000; // 中关村
// ch: 榜=榜单(红) 搜=搜索词(蓝) 供=供给(琥珀)
const PRESETS = [
// 到店 · 团购
{g:"dd", ch:"", label:"实时热销", body:{platform:2, bizLine:1, listTopiId:5, longitude:LON, latitude:LAT, pageSize:20}},
{g:"dd", ch:"", label:"同城热销", body:{platform:2, bizLine:1, listTopiId:3, longitude:LON, latitude:LAT, pageSize:20}},
{g:"dd", ch:"", label:"到餐·按销量(翻页)", body:{multipleSupplyList:[{platform:2, bizLineParamList:[{bizLine:1}]}], cityId:BJ, sortField:2, pageSize:20}},
{g:"dd", ch:"", label:"到餐·按距离(中关村)", body:{multipleSupplyList:[{platform:2, bizLineParamList:[{bizLine:1}]}], cityId:BJ, sortField:6, longitude:ZLON, latitude:ZLAT, pageSize:20}},
// 到家 · 外卖
{g:"dj", ch:"", label:"同城热销·按销量", body:{platform:1, listTopiId:3, longitude:ZLON, latitude:ZLAT, pageSize:20}},
{g:"dj", ch:"", label:"按距离(中关村)", body:{searchText:"美食", sortField:6, longitude:ZLON, latitude:ZLAT, pageSize:20}},
{g:"dj", ch:"", label:"价格升序", body:{searchText:"美食", sortField:2, longitude:ZLON, latitude:ZLAT, pageSize:20}},
{g:"dj", ch:"", label:"综合(供给深)", body:{searchText:"美食", sortField:1, longitude:ZLON, latitude:ZLAT, pageSize:20}},
];
const CH_CLS = {"":"ch-r", "":"ch-b", "":"ch-a"};
PRESETS.forEach(p=>{
const box = $(p.g==="dd" ? "presets-dd" : "presets-dj");
const b = document.createElement("button");
b.innerHTML = `<span class="chtag ${CH_CLS[p.ch]}">${p.ch}</span>${p.label}`;
b.onclick = ()=>{ $("body").value = JSON.stringify(p.body, null, 2); };
box.appendChild(b);
});
// ---------- 测试坐标 ----------
const COORDS = {
"天安门":[116.397,39.909], "王府井":[116.418,39.914], "中关村":[116.316,39.984],
"国贸":[116.461,39.909], "三里屯":[116.455,39.937], "望京":[116.470,39.997],
};
const cbox = $("coords");
Object.entries(COORDS).forEach(([nm,[lo,la]])=>{
const b=document.createElement("button"); b.textContent=nm;
b.onclick=()=>applyCoords(lo,la); cbox.appendChild(b);
});
function applyCoords(lo, la){
$("lon").value = lo; $("lat").value = la;
// 若当前 body 里已有经纬度,顺手原地替换,立即生效(供给查询用 cityId 无经纬度则不动)
try{
const o = JSON.parse($("body").value);
if(o && typeof o==="object" && ("longitude" in o || "latitude" in o)){
o.longitude = Math.round(lo*1e6); o.latitude = Math.round(la*1e6);
$("body").value = JSON.stringify(o, null, 2);
}
}catch(e){}
}
// ---------- 表单 → body ----------
function num(id){ const v=$(id).value.trim(); return v===""? null : Number(v); }
function str(id){ const v=$(id).value.trim(); return v===""? null : v; }
function buildBody(){
const ch = $("channel").value;
const b = {};
const ps = num("pageSize"); if(ps!=null) b.pageSize = ps;
const pn = num("pageNo"); if(pn!=null) b.pageNo = pn;
const lon = num("lon"), lat = num("lat");
if(lon!=null) b.longitude = Math.round(lon*1e6);
if(lat!=null) b.latitude = Math.round(lat*1e6);
const sf = str("sortField"); if(sf!=null) b.sortField = Number(sf);
const city = str("cityId"); if(city!=null) b.cityId = city;
const sid = str("searchId"); if(sid!=null) b.searchId = sid;
const plat = Number($("platform").value);
const bl = num("bizLine");
if(ch==="supply"){
b.multipleSupplyList = [{platform:plat, bizLineParamList:[{bizLine: bl!=null? bl : 1}]}];
if(b.sortField==null) b.sortField = 2; // 多供给查询 sortField 必填
} else if(ch==="ids"){
const ids = str("ids");
b.productViewSignList = ids? ids.split(",").map(s=>s.trim()).filter(Boolean) : [];
} else {
b.platform = plat;
if(bl!=null) b.bizLine = bl;
if(ch==="topic"){ const t=num("listTopiId"); if(t!=null) b.listTopiId=t; }
else if(ch==="search"){ const s=str("searchText"); if(s!=null) b.searchText=s; }
}
return b;
}
$("genBtn").onclick = ()=>{ $("body").value = JSON.stringify(buildBody(), null, 2); };
// ---------- 发送 / 翻页 ----------
function setStatus(t, cls){ const s=$("status"); s.innerHTML = cls? `<span class="${cls}">${t}</span>` : t; }
async function doQuery(body, append){
setStatus("请求中…", "");
$("pageBtn").disabled = true;
let res;
try{
res = await fetch("/api/query", {method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({path: $("path").value.trim(), body})}).then(r=>r.json());
}catch(e){ setStatus("本地请求失败: "+e.message, "err"); updatePageBtn(); return; }
if(!res.ok){ setStatus(""+res.error, "err"); renderTree({error:res.error}); if(!append) renderCards({}, false); updatePageBtn(); return; }
const j = res.json || {};
lastJson = j;
const code = j.code, n = Array.isArray(j.data)? j.data.length : 0;
const cls = code===0? "ok":"err";
const rt = res.retries? ` · 退避${res.retries}次`:"";
setStatus(`HTTP ${res.http_status} · code=${code} · 本页${n}条 · hasNext=${j.hasNext} · ${res.elapsed_ms}ms${rt}`, cls);
renderCards(j, append);
renderTree(j);
updatePageBtn();
}
async function send(){
let body;
try{ body = JSON.parse($("body").value); }
catch(e){ setStatus("body 不是合法 JSON: "+e.message, "err"); return; }
pageNum = 1;
await doQuery(body, false);
}
async function nextPage(){
if(!lastJson) return;
let body;
try{ body = JSON.parse($("body").value); }
catch(e){ setStatus("body 不是合法 JSON,无法翻页: "+e.message, "err"); return; }
if(lastJson.searchId){ body.searchId = lastJson.searchId; } // 供给/搜索:带令牌
else if(lastJson.hasNext){ body.pageNo = (Number(body.pageNo)||1)+1; } // pageNo 翻页
else { setStatus("没有下一页(searchId 为空且 hasNext=false)", "err"); return; }
$("body").value = JSON.stringify(body, null, 2); // 反映当前翻页状态
pageNum += 1;
await doQuery(body, true);
}
function updatePageBtn(){
const b = $("pageBtn");
const canNext = !!(lastJson && (lastJson.searchId || lastJson.hasNext===true));
b.disabled = !canNext;
b.textContent = canNext? "下一页 " : "没有下一页";
}
$("sendBtn").onclick = send;
$("pageBtn").onclick = nextPage;
// ---------- 卡片渲染 ----------
function clean(u){ return u? String(u).split("@")[0] : ""; }
function fmtDist(d){ if(d==null||d==="") return null; const v=Number(d); if(!isFinite(v)) return null; return v>=1000? (v/1000).toFixed(1)+"km" : Math.round(v)+"m"; }
function cardGrid(){
let g = document.querySelector("#cardlist .cardgrid");
if(!g){ $("cardlist").innerHTML=""; g=document.createElement("div"); g.className="cardgrid"; $("cardlist").appendChild(g); }
return g;
}
function makeCardHtml(it){
const cpd = it.couponPackDetail || {};
const br = it.brandInfo || {};
const ci = it.commissionInfo || {};
const poi = it.availablePoiInfo || {};
const dp = it.deliverablePoiInfo || {};
const lab = it.productLabel || {};
const pp = lab.pricePowerLabel || {};
const pvs = cpd.productViewSign || cpd.skuViewId || "";
const commPct = ci.commissionPercent!=null ? (Number(ci.commissionPercent)/100)+"%"
: (ci.commission!=null? "¥"+ci.commission : null);
const dist = fmtDist(dp.deliveryDistance);
const badges = [];
if(dist) badges.push(`<span class="b dist" title="${esc((dp.poiName||"")+" · "+(dp.deliveryDistance||"")+"")}">📍 ${dist}</span>`);
if(cpd.saleVolume) badges.push(`<span class="b sale">${esc(cpd.saleVolume)}</span>`);
if(commPct) badges.push(`<span class="b comm">佣金 ${esc(commPct)}</span>`);
if(poi.availablePoiNum) badges.push(`<span class="b poi">门店 ${esc(poi.availablePoiNum)}</span>`);
if(dp.deliveryDistance) badges.push(`<span class="b">${esc(dp.deliveryDistance)}</span>`);
if(pp.beatMTLabel) badges.push(`<span class="b rank">${esc(pp.beatMTLabel)}</span>`);
else if(pp.historyPriceLabel) badges.push(`<span class="b rank">${esc(pp.historyPriceLabel)}</span>`);
if(lab.productRankLabel) badges.push(`<span class="b rank">${esc(lab.productRankLabel)}</span>`);
return `<div class="card">
${cpd.headUrl? `<img class="thumb" src="${esc(clean(cpd.headUrl))}" loading="lazy" onerror="this.style.visibility='hidden'">` : ``}
<div class="body">
<div class="brand">${br.brandLogoUrl? `<img src="${esc(clean(br.brandLogoUrl))}">`:``}${esc(br.brandName||dp.poiName||"")}</div>
<div class="nm">${esc(cpd.name||"(无名称)")}</div>
<div class="pr"><span class="sell">${esc(cpd.sellPrice??"?")}</span>${cpd.originalPrice!=null?`<span class="ori">¥${esc(cpd.originalPrice)}</span>`:``}</div>
<div class="badges">${badges.join("")}</div>
${pvs? `<div class="pvs"><code title="${esc(pvs)}">${esc(pvs)}</code><button onclick="navigator.clipboard.writeText('${esc(pvs)}')">复制ID</button></div>`:``}
</div></div>`;
}
function renderCards(json, append){
const data = json && Array.isArray(json.data)? json.data : [];
if(!append){ $("cardlist").innerHTML=""; totalShown=0; }
const g = cardGrid();
if(!data.length){
if(!append) $("cardlist").innerHTML = `<div class="empty">无 data 数组可渲染(看右侧 JSON)</div>`;
else g.insertAdjacentHTML("beforeend", `<div class="pagediv">— 第 ${pageNum} 页 · 0 条 —</div>`);
} else {
const divider = append? `<div class="pagediv">— 第 ${pageNum} 页 · ${data.length} 条 —</div>` : "";
g.insertAdjacentHTML("beforeend", divider + data.map(makeCardHtml).join(""));
totalShown += data.length;
}
$("cardCount").textContent = totalShown? `(累计 ${totalShown} 条 / ${pageNum} 页)` : "";
}
// ---------- JSON 树(可折叠) ----------
function el(tag,cls,txt){ const e=document.createElement(tag); if(cls)e.className=cls; if(txt!=null)e.textContent=txt; return e; }
function fmt(v){ return v===null? "null" : typeof v==="string"? `"${v}"` : String(v); }
function typeCls(v){ return v===null?"null":typeof v==="string"?"str":typeof v==="number"?"num":"bool"; }
function buildNode(key, val, depth){
const wrap = el("div","jrow");
const isObj = val && typeof val==="object";
if(isObj){
const entries = Array.isArray(val)? val.map((v,i)=>[i,v]) : Object.entries(val);
const head = el("div","jhead");
const open = depth < 2;
const tog = el("span","jtog", entries.length? (open?"":"") : "·");
const k = el("span","jkey", key!==null? key+": " : "");
const sum = el("span","jsum", Array.isArray(val)? `[${entries.length}]` : `{${entries.length}}`);
head.append(tog,k,sum);
const kids = el("div","jkids");
entries.forEach(([ck,cv])=> kids.append(buildNode(ck,cv,depth+1)));
kids.style.display = open? "block":"none";
head.onclick = ()=>{ const o=kids.style.display!=="none"; kids.style.display=o?"none":"block"; if(entries.length) tog.textContent=o?"":""; };
wrap.append(head,kids);
} else {
wrap.append(el("span","jkey", key+": "), el("span","jval "+typeCls(val), fmt(val)));
}
return wrap;
}
function renderTree(obj){ const t=$("tree"); t.innerHTML=""; t.append(buildNode(null, obj, 0)); }
$("expandAll").onclick = ()=>{ document.querySelectorAll("#tree .jkids").forEach(k=>k.style.display="block");
document.querySelectorAll("#tree .jtog").forEach(x=>{ if(x.textContent==="")x.textContent=""; }); };
$("collapseAll").onclick = ()=>{ document.querySelectorAll("#tree .jrow .jkids").forEach((k,i)=>{ if(i>0) k.style.display="none"; });
document.querySelectorAll("#tree .jtog").forEach((x,i)=>{ if(i>0 && x.textContent==="")x.textContent=""; }); };
// 初始填一个默认 body
$("body").value = JSON.stringify(PRESETS[0].body, null, 2);
</script>
</body></html>"""
if __name__ == "__main__":
import uvicorn
port = int(os.environ.get("MT_PLAYGROUND_PORT", "8799"))
print(f"\n 美团 CPS Playground → http://127.0.0.1:{port}\n (Ctrl-C 退出)\n")
uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")