Compare commits

...

5 Commits

Author SHA1 Message Date
marco 3eb439b5ae feat(onboarding): 新增重置新手引导端点 /api/v1/user/onboarding/reset (#114)
Reviewed-on: #114
2026-07-04 02:39:12 +08:00
liujiahui 6b10e866e9 7-3 尾声帧: /api/v1/trace/epilogue 透传壳 (#112)
比价结果页"尾声帧"配套改动(透传壳一条)。

App 收到 done、渲染完比价结果页后, 把自己页面的截图(base64)经本壳透传给 pricebot
`/api/trace/epilogue`, 存进 trace 目录并触发重传 —— trace 里补上"用户实际看到的
汇总页"(trace 步骤帧只有目标 App 画面, App 自渲染的汇总页天然在镜头外)。

壳同 `/trace/finalize`: 不鉴权、按 trace_id 一致性 hash 亲和。

配套分支(同名 7-3-ljh-testlab): pricebot-backend(端点+viewer 实拍块, PR #160) /
shaguabijia-app-android(结果页拍屏上传)。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #112
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-07-04 01:42:01 +08:00
liujiahui 13b143cd97 feat(invite): 发奖改「比价并下单」+ 好友 is_compared 状态 + DEV 造数/重置接口 (#113)
重开分支重提 PR #110 的邀请发奖改动,**不含任何测试文件**(`tests/` 3 个 + `scripts/invite_reward_e2e.*` 2 个 E2E 脚本本地自用,未入库)。

只含 8 个产品/部署文件:

- 发奖口径从「上报比价」改为「实际下单」:`order.py` / `compare_record.py` / `repositories/invite.py`
- 好友列表返回 `is_compared` 已比价状态:`schemas/invite.py` / `repositories/invite.py`
- 新增 DEV 内部接口(生产 `is_prod` 一律 404):`POST/DELETE /invite/seed-fake` 造/清虚拟好友;`POST /invite/dev-reset` 固定测试号打回出厂态
- fix(deploy):每日兑换定时器锁北京时区 `Asia/Shanghai`(`daily-exchange.service` / `.timer`)

对应 PR #110,源分支 `7-1ljh`。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #113
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-07-03 19:01:37 +08:00
zhuzihao 93f052535e 精简短信登录风控:保留单号冷却 + 单设备频控两道 (#109)
按 mentor 要求精简 SMS 登录链路的防刷策略,只保留两道主策略:
- 单号 60s 冷却(发码,SMS_SEND_INTERVAL_SEC)
- 单设备(device_id + IP)每小时 5 次频控(发码 + 登录,enforce_rate_limit)

删除两道:
- 单号每日发送上限(SMS_DAILY_LIMIT_PER_PHONE)
- 登录接口纯 IP 20 次/分钟限流(rate_limit 依赖)

验证码安全底线保留(一次性使用 + 输错 SMS_MAX_VERIFY_ATTEMPTS 次即作废),
属验证码正确性、不算限流策略,不在精简范围。

顺带清理:sms.py 移除 _daily_count/_today 及 datetime 导入;config.py 删常量;
auth.py 清理变为无用的 Depends/rate_limit 导入;同步 docs/integrations/sms.md 与
docs/后端技术实现.md 的防刷说明(并修正 sms.md 里已过时的 /sms/send rate_limit 描述)。

影响:单号发码上限由 10 条/天放宽为仅受 60s 冷却约束;登录撞库防护改由设备频控
+ 验证码错误次数上限兜底。test_auth.py 相应用例已删除/更新,auth 测试全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #109
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-03 15:28:32 +08:00
guke a563c1ca4b @ (#111)
docs/api目录文档分类和补全

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #111
2026-07-03 15:00:37 +08:00
128 changed files with 3381 additions and 399 deletions
@@ -0,0 +1,49 @@
"""comparison_record: 唯一键 (user_id,trace_id) → trace_id 单列 + user_id 可空
Revision ID: comparison_record_trace_unique
Revises: feedback_type_reply
Create Date: 2026-07-03 12:00:00.000000
比价记录改「后端 harvest」:app-server 在帧0(pricebot 出 trace_id)即建行,随 done/finalize
逐步补全,客户端不再 POST 记录。故本表要两处调整:
1. user_id 改**可空**——harvest 建行那刻(软鉴权 / 老客户端匿名)可能还没有 user_id。
2. 唯一键 (user_id, trace_id) → **trace_id 单列**——trace_id 由 app-server 签发、全局唯一,
一次比价一行;harvest 建行时 user_id 还没有,不能再用复合键去重。
⚠️ 上线前(QA)务必确认 comparison_record 无重复 trace_id:旧复合唯一键**允许**同 trace_id
跨不同 user_id(实际上客户端 UUID 从不重复,但约束没拦),若真有重复,建 trace 单列唯一会失败。
查: SELECT trace_id, COUNT(*) c FROM comparison_record GROUP BY trace_id HAVING c > 1;
SQLite 不支持直接 drop/alter 约束/列,用 batch_alter_table(建临时表+拷数据+换名),
与 coupon_engage_per_package / store_mapping_* 同款;Postgres 直接执行原生 ALTER。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'comparison_record_trace_unique'
down_revision: Union[str, Sequence[str], None] = 'feedback_type_reply'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
# harvest 帧0 建行时 user_id 可能暂缺 → 放开非空。
batch_op.alter_column('user_id', existing_type=sa.Integer(), nullable=True)
# 复合唯一 → trace_id 单列唯一。
batch_op.drop_constraint('uq_comparison_user_trace', type_='unique')
batch_op.create_unique_constraint('uq_comparison_trace', ['trace_id'])
def downgrade() -> None:
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
batch_op.drop_constraint('uq_comparison_trace', type_='unique')
batch_op.create_unique_constraint(
'uq_comparison_user_trace', ['user_id', 'trace_id']
)
# 回退非空前提是当时无 null user_id 行(harvest 期可能有孤儿行,回滚需先清理)。
batch_op.alter_column('user_id', existing_type=sa.Integer(), nullable=False)
+24
View File
@@ -54,5 +54,29 @@ def get_current_user(
return user
def get_current_user_optional(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
db: Annotated[Session, Depends(get_db)],
) -> User | None:
"""软鉴权:有合法 Bearer 就返回 user,否则(无 header / 无效 token / 用户禁用)一律返回
None,**不 raise**。
比价 step / finalize 灰度期用:新客户端带 JWT → 拿到 user_id 绑定 harvest 行;
老客户端(不带 JWT)→ None,harvest 行 user_id 暂空,由其后续 /compare/record 上报补齐。
等新版覆盖率够高,再把这几条端点从软鉴权收紧成硬 get_current_user。
"""
if credentials is None or credentials.scheme.lower() != "bearer":
return None
try:
payload = decode_token(credentials.credentials, expected_type="access")
user = db.get(User, int(payload["sub"]))
except (TokenError, KeyError, ValueError, TypeError):
return None
if user is None or user.status != "active":
return None
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
OptionalUser = Annotated[User | None, Depends(get_current_user_optional)]
DbSession = Annotated[Session, Depends(get_db)]
+6 -7
View File
@@ -12,11 +12,11 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, HTTPException, Request, status
from app.api.deps import CurrentUser, DbSession
from app.core import test_account
from app.core.ratelimit import enforce_rate_limit, rate_limit
from app.core.ratelimit import enforce_rate_limit
from app.core.security import TokenError, decode_token, issue_token_pair
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
from app.integrations.sms import SmsError, send_code, verify_code
@@ -41,7 +41,7 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
# 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。
SMS_LOGIN_MAX_PER_HOUR = 5
# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。
# 堵「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞 —— 那两道是单号维度,一机换号能绕开。
# 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5
@@ -100,8 +100,8 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码。
# 补「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞(那两道是单号维度,一机换号能绕);设备维度按机器封顶,
# 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。与路由上 IP 维度(10次/分钟)互补。
# 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,
# 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。
enforce_rate_limit(
request,
scope="sms-send-device",
@@ -125,7 +125,6 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
"/sms/login",
response_model=TokenWithUser,
summary="手机号+验证码登录",
dependencies=[Depends(rate_limit(20, 60, "sms-login"))], # 防撞库爆破(另有单码失败次数上限)
)
def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWithUser:
# 测试账号:免验证码登录 + 每日上限 + 每次都走新手引导(详见 app/core/test_account.py)。
@@ -143,7 +142,7 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
return _login_response(user, onboarding_completed=False, force_onboarding=True)
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试。放在验证码校验
# **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破。与路由上 IP 维度的 sms-login 限流(同 IP)互补
# **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破(另有单码失败 SMS_MAX_VERIFY_ATTEMPTS 次即作废兜底)
# ⚠️ 按设备而非手机号 → 一台机器换不同手机号刷登录也受限(防一机狂登多号);device_id 空(老客户端)时
# 退化为该 IP 下所有空设备聚一桶,仍受限。
enforce_rate_limit(
+203 -60
View File
@@ -1,49 +1,114 @@
"""外卖比价业务透传端点。
"""外卖比价业务透传端点 + 后端 harvest 落库
把客户端 POST /api/v1/intent/recognize 和 /api/v1/price/step 请求原样转发给
pricebot-backend 的 /api/intent/recognize 和 /api/price/step。MVP 阶段**不鉴权**
(同 coupon/step,见 docs/待办与技术债.md P1:device_id 透传区分设备,待补 JWT +
device_id↔user_id 绑定后才能做用户级画像)。
把客户端 POST /api/v1/intent/* 和 /api/v1/price/step 转发给 pricebot,同时**由 app-server
在透传里直接落库比价记录**(不再靠客户端 POST /compare/record):
- 帧0(客户端首帧不带 trace_id)→ app-server 用 uuid 签发 trace_id、注入转发 body、回给
客户端;并按 trace_id 建 running 行(harvest_running)。
- 最终 done 帧 → 更新成 success/failed + 结果(harvest_done)。发奖不在这里:#113 已把
邀请奖口径从「比价」移到「实际下单」(order.py)。
- /trace/finalize(用户终止/Phase1 未识别,无 done)→ 更新成 cancelled/failed
(harvest_abort,**不降级 success**)。
trace_url 从 pricebot 响应**顶层 trace_url** 取(pricebot 每帧都带,见其 goal_engine.process_step)。
- Phase 1 /intent/recognize:从源平台(淘宝闪购/美团/京东外卖)购物车页识别店名+菜品+
价格,一次性
- Phase 2 /price/step:多轮循环,在目标平台复现订单读到手价,直到 done。
鉴权:软鉴权(OptionalUser)——新客户端带 JWT → 绑 user_id;老客户端不带 → user_id 暂空,
由其后续 /compare/record 上报补(灰度期两条写路径按 trace_id reconcile,success 不被降级)
真正的目标驱动比价逻辑在 pricebot-backend(GoalEngine,另一个 repo),本接口是透传壳。
电商(ecom)那两个端点 food MVP 暂不需要,以后放开 scene 时再加 /ecom/intent/recognize
和 /ecom/step 两行即可。
pricebot 协议文档:
pricebot-backend/docs/main/02_api_protocol.md
真正的目标驱动比价逻辑在 pricebot-backend(GoalEngine,另一个 repo),本接口是透传壳 + 落库
pricebot 协议文档: pricebot-backend/docs/main/02_api_protocol.md
"""
from __future__ import annotations
import json
import logging
import time
import uuid
from typing import Any
import httpx
from fastapi import APIRouter, HTTPException, Request, status
from fastapi.concurrency import run_in_threadpool
from app.api.deps import OptionalUser
from app.core.config import settings
from app.core.logging import trace_id_ctx
from app.core.pricebot_client import get_pricebot_client
from app.core.pricebot_router import pick_pricebot
from app.db.session import SessionLocal
from app.repositories import comparison as crud_compare
logger = logging.getLogger("shagua.compare")
router = APIRouter(prefix="/api/v1", tags=["compare"])
async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
"""把请求体原样转发给 pricebot-backend 的 {upstream_path}
# ============================================================
# harvest 落库(阻塞 SQLAlchemy → run_in_threadpool,独立 SessionLocal,不阻塞事件循环;
# 任何写库异常都吞掉、绝不连累比价透传返回 —— 同 coupon.py 现有 best-effort 写法)。
# ============================================================
跟 coupon_step 同款透传壳:不鉴权、不做 schema 校验,仅读 device_id/trace_id/step
打日志。比价单帧是大上下文 / 逐帧 LLM,超时用 PRICEBOT_COMPARE_TIMEOUT_SEC(60s,
比领券的 30s 长)。
def _harvest_running_blocking(
trace_id: str, user_id: int | None, business_type: str,
device_id: str | None, device_info: dict | None, trace_url: str | None,
) -> None:
with SessionLocal() as db:
crud_compare.harvest_running(
db, trace_id=trace_id, user_id=user_id, business_type=business_type,
device_id=device_id, device_info=device_info, trace_url=trace_url,
)
logger.info(
"harvest running row (user=%s)", user_id,
extra={"phase": "harvest_running", "status": "running", "user_id": user_id},
)
def _harvest_done_blocking(
trace_id: str, user_id: int | None, done_params: dict, business_type: str,
device_id: str | None, device_info: dict | None, trace_url: str | None,
) -> None:
with SessionLocal() as db:
rec, newly_success = crud_compare.harvest_done(
db, trace_id=trace_id, user_id=user_id, done_params=done_params,
business_type=business_type, device_id=device_id,
device_info=device_info, trace_url=trace_url,
)
logger.info(
"harvest done → %s saved=%s newly=%s", rec.status,
rec.saved_amount_cents, newly_success,
extra={"phase": "harvest_done", "status": rec.status,
"saved_cents": rec.saved_amount_cents,
"best_platform": rec.best_platform_id, "newly_success": newly_success,
"user_id": user_id},
)
# 不在此处发邀请奖:#113 已把发奖口径从「比价」移到「实际下单」(order.py),harvest
# 只记录比价、不发奖。否则比价先于下单 + try_reward 幂等闸会让奖落在「比价」这步,
# 架空 #113 的「下单才发奖」防刷意图(newly_success 仅留作日志观测)。
def _harvest_abort_blocking(
trace_id: str, status_hint: str, reason: str | None, trace_url: str | None,
) -> None:
with SessionLocal() as db:
rec = crud_compare.harvest_abort(
db, trace_id=trace_id, status=status_hint, reason=reason, trace_url=trace_url,
)
logger.info(
"harvest abort → %s", (rec.status if rec else "no-row"),
extra={"phase": "harvest_abort",
"status": (rec.status if rec else None), "reason": reason},
)
async def _forward(
request: Request, upstream_path: str, user, *, harvest_first_frame: bool = True,
) -> tuple[dict[str, Any], str, dict[str, Any]]:
"""透传给 pricebot 的 {upstream_path},并 mint trace_id + 首帧建 running 行。
返回 (resp_json, trace_id, meta)。
- 客户端首帧不带 trace_id → app-server 用 uuid 签发、写进转发 body、回填进响应顶层
`trace_id`(客户端据此拿到后续帧都带上)。带了(老客户端 / 后续帧)→ 原样用、走原始 bytes 快路。
- 仅**本次 mint(=首帧)**建 running 行(idempotent);后续帧不再写库。
"""
# 读原始字节,避免"反序列化→再序列化"的双重 JSON(省 ~一半透传 CPU,让 app-server
# 单 worker 也扛得住高并发)。只 json.loads 一次拿 trace_id 做亲和 + 打日志,转发时
# 直接发原始 bytes(content=raw),不重新 dumps。
raw = await request.body()
try:
meta = json.loads(raw)
@@ -52,26 +117,41 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
if not isinstance(meta, dict):
meta = {}
# 按 trace_id 一致性 hash 选 pricebot 实例(同一比价的所有帧落同一进程,内存维护 state)
base = pick_pricebot(meta.get("trace_id"))
trace_id = meta.get("trace_id")
minted = False
if not trace_id:
trace_id = str(uuid.uuid4())
meta["trace_id"] = trace_id
raw = json.dumps(meta).encode() # 仅首帧重新序列化(注入 trace_id);后续帧走原始 bytes
minted = True
# 请求级 trace_id 贯穿:此后本请求(含 run_in_threadpool 里的 harvest)每行日志自动带 trace_id
trace_id_ctx.set(trace_id)
base = pick_pricebot(trace_id)
url = f"{base.rstrip('/')}{upstream_path}"
timeout = settings.PRICEBOT_COMPARE_TIMEOUT_SEC
step = meta.get("step")
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"),
"→ pricebot %s step=%s%s", upstream_path, step, " [mint]" if minted else "",
extra={"phase": "forward", "endpoint": upstream_path, "step": step,
"device_id": meta.get("device_id"), "minted": minted,
"query": meta.get("query"), "user_id": (user.id if user else None)},
)
t0 = time.monotonic()
try:
client = get_pricebot_client()
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout
)
except httpx.RequestError as e:
logger.error("[pricebot] request failed: %s", e)
logger.error(
"← pricebot %s 不可达: %s", upstream_path, e,
extra={"phase": "resp", "endpoint": upstream_path, "step": step,
"error": "upstream_unreachable"},
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"pricebot upstream unreachable: {e}",
@@ -79,49 +159,112 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
if resp.status_code >= 500:
logger.error(
"[pricebot] 5xx status=%d body=%s",
resp.status_code,
resp.text[:500],
"pricebot %s 5xx=%d", upstream_path, resp.status_code,
extra={"phase": "resp", "endpoint": upstream_path, "step": step,
"http_status": resp.status_code, "error": "upstream_5xx"},
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"pricebot upstream returned {resp.status_code}",
)
return resp.json()
resp_json = resp.json()
cost_ms = int((time.monotonic() - t0) * 1000)
if not isinstance(resp_json, dict):
return {}, trace_id, meta
# 回传 app-server 签发的 trace_id(新客户端首帧据此拿到,后续帧都带上它)
resp_json.setdefault("trace_id", trace_id)
_action = resp_json.get("action") or {}
logger.info(
"← pricebot %s cmd=%s cont=%s %dms", upstream_path,
_action.get("command"), resp_json.get("continue"), cost_ms,
extra={"phase": "resp", "endpoint": upstream_path, "step": step,
"command": _action.get("command"), "continue": resp_json.get("continue"),
"cost_ms": cost_ms},
)
# 首帧建 running 行(仅本次 mint;老客户端自带 trace_id → 不 mint → 由 done / 其 POST 建行)
if minted and harvest_first_frame:
try:
await run_in_threadpool(
_harvest_running_blocking, trace_id,
(user.id if user else None), "food",
meta.get("device_id"), meta.get("device_info"),
resp_json.get("trace_url"),
)
except Exception as e: # noqa: BLE001
logger.warning("harvest_running failed trace=%s: %s", trace_id, e)
return resp_json, trace_id, meta
@router.post("/intent/recognize", summary="外卖比价 Phase 1 意图识别 (透传到 pricebot)")
async def intent_recognize(request: Request) -> dict[str, Any]:
return await _passthrough(request, "/api/intent/recognize")
@router.post("/intent/recognize", summary="外卖比价 Phase 1 意图识别 (透传 + 建 running 行)")
async def intent_recognize(request: Request, user: OptionalUser) -> dict[str, Any]:
resp, _, _ = await _forward(request, "/api/intent/recognize", user)
return resp
@router.post("/intent/step", summary="外卖比价 Phase 1 多帧意图识别 (透传到 pricebot, 仅淘宝源)")
async def intent_step(request: Request) -> dict[str, Any]:
# 多帧版意图识别(展开+滚动采集→提取): 循环调用直到 done(done 帧顶层带
# result+calibration)。目前仅淘宝源走这条, 其它源走上面单次 /intent/recognize。
return await _passthrough(request, "/api/intent/step")
@router.post("/intent/step", summary="外卖比价 Phase 1 多帧意图识别 (透传, 仅淘宝源)")
async def intent_step(request: Request, user: OptionalUser) -> dict[str, Any]:
resp, _, _ = await _forward(request, "/api/intent/step", user)
return resp
@router.post(
"/intent/precoupon/step",
summary="外卖比价 Phase 0 意图识别前先用券 (透传到 pricebot, 仅美团源)",
)
async def intent_precoupon_step(request: Request) -> dict[str, Any]:
# 美团源平台『意图识别前先用券』多帧循环: 客户端在调 /intent/recognize 之前先循环
# 调本端点到 done(continue=false)。订单页底部有『点击使用X红包』就自动选最大免费券
# 用上, 已用券/无券则首帧秒过。与 /intent/step 同属 intent 域, 复用同一透传壳。
return await _passthrough(request, "/api/intent/precoupon/step")
@router.post("/intent/precoupon/step", summary="外卖比价 Phase 0 识别前先用券 (透传, 仅美团源)")
async def intent_precoupon_step(request: Request, user: OptionalUser) -> dict[str, Any]:
resp, _, _ = await _forward(request, "/api/intent/precoupon/step", user)
return resp
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传到 pricebot)")
async def price_step(request: Request) -> dict[str, Any]:
return await _passthrough(request, "/api/price/step")
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传 + done 落库)")
async def price_step(request: Request, user: OptionalUser) -> dict[str, Any]:
resp, trace_id, meta = await _forward(request, "/api/price/step", user)
# 最终 done 帧(command=done 且 continue=false)→ harvest 更新成终态。
# (单平台中途 done 被 pricebot 改写成 wait+continue=true,不会命中这里,同 coupon 语义。)
action = resp.get("action") or {}
if action.get("command") == "done" and not resp.get("continue", True):
done_params = action.get("params") or {}
try:
await run_in_threadpool(
_harvest_done_blocking, trace_id, (user.id if user else None),
done_params, "food",
meta.get("device_id"), meta.get("device_info"),
resp.get("trace_url") or done_params.get("trace_url"),
)
except Exception as e: # noqa: BLE001
logger.warning("harvest_done failed trace=%s: %s", trace_id, e)
return resp
@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传到 pricebot, 终止/未识别拿 trace_url)")
async def trace_finalize(request: Request) -> dict[str, Any]:
# 用户终止 / Phase1 未识别没走到 done 帧, pricebot 没上云也没回传 trace_url。客户端收尾时
# 打这个, _passthrough 按 trace_id 一致性 hash 落到处理这条 trace 的同一 pricebot 进程
# (dir_cache 在那, 才能算对 trace 目录), 由后者打包上云返回 {trace_url}。
return await _passthrough(request, "/api/trace/finalize")
@router.post("/trace/epilogue", summary="比价结果页尾声帧 (透传到 pricebot, 用户视角截图入 trace)")
async def trace_epilogue(request: Request, user: OptionalUser) -> dict[str, Any]:
# App 收到 done、渲染完结果页后,把自己页面的截图(base64)传给 pricebot 存进 trace
# 目录并触发重传 —— trace 里补上"用户实际看到的汇总页"(步骤帧只有目标 App 画面)。
# body ~几百 KB(截图 base64), 纯透传壳(harvest_first_frame=False:不建行/不落库,
# 该 trace 的记录已由 done/finalize 落终态)。#112 原走 _passthrough,合并到 harvest
# 分支后统一走 _forward(_passthrough 已并入它)。
resp, _, _ = await _forward(
request, "/api/trace/epilogue", user, harvest_first_frame=False,
)
return resp
@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传 + 夭折落库)")
async def trace_finalize(request: Request, user: OptionalUser) -> dict[str, Any]:
# 用户终止 / Phase1 未识别没到 done 帧: pricebot 打包半截上云返回 {trace_url};
# app-server 顺手把该 trace 的 running 行更新成夭折终态(**不降级 success**)。
# 客户端 finalize body 带 status(cancelled/failed)+ reason;老客户端只带 trace_id →
# 默认 cancelled,其后续 /compare/record 上报再补精确态。
resp, trace_id, meta = await _forward(
request, "/api/trace/finalize", user, harvest_first_frame=False,
)
try:
await run_in_threadpool(
_harvest_abort_blocking, trace_id,
(meta.get("status") or "cancelled"),
(meta.get("reason") or meta.get("information")),
(resp.get("trace_url") if isinstance(resp, dict) else None),
)
except Exception as e: # noqa: BLE001
logger.warning("harvest_abort failed trace=%s: %s", trace_id, e)
return resp
+9 -22
View File
@@ -1,15 +1,13 @@
"""比价记录 endpoint(「我的比价记录」数据源)。
"""比价记录 endpoint(「我的比价记录」+ admin 数据源)。
路由前缀 `/api/v1/compare`:
POST /record 上报一次比价结果(幂等:同 user+trace_id 覆盖)
POST /record (灰度期兼容)老客户端上报,按 **trace_id** 幂等 + 不降级 success
GET /records 比价记录列表(游标分页)
GET /records/{id} 单条详情(含 raw_payload 全量)
**均需鉴权**(CurrentUser)——与同文件无关的不鉴权透传 `compare.py` 分开:那个是
转发壳(MVP 不鉴权),这里是按用户维度落库的业务接口,必须有 user_id。
注:本轮只做 server 端,客户端(android 仓)在 done 帧后调 POST /record 上报的改动
另起一轮(见 app-server docs/待办与技术债.md P1)。
**均需鉴权**(CurrentUser)。⚠️ 写路径现以 `compare.py` 透传壳的**后端 harvest** 为主
(帧0 建 running 行 → done/finalize 落终态,新客户端不再 POST);本 POST /record 仅灰度期
给老客户端用,与 harvest 按 trace_id reconcile。新版覆盖够高后可下线本 POST(阶段3)。
"""
from __future__ import annotations
@@ -21,16 +19,15 @@ from app.api.deps import CurrentUser, DbSession
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
from app.repositories import comparison as crud_compare
from app.repositories import invite as crud_invite
from app.services.pricebot_llm_calls import fetch_llm_calls
from app.schemas.compare_record import (
CompareStatsOut,
ComparisonRecordCreatedOut,
ComparisonRecordDetailOut,
ComparisonRecordIn,
ComparisonRecordPage,
ComparisonRecordOut,
ComparisonRecordPage,
)
from app.services.pricebot_llm_calls import fetch_llm_calls
logger = logging.getLogger("shagua.compare_record")
@@ -53,18 +50,8 @@ def report_record(
# 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用
# 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。
background_tasks.add_task(_backfill_llm_calls, rec.id, rec.trace_id)
# 邀请 v2 发奖:被邀请人完成一次【成功】比价 → 给邀请人发邀请奖励金(幂等,只发一次)。
# best-effort:发奖异常不影响比价上报本身(rec 已 commit),只 log;邀请人补偿靠后续对账
if rec.status == "success":
try:
reward = crud_invite.try_reward_on_compare(db, user.id)
if reward.status == "granted":
logger.info(
"invite compare reward granted inviter=%s invitee=%s cents=%s",
reward.inviter_user_id, user.id, reward.reward_cents,
)
except Exception as e: # noqa: BLE001 best-effort,发奖失败不阻塞上报
logger.warning("invite compare reward failed invitee=%s: %s", user.id, e)
# 注:邀请发奖已从"比价成功"挪到"实际下单"(见 api/v1/order.py report_order)——
# 冰拍板:被邀请人完成比价并实际下单才算邀请成功,仅完成比价不再发奖
logger.info(
"compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)",
user.id,
+19
View File
@@ -1,9 +1,14 @@
import logging
from fastapi import APIRouter
from app.api.deps import CurrentUser, DbSession
from app.repositories import invite as crud_invite
from app.repositories import savings as crud_savings
from app.schemas.order import OrderReportOut, OrderReportRequest
logger = logging.getLogger("shagua.order")
router = APIRouter(prefix="/api/v1/order", tags=["order"])
@@ -15,6 +20,20 @@ router = APIRouter(prefix="/api/v1/order", tags=["order"])
def report_order(req: OrderReportRequest, user: CurrentUser, db: DbSession) -> OrderReportOut:
# 记账唯一真相表是 savings_record(source='compare')。
rec, duplicated = crud_savings.create_from_report(db, user.id, req)
# 邀请 v2 发奖:被邀请人【实际下单】(而非仅完成比价)才给邀请人发邀请奖励金(幂等,只发一次)。
# 触发点从"比价成功上报"挪到这里(冰:完成比价并实际下单才算成功)。仅首次真实上报触发,
# 重复上报(duplicated)跳过。best-effort:发奖异常不影响订单上报本身(rec 已 commit),只 log;
# try_reward_on_compare 自带 compare_reward_granted 幂等闸,漏发靠后续对账补。
if not duplicated:
try:
reward = crud_invite.try_reward_on_compare(db, user.id)
if reward.status == "granted":
logger.info(
"invite order reward granted inviter=%s invitee=%s cents=%s",
reward.inviter_user_id, user.id, reward.reward_cents,
)
except Exception as e: # noqa: BLE001 best-effort,发奖失败不阻塞订单上报
logger.warning("invite order reward failed invitee=%s: %s", user.id, e)
return OrderReportOut(
id=rec.id,
platform=rec.platform or req.platform,
+12
View File
@@ -69,6 +69,18 @@ def complete_onboarding(
return OkResponse()
@router.post("/onboarding/reset", response_model=OkResponse, summary="重置新手引导(删该 设备+账号 完成标记,下次登录重走)")
def reset_onboarding(
req: OnboardingCompleteRequest, user: CurrentUser, db: DbSession
) -> OkResponse:
"""删该 (当前账号, device_id) 的引导完成标记 → 下次登录 onboarding_completed=false,客户端重走。
与 /onboarding/complete 互逆。device_id 取客户端硬件级 ANDROID_ID(与登录/complete 一致)。
幂等:无记录也返回 ok。仅删自己(当前 JWT 用户)这台设备的记录,不影响别的账号/设备。"""
deleted = onboarding_repo.delete_completion(db, user_id=user.id, device_id=req.device_id)
logger.info("onboarding reset user_id=%d device_len=%d deleted=%d", user.id, len(req.device_id), deleted)
return OkResponse()
@router.get(
"/onboarding/status",
response_model=OnboardingStatusResponse,
-1
View File
@@ -81,7 +81,6 @@ class Settings(BaseSettings):
SMS_SIGN_ID: int = 31729 # 极光短信签名 ID(非机密,可被 .env 覆盖)
SMS_TEMPLATE_ID: int = 1 # 极光短信模板 ID(变量名 code,有效期 5 分钟)
SMS_CODE_LENGTH: int = 6 # 验证码位数(本服务生成;前端 code 字段 4-8 位兼容)
SMS_DAILY_LIMIT_PER_PHONE: int = 10 # 单手机号每日发送上限(防刷 + 控费)
SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破)
# ===== 测试账号(release 包全流程联调用)=====
+57 -8
View File
@@ -2,9 +2,15 @@
业务代码用 `logger = logging.getLogger("shagua.xxx")` 即可, 本模块在 main.py 启动时调一次。
- 控制台(stdout): 人类可读文本, 给 systemd / 本地看。
- 文件 `logs/app-server.log`: 单行 JSON, 供阿里云 SLS/Logtail 采集(JSON 模式零正则);
异常栈为字段内嵌不换行 → 每条日志一行。
- 控制台(stdout): 人类可读文本, 给 systemd / 本地看; 有 trace 时行尾附 `trace=xxx`
- 文件 `logs/app-server.log`: 单行 JSON, 供**阿里云 SLS / Logtail** 采集(JSON 模式零正则);
异常栈内嵌为字段不换行 → 每条日志一行。
- **结构化字段(SLS 可直接查/聚合)**:
- `trace_id`: 请求级贯穿——在入口 `trace_id_ctx.set(...)` 后, 本请求内**每一行日志**(含
run_in_threadpool 里的 harvest, contextvars 自动拷进线程)都自动带上, 无需手写。
SLS 里 `trace_id: "xxx"` 一查即得整条比价链路, 按 time 升序即请求顺序。
- 任意 `logger.info(msg, extra={"phase": ..., "step": ..., "command": ...})` 的 extra
键都会平铺进 JSON 顶层 → SLS 可按 phase/step/command/cost_ms 等过滤聚合。
- 环境变量:
- LOG_JSON_CONSOLE=1 控制台也输出 JSON
- LOG_DIR / LOG_FILE 改落盘路径(默认 logs/app-server.log)
@@ -17,13 +23,36 @@ import json
import logging
import os
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="")
# 标准 LogRecord 属性 + 格式化期附加项:凡不在此集合的 record 属性都视为业务 extra, 平铺进 JSON。
_RESERVED = set(
logging.LogRecord("", 0, "", 0, "", (), None).__dict__
) | {"message", "asctime", "trace_id", "taskName"}
class _ContextFilter(logging.Filter):
"""把 trace_id_ctx 注入每条 record(供两个 formatter 取用)。挂在 handler 上,
命中每条(含 propagate 上来的)记录, 在 format 之前置好 record.trace_id。"""
def filter(self, record: logging.LogRecord) -> bool:
if not hasattr(record, "trace_id"):
record.trace_id = trace_id_ctx.get()
return True
class JsonFormatter(logging.Formatter):
"""LogRecord 序列化成单行 JSON(SLS/Logtail 友好)。异常栈内嵌为字段, 整条仍是一行"""
"""LogRecord 单行 JSON(SLS/Logtail 友好)。trace_id 提到顶层、extra 键平铺, 异常栈内嵌。"""
def __init__(self, service: str = "app-server"):
super().__init__()
self.service = service
@@ -35,10 +64,17 @@ class JsonFormatter(logging.Formatter):
"level": record.levelname,
"service": self.service,
"logger": record.name,
"func": record.funcName,
"line": record.lineno,
"message": record.getMessage(),
}
tid = getattr(record, "trace_id", "") or trace_id_ctx.get()
if tid:
data["trace_id"] = tid
# 业务 extra 字段(phase / step / endpoint / command / cost_ms / status ...)平铺进顶层
for k, v in record.__dict__.items():
if k not in _RESERVED and not k.startswith("_"):
data[k] = v
data["func"] = record.funcName
data["line"] = record.lineno
data["message"] = record.getMessage()
if record.exc_info:
data["exception"] = self.formatException(record.exc_info)
if record.stack_info:
@@ -46,6 +82,15 @@ class JsonFormatter(logging.Formatter):
return json.dumps(data, ensure_ascii=False, default=str)
class TextFormatter(logging.Formatter):
"""控制台文本:标准行 + 有 trace_id 时行尾附 `trace=xxx`(无 trace 的启动/后台日志不加噪)。"""
def format(self, record: logging.LogRecord) -> str:
base = super().format(record)
tid = getattr(record, "trace_id", "") or trace_id_ctx.get()
return f"{base} trace={tid}" if tid else base
_CONFIGURED = False
@@ -63,14 +108,17 @@ def setup_logging(debug: bool = False) -> None:
for h in list(root.handlers):
root.removeHandler(h)
ctx_filter = _ContextFilter()
# 控制台: 默认文本(systemd/本地看); LOG_JSON_CONSOLE=1 时输出 JSON
console = logging.StreamHandler(sys.stdout)
if os.getenv("LOG_JSON_CONSOLE") == "1":
console.setFormatter(JsonFormatter(service))
else:
console.setFormatter(
logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
TextFormatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
)
console.addFilter(ctx_filter)
root.addHandler(console)
# 文件: 单行 JSON, 供 Logtail 采集(自动轮转, 单文件 10MB, 保留 5 个)
@@ -82,6 +130,7 @@ def setup_logging(debug: bool = False) -> None:
log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8",
)
file_handler.setFormatter(JsonFormatter(service))
file_handler.addFilter(ctx_filter)
root.addHandler(file_handler)
# 第三方库降噪
+8 -25
View File
@@ -8,15 +8,16 @@
校验)→ 鉴权复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`(同一极光应用)。
验证码存储:**进程内存**(单 worker uvicorn 够用)。重启丢失(用户重发即可)。多
worker / 多机时内存不共享 → 冷却、每日上限、校验都会失效,届时迁移到 DB/Redis。
worker / 多机时内存不共享 → 冷却、校验都会失效,届时迁移到 DB/Redis。
见 docs/待办与技术债.md。
防刷层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
防刷层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)
2. 单号每日 `SMS_DAILY_LIMIT_PER_PHONE` 条上限(本文件)
3. 单设备(device_id)每小时频控(api 层 auth.sms_send 内 enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)。
2. 单设备(device_id)每小时频控(api 层 auth.sms_send 内 enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)。
⚠️ 原「单 IP 频控(rate_limit 依赖)」2026-06-26 按产品要求删除、改设备维度;但 device_id 客户端可伪造/轮换,
脚本轮换 id 能绕过本层 → 挡脚本狂发主要靠极光控制台侧(+ 可选 nginx 限流)。
⚠️ 原「单号每日上限」2026-07-03 按精简要求删除(mentor 定:登录风控只留单号冷却 + 单设备频控);
单号维度现仅剩 60s 冷却,「换号轰炸」由单设备频控封顶。
另:单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废(防爆破),验过即作废(一次性)。
"""
from __future__ import annotations
@@ -26,7 +27,6 @@ import logging
import secrets
import time
from dataclasses import dataclass
from datetime import datetime
from threading import Lock
import httpx
@@ -56,22 +56,17 @@ class _CodeRecord:
# 进程内存(单 worker 有效;多 worker 不共享,见模块 docstring)
_codes: dict[str, _CodeRecord] = {} # phone -> 当前有效验证码
_last_sent: dict[str, float] = {} # phone -> 上次发送 epoch(冷却)
_daily_count: dict[str, tuple[str, int]] = {} # phone -> (date_str, 当日发送数)
_lock = Lock()
_GC_THRESHOLD = 10000 # 任一内存 dict 超此阈值,send 时顺手清过期项(防无限增长,仿 ratelimit)
def _today() -> str:
return datetime.now().strftime("%Y-%m-%d")
def _gen_code() -> str:
"""生成 N 位数字验证码(用 secrets 而非 random;允许前导 0)。"""
return "".join(secrets.choice("0123456789") for _ in range(settings.SMS_CODE_LENGTH))
def _gc(now: float) -> None:
"""顺手清理过期内存项,防个 dict 无限增长。仅在持锁时调用,且某 dict 超
"""顺手清理过期内存项,防个 dict 无限增长。仅在持锁时调用,且某 dict 超
_GC_THRESHOLD 才扫它(低频,开销可忽略)。"""
if len(_codes) > _GC_THRESHOLD:
for p in [p for p, r in _codes.items() if now > r.expires_at]:
@@ -80,10 +75,6 @@ def _gc(now: float) -> None:
cutoff = now - settings.SMS_SEND_INTERVAL_SEC
for p in [p for p, ts in _last_sent.items() if ts < cutoff]:
_last_sent.pop(p, None)
if len(_daily_count) > _GC_THRESHOLD:
today = _today()
for p in [p for p, (d, _c) in _daily_count.items() if d != today]:
_daily_count.pop(p, None)
def send_code(phone: str) -> int:
@@ -102,17 +93,9 @@ def send_code(phone: str) -> int:
remain = int(settings.SMS_SEND_INTERVAL_SEC - elapsed)
raise SmsError(f"发送过于频繁,请 {remain}s 后再试")
today = _today()
day, cnt = _daily_count.get(phone, ("", 0))
if day != today:
cnt = 0
if cnt >= settings.SMS_DAILY_LIMIT_PER_PHONE:
raise SmsError("今日验证码发送次数已达上限,请明天再试")
code = _gen_code()
# 预占:先记冷却/计数/存码,释放锁后再发网络(发失败保留冷却+计数,见下)
# 预占:先记冷却/存码,释放锁后再发网络(发失败保留冷却,见下)
_last_sent[phone] = now
_daily_count[phone] = (today, cnt + 1)
_codes[phone] = _CodeRecord(code=code, expires_at=now + settings.SMS_CODE_TTL_SEC)
# --- lock 外:真正发送(网络 IO 不持锁)---
@@ -123,7 +106,7 @@ def send_code(phone: str) -> int:
_send_via_jiguang(phone, code)
logger.info("[SMS] sent to %s****", phone[:3])
except Exception as e:
# 发送失败:**保留冷却 + 每日计数**(失败也限速,挡住余额不足/签名失效时
# 发送失败:**保留冷却**(失败也限速,挡住余额不足/签名失效时
# 前端重试狂打极光),只清掉没发出去的码(用户收不到,留着无意义且占内存)。
with _lock:
_codes.pop(phone, None)
+7 -4
View File
@@ -39,16 +39,19 @@ _JSON = JSON().with_variant(JSONB(), "postgresql")
class ComparisonRecord(Base):
__tablename__ = "comparison_record"
__table_args__ = (
# 同一用户同一次比价(trace_id)只存一条:客户端重试/误点重复上报时幂等覆盖
UniqueConstraint("user_id", "trace_id", name="uq_comparison_user_trace"),
# trace_id 由 app-server 签发、全局唯一 → 一次比价一行,后端 harvest 按它 upsert
# (原 (user_id,trace_id) 复合唯一改为 trace_id 单列:harvest 帧0 建行时 user_id 可能暂缺。)
UniqueConstraint("trace_id", name="uq_comparison_trace"),
# 首页轮播 / 省钱战绩聚合都按 status='success' 过滤 + created_at 近期排序;
# 复合索引避免随数据量增大退化成全表扫(单列 created_at 索引不含 status)。
Index("ix_comparison_status_created", "status", "created_at"),
)
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
# 后端 harvest 在帧0(pricebot 出 trace_id)即建行,软鉴权下 user_id 可能暂缺(老客户端/匿名)→ 可空。
# C 端「我的比价记录」按 user_id 过滤天然排除 null-user 行;admin 全看(含孤儿行)。
user_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=True
)
# 仍记录设备号(同一用户多设备的行为区分 / 与不鉴权期 device_id 数据对账)
device_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
+217 -2
View File
@@ -74,7 +74,12 @@ def _derive(payload: ComparisonRecordIn) -> dict:
def upsert_record(
db: Session, *, user_id: int, payload: ComparisonRecordIn
) -> ComparisonRecord:
"""(user_id, trace_id) 幂等写入:已存在则覆盖(更完整的重试上报胜出),否则新建。"""
"""**trace_id** 幂等写入(唯一键已从 user_id+trace_id 改为 trace_id):已存在则合并
(回填 null user_id + 覆盖字段,但**不降级 success**),否则新建。
灰度期老客户端 POST /compare/record 走这条,与后端 harvest 按 trace_id reconcile;
新客户端不再 POST(改由 compare.py 透传壳 harvest 落库)。
"""
derived = _derive(payload)
fields = dict(
device_id=payload.device_id,
@@ -110,14 +115,29 @@ def upsert_record(
**derived,
)
# 按 trace_id 定位(唯一键已改 trace_id):后端 harvest 可能已建行,这里的客户端上报
# (灰度期老客户端 / 新客户端不再走这条)与之 reconcile。
existing = db.execute(
select(ComparisonRecord).where(
ComparisonRecord.user_id == user_id,
ComparisonRecord.trace_id == payload.trace_id,
)
).scalar_one_or_none()
if existing is not None:
# 不降级:harvest 或更早上报已落成 success,收尾期 fromFailure 的 cancelled/failed
# 不许把它盖回去(老 comparisonReported bug 的服务端兜底)——只补 user_id / trace_url。
if existing.status == "success" and fields.get("status") != "success":
if existing.user_id is None and user_id is not None:
existing.user_id = user_id
if fields.get("trace_url"):
existing.trace_url = fields["trace_url"]
db.commit()
db.refresh(existing)
return existing
# 只**填**空缺 user_id、不 reassign:trace_id 全局唯一,一条 trace 只属一个用户;
# 绝不把已有归属的记录改判给另一个上报者(仅 harvest 建的 null-user 行在此绑上)。
if existing.user_id is None:
existing.user_id = user_id
for k, v in fields.items():
setattr(existing, k, v)
db.commit()
@@ -139,6 +159,201 @@ def upsert_record(
return rec
# ============================================================
# 后端 harvestapp-server 从 pricebot 透传响应里直接落库(不靠客户端上报)。
# 帧0 建行(running) → 最终 done 更新(success/failed) → finalize 更新(aborted)。
# 全按 trace_id upsertsuccess 行永不被后到的 failed/aborted 降级。
# ============================================================
def _derive_from_results(results: list[dict]) -> dict:
"""从 done 帧 comparison_results(pricebot 原始 dict 列表)派生结构化列。
等价 _derive,但吃原始字段(is_source/price/rank/platform_id/store_name...)而非 pydantic 对象。"""
priced = [r for r in results if r.get("price") is not None]
best = None
if priced:
best = min(
priced,
key=lambda r: (r.get("rank") if r.get("rank") is not None else 10**9, r["price"]),
)
src_row = next((r for r in results if r.get("is_source")), None)
source_price_cents = None
if src_row is not None and src_row.get("price") is not None:
source_price_cents = _yuan_to_cents(src_row["price"])
best_price_cents = _yuan_to_cents(best["price"]) if best else None
saved_amount_cents = None
if source_price_cents is not None and best_price_cents is not None:
saved_amount_cents = source_price_cents - best_price_cents
has_valid_target = any(
(not r.get("is_source")) and r.get("price") is not None for r in results
)
return {
"source_platform_id": (src_row or {}).get("platform_id"),
"source_platform_name": (src_row or {}).get("platform_name"),
"source_package": (src_row or {}).get("package"),
"source_price_cents": source_price_cents,
"best_platform_id": best.get("platform_id") if best else None,
"best_platform_name": best.get("platform_name") if best else None,
"best_price_cents": best_price_cents,
"saved_amount_cents": saved_amount_cents,
"is_source_best": best.get("is_source") if best else None,
"store_name": (src_row or {}).get("store_name") or None,
"status": "success" if has_valid_target else "failed",
}
def _device_cols_from_info(device_info: dict | None) -> dict:
"""step 帧 device_info({locale,brand,model,android_version,rom_version})→ 表列。
step 帧只带这几项;rom_name / android_sdk / app_version / 耗时步数 harvest 拿不到 → 留 None
(灰度期老客户端 fromComparison 会补齐;要新客户端也全带需扩 collectDeviceInfo,后续)。"""
d = device_info or {}
rv = str(d.get("rom_version") or "")
return {
"device_model": d.get("model") or None,
"device_manufacturer": d.get("brand") or None,
"android_version": d.get("android_version") or None,
"rom_version": int(rv) if rv.isdigit() else None,
}
def _get_by_trace(db: Session, trace_id: str) -> ComparisonRecord | None:
return db.execute(
select(ComparisonRecord).where(ComparisonRecord.trace_id == trace_id)
).scalar_one_or_none()
def harvest_running(
db: Session,
*,
trace_id: str,
user_id: int | None,
business_type: str = "food",
device_id: str | None = None,
device_info: dict | None = None,
trace_url: str | None = None,
) -> ComparisonRecord:
"""帧0(或任一尚未建行的帧)建/补 running 行。幂等:已存在只补空缺(user_id/trace_url/
device_id/机型),绝不动已落定的 status / 结果。"""
rec = _get_by_trace(db, trace_id)
if rec is None:
rec = ComparisonRecord(
trace_id=trace_id,
user_id=user_id,
business_type=business_type or "food",
device_id=device_id,
status="running",
trace_url=trace_url,
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
**_device_cols_from_info(device_info),
)
db.add(rec)
db.commit()
db.refresh(rec)
return rec
changed = False
if rec.user_id is None and user_id is not None:
rec.user_id = user_id
changed = True
if not rec.trace_url and trace_url:
rec.trace_url = trace_url
changed = True
if rec.device_id is None and device_id:
rec.device_id = device_id
changed = True
for k, v in _device_cols_from_info(device_info).items():
if getattr(rec, k) is None and v is not None:
setattr(rec, k, v)
changed = True
if changed:
db.commit()
db.refresh(rec)
return rec
def harvest_done(
db: Session,
*,
trace_id: str,
user_id: int | None,
done_params: dict,
business_type: str = "food",
device_id: str | None = None,
device_info: dict | None = None,
trace_url: str | None = None,
) -> tuple[ComparisonRecord, bool]:
"""最终 done 帧:running 行 → 终态(success/failed)+结果+trace_url。
返回 (记录, 是否本次**新**落成 success)——供调用方据此幂等发一次邀请奖。
行不存在(理论上帧0已建;防御)则新建。"""
results = done_params.get("comparison_results") or []
derived = _derive_from_results(results)
fields = dict(
business_type=business_type or "food",
information=done_params.get("information") or None,
# best_deeplink 来自客户端剪贴板采集,harvest 拿不到 → 留空(灰度期 fromComparison 会补;
# 纯 harvest 行「再次比价」退化为按 package 拉起 App。要精确深链需客户端另传,后续)。
trace_url=trace_url or done_params.get("trace_url"),
total_dish_count=done_params.get("total_dish_count"),
skipped_dish_count=done_params.get("skipped_dish_count"),
skipped_dish_names=list(done_params.get("skipped_dish_names") or []),
comparison_results=results,
# 菜品:pricebot 已把源单菜品塞进 comparison_results[源行].items
items=next((r.get("items") or [] for r in results if r.get("is_source")), []),
raw_payload=done_params,
**derived,
**_device_cols_from_info(device_info),
)
rec = _get_by_trace(db, trace_id)
was_success = rec is not None and rec.status == "success"
if rec is None:
rec = ComparisonRecord(
trace_id=trace_id,
user_id=user_id,
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
device_id=device_id,
**fields,
)
db.add(rec)
else:
if user_id is not None and rec.user_id is None:
rec.user_id = user_id
if device_id and rec.device_id is None:
rec.device_id = device_id
for k, v in fields.items():
setattr(rec, k, v)
db.commit()
db.refresh(rec)
newly_success = (rec.status == "success") and not was_success
return rec, newly_success
def harvest_abort(
db: Session,
*,
trace_id: str,
status: str,
reason: str | None,
trace_url: str | None = None,
) -> ComparisonRecord | None:
"""finalize(用户终止/超时/异常,无 done 帧):running 行 → aborted/failed + trace_url。
**不降级 success**:行已 success(收尾取消那种 finalize 后到)只 refresh trace_url。
行不存在(极少:帧0没建成)→ 返回 None,不凭空造。"""
rec = _get_by_trace(db, trace_id)
if rec is None:
return None
if trace_url and not rec.trace_url:
rec.trace_url = trace_url
if rec.status != "success":
rec.status = status or "cancelled"
if reason:
rec.information = reason
db.commit()
db.refresh(rec)
return rec
def _ordered_shop_names(db: Session, user_id: int) -> set[str]:
"""该用户「真实下单」(source='compare')覆盖到的店名集合,用来给比价记录打「已下单」。
+3
View File
@@ -304,6 +304,9 @@ def get_invitees(
"avatar_url": u.avatar_url or u.wechat_avatar_url or None,
"coins": rel.inviter_coin, # v3 起恒 0(邀请人收益改走邀请奖励金)
"invited_at": rel.created_at,
# 是否已完成过一次比价(= 已发过邀请奖励金)。客户端据此:好友列表分"去提醒/邀请成功"、
# 在途列表只取未比价(is_compared=False)、算在途好友数与在途收益(未比价数×2元)。
"is_compared": bool(rel.compare_reward_granted),
})
has_more = offset + len(rows) < int(total)
return items, int(total), has_more
+16 -1
View File
@@ -5,7 +5,7 @@ device_id 为空(老客户端 / 取不到 ANDROID_ID)一律按"未完成"处理,
"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -33,3 +33,18 @@ def mark_completed(db: Session, *, user_id: int, device_id: str) -> None:
except IntegrityError:
# 并发 / 重复提交撞唯一约束:已有行即视为成功。
db.rollback()
def delete_completion(db: Session, *, user_id: int, device_id: str) -> int:
"""删该 (账号, 设备) 的引导完成标记 → 下次登录 is_completed=False → 客户端重走。
与 [mark_completed] 互逆。device_id 为空忽略(返 0);无记录也幂等(返 0)。返回删除行数。"""
if not device_id:
return 0
result = db.execute(
delete(OnboardingCompletion).where(
OnboardingCompletion.user_id == user_id,
OnboardingCompletion.device_id == device_id,
)
)
db.commit()
return result.rowcount
+3
View File
@@ -296,6 +296,9 @@ def daily_auto_exchange(db: Session) -> dict:
- **逐用户独立事务**:单个用户异常 rollback 不影响其他人;exchange_coins_to_cash 内部按用户 commit。
返回统计 dict(scanned/converted/skipped_done/skipped_dust/failed/total_cents)。
"""
# ⚠️「今天」写死北京时(rewards.cn_today() = datetime.now(CN_TZ).date(), CN_TZ=+8),与服务器/用户
# 时区无关(用户 2026-07-01 硬约束:兑换一律北京 0 点日切)。**禁止**改成 date.today() / 裸
# datetime.now().date()——那会跟随进程本地时区,服务器非 CST 时会在错误的"天"兑/重复兑。
today = rewards.cn_today()
stats = {
"scanned": 0, "converted": 0, "skipped_done": 0,
+1
View File
@@ -69,6 +69,7 @@ class InviteeItem(BaseModel):
avatar_url: str | None = None # 头像 URL;null = 前端画默认色块
coins: int # 这次邀请给我(邀请人)发的金币
invited_at: datetime # 邀请绑定时间(前端转"今天/3天前")
is_compared: bool = False # 该好友是否已完成过一次比价(好友列表分"去提醒/邀请成功";在途列表只取 False)
class InviteeListOut(BaseModel):
+3
View File
@@ -19,6 +19,9 @@ Type=oneshot
User=root
WorkingDirectory=/opt/shaguabijia-app-server
Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin"
# 写死北京时:兑换的"当天/0 点"一律按北京时,不随服务器 OS 时区漂(用户 2026-07-01 硬约束)。
# 脚本内的日期判断本就走 rewards.cn_today()(CN_TZ=+8),这里再把进程 TZ 也钉成北京,双保险。
Environment="TZ=Asia/Shanghai"
EnvironmentFile=/opt/shaguabijia-app-server/.env
ExecStart=/opt/shaguabijia-app-server/.venv/bin/python -m scripts.daily_auto_exchange --once
SyslogIdentifier=daily-exchange
+4 -2
View File
@@ -4,8 +4,10 @@
Description=Run daily auto-exchange coins->cash at midnight
[Timer]
# 每天 0 点跑。客户端文案已注明「可能存在延迟」,可按需改 00:05 错开整点扎堆
OnCalendar=*-*-* 00:00:00
# 每天【北京时】0 点跑,写死时区(用户 2026-07-01 硬约束:兑换一律北京 0 点,不随服务器本地时区漂)
# systemd OnCalendar 支持尾缀时区;不写时区会按服务器 OS 本地时区触发 → 服务器非 CST 时会在错误时刻兑。
# 客户端文案已注明「可能存在延迟」,可按需改 00:05 错开整点扎堆。
OnCalendar=*-*-* 00:00:00 Asia/Shanghai
# 服务器宕机/重启后,补跑错过的那一轮(而不是干等次日)。
Persistent=true
AccuracySec=1min
+73 -22
View File
@@ -1,32 +1,83 @@
# 后端文档库(docs/)
# 文档索引
`shaguabijia-app-server` 的文档都在这里。结构:**根目录放总览,其余按领域/用途分目录**
项目文档结构说明。后续大模型增补/更新文档时,按此分类找到对应目录
## 根目录
---
| 文档 | 作用 |
|---|---|
| [后端技术实现.md](./后端技术实现.md) | **后端技术方案总览**:业务概览、分层架构与目录、登录链路、美团 CPS、领券/比价透传、数据模型、配置与部署、已知问题。想了解"整个后端怎么回事"先看这份。 |
| README.md | 本文件:文档库索引/传送门。 |
## API 接口文档 (`api/`)
## 子目录
按业务领域分类,每个子目录对应一类接口。
| 目录 | 作用 |
|---|---|
| [api/](./api/) | **API 接口文档**:"索引 + 一接口一文件"(组织方式见下)。想查"某个接口的协议"看这里。 |
| [database/](./database/) | **数据库文档**:每张表一个文件(表结构/字段/索引/约定) + 两份迁移指南——[数据库迁移.md](./database/数据库迁移.md)(Alembic 操作:clone 后建表/日常升级/新增迁移/多 head 排查) + [postgres-migration.md](./database/postgres-migration.md)(SQLite → PostgreSQL 切换步骤,配套 `scripts/init_postgres.py`)。想"把库跑起来 / 改表结构 / 查某张表"看这里。 |
| [integrations/](./integrations/) | **集成层实现文档**:穿山甲验签 / 微信支付 / 极光 / 短信 / 美团 CPS 等 SDK 集成的签名、加解密、协议细节与踩坑。想知道"接外部服务那块到底怎么实现"看这里。 |
| [guides/](./guides/) | **开发 / 上线 / 功能 指南**:[待办与技术债.md](./guides/待办与技术债.md)(跨前后端 backlog,P1 鉴权/用户绑定、引擎移植待办等,"还欠什么、以后要补什么"看这份) + [看广告赚金币上线清单.md](./guides/看广告赚金币上线清单.md)(看广告发奖上线 checklist) + [邀请功能-实现原理与本地测试.md](./guides/邀请功能-实现原理与本地测试.md)(invite-mvp 实现原理 + 本地内网全链路测试) + [CPS发券分发与微信授权.md](./guides/CPS发券分发与微信授权.md)(**CPS 发券系统交接文档**:设群/建活动/生成落地页短链/美团对账/统计 + 微信网页授权拿 openid 做用户级统计;含数据流/表/平台差异/端点/配置/代码地图/排障/技术债)。 |
### 分类目录
## api/ 目录是怎么组织的(传送门式)
| 目录 | 分类 | URL 前缀 | 说明 |
|------|------|----------|------|
| [api/auth/](api/auth/) | 认证 | `/api/v1/auth` | 用户登录(极光一键登录/短信验证码)、Token 刷新(access + refresh)、登出、当前用户信息查询 |
| [api/ad/](api/ad/) | 广告 | `/api/v1/ad` | 穿山甲 S2S 回调验签发奖、激励视频/信息流广告奖励结算、eCPM 上报、发奖状态查询、测试发奖(仅本地) |
| [api/wallet/](api/wallet/) | 钱包 | `/api/v1/wallet` | 账户资产查询(金币/现金余额)、金币与现金流水、兑换规则与执行、绑定/解绑微信、提现申请/状态/记录、免确认收款授权 |
| [api/coupon/](api/coupon/) | 领券 | `/api/v1/coupon` | CPS 领券透传(step/session)、引导窗频控(should-show/shown/dismiss/reset)、每日完成状态、累计领券统计 |
| [api/compare/](api/compare/) | 比价记录 | `/api/v1/compare` | 比价记录上报/列表/详情/统计、比价战绩里程碑查询及奖励领取(按成功比价数解锁金币) |
| [api/savings/](api/savings/) | 省钱 | `/api/v1/savings` + `/api/v1/platform/savings-feed` | 省钱大作战(battle)、省钱明细/汇总、平台省钱动态 Feed |
| [api/signin/](api/signin/) | 签到 | `/api/v1/signin` | 每日签到执行、签到加速(看广告多领)、签到状态查询 |
| [api/tasks/](api/tasks/) | 任务 | `/api/v1/tasks` | 任务列表查询、任务奖励领取(按 task_key) |
| [api/invite/](api/invite/) | 邀请 | `/api/v1/invite` | 邀请码/分享链接生成、已邀请列表、邀请绑定(支持 clipboard/manual/fingerprint 三种归因) |
| [api/user/](api/user/) | 用户 | `/api/v1/user` | 个人资料编辑(昵称)、头像上传、新手引导状态/完成标记、注销账号 |
| [api/device/](api/device/) | 设备 | `/api/v1/device` | 设备注册/推送 token 更新、无障碍存活心跳、掉线检测(后置 pull)、告警确认 |
| [api/platform/](api/platform/) | 平台配置 | `/api/v1/platform` | 平台统计数据、Feature Flag 开关、广告配置(穿山甲 ID)、App 版本更新检查(OTA);全部不鉴权 |
| [api/intent/](api/intent/) | 意图/电商 | `/api/v1/intent` + `/api/v1/price` + `/api/v1/ecom` | 比价意图识别(Phase 1 单次/多帧/预券)、电商意图识别、比价步进(Phase 2) |
| [api/meituan/](api/meituan/) | 美团 CPS | `/api/v1/meituan` | 美团券列表/信息流/推广链接/销量榜;全部无鉴权 |
| [api/other/](api/other/) | 其它 | 分散 | 健康检查、CPS 短链重定向(含微信 OAuth)、反馈提交/配置/记录、埋点上报、订单上报、更低价上报、Trace 收尾 |
| [api/admin/](api/admin/) | 管理后台 | `/admin/api` | Admin 独立子应用,二级目录按子资源拆分:`auth/`(登录)、`users/`(用户管理/财务操作)、`wallet/`(流水查询)、`withdraws/`(提现审核/对账)、`feedbacks/`(反馈处理)、`admins/`(管理员管理)、`ad/`(广告对账/收益);单文件留根:审计日志、数据大盘、跑马灯种子、统计概览 |
| [api/internal/](api/internal/) | 内部接口 | `/internal` | 服务间调用(pricebot→app-server):价格事实回写、店铺映射、启动确认样本、App 版本写入;X-Internal-Secret 鉴权 |
接口多了之后,单个大文件会臃肿、难维护,所以拆成 **一个索引 + 一个接口一个文件**:
### 接口索引入口
- **[api/README.md](./api/README.md) — 入口/传送门**
一张总览表列出**全部接口**(方法、路径、鉴权),每行链接到该接口的独立文档;另含**通用约定**(错误码、时间格式等)和**复用数据结构**(`TokenPair` / `UserOut` / `CouponCard` 等)。它本身不展开每个接口的细节,只负责"指路"。
- **api/&lt;模块&gt;-&lt;接口&gt;.md — 单接口文档**
每个接口一个文件,只写自己的入参 / 出参 / 错误码 / 说明。命名按 `<模块>-<接口>`,如 `auth-jverify-login.md``coupon-step.md``meituan-feed.md`
完整接口列表(含路径、方法、鉴权方式)见 [api/README.md](api/README.md)
**查某个接口的协议**:先打开 [api/README.md](./api/README.md) 的总览表 → 找到接口 → 点链接进对应文档。
### 文档命名规则
> **新增接口时**:在 `api/` 下加一个 `<模块>-<接口>.md`,并到 `api/README.md` 总览表里补一行链接。这样索引始终是唯一的"传送门",细节各自独立、互不干扰
单端点文档按 URL 路径命名:`{prefix}-{resource}.md`(如 `wallet-withdraw.md``auth-sms-send.md`
含子资源的合并文档按族命名(如 `device-liveness.md` 覆盖 register/heartbeat/liveness/liveness-ack 四个端点)。
文件名唯一确定文档位置:大类目录 + 文件名前缀 → 直接匹配。
---
## 数据库文档 (`database/`)
数据库表结构说明,每表一个文件,[database/OVERVIEW.md](database/OVERVIEW.md) 为索引入口。
---
## 第三方集成 (`integrations/`)
外部 SDK/API 集成架构与实现说明:[integrations/README.md](integrations/README.md)。
| 文件 | 说明 |
|------|------|
| [jiguang.md](integrations/jiguang.md) | 极光一键登录(REST 验证 + RSA 解密) |
| [pangle.md](integrations/pangle.md) | 穿山甲广告 S2S 回调 |
| [wxpay.md](integrations/wxpay.md) | 微信支付 V3(提现/授权) |
| [meituan.md](integrations/meituan.md) | 美团 CPS 网关 |
---
## 开发指南 (`guides/`)
| 文件 | 说明 |
|------|------|
| [邀请功能-实现原理与本地测试.md](guides/邀请功能-实现原理与本地测试.md) | 邀请系统实现细节 |
| [CPS发券分发与微信授权.md](guides/CPS发券分发与微信授权.md) | CPS 发券 + 微信网页授权流程 |
| [看广告赚金币上线清单.md](guides/看广告赚金币上线清单.md) | 广告功能上线检查清单 |
| [待办与技术债.md](guides/待办与技术债.md) | 待办事项与技术债务 |
---
## 架构与设计 (`superpowers/`)
需求规格与设计文档,按 `YYYY-MM-DD-<topic>-design.md` 命名。
---
## 后端技术实现 (`后端技术实现.md`)
后端整体技术架构说明。
+129 -121
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-23(补全此前缺整族的端点:device 无障碍存活监控、internal 内部回写、CPS 短链落地、report/invite/wxpay,及 platform/coupon/feedback/user 的零散读端点;device/internal/cps-redirect 三族新建单文件文档。上一次 2026-06-18 补录比价透传端点与 `meituan/top-sales`
> 最后更新:2026-07-03(补全缺失文档:ad/watch-report, wallet/transfer-auth 族, coupon/session+stats+completed-today+prompt 族, invite 族, user/onboarding, platform/flags+ad-config+app-version, intent/step+precoupon/step, analytics/events, order/report, report 族, feedback/config+records, trace/finalize。文档移至分类子目录,新增 mock 入参/出参示例
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
---
@@ -12,146 +12,154 @@
| # | 方法 + 路径 | 鉴权 | 详情 |
|---|---|---|---|
| 1 | `GET /health` | 无 | [详情](./health.md) |
| 2 | `POST /api/v1/auth/jverify-login` | 无 | [详情](./auth-jverify-login.md) |
| 3 | `POST /api/v1/auth/sms/send` | 无 | [详情](./auth-sms-send.md) |
| 4 | `POST /api/v1/auth/sms/login` | 无 | [详情](./auth-sms-login.md) |
| 5 | `POST /api/v1/auth/refresh` | 无 | [详情](./auth-refresh.md) |
| 6 | `GET /api/v1/auth/me` | Bearer | [详情](./auth-me.md) |
| 7 | `POST /api/v1/auth/logout` | Bearer | [详情](./auth-logout.md) |
| 8 | `POST /api/v1/coupon/step` | 无 | [详情](./coupon-step.md)(透传 pricebot + best-effort 写 `coupon_*` 三表) |
| 8a | `POST /api/v1/coupon/prompt/shown` | 无 | 引导窗弹出即上报(按 device+package+日记 `shown`,今天这个 App 不再自动弹)(无单独文档) |
| 8b | `POST /api/v1/coupon/prompt/dismiss` | 无 | 用户拒绝/关闭引导窗(透传链路看不到拒绝,客户端通知)(无单独文档) |
| 8c | `GET /api/v1/coupon/prompt/should-show` | 无 | 切到外卖 App 时是否还应弹引导窗(`device_id`+`package`)(无单独文档) |
| 8d | `POST /api/v1/coupon/prompt/reset` | 无 | 重置今日引导窗 engagement开发测频控用)(无单独文档) |
| 8e | `GET /api/v1/coupon/completed-today` | 无 | 这台设备今天是否已跑完整轮领券(首页「去领取」卡置灰源)(无单独文档) |
| 8f | `POST /api/v1/coupon/completed-today/reset` | 无 | 重置今日已完成开发用)(无单独文档) |
| 8g | `GET /api/v1/coupon/stats` | Bearer | 累计领券数「我的」页战绩卡;按 user_id 聚合,**鉴权**)(无单独文档) |
| 9 | `POST /api/v1/meituan/coupons` | 无 | [详情](./meituan-coupons.md) |
| 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan-feed.md) |
| 11 | `POST /api/v1/meituan/referral-link` | 无 | [详情](./meituan-referral-link.md) |
| 11a | `POST /api/v1/meituan/top-sales` | 无 | [详情](./meituan-top-sales.md)(销量榜:离线库 `meituan_coupon` 按销量降序 + 跨源去重,不实时打美团) |
| 1 | `GET /health` | 无 | [详情](./other/health.md) |
| 2 | `POST /api/v1/auth/jverify-login` | 无 | [详情](./auth/auth-jverify-login.md) |
| 3 | `POST /api/v1/auth/sms/send` | 无 | [详情](./auth/auth-sms-send.md) |
| 4 | `POST /api/v1/auth/sms/login` | 无 | [详情](./auth/auth-sms-login.md) |
| 5 | `POST /api/v1/auth/refresh` | 无 | [详情](./auth/auth-refresh.md) |
| 6 | `GET /api/v1/auth/me` | Bearer | [详情](./auth/auth-me.md) |
| 7 | `POST /api/v1/auth/logout` | Bearer | [详情](./auth/auth-logout.md) |
| 8 | `POST /api/v1/coupon/step` | 无 | [详情](./coupon/coupon-step.md)(透传 pricebot + best-effort 写 `coupon_*` 三表) |
| 8a | `POST /api/v1/coupon/prompt/shown` | 无 | [详情](./coupon/coupon-prompt.md)(引导窗弹出即上报) |
| 8b | `POST /api/v1/coupon/prompt/dismiss` | 无 | [详情](./coupon/coupon-prompt.md)(用户拒绝/关闭引导窗) |
| 8c | `GET /api/v1/coupon/prompt/should-show` | 无 | [详情](./coupon/coupon-prompt.md)(切到外卖 App 时是否还应弹引导窗) |
| 8d | `POST /api/v1/coupon/prompt/reset` | 无 | [详情](./coupon/coupon-prompt.md)重置今日引导窗 engagement,开发测频控用 |
| 8e | `GET /api/v1/coupon/completed-today` | 无 | [详情](./coupon/coupon-completed-today.md)(这台设备今天是否已跑完整轮领券) |
| 8f | `POST /api/v1/coupon/completed-today/reset` | 无 | [详情](./coupon/coupon-completed-today.md)重置今日已完成,开发用 |
| 8g | `GET /api/v1/coupon/stats` | Bearer | [详情](./coupon/coupon-stats.md)累计领券数,「我的」页战绩卡 |
| 8h | `POST /api/v1/coupon/session` | 无 | [详情](./coupon/coupon/coupon-session.md)(领券流水上报,admin 看板数据源) |
| 9 | `POST /api/v1/meituan/coupons` | 无 | [详情](./meituan/meituan-coupons.md) |
| 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan/meituan-feed.md) |
| 11 | `POST /api/v1/meituan/referral-link` | 无 | [详情](./meituan/meituan-referral-link.md) |
| 11a | `POST /api/v1/meituan/top-sales` | 无 | [详情](./meituan/meituan-top-sales.md)(销量榜:离线库 `meituan_coupon` 按销量降序 + 跨源去重,不实时打美团) |
| **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend;下按 Phase 流程列,均不鉴权) |||
| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./compare-intent-recognize.md)(Phase 1 意图识别,单次,多数源) |
| 12a | `POST /api/v1/intent/precoupon/step` | 无 | Phase 0 意图识别前先用券,仅美团源(透传,无单独文档) |
| 12b | `POST /api/v1/intent/step` | 无 | Phase 1 多帧意图识别,仅淘宝源,循环到 done(透传,无单独文档) |
| 13 | `POST /api/v1/price/step` | 无 | [详情](./compare-price-step.md)Phase 2 步进) |
| 13a | `POST /api/v1/trace/finalize` | 无 | 比价 trace 收尾上云,终止/未识别拿 trace_url(透传,无单独文档) |
| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./intent/compare-intent-recognize.md)(Phase 1 意图识别,单次,多数源) |
| 12a | `POST /api/v1/intent/precoupon/step` | 无 | [详情](./intent/intent-step.md)Phase 0 意图识别前先用券,仅美团源 |
| 12b | `POST /api/v1/intent/step` | 无 | [详情](./intent/intent-step.md)Phase 1 多帧意图识别,仅淘宝源,循环到 done |
| 13 | `POST /api/v1/price/step` | 无 | [详情](./intent/compare-price-step.md)Phase 2 步进) |
| 13a | `POST /api/v1/trace/finalize` | 无 | [详情](./other/trace-finalize.md)比价 trace 收尾上云,终止/未识别拿 trace_url |
| **比价记录**(前缀 `/api/v1/compare`;按用户落库,**鉴权**,区别于上面不鉴权的透传) |||
| 12a | `POST /api/v1/compare/record` | Bearer | [详情](./compare-record-report.md) |
| 12b | `GET /api/v1/compare/records` | Bearer | [详情](./compare-records.md) |
| 12c | `GET /api/v1/compare/records/{id}` | Bearer | [详情](./compare-record-detail.md) |
| 12e | `GET /api/v1/compare/stats` | Bearer | [详情](./compare-stats.md)(「我的」页省钱战绩卡:完成比价数 + 累计发现可省) |
| 12a | `POST /api/v1/compare/record` | Bearer | [详情](./compare/compare-record-report.md) |
| 12b | `GET /api/v1/compare/records` | Bearer | [详情](./compare/compare-records.md) |
| 12c | `GET /api/v1/compare/records/{id}` | Bearer | [详情](./compare/compare-record-detail.md) |
| 12e | `GET /api/v1/compare/stats` | Bearer | [详情](./compare/compare-stats.md)(「我的」页省钱战绩卡:完成比价数 + 累计发现可省) |
| **比价战绩里程碑**(前缀 `/api/v1/compare`;福利页「记录比价战绩」,按成功比价数解锁逐档发金币) |||
| 12d | `GET /api/v1/compare/milestones` | Bearer | [详情](./compare-milestones.md) |
| 12e | `POST /api/v1/compare/milestones/{milestone}/claim` | Bearer | [详情](./compare-milestone-claim.md) |
| 12d | `GET /api/v1/compare/milestones` | Bearer | [详情](./compare/compare-milestones.md) |
| 12e | `POST /api/v1/compare/milestones/{milestone}/claim` | Bearer | [详情](./compare/compare-milestone-claim.md) |
| **设备 / 无障碍存活监控**(前缀 `/api/v1/device`;心跳超时检出 + 掉线召回,#65 |||
| D1 | `POST /api/v1/device/register` | Bearer | [详情](./device-liveness.md)(注册设备/更新极光 push token |
| D2 | `POST /api/v1/device/heartbeat` | Bearer | [详情](./device-liveness.md)(无障碍服务存活心跳,心跳也能自注册) |
| D3 | `GET /api/v1/device/liveness` | Bearer | [详情](./device-liveness.md)(进 App 查本机是否被判掉线过) |
| D4 | `POST /api/v1/device/liveness/ack` | Bearer | [详情](./device-liveness.md)(确认已弹引导,清掉线告警) |
| D1 | `POST /api/v1/device/register` | Bearer | [详情](./device/device-liveness.md)(注册设备/更新极光 push token |
| D2 | `POST /api/v1/device/heartbeat` | Bearer | [详情](./device/device-liveness.md)(无障碍服务存活心跳,心跳也能自注册) |
| D3 | `GET /api/v1/device/liveness` | Bearer | [详情](./device/device-liveness.md)(进 App 查本机是否被判掉线过) |
| D4 | `POST /api/v1/device/liveness/ack` | Bearer | [详情](./device/device-liveness.md)(确认已弹引导,清掉线告警) |
| **上报更低价**(前缀 `/api/v1/report`;众包纠偏,人工审核发奖) |||
| R1 | `POST /api/v1/report` | Bearer | 提交上报(multipart:`comparison_record_id`/`reported_platform_id`/`reported_price`(元) + 1~4 张截图;原最低价反查 `comparison_record.best_*` 校验须更低)(无单独文档) |
| R2 | `GET /api/v1/report/records` | Bearer | 上报记录列表`?status=` pending/approved/rejected 可选筛选)(无单独文档) |
| R1 | `POST /api/v1/report` | Bearer | [详情](./other/report-submit.md)(提交上报更低价,multipart:比价记录ID+平台+价格+截图1-4张) |
| R2 | `GET /api/v1/report/records` | Bearer | [详情](./other/report-records.md)上报记录列表,?status=pending/approved/rejected 可选筛选) |
| **好友邀请**(前缀 `/api/v1/invite`;注册即生效,双方各发 1 万金币) |||
| I1 | `GET /api/v1/invite/me` | Bearer | 我的邀请码 + 分享链接 + 已邀人数/已得金币(无单独文档) |
| I2 | `GET /api/v1/invite/invitees` | Bearer | 我邀请的人列表`limit`/`offset` 分页)(无单独文档) |
| I3 | `POST /api/v1/invite/landing-track` | 无 | 落地页 `dl.html` 访问上报指纹(剪贴板归因兜底;浏览器无 token)(无单独文档) |
| I4 | `POST /api/v1/invite/bind` | Bearer | 绑定邀请人;支持 clipboard/manual 邀请码 + fingerprint 指纹反查三种归因(无单独文档) |
| I1 | `GET /api/v1/invite/me` | Bearer | [详情](./invite/invite-me.md)我的邀请码+分享链接+已邀人数/已得金币 |
| I2 | `GET /api/v1/invite/invitees` | Bearer | [详情](./invite/invite-invitees.md)我邀请的人列表,limit/offset 分页) |
| I3 | `POST /api/v1/invite/landing-track` | 无 | [详情](./invite/invite-bind.md)落地页 dl.html 访问上报指纹,剪贴板归因兜底;浏览器无 token |
| I4 | `POST /api/v1/invite/bind` | Bearer | [详情](./invite/invite-bind.md)绑定邀请人;支持 clipboard/manual 邀请码+fingerprint 指纹反查三种归因 |
| **钱包 / 我的资产**(前缀 `/api/v1/wallet` |||
| 14 | `GET /api/v1/wallet/account` | Bearer | [详情](./wallet-account.md) |
| 15 | `GET /api/v1/wallet/coin-transactions` | Bearer | [详情](./wallet-coin-transactions.md) |
| 16 | `GET /api/v1/wallet/cash-transactions` | Bearer | [详情](./wallet-cash-transactions.md) |
| 17 | `GET /api/v1/wallet/exchange-info` | 无 | [详情](./wallet-exchange-info.md) |
| 18 | `POST /api/v1/wallet/exchange` | Bearer | [详情](./wallet-exchange.md) |
| 19 | `POST /api/v1/wallet/bind-wechat` | Bearer | [详情](./wallet-bind-wechat.md) |
| 20 | `POST /api/v1/wallet/unbind-wechat` | Bearer | [详情](./wallet-unbind-wechat.md) |
| 21 | `GET /api/v1/wallet/withdraw-info` | Bearer | [详情](./wallet-withdraw-info.md) |
| 22 | `POST /api/v1/wallet/withdraw` | Bearer | [详情](./wallet-withdraw.md) |
| 23 | `GET /api/v1/wallet/withdraw/status` | Bearer | [详情](./wallet-withdraw-status.md) |
| 24 | `GET /api/v1/wallet/withdraw-orders` | Bearer | [详情](./wallet-withdraw-orders.md) |
| 14 | `GET /api/v1/wallet/account` | Bearer | [详情](./wallet/wallet-account.md) |
| 15 | `GET /api/v1/wallet/coin-transactions` | Bearer | [详情](./wallet/wallet-coin-transactions.md) |
| 16 | `GET /api/v1/wallet/cash-transactions` | Bearer | [详情](./wallet/wallet-cash-transactions.md) |
| 17 | `GET /api/v1/wallet/exchange-info` | 无 | [详情](./wallet/wallet-exchange-info.md) |
| 18 | `POST /api/v1/wallet/exchange` | Bearer | [详情](./wallet/wallet-exchange.md) |
| 19 | `POST /api/v1/wallet/bind-wechat` | Bearer | [详情](./wallet/wallet-bind-wechat.md) |
| 20 | `POST /api/v1/wallet/unbind-wechat` | Bearer | [详情](./wallet/wallet-unbind-wechat.md) |
| 21 | `GET /api/v1/wallet/withdraw-info` | Bearer | [详情](./wallet/wallet-withdraw-info.md) |
| 22 | `POST /api/v1/wallet/withdraw` | Bearer | [详情](./wallet/wallet-withdraw.md) |
| 23 | `GET /api/v1/wallet/withdraw/status` | Bearer | [详情](./wallet/wallet-withdraw-status.md) |
| 24 | `GET /api/v1/wallet/withdraw-orders` | Bearer | [详情](./wallet/wallet-withdraw-orders.md) |
| 24a | `POST /api/v1/wallet/transfer-auth` | Bearer | [详情](./wallet/wallet-transfer-auth.md)(开启免确认到账,申请授权,返回拉起微信授权页的 package) |
| 24b | `GET /api/v1/wallet/transfer-auth/status` | Bearer | [详情](./wallet/wallet-transfer-auth.md)(查免确认授权状态,从微信授权页返回后轮询) |
| 24c | `POST /api/v1/wallet/transfer-auth/close` | Bearer | [详情](./wallet/wallet-transfer-auth.md)(关闭免确认到账,解除授权) |
| **签到**(前缀 `/api/v1/signin` |||
| 25 | `GET /api/v1/signin/status` | Bearer | [详情](./signin-status.md) |
| 26 | `POST /api/v1/signin` | Bearer | [详情](./signin-do.md) |
| 26a | `POST /api/v1/signin/boost` | Bearer | [详情](./signin-boost.md) |
| 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-list.md) |
| 28 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks-claim.md) |
| 27 | `GET /api/v1/tasks` | Bearer | [详情](./tasks/tasks-list.md) |
| 28 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks/tasks-claim.md) |
| **省钱**(前缀 `/api/v1/savings` |||
| 29 | `GET /api/v1/savings/summary` | Bearer | [详情](./savings-summary.md) |
| 30 | `GET /api/v1/savings/battle` | Bearer | [详情](./savings-battle.md) |
| 31 | `GET /api/v1/savings/records` | Bearer | [详情](./savings-records.md) |
| 29 | `GET /api/v1/savings/summary` | Bearer | [详情](./savings/savings-summary.md) |
| 30 | `GET /api/v1/savings/battle` | Bearer | [详情](./savings/savings-battle.md) |
| 31 | `GET /api/v1/savings/records` | Bearer | [详情](./savings/savings-records.md) |
| **看广告发奖**(前缀 `/api/v1/ad` |||
| 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad-pangle-callback.md) |
| 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad-reward-status.md) |
| 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) |
| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.md) |
| 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad-feed-reward.md) |
| 35b | `POST /api/v1/ad/reward-noshow` | Bearer | [详情](./ad-reward-noshow.md)(激励视频提前关闭/未发奖留痕,只记原因不发币) |
| 35c | `GET /api/v1/ad/feed-reward/units` | Bearer | 信息流广告今日已发份数/上限(配合 `feed-reward` 看进度)(无单独文档) |
| 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) |
| 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) |
| 35b | `POST /api/v1/ad/reward-noshow` | Bearer | [详情](./ad/ad-reward-noshow.md)(激励视频提前关闭/未发奖留痕,只记原因不发币) |
| 35c | `GET /api/v1/ad/feed-reward/units` | Bearer | [详情](./ad/ad-feed-reward.md)信息流广告今日已发份数/上限,配合 feed-reward 看进度 |
| 35d | `POST /api/v1/ad/watch-report` | Bearer | [详情](./ad/ad-watch-report.md)(上报激励视频观看时长,旧客户端兼容) |
| **用户资料**(前缀 `/api/v1/user` |||
| 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) |
| 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.md) |
| 36a | `POST /api/v1/user/onboarding/complete` | Bearer | 标记新手引导完成按 账号+device_id 幂等,跨卸载重装持久)(无单独文档) |
| 36b | `GET /api/v1/user/onboarding/status` | Bearer | 查该 (账号,设备) 是否走过引导运营在 admin 删记录即触发重走)(无单独文档) |
| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user-delete.md) |
| 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user/user-profile.md) |
| 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user/user-avatar.md) |
| 36a | `POST /api/v1/user/onboarding/complete` | Bearer | [详情](./user/user-onboarding.md)标记新手引导完成,按 账号+device_id 幂等,跨卸载重装持久) |
| 36b | `GET /api/v1/user/onboarding/status` | Bearer | [详情](./user/user-onboarding.md)查该 账号+设备 是否走过引导,运营在 admin 删记录即触发重走) |
| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user/user-delete.md) |
| **帮助与反馈**(前缀 `/api/v1/feedback` |||
| 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(无单独文档) |
| 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元) |
| **首页门面数据 / 客户端配置**(前缀 `/api/v1/platform`;全平台展示数字 + 运营开关,**全部不鉴权**,登录前可读) |||
| 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform-stats.md) |
| 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./platform-savings-feed.md) |
| 40a | `GET /api/v1/platform/flags` | 无 | 客户端运营 feature flag比价/领券期广告开关等),拉取后缓存(无单独文档) |
| 40b | `GET /api/v1/platform/ad-config` | 无 | 客户端拉广告配置穿山甲 app_id + 各位 ID + 各场景开关;不含验签密钥)(无单独文档) |
| 40c | `GET /api/v1/platform/app-version` | 无 | 最新 App 版本OTA 检查更新;与本机 versionCode 比)(无单独文档) |
| 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform/platform-stats.md) |
| 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./savings/platform-savings-feed.md) |
| 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 比) |
| **微信支付回调**(前缀 `/api/v1/wxpay` |||
| W1 | `POST /api/v1/wxpay/transfer-auth-notify` | 无 | 免确认收款授权结果通知(一期 stub:仅应答 200 不验签不改账,授权状态靠主动查询兜底)(无单独文档) |
| **CPS 群发短链落地**(**无前缀**,挂域名根;公网不鉴权) |||
| C1 | `GET /c/{code}` | 无 | [详情](./cps-redirect.md)(短链落地:微信授权拿 openid + 记点击 + 302 跳/淘宝 H5 落地页) |
| C2 | `POST /c/{code}/copy` | 无 | [详情](./cps-redirect.md)(淘宝落地页点「复制口令」记 `copy` |
| C3 | `GET /wx/oauth/cb` | 无 | [详情](./cps-redirect.md)(微信网页授权回调;upsert `cps_wx_user` + 种 cookie,`include_in_schema=False` |
| C4 | `GET /MP_verify_*.txt` | 无 | [详情](./cps-redirect.md)(微信「网页授权域名」归属校验文件,`include_in_schema=False` |
| C1 | `GET /c/{code}` | 无 | [详情](./other/cps-redirect.md)(短链落地:微信授权拿 openid + 记点击 + 302 跳/淘宝 H5 落地页) |
| C2 | `POST /c/{code}/copy` | 无 | [详情](./other/cps-redirect.md)(淘宝落地页点「复制口令」记 `copy` |
| C3 | `GET /wx/oauth/cb` | 无 | [详情](./other/cps-redirect.md)(微信网页授权回调;upsert `cps_wx_user` + 种 cookie,`include_in_schema=False` |
| C4 | `GET /MP_verify_*.txt` | 无 | [详情](./other/cps-redirect.md)(微信「网页授权域名」归属校验文件,`include_in_schema=False` |
| **内部回写端点**(前缀 `/internal`;pricebot/发布流程→app-server,**`X-Internal-Secret` 头**,非客户端接口) |||
| N1 | `POST /internal/price-observation` | 内部密钥 | [详情](./internal.md)(比价价格事实批量落 `price_observation` |
| N2 | `GET /internal/store-mapping/lookup` | 内部密钥 | [详情](./internal.md)(按源平台店名反查目标平台已沉淀店铺 id/deeplink |
| N3 | `POST /internal/store-mapping` | 内部密钥 | [详情](./internal.md)(跨平台店铺身份映射落 `store_mapping` |
| N4 | `POST /internal/store-mapping/invalidate` | 内部密钥 | [详情](./internal.md)(标记某平台 shopId 缓存 deeplink 失效) |
| N5 | `POST /internal/launch-confirm-sample` | 内部密钥 | [详情](./internal.md)(启动确认窗兜底样本落 `launch_confirm_sample` |
| N6 | `POST /internal/app-version` | 内部密钥 | [详情](./internal.md)(发布流程写最新 App 版本,落 `app_config` |
| N1 | `POST /internal/price-observation` | 内部密钥 | [详情](./internal/internal.md)(比价价格事实批量落 `price_observation` |
| N2 | `GET /internal/store-mapping/lookup` | 内部密钥 | [详情](./internal/internal.md)(按源平台店名反查目标平台已沉淀店铺 id/deeplink |
| N3 | `POST /internal/store-mapping` | 内部密钥 | [详情](./internal/internal.md)(跨平台店铺身份映射落 `store_mapping` |
| N4 | `POST /internal/store-mapping/invalidate` | 内部密钥 | [详情](./internal/internal.md)(标记某平台 shopId 缓存 deeplink 失效) |
| N5 | `POST /internal/launch-confirm-sample` | 内部密钥 | [详情](./internal/internal.md)(启动确认窗兜底样本落 `launch_confirm_sample` |
| N6 | `POST /internal/app-version` | 内部密钥 | [详情](./internal/internal.md)(发布流程写最新 App 版本,落 `app_config` |
| **静态资源**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 | `POST /admin/api/users/{user_id}/cash` | finance | [详情](./admin-user-cash.md) |
| A9 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin-wallet-coin-transactions.md) |
| A10 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin-wallet-cash-transactions.md) |
| A11 | `GET /admin/api/withdraws` | admin | [详情](./admin-withdraws-list.md) |
| A12 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin-withdraw-reconcile.md) |
| A13 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin-withdraw-refresh.md) |
| A14 | `GET /admin/api/feedbacks` | admin | [详情](./admin-feedbacks-list.md) |
| A15 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin-feedback-handle.md) |
| A16 | `GET /admin/api/admins` | super_admin | [详情](./admin-admins-list.md) |
| A17 | `POST /admin/api/admins` | super_admin | [详情](./admin-admin-create.md) |
| A18 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin-admin-update.md) |
| A19 | `GET /admin/api/audit-logs` | admin | [详情](./admin-audit-logs.md) |
| A20 | `GET /admin/api/dashboard-display` | admin | [详情](./admin-dashboard-display.md) |
| A21 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin-dashboard-display.md) |
| A22 | `GET /admin/api/marquee-seeds` | admin | [详情](./admin-marquee-seeds.md) |
| A23 | `POST /admin/api/marquee-seeds` | operator | [详情](./admin-marquee-seeds.md) |
| A24 | `PATCH /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) |
| A25 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) |
| A26 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) |
| A27 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) |
| A28 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) |
| A29 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin-ad-revenue-report.md)(广告收益报表:按用户/日期/类型/应用/代码位 聚合 条数/收益/金币,只读) |
| A1 | `POST /admin/api/auth/login` | 无 | [详情](./admin/auth/admin-auth-login.md) |
| A2 | `GET /admin/api/auth/me` | admin | [详情](./admin/auth/admin-auth-me.md) |
| A3 | `GET /admin/api/stats/overview` | admin | [详情](./admin/admin-stats-overview.md) |
| A4 | `GET /admin/api/users` | admin | [详情](./admin/users/admin-users-list.md) |
| A5 | `GET /admin/api/users/{user_id}` | admin | [详情](./admin/users/admin-user-detail.md) |
| A6 | `POST /admin/api/users/{user_id}/status` | operator | [详情](./admin/users/admin-user-status.md) |
| A7 | `POST /admin/api/users/{user_id}/coins` | finance | [详情](./admin/users/admin-user-coins.md) |
| A8 | `POST /admin/api/users/{user_id}/cash` | finance | [详情](./admin/users/admin-user-cash.md) |
| A9 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin/wallet/admin-wallet-coin-transactions.md) |
| A10 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin/wallet/admin-wallet-cash-transactions.md) |
| A11 | `GET /admin/api/withdraws` | admin | [详情](./admin/withdraws/admin-withdraws-list.md) |
| A12 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin/withdraws/admin-withdraw-reconcile.md) |
| A13 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin/withdraws/admin-withdraw-refresh.md) |
| A14 | `GET /admin/api/feedbacks` | admin | [详情](./admin/feedbacks/admin-feedbacks-list.md) |
| A15 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin/feedbacks/admin-feedback-handle.md) |
| A16 | `GET /admin/api/admins` | super_admin | [详情](./admin/admins/admin-admins-list.md) |
| A17 | `POST /admin/api/admins` | super_admin | [详情](./admin/admins/admin-admin-create.md) |
| A18 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin/admins/admin-admin-update.md) |
| A19 | `GET /admin/api/audit-logs` | admin | [详情](./admin/admin-audit-logs.md) |
| A20 | `GET /admin/api/dashboard-display` | admin | [详情](./admin/admin-dashboard-display.md) |
| A21 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin/admin-dashboard-display.md) |
| A22 | `GET /admin/api/marquee-seeds` | admin | [详情](./admin/admin-marquee-seeds.md) |
| A23 | `POST /admin/api/marquee-seeds` | operator | [详情](./admin/admin-marquee-seeds.md) |
| A24 | `PATCH /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin/admin-marquee-seeds.md) |
| A25 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin/admin-marquee-seeds.md) |
| A26 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin/admin-marquee-seeds.md) |
| A27 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin/admin-marquee-seeds.md) |
| A28 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin/ad/admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) |
| A29 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin/ad/admin-ad-revenue-report.md)(广告收益报表:按用户/日期/类型/应用/代码位 聚合 条数/收益/金币,只读) |
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
@@ -201,8 +209,8 @@
|---|---|---|
| `id` | int | 用户主键 |
| `phone` | string | 手机号(注销账号后变 `deleted_<id>` 占位释放唯一约束) |
| `nickname` | string \| null | 昵称,经 [`PATCH /api/v1/user/profile`](./user-profile.md) 修改 |
| `avatar_url` | string \| null | 头像相对 URL(`/media/avatars/...`),经 [`POST /api/v1/user/avatar`](./user-avatar.md) 上传 |
| `nickname` | string \| null | 昵称,经 [`PATCH /api/v1/user/profile`](./user/user-profile.md) 修改 |
| `avatar_url` | string \| null | 头像相对 URL(`/media/avatars/...`),经 [`POST /api/v1/user/avatar`](./user/user-avatar.md) 上传 |
| `register_channel` | string | 注册渠道:`jverify` / `sms` |
| `status` | string | `active` / `disabled` / `deleted` |
| `created_at` | datetime | 注册时间 |
@@ -1,6 +1,6 @@
# POST /api/v1/ad/ecpm-report — 上报本次广告展示的 eCPM(内部收益统计)
> 所属:Ad 组(前缀 `/api/v1/ad` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
> 所属:Ad 组(前缀 `/api/v1/ad` | 鉴权:Bearer | [← 返回 API 索引](../README.md)
## 入参
请求体:`EcpmReportIn`
@@ -1,6 +1,6 @@
# GET /api/v1/ad/pangle-callback — 穿山甲 GroMore 激励视频发奖回调(S2S)
> 所属:Ad 组(前缀 `/api/v1/ad` | 鉴权:**无 JWT,靠验签**(穿山甲 GroMore 服务器调用) | 限流:同 IP ≤300 次/分 | [← 返回 API 索引](./README.md)
> 所属:Ad 组(前缀 `/api/v1/ad` | 鉴权:**无 JWT,靠验签**(穿山甲 GroMore 服务器调用) | 限流:同 IP ≤300 次/分 | [← 返回 API 索引](../README.md)
>
> ⚠️ 我们客户端用 `useMediation(true)`(GroMore 融合),回调走 **GroMore 广告位层级**(规范见 supportcenter/26240),**不是**联盟代码位层级(5416)。两者密钥、响应格式都不同,别混。后台配置入口:**GroMore 聚合管理 → 搜广告位ID → 编辑 → 勾选「服务端激励回调」**(广告位层级配了就别再在代码位层级重复配,会冲突)。
>
@@ -1,6 +1,6 @@
# GET /api/v1/ad/reward-status — 今日看广告发奖进度
> 所属:Ad 组(前缀 `/api/v1/ad` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
> 所属:Ad 组(前缀 `/api/v1/ad` | 鉴权:Bearer | [← 返回 API 索引](../README.md)
## 入参
无(用户由 token 确定)。
@@ -1,6 +1,6 @@
# POST /api/v1/ad/test-grant — [仅本地联调]模拟穿山甲回调发奖
> 所属:Ad 组(前缀 `/api/v1/ad` | 鉴权:Bearer | 限流:同 IP ≤60 次/分 | [← 返回 API 索引](./README.md)
> 所属:Ad 组(前缀 `/api/v1/ad` | 鉴权:Bearer | 限流:同 IP ≤60 次/分 | [← 返回 API 索引](../README.md)
>
> ⚠️ **仅本地联调**,受 `AD_REWARD_TEST_GRANT_ENABLED` 开关控制,**生产必须关闭**(默认 False → 一律 404)。
+46
View File
@@ -0,0 +1,46 @@
# POST /api/v1/ad/watch-report — 上报激励视频观看时长
> 所属:Ad 组(前缀 `/api/v1/ad` | 鉴权:Bearer | 限流:同 IP ≤120 次/分 | [← 返回 API 索引](../README.md)
客户端在激励视频关闭(onAdClose)后上报本次实际观看秒数,服务端累计到当日总时长。当前产品只保留每日 500 次上限,DAILY_AD_WATCH_SECONDS_LIMIT=0 表示时长闸不启用;该接口仍保留用于旧客户端兼容和排查观看时长。
## 入参
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `seconds` | int | ✅ (≥0) | 本次观看秒数,服务端夹 [0, MAX_SINGLE_WATCH_SECONDS] |
Mock 入参:
```json
{
"seconds": 28
}
```
## 出参
响应 `200`:`WatchReportOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `watched_seconds_today` | int | 今日累计观看秒数 |
| `watch_seconds_limit` | int | 每日上限(秒);0 表示当前未启用时长闸 |
| `watch_seconds_remaining` | int | 今日剩余可观看秒数 |
Mock 出参:
```json
{
"watched_seconds_today": 128,
"watch_seconds_limit": 0,
"watch_seconds_remaining": 0
}
```
## 错误码
- `401` 未鉴权 / token 失效
- `422` `seconds` 缺或为负
## 说明
- 鉴权靠 Bearer`user_id` 取自 JWT(不信 body
- 后端只做累计 + 上限裁切,不据此发奖(发奖靠 S2S 回调)
- `watch_seconds_limit=0` 时客户端不据此拦截,按每日 500 次上限走
@@ -1,6 +1,6 @@
# Admin 看广告金币审计
> 所属:Admin 组(前缀 `/admin/api/ad-coin-audit`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](./README.md)
> 所属:Admin 组(前缀 `/admin/api/ad-coin-audit`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](../../README.md)
把「看视频赚金币」(`ad_reward_record`)和「比价信息流广告」(`ad_feed_reward_record`)两类发奖记录,用与**正式发奖完全相同**的公式 [`app/core/rewards.py` `calculate_ad_reward_coin`](../../app/core/rewards.py) 复算一遍 `expected_coin`,与实际入账的 `actual_coin` 对比,核对金币公式是否生效。**纯只读对账**,不发币、不改任何数据。
@@ -1,6 +1,6 @@
# Admin 广告收益报表
> 所属:Admin 组(前缀 `/admin/api/ad-revenue-report`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](./README.md)
> 所属:Admin 组(前缀 `/admin/api/ad-revenue-report`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](../../README.md)
**用户 × 日期 × 广告类型 × 我们的应用 × 我们的代码位** 聚合,回答「每个用户某天、每类广告(激励视频 / 信息流 / 历史 Draw)分别**看了多少条**、**收益多少**、按现算法**发了多少金币**、广告来自**哪个应用的哪个代码位**」。**纯只读**,不发币、不改数据,也**不改发奖逻辑**。
@@ -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(角色:任意已登录 admin | [← 返回 API 索引](../README.md)
## 入参(query
| 字段 | 类型 | 必填 | 默认 | 说明 |
@@ -1,6 +1,6 @@
# Admin 首页数据配置 — 三统计展示模式
> 所属:Admin 组(前缀 `/admin/api/dashboard-display` | 鉴权:Admin Bearer(改需 operator/super | [← 返回 API 索引](./README.md)
> 所属:Admin 组(前缀 `/admin/api/dashboard-display` | 鉴权:Admin Bearer(改需 operator/super | [← 返回 API 索引](../README.md)
配置客户端首页三个门面数字(帮助用户 / 完成比价 / 累计节省)的展示模式。每个指标可独立选 real/manual/random。用户侧读取见 [platform-stats](./platform-stats.md);表见 [ops_stat_config](../database/ops_stat_config.md)。
@@ -1,6 +1,6 @@
# Admin 首页轮播种子管理
> 所属:Admin 组(前缀 `/admin/api/marquee-seeds` | 鉴权:Admin Bearer(改需 operator/super) | [← 返回 API 索引](./README.md)
> 所属:Admin 组(前缀 `/admin/api/marquee-seeds` | 鉴权:Admin Bearer(改需 operator/super) | [← 返回 API 索引](../README.md)
管理首页轮播「真实+种子混播」的兜底种子。种子是「生成规则」:`masked_user` 可空(空→feed 随机合成名)、金额是 `[min_cents, max_cents]` 区间(feed 每次随机取值)。用户侧 feed 见 [platform-savings-feed](./platform-savings-feed.md);表见 [ops_marquee_seed](../database/ops_marquee_seed.md)。金额单位:分(前端 ÷100 显示元)。
@@ -1,6 +1,6 @@
# GET /admin/api/stats/overview — 大盘核心指标
> 所属:Admin·数据大盘 组(前缀 `/admin/api/stats` | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role | [← 返回 API 索引](./README.md)
> 所属:Admin·数据大盘 组(前缀 `/admin/api/stats` | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role | [← 返回 API 索引](../README.md)
## 入参
@@ -1,6 +1,6 @@
# POST /admin/api/admins — 创建管理员
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins` | 鉴权:Bearer admin_token(角色:super_admin | [← 返回 API 索引](./README.md)
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins` | 鉴权:Bearer admin_token(角色:super_admin | [← 返回 API 索引](../../README.md)
## 入参
**application/json**:
@@ -1,6 +1,6 @@
# PATCH /admin/api/admins/{admin_id} — 改角色/启停/重置密码
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins` | 鉴权:Bearer admin_token(角色:super_admin | [← 返回 API 索引](./README.md)
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins` | 鉴权:Bearer admin_token(角色:super_admin | [← 返回 API 索引](../../README.md)
## 入参
**路径参数**:
@@ -1,6 +1,6 @@
# GET /admin/api/admins — 管理员列表
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins` | 鉴权:Bearer admin_token(角色:super_admin | [← 返回 API 索引](./README.md)
> 所属:Admin·Accounts 组(前缀 `/admin/api/admins` | 鉴权:Bearer admin_token(角色:super_admin | [← 返回 API 索引](../../README.md)
## 入参
无(按 `id` 升序返回全部,无分页)
@@ -1,6 +1,6 @@
# POST /admin/api/auth/login — 管理员登录
> 所属:Admin·Auth 组(前缀 `/admin/api/auth` | 鉴权:无 | [← 返回 API 索引](./README.md)
> 所属:Admin·Auth 组(前缀 `/admin/api/auth` | 鉴权:无 | [← 返回 API 索引](../../README.md)
## 入参
**application/json**:
@@ -1,6 +1,6 @@
# GET /admin/api/auth/me — 当前管理员
> 所属:Admin·Auth 组(前缀 `/admin/api/auth` | 鉴权:Bearer admin_token(角色:任意已登录 admin | [← 返回 API 索引](./README.md)
> 所属:Admin·Auth 组(前缀 `/admin/api/auth` | 鉴权:Bearer admin_token(角色:任意已登录 admin | [← 返回 API 索引](../../README.md)
## 入参
无(身份取自 Header token
@@ -1,6 +1,6 @@
# POST /admin/api/feedbacks/{feedback_id}/handle — 标记反馈已处理
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks` | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过,`require_role("operator")` | [← 返回 API 索引](./README.md)
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks` | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过,`require_role("operator")` | [← 返回 API 索引](../../README.md)
## 入参
- 路径:`feedback_id`(int)
@@ -1,6 +1,6 @@
# GET /admin/api/feedbacks — 反馈工单列表(offset 分页 + 筛选/排序)
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks` | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 `require_role`,仅 `get_current_admin` | [← 返回 API 索引](./README.md)
> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks` | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 `require_role`,仅 `get_current_admin` | [← 返回 API 索引](../../README.md)
## 入参(query
| 字段 | 类型 | 必填 | 默认 | 说明 |
@@ -1,6 +1,6 @@
# POST /admin/api/users/{user_id}/cash — 手动增减/设值现金(带审计)
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](../../README.md)
主要用于给无现金用户直接发钱、好让其测试提现链路。
@@ -1,6 +1,6 @@
# POST /admin/api/users/{user_id}/coins — 手动增减/设值金币(带审计)
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](../../README.md)
## 入参
- 路径:`user_id`(int)
@@ -1,6 +1,6 @@
# GET /admin/api/users/{user_id} — 用户 360 详情
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role | [← 返回 API 索引](./README.md)
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role | [← 返回 API 索引](../../README.md)
## 入参
- 路径:`user_id`(int)
@@ -1,6 +1,6 @@
# POST /admin/api/users/{user_id}/status — 封禁/解封用户
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md)
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过) | [← 返回 API 索引](../../README.md)
## 入参
- 路径:`user_id`(int)
@@ -1,6 +1,6 @@
# GET /admin/api/users — 用户列表(筛选+排序+分页)
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role | [← 返回 API 索引](./README.md)
> 所属:Admin·用户 组(前缀 `/admin/api/users` | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role | [← 返回 API 索引](../../README.md)
## 入参(query
| 字段 | 类型 | 必填 | 默认 | 说明 |
@@ -1,6 +1,6 @@
# GET /admin/api/wallet/cash-transactions — 现金流水(游标分页)
> 所属:Admin·钱包 组(前缀 `/admin/api/wallet` | 鉴权:Bearer admin_token(角色:任意已登录管理员,仅需 `get_current_admin`,无 `require_role` | [← 返回 API 索引](./README.md)
> 所属:Admin·钱包 组(前缀 `/admin/api/wallet` | 鉴权:Bearer admin_token(角色:任意已登录管理员,仅需 `get_current_admin`,无 `require_role` | [← 返回 API 索引](../../README.md)
跨用户查询全量现金流水,可按 `user_id` / `biz_type` 过滤。游标分页(`id` 倒序)。金额单位一律为**分**。
@@ -1,6 +1,6 @@
# GET /admin/api/wallet/coin-transactions — 金币流水(游标分页)
> 所属:Admin·钱包 组(前缀 `/admin/api/wallet` | 鉴权:Bearer admin_token(角色:任意已登录管理员,仅需 `get_current_admin`,无 `require_role` | [← 返回 API 索引](./README.md)
> 所属:Admin·钱包 组(前缀 `/admin/api/wallet` | 鉴权:Bearer admin_token(角色:任意已登录管理员,仅需 `get_current_admin`,无 `require_role` | [← 返回 API 索引](../../README.md)
跨用户查询全量金币流水,可按 `user_id` / `biz_type` 过滤。游标分页(`id` 倒序)。
@@ -1,6 +1,6 @@
# POST /admin/api/withdraws/reconcile — 批量对账(扫超时 pending 单)
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws` | 鉴权:Bearer admin_token(角色:`finance``super_admin` 恒通过) | [← 返回 API 索引](./README.md)
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws` | 鉴权:Bearer admin_token(角色:`finance``super_admin` 恒通过) | [← 返回 API 索引](../../README.md)
扫描创建时间超过 `older_than_minutes` 分钟、仍为 `pending` 的提现单,逐单调微信查单并归一化(成功落 `success`;失败/已撤销则退款落 `failed`;查到 `WAIT_USER_CONFIRM` 视为用户放弃,撤单+退款)。用于解开"扣了款但转账没发起/没确认"的孤儿单。单笔失败不影响其余(内部 rollback 后继续,下轮再试)。
@@ -1,6 +1,6 @@
# POST /admin/api/withdraws/{out_bill_no}/refresh — 单笔提现重试查单
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws` | 鉴权:Bearer admin_token(角色:`finance``super_admin` 恒通过) | [← 返回 API 索引](./README.md)
> 所属: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`。已是终态的单直接返回、不再查。
@@ -1,6 +1,6 @@
# GET /admin/api/withdraws — 提现单列表(游标分页)
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws` | 鉴权:Bearer admin_token(角色:任意已登录管理员,列表为只读,仅需 `get_current_admin`,无 `require_role` | [← 返回 API 索引](./README.md)
> 所属:Admin·提现 组(前缀 `/admin/api/withdraws` | 鉴权:Bearer admin_token(角色:任意已登录管理员,列表为只读,仅需 `get_current_admin`,无 `require_role` | [← 返回 API 索引](../../README.md)
跨用户查询全量提现单,可按 `user_id` / `status` 过滤。游标分页(`id` 倒序)。
@@ -1,6 +1,6 @@
# POST /api/v1/auth/jverify-login — 极光一键登录
> 所属:Auth 组(前缀 `/api/v1/auth` | 鉴权:无 | [← 返回 API 索引](./README.md)
> 所属:Auth 组(前缀 `/api/v1/auth` | 鉴权:无 | [← 返回 API 索引](../README.md)
>
> 集成实现:见 [integrations/jiguang](../integrations/jiguang.md)(极光核验链路、RSA 解密策略、私钥配对踩坑)。
@@ -1,6 +1,6 @@
# POST /api/v1/auth/logout — 登出
> 所属:Auth 组(前缀 `/api/v1/auth` | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md)
> 所属:Auth 组(前缀 `/api/v1/auth` | 鉴权:Bearer access_token | [← 返回 API 索引](../README.md)
## 入参
@@ -1,6 +1,6 @@
# GET /api/v1/auth/me — 获取当前登录用户
> 所属:Auth 组(前缀 `/api/v1/auth` | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md)
> 所属:Auth 组(前缀 `/api/v1/auth` | 鉴权:Bearer access_token | [← 返回 API 索引](../README.md)
## 入参
无(身份取自 Header token
@@ -1,6 +1,6 @@
# POST /api/v1/auth/refresh — 刷新 token
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无(凭 body 里的 refresh_token | [← 返回 API 索引](./README.md)
> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无(凭 body 里的 refresh_token | [← 返回 API 索引](../README.md)
## 入参
@@ -1,6 +1,6 @@
# POST /api/v1/auth/sms/login — 手机号 + 验证码登录
> 所属:Auth 组(前缀 `/api/v1/auth` | 鉴权:无 | [← 返回 API 索引](./README.md)
> 所属:Auth 组(前缀 `/api/v1/auth` | 鉴权:无 | [← 返回 API 索引](../README.md)
>
> 集成实现:见 [integrations/sms](../integrations/sms.md)(验证码校验逻辑)。
@@ -1,6 +1,6 @@
# POST /api/v1/auth/sms/send — 发送短信验证码
> 所属:Auth 组(前缀 `/api/v1/auth` | 鉴权:无 | [← 返回 API 索引](./README.md)
> 所属:Auth 组(前缀 `/api/v1/auth` | 鉴权:无 | [← 返回 API 索引](../README.md)
>
> 集成实现:见 [integrations/sms](../integrations/sms.md)(mock 模式、频控、接真供应商 TODO)。
@@ -1,6 +1,6 @@
# POST /api/v1/compare/milestones/{milestone}/claim — 领取比价战绩里程碑奖励
> 所属:比价记录组(前缀 `/api/v1/compare` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
> 所属:比价记录组(前缀 `/api/v1/compare` | 鉴权:Bearer | [← 返回 API 索引](../README.md)
领取某一档(第 `milestone` 次)。⚠️ **当前不真发金币**(产品定,后续整体删除该功能):仍写
`comparison_milestone_claim`((user_id, milestone) 唯一)标记该档已领、**每档只能领一次**,但不调
@@ -1,6 +1,6 @@
# GET /api/v1/compare/milestones — 比价战绩里程碑进度
> 所属:比价记录组(前缀 `/api/v1/compare` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
> 所属:比价记录组(前缀 `/api/v1/compare` | 鉴权:Bearer | [← 返回 API 索引](../README.md)
福利页「记录比价战绩」的数据源。返回各档(第 1~6 次)解锁/领取状态。
@@ -1,6 +1,6 @@
# GET /api/v1/compare/records/{record_id} — 比价记录详情
> 所属:比价记录组(前缀 `/api/v1/compare` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
> 所属:比价记录组(前缀 `/api/v1/compare` | 鉴权:Bearer | [← 返回 API 索引](../README.md)
单条比价记录详情,在列表项基础上额外带 `raw_payload`(客户端上报的原始全量),供未来 UI 展示任意细节。
@@ -1,11 +1,11 @@
# POST /api/v1/compare/record — 上报一次比价结果(幂等)
> 所属:比价记录组(前缀 `/api/v1/compare` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
> 所属:比价记录组(前缀 `/api/v1/compare` | 鉴权:Bearer | [← 返回 API 索引](../README.md)
比价 `done` 帧后,客户端用**带 JWT 的通道**上报一条比价结果,落 `comparison_record` 表,作为「我的比价记录」数据源 + 用户级行为画像。
比价 `done` 帧后上报一条比价结果,落 `comparison_record` 表,作为「我的比价记录」数据源 + 用户级行为画像。
> ⚠️ 与不鉴权的透传端点 [`/api/v1/price/step`](./compare-price-step.md) 不同:那是转发壳,本接口按用户维度落库,**必须鉴权**
> 本轮只做 server 端;客户端在 done 帧后调本接口的改动另起一轮(见 [待办与技术债.md](../guides/待办与技术债.md) P1
> ⚠️ **灰度定位(2026-07)**:写 `comparison_record` 现以透传壳 `compare.py` 的**后端 harvest** 为主(帧0 建 `running` 行 → done/finalize 落终态,新客户端不再 POST)。本接口降级为**老客户端兼容**通道,与 harvest 按 `trace_id` reconcile(**success 不被降级**);新版覆盖够高后可下线本 POST
> 与软鉴权透传端点 [`/api/v1/price/step`](./compare-price-step.md) 不同:那是转发壳(顺带 harvest 落库),本接口按用户维度显式上报,**必须鉴权**
## 入参(JSON body
@@ -13,7 +13,7 @@
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `trace_id` | string | ✅ | — | pricebot 侧 trace_id。**幂等键**:同用户同 trace_id 重复上报覆盖、返回同一 id |
| `trace_id` | string | ✅ | — | 一次比价的唯一标识(app-server 帧0 签发)。**幂等键(trace_id 单列唯一)**同 trace_id 重复上报覆盖、返回同一 id;与 harvest 行按它 reconcile |
| `business_type` | string | ❌ | `food` | `food`(外卖,当前唯一接通) / `ecom`(电商) / `coupon`(领券) |
| `device_id` | string \| null | ❌ | null | 设备号 |
| `store_name` | string \| null | ❌ | null | 店铺名(外卖,来自 calibration.result |
@@ -1,6 +1,6 @@
# GET /api/v1/compare/records — 比价记录列表(游标分页)
> 所属:比价记录组(前缀 `/api/v1/compare` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
> 所属:比价记录组(前缀 `/api/v1/compare` | 鉴权:Bearer | [← 返回 API 索引](../README.md)
「我的比价记录」列表页数据源。按 `id` 倒序(最新在前)。
@@ -1,6 +1,6 @@
# GET /api/v1/compare/stats — 比价口径战绩(「我的」页省钱战绩卡)
> 所属:比价记录组(前缀 `/api/v1/compare` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
> 所属:比价记录组(前缀 `/api/v1/compare` | 鉴权:Bearer | [← 返回 API 索引](../README.md)
## 入参
无(用户由 token 确定)。
+67
View File
@@ -0,0 +1,67 @@
# 今日领券完成状态(completed-today 族)
> 所属:Coupon 组(前缀 `/api/v1/coupon` | 鉴权:无(按 device_id 判断) | [← 返回 API 索引](../README.md)
---
## GET /completed-today — 今天是否已完成整轮领券
判断这台设备今天是否跑到 done 帧。已完成 → 首页「去领取」卡置灰。
### 入参(query
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `device_id` | string | ✅ | 设备 ID(需与领券循环上报的一致) |
Mock 请求:
```
GET /api/v1/coupon/completed-today?device_id=android_abc123def456
```
### 出参
响应 `200`:`CouponCompletedTodayOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `completed` | bool | 今天是否已跑完整轮 |
Mock 出参:
```json
{"completed": true}
```
---
## POST /completed-today/reset — 重置今日已完成
删这台设备今天的 completion → `has_completed_today` 变 false,首页「去领取」卡恢复可点。开发设置用。
### 入参
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `device_id` | string | ✅ | 设备 ID |
| `package` | string | ❌ | App 包名(默认 "" |
| `user_id` | int \| null | ❌ | 登录用户 ID |
Mock 入参:
```json
{
"device_id": "android_abc123def456"
}
```
### 出参
```json
{"ok": true}
```
---
## 说明
- 判断维度 `device_id`(客户端两端都用 ANDROID_ID
- 用户决策 A 方案:到 done 即算完成,不管单券成败
- MVP 不鉴权
+128
View File
@@ -0,0 +1,128 @@
# 领券引导窗频控(prompt 族)
> 所属:Coupon 组(前缀 `/api/v1/coupon` | 鉴权:无(按 device_id 判断,MVP 阶段) | [← 返回 API 索引](../README.md)
领券引导窗频控:今天这台设备**这个 App** 已弹过/领过/拒过 → 不再弹。各 App 独立(美团弹过不压淘宝/京东)。
---
## GET /prompt/should-show — 是否还应弹引导窗
客户端切到外卖 App 时查。
### 入参(query
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `device_id` | string | ✅ | 设备 ID |
| `package` | string | ❌ | App 包名(默认 "",老客户端兼容全局态) |
Mock 请求:
```
GET /api/v1/coupon/prompt/should-show?device_id=android_abc&package=com.sankuai.meituan
```
### 出参
响应 `200`:`CouponPromptShouldShowOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `should_show` | bool | 今天是否还应弹引导窗 |
Mock 出参:
```json
{"should_show": true}
```
---
## POST /prompt/shown — 引导窗弹出即上报
客户端弹出引导窗那刻调 → 记一条今日 engagementshown),今天这个 App 不再自动弹。频控主判据管跨重装。
### 入参
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `device_id` | string | ✅ | 设备 ID |
| `package` | string | ✅ | App 包名 |
| `user_id` | int \| null | ❌ | 登录用户 ID |
Mock 入参:
```json
{
"device_id": "android_abc123def456",
"package": "com.sankuai.meituan",
"user_id": 42
}
```
### 出参
```json
{"ok": true}
```
---
## POST /prompt/dismiss — 用户拒绝/关闭引导窗
客户端点关闭时调 → 记 dismissed,今天这个 App 不再弹。
### 入参
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `device_id` | string | ✅ | 设备 ID |
| `package` | string | ❌ | App 包名(默认 "" |
| `user_id` | int \| null | ❌ | 登录用户 ID |
Mock 入参:
```json
{
"device_id": "android_abc123def456",
"package": "com.sankuai.meituan"
}
```
### 出参
```json
{"ok": true}
```
---
## POST /prompt/reset — 重置今日引导窗状态
删这台设备今天的 engagement → 今天又能弹。开发测频控用。
### 入参
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `device_id` | string | ✅ | 设备 ID |
| `package` | string | ❌ | App 包名(默认 "" |
| `user_id` | int \| null | ❌ | 登录用户 ID |
Mock 入参:
```json
{
"device_id": "android_abc123def456"
}
```
### 出参
```json
{"ok": true}
```
---
## 说明
- 频控按 `(device, package, 日)`,各 App 独立
- 弹出即占用今天一次(管跨重装),后续领取/拒绝再升级 type
- user_id 可选,登录态带上就一并记(资产留痕)
- MVP 不鉴权
+75
View File
@@ -0,0 +1,75 @@
# POST /api/v1/coupon/session — 领券任务流水上报
> 所属:Coupon 组(前缀 `/api/v1/coupon` | 鉴权:无(按 device_id/trace_id 区分) | [← 返回 API 索引](../README.md)
客户端两段上报一次领券流水(发起 / 收尾),按 `trace_id` upsert 到 `coupon_session`。供 admin「领券数据」看板算发起/完成数、耗时分位、机型维度。
## 入参
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `trace_id` | string | ✅ | 领券 trace 标识(同领券循环 step 的 trace_id |
| `device_id` | string | ✅ | 设备 ID(与领券循环一致) |
| `status` | string | ✅ | `started` / `completed` / `failed` / `abandoned` |
| `started_at_ms` | int | ✅ | 发起墙钟毫秒(客户端 `System.currentTimeMillis` |
| `user_id` | int \| null | ❌ | 登录用户 ID(未登录可空) |
| `platforms` | list[string] \| null | ❌ | 勾选的平台列表 |
| `origin_package` | string \| null | ❌ | 发起领券的 App 包名 |
| `device_model` | string \| null | ❌ | 机型(如 `24115RA8EC` |
| `rom` | string \| null | ❌ | ROM 信息(如 `MIUI 14.0.8` |
| `app_env` | string \| null | ❌ | 应用环境(`prod` / `test` |
| `elapsed_ms` | int \| null | ❌ | 全程耗时(ms),收尾帧必带 |
| `platform_elapsed` | dict \| null | ❌ | 各平台耗时(如 `{"meituan": 3200, "jd": 2800}`),收尾帧带 |
| `claimed_count` | int \| null | ❌ | 本场实际领到的券张数 |
| `trace_url` | string \| null | ❌ | trace 云端 URLdone 帧带) |
Mock 入参(started:
```json
{
"trace_id": "tr_20260703_a1b2c3d4",
"device_id": "android_abc123def456",
"status": "started",
"started_at_ms": 1719993600000,
"user_id": 42,
"platforms": ["meituan", "jd"],
"origin_package": "com.sankuai.meituan",
"device_model": "24115RA8EC",
"rom": "MIUI 14.0.8",
"app_env": "prod"
}
```
Mock 入参(completed:
```json
{
"trace_id": "tr_20260703_a1b2c3d4",
"device_id": "android_abc123def456",
"status": "completed",
"started_at_ms": 1719993600000,
"user_id": 42,
"platforms": ["meituan", "jd"],
"origin_package": "com.sankuai.meituan",
"device_model": "24115RA8EC",
"rom": "MIUI 14.0.8",
"app_env": "prod",
"elapsed_ms": 12500,
"platform_elapsed": {"meituan": 5200, "jd": 4800},
"claimed_count": 3,
"trace_url": "https://trace.shaguabijia.com/tr_20260703_a1b2c3d4"
}
```
## 出参
响应 `200`:
```json
{"ok": true}
```
## 错误码
- `422` 必填字段缺失或类型不符
## 说明
- 不鉴权(同领券循环 MVP,按 `device_id`/`trace_id`
- 写库失败不连累客户端(fire-and-forget,吞掉返回 ok
- 一次领券两段上报:发起(started)→ 收尾(completed/failed/abandoned
+33
View File
@@ -0,0 +1,33 @@
# GET /api/v1/coupon/stats — 累计领券数
> 所属:Coupon 组(前缀 `/api/v1/coupon` | 鉴权:Bearer | [← 返回 API 索引](../README.md)
「我的」页战绩卡「领取优惠券 X 张」数据源。该登录用户累计领到的券数(`SUM(claimed_count)`,与领券完成时给用户看的「本次领了 N 张」同源)。
**鉴权(CurrentUser)** — 区别于同文件不鉴权的 `/step` 透传与 `/prompt` 频控:个人战绩按 `user_id` 聚合,必须有登录态。
## 入参
无(`user_id` 从 JWT 取)。
## 出参
响应 `200`:`CouponStatsOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `coupon_count` | int | 累计领到的券张数 |
Mock 出参:
```json
{
"coupon_count": 42
}
```
## 错误码
- `401` 未鉴权 / token 失效
## 说明
- 口径:`SUM(claimed_count)` — 各成功领券记录 pricebot 展示张数之和
- 只算登录用户,按 `user_id` 聚合(非 device_id
@@ -1,6 +1,6 @@
# POST /api/v1/coupon/step — 一键领券任务步进(透传到 pricebot)
> 所属:Coupon 组(前缀 `/api/v1/coupon` | 鉴权:Bearer access_token(客户端契约;Server MVP 阶段不强校验) | [← 返回 API 索引](./README.md)
> 所属:Coupon 组(前缀 `/api/v1/coupon` | 鉴权:Bearer access_token(客户端契约;Server MVP 阶段不强校验) | [← 返回 API 索引](../README.md)
## 入参
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id``trace_id``step` 用于日志。
@@ -1,6 +1,6 @@
# 设备 / 无障碍存活监控(device 族)
> 所属:device 组(前缀 `/api/v1/device`,源 `app/api/v1/device.py`) | 鉴权:**全部 Bearer**(设备绑登录用户) | [← 返回 API 索引](./README.md)
> 所属:device 组(前缀 `/api/v1/device`,源 `app/api/v1/device.py`) | 鉴权:**全部 Bearer**(设备绑登录用户) | [← 返回 API 索引](../README.md)
>
> 落库:[`device_liveness`](../database/device_liveness.md) 表;后台 `heartbeat_monitor_worker` 据此检出心跳超时的设备并召回。设计见 spec `accessibility-liveness-push.md`(推送版)+ `accessibility-liveness-pull-prompt.md`(后置 pull 提醒版)。#65 新增。
@@ -1,6 +1,6 @@
# POST /api/v1/intent/recognize — 外卖比价 Phase 1 意图识别(透传到 pricebot)
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](./README.md)
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](../README.md)
## 入参
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id``trace_id``step` 用于日志。
@@ -1,6 +1,6 @@
# POST /api/v1/price/step — 外卖比价 Phase 2 步进(透传到 pricebot
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](./README.md)
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](../README.md)
## 入参
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id``trace_id``step` 用于日志。
+113
View File
@@ -0,0 +1,113 @@
# 比价意图多帧步进(intent/step + intent/precoupon/step
> 所属:Intent 组(前缀 `/api/v1`,外卖比价透传) | 鉴权:无(MVP 阶段不鉴权) | [← 返回 API 索引](../README.md)
透传到 pricebot-backend。请求体原样转发、不做 schema 校验。
---
## POST /intent/step — Phase 1 多帧意图识别(淘宝源)
淘宝源走多帧版意图识别(展开+滚动采集→提取):循环调用直到 done(done 帧顶层带 `result` + `calibration`)。其它源走单次 `/intent/recognize`
### 入参
透传 pricebot,客户端按 pricebot 协议组装。关键字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| `device_id` | string | 设备 ID |
| `trace_id` | string | 比价 trace 标识 |
| `frame` | int | 帧序号(0 起始) |
| `continue` | bool | 是否继续(pricebot 回传,客户端 next 调用时带上帧) |
| `image` | string | 当前帧截图 base64 |
| `platform` | string | 源平台 |
| `package` | string | 目标平台包名 |
| *(透传)* | | 其余字段由 pricebot 定义,本端点不做校验 |
Mock 入参(首帧):
```json
{
"device_id": "android_abc123def456",
"trace_id": "tr_20260703_e5f6g7h8",
"frame": 0,
"continue": true,
"image": "/9j/4AAQSkZJRgABAQEAYABgAAD/...(base64)",
"platform": "taobao",
"package": "com.sankuai.meituan"
}
```
### 出参
透传 pricebot 原始响应。通常包含 `action.command``continue` / `done`)、`action.params` 等。
Mock 出参:
```json
{
"trace_id": "tr_20260703_e5f6g7h8",
"frame": 0,
"continue": true,
"action": {
"command": "continue",
"params": {
"next_frame": 1,
"scroll_distance": 300
}
}
}
```
---
## POST /intent/precoupon/step — Phase 0 意图识别前先用券(美团源)
美团源平台「意图识别前先用券」多帧循环:客户端在调 `/intent/recognize` 之前先循环调本端点到 done`continue=false`)。订单页底部有「点击使用X红包」就自动选最大免费券用上,已用券/无券则首帧秒过。与 `/intent/step` 同属 intent 域,复用同一透传壳。
### 入参
`/intent/step`,透传 pricebot。
Mock 入参(首帧):
```json
{
"device_id": "android_abc123def456",
"trace_id": "tr_20260703_i9j0k1l2",
"frame": 0,
"continue": true,
"image": "/9j/4AAQSkZJRgABAQEAYABgAAD/...(base64)",
"platform": "meituan",
"package": "com.sankuai.meituan"
}
```
### 出参
透传 pricebot 原始响应。
Mock 出参(无券首帧秒过):
```json
{
"trace_id": "tr_20260703_i9j0k1l2",
"frame": 0,
"continue": false,
"action": {
"command": "done",
"params": {
"coupon_used": false,
"reason": "no_coupon_available"
}
}
}
```
---
## 错误码
- `502` pricebot 不可达或返回 5xx
- `400` 请求体不是合法 JSON
## 说明
- 两个端点是同域透传,区别是 pricebot 后端路由不同(`/api/intent/step` vs `/api/intent/precoupon/step`
- 一致性 hash 按 `trace_id` 路由到同一 pricebot 实例
- MVP 阶段不鉴权(待补 JWT
@@ -1,6 +1,6 @@
# 内部回写端点(internal 族,pricebot / 发布流程 → app-server)
> 所属:internal 组(前缀 `/internal`,源 `app/api/internal/`) | 鉴权:**`X-Internal-Secret` 头**(== `settings.INTERNAL_API_SECRET`) | [← 返回 API 索引](./README.md)
> 所属:internal 组(前缀 `/internal`,源 `app/api/internal/`) | 鉴权:**`X-Internal-Secret` 头**(== `settings.INTERNAL_API_SECRET`) | [← 返回 API 索引](../README.md)
**不是给客户端的接口**:不走用户 JWT。`§6` 是 app-server 透传给 pricebot,这里反过来——pricebot(及发布流程)把少量数据 server→server 回写 app-server 落库。
+100
View File
@@ -0,0 +1,100 @@
# 邀请绑定(bind + landing-track
> 所属:Invite 组(前缀 `/api/v1/invite` | 鉴权:bind 需 Bearer / landing-track 无需鉴权 | [← 返回 API 索引](../README.md)
---
## POST /bind — 绑定邀请人
把当前登录用户(被邀请人)绑定到某邀请码。支持三种归因路径:clipboard(首启读剪贴板)、manual(手动输入邀请码)、fingerprint(指纹兜底反查)。绑定成功双方各发 1 万金币。
### 入参
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `invite_code` | string \| null | ❌ | 邀请码;走指纹兜底时为空 |
| `channel` | string | ✅ | 归因来源:`clipboard` / `manual` / `fingerprint` |
| `fingerprint` | object \| null | ❌ | 指纹兜底时必传 |
| `fingerprint.screen` | string | - | 屏幕分辨率(如 `1080x2400` |
| `fingerprint.device_model` | string | - | 设备型号(如 `24115RA8EC` |
Mock 入参(clipboard 归因):
```json
{
"invite_code": "A3F8K2",
"channel": "clipboard",
"fingerprint": null
}
```
Mock 入参(指纹兜底):
```json
{
"invite_code": null,
"channel": "fingerprint",
"fingerprint": {
"screen": "1080x2400",
"device_model": "24115RA8EC"
}
}
```
### 出参
响应 `200`:`BindInviteOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `status` | string | `success` / `already_bound` / `invalid_code` / `self_invite` / `not_eligible` / `fp_not_found` |
| `coins_awarded` | int | 本次给当前用户(被邀请人)发的金币 |
| `message` | string | 给前端直接展示的文案 |
Mock 出参:
```json
{
"status": "success",
"coins_awarded": 10000,
"message": "邀请绑定成功"
}
```
---
## POST /landing-track — 落地页指纹采集
B 浏览器打开 `dl.html?ref=xxx` 时上报指纹(剪贴板归因失败时兜底)。**无需鉴权**(浏览器没 token)。
### 入参
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `ref` | string | ✅(4-16 位) | 邀请码(落地页 `?ref=` |
| `screen` | string | ❌ | 屏幕分辨率(如 `1080x2400` |
Mock 入参:
```json
{
"ref": "A3F8K2",
"screen": "1080x2400"
}
```
### 出参
响应 `200`:`LandingTrackOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `status` | string | `ok` / `invalid_code` / `no_ip` |
Mock 出参:
```json
{"status": "ok"}
```
---
## 说明
- `bind`: 绑定只在注册后首次有效(`not_eligible` = 已过新人期),自邀屏蔽
- `landing-track`: IP/UA 服务端从 HTTP 头自动拿,JS 无需上报
- 指纹反查窗口期由 `INVITE_FP_WINDOW_DAYS` 控制
+60
View File
@@ -0,0 +1,60 @@
# GET /api/v1/invite/invitees — 我邀请的人列表
> 所属:Invite 组(前缀 `/api/v1/invite` | 鉴权:Bearer | [← 返回 API 索引](../README.md)
分页查询当前用户成功邀请的人列表。邀请页小窗(取前几条)+ 完整列表页(分页加载)共用。
## 入参(query
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `limit` | int | ❌ | 每页条数(150,默认 20) |
| `offset` | int | ❌ | 偏移量(≥0,默认 0) |
Mock 请求:
```
GET /api/v1/invite/invitees?limit=5&offset=0
```
## 出参
响应 `200`:`InviteeListOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `items` | list[InviteeItem] | 被邀请人列表 |
| `items[].display_name` | string | 显示名(昵称 → 微信昵称 → 脱敏手机号,后端已兜底) |
| `items[].avatar_url` | string \| null | 头像 URLnull = 前端画默认色块 |
| `items[].coins` | int | 这次邀请给邀请人发的金币 |
| `items[].invited_at` | datetime | 邀请绑定时间(ISO 8601 UTC |
| `total` | int | 我邀请的总人数 |
| `has_more` | bool | 还有下一页吗 |
Mock 出参:
```json
{
"items": [
{
"display_name": "省钱小王",
"avatar_url": "/media/avatars/u2_f1e2d3c4b5a60708.jpg",
"coins": 10000,
"invited_at": "2026-06-28T14:30:00Z"
},
{
"display_name": "138****1234",
"avatar_url": null,
"coins": 10000,
"invited_at": "2026-07-01T09:15:00Z"
}
],
"total": 5,
"has_more": false
}
```
## 错误码
- `401` 未鉴权 / token 失效
## 说明
- 名字/头像降级兜底在后端算好:昵称 → 微信昵称 → 脱敏手机号
- `limit` 钳到 [1, 50]`offset` 钳到 ≥0
+48
View File
@@ -0,0 +1,48 @@
# GET /api/v1/invite/me — 我的邀请信息
> 所属:Invite 组(前缀 `/api/v1/invite` | 鉴权:Bearer | [← 返回 API 索引](../README.md)
获取当前用户的邀请码、分享链接、已邀人数、累计获得金币/奖励金,以及 7 天一轮的倒计时信息。
## 入参
无(`user_id` 从 JWT 取)。
## 出参
响应 `200`:`InviteInfoOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `invite_code` | string | 我的邀请码(6-8 位) |
| `share_url` | string | 落地页链接(含 `?ref=`),前端据此生成二维码 + 复制分享 |
| `invited_count` | int | 已成功邀请人数 |
| `coins_earned` | int | 累计从邀请获得的金币(v1 口径) |
| `reward_balance_cents` | int | v2 可提现邀请奖励金(分) |
| `reward_withdrawn_cents` | int | v2 累计提现成功的邀请奖励金(分) |
| `countdown_days_left` | int | v2 本轮剩余天数(7 天 1 轮) |
| `countdown_is_fresh_round` | bool | 是否刚进入新一轮(非首轮第 1 天) |
| `countdown_text` | string | 倒计时展示文案(前端直接显示,新轮含换行) |
Mock 出参:
```json
{
"invite_code": "A3F8K2",
"share_url": "https://app.shaguabijia.com/dl.html?ref=A3F8K2",
"invited_count": 5,
"coins_earned": 50000,
"reward_balance_cents": 3200,
"reward_withdrawn_cents": 1800,
"countdown_days_left": 4,
"countdown_is_fresh_round": false,
"countdown_text": "还剩 4 天"
}
```
## 错误码
- `401` 未鉴权 / token 失效
## 说明
- 首次调用自动生成邀请码(幂等)
- v2 奖励金与现金隔离(`reward_balance_cents` 独立于 `cash_balance_cents`
- 7 天 1 轮倒计时:新用户从注册日起算
@@ -1,6 +1,6 @@
# POST /api/v1/meituan/coupons — 券列表 / 搜索
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md)
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](../README.md)
>
> 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑)。
@@ -1,6 +1,6 @@
# POST /api/v1/meituan/feed — 首页推荐流(多 tab)
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md)
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](../README.md)
>
> 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑);离线库见 [database/meituan_coupon](../database/meituan_coupon.md)。
@@ -1,6 +1,6 @@
# POST /api/v1/meituan/referral-link — 换取推广链接
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md)
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](../README.md)
>
> 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑)。
@@ -1,6 +1,6 @@
# POST /api/v1/meituan/top-sales — 销量最高(离线库)
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md)
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](../README.md)
>
> 数据来自离线库 [database/meituan_coupon](../database/meituan_coupon.md);**不实时打美团**(美团搜索对销量排序支持差、且有 402 限流)。
+82
View File
@@ -0,0 +1,82 @@
# POST /api/v1/analytics/events — 批量上报埋点事件
> 所属:Analytics 组(前缀 `/api/v1/analytics`) | 鉴权:无(不强制登录,未登录态也要采集行为) | [← 返回 API 索引](../README.md)
批量接收新手引导(及后续)埋点,append 落 `analytics_event` 表。`user_id` 由客户端在 body 可选带上,不靠 Bearer。服务端补 `client_ip`X-Forwarded-For)与 `server_at`(接收时间)。
## 入参
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `device_id` | string | ✅(≤64 | 设备 ID |
| `user_id` | int \| null | ❌ | 登录用户 ID(未登录可空) |
| `sent_at` | int \| null | ❌ | 批次发送时间(epoch ms) |
| `oem` | string \| null | ❌ | 厂商(如 `Xiaomi` |
| `os` | string \| null | ❌ | 操作系统(如 `Android 14` |
| `model` | string \| null | ❌ | 机型(如 `24115RA8EC` |
| `app_ver` | string \| null | ❌ | App 版本 |
| `channel` | string \| null | ❌ | 渠道 |
| `events` | list[object] | ✅(1-200 条) | 事件数组 |
| `events[].event` | string | ✅(≤64 | 事件名(如 `onboarding_start` |
| `events[].client_ts` | int | ✅ | 端事件发生时间(epoch ms) |
| `events[].session_id` | string \| null | ❌ | 会话 ID |
| `events[].page` | string \| null | ❌ | 页面标识 |
| `events[].network` | string \| null | ❌ | 网络类型(如 `wifi` |
| `events[].props` | dict | ❌ | 事件属性(key-value |
Mock 入参:
```json
{
"device_id": "android_abc123def456",
"user_id": 42,
"sent_at": 1719993700000,
"oem": "Xiaomi",
"os": "Android 14",
"model": "24115RA8EC",
"app_ver": "0.1.5",
"channel": "official",
"events": [
{
"event": "onboarding_start",
"client_ts": 1719993600000,
"session_id": "sess_a1b2c3",
"page": "onboarding",
"network": "wifi",
"props": {"step": "1", "source": "fresh_install"}
},
{
"event": "onboarding_step_complete",
"client_ts": 1719993615000,
"session_id": "sess_a1b2c3",
"page": "onboarding",
"network": "wifi",
"props": {"step": "1", "duration_ms": "15000"}
}
]
}
```
## 出参
响应 `200`:`AnalyticsIngestOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `ok` | bool | 固定 `true` |
| `received` | int | 成功写入的条数 |
Mock 出参:
```json
{
"ok": true,
"received": 2
}
```
## 错误码
- `422` `events` 为空或超过 200 条 / 字段类型不符
## 说明
- `user_id` 不靠 JWT:未登录态也要采集行为(新手引导可能在登录前)
- 每批最多 200 条,建议客户端攒到一定量再批量上报
- `client_ts` 是端侧时间(客户端时钟),`server_at` 由服务端补(可靠时间轴)
@@ -1,6 +1,6 @@
# CPS 群发短链落地(cps-redirect 族)
> 所属:cps-redirect 组(**无前缀**,挂域名根;源 `app/api/v1/cps_redirect.py`) | 鉴权:**公网无鉴权**(群里任何人点都要能跳/能领) | [← 返回 API 索引](./README.md)
> 所属:cps-redirect 组(**无前缀**,挂域名根;源 `app/api/v1/cps_redirect.py`) | 鉴权:**公网无鉴权**(群里任何人点都要能跳/能领) | [← 返回 API 索引](../README.md)
>
> 落库:点击落 [`cps_click`](../database/cps_click.md)、微信落地页用户落 [`cps_wx_user`](../database/cps_wx_user.md);短链由 [`cps_link`](../database/cps_link.md) 解析。
+40
View File
@@ -0,0 +1,40 @@
# GET /api/v1/feedback/config — 反馈页二维码卡配置
> 所属:Feedback 组(前缀 `/api/v1/feedback` | 鉴权:Bearer | [← 返回 API 索引](../README.md)
运营后台配的反馈页「加群二维码」卡配置(开关 + 二维码图 + 三行文案)。客户端进反馈页时拉取,据此渲染整张「加群二维码」卡。
## 入参
无(`user_id` 从 JWT 取)。
## 出参
响应 `200`:`FeedbackQrConfigOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `enabled` | bool | 是否展示加群二维码卡 |
| `image_url` | string \| null | 二维码图片 URL(相对 `/media` 路径);空 → 客户端走本地兜底 |
| `title` | string | 卡片标题(如 `加入用户反馈群` |
| `group_name` | string | 群名称(如 `傻瓜比价用户群` |
| `subtitle` | string | 副标题/说明文案(如 `扫码加入,你的声音我们听得见` |
Mock 出参:
```json
{
"enabled": true,
"image_url": "/media/feedback_qr/qr_default.png",
"title": "加入用户反馈群",
"group_name": "傻瓜比价用户群",
"subtitle": "扫码加入,你的声音我们听得见"
}
```
## 错误码
- `401` 未鉴权 / token 失效
## 说明
- `image_url` 是相对路径,客户端按自己的 `BASE_URL` 拼绝对地址
- `enabled=false` 时客户端隐藏整张卡
- 配置在 admin 后台 `feedback_qr` 表维护
+71
View File
@@ -0,0 +1,71 @@
# GET /api/v1/feedback/records — 我的反馈历史
> 所属:Feedback 组(前缀 `/api/v1/feedback` | 鉴权:Bearer | [← 返回 API 索引](../README.md)
查询当前用户提交的反馈历史,支持按状态筛选。
## 入参(query
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `status` | string \| null | ❌ | 筛选状态:`pending` / `adopted` / `rejected`;不传 = 全部 |
Mock 请求:
```
GET /api/v1/feedback/records?status=adopted
```
## 出参
响应 `200`:`FeedbackRecordsOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `records` | list[FeedbackRecordOut] | 反馈记录列表 |
| `records[].id` | int | 反馈 ID |
| `records[].content` | string | 反馈正文 |
| `records[].scene` | string \| null | 问题场景(比价结果页反馈时带,如 `找错商品` |
| `records[].images` | list[string] | 截图 URL 列表 |
| `records[].status` | string | `pending` / `adopted` / `rejected` |
| `records[].reject_reason` | string \| null | 驳回原因 |
| `records[].reward_coins` | int \| null | 采纳后发的金币数 |
| `records[].admin_reply` | string \| null | 管理员回复 |
| `records[].created_at` | datetime | 提交时间 |
| `counts` | object | 三态计数(不受 status 筛选影响) |
| `counts.all` | int | 总数 |
| `counts.pending` | int | 待处理 |
| `counts.adopted` | int | 已采纳 |
| `counts.rejected` | int | 已驳回 |
Mock 出参:
```json
{
"records": [
{
"id": 56,
"content": "比价结果显示美团 28.5 元,但实际下单时涨到了 32 元",
"scene": "价格不一致",
"images": ["/media/feedback/u42_f1e2d3c4b5a60708.jpg"],
"status": "adopted",
"reject_reason": null,
"reward_coins": 500,
"admin_reply": "感谢反馈,已核实并修复",
"created_at": "2026-07-02T15:20:00Z"
}
],
"counts": {
"all": 3,
"pending": 1,
"adopted": 2,
"rejected": 0
}
}
```
## 错误码
- `400` 无效的 `status` 值(仅 `pending`/`adopted`/`rejected` 合法)
- `401` 未鉴权
## 说明
- `counts` 始终基于全量(不受 `status` 筛选影响),供前端筛选 chip
- `scene` 仅比价结果页反馈时带(区分普通反馈 vs 比价场景反馈)
@@ -1,6 +1,6 @@
# POST /api/v1/feedback — 提交反馈
> 所属:Feedback 组(前缀 `/api/v1/feedback` | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md)
> 所属:Feedback 组(前缀 `/api/v1/feedback` | 鉴权:Bearer access_token | [← 返回 API 索引](../README.md)
## 入参
**multipart/form-data**:
@@ -1,6 +1,6 @@
# GET /health — 健康检查
> 所属:Meta | 鉴权:无 | [← 返回 API 索引](./README.md)
> 所属:Meta | 鉴权:无 | [← 返回 API 索引](../README.md)
## 入参
+74
View File
@@ -0,0 +1,74 @@
# POST /api/v1/order/report — 上报归因订单
> 所属:Order 组(前缀 `/api/v1/order` | 鉴权:Bearer | [← 返回 API 索引](../README.md)
比价后 5 分钟内点链接下单、支付金额与比价价相差 ≤1 元时,客户端上报归因订单。落 `savings_record``source='compare'`),客户端幂等键防重。
## 入参
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `client_event_id` | string | ✅(≤64 | 客户端幂等键(UUID) |
| `platform` | string | ✅(≤32) | 平台展示名(如 `美团` |
| `platform_package` | string \| null | ❌ | 平台包名 |
| `pay_channel` | string | ✅(≤16 | 支付渠道(`wechat` / `alipay` |
| `compared_price_cents` | int | ✅(≥0) | 我们给出的比价价(分) |
| `paid_amount_cents` | int | ✅(≥0) | 实际支付金额(分) |
| `device_id` | string \| null | ❌ | 设备 ID |
| `shop_name` | string \| null | ❌ | 门店名(如 `肯德基宅急送(天北路店)` |
| `dishes` | list[string] | ❌ | 菜品名列表 |
| `original_price_cents` | int \| null | ❌ | 源平台原价(分),省额 = 原价 − 实付 |
| `source_platform_name` | string \| null | ❌ | 源平台展示名(如 `美团` |
| `source_deeplink` | string \| null | ❌ | 源平台重进链接(预留,本期只存不展示) |
Mock 入参:
```json
{
"client_event_id": "550e8400-e29b-41d4-a716-446655440000",
"platform": "美团",
"platform_package": "com.sankuai.meituan",
"pay_channel": "wechat",
"compared_price_cents": 2850,
"paid_amount_cents": 2800,
"device_id": "android_abc123def456",
"shop_name": "肯德基宅急送(天北路店)",
"dishes": ["香辣鸡腿堡套餐", "可口可乐(中)"],
"original_price_cents": 4200,
"source_platform_name": "美团",
"source_deeplink": null
}
```
## 出参
响应 `200`:`OrderReportOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 省钱记录 ID |
| `platform` | string | 平台 |
| `pay_channel` | string | 支付渠道 |
| `compared_price_cents` | int | 比价价(分) |
| `paid_amount_cents` | int | 实付金额(分) |
| `duplicated` | bool | 是否为重复上报(幂等命中) |
Mock 出参:
```json
{
"id": 1234,
"platform": "美团",
"pay_channel": "wechat",
"compared_price_cents": 2850,
"paid_amount_cents": 2800,
"duplicated": false
}
```
## 错误码
- `401` 未鉴权 / token 失效
- `422` 必填字段缺失或类型不符
## 说明
- 记账唯一真相表是 `savings_record``source='compare'`
- `client_event_id` 幂等防重(网络重试不重复记)
- 省额 = `original_price_cents paid_amount_cents`(若原价可用)
+81
View File
@@ -0,0 +1,81 @@
# GET /api/v1/report/records — 上报更低价记录列表
> 所属:Report 组(前缀 `/api/v1/report` | 鉴权:Bearer | [← 返回 API 索引](../README.md)
当前用户的上报更低价记录列表,支持按状态筛选。
## 入参(query
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `status` | string \| null | ❌ | 筛选状态:`pending` / `approved` / `rejected`;不传 = 全部 |
Mock 请求:
```
GET /api/v1/report/records?status=pending
```
## 出参
响应 `200`:`ReportRecordsOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `records` | list[ReportRecordOut] | 上报记录列表 |
| `records[].id` | int | 记录 ID |
| `records[].store_name` | string \| null | 门店名 |
| `records[].dish_summary` | string \| null | 菜品摘要(如 `香辣鸡腿堡套餐、可口可乐` |
| `records[].original_platform_id` | string \| null | 原最低价来源平台 ID |
| `records[].original_platform_name` | string \| null | 原最低价来源平台名 |
| `records[].original_price_cents` | int \| null | 原最低价(分) |
| `records[].reported_platform_id` | string | 上报平台标识 |
| `records[].reported_platform_name` | string | 上报平台名(如 `京东外卖` |
| `records[].reported_price_cents` | int | 上报更低价(分) |
| `records[].images` | list[string] | 截图 URL 列表 |
| `records[].status` | string | `pending` / `approved` / `rejected` |
| `records[].reject_reason` | string \| null | 驳回原因(rejected 时) |
| `records[].reward_coins` | int \| null | 通过后发的金币数 |
| `records[].created_at` | datetime | 提交时间 |
| `counts` | object | 四态计数(不受 status 筛选影响,供前端 chip 展示) |
| `counts.all` | int | 总数 |
| `counts.pending` | int | 审核中 |
| `counts.approved` | int | 已通过 |
| `counts.rejected` | int | 未通过 |
Mock 出参:
```json
{
"records": [
{
"id": 89,
"store_name": "肯德基宅急送(天北路店)",
"dish_summary": "香辣鸡腿堡套餐、可口可乐(中)",
"original_platform_id": "meituan-waimai",
"original_platform_name": "美团外卖",
"original_price_cents": 2850,
"reported_platform_id": "jd-waimai",
"reported_platform_name": "京东外卖",
"reported_price_cents": 1880,
"images": ["/media/price_report/u42_a1b2c3d4e5f6g7h8.jpg"],
"status": "pending",
"reject_reason": null,
"reward_coins": null,
"created_at": "2026-07-03T10:30:00Z"
}
],
"counts": {
"all": 3,
"pending": 1,
"approved": 1,
"rejected": 1
}
}
```
## 错误码
- `400` 无效的 `status` 值(仅 `pending`/`approved`/`rejected` 合法)
- `401` 未鉴权
## 说明
- `counts` 始终基于全量(不受 `status` 筛选影响),供前端筛选 chip 显示各状态数量
- 价格单位均为分(`*_cents`),客户端 ÷100 显示元
+57
View File
@@ -0,0 +1,57 @@
# POST /api/v1/report — 提交上报更低价
> 所属:Report 组(前缀 `/api/v1/report` | 鉴权:Bearer | [← 返回 API 索引](../README.md)
众包纠偏:用户发现比价记录中某平台有更低价格时提交上报。需附截图证明,原最低价由 `comparison_record_id` 反查(不信任客户端传的快照),提交价必须 < 原最低价。提交后 `status=pending`,人工审核通过后发奖。
## 入参
**multipart/form-data**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `comparison_record_id` | int | ✅ | 比价记录 ID |
| `reported_platform_id` | string | ✅ | 上报平台标识:`meituan-waimai` / `jd-waimai` / `taobao-shanguang` |
| `reported_price` | string | ✅ | 用户填的更低价(元,如 `23.5` |
| `images` | file[] | ✅(14 张) | 截图证明 |
Mock 入参(curl 示例):
```bash
curl -X POST https://app-api.shaguabijia.com/api/v1/report \
-H "Authorization: Bearer <access_token>" \
-F "comparison_record_id=5678" \
-F "reported_platform_id=jd-waimai" \
-F "reported_price=18.8" \
-F "images=@screenshot1.png" \
-F "images=@screenshot2.png"
```
## 出参
响应 `200`:`ReportSubmitOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | int | 上报记录 ID |
| `status` | string | 固定 `pending`(待审核) |
| `created_at` | datetime | 提交时间(ISO 8601 UTC |
Mock 出参:
```json
{
"id": 89,
"status": "pending",
"created_at": "2026-07-03T10:30:00Z"
}
```
## 错误码
- `400` 价格不合法(≤0 / 格式错)/ 上报价 ≥ 原最低价 / 图片问题(空/超 4 张/格式不对)/ 不支持的上报平台
- `401` 未鉴权
- `404` 比价记录不存在或不属于当前用户
## 说明
- 原最低价由 `comparison_record_id``ComparisonRecord.best_price_cents` 反查
- 校验(D):`reported_price_cents < original_price_cents`,否则 400
- 截图落盘 `MEDIA_ROOT/price_report/`,文件名随机防覆盖
- 发奖走人工审核(admin 后台操作),不在此端点
+45
View File
@@ -0,0 +1,45 @@
# POST /api/v1/trace/finalize — 比价 trace 收尾上云
> 所属:透传端点(前缀 `/api/v1`,外卖比价) | 鉴权:无(MVP 阶段不鉴权) | [← 返回 API 索引](../README.md)
透传到 pricebot-backend。用户终止 / Phase 1 未识别没走到 done 帧时,pricebot 没上云也没回传 `trace_url`。客户端收尾时打这个,pricebot 按 `trace_id` 一致性 hash 落到处理这条 trace 的同一进程(dir_cache 在那才能算对 trace 目录),打包上云返回 `{trace_url}`
## 入参
透传 pricebot,客户端按 pricebot 协议组装。关键字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| `device_id` | string | 设备 ID |
| `trace_id` | string | 比价 trace 标识 |
| *(透传)* | | 其余字段由 pricebot 定义,本端点不做校验 |
Mock 入参:
```json
{
"device_id": "android_abc123def456",
"trace_id": "tr_20260703_m3n4o5p6",
"reason": "user_cancelled"
}
```
## 出参
透传 pricebot 原始响应,通常包含 `trace_url`
Mock 出参:
```json
{
"trace_url": "https://trace.shaguabijia.com/tr_20260703_m3n4o5p6",
"ok": true
}
```
## 错误码
- `502` pricebot 不可达或返回 5xx
- `400` 请求体不是合法 JSON
## 说明
- 一致性 hash 按 `trace_id` 路由到同一 pricebot 实例(确保 dir_cache 命中)
- MVP 阶段不鉴权
- 与 `/intent/recognize``/price/step` 等同属外卖比价透传族
+43
View File
@@ -0,0 +1,43 @@
# GET /api/v1/platform/ad-config — 客户端广告配置
> 所属:Platform 组(前缀 `/api/v1/platform` | 鉴权:无 | [← 返回 API 索引](../README.md)
客户端启动 / 每场广告前拉取,缓存后用:穿山甲 `app_id` + 各位 ID + 各场景开关。不含验签密钥(密钥只在后端验 S2S 回调用)。
## 入参
无。
## 出参
响应 `200`:`AdConfigPublicOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `app_id` | string | 穿山甲应用 ID(改了需冷启才生效,SDK init 一次性读) |
| `reward_code_id` | string | 福利页激励视频位 |
| `compare_draw_code_id` | string | 比价 Draw 代码位 |
| `coupon_draw_code_id` | string | 领券 Draw 代码位(与比价共用同一位,靠 `feed_scene` 区分收益) |
| `reward_enabled` | bool | 福利激励视频开关 |
| `compare_ad_enabled` | bool | 比价广告开关 |
| `coupon_ad_enabled` | bool | 领券广告开关 |
| `withdrawal_ad_enabled` | bool | 提现激励视频开关(关 = 客户端直接放行提现) |
Mock 出参:
```json
{
"app_id": "5123456",
"reward_code_id": "104001",
"compare_draw_code_id": "104002",
"coupon_draw_code_id": "104002",
"reward_enabled": true,
"compare_ad_enabled": true,
"coupon_ad_enabled": true,
"withdrawal_ad_enabled": false
}
```
## 说明
- 空库回退默认值(= 客户端内置值,维持现状)
- 不含验签密钥(`m-key`),安全边界
- `coupon_draw_code_id``compare_draw_code_id` 通常同值,客户端按场景调不同端点区分
+39
View File
@@ -0,0 +1,39 @@
# GET /api/v1/platform/app-version — 最新 App 版本
> 所属:Platform 组(前缀 `/api/v1/platform` | 鉴权:无 | [← 返回 API 索引](../README.md)
客户端启动 / 手动检查更新时拉取。用 `latest_version_code` 与本机 `versionCode` 比;未配置(返回默认 0)时客户端视为已是最新。
## 入参
无。
## 出参
响应 `200`:`AppVersionOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `latest_version_code` | int | 最新版 versionCode0 = 未配置(无更新) |
| `latest_version_name` | string | 展示用版本号(如 `0.1.4` |
| `apk_url` | string | 下载链接(发车产出的永久版本化链接) |
| `update_note` | string | 更新说明,弹窗展示(如「修复了部分机型闪退问题」) |
| `min_supported_version_code` | int | 本机低于此版本 = 强制更新;0 = 不强更(全可选) |
| `apk_size_bytes` | int | 包大小(字节),展示「约 x MB」 |
Mock 出参:
```json
{
"latest_version_code": 42,
"latest_version_name": "0.1.5",
"apk_url": "https://cdn.shaguabijia.com/releases/app-v0.1.5.apk",
"update_note": "1. 修复了部分机型闪退问题\n2. 优化了比价速度\n3. 新增省钱大作战功能",
"min_supported_version_code": 35,
"apk_size_bytes": 18350080
}
```
## 说明
- 不鉴权(版本信息非敏感,检查更新可能在登录前)
- `latest_version_code = 0` → 无更新,客户端无需提示
- `min_supported_version_code > 本机 versionCode` → 强制更新弹窗(不可跳过)
+28
View File
@@ -0,0 +1,28 @@
# GET /api/v1/platform/flags — 客户端 Feature Flags
> 所属:Platform 组(前缀 `/api/v1/platform` | 鉴权:无 | [← 返回 API 索引](../README.md)
客户端拉取运营开关并缓存(app 启动 / 每场比价开始时刷新)。不鉴权:开关非敏感,且比价无障碍服务取值时未必有登录态。值来自 `app_config`admin 可改),空库回退默认。
## 入参
无。
## 出参
响应 `200`:`AppFlagsOut`
| 字段 | 类型 | 说明 |
|---|---|---|
| `comparing_ad_enabled` | bool | 比价/领券期是否展示信息流广告(远程 kill-switch |
Mock 出参:
```json
{
"comparing_ad_enabled": true
}
```
## 说明
- 客户端拉取后本地缓存,避免每次请求
- 空库回退默认值(false
@@ -1,6 +1,6 @@
# GET /api/v1/platform/stats — 首页三统计(全平台门面数字)
> 所属:Platform 组(前缀 `/api/v1/platform`) | 鉴权:**无**(登录前首页也要展示) | [← 返回 API 索引](./README.md)
> 所属:Platform 组(前缀 `/api/v1/platform`) | 鉴权:**无**(登录前首页也要展示) | [← 返回 API 索引](../README.md)
客户端首页顶部「帮助用户 / 完成比价 / 累计节省」三个平台级数字。每个指标的展示模式由运营后台配(见 [admin-dashboard-display](./admin-dashboard-display.md)),本接口只返回算好的结果值。
@@ -1,6 +1,6 @@
# GET /api/v1/platform/savings-feed — 首页轮播 feed(真实+种子混播)
> 所属:Platform 组(前缀 `/api/v1/platform` | 鉴权:**无** | [← 返回 API 索引](./README.md)
> 所属:Platform 组(前缀 `/api/v1/platform` | 鉴权:**无** | [← 返回 API 索引](../README.md)
客户端首页顶部「某用户 比价后节省 xx 元」滚动条数据源。全平台真实比价记录优先;不足时用运营配的种子([ops_marquee_seed](../database/ops_marquee_seed.md))补齐到 `limit` 条「混播」,保证轮播不空。
@@ -1,6 +1,6 @@
# GET /api/v1/savings/battle — 省钱战绩
> 所属:Savings 组(前缀 `/api/v1/savings` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
> 所属:Savings 组(前缀 `/api/v1/savings` | 鉴权:Bearer | [← 返回 API 索引](../README.md)
## 入参
无(用户由 token 确定)。
@@ -1,6 +1,6 @@
# GET /api/v1/savings/records — 省钱明细(游标分页)
> 所属:Savings 组(前缀 `/api/v1/savings` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
> 所属:Savings 组(前缀 `/api/v1/savings` | 鉴权:Bearer | [← 返回 API 索引](../README.md)
## 入参(query
@@ -1,6 +1,6 @@
# GET /api/v1/savings/summary — 累计帮你省了
> 所属:Savings 组(前缀 `/api/v1/savings` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
> 所属:Savings 组(前缀 `/api/v1/savings` | 鉴权:Bearer | [← 返回 API 索引](../README.md)
## 入参
无(用户由 token 确定)。
@@ -1,6 +1,6 @@
# POST /api/v1/signin — 执行今日签到
> 所属:Signin 组(前缀 `/api/v1/signin`,本接口 POST 到前缀本身) | 鉴权:Bearer | [← 返回 API 索引](./README.md)
> 所属:Signin 组(前缀 `/api/v1/signin`,本接口 POST 到前缀本身) | 鉴权:Bearer | [← 返回 API 索引](../README.md)
## 入参
无(用户由 token 确定)。
@@ -1,6 +1,6 @@
# GET /api/v1/signin/status — 今日签到状态 + 14 天档位
> 所属:Signin 组(前缀 `/api/v1/signin` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
> 所属:Signin 组(前缀 `/api/v1/signin` | 鉴权:Bearer | [← 返回 API 索引](../README.md)
## 入参
无(用户由 token 确定)。

Some files were not shown because too many files have changed in this diff Show More