Compare commits

...

22 Commits

Author SHA1 Message Date
guke 00404c8b45 feat(observe): 生产部署(单机)—— 硬化 compose + nginx 反代 + README(SSH隧道/保留期/专用账号)
- docker-compose.prod.yml: 只绑 127.0.0.1、命名卷、mem 1g、密码走 .env(:? 守卫)
- nginx/observe.shaguabijia.com.conf: 子域名反代 + IP白名单 + TLS + WS 透传
- .gitignore: 忽略 prod 密码 .env
- README: 生产部署步骤 + UI 访问两方案(SSH隧道推荐)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 14:38:26 +08:00
guke 964032d16f feat(observe): 现成 OpenObserve 仪表盘(QPS/P95/分位/错误率) + 导入说明
dashboard-api-metrics.json: v8 schema 4 面板,已在 v0.91.1 实例 test-import 验证可用。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 13:39:39 +08:00
guke 1f97b19d21 observe: 关停时补记残留丢弃计数(终审 minor:账不丢在关停期)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:53:34 +08:00
guke 8938b951fb feat(observe): OpenObserve 本地 compose + README(查询/仪表盘) + .env.example
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:17:28 +08:00
guke 0cf4cc706b feat(observe): main.py 挂中间件 + lifespan 启停上报 worker
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:14:14 +08:00
guke a5f63cb53c harden(observe): 防重复 start 泄漏 client + 关停丢余量注释 + 补 stop/失败存活测试
评审建议(Task4 code-quality):
- start 已启动则不重复建 client(避免泄漏旧连接池)
- 关停只发一批、超出丢弃,补注释说明 best-effort
- 补测: _run_loop 失败后存活 / stop drain+发最后一批+关 client

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:11:16 +08:00
guke 65e9b422fe fix(observe): 保留 asyncio.TimeoutError 捕获(3.10 兼容,撤销 UP041 自动改写)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:04:25 +08:00
guke 0350a140fe feat(observe): 加后台批量上报 worker(失败丢批不重试)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:03:06 +08:00
guke 95a4c5c3cc feat(observe): 加 RequestMetricsMiddleware(路由模板+状态码+耗时) 2026-07-06 11:33:13 +08:00
guke 822c2ca2a6 docs(plan): 去注解引号 + Task7 lint 只查改动文件(基线558既有ruff错)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:16:00 +08:00
guke 9354588ba2 style(observe): 去冗余注解引号 + 整理 import(ruff UP037/I001)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:14:52 +08:00
guke 6c624c3cea feat(observe): 加有界事件队列与 record_event(满则丢) 2026-07-06 11:04:00 +08:00
guke 70c5c4cf08 docs(plan): 拆分 observe.py 导入,避免 Task 2 提交时 ruff F401
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 10:58:28 +08:00
guke 804de02188 test(observe): 补 observe_configured 缺用户名分支断言
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 10:56:38 +08:00
guke 76cb981d84 feat(observe): 加 OBSERVE_* 配置与 observe_configured 门槛
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 10:48:27 +08:00
guke 20fa32e884 docs: OpenObserve 接口指标实现计划(分步 TDD)
7 个任务:配置门槛 → 事件队列/record_event → 中间件 → 上报 worker →
main 接线 → OpenObserve compose/README/.env → 端到端验证。每任务 TDD + 提交。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 10:40:24 +08:00
guke 29aa6fbff8 docs: OpenObserve 接口 QPS/耗时可观测设计 spec
方案 A:轻量自研 ASGI 中间件 + 后台 worker 批量直采到本地 Docker OpenObserve。
仅 app-server(8770);默认关、opt-in;队列满丢弃、上报失败不重试、跳过 /health。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 09:50:18 +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
chenshirui ee132aa93b 心跳掉线判定超时从10分钟调成1小时 (#107)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------

Co-authored-by: 陈世睿 <2839904623@qq.com>
Reviewed-on: #107
Co-authored-by: chenshirui <chenshirui@wonderable.ai>
Co-committed-by: chenshirui <chenshirui@wonderable.ai>
2026-07-02 22:09:54 +08:00
zhuzihao 4512b6ecac feat: 反馈加来源/场景/运营回复 + 提现类型筛选 (#105)
用户反馈后台:区分反馈类型(比价反馈/普通反馈)+ 审核可给用户留言;提现后台:按提现类型筛选。

- feedback 表加 source(profile/comparison)/scene/admin_reply + 迁移;提交接口 /api/v1/feedback
  接收 source/scene,来源判定显式 source 优先、否则据 scene 有无派生(有=comparison);
  /records 带回 scene + admin_reply。
- admin 反馈:列表加「反馈类型」筛选;采纳/拒绝支持存 admin_reply(给用户的回复,用户端可见);
  FeedbackOut 带出 source/scene/admin_reply。
- admin 提现:WithdrawOut 暴露 source,列表加「提现类型」筛选(coin_cash=福利页提现 / invite_cash=邀请提现)。
- 补 4 项测试:来源派生、回复存取、反馈按类型筛选、提现按类型筛选。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #105
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-02 19:50:14 +08:00
zhuzihao 4630fd017b feat(ad-revenue): 收益报表按一次比价/领券聚合(trace 合并)+ 微信昵称/签到时间修复 (#103)
- 报表主表改为「单次广告行为」:激励视频=一次观看一行;一次比价/领券=同一 trace_id(整场,
  无 trace 时兜底 ad_session_id)的多条发奖聚成一行,点开看逐条金币复算。中途跳转广告致浮层重弹、
  ad_session_id 变化也能按 trace 归一行。
- 信息流统一 Draw(业务已全切):新增 audit 的 feed_all scene,让「Draw 信息流」筛选覆盖历史误标的
  feed/NULL;聚合行 ad_type 统一 draw;_feed_rows 带出 trace_id 供聚合。
- 父行补 sub_rewards(组内逐条复算明细)、sub_count;row_revenue_yuan=该次发奖广告 eCPM 折算收益之和,
  仅供主表逐行展示、不进合计/趋势(避免与展示侧 total 重复计)。
- 用户 360 概览(AdminUserListItem)返回 wechat_nickname,供收益详情抽屉显示微信昵称。
- 金币记录:签到来自 coin_transaction(存北京 wall-clock),组装时转 UTC 与广告记录统一,修抽屉签到
  时间多 8 小时;签到窗口边界 +8h 对齐;顺带补 _window_conds 引用却从未定义的 _as_utc_naive
  (自定义区间会 NameError)。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #103
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-02 08:57:15 +08:00
wuqi 70c2349950 feat(migration): 添加合并 jd_cps_order_fields 和 coupon_session_origin_package 的迁移文件 (#102)
Reviewed-on: #102
Co-authored-by: wuqi <wuqi@wonderable.ai>
Co-committed-by: wuqi <wuqi@wonderable.ai>
2026-07-01 20:21:34 +08:00
135 changed files with 5024 additions and 265 deletions
+17 -1
View File
@@ -29,7 +29,7 @@ JG_REQUEST_TIMEOUT_SEC=15
# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)=====
HEARTBEAT_MONITOR_ENABLED=true
HEARTBEAT_TIMEOUT_MINUTES=10
HEARTBEAT_TIMEOUT_MINUTES=60
HEARTBEAT_SCAN_INTERVAL_SEC=60
# ===== 短信 (mock 模式) =====
@@ -137,3 +137,19 @@ PANGLE_REPORT_SECURITY_KEY=
# GroMore AppId(报表 site_id 维度)→ 应用环境;默认取现网两个应用,按需覆盖。
PANGLE_REPORT_SITE_ID_PROD=5830519
PANGLE_REPORT_SITE_ID_TEST=5832303
# ===== 可观测(OpenObserve 接口指标)=====
# 采集每个接口 QPS + 耗时 + 错误率,批量直采到 OpenObserve(本地 Docker,见 deploy/openobserve/)。
# 默认关;开启需 ENABLED=true 且填 USER/PASSWORD(与 docker-compose 里 root 账号一致)。
# 未开/缺凭证 → 中间件透传、worker 不启动,整套 no-op,不影响业务。
OBSERVE_ENABLED=false
OBSERVE_ENDPOINT=http://localhost:5080
OBSERVE_ORG=default
OBSERVE_STREAM=app_requests
OBSERVE_USER=admin@shaguabijia.local
OBSERVE_PASSWORD=Complexpass#123
# 进阶(一般不用改):攒批间隔秒 / 单批最大条数 / 有界队列上限(满则丢) / 上报超时秒
OBSERVE_FLUSH_INTERVAL_SEC=5
OBSERVE_BATCH_MAX=200
OBSERVE_QUEUE_MAX=10000
OBSERVE_TIMEOUT_SEC=5
@@ -0,0 +1,26 @@
"""merge jd_cps_order_fields and coupon_session_origin_package heads
Revision ID: 761ef181ce7c
Revises: coupon_session_origin_package, jd_cps_order_fields
Create Date: 2026-07-01 13:52:16.068808
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '761ef181ce7c'
down_revision: Union[str, Sequence[str], None] = ('coupon_session_origin_package', 'jd_cps_order_fields')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+48
View File
@@ -0,0 +1,48 @@
"""feedback 加反馈来源/场景/运营回复(source / scene / admin_reply)
admin「用户反馈」页要展示并筛选「反馈类型」(比价反馈 / 普通反馈),并支持审核时给用户留言:
- source: 反馈来源入口(profile=「我的」页 / comparison=比价结果页)。NOT NULL,
旧数据 + 普通反馈默认 profile(server_default)。加索引供 admin 按类型筛选。
- scene: 比价反馈的问题场景(找错商品/优惠不对…),普通反馈为 NULL。
- admin_reply: 运营给用户的回复留言(用户端可见),随「我的反馈」历史下发。
均为新增列(SQLite 原生支持 add_column);downgrade 的 drop_column 在 SQLite 走 batch 兜底。
Revision ID: feedback_type_reply
Revises: 761ef181ce7c
Create Date: 2026-07-02 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "feedback_type_reply"
down_revision: str | Sequence[str] | None = "761ef181ce7c"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"feedback",
sa.Column(
"source",
sa.String(length=16),
nullable=False,
server_default="profile",
),
)
op.add_column("feedback", sa.Column("scene", sa.String(length=32), nullable=True))
op.add_column("feedback", sa.Column("admin_reply", sa.String(length=256), nullable=True))
op.create_index("ix_feedback_source", "feedback", ["source"])
def downgrade() -> None:
with op.batch_alter_table("feedback") as batch_op:
batch_op.drop_index("ix_feedback_source")
batch_op.drop_column("admin_reply")
batch_op.drop_column("scene")
batch_op.drop_column("source")
+7 -1
View File
@@ -136,12 +136,16 @@ def _feed_scene_matches(rec: AdFeedRewardRecord, scene: str | None) -> bool:
"""该信息流记录是否落入请求的展示筛选 scene。
- scene=="feed":ad_type in ("feed", NULL)(旧数据 NULL 视为 feed,向后兼容)
- scene=="draw":ad_type=="draw"
- scene=="feed_all":所有信息流(feed/draw/NULL 都要)——业务已全切 Draw 信息流,收益报表把「Draw 信息流」
当作整个信息流口径(含历史误标 feed/NULL),用它避免筛选漏历史。
- scene 为 None:不筛(两类都要)。
"""
if scene == "feed":
return rec.ad_type in (None, "feed")
if scene == "draw":
return rec.ad_type == "draw"
if scene == "feed_all":
return True
return True
@@ -184,6 +188,7 @@ def _feed_rows(
"record_id": rec.id,
"user_id": rec.user_id,
"ad_session_id": rec.ad_session_id,
"trace_id": rec.trace_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at,
@@ -209,6 +214,7 @@ def _feed_rows(
"record_id": rec.id,
"user_id": rec.user_id,
"ad_session_id": rec.ad_session_id,
"trace_id": rec.trace_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at,
@@ -241,7 +247,7 @@ def audit_rows(
rows: list[dict] = []
if scene in (None, "reward_video"):
rows.extend(_reward_video_rows(db, date=date, user_id=user_id))
if scene in (None, "feed", "draw"):
if scene in (None, "feed", "draw", "feed_all"):
rows.extend(_feed_rows(db, date=date, user_id=user_id, scene=scene))
return rows
+105 -35
View File
@@ -3,9 +3,11 @@
只读。每行 = 一次广告事件(不再按用户聚合):
- **激励视频**:一次观看 = 1 条展示(ad_ecpm)+ 1 条发奖(ad_reward),按 ad_session_id 合并成一行,
直接给出 eCPM / 收益 + 状态 / 应发 / 实发 / 一致;点开看该条金币复算因子。
- **信息流**:轮播每条展示各一行(impressionId 各自独立);整场发奖(ad_feed_reward,client_event_id)
与逐条展示无法对应,单独成「纯发奖」行。
- 兜底:有展示无发奖(中途关 / 未达发奖)、有发奖无展示(未上报 eCPM)都各自成行。
- **信息流(比价/领券)**:一次比价 / 一次领券 = 一条整场发奖(ad_feed_reward)一行,给出 eCPM /
发奖金币 + 应发 / 实发 / 一致;点开看金币复算因子。⚠️ draw 的逐条展示(ad_ecpm,impressionId 各自
独立、与整场发奖无公共键、无法归到「哪一次」)**不再单独占行**(2026-07 按「一次比价/领券放一块」调整)——
其展示数 / eCPM / 预估收益仍进全量统计(合计 / 趋势 / 分类大盘 / 穿山甲对照),只是主表不逐条铺开。
- 兜底:激励视频有展示无发奖(中途关 / 未达发奖)、有发奖无展示(未上报 eCPM)仍各自成行。
展示与收益来自 ad_ecpm_record(收益 = eCPM元 ÷ 1000);应发 / 实发金币复用金币审计逐条复算
(ad_audit.audit_rows,与正式发奖同一公式口径,不另写公式)。合计与对账在全量上统计,
@@ -58,14 +60,6 @@ def _date_range(date_from: str, date_to: str) -> list[str]:
_AUDIT_SCENES = {"reward_video", "feed", "draw"}
def _event_ad_type(row: dict) -> str:
"""纯发奖事件行的 ad_type:信息流行用 audit 带回的真实 ad_type(feed/draw),回退 feed;
激励视频行恒 reward_video。不再用 scene 硬映射,避免把 draw 丢成 feed。"""
if row["scene"] == "reward_video":
return "reward_video"
return row.get("ad_type") or "feed"
# 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。
_REWARD_DETAIL_KEYS = (
"record_id", "created_at", "status", "ecpm", "ecpm_factor", "units",
@@ -108,9 +102,14 @@ def ad_revenue_report(
# 同时保留全量列表,未被展示合并的成「纯发奖」事件。
reward_by_session: dict[tuple[int, str], list[dict]] = {}
all_reward_rows: list[dict] = []
# 报表 ad_type 直接当 audit scene 用(取值一致);未知/无效 ad_type 不取发奖行。draw 在此被
# 正确传成 scene="draw",audit 会按 ad_type 筛出 Draw 发奖,不再丢成 feed
audit_scene = ad_type if ad_type in _AUDIT_SCENES else None
# 报表 ad_type audit scene:reward_video/feed 直传;**draw(前端「Draw 信息流」)映射成 feed_all**
# ——业务已全切 Draw,把「Draw 信息流」当作整个信息流口径(含历史误标 feed/NULL),否则筛选会漏历史
if ad_type == "draw":
audit_scene = "feed_all"
elif ad_type in _AUDIT_SCENES:
audit_scene = ad_type
else:
audit_scene = None
if ad_type is None or audit_scene is not None:
for d in _date_range(date_from, date_to):
for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene):
@@ -140,7 +139,10 @@ def ad_revenue_report(
)
if user_id is not None:
stmt = stmt.where(AdEcpmRecord.user_id == user_id)
if ad_type is not None:
if ad_type == "draw":
# draw = 所有信息流展示(业务已全 Draw,含历史误标 feed);展示行只进统计,不占主表行
stmt = stmt.where(AdEcpmRecord.ad_type.in_(["draw", "feed"]))
elif ad_type is not None:
stmt = stmt.where(AdEcpmRecord.ad_type == ad_type)
for rec in db.execute(stmt).scalars():
rwd = _pop_reward(rec.user_id, rec.ad_session_id)
@@ -165,6 +167,8 @@ def ad_revenue_report(
),
"adn": rec.adn,
"slot_id": rec.slot_id,
"sub_rewards": [],
"sub_count": 1,
}
if rwd is not None:
ev.update({
@@ -184,33 +188,88 @@ def ad_revenue_report(
})
events.append(ev)
# 3) 未被展示合并的发奖行 → 「纯发奖」事件(信息流整场发奖 / 有发奖无展示)。
# 收益恒 0(收益只算展示侧,避免与展示行重复计)。
# 3) 未被展示合并的发奖行 → 事件:
# - 激励视频(reward_video):逐条成「纯发奖」事件(每次一个 ad_session_id;有发奖无展示等)。
# - 信息流(feed/draw):同一次比价/领券的多条广告共享**整场 ad_session_id**(客户端整场复用),
# 按 (user_id, ad_session_id) 聚成**一次比价 / 一次领券**父事件;sub_rewards 为组内逐条明细,
# 应发/实发取组内合计;业务已全 Draw → 类型统一 "draw"。session 缺失(极少旧数据)各自单独成组。
feed_groups: dict[tuple[int, str], list[dict]] = {}
for row in all_reward_rows:
if row["record_id"] in used_reward_ids:
continue
if row["scene"] == "reward_video":
events.append({
"event_key": f"rwd-{row['record_id']}",
"report_date": row["_report_date"],
"user_id": row["user_id"],
"ad_type": "reward_video",
"feed_scene": row.get("feed_scene"),
"app_env": row.get("app_env"),
"our_code_id": row.get("our_code_id"),
"created_at": row["created_at"],
"hour": _cn_hour(row["created_at"]) if by_hour else None,
"has_impression": False,
"impressions": 0,
"ecpm": row["ecpm"],
"revenue_yuan": 0.0,
"adn": None,
"slot_id": None,
"has_reward": True,
"status": row["status"],
"expected_coin": int(row["expected_coin"]),
"actual_coin": int(row["actual_coin"]),
"matched": bool(row["matched"]),
"reward_detail": _reward_detail(row),
"sub_rewards": [],
"sub_count": 1,
})
else:
# 聚合单位 = 一次完整比价/领券流程:优先用 trace_id(比价带 comparisonTraceId、领券带 sessionTraceId,
# 整个流程不变;即使中途点广告致浮层关闭重弹、ad_session_id 变了,trace_id 仍不变 → 全流程聚成一行)。
# 无 trace_id(历史领券未上报 / 旧数据)回退整场 ad_session_id;再无则 record_id 各自成组、不误并。
grp_key = row.get("trace_id") or row.get("ad_session_id") or f"_rid-{row['record_id']}"
feed_groups.setdefault((row["user_id"], grp_key), []).append(row)
# 信息流分组 → 「一次比价 / 一次领券」父事件(收益恒 0:收益只算展示侧,避免与展示行重复计)。
for (uid, grp_key), group in feed_groups.items():
group.sort(key=lambda r: (r["created_at"], r["record_id"]))
rep = group[-1] # 代表条(最新一条):时间/场景/应用/代码位取它
expected_sum = sum(int(g["expected_coin"]) for g in group)
actual_sum = sum(int(g["actual_coin"]) for g in group)
# 父行 eCPM:组内各条 eCPM(分)均值(展示用,各条不同);无有效值则取代表条
ecpm_fens = [rewards.parse_ecpm_fen(g["ecpm"]) for g in group if g.get("ecpm")]
avg_ecpm = str(round(sum(ecpm_fens) / len(ecpm_fens))) if ecpm_fens else rep.get("ecpm")
# 主表逐行显示用:这次发奖广告的预估收益之和(发奖侧 eCPM 折算,钳顶同展示侧)。只放进
# row_revenue_yuan 给主表逐行展示,不进 revenue_yuan/合计/趋势——避免与展示侧 total 重复计。
row_revenue = round(sum(
min(rewards.parse_ecpm_yuan(g["ecpm"]), rewards.AD_ECPM_MAX_FEN / 100.0) / 1000.0
for g in group if g.get("ecpm")
), 6)
events.append({
"event_key": f"rwd-{row['record_id']}",
"report_date": row["_report_date"],
"user_id": row["user_id"],
"ad_type": _event_ad_type(row),
"feed_scene": row.get("feed_scene"),
"app_env": row.get("app_env"),
"our_code_id": row.get("our_code_id"),
"created_at": row["created_at"],
"hour": _cn_hour(row["created_at"]) if by_hour else None,
"event_key": f"feedgrp-{uid}-{grp_key}",
"report_date": rep["_report_date"],
"user_id": uid,
"ad_type": "draw", # 业务已全切 Draw 信息流,聚合行统一 draw
"feed_scene": rep.get("feed_scene"),
"app_env": rep.get("app_env"),
"our_code_id": rep.get("our_code_id"),
"created_at": rep["created_at"],
"hour": _cn_hour(rep["created_at"]) if by_hour else None,
"has_impression": False,
"impressions": 0,
"ecpm": row["ecpm"],
"ecpm": avg_ecpm,
"revenue_yuan": 0.0,
"row_revenue_yuan": row_revenue,
"adn": None,
"slot_id": None,
"has_reward": True,
"status": row["status"],
"expected_coin": int(row["expected_coin"]),
"actual_coin": int(row["actual_coin"]),
"matched": bool(row["matched"]),
"reward_detail": _reward_detail(row),
"status": rep["status"], # 代表状态(逐条见展开)
"expected_coin": expected_sum,
"actual_coin": actual_sum,
"matched": all(bool(g["matched"]) for g in group),
"reward_detail": None,
"sub_rewards": [_reward_detail(g) for g in group],
"sub_count": len(group),
})
# 「场景」作为全局筛选(与 user_id/ad_type 一致):同时作用于明细、合计与 daily/hourly 趋势。
@@ -331,9 +390,20 @@ def ad_revenue_report(
is_today = date_from == date_to == rewards.cn_today().isoformat()
dau = admin_stats.today_dau(db) if is_today else None
# 主表「逐行」= 单次广告行为(2026-07 按「一次比价/领券放一块」聚合):激励视频 = 一次观看一行(展示+发奖
# 按 ad_session_id 合并);一次比价 / 一次领券 = 该次整场多条广告按 ad_session_id 聚成一行(展开看逐条)。
# 信息流(draw/feed)的逐条展示(ad_ecpm,impressionId 各自独立、与整场发奖无公共键)不再单独占行
# ——其展示数 / eCPM / 预估收益已计入上面的全量统计(total_*、daily / hourly、type_stats、穿山甲对照),
# 只是主表不逐条铺开;逐条明细在父行展开里看(sub_rewards)。合计 / 趋势 / 分类大盘均基于全量 events,
# 不受此过滤影响;total / 分页只作用于主表行。
main_rows = [
e for e in events
if not (e["ad_type"] in ("draw", "feed") and e["has_impression"] and not e["has_reward"])
]
return {
"total": len(events),
"truncated": len(events) > offset + limit,
"total": len(main_rows),
"truncated": len(main_rows) > offset + limit,
"total_impressions": total_impressions,
"total_revenue_yuan": total_revenue_yuan,
# 穿山甲后台收益合计(元):预估 revenue + 收益Api;非全量视图(带 user/类型/场景过滤)或无数据为 None。
@@ -347,5 +417,5 @@ def ad_revenue_report(
"hourly": hourly,
"type_stats": type_stats,
"dau": dau,
"items": events[offset:offset + limit],
"items": main_rows[offset:offset + limit],
}
+4 -1
View File
@@ -65,16 +65,19 @@ def review_feedback(
reward_coins: int | None = None,
reject_reason: str | None = None,
review_note: str | None = None,
admin_reply: str | None = None,
commit: bool = True,
) -> Feedback:
"""审核反馈:置 adopted/rejected + 记录奖励/原因/审核人/审核时间。
"""审核反馈:置 adopted/rejected + 记录奖励/原因/回复留言/审核人/审核时间。
发金币(wallet.grant_coins)由 router 在同一事务里调,确保状态、金币流水、审计一起提交。
admin_reply=给用户的回复留言(用户端可见),与 review_note(内部备注)区分。
"""
feedback.status = status
feedback.reward_coins = reward_coins
feedback.reject_reason = reject_reason
feedback.review_note = review_note
feedback.admin_reply = admin_reply
feedback.reviewed_by_admin_id = reviewed_by_admin_id
feedback.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None)
if commit:
+31 -7
View File
@@ -390,6 +390,7 @@ def list_all_withdraw_orders(
*,
user_id: int | None = None,
status: str | None = None,
source: str | None = None,
keyword: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
@@ -409,6 +410,9 @@ def list_all_withdraw_orders(
stmt = stmt.where(WithdrawOrder.user_id == user_id)
if status:
stmt = stmt.where(WithdrawOrder.status == status)
# 提现类型:coin_cash(福利页提现)/ invite_cash(邀请提现);None=全部
if source:
stmt = stmt.where(WithdrawOrder.source == source)
kw = (keyword or "").strip()
if kw:
@@ -534,6 +538,7 @@ def list_feedbacks(
db: Session,
*,
status: str | None = None,
source: str | None = None,
user_id: int | None = None,
content: str | None = None,
created_from: datetime | None = None,
@@ -543,13 +548,15 @@ def list_feedbacks(
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[Feedback], int | None, int]:
"""反馈工单列表。支持 状态 / 用户ID / 内容模糊 / 提交时间范围 筛选,按 id·提交时间排序。
**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),代价是翻页期间
数据变动可能错位一条——admin 低频场景可接受。返回 (items, next_cursor, total),total 供页码分页。
created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。"""
"""反馈工单列表。支持 状态 / 反馈类型(source) / 用户ID / 内容模糊 / 提交时间范围 筛选,
按 id·提交时间排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受。返回 (items, next_cursor, total),
total 供页码分页。created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。"""
stmt = select(Feedback)
if status:
stmt = stmt.where(Feedback.status == status)
if source:
stmt = stmt.where(Feedback.source == source)
if user_id is not None:
stmt = stmt.where(Feedback.user_id == user_id)
if content and content.strip():
@@ -806,6 +813,12 @@ def get_user_overview(db: Session, user_id: int) -> dict | None:
}
def _as_utc_naive(value: datetime) -> datetime:
"""窗口入参 → UTC naive(= _as_utc 去时区),与库里按 naive UTC 存取的 created_at 同口径比较。
历史遗留:_window_conds 一直引用本函数却未定义(自定义区间会 NameError),此处补上。"""
return _as_utc(value).replace(tzinfo=None)
def _window_conds(col, date_from: datetime | None, date_to: datetime | None) -> list:
"""把 [date_from, date_to] 转成对 col(created_at)的过滤条件;都为 None = 全量(注册至今)。"""
conds = []
@@ -895,6 +908,13 @@ def user_reward_stats(
}
def _cn_wall_to_utc(dt: datetime) -> datetime:
"""coin_transaction 存的是北京 wall-clock(naive,见 wallet.grant_coins「存北京 wall-clock」),转成 UTC naive,
与广告表(func.now() UTC)统一 —— 让本函数按同一绝对时刻排序、且前端 apiTime(把无时区时间当 UTC 再 +8 展示)
口径一致;否则签到会比实际多显示 8 小时(北京时间又被 +8)。"""
return dt.replace(tzinfo=rewards.CN_TZ).astimezone(timezone.utc).replace(tzinfo=None)
def user_coin_records(
db: Session,
user_id: int,
@@ -915,6 +935,9 @@ def user_coin_records(
offset = max(cursor or 0, 0)
fetch = offset + limit + 1
rows: list[dict] = []
# coin_transaction 存北京 wall-clock(其余表存 UTC);签到窗口边界 +8h 对齐北京,过滤/计数才不偏移 8 小时
signin_from = date_from + timedelta(hours=8) if date_from is not None else None
signin_to = date_to + timedelta(hours=8) if date_to is not None else None
for rec in db.execute(
select(AdRewardRecord)
@@ -958,7 +981,7 @@ def user_coin_records(
.where(
CoinTransaction.user_id == user_id,
CoinTransaction.biz_type == "signin",
*_window_conds(CoinTransaction.created_at, date_from, date_to),
*_window_conds(CoinTransaction.created_at, signin_from, signin_to),
)
.order_by(CoinTransaction.created_at.desc())
.limit(fetch)
@@ -966,7 +989,8 @@ def user_coin_records(
rows.append({
"source": "signin",
"source_label": "签到",
"created_at": rec.created_at,
# 北京 wall-clock → UTC,与广告记录统一(前端 apiTime 会 +8 回北京展示,不然签到会多 8 小时)
"created_at": _cn_wall_to_utc(rec.created_at),
"ecpm": None,
"coin": rec.amount,
})
@@ -992,7 +1016,7 @@ def user_coin_records(
+ _count(
CoinTransaction, CoinTransaction.user_id == user_id,
CoinTransaction.biz_type == "signin",
*_window_conds(CoinTransaction.created_at, date_from, date_to),
*_window_conds(CoinTransaction.created_at, signin_from, signin_to),
)
)
return rows[offset:offset + limit], (offset + limit if has_more else None), total
+7
View File
@@ -36,6 +36,8 @@ def _ensure_pending(fb: Feedback) -> None:
def list_feedbacks(
db: AdminDb,
status: Annotated[str | None, Query()] = None,
# 反馈类型筛选:profile(普通反馈)/ comparison(比价反馈);None=全部
source: Annotated[str | None, Query(pattern="^(profile|comparison)$")] = None,
user_id: Annotated[int | None, Query()] = None,
content: Annotated[str | None, Query(max_length=100)] = None,
created_from: Annotated[datetime | None, Query()] = None,
@@ -48,6 +50,7 @@ def list_feedbacks(
items, next_cursor, total = queries.list_feedbacks(
db,
status=status,
source=source,
user_id=user_id,
content=content,
created_from=created_from,
@@ -101,6 +104,7 @@ def approve_feedback(
status="adopted",
reward_coins=payload.reward_coins,
review_note=payload.note,
admin_reply=payload.reply,
reviewed_by_admin_id=admin.id,
commit=False,
)
@@ -123,6 +127,7 @@ def approve_feedback(
"after": "adopted",
"reward_coins": payload.reward_coins,
"note": payload.note,
"reply": payload.reply,
},
ip=get_client_ip(request),
commit=False,
@@ -152,6 +157,7 @@ def reject_feedback(
status="rejected",
reject_reason=payload.reason,
review_note=payload.note,
admin_reply=payload.reply,
reviewed_by_admin_id=admin.id,
commit=False,
)
@@ -166,6 +172,7 @@ def reject_feedback(
"after": "rejected",
"reason": payload.reason,
"note": payload.note,
"reply": payload.reply,
},
ip=get_client_ip(request),
commit=False,
+3
View File
@@ -50,6 +50,8 @@ def list_withdraws(
db: AdminDb,
user_id: Annotated[int | None, Query()] = None,
status: Annotated[str | None, Query()] = None,
# 提现类型筛选:coin_cash(福利页提现)/ invite_cash(邀请提现);None=全部
source: Annotated[str | None, Query(pattern="^(coin_cash|invite_cash)$")] = None,
keyword: Annotated[str | None, Query(max_length=100)] = None,
date_from: Annotated[datetime | None, Query()] = None,
date_to: Annotated[datetime | None, Query()] = None,
@@ -69,6 +71,7 @@ def list_withdraws(
db,
user_id=user_id,
status=status,
source=source,
keyword=keyword,
date_from=date_from,
date_to=date_to,
+14
View File
@@ -94,6 +94,11 @@ class AdRevenueRow(BaseModel):
impressions: int = Field(..., description="本行展示条数:有展示=1 / 纯发奖=0(供日汇总、趋势图复用)")
ecpm: str | None = Field(None, description="eCPM 原始值(分/千次);展示行取展示值,纯发奖行取发奖采用值")
revenue_yuan: float = Field(..., description="本次展示预估收益(元)= eCPM元 ÷ 1000;纯发奖行=0")
row_revenue_yuan: float | None = Field(
None,
description="主表逐行展示用的预估收益(元):一次比价/领券聚合行=该次发奖广告 eCPM 折算之和;"
"其它行为空(前端回退取 revenue_yuan)。不进合计/趋势,避免与展示侧重复计",
)
adn: str | None = Field(None, description="实际填充 ADN 子渠道(pangle/gdt…);纯发奖行为空")
slot_id: str | None = Field(None, description="底层 mediation rit(非我们配置的广告位 ID);纯发奖行为空")
# ── 发奖侧 ──
@@ -106,6 +111,15 @@ class AdRevenueRow(BaseModel):
None,
description="发奖复算明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致);点行展开下钻用,纯展示为空",
)
sub_rewards: list[AdRevenueRecord] = Field(
default_factory=list,
description="一次比价/领券聚合行的组内逐条发奖明细(同一整场 ad_session_id 的多条广告);"
"点行展开渲染多行。激励视频/纯展示行为空(单条看 reward_detail)",
)
sub_count: int = Field(
1,
description="本行聚合的发奖条数:一次比价/领券=该次广告条数(≥1);激励视频/纯展示=1",
)
class AdRevenueReportOut(BaseModel):
+9 -1
View File
@@ -15,11 +15,17 @@ class FeedbackOut(BaseModel):
user_id: int
content: str
contact: str
# 反馈来源入口:profile(「我的」页)/ comparison(比价结果页)——admin 展示/筛选「反馈类型」
source: str = "profile"
# 比价反馈的问题场景(找错商品/优惠不对…);普通反馈为 None
scene: str | None = None
images: list[str] | None = None
status: str
reject_reason: str | None = None
reward_coins: int | None = None
review_note: str | None = None
# 运营给用户的回复留言(用户端可见,采纳/未采纳都可填)
admin_reply: str | None = None
reviewed_by_admin_id: int | None = None
reviewed_at: datetime | None = None
created_at: datetime
@@ -39,12 +45,14 @@ class FeedbackApproveRequest(BaseModel):
le=FEEDBACK_REWARD_MAX_COINS,
description="采纳后发放金币数",
)
note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注")
note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注(内部)")
reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见")
class FeedbackRejectRequest(BaseModel):
reason: str = Field(min_length=1, max_length=256, description="未采纳原因,用户端可见")
note: str | None = Field(default=None, max_length=256, description="运营内部审核备注")
reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见")
class FeedbackSummary(BaseModel):
+1
View File
@@ -17,6 +17,7 @@ class AdminUserListItem(BaseModel):
status: str
debug_trace_enabled: bool = False
wechat_openid: str | None = None
wechat_nickname: str | None = None
created_at: datetime
last_login_at: datetime
+2
View File
@@ -41,6 +41,8 @@ class WithdrawOrderOut(BaseModel):
user_id: int
out_bill_no: str
amount_cents: int
# 提现类型:coin_cash(福利页提现,金币兑换的现金)/ invite_cash(邀请提现,邀请奖励金)
source: str = "coin_cash"
user_name: str | None = None # 提现实名(审核核对 + 打款用)
status: str
wechat_state: str | None = None
+24 -1
View File
@@ -31,7 +31,18 @@ router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
_MAX_IMAGES = 6
_CONTENT_MAX = 200
_CONTACT_MAX = 128
_SCENE_MAX = 32
_VALID_RECORD_STATUS = {"pending", "adopted", "rejected"}
_VALID_SOURCES = {"profile", "comparison"}
def _resolve_source(source: str, scene: str) -> str:
"""反馈来源:客户端显式传的 source 优先(profile / comparison);未传或非法时,
按 scene 有无派生——比价结果页反馈才带 scene,故 scene 非空即视作 comparison。"""
src = source.strip().lower()
if src in _VALID_SOURCES:
return src
return "comparison" if scene.strip() else "profile"
def _app_status(db_status: str) -> str:
@@ -46,10 +57,12 @@ def _record_out(fb) -> FeedbackRecordOut:
return FeedbackRecordOut(
id=fb.id,
content=fb.content,
scene=getattr(fb, "scene", None),
images=fb.images or [],
status=_app_status(fb.status),
reject_reason=getattr(fb, "reject_reason", None),
reward_coins=getattr(fb, "reward_coins", None),
admin_reply=getattr(fb, "admin_reply", None),
created_at=fb.created_at,
)
@@ -61,6 +74,11 @@ async def submit_feedback(
content: str = Form(...),
# 原型改版后客户端不再采集联系方式;保留字段以兼容旧端 + 后续可能复用,默认空串。
contact: str = Form(default=""),
# 反馈来源入口:profile(「我的」页)/ comparison(比价结果页)。新端显式传;旧端不带 →
# 空串 → 按 scene 有无派生。admin 据此展示/筛选「反馈类型」。
source: str = Form(default=""),
# 比价结果页反馈的「问题场景」(找错商品/优惠不对…);普通反馈不带 → 空串 → 存 NULL。
scene: str = Form(default=""),
# 提交端环境快照(admin 反馈页展示「提交版本号」「机型OS版本」);旧端不带 → 空串 → 存 NULL。
app_version: str = Form(default=""),
device_model: str = Form(default=""),
@@ -76,6 +94,8 @@ async def submit_feedback(
raise HTTPException(status_code=400, detail="反馈内容过长")
if len(contact) > _CONTACT_MAX:
raise HTTPException(status_code=400, detail="联系方式过长")
scene = scene.strip()[:_SCENE_MAX]
resolved_source = _resolve_source(source, scene)
files = [f for f in (images or []) if f is not None and f.filename]
if len(files) > _MAX_IMAGES:
@@ -91,10 +111,13 @@ async def submit_feedback(
fb = feedback_repo.create_feedback(
db, user_id=user.id, content=content, contact=contact, images=urls,
source=resolved_source, scene=scene or None,
app_version=app_version.strip(), device_model=device_model.strip(),
rom_name=rom_name.strip(), android_version=android_version.strip(),
)
logger.info("feedback id=%d user_id=%d images=%d", fb.id, user.id, len(urls))
logger.info(
"feedback id=%d user_id=%d source=%s images=%d", fb.id, user.id, resolved_source, len(urls)
)
return FeedbackOut.model_validate(fb)
+26 -1
View File
@@ -68,7 +68,7 @@ class Settings(BaseSettings):
# 无障碍保护存活监控后台任务(pull 后置检测;本期不接推送)
HEARTBEAT_MONITOR_ENABLED: bool = True # 总开关
HEARTBEAT_TIMEOUT_MINUTES: int = 10 # 多久没心跳算掉线(≈3 个客户端心跳周期)
HEARTBEAT_TIMEOUT_MINUTES: int = 60 # 多久没心跳算掉线(1 小时,避免短暂离线误判被杀)
HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期
# ===== 短信 =====
@@ -317,6 +317,31 @@ class Settings(BaseSettings):
return []
return [o.strip() for o in self.CORS_ALLOW_ORIGINS.split(",") if o.strip()]
# ===== 可观测(OpenObserve 接口指标)=====
# 采集每个接口的 QPS + 耗时 + 错误率,批量直采到 OpenObserve(本地 Docker)。
# 默认关(prod 安全):未开启 → 中间件透传、worker 不启动,整套 no-op。
# 开启需 ENABLED=true 且 ENDPOINT/USER/PASSWORD 齐全(见 observe_configured)。
OBSERVE_ENABLED: bool = False
OBSERVE_ENDPOINT: str = "http://localhost:5080" # OpenObserve base URL
OBSERVE_ORG: str = "default" # 组织名
OBSERVE_STREAM: str = "app_requests" # stream 名(首次上报自动建)
OBSERVE_USER: str = "" # Basic auth 邮箱
OBSERVE_PASSWORD: str = "" # Basic auth 密码/token
OBSERVE_FLUSH_INTERVAL_SEC: float = 5.0 # worker 最长攒批间隔
OBSERVE_BATCH_MAX: int = 200 # 单批最大事件数
OBSERVE_QUEUE_MAX: int = 10000 # 有界队列上限,满则丢
OBSERVE_TIMEOUT_SEC: float = 5.0 # 上报 HTTP 超时
@property
def observe_configured(self) -> bool:
"""观测上报可用 = 总开关开 且 endpoint/账号/密码齐全(缺则整套 no-op)。"""
return bool(
self.OBSERVE_ENABLED
and self.OBSERVE_ENDPOINT
and self.OBSERVE_USER
and self.OBSERVE_PASSWORD
)
@property
def is_prod(self) -> bool:
return self.APP_ENV == "prod"
+110
View File
@@ -0,0 +1,110 @@
"""接口指标埋点:有界事件队列 + 纯 ASGI 中间件。
每个 HTTP 请求测总耗时、抓路由模板 + 状态码,非阻塞塞进有界队列;由 observe_worker
后台批量上报到 OpenObserve。请求路径上无任何 I/O。未配置观测时中间件直接透传。
"""
from __future__ import annotations
import asyncio
import os
import time
from starlette.routing import Match
from app.core.config import settings
# 不采集的路径(纯噪音):健康检查。
_SKIP_PATHS = frozenset({"/health"})
# 未匹配路由(404/扫描器)归一到此,防维度爆炸。
_UNMATCHED = "__unmatched__"
# service 字段:与 logging.py 同源(LOG_SERVICE_NAME),默认 app-server。
_SERVICE = os.getenv("LOG_SERVICE_NAME", "app-server")
# 有界事件队列(懒创建,见 get_queue):首次取用时在运行中的 loop 里建,避免 import 期
# 无 loop 的边角问题;put_nowait/get_nowait 不需运行中的 loop → 可在无 loop 下测试。
_queue: asyncio.Queue[dict] | None = None
# 队列满时的丢弃计数,worker 定期取出打日志。
_dropped = 0
def get_queue() -> asyncio.Queue[dict]:
"""返回全局有界事件队列(懒创建)。测试可 monkeypatch 模块级 _queue 换成小队列。"""
global _queue
if _queue is None:
_queue = asyncio.Queue(maxsize=settings.OBSERVE_QUEUE_MAX)
return _queue
def take_dropped() -> int:
"""取出并清零累计丢弃数(供 worker 打点)。"""
global _dropped
n, _dropped = _dropped, 0
return n
def record_event(event: dict) -> None:
"""非阻塞入队;队列满则丢弃当前事件并计数。永不抛异常、永不阻塞请求。"""
global _dropped
try:
get_queue().put_nowait(event)
except asyncio.QueueFull:
_dropped += 1
def _resolve_route(scope) -> str:
"""从 scope 取路由模板(如 /things/{tid})。优先 scope['route'](现代 Starlette
路由后写入);取不到则手动匹配一次(老版本兜底);仍无 → __unmatched__(404/扫描器)。"""
route = scope.get("route")
path = getattr(route, "path", None)
if path:
return path
app_ = scope.get("app")
router = getattr(app_, "router", None)
for candidate in getattr(router, "routes", []):
try:
match, _ = candidate.matches(scope)
except Exception: # noqa: BLE001 - 匹配兜底,任一路由异常不影响整体
continue
if match == Match.FULL and getattr(candidate, "path", None):
return candidate.path
return _UNMATCHED
class RequestMetricsMiddleware:
"""纯 ASGI 中间件:测每个 http 请求耗时,记 method/route/status/duration。
放在最外层(main.py 里 CORS 之后 add),测到含 CORS 的完整耗时。未配置观测 → 透传。
"""
def __init__(self, app) -> None:
self.app = app
async def __call__(self, scope, receive, send) -> None:
if scope["type"] != "http" or not settings.observe_configured:
await self.app(scope, receive, send)
return
if scope.get("path") in _SKIP_PATHS:
await self.app(scope, receive, send)
return
start = time.perf_counter()
status_holder = {"status": 500} # 下游异常未产出 response 时兜底 500
async def send_wrapper(message) -> None:
if message["type"] == "http.response.start":
status_holder["status"] = message["status"]
await send(message)
try:
await self.app(scope, receive, send_wrapper)
finally:
duration_ms = (time.perf_counter() - start) * 1000.0
record_event({
"_timestamp": int(time.time() * 1_000_000), # µs,OpenObserve 时间列
"service": _SERVICE,
"env": settings.APP_ENV,
"method": scope.get("method", ""),
"route": _resolve_route(scope),
"status": status_holder["status"],
"duration_ms": round(duration_ms, 3),
})
+128
View File
@@ -0,0 +1,128 @@
"""接口指标后台上报 worker:批量 drain 事件队列 → POST 到 OpenObserve。
对齐 heartbeat_monitor_worker 等的 start_*/stop_* 形态。best-effort 遥测:catch 全部
异常,上报失败直接丢批不重试。未配置观测 → start 返回 None(不启动),整套 no-op。
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import httpx
from app.core.config import settings
from app.core.observe import get_queue, take_dropped
logger = logging.getLogger("shagua.observe")
# 上报用的 httpx client,start 时建、stop 时关。
_client: httpx.AsyncClient | None = None
async def _collect_batch() -> list[dict]:
"""等到 ≥1 条(或到 flush 间隔)后,连抽到 BATCH_MAX 条或抽空。超时且空 → 返回 []。"""
queue = get_queue()
batch: list[dict] = []
try:
first = await asyncio.wait_for(
queue.get(), timeout=settings.OBSERVE_FLUSH_INTERVAL_SEC
)
except asyncio.TimeoutError: # noqa: UP041 - 3.10 兼容:该版 wait_for 抛的 asyncio.TimeoutError ≠ 内置 TimeoutError
return batch
batch.append(first)
while len(batch) < settings.OBSERVE_BATCH_MAX:
try:
batch.append(queue.get_nowait())
except asyncio.QueueEmpty:
break
return batch
async def _post_batch(client: httpx.AsyncClient, batch: list[dict]) -> None:
"""POST 一批事件到 OpenObserve 的 _json ingest 端点。非 2xx 仅告警。"""
url = f"/api/{settings.OBSERVE_ORG}/{settings.OBSERVE_STREAM}/_json"
resp = await client.post(url, json=batch)
if resp.status_code >= 300:
logger.warning(
"observe ingest failed status=%s body=%s",
resp.status_code,
resp.text[:200],
)
async def _run_loop(client: httpx.AsyncClient) -> None:
try:
while True:
batch = await _collect_batch()
dropped = take_dropped()
if dropped:
logger.warning("observe dropped %d events (queue full)", dropped)
if not batch:
continue
try:
await _post_batch(client, batch)
except Exception: # noqa: BLE001 - best-effort 遥测,失败丢批不重试、不退出
logger.warning(
"observe post batch failed, dropped %d events",
len(batch),
exc_info=True,
)
except asyncio.CancelledError:
logger.info("observe worker stopped")
raise
def start_observe_worker() -> asyncio.Task | None:
"""启动上报 worker。未配置观测 → 返回 None(no-op)。约定每进程只调一次(lifespan)。"""
global _client
if not settings.observe_configured:
return None
if _client is not None:
# 约定 start 每进程只调一次;已启动则不重复建 client(避免泄漏旧连接池)。
logger.warning("observe worker already started; ignoring duplicate start")
return None
_client = httpx.AsyncClient(
base_url=settings.OBSERVE_ENDPOINT,
auth=(settings.OBSERVE_USER, settings.OBSERVE_PASSWORD),
timeout=settings.OBSERVE_TIMEOUT_SEC,
)
logger.info(
"observe worker started endpoint=%s org=%s stream=%s",
settings.OBSERVE_ENDPOINT,
settings.OBSERVE_ORG,
settings.OBSERVE_STREAM,
)
return asyncio.create_task(_run_loop(_client), name="observe-worker")
async def stop_observe_worker(task: asyncio.Task | None) -> None:
"""收尾:cancel worker → best-effort 发最后一批 → 关 client。"""
global _client
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
dropped = take_dropped() # 收口:补记最后一个 flush 窗口累计的丢弃数,不让账丢在关停期
if dropped:
logger.warning("observe dropped %d events (queue full) before shutdown", dropped)
if _client is not None:
# worker 已停,安全 drain 剩余并 best-effort 发最后一批(短超时,不卡关停);
# 超过一批(BATCH_MAX)的剩余直接丢,不做多轮 flush(best-effort,关停从速)。
try:
queue = get_queue()
final: list[dict] = []
while len(final) < settings.OBSERVE_BATCH_MAX:
try:
final.append(queue.get_nowait())
except asyncio.QueueEmpty:
break
if final:
await asyncio.wait_for(
_post_batch(_client, final), timeout=settings.OBSERVE_TIMEOUT_SEC
)
except Exception: # noqa: BLE001 - 关停期尽力而为,失败忽略
pass
await _client.aclose()
_client = None
+10
View File
@@ -50,6 +50,11 @@ from app.core.heartbeat_monitor_worker import (
stop_heartbeat_monitor,
)
from app.core.logging import setup_logging
from app.core.observe import RequestMetricsMiddleware
from app.core.observe_worker import (
start_observe_worker,
stop_observe_worker,
)
from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client
from app.core.withdraw_reconcile_worker import (
start_withdraw_reconcile_worker,
@@ -74,12 +79,14 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
reconcile_task = start_withdraw_reconcile_worker()
heartbeat_task = start_heartbeat_monitor()
daily_exchange_task = start_daily_exchange_worker()
observe_task = start_observe_worker()
try:
yield
finally:
await stop_heartbeat_monitor(heartbeat_task)
await stop_withdraw_reconcile_worker(reconcile_task)
await stop_daily_exchange_worker(daily_exchange_task)
await stop_observe_worker(observe_task)
await aclose_pricebot_client()
logger.info("shutting down")
@@ -101,6 +108,9 @@ if settings.cors_origins_list:
allow_headers=["*"],
)
# 接口指标埋点(放在 CORS 之后 = 最外层:测到含 CORS 的完整耗时)。未配置观测时中间件自 no-op。
app.add_middleware(RequestMetricsMiddleware)
@app.get("/health", tags=["meta"])
def health() -> dict[str, str]:
+11
View File
@@ -23,6 +23,14 @@ class Feedback(Base):
)
content: Mapped[str] = mapped_column(Text, nullable=False)
contact: Mapped[str] = mapped_column(String(128), nullable=False)
# 反馈来源入口:profile(「我的」页反馈入口)/ comparison(比价结果页反馈入口)。
# admin 据此展示/筛选「反馈类型」。旧数据与「我的」页反馈均为 profile(server_default)。
# 客户端显式传 source 优先;未传时由 scene 有无派生(见 api/v1/feedback.submit_feedback)。
source: Mapped[str] = mapped_column(
String(16), nullable=False, default="profile", server_default="profile", index=True
)
# 比价反馈的「问题场景」(找错商品/优惠不对/比价太慢…),比价结果页反馈才有;普通反馈为 NULL。
scene: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 截图 URL 列表(相对路径,如 ["/media/feedback/u1_ab12.jpg"]);无图为 None
images: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
# 提交时的端环境快照(admin 排查用;客户端改版带上后的新反馈才有,历史数据为 NULL)
@@ -36,6 +44,9 @@ class Feedback(Base):
reward_coins: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 审核批注:采纳时可写采纳要点,未采纳时也可保留运营侧备注
review_note: Mapped[str | None] = mapped_column(String(256), nullable=True)
# 运营给用户的回复留言(**用户端可见**,采纳/未采纳都可填,随「我的反馈」历史下发)。
# 与 review_note(内部备注,用户不可见)、reject_reason(未采纳原因)区分开。
admin_reply: Mapped[str | None] = mapped_column(String(256), nullable=True)
reviewed_by_admin_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("admin_user.id"), nullable=True
)
+4
View File
@@ -17,6 +17,8 @@ def create_feedback(
content: str,
contact: str,
images: list[str] | None,
source: str = "profile",
scene: str | None = None,
app_version: str | None = None,
device_model: str | None = None,
rom_name: str | None = None,
@@ -26,6 +28,8 @@ def create_feedback(
user_id=user_id,
content=content,
contact=contact,
source=source,
scene=scene or None,
images=images or None,
app_version=app_version or None,
device_model=device_model or None,
+4
View File
@@ -30,10 +30,14 @@ class FeedbackQrConfigOut(BaseModel):
class FeedbackRecordOut(BaseModel):
id: int
content: str
# 比价反馈的问题场景(找错商品/优惠不对…);普通反馈为 None
scene: str | None = None
images: list[str] = Field(default_factory=list)
status: str
reject_reason: str | None = None
reward_coins: int | None = None
# 运营给用户的回复留言(用户端可见,采纳/未采纳都可能有)
admin_reply: str | None = None
created_at: datetime
+47
View File
@@ -0,0 +1,47 @@
# OpenObserve 监控台反代(单独子域名)。前提:
# - DNS: observe.shaguabijia.com A 记录 → 本机公网 IP
# - 证书: /etc/nginx/ssl/observe.shaguabijia.com.pem|.key(同现有域名放法)
# - OpenObserve 只绑 127.0.0.1:5080(见 docker-compose.prod.yml)
# 三重防护:IP 白名单 + nginx TLS + OpenObserve 自身登录。
# 只你/少数人看的话,更省事的是 SSH 隧道(README「UI 访问」方案 A),不用域名/证书。
# 80 → 301 → 443
server {
listen 80;
listen [::]:80;
server_name observe.shaguabijia.com;
return 301 https://$host$request_uri;
}
# 443 SSL 反代到本地 OpenObserve (127.0.0.1:5080)
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name observe.shaguabijia.com;
ssl_certificate /etc/nginx/ssl/observe.shaguabijia.com.pem;
ssl_certificate_key /etc/nginx/ssl/observe.shaguabijia.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
# ★ 只放行你的固定出口 IP(办公/家宽)。监控台不必对全网开。
allow 1.2.3.4; # ← 换成真实 IP,可多行
deny all;
client_max_body_size 10m;
location / {
proxy_pass http://127.0.0.1:5080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# OpenObserve 有实时/流式,需透传 WebSocket
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
}
}
+4
View File
@@ -0,0 +1,4 @@
# OpenObserve 落盘数据(parquet/索引/元数据),运行时产生,不入库。
data/
# 生产 compose 的密码文件(OO_ROOT_PASSWORD),含机密,不入库。
.env
+126
View File
@@ -0,0 +1,126 @@
# OpenObserve 本地部署(接口 QPS / 耗时可观测)
app-server 通过中间件采集每个接口的 QPS + 耗时 + 错误率,批量上报到这里。
设计见 [../../docs/superpowers/specs/2026-07-06-openobserve-api-metrics-design.md](../../docs/superpowers/specs/2026-07-06-openobserve-api-metrics-design.md)。
## 启动
```bash
cd deploy/openobserve
docker compose up -d
```
- Web UIhttp://localhost:5080
- 登录:`admin@shaguabijia.local` / `Complexpass#123`(见 `docker-compose.yml`
- 数据落 `deploy/openobserve/data/`(已挂卷持久化;该目录已 gitignore)
## 让 app-server 上报
在项目根的 `.env` 打开观测(`OBSERVE_*`,账号密码与 compose 里 root 一致):
```dotenv
OBSERVE_ENABLED=true
OBSERVE_ENDPOINT=http://localhost:5080
OBSERVE_ORG=default
OBSERVE_STREAM=app_requests
OBSERVE_USER=admin@shaguabijia.local
OBSERVE_PASSWORD=Complexpass#123
```
重启 app-server,随便打几个接口。stream `app_requests` **首次上报自动创建**
在 UI 的 Logs → 选 `app_requests` 就能看到逐条请求事件(字段:`method` / `route` /
`status` / `duration_ms` / `service` / `env`)。
> 未开 `OBSERVE_ENABLED` 或缺账号密码时,中间件透传、worker 不启动,整套 no-op,不影响业务。
## 查询(Logs 页 SQL,或建 Dashboard 面板)
各接口 QPS(1 分钟分桶,面板里再除 60 得每秒):
```sql
SELECT route, histogram(_timestamp, '1 minute') AS ts, count(*) AS cnt
FROM app_requests GROUP BY route, ts ORDER BY ts
```
各接口 P95 耗时(毫秒):
```sql
SELECT route, approx_percentile_cont(duration_ms, 0.95) AS p95_ms
FROM app_requests GROUP BY route ORDER BY p95_ms DESC
```
各接口错误率(5xx 占比):
```sql
SELECT route,
count(*) FILTER (WHERE status >= 500) * 100.0 / count(*) AS err_pct
FROM app_requests GROUP BY route ORDER BY err_pct DESC
```
## 一键导入现成仪表盘(QPS / P95 / 分位 / 错误率)
备好了 [dashboard-api-metrics.json](dashboard-api-metrics.json),4 个面板:各接口每分钟请求数(QPS 源)、
P95 耗时折线、P50/P95/P99 分位表、5xx 错误率表。
- **UI 导入**Dashboards → 右上 **Import** → 选该 JSON 文件 → Import(每次导入新建,不覆盖)。
- **或 API 导入**
```bash
curl -u admin@shaguabijia.local:Complexpass#123 -H 'Content-Type: application/json' \
-X POST 'http://localhost:5080/api/default/dashboards?folder=default' \
--data-binary @deploy/openobserve/dashboard-api-metrics.json
```
导入后进仪表盘,右上角时间调到「最近 15 分钟 / 1 小时」、开自动刷新即可。低流量下 QPS 面板看「每分钟请求数」比「每秒」直观。
## 停止 / 清数据
```bash
docker compose down # 停止(保留数据)
docker compose down -v && rm -rf data # 停止并清空数据
```
## 生产部署(单机)+ UI 访问
前提:app-server 与 OpenObserve **同机**,app→OO 走 localhost(`127.0.0.1:5080`)、不出网、无需 TLS。
唯一要防的是**别把 :5080 裸暴露公网**。硬化版编排见 [docker-compose.prod.yml](docker-compose.prod.yml)。
### 部署步骤
```bash
# 1) 密码文件(本目录,已 gitignore)
echo "OO_ROOT_PASSWORD=$(python -c 'import secrets;print(secrets.token_urlsafe(24))')" > deploy/openobserve/.env
# 2) 起 OpenObserve(只绑 127.0.0.1、命名卷持久化、mem 1g)
cd deploy/openobserve && docker compose -f docker-compose.prod.yml up -d
sudo systemctl enable docker # 开机自起
```
3) app-server 的 `.env` 打开观测并**重启**(用非 root 的专用 ingest 账号):
```dotenv
OBSERVE_ENABLED=true
OBSERVE_ENDPOINT=http://127.0.0.1:5080
OBSERVE_ORG=default
OBSERVE_STREAM=app_requests
OBSERVE_USER=ingest@shaguabijia.com # UI → Users 建的非 root 账号
OBSERVE_PASSWORD=<该账号密码>
```
```bash
sudo systemctl restart shaguabijia-app-server # 日志出现 "observe worker started" 即生效
```
4) 两个必做收口(磁盘/安全):
- **保留期**:UI → Streams → `app_requests` → Data Retention 设 14/30 天(一请求一行,不封顶迟早撑爆盘)。
- **专用账号**:UI → Users 建非 root 账号给 app 上报,root 只留人工登 UI。
### UI 访问(二选一)
**A. SSH 隧道(推荐,零暴露、不用域名/证书):**
```bash
ssh -L 5080:127.0.0.1:5080 用户@服务器IP
# 然后本机浏览器开 http://localhost:5080
```
**B. nginx 子域名反代(要固定 URL / 团队常看):** 见 [../nginx/observe.shaguabijia.com.conf](../nginx/observe.shaguabijia.com.conf)。
需 DNS `observe.shaguabijia.com` → 本机 + 证书放 `/etc/nginx/ssl/`;含 IP 白名单 + TLS + WebSocket 透传。
> ⚠️ prod compose 必须保持 `127.0.0.1:5080:5080`;写成 `5080:5080`(绑 0.0.0.0)= 裸暴露公网,这是唯一真正的坑。
@@ -0,0 +1,301 @@
{
"version": 8,
"title": "接口监控 (QPS / 耗时 / 错误率)",
"description": "app-server 接口 QPS、P50/P95/P99 耗时、5xx 错误率。数据流 app_requests。",
"role": "",
"tabs": [
{
"tabId": "default",
"name": "Default",
"panels": [
{
"id": "panel_qps",
"type": "line",
"title": "各接口 每分钟请求数 (QPS 源)",
"description": "",
"config": {
"show_legends": true,
"legends_position": null,
"decimals": 2.0,
"axis_border_show": false,
"base_map": null,
"map_view": null
},
"queryType": "sql",
"queries": [
{
"query": "SELECT histogram(_timestamp, '1 minute') as ts, route, count(*) as reqs FROM app_requests GROUP BY ts, route ORDER BY ts",
"vrlFunctionQuery": "",
"customQuery": true,
"fields": {
"stream": "app_requests",
"stream_type": "logs",
"x": [
{
"label": "ts",
"alias": "ts",
"column": "ts",
"color": null,
"sortBy": "ASC"
}
],
"y": [
{
"label": "reqs",
"alias": "reqs",
"column": "reqs",
"color": null
}
],
"z": [],
"breakdown": [
{
"label": "route",
"alias": "route",
"column": "route",
"color": null
}
],
"filter": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": []
}
},
"config": {
"promql_legend": "",
"layer_type": "scatter",
"weight_fixed": 1.0
}
}
],
"layout": {
"x": 0,
"y": 0,
"w": 24,
"h": 9,
"i": 1
}
},
{
"id": "panel_p95",
"type": "line",
"title": "各接口 P95 耗时 (ms)",
"description": "",
"config": {
"show_legends": true,
"legends_position": null,
"decimals": 2.0,
"axis_border_show": false,
"base_map": null,
"map_view": null
},
"queryType": "sql",
"queries": [
{
"query": "SELECT histogram(_timestamp, '1 minute') as ts, route, approx_percentile_cont(duration_ms, 0.95) as p95_ms FROM app_requests GROUP BY ts, route ORDER BY ts",
"vrlFunctionQuery": "",
"customQuery": true,
"fields": {
"stream": "app_requests",
"stream_type": "logs",
"x": [
{
"label": "ts",
"alias": "ts",
"column": "ts",
"color": null,
"sortBy": "ASC"
}
],
"y": [
{
"label": "p95_ms",
"alias": "p95_ms",
"column": "p95_ms",
"color": null
}
],
"z": [],
"breakdown": [
{
"label": "route",
"alias": "route",
"column": "route",
"color": null
}
],
"filter": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": []
}
},
"config": {
"promql_legend": "",
"layer_type": "scatter",
"weight_fixed": 1.0
}
}
],
"layout": {
"x": 24,
"y": 0,
"w": 24,
"h": 9,
"i": 2
}
},
{
"id": "panel_pctl",
"type": "table",
"title": "各接口 耗时分位 P50/P95/P99 (ms)",
"description": "",
"config": {
"show_legends": true,
"legends_position": null,
"decimals": 2.0,
"axis_border_show": false,
"base_map": null,
"map_view": null
},
"queryType": "sql",
"queries": [
{
"query": "SELECT route, approx_percentile_cont(duration_ms,0.5) as p50, approx_percentile_cont(duration_ms,0.95) as p95, approx_percentile_cont(duration_ms,0.99) as p99, count(*) as cnt FROM app_requests GROUP BY route ORDER BY p95 DESC",
"vrlFunctionQuery": "",
"customQuery": true,
"fields": {
"stream": "app_requests",
"stream_type": "logs",
"x": [
{
"label": "route",
"alias": "route",
"column": "route",
"color": null
}
],
"y": [
{
"label": "p50",
"alias": "p50",
"column": "p50",
"color": null
},
{
"label": "p95",
"alias": "p95",
"column": "p95",
"color": null
},
{
"label": "p99",
"alias": "p99",
"column": "p99",
"color": null
},
{
"label": "cnt",
"alias": "cnt",
"column": "cnt",
"color": null
}
],
"z": [],
"breakdown": [],
"filter": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": []
}
},
"config": {
"promql_legend": "",
"layer_type": "scatter",
"weight_fixed": 1.0
}
}
],
"layout": {
"x": 0,
"y": 9,
"w": 24,
"h": 9,
"i": 3
}
},
{
"id": "panel_err",
"type": "table",
"title": "各接口 错误率 (5xx %)",
"description": "",
"config": {
"show_legends": true,
"legends_position": null,
"decimals": 2.0,
"axis_border_show": false,
"base_map": null,
"map_view": null
},
"queryType": "sql",
"queries": [
{
"query": "SELECT route, count(*) FILTER (WHERE status >= 500) * 100.0 / count(*) as err_pct, count(*) as cnt FROM app_requests GROUP BY route ORDER BY err_pct DESC",
"vrlFunctionQuery": "",
"customQuery": true,
"fields": {
"stream": "app_requests",
"stream_type": "logs",
"x": [
{
"label": "route",
"alias": "route",
"column": "route",
"color": null
}
],
"y": [
{
"label": "err_pct",
"alias": "err_pct",
"column": "err_pct",
"color": null
},
{
"label": "cnt",
"alias": "cnt",
"column": "cnt",
"color": null
}
],
"z": [],
"breakdown": [],
"filter": {
"filterType": "group",
"logicalOperator": "AND",
"conditions": []
}
},
"config": {
"promql_legend": "",
"layer_type": "scatter",
"weight_fixed": 1.0
}
}
],
"layout": {
"x": 24,
"y": 9,
"w": 24,
"h": 9,
"i": 4
}
}
]
}
],
"variables": {
"list": []
}
}
@@ -0,0 +1,27 @@
# 生产用 OpenObserve(单机)。相对本地版 docker-compose.yml 的区别:
# - 端口只绑 127.0.0.1 → 公网/外网都到不了(UI 访问走 SSH 隧道或 nginx 反代,见 README)
# - root 密码走环境变量(放同目录 .env,已 gitignore,勿提交)
# - 数据用 docker 命名卷(免 bind-mount 权限坑)+ 内存上限(与 app/PG 共存防抢内存)
#
# 用法:
# 1) 本目录建 .env(已 gitignore):
# OO_ROOT_PASSWORD=<强随机串> # 生成: python -c "import secrets;print(secrets.token_urlsafe(24))"
# 2) docker compose -f docker-compose.prod.yml up -d
# 3) 开机自起: sudo systemctl enable docker
services:
openobserve:
image: public.ecr.aws/zinclabs/openobserve:latest
container_name: openobserve
ports:
- "127.0.0.1:5080:5080" # ★只绑本机;写成 5080:5080 = 裸暴露公网,别这么干
environment:
ZO_ROOT_USER_EMAIL: "admin@shaguabijia.com"
ZO_ROOT_USER_PASSWORD: "${OO_ROOT_PASSWORD:?请先在 deploy/openobserve/.env 里设 OO_ROOT_PASSWORD}"
ZO_DATA_DIR: "/data"
volumes:
- openobserve_data:/data # docker 命名卷;想固定到某块盘改成 bind: /your/disk:/data
restart: unless-stopped
mem_limit: 1g # 按机器内存调;查询重可给 2g
volumes:
openobserve_data:
+16
View File
@@ -0,0 +1,16 @@
# 本地开发用 OpenObserve(单容器 = local 模式)。用于接收 app-server 的接口指标(QPS/耗时/错误率)。
# 启动: cd deploy/openobserve && docker compose up -d
# Web UI: http://localhost:5080 (账号见下方 env)
services:
openobserve:
image: public.ecr.aws/zinclabs/openobserve:latest
container_name: openobserve
ports:
- "5080:5080"
environment:
ZO_ROOT_USER_EMAIL: "admin@shaguabijia.local"
ZO_ROOT_USER_PASSWORD: "Complexpass#123"
ZO_DATA_DIR: "/data"
volumes:
- ./data:/data
restart: unless-stopped
+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,6 +1,6 @@
# POST /api/v1/compare/record — 上报一次比价结果(幂等)
> 所属:比价记录组(前缀 `/api/v1/compare` | 鉴权:Bearer | [← 返回 API 索引](./README.md)
> 所属:比价记录组(前缀 `/api/v1/compare` | 鉴权:Bearer | [← 返回 API 索引](../README.md)
比价 `done` 帧后,客户端用**带 JWT 的通道**上报一条比价结果,落 `comparison_record` 表,作为「我的比价记录」数据源 + 用户级行为画像。
@@ -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` → 强制更新弹窗(不可跳过)

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