Compare commits

...

9 Commits

Author SHA1 Message Date
marco d9babc0ba3 更新文档0709 2026-07-09 14:23:59 +08:00
liujiahui 9e082e6376 后台权限管理支持「自定义」角色(后端) (#126)
admin_user 加 pages_override(可空 JSON)+ Alembic 迁移;role==custom 时可见页由逐页勾选决定,否则跟随角色。
create/update 收 pages_override(切回普通角色自动清空),_validate_role 放行 custom 哨兵角色。
登录/me 下发有效页时优先用 override → 左侧导航按勾选即时生效。补 4 个用例,test_admin_roles 全绿。

配套前端 PR:shaguabijia-admin-web#admin-custom-perms。

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #126
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-07-09 01:09:03 +08:00
zhuzihao 93cb283c1b feat(marquee): 真实条不按用户去重(打乱+去连簇)+ 后台按模式分页浏览可展示记录 (#123)
- get_feed 真实条不再按 user 去重:改「洗牌 + 去连簇」(相邻尽量不同用户、减少单人连刷),
  同一用户可多条露出但被打散、尽量不连续。_shuffle_declustered 支持传入固定种子 rng。
- 新增 GET /admin/api/marquee-seeds/real-records:按**当前模式**分页浏览全部可展示记录(不去重),
  供运营逐页审核——只真实=真实记录;只种子=各启用种子按生成逻辑各出一行;混播=真实+种子。
  固定种子洗牌+去连簇 → 排列恒定、翻页稳定、能翻遍全部;含 OpsRealRecord* schema。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #123
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-08 00:57:00 +08:00
guke a43e082391 fix(dashboard): 比价/领券奖励金币按 feed_scene 汇总(修复恒 0 + 激励视频双计) (#125)
世睿之前mr未合并的逻辑
比价/领券奖励金币此前查 coin_transaction.biz_type in (comparison/coupon...),
但这些 biz_type 全站从未写入——比价/领券信息流广告金币实际记为 feed_ad_reward、
场景区分在 ad_feed_reward_record.feed_scene——故两卡恒 0;领券桶还误含
reward_video/ad_reward,把激励视频金币双计进领券。

改为:comparison/coupon 奖励金币 = biz_type 桶(历史空、留作兜底)+ 按
ad_feed_reward_record.feed_scene 的 granted 实发金币(reward_date 北京自然日窗口);
reward_video/ad_reward 拆成独立 REWARD_VIDEO_BIZ_TYPES,不再混入领券,
REGULAR_TASK_EXCLUDED_BIZ_TYPES 保持不变。

测试:tests/test_admin_read.py 加 3 个用例(比价/领券按 feed_scene 汇总、
too_short 不计、领券排除激励视频);全量 pytest 除 5 个既有失败外全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #125
2026-07-07 18:29:23 +08:00
guke e11ad9449c revert 6ec4cfb4be
revert fix(dashboard): 比价/领券奖励金币按 feed_scene 汇总(修复恒 0 + 激励视频双计)

比价/领券奖励金币此前查 coin_transaction.biz_type in (comparison/coupon...),
但这些 biz_type 全站从未写入——比价/领券信息流广告金币实际记为 feed_ad_reward、
场景区分在 ad_feed_reward_record.feed_scene——故两卡恒 0;领券桶还误含
reward_video/ad_reward,把激励视频金币双计进领券。

改为:comparison/coupon 奖励金币 = biz_type 桶(历史空、留作兜底)+ 按
ad_feed_reward_record.feed_scene 的 granted 实发金币(reward_date 北京自然日窗口);
reward_video/ad_reward 拆成独立 REWARD_VIDEO_BIZ_TYPES,不再混入领券,
REGULAR_TASK_EXCLUDED_BIZ_TYPES 保持不变。

测试:tests/test_admin_read.py 加 3 个用例(比价/领券按 feed_scene 汇总、
too_short 不计、领券排除激励视频);全量 pytest 除 5 个既有失败外全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:05:33 +08:00
guke 6ec4cfb4be fix(dashboard): 比价/领券奖励金币按 feed_scene 汇总(修复恒 0 + 激励视频双计)
比价/领券奖励金币此前查 coin_transaction.biz_type in (comparison/coupon...),
但这些 biz_type 全站从未写入——比价/领券信息流广告金币实际记为 feed_ad_reward、
场景区分在 ad_feed_reward_record.feed_scene——故两卡恒 0;领券桶还误含
reward_video/ad_reward,把激励视频金币双计进领券。

改为:comparison/coupon 奖励金币 = biz_type 桶(历史空、留作兜底)+ 按
ad_feed_reward_record.feed_scene 的 granted 实发金币(reward_date 北京自然日窗口);
reward_video/ad_reward 拆成独立 REWARD_VIDEO_BIZ_TYPES,不再混入领券,
REGULAR_TASK_EXCLUDED_BIZ_TYPES 保持不变。

测试:tests/test_admin_read.py 加 3 个用例(比价/领券按 feed_scene 汇总、
too_short 不计、领券排除激励视频);全量 pytest 除 5 个既有失败外全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:02:21 +08:00
guke a52fc1973f revert 58b59c264d
revert fix(dashboard): 比价/领券奖励金币按 feed_scene 汇总(修复恒 0 + 激励视频双计)

比价/领券奖励金币此前查 coin_transaction.biz_type in (comparison/coupon...),
但这些 biz_type 全站从未写入——比价/领券信息流广告金币实际记为 feed_ad_reward、
场景区分在 ad_feed_reward_record.feed_scene——故两卡恒 0;领券桶还误含
reward_video/ad_reward,把激励视频金币双计进领券。

改为:comparison/coupon 奖励金币 = biz_type 桶(历史空、留作兜底)+ 按
ad_feed_reward_record.feed_scene 的 granted 实发金币(reward_date 北京自然日窗口);
reward_video/ad_reward 拆成独立 REWARD_VIDEO_BIZ_TYPES,不再混入领券,
REGULAR_TASK_EXCLUDED_BIZ_TYPES 保持不变。

测试:tests/test_admin_read.py 加 3 个用例(比价/领券按 feed_scene 汇总、
too_short 不计、领券排除激励视频);全量 pytest 除 5 个既有失败外全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 17:52:57 +08:00
guke 58b59c264d fix(dashboard): 比价/领券奖励金币按 feed_scene 汇总(修复恒 0 + 激励视频双计)
比价/领券奖励金币此前查 coin_transaction.biz_type in (comparison/coupon...),
但这些 biz_type 全站从未写入——比价/领券信息流广告金币实际记为 feed_ad_reward、
场景区分在 ad_feed_reward_record.feed_scene——故两卡恒 0;领券桶还误含
reward_video/ad_reward,把激励视频金币双计进领券。

改为:comparison/coupon 奖励金币 = biz_type 桶(历史空、留作兜底)+ 按
ad_feed_reward_record.feed_scene 的 granted 实发金币(reward_date 北京自然日窗口);
reward_video/ad_reward 拆成独立 REWARD_VIDEO_BIZ_TYPES,不再混入领券,
REGULAR_TASK_EXCLUDED_BIZ_TYPES 保持不变。

测试:tests/test_admin_read.py 加 3 个用例(比价/领券按 feed_scene 汇总、
too_short 不计、领券排除激励视频);全量 pytest 除 5 个既有失败外全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 17:46:09 +08:00
Ghost 0e149c83e7 修复美团 CPS 订单 pay_time 入库为空导致大盘美团收益漏算 (#119)
Co-authored-by: guke <guke@autohome.com.cn>
Co-authored-by: 陈世睿 <2839904623@qq.com>
Reviewed-on: #119
Co-authored-by: Ghost <>
Co-committed-by: Ghost <>
2026-07-07 17:11:00 +08:00
65 changed files with 1500 additions and 334 deletions
@@ -0,0 +1,35 @@
"""admin_user 加 pages_override 列(「自定义」权限:按人存专属可见页 key 列表)
权限管理页新增「自定义」角色:选它时该成员的可见页不跟随任何共享角色,而由逐页勾选决定,
存这个人专属的一份页 key 列表。仅 role == "custom" 时有效;普通角色为 None(可见页跟随角色)。
PG 用 JSONB,SQLite 退化为通用 JSON(同 admin_audit_log.detail / comparison_record.raw_payload)。
Revision ID: admin_user_pages_override
Revises: admin_user_plain_password
Create Date: 2026-07-08 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
from alembic import op
revision: str = "admin_user_pages_override"
down_revision: str | Sequence[str] | None = "admin_user_plain_password"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# 与 app/models/admin.py 的 _JSON 一致:PG JSONB / 其它 JSON
_JSON = sa.JSON().with_variant(JSONB(), "postgresql")
def upgrade() -> None:
with op.batch_alter_table("admin_user", schema=None) as batch_op:
batch_op.add_column(sa.Column("pages_override", _JSON, nullable=True))
def downgrade() -> None:
with op.batch_alter_table("admin_user", schema=None) as batch_op:
batch_op.drop_column("pages_override")
+3
View File
@@ -9,6 +9,9 @@ super_admin 为内建全权角色,恒可见全部页(effective_pages 特判)。
from __future__ import annotations
SUPER_ADMIN_ROLE = "super_admin"
# 「自定义」哨兵角色:不是 admin_role 表里的行,而是标记「这个人的可见页由 pages_override 决定」。
# admin_user.role == CUSTOM_ROLE 时,有效可见页取 admin_user.pages_override(见 auth._admin_out_with_pages)。
CUSTOM_ROLE = "custom"
# 分组镜像前端导航(app/(main)/layout.tsx 的 NAV_GROUPS);key = 路由一级
PERMISSION_CATALOG: list[dict] = [
+22
View File
@@ -385,6 +385,27 @@ def ad_revenue_report(
for k, v in type_map.items()
}
# 分场景小计(按 feed_scene:展示条数 + 预估收益),同 type_stats 基于全量 events——
# 供数据大盘「领券广告 / 比价广告」卡用。此前大盘是在分页 items 里按 feed_scene 现算,
# 2026-07-02 起信息流逐条展示行(唯一带收益 + 场景的行)不再进主表 items,现算恒为 0;
# 改为服务端在全量上聚合下发(也顺带不受 limit 分页截断影响)。feed_scene 为空(激励视频 /
# 旧数据)不计入任何场景桶。
scene_map: dict[str, dict] = {}
for e in events:
sc = e.get("feed_scene")
if not sc:
continue
s = scene_map.get(sc)
if s is None:
s = {"impressions": 0, "revenue_yuan": 0.0}
scene_map[sc] = s
s["impressions"] += e["impressions"]
s["revenue_yuan"] += e["revenue_yuan"]
scene_stats = {
k: {"impressions": v["impressions"], "revenue_yuan": round(v["revenue_yuan"], 6)}
for k, v in scene_map.items()
}
# DAU:复用数据大盘活跃用户口径(登录 + 开始比价 + 开始领券,按用户去重),按所选日期区间
# 统计(含今日),历史 / 多天区间同样有值。ARPU = 区间预估收益 ÷ 区间活跃用户。全局口径,
# 不随 user / ad_type / feed_scene / app_env 筛选变化(活跃用户口径无这些维度)。
@@ -418,6 +439,7 @@ def ad_revenue_report(
"daily": daily,
"hourly": hourly,
"type_stats": type_stats,
"scene_stats": scene_stats,
"dau": dau,
"items": main_rows[offset:offset + limit],
}
+4 -1
View File
@@ -26,14 +26,17 @@ def create_admin(
password: str,
role: str = "operator",
plain_password: str | None = None,
pages_override: list[str] | None = None,
) -> AdminUser:
"""建管理员。plain_password 非空则额外留存明文(后台 UI 建的账号传,供权限管理页复看);
脚本/起后台建账号不传(留 None → 前端不显示密码)。"""
脚本/起后台建账号不传(留 None → 前端不显示密码)。
pages_override:role=="custom" 时传专属可见页 key 列表;普通角色为 None。"""
admin = AdminUser(
username=username,
password_hash=hash_password(password),
role=role,
plain_password=plain_password,
pages_override=pages_override,
)
db.add(admin)
db.commit()
+21 -5
View File
@@ -51,7 +51,27 @@ def _yuan_to_cents(v: object) -> int | None:
def _ts_to_dt(ts: object) -> datetime | None:
"""秒级时间戳 → tz-aware UTC datetime(绝对时刻,前端按北京展示)。"""
if not ts:
if ts is None:
return None
if isinstance(ts, datetime):
return ts if ts.tzinfo else ts.replace(tzinfo=_BJ_TZ).astimezone(timezone.utc)
s = str(ts).strip()
if not s or s.lower() == "null":
return None
try:
seconds = float(Decimal(s))
except (InvalidOperation, ValueError):
return None
if seconds == 0:
return None
# 美团文档是秒级时间戳,这里顺手兼容毫秒/微秒,避免上游格式变化导致时间再次落空。
if abs(seconds) > 10_000_000_000_000:
seconds /= 1_000_000
elif abs(seconds) > 10_000_000_000:
seconds /= 1_000
try:
return datetime.fromtimestamp(seconds, tz=timezone.utc)
except (OverflowError, OSError, ValueError):
return None
@@ -83,10 +103,6 @@ def _pick(row: dict[str, Any], *keys: str) -> Any:
if key in row and row[key] is not None:
return row[key]
return None
try:
return datetime.fromtimestamp(int(ts), tz=timezone.utc)
except (ValueError, OSError, TypeError):
return None
# ───────────── 群 ─────────────
+116 -4
View File
@@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo
from sqlalchemy import Select, asc, case, desc, func, or_, select
from sqlalchemy.orm import Session
from app.admin.repositories.stats import COMPARE_START_EVENT, COUPON_START_EVENT
from app.core import rewards
from app.core.config import settings
from app.models.ad_feed_reward import AdFeedRewardRecord
@@ -18,6 +19,7 @@ from app.models.ad_reward import AdRewardRecord
from app.models.admin import AdminAuditLog
from app.models.analytics_event import AnalyticsEvent
from app.models.comparison import ComparisonRecord
from app.models.coupon_state import CouponPromptEngagement
from app.models.device import DeviceLiveness
from app.models.feedback import Feedback
from app.models.onboarding import OnboardingCompletion
@@ -31,6 +33,9 @@ from app.models.wallet import (
WithdrawOrder,
)
# 「最近活跃」计入的行为事件(与大盘 DAU/留存活跃口径一致:开始比价 + 开始领券)
_ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT)
# 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取")
_NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct")
@@ -82,6 +87,86 @@ def offset_paginate(
return items, next_cursor, total
def _last_active_parts():
"""「最近活跃」的两个按 user_id 预聚合派生表(最近开始比价/领券事件、最近领券发起)。
活跃口径与大盘 DAU/留存一致(2026-07-05 产品定:进入 App≈登录 last_login_at +
发起比价 real_compare_start + 发起领券 real_coupon_start/claim_started)。
用 LEFT JOIN 预聚合而非相关标量子查询:后者在 PG 上对 users 每行各跑一个 SubPlan
(排序键、range 筛选、offset_paginate 的 count 三处叠加),埋点表大了会拖垮列表接口;
预聚合借 analytics_event.event 索引只扫两类 start 事件,每次查询聚合一次。
"""
ev_agg = (
select(
AnalyticsEvent.user_id.label("user_id"),
func.max(AnalyticsEvent.created_at).label("last_at"),
)
.where(
AnalyticsEvent.user_id.is_not(None),
AnalyticsEvent.event.in_(_ACTIVE_EVENTS),
)
.group_by(AnalyticsEvent.user_id)
.subquery()
)
eng_agg = (
select(
CouponPromptEngagement.user_id.label("user_id"),
func.max(CouponPromptEngagement.created_at).label("last_at"),
)
.where(
CouponPromptEngagement.user_id.is_not(None),
CouponPromptEngagement.engage_type == "claim_started",
)
.group_by(CouponPromptEngagement.user_id)
.subquery()
)
return ev_agg, eng_agg
def _norm_utc(dt: datetime | None) -> datetime | None:
"""naive 视为 UTC 补 tzinfo(SQLite 读回 naive、PG 读回 aware,混着 max() 会 TypeError)。"""
if dt is None:
return None
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
def _attach_last_active(db: Session, users: list[User]) -> None:
"""给本页用户瞬态挂 last_active_at(非 DB 列,供 AdminUserListItem from_attributes 读)。
口径同 [_last_active_expr];按本页 user_id 批量两次 GROUP BY,防 N+1。
"""
uids = [u.id for u in users]
if not uids:
return
ev_map = dict(
db.execute(
select(AnalyticsEvent.user_id, func.max(AnalyticsEvent.created_at))
.where(
AnalyticsEvent.user_id.in_(uids),
AnalyticsEvent.event.in_(_ACTIVE_EVENTS),
)
.group_by(AnalyticsEvent.user_id)
).all()
)
eng_map = dict(
db.execute(
select(CouponPromptEngagement.user_id, func.max(CouponPromptEngagement.created_at))
.where(
CouponPromptEngagement.user_id.in_(uids),
CouponPromptEngagement.engage_type == "claim_started",
)
.group_by(CouponPromptEngagement.user_id)
).all()
)
for u in users:
candidates = [
_norm_utc(u.last_login_at),
_norm_utc(ev_map.get(u.id)),
_norm_utc(eng_map.get(u.id)),
]
u.last_active_at = max((c for c in candidates if c is not None), default=None)
def list_users(
db: Session,
*,
@@ -93,16 +178,34 @@ def list_users(
created_to: datetime | None = None,
last_login_from: datetime | None = None,
last_login_to: datetime | None = None,
last_active_from: datetime | None = None,
last_active_to: datetime | None = None,
sort_by: str = "id",
sort_order: str = "desc",
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[User], int | None, int]:
"""用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录时间范围筛选,
按 id·注册时间·最近登录排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
"""用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录·最近活跃
时间范围筛选,按 id·注册时间·最近登录·最近活跃排序;每页附带计算列 last_active_at
(口径见 [_last_active_expr])。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。
日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。"""
stmt = select(User)
# 最近活跃 = max(最近登录, 最近行为事件, 最近领券发起)。PG 用 GREATEST;SQLite 标量 max()
# 任一参数 NULL 即返回 NULL,故 LEFT JOIN 未命中侧 coalesce 到 last_login_at 兜底
# (注册即登录,该列恒非空)。派生表 1:1(按 user_id 聚合),outerjoin 不会放大行数,
# offset_paginate 的 count 不受影响。
ev_agg, eng_agg = _last_active_parts()
greatest = func.greatest if db.get_bind().dialect.name == "postgresql" else func.max
last_active = greatest(
User.last_login_at,
func.coalesce(ev_agg.c.last_at, User.last_login_at),
func.coalesce(eng_agg.c.last_at, User.last_login_at),
)
stmt = (
select(User)
.outerjoin(ev_agg, ev_agg.c.user_id == User.id)
.outerjoin(eng_agg, eng_agg.c.user_id == User.id)
)
if phone:
stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配
if register_channel:
@@ -119,16 +222,25 @@ def list_users(
stmt = stmt.where(User.last_login_at >= _as_utc(last_login_from))
if last_login_to is not None:
stmt = stmt.where(User.last_login_at <= _as_utc(last_login_to))
if last_active_from is not None:
stmt = stmt.where(last_active >= _as_utc(last_active_from))
if last_active_to is not None:
stmt = stmt.where(last_active <= _as_utc(last_active_to))
sort_cols = {
"id": User.id,
"created_at": User.created_at,
"last_login_at": User.last_login_at,
"last_active_at": last_active,
}
sort_col = sort_cols.get(sort_by, User.id)
order_fn = asc if sort_order == "asc" else desc
id_order = asc(User.id) if sort_order == "asc" else desc(User.id)
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
items, next_cursor, total = offset_paginate(
db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor
)
_attach_last_active(db, items)
return items, next_cursor, total
def _attach_user_info(db: Session, records: list[ComparisonRecord | Feedback | PriceReport]) -> None:
+167 -23
View File
@@ -5,17 +5,23 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索
"""
from __future__ import annotations
from collections import Counter
from datetime import date, datetime, time, timedelta, timezone
from decimal import Decimal, InvalidOperation
from sqlalchemy import func, select
from sqlalchemy import case, func, select
from sqlalchemy.orm import Session
from app.admin.repositories.coupon_data import _percentile
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
from app.models.analytics_event import AnalyticsEvent
from app.models.comparison import ComparisonRecord
from app.models.coupon_state import CouponPromptEngagement
from app.models.coupon_state import (
CouponClaimRecord,
CouponPromptEngagement,
CouponSession,
)
from app.models.cps_order import CpsOrder
from app.models.feedback import Feedback
from app.models.savings import SavingsRecord
@@ -24,11 +30,17 @@ from app.models.user import User
from app.models.wallet import CoinTransaction, WithdrawOrder
_BEIJING = timezone(timedelta(hours=8))
COUPON_REWARD_BIZ_TYPES = ("reward_video", "ad_reward", "coupon", "coupon_reward")
REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward")
# 领券/比价奖励金币的真实来源是信息流广告发奖(ad_feed_reward_record,按 feed_scene 分场景);
# coin_transaction 里只有扁平的 feed_ad_reward、biz_type 不分 coupon/comparison,故这俩桶历史从未
# 被写入,仅留作未来兜底,实际金额在下方按 feed_scene 汇总 ad_feed_reward_record 得出。reward_video/
# ad_reward 是激励视频,单独成桶、不再混进领券奖励(历史误并会把激励视频金币双计进领券)。
COUPON_REWARD_BIZ_TYPES = ("coupon", "coupon_reward")
COMPARISON_REWARD_BIZ_TYPES = ("comparison", "compare_reward", "comparison_reward")
EXCLUDED_REWARD_BIZ_TYPES = ("invite_inviter", "invite_invitee", "admin_grant")
UNCLASSIFIED_FEED_BIZ_TYPES = ("feed_ad_reward",)
REGULAR_TASK_EXCLUDED_BIZ_TYPES = (
*REWARD_VIDEO_BIZ_TYPES,
*COUPON_REWARD_BIZ_TYPES,
*COMPARISON_REWARD_BIZ_TYPES,
*EXCLUDED_REWARD_BIZ_TYPES,
@@ -299,12 +311,12 @@ def dashboard_overview(
period_from=period_from,
period_to=period_to,
)
period_retained_new_user_ids = period_new_user_ids & period_active_user_ids
period_retention_rate = (
round(len(period_retained_new_user_ids) / len(period_new_user_ids), 4)
if period_new_user_ids
else None
)
# 留存口径(2026-07-05 产品改):次日留存——窗口内每天 D,取 **D-1 日(前日)新增**用户,
# 统计其 D 日活跃(登录/开始比价/开始领券)比例,逐日累加。默认窗口=昨日单天,即
# 「前日新增用户的昨日留存」。原口径(窗口内新增∩窗口内活跃)在单日窗口下≈100% 无意义
# (注册即登录,当天新增必然当天活跃)。逐日 cohort 在下方 trend 循环内顺带累计。
retention_cohort_total = 0
retention_retained_total = 0
trend_points: list[dict] = []
for cur_date in _date_range(period_from, period_to):
day_start_utc, day_end_utc, day_start_local, day_end_local = _period_bounds(
@@ -314,18 +326,26 @@ def dashboard_overview(
ComparisonRecord.created_at >= day_start_local,
ComparisonRecord.created_at < day_end_local,
)
daily_active_user_ids = _period_active_user_ids(
db,
start_utc=day_start_utc,
end_utc=day_end_utc,
period_from=cur_date,
period_to=cur_date,
)
# 次日留存:cohort = 前一日(D-1)新增用户,留存 = 其中当日(D)活跃者(口径见上)。
cohort_ids = _user_id_set(
select(User.id).where(
User.created_at >= day_start_utc - timedelta(days=1),
User.created_at < day_end_utc - timedelta(days=1),
)
)
retention_cohort_total += len(cohort_ids)
retention_retained_total += len(cohort_ids & daily_active_user_ids)
trend_points.append(
{
"date": cur_date,
"active_users": len(
_period_active_user_ids(
db,
start_utc=day_start_utc,
end_utc=day_end_utc,
period_from=cur_date,
period_to=cur_date,
)
),
"active_users": len(daily_active_user_ids),
"new_users": _count(
User,
User.created_at >= day_start_utc,
@@ -334,6 +354,11 @@ def dashboard_overview(
"comparisons": _count(ComparisonRecord, *daily_comparison_conds),
}
)
period_retention_rate = (
round(retention_retained_total / retention_cohort_total, 4)
if retention_cohort_total
else None
)
period_coin_conds = (
CoinTransaction.created_at >= start_local,
@@ -343,7 +368,7 @@ def dashboard_overview(
period_reward_video_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
)
period_feed_ad_coin_total = _sum(
CoinTransaction.amount,
@@ -365,15 +390,29 @@ def dashboard_overview(
*period_coin_conds,
CoinTransaction.biz_type.like("task_%"),
)
# 领券/比价奖励金币 = biz_type 桶(历史空,兜底)+ 该场景信息流广告实发金币
# (ad_feed_reward_record.feed_scene,granted;reward_date 是北京日期串,与 period 同自然日窗口)。
period_coupon_reward_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.in_(COUPON_REWARD_BIZ_TYPES),
) + _sum(
AdFeedRewardRecord.coin,
AdFeedRewardRecord.status == "granted",
AdFeedRewardRecord.feed_scene == "coupon",
AdFeedRewardRecord.reward_date >= period_from.isoformat(),
AdFeedRewardRecord.reward_date <= period_to.isoformat(),
)
period_comparison_reward_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.in_(COMPARISON_REWARD_BIZ_TYPES),
) + _sum(
AdFeedRewardRecord.coin,
AdFeedRewardRecord.status == "granted",
AdFeedRewardRecord.feed_scene == "comparison",
AdFeedRewardRecord.reward_date >= period_from.isoformat(),
AdFeedRewardRecord.reward_date <= period_to.isoformat(),
)
period_regular_task_coin_total = _sum(
CoinTransaction.amount,
@@ -417,6 +456,98 @@ def dashboard_overview(
else None
)
# ===== 领券核心数据(2026-07-05 产品新增)=====
# 数据源:coupon_session(一次领券一行,started_date 北京自然日)+ coupon_claim_record
# (一券/点位一天一条终态,claim_date 北京自然日)。点位与 session 不按 trace_id 关联——
# record_claims 更新路径不覆盖 trace_id(同设备同券同日重跑归第一次的 trace),按
# (device_id, 自然日) 桶关联才可靠;同桶多次发起共享同一份点位终态。
period_coupon_sessions = db.execute(
select(
CouponSession.device_id,
CouponSession.started_date,
CouponSession.status,
CouponSession.elapsed_ms,
).where(
CouponSession.started_date >= period_from,
CouponSession.started_date <= period_to,
# 只统计正式环境,同「领券数据」页默认口径(防 debug 包调试数据串台;
# 命中 ix_coupon_session_date_env)。点位表无 app_env 列,但点位指标只经
# 下方 prod session 触达的 (device, 日) 桶进入统计,随之收敛到 prod。
CouponSession.app_env == "prod",
)
).all()
coupon_started = len(period_coupon_sessions)
coupon_completed_elapsed = sorted(
s.elapsed_ms
for s in period_coupon_sessions
if s.status == "completed" and s.elapsed_ms is not None
)
# 点位桶:(device, 日) → (点位总数, 成功点位数)。成功口径与「我的」页累计领券一致
# (sum_claimed_count,2026-06-15 产品定):success + already_claimed(已领过=持有券)都算成功。
point_buckets: dict[tuple[str, date], tuple[int, int]] = {
(dev, d): (int(total), int(succ or 0))
for dev, d, total, succ in db.execute(
select(
CouponClaimRecord.device_id,
CouponClaimRecord.claim_date,
func.count(),
func.sum(
case(
(CouponClaimRecord.status.in_(("success", "already_claimed")), 1),
else_=0,
)
),
)
.where(
CouponClaimRecord.claim_date >= period_from,
# 上界放宽一天:跨零点场次(23:5x 发起)的点位 claim_date 落在发起日+1,
# 桶只经下方 session 触达的键参与计数,放宽不会引入无关数据。
CouponClaimRecord.claim_date <= period_to + timedelta(days=1),
)
.group_by(CouponClaimRecord.device_id, CouponClaimRecord.claim_date)
).all()
}
# 全部领成功的次数:completed 且其 (device, 日) 桶内点位全部成功(桶为空不算)。
coupon_all_success = 0
completed_bucket_totals: list[int] = []
session_bucket_keys: set[tuple[str, date]] = set()
for s in period_coupon_sessions:
key = (s.device_id, s.started_date)
if key not in point_buckets:
# 跨零点回退:发起日桶不存在(点位终态全部落在次日)时取 (device, 发起日+1)。
# 仅在发起日桶完全缺失时回退,避免抢占该设备次日 session 自己的桶。
next_key = (s.device_id, s.started_date + timedelta(days=1))
if next_key in point_buckets:
key = next_key
bucket = point_buckets.get(key)
if bucket is not None:
session_bucket_keys.add(key)
if s.status != "completed" or bucket is None:
continue
total, succ = bucket
completed_bucket_totals.append(total)
if total > 0 and succ == total:
coupon_all_success += 1
# 每次发起的应领点位数:取「完成过的领券」实际点位数的众数(done 帧会给所有点位终态,
# 完成场的点位数=当前配置的全量点位数;数据自校准,配置改点位数无需改代码)。本期无完成场
# 时给不出,点位成功率置空。
coupon_points_per_session = (
Counter(completed_bucket_totals).most_common(1)[0][0]
if completed_bucket_totals
else None
)
# 成功点位数:本期 session 触达过的 (device, 日) 桶内成功点位之和(桶级去重,同桶重试不重复计)。
coupon_point_success = sum(point_buckets[k][1] for k in session_bucket_keys)
# 点位成功率 = 成功点位数 / (发起数 × 应领点位数):中途退出未跑到的点位不产生记录,
# 但发起数×点位数把它们计入分母 → 视为失败,符合产品口径;重试会拉低该率(分母按次数计)。
coupon_point_success_rate = (
round(
min(1.0, coupon_point_success / (coupon_started * coupon_points_per_session)), 4
)
if coupon_started and coupon_points_per_session
else None
)
return {
"users": {
"total": _count(User),
@@ -432,7 +563,7 @@ def dashboard_overview(
"reward_video_coin_total": _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
),
"reward_video_watch_count": _count(
AdRewardRecord,
@@ -480,11 +611,13 @@ def dashboard_overview(
"users": {
"new": len(period_new_user_ids),
"active": len(period_active_user_ids),
"retained_new_users": len(period_retained_new_user_ids),
"retained_new_users": retention_retained_total,
"retention_cohort": retention_cohort_total,
"retention_rate": period_retention_rate,
"retention_note": (
"口径:登录(last_login_at)+开始比价(real_compare_start)+"
"开始领券(real_coupon_start/claim_started),按用户去重"
"次日留存:窗口内每天取前一日新增用户,统计其当日活跃"
"(登录/开始比价/开始领券,按用户去重)比例,逐日累加;"
"默认窗口=昨日,即前日新增用户的昨日留存"
),
},
"comparison": {
@@ -495,6 +628,17 @@ def dashboard_overview(
"average_duration_ms": period_avg_duration_ms,
"average_saved_cents": period_avg_saved_cents,
},
"coupon": {
"started": coupon_started,
"all_success": coupon_all_success,
"success_rate": (
round(coupon_all_success / coupon_started, 4) if coupon_started else None
),
"point_success": coupon_point_success,
"points_per_session": coupon_points_per_session,
"point_success_rate": coupon_point_success_rate,
"median_elapsed_ms": _percentile(coupon_completed_elapsed, 50),
},
"coins": {
"granted_total": _sum(CoinTransaction.amount, *period_coin_conds),
"reward_video_coin_total": period_reward_video_coin_total,
+1
View File
@@ -91,6 +91,7 @@ def get_ad_revenue_report(
daily=[AdRevenueDaily(**d) for d in result["daily"]],
hourly=[AdRevenueHourly(**h) for h in result["hourly"]],
type_stats={k: AdRevenueTypeStat(**v) for k, v in result["type_stats"].items()},
scene_stats={k: AdRevenueTypeStat(**v) for k, v in result["scene_stats"].items()},
dau=result["dau"],
total=result["total"],
truncated=result["truncated"],
+28 -5
View File
@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_role
from app.admin.permissions import SUPER_ADMIN_ROLE
from app.admin.permissions import CUSTOM_ROLE, SUPER_ADMIN_ROLE, sanitize_pages
from app.admin.repositories import admin_role as role_repo
from app.admin.repositories import admin_user as admin_repo
from app.admin.schemas.admin import AdminCreateRequest, AdminUpdateRequest
@@ -26,14 +26,22 @@ def _active_super_count(db: AdminDb) -> int:
def _validate_role(db: AdminDb, role: str) -> None:
"""角色必须是 super_admin admin_role 表里已存在的角色,否则 400。"""
if role == SUPER_ADMIN_ROLE:
"""角色必须是 super_admin / custom(自定义) / admin_role 表里已存在的角色,否则 400。"""
if role in (SUPER_ADMIN_ROLE, CUSTOM_ROLE):
return
role_repo.ensure_builtin_roles(db) # 空表(测试/全新库)兜底播种,再校验
if role_repo.get_role(db, role) is None:
raise HTTPException(status_code=400, detail=f"角色不存在: {role}")
def _clean_override(role: str, pages_override: list[str] | None) -> list[str] | None:
"""按最终角色算出该存的 pages_override:custom → 勾选集(过滤非法 key,可空列表);
custom None(切回普通角色即清空自定义页)"""
if role == CUSTOM_ROLE:
return sanitize_pages(pages_override)
return None
@router.get("", response_model=list[AdminOut], summary="管理员列表(含明文密码,super_admin 专属)")
def list_admins(db: AdminDb) -> list[AdminOut]:
out: list[AdminOut] = []
@@ -51,13 +59,16 @@ def create_admin(
if admin_repo.get_by_username(db, body.username) is not None:
raise HTTPException(status_code=409, detail="用户名已存在")
_validate_role(db, body.role)
override = _clean_override(body.role, body.pages_override)
new = admin_repo.create_admin(
db, username=body.username, password=body.password, role=body.role,
plain_password=body.password, # UI 建的账号留存明文,供权限管理页复看
pages_override=override,
)
write_audit(
db, admin, action="admin.create", target_type="admin", target_id=new.id,
detail={"username": new.username, "role": new.role}, ip=get_client_ip(request), commit=True,
detail={"username": new.username, "role": new.role, "pages_override": override},
ip=get_client_ip(request), commit=True,
)
return AdminOut.model_validate(new)
@@ -89,7 +100,8 @@ def update_admin(
_validate_role(db, body.role)
changes: dict = {}
if body.role is not None and body.role != target.role:
role_changed = body.role is not None and body.role != target.role
if role_changed:
changes["role"] = {"before": target.role, "after": body.role}
target.role = body.role
if body.status is not None and body.status != target.status:
@@ -99,6 +111,17 @@ def update_admin(
changes["password"] = "reset"
target.password_hash = hash_password(body.password)
target.plain_password = body.password # 同步留存明文,权限管理页复看保持一致
# 自定义可见页:按「最终角色」(target.role,已应用完角色变更)决定该存什么。
# - 切到/维持 custom 且传了 pages_override → 用勾选集;切到 custom 没传 → 清成空列表。
# - 切回普通角色 → 清空 override(_clean_override 返回 None)。
# - 角色没变、只传 pages_override(编辑现有 custom 用户的勾选)→ 也更新。
if role_changed or body.pages_override is not None:
new_override = _clean_override(target.role, body.pages_override)
if new_override != target.pages_override:
changes["pages_override"] = {"before": target.pages_override, "after": new_override}
target.pages_override = new_override
if not changes:
raise HTTPException(status_code=400, detail="无任何变更字段")
db.commit()
+7 -2
View File
@@ -6,6 +6,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException
from app.admin.deps import AdminDb, CurrentAdmin
from app.admin.permissions import CUSTOM_ROLE, sanitize_pages
from app.admin.repositories import admin_role as role_repo
from app.admin.repositories import admin_user as admin_repo
from app.admin.schemas.auth import AdminLoginRequest, AdminLoginResponse, AdminOut
@@ -19,9 +20,13 @@ router = APIRouter(prefix="/admin/api/auth", tags=["admin-auth"])
def _admin_out_with_pages(admin, db: AdminDb) -> AdminOut: # noqa: ANN001
"""AdminOut + 当前角色有效可见页(前端左侧导航按此过滤)。"""
"""AdminOut + 有效可见页(前端左侧导航按此过滤)。
role=="custom" 用这个人的 pages_override(按人自定义,过滤悬空 key);其余走角色解析"""
out = AdminOut.model_validate(admin)
out.pages = role_repo.effective_pages_of(db, admin.role)
if admin.role == CUSTOM_ROLE:
out.pages = sanitize_pages(admin.pages_override)
else:
out.pages = role_repo.effective_pages_of(db, admin.role)
return out
+18
View File
@@ -19,6 +19,8 @@ from app.admin.schemas.ops_marquee_seed import (
OpsMarqueeSeedCreate,
OpsMarqueeSeedOut,
OpsMarqueeSeedUpdate,
OpsRealRecordItem,
OpsRealRecordsOut,
OpsSavingsFeedPreviewOut,
)
from app.models.admin import AdminUser
@@ -68,6 +70,22 @@ def preview_feed(
return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit, mode=mode))
@router.get("/real-records", response_model=OpsRealRecordsOut, summary="分页浏览当前模式下可展示的记录(审核用)")
def list_real_records(
db: AdminDb,
mode: Annotated[str | None, Query(description="mixed/real/seed;不传=当前持久化模式")] = None,
offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=50)] = 8,
) -> OpsRealRecordsOut:
"""分页列出**当前模式**下可在 app 轮播展示的全部记录(**不去重**):只真实=真实记录;只种子=各启用
种子按生成逻辑各出一行;混播=真实+种子 app 同口径洗牌+去连簇,固定种子翻页稳定能翻遍全部
item.user_id=0 表示种子行"""
if mode is not None and mode not in ops_marquee.FEED_MODES:
raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed")
items, total = ops_marquee.list_real_records(db, mode=mode, offset=offset, limit=limit)
return OpsRealRecordsOut(items=[OpsRealRecordItem(**it) for it in items], total=total)
# 注:/mode 两个端点须在 /{seed_id} 之前注册,否则 PATCH /mode 会被 /{seed_id} 抢先按 id 解析。
@router.get("/mode", summary="首页轮播数据源模式(mixed/real/seed)")
def get_feed_mode(db: AdminDb) -> dict:
+7 -1
View File
@@ -42,7 +42,12 @@ def list_users(
created_to: Annotated[datetime | None, Query()] = None,
last_login_from: Annotated[datetime | None, Query()] = None,
last_login_to: Annotated[datetime | None, Query()] = None,
sort_by: Annotated[str, Query(pattern="^(id|created_at|last_login_at)$")] = "id",
# 最近活跃(登录/发起比价/发起领券取最大,见 queries._last_active_expr)筛选与排序
last_active_from: Annotated[datetime | None, Query()] = None,
last_active_to: Annotated[datetime | None, Query()] = None,
sort_by: Annotated[
str, Query(pattern="^(id|created_at|last_login_at|last_active_at)$")
] = "id",
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
@@ -51,6 +56,7 @@ def list_users(
db, phone=phone, register_channel=register_channel, status=status,
nickname=nickname, created_from=created_from, created_to=created_to,
last_login_from=last_login_from, last_login_to=last_login_to,
last_active_from=last_active_from, last_active_to=last_active_to,
sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor,
)
return CursorPage(
+5
View File
@@ -136,6 +136,11 @@ class AdRevenueReportOut(BaseModel):
default_factory=dict,
description="按广告类型(ad_type)小计 {ad_type: {impressions, revenue_yuan}};前端取 draw / reward_video 做分类大盘",
)
scene_stats: dict[str, AdRevenueTypeStat] = Field(
default_factory=dict,
description="按信息流场景(feed_scene)小计 {comparison/coupon/welfare: {impressions, revenue_yuan}};"
"全量统计(不受分页截断),供数据大盘「领券广告 / 比价广告」卡;feed_scene 为空的事件不计入",
)
dau: int | None = Field(
None,
description="所选日期区间的去重活跃用户数(口径同数据大盘 period.users.active:登录 + 开始比价 + "
+5 -1
View File
@@ -13,14 +13,18 @@ class AdminCreateRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=64)
password: str = Field(..., min_length=8, max_length=72) # bcrypt ≤72 字节
role: str = Field("operator", min_length=1, max_length=32)
# 仅当 role == "custom":这个人专属可见页 key 列表(逐页勾选结果)。其余角色不传/忽略。
pages_override: list[str] | None = None
class AdminUpdateRequest(BaseModel):
"""改角色 / 启用禁用 / 重置密码,字段都可选(只改传了的)。"""
"""改角色 / 启用禁用 / 重置密码 / 自定义可见页,字段都可选(只改传了的)。"""
role: str | None = Field(None, min_length=1, max_length=32)
status: Literal["active", "disabled"] | None = None
password: str | None = Field(None, min_length=8, max_length=72)
# 改成/更新「自定义」可见页;role 切回普通角色时后端会清空 override(见路由)。
pages_override: list[str] | None = None
class AdminAuditLogOut(BaseModel):
+4 -1
View File
@@ -21,8 +21,11 @@ class AdminOut(BaseModel):
created_at: datetime
last_login_at: datetime | None = None
# 该管理员当前角色的有效可见页(= 左侧导航项 key);仅登录 / /me 填充,列表接口默认空。
# 前端据此过滤左侧导航(super_admin = 全部页)。见 app/admin/permissions.py。
# 前端据此过滤左侧导航(super_admin = 全部页;role=="custom" = pages_override)。见 permissions.py。
pages: list[str] = []
# 「自定义」可见页原始勾选集(role=="custom" 时非空);列表接口下发,供权限管理页编辑回填勾选。
# 普通角色为 None。from_attributes 自动从 ORM 列取。
pages_override: list[str] | None = None
# 明文登录密码:仅「管理员账号列表」(super_admin 专属路由)填充,供权限管理页编辑时复看;
# 无留存(脚本建的超管 / 旧账号)为 None → 前端不显示。登录 / /me 不下发(保持 None)。
password: str | None = None
+22
View File
@@ -43,7 +43,10 @@ class DashboardComparison(BaseModel):
class DashboardPeriodUsers(BaseModel):
new: int
active: int
# 次日留存(2026-07-05 起):retained_new_users = 窗口内逐日「前一日新增且当日活跃」用户数之和,
# retention_cohort = 对应的前一日新增基数之和,retention_rate = 两者之比。
retained_new_users: int
retention_cohort: int = 0
retention_rate: float | None = None
retention_note: str
@@ -57,6 +60,24 @@ class DashboardPeriodComparison(BaseModel):
average_saved_cents: int | None = None
class DashboardPeriodCoupon(BaseModel):
"""领券核心数据(2026-07-05 产品新增)。点位=一张券(coupon_claim_record 一天一条终态);
成功口径 success+already_claimed(我的页累计领券一致)"""
started: int = 0
# 全部领成功的次数:completed 且当日该设备全部点位成功
all_success: int = 0
success_rate: float | None = None
# 本期 session 触达的点位中成功的条数(同设备同日去重)
point_success: int = 0
# 每次发起的应领点位数(本期完成场实际点位数的众数;无完成场为空)
points_per_session: int | None = None
# 点位成功率 = point_success / (started × points_per_session);未跑到的点位计入分母视为失败
point_success_rate: float | None = None
# 耗时中位数(仅 completed 的 elapsed_ms,同「领券数据」页口径)
median_elapsed_ms: int | None = None
class DashboardPeriodCoins(BaseModel):
granted_total: int
reward_video_coin_total: int = 0
@@ -85,6 +106,7 @@ class DashboardPeriod(BaseModel):
date_to: date
users: DashboardPeriodUsers
comparison: DashboardPeriodComparison
coupon: DashboardPeriodCoupon = DashboardPeriodCoupon()
coins: DashboardPeriodCoins
cash: DashboardPeriodCash
trend: list[DashboardTrendPoint] = []
+13
View File
@@ -62,3 +62,16 @@ class OpsSavingsFeedPreviewItem(BaseModel):
class OpsSavingsFeedPreviewOut(BaseModel):
"""运营预览:实际混播出来的 feed(真实记录会插队,与客户端一致)。"""
items: list[OpsSavingsFeedPreviewItem]
class OpsRealRecordItem(BaseModel):
masked_user: str # 脱敏后展示名(与 app 一致)
saved_amount_cents: int # 节省金额(分)
created_at: str # 比价记录时间(YYYY-MM-DD HH:MM)
user_id: int # 真实 user_id(供运营核对,不下发客户端)
class OpsRealRecordsOut(BaseModel):
"""分页浏览「全部可展示的真实记录」(success+省>0,不去重、稳定顺序)。"""
items: list[OpsRealRecordItem]
total: int # 满足条件的真实记录总数(算页数用)
+3
View File
@@ -20,6 +20,9 @@ class AdminUserListItem(BaseModel):
wechat_nickname: str | None = None
created_at: datetime
last_login_at: datetime
# 最近活跃 = max(最近登录, 最近发起比价, 最近发起领券);列表页由 queries._attach_last_active
# 瞬态挂上。其他复用本 schema 的入口(用户 360 等)没挂该属性 → None(前端显示 '-')。
last_active_at: datetime | None = None
class AdminUserOverview(BaseModel):
+4 -1
View File
@@ -28,8 +28,11 @@ class AdminUser(Base):
# 明文登录密码:仅「后台 UI 创建/重置」的管理员留存,供超管在权限管理页复看转交。
# 脚本/起后台时建的超管账号不写(为 None → 前端「不显示密码」)。⚠️ 内部工具便利取舍,见 create/list。
plain_password: Mapped[str | None] = mapped_column(String(128), nullable=True)
# super_admin(全权+管账号)/ finance(钱:提现+金币)/ operator(用户+反馈+大盘)
# super_admin(全权+管账号)/ finance(钱:提现+金币)/ operator(用户+反馈+大盘)/ custom(按人自定义)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="operator")
# 「自定义」权限:仅当 role == "custom" 时有效,存这个人专属的可见页 key 列表(不共享给他人)。
# 非 custom 用户为 None → 可见页跟随角色。登录/`/me` 下发有效页时,非空即优先用它(见 auth._admin_out_with_pages)。
pages_override: Mapped[list | None] = mapped_column(_JSON, nullable=True)
# active / disabled
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
+93 -11
View File
@@ -217,12 +217,28 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]:
return out
def _shuffle_declustered(rows: list, rng: random.Random | None = None) -> list:
"""洗牌 + 「去连簇」:先洗牌,再贪心重排让相邻两条尽量不是同一 user_id(元素 [0] 即 user_id)。
rng=None 用全局 _rng(feed 每次新随机);传入 rng(如固定种子 Random) 排列确定(admin 稳定分页)
减少同一用户连续出现;只有少数几个用户时 best-effort"""
rng = rng or _rng
pool = list(rows)
rng.shuffle(pool)
result: list = []
while pool:
prev_uid = result[-1][0] if result else None
# 优先挑与上一条不同 user 的;挑不到(只剩同 user)才取第一个
idx = next((i for i, r in enumerate(pool) if r[0] != prev_uid), 0)
result.append(pool.pop(idx))
return result
def get_feed(db: Session, limit: int = 8, mode: str | None = None) -> list[dict]:
"""返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。
mode:显式传入(admin 预览指定模式)则用它**不改持久化配置**;不传(客户端 /savings-feed)
持久化的 marquee_feed_mode;非法值一律回退到持久化模式
真实条:success 0 < saved 上限, user 去重(同一用户只取最新一条,避免单人刷屏)
真实条:success 0 < saved 上限,** user 去重**(打乱 + 去连簇:相邻尽量不同用户减少单人连刷)后取前 limit
不足用启用的种子补齐**公平随机抽取** need (而非固定取前 N),让所有种子都有机会露出;
种子用户名留空则随机合成(避开撞名),金额取**长尾随机**(小额居多偶尔大额,更像真实分布)
真实 + 种子仍不满 limit 内置合成条**补满**,保证轮播既不空也不稀疏
@@ -235,19 +251,13 @@ def get_feed(db: Session, limit: int = 8, mode: str | None = None) -> list[dict]
items: list[dict] = []
used_names: set[str] = set()
# 真实条(mixed / real):取较多近期记录(带 ~30s 缓存)后按 user 去重;金额超上限的异常值已在查询剔除。
# 真实条(mixed / real):**不按 user 去重**——打乱 + 去连簇(相邻尽量不同用户、减少单人连刷)后取前
# limit;金额超上限的异常值已在查询剔除。同一用户可多条露出,但被打散、尽量不连续。
if mode != "seed":
rows = _recent_real_rows(db)
seen_users: set[int] = set()
for uid, sc, nick in rows:
if uid in seen_users:
continue
seen_users.add(uid)
for uid, sc, nick in _shuffle_declustered(_recent_real_rows(db))[:limit]:
name = _mask_real(nick, uid)
used_names.add(name) # 真实名按昵称/id 稳定;偶发撞名可接受
used_names.add(name) # 同一用户可多条,名字重复无害(used_names 仅供种子避重)
items.append({"masked_user": name, "saved_amount_cents": int(sc)})
if len(items) >= limit:
break
# 种子补位 + 合成兜底(mixed / seed):mixed 下补真实不足的部分,seed 下全量用种子/合成。
# real 模式**跳过**——只出真实,不掺任何假数据(真实不足则少于 limit,为 0 时返回空)。
@@ -300,6 +310,78 @@ def get_feed(db: Session, limit: int = 8, mode: str | None = None) -> list[dict]
return items
# ===== admin 侧:分页浏览「当前模式下可展示的记录」(审核用,不去重) =====
_REAL_BROWSE_CAP = 1000 # 真实记录一次最多纳入这么多去洗牌+分页(足够审核;防超大库全量洗牌)
_BROWSE_SEED = 20260707 # 固定洗牌种子:同一批数据下排列恒定 → 翻页稳定、能翻遍全部
def _seed_browse_row(seed: OpsMarqueeSeed) -> dict:
"""把一条种子按其「生成逻辑」**确定性**生成一行浏览项(名字/金额按 seed.id 派生固定种子 → 翻页稳定)。
名字:运营手填的非模板名原样用,否则本地合成;金额:区间内确定性长尾取值user_id=0(种子无真实用户)"""
r = random.Random(_BROWSE_SEED * 1_000_003 + int(seed.id))
fixed = (seed.masked_user or "").strip()
name = fixed if (fixed and not fixed.startswith("用户****")) else _synth_name(r)
lo = max(0, int(seed.min_cents))
hi = max(lo, int(seed.max_cents))
amt = lo if hi <= lo else lo + int(round((hi - lo) * (r.random() ** 2.2)))
return {"masked_user": name, "saved_amount_cents": amt, "created_at": "", "user_id": 0}
def list_real_records(
db: Session, mode: str | None = None, offset: int = 0, limit: int = 8
) -> tuple[list[dict], int]:
"""分页浏览「**当前模式**下可在 app 轮播展示的全部记录」,供 admin 逐页审核(**不去重**):
- 只真实:全部 success+>0 的真实记录;
- 只种子:每条启用种子按生成逻辑各出一行;
- 混播:真实 + 种子 全部合在一起
app 轮播同口径:先洗牌 + 去连簇(相邻尽量不同 user;种子各自独立不算同 user);**固定种子**
同批数据下排列恒定,翻页不跳能翻遍全部返回 (items, total);item.user_id=0 表示种子"""
mode = mode if mode in FEED_MODES else get_feed_mode(db)
# pool: [(cluster_key, item)];cluster_key 供去连簇——真实=user_id、种子=各自唯一负数(互不聚簇)
pool: list[tuple[int, dict]] = []
if mode != "seed":
rows = db.execute(
select(
ComparisonRecord.user_id,
ComparisonRecord.saved_amount_cents,
User.nickname,
ComparisonRecord.created_at,
)
.join(User, User.id == ComparisonRecord.user_id)
.where(
ComparisonRecord.status == "success",
ComparisonRecord.saved_amount_cents > 0,
ComparisonRecord.saved_amount_cents <= _REAL_MAX_CENTS,
)
.order_by(ComparisonRecord.created_at.desc())
.limit(_REAL_BROWSE_CAP)
).all()
for uid, sc, nick, ca in rows:
pool.append((
int(uid),
{
"masked_user": _mask_real(nick, int(uid)),
"saved_amount_cents": int(sc),
"created_at": str(ca)[:16] if ca is not None else "",
"user_id": int(uid),
},
))
if mode != "real":
seeds = (
db.execute(select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True)))
.scalars()
.all()
)
for i, s in enumerate(seeds):
pool.append((-(i + 1), _seed_browse_row(s))) # 每个种子唯一 key → 互不聚簇
total = len(pool)
# 每次用同一固定种子新建 Random → 同批数据排列恒定(翻页稳定);同时相邻尽量不同 user。
ordered = _shuffle_declustered(pool, random.Random(_BROWSE_SEED))
off = max(0, offset)
items = [item for _key, item in ordered[off : off + limit]]
return items, total
# ===== 运营侧:种子 CRUD =====
def list_seeds(db: Session) -> list[OpsMarqueeSeed]:
return list(
+40 -44
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-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 入参/出参示例
> 最后更新:2026-07-09(① 比价透传改「软鉴权 + trace_id 签发 + harvest 落库」(#112 尾声帧 `trace/epilogue` 一并补录);② 新端点:`user/onboarding/reset`(#114)、`GET /internal/launch-confirm-samples`(#91);③ 参数更新:提现族 `source` 分账(#82/#121)、`wallet/account` 邀请奖励金余额、美团 feed/top-sales 按城市过滤(#116)、admin 调现金 `account` 目标账户(#95);④ **Admin 索引补全到当前全量**:新家族 roles(#117/#126)/coupon-data(#99)/device-liveness(#80)/event-logs(#83)/price-reports(#94)/CPS 运营台/提现审核族, feedbacks 采纳拒绝(#94/#105)、marquee 模式与真实条浏览(#122/#123)等。上一次 2026-07-03
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
---
@@ -27,17 +27,18 @@
| 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 看板数据源) |
| 8h | `POST /api/v1/coupon/session` | 无 | [详情](./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) |
| 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan/meituan-feed.md)`rec` tab 离线库 + **按城市过滤** #116 |
| 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` | | [详情](./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 |
| 11a | `POST /api/v1/meituan/top-sales` | 无 | [详情](./meituan/meituan-top-sales.md)同城销量榜:离线库按销量降序 + 跨源去重 + 城市过滤 #116,不实时打美团) |
| **比价透传**(前缀 `/api/v1`,透传 pricebot-backend;**软鉴权 OptionalUser** + 首帧签发 trace_id + harvest 落 `comparison_record`,2026-07 起不再是纯透传 |||
| 12 | `POST /api/v1/intent/recognize` | | [详情](./intent/compare-intent-recognize.md)(Phase 1 意图识别,单次,多数源;mint 帧建 running 行 |
| 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 步进;done 帧 harvest 写终态 |
| 13a | `POST /api/v1/trace/finalize` | | [详情](./other/trace-finalize.md)(比价 trace 收尾上云 + 夭折落库,终止/未识别拿 trace_url |
| 13b | `POST /api/v1/trace/epilogue` | 软 | [详情](./other/trace-finalize.md)(结果页尾声帧:App 结果页截图入 trace,纯透传不落库,#112 |
| **比价记录**(前缀 `/api/v1/compare`;按用户落库,**鉴权**,区别于上面不鉴权的透传) |||
| 12a | `POST /api/v1/compare/record` | Bearer | [详情](./compare/compare-record-report.md) |
| 12b | `GET /api/v1/compare/records` | Bearer | [详情](./compare/compare-records.md) |
@@ -54,7 +55,7 @@
| **上报更低价**(前缀 `/api/v1/report`;众包纠偏,人工审核发奖) |||
| 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 万金币 |||
| **好友邀请**(前缀 `/api/v1/invite`绑定注册即生效但**不发奖**,#113 起好友「比价并下单」才给邀请人发**邀请奖励金**,经 `POST /order/report` 触发 |||
| 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) |
@@ -68,9 +69,9 @@
| 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) |
| 22 | `POST /api/v1/wallet/withdraw` | Bearer | [详情](./wallet/wallet-withdraw.md)`source` 分账:coin_cash / invite_cash,#121 |
| 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) |
| 24 | `GET /api/v1/wallet/withdraw-orders` | Bearer | [详情](./wallet/wallet-withdraw-orders.md)(可按 `source` 过滤) |
| 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)(关闭免确认到账,解除授权) |
@@ -99,6 +100,7 @@
| 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 删记录即触发重走) |
| 36c | `POST /api/v1/user/onboarding/reset` | Bearer | [详情](./user/user-onboarding.md)(重置本设备引导标记,下次登录重走,#114 |
| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user/user-delete.md) |
| **帮助与反馈**(前缀 `/api/v1/feedback` |||
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./other/feedback.md) |
@@ -127,43 +129,37 @@
| 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` |
| N7 | `GET /internal/launch-confirm-samples` | 内部密钥 | [详情](./internal/internal.md)(样本列表,供 pricebot distill 脚本聚合沉淀回静态规则,#91 |
| **静态资源**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/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)(广告收益报表:按用户/日期/类型/应用/代码位 聚合 条数/收益/金币,只读) |
| **运营后台 Admin**(独立子应用 `app/admin/`,前缀 `/admin/api`,独立进程 + 独立 admin JWT。鉴权列:`admin`=任意已登录管理员,`operator`/`finance`/`super_admin`=需对应角色;#117 起可见页由 [admin_role](../database/admin_role.md) 数据驱动,`super_admin` 恒通过) |||
| A1 | `POST /admin/api/auth/login` · `GET /auth/me` | 无 / admin | [详情](./admin/auth/admin-auth-login.md) / [me](./admin/auth/admin-auth-me.md)me 返回有效可见页 `pages` |
| A2 | `GET /admin/api/stats/overview` | admin | [详情](./admin/admin-stats-overview.md)(大盘核心指标;#103 按 trace 聚合 + 京东收益 #90 + feed_scene 口径 #125 |
| A3 | `GET /admin/api/event-logs` | admin | [详情](./admin/admin-event-logs.md)(埋点日志检索,#83 |
| **A·用户**:`GET /users`(筛选排序分页)、`GET /users/{id}`(360 详情)、`GET /{id}/reward-stats` + `GET /{id}/coin-records`(提现详情联查)、`POST /{id}/status`(封禁)、`POST /{id}/debug-trace`(调试链接权限)、`POST /{id}/coins``POST /{id}/cash`(#95 `account` 目标账户) ||| [列表](./admin/users/admin-users-list.md) / [详情](./admin/users/admin-user-detail.md) / [状态+debug-trace](./admin/users/admin-user-status.md) / [金币](./admin/users/admin-user-coins.md) / [现金](./admin/users/admin-user-cash.md) |
| A4 | `GET /admin/api/wallet/coin-transactions` / `cash-transactions` | admin | [金币](./admin/wallet/admin-wallet-coin-transactions.md) / [现金](./admin/wallet/admin-wallet-cash-transactions.md) |
| **A·提现审核台**:`GET /withdraws`(列表)、`/summary``/health-check`(finance)、`/ledger-check`(#121 分账对账)、`/{out_bill_no}`(详情)、`POST /reconcile`、单笔 `refresh`/`approve`/`reject`、批量 `bulk/refresh`/`bulk/approve`/`bulk/reject` ||| [列表](./admin/withdraws/admin-withdraws-list.md) / [审核族](./admin/withdraws/admin-withdraw-review.md) / [对账](./admin/withdraws/admin-withdraw-reconcile.md) / [查单](./admin/withdraws/admin-withdraw-refresh.md) |
| **A·反馈**:`GET /feedbacks``/summary``POST /{id}/approve`(采纳发币 #94)、`/{id}/reject``/{id}/handle` ||| [列表](./admin/feedbacks/admin-feedbacks-list.md) / [审核族](./admin/feedbacks/admin-feedback-handle.md) |
| A5 | `GET`/`PATCH` `/admin/api/feedback-config`,`POST`/`DELETE` `…/image` | operator | 反馈页「加群二维码」卡配置(admin 侧;C 端读见 38a)(无单独文档,见 `app/admin/routers/feedback_qr.py`) |
| **A·上报更低价**:`GET /price-reports``/summary``POST /{id}/approve|reject`(#94) ||| [审核族](./admin/admin-price-reports.md) |
| A6 | `GET /admin/api/comparison-records`(+`/{id}` 详情) | admin | 比价记录检索(按 user/phone/**店与商品名模糊搜** #117 筛;详情含 LLM 调用明细)(无单独文档,见 `app/admin/routers/comparison.py`) |
| A7 | `GET /admin/api/coupon-data`(+`/user-records`) | admin | [详情](./admin/admin-coupon-data.md)(领券数据看板,#99) |
| A8 | `GET /admin/api/device-liveness`(+`/stats`) | admin | [详情](./admin/admin-device-liveness.md)(设备存活监控,#80) |
| A9 | `GET /onboarding/devices``POST /devices/{id}/reset``POST /reset-all` | operator | 新手引导记录管理(按设备聚合/重置)(无单独文档,见 `app/admin/routers/onboarding.py`) |
| **A·轮播**:`GET /marquee-seeds``/preview``/real-records`(#123)、`GET`/`PATCH` `/mode`(#122,模式落 `app_config`)、`POST`(+`/bulk``/batch-delete``/batch-enable`)、`PATCH`/`DELETE` `/{seed_id}` ||| [详情](./admin/admin-marquee-seeds.md) |
| A10 | `GET / PATCH /admin/api/dashboard-display` | admin / operator | [详情](./admin/admin-dashboard-display.md)(首页三统计配置) |
| A11 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin/ad/admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) |
| A12 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin/ad/admin-ad-revenue-report.md)(广告收益报表:分页/场景/`app_env` 筛 + **DAU/ARPU** #120;真实收益侧接穿山甲日表 #92) |
| A13 | `GET / PATCH /admin/api/ad-config` | operator/finance | 广告配置(穿山甲 ID/验签密钥/各场景开关;C 端只读版见 40b)(无单独文档,见 `app/admin/routers/ad_config.py`) |
| A14 | `GET /admin/api/config``PATCH /config/{key}` | operator/finance | 运营可配置项([app_config](../database/app_config.md):奖励常量/提现地板价等;#117 修系统配置下发)(无单独文档,见 `app/admin/routers/config.py`) |
| **A·管理员与角色**(super_admin):`GET`/`POST` `/admins``PATCH`/`DELETE` `/admins/{id}`(#126 删除+`pages_override`)、`GET`/`POST` `/roles``GET /roles/catalog``PATCH`/`DELETE` `/roles/{id}`(#117/#126 自定义角色) ||| [列表](./admin/admins/admin-admins-list.md) / [](./admin/admins/admin-admin-create.md) / [改+删](./admin/admins/admin-admin-update.md) / [角色](./admin/admin-roles.md) |
| A15 | `GET /admin/api/audit-logs` | admin | [详情](./admin/admin-audit-logs.md) |
| **A·CPS 运营台**:群/活动 CRUD、`POST /referral-links``POST /orders/reconcile`(美团+京东 #90)、`GET /orders``/stats`、群 `timeseries`/`daily`/`wx-users`/`day-users`(#79) ||| [详情](./admin/admin-cps.md) |
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link``sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
> `coupon/step` 及外卖比价的 `intent/recognize``intent/precoupon/step``intent/step``price/step``trace/finalize` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——`app/api/v1/compare.py`)。
> `coupon/step` 透传到 pricebot-backend,**仍不鉴权**(device_id 区分设备,待补 JWT)。外卖比价透传族(`intent/*``price/step``trace/finalize|epilogue`)2026-07 起改**软鉴权 OptionalUser**:带 Bearer 则比价记录绑 `user_id`,不带也放行;并由 app-server 首帧签发 `trace_id` + harvest 落 `comparison_record`(`app/api/v1/compare.py` 模块注释)。
> 福利相关业务接口(wallet/signin/tasks/savings、`ad/reward-status``ad/feed-reward`)均需 **Bearer**;`wallet/exchange-info` 是静态规则无鉴权;`ad/pangle-callback` 不走 JWT、靠穿山甲**验签**;`ad/test-grant` **仅本地联调**(开关控制,生产 404)。
> 金额字段一律以**分**为单位(`*_cents`)。
+17
View File
@@ -0,0 +1,17 @@
# /admin/api/coupon-data — 领券数据看板(#99)
> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin(任意已登录管理员) | 表 [coupon_session](../../database/coupon_session.md) | [← 返回 API 索引](../README.md)
数据源是客户端两段上报的 `coupon_session`(一次领券任务一行:发起建行/收尾更新)。看板量化:发起数、完成率、**中途流失**(started 无终态)、平均/分位耗时、各平台耗时、机型/ROM 维度。
## 端点
| 方法 + 路径 | 说明 |
|---|---|
| `GET /admin/api/coupon-data` | 看板聚合:发起/完成数 + 耗时分位 + 按天趋势 + 逐条明细(按 `started_date` 区间 + `app_env` 筛,默认只看 prod 防测试数据串台) |
| `GET /admin/api/coupon-data/user-records` | 某用户全部领券记录(用户列表点手机号抽屉:领券次数 + 记录列表) |
## 说明
- 明细行 LEFT JOIN `user` 出手机号/昵称(匿名领券行 user 列为空)。
- 「发起平台」列按 `origin_package` 区分:空=App 内首页发起,包名=从对应外卖 App 弹券引导发起。
- 耗时口径:`elapsed_ms` 客户端全程计时(只统计 completed)。
+28
View File
@@ -0,0 +1,28 @@
# /admin/api/cps — CPS 群发联盟运营台(群/活动/短链/对账)
> 所属:Admin 子应用(前缀 `/admin/api/cps`) | 鉴权:读=admin,写=operator/finance(对账) | 表 [cps_group](../../database/cps_group.md) / [cps_activity](../../database/cps_activity.md) / [cps_link](../../database/cps_link.md) / [cps_click](../../database/cps_click.md) / [cps_order](../../database/cps_order.md) / [cps_wx_user](../../database/cps_wx_user.md) | [← 返回 API 索引](../README.md)
>
> 业务与授权流程详见 [guides/CPS发券分发与微信授权](../../guides/CPS发券分发与微信授权.md);C 端落地短链见 [cps-redirect](../other/cps-redirect.md)。
私域社群 CPS 的完整运营链:建群(拿 `sid`)→ 建活动(券/物料)→ 生成群发短链 `/c/{code}` → 用户点击/复制口令 → 联盟订单按 `sid` 归群对账,汇成「点击→下单→佣金」漏斗。
## 端点
| 方法 + 路径 | 说明 |
|---|---|
| `GET / POST /admin/api/cps/groups`,`PATCH / DELETE /groups/{id}` | 推广群 CRUD;含美团平台的群自动分配 `sid` |
| `GET / POST /admin/api/cps/activities`,`PATCH / DELETE /activities/{id}` | 可推广活动 CRUD(美团 actId / 淘宝淘口令 / 京东链接) |
| `POST /admin/api/cps/upload-image``GET /activity-images` | 活动落地页图上传 / 已有图列表(新建复用) |
| `POST /admin/api/cps/referral-links` | 批量生成群×活动短链(美团经 sid 转链) |
| `POST /admin/api/cps/orders/reconcile` | 拉联盟订单对账(美团 `query_order` + 京东联盟 #90,`order_id` 幂等 upsert;finance) |
| `GET /admin/api/cps/orders` | 订单明细(游标分页,可按 sid / 状态筛) |
| `GET /admin/api/cps/stats` | 按群对账统计(点击/订单/GMV/预估与结算佣金) |
| `GET /admin/api/cps/groups/{id}/timeseries` | 群点击时序(天/小时级 PV/UV/复制,折线图) |
| `GET /admin/api/cps/groups/{id}/daily` | 群每天明细大表格(点击+订单按天合并;#79 起支持按天下钻) |
| `GET /admin/api/cps/groups/{id}/wx-users` | 群内微信用户(领券画像:头像/昵称/领券次数) |
| `GET /admin/api/cps/groups/{id}/day-users` | 某天该群按用户的领券/点击 + 每人点过的券(#79) |
## 说明
- 订单与点击**只能在群(sid)维度汇合**,无法对到单笔(联盟只回传 sid)。
- 京东单有效性按 `jd_valid_code`,美团按 `mt_status`(4 取消/5 风控不计佣,6 结算才到账);淘宝无对账 API,对账列显示 `-`
- #119 修美团 `pay_time` 入库为空导致大盘时间窗漏算。
+15
View File
@@ -0,0 +1,15 @@
# /admin/api/device-liveness — 设备存活监控(#80)
> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin | 表 [device_liveness](../../database/device_liveness.md) | [← 返回 API 索引](../README.md)
无障碍保护存活的后台视角:哪些设备开过保护(`ever_protected`)、现在在线还是掉线(心跳超时,#107 起阈值 1 小时)、首次开启时间(`first_protected_at`)。
## 端点
| 方法 + 路径 | 说明 |
|---|---|
| `GET /admin/api/device-liveness/stats` | 顶部卡片统计:设备总数 / 开过保护 / 当前在线 / 掉线数 |
| `GET /admin/api/device-liveness` | 设备存活列表(游标分页):在线情况/设备 id/归属用户 筛选 + 排序,**默认掉线置顶**;行含最近心跳、首次开启、App 版本、push token 有无 |
## 说明
- 「在线」= `last_heartbeat_at` 距今 < 超时阈值;掉线召回链路(worker 置 `kill_alert_pending` → 客户端 pull)见表文档。
+15
View File
@@ -0,0 +1,15 @@
# /admin/api/event-logs — 埋点日志(#83)
> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin | 表 [analytics_event](../../database/analytics_event.md) | [← 返回 API 索引](../README.md)
客户端埋点(`POST /api/v1/analytics/events` 批量上报)的后台检索页。
## 端点
| 方法 + 路径 | 说明 |
|---|---|
| `GET /admin/api/event-logs` | 埋点事件列表(游标分页):可按 `event` / `device_id` / `user_id` 筛;行含事件名、props、页面、机型/系统/网络、client_ts |
## 说明
- 纯只读;无聚合报表(要分析导出后自己算)。
- 时间轴用 `client_ts`(事件真实发生时刻),入库时间受客户端攒批影响。
+14 -3
View File
@@ -2,7 +2,7 @@
> 所属: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 显示元)。
管理首页轮播「真实+种子混播」的兜底种子。种子是「生成规则」:`masked_user` 可空(空→feed 随机合成名)、金额是 `[min_cents, max_cents]` 区间(feed 每次随机取值)。用户侧 feed 见 [platform-savings-feed](../savings/platform-savings-feed.md);表见 [ops_marquee_seed](../../database/ops_marquee_seed.md)。金额单位:分(前端 ÷100 显示元)。
## 复用结构 OpsMarqueeSeedOut
| 字段 | 类型 | 说明 |
@@ -19,9 +19,20 @@
出参 `200`:`list[OpsMarqueeSeedOut]`(按 `sort_order,id`)。
## GET /admin/api/marquee-seeds/preview — 预览实际混播 feed
预览客户端实际会看到的轮播(真实记录会插队、种子随机抽取 / 金额随机 / 名字合成),供运营对效果。**含随机,每次结果不同**。
预览客户端实际会看到的轮播(真实记录会插队、种子随机抽取 / 金额随机 / 名字合成),供运营对效果。**含随机,每次结果不同**;#122 起按**当前数据源模式**实时预览(mixed/real/seed 各自的真实产出)
- 入参:`limit`(query,1~30,默认 8)
- 出参 `200`:`{"items": [{masked_user, saved_amount_cents, time}]}`(条目同 [platform-savings-feed](./platform-savings-feed.md))
- 出参 `200`:`{"items": [{masked_user, saved_amount_cents, time}]}`(条目同 [platform-savings-feed](../savings/platform-savings-feed.md))
## GET /admin/api/marquee-seeds/real-records — 分页浏览当前模式下可展示的真实记录(#123)
审核用:看「真实条」到底会拿哪些 `comparison_record` 上轮播(真实条**不按用户去重**,打乱+去连簇后混播;默认昵称归「无昵称」脱敏档,#122)。
- 入参:`limit` / `cursor`(游标分页)
- 出参 `200`:`{"items": [...], "next_cursor": int|null}`
## GET /admin/api/marquee-seeds/mode — 首页轮播数据源模式
出参:`{"mode": "mixed" | "real" | "seed"}`(混播 / 只真实 / 只种子)。
## PATCH /admin/api/marquee-seeds/mode — 改数据源模式(带审计)
- 入参:`{"mode": "mixed" | "real" | "seed"}`;operator 起。改动客户端重拉 feed 生效。
## POST /admin/api/marquee-seeds — 新增(带审计)
入参 `OpsMarqueeSeedCreate`:`masked_user`(可选,空 / 不传 → 随机合成)、`min_cents`(必填,≥0)、`max_cents`(必填,≥min,≤1000 元)、`enabled`(默认 true)、`sort_order`(默认 0)。出参:新建的 `OpsMarqueeSeedOut``400`=金额非法。
+16
View File
@@ -0,0 +1,16 @@
# /admin/api/price-reports — 上报更低价审核(#94)
> 所属:Admin 子应用(前缀 `/admin/api/price-reports`) | 鉴权:读=admin,审=operator | 表 [price_report](../../database/price_report.md) | [← 返回 API 索引](../README.md)
>
> C 端提交/查询见 [report-submit](../other/report-submit.md) / [report-records](../other/report-records.md)。
用户众包「上报更低价」的人工审核台:审截图与价格,通过发固定金币。
## 端点
| 方法 + 路径 | 说明 |
|---|---|
| `GET /admin/api/price-reports` | 上报列表(状态筛选 + 游标分页,含截图、关联比价记录快照) |
| `GET /admin/api/price-reports/summary` | 审核统计(pending/approved/rejected 计数) |
| `POST /admin/api/price-reports/{report_id}/approve` | 通过 → 发固定金币(`grant_coins` 同事务)+ 带审计 |
| `POST /admin/api/price-reports/{report_id}/reject` | 拒绝(填原因,用户端可见)+ 带审计 |
+19
View File
@@ -0,0 +1,19 @@
# /admin/api/roles — 角色与可见页管理(RBAC,#117/#126)
> 所属:Admin 子应用(前缀 `/admin/api`,独立 admin JWT) | 鉴权:**super_admin** | 表 [admin_role](../../database/admin_role.md) | [← 返回 API 索引](../README.md)
后台 RBAC 的数据驱动层:内建三角色(`super_admin`/`finance`/`operator`,`is_builtin=true` 不可删)+ **自定义角色**(勾任意页面组合)。管理员的有效可见页 = `admin_user.pages_override`(个人覆盖,非空优先)∪ 否则取其角色 `pages`;`super_admin` 恒全通。
## 端点
| 方法 + 路径 | 说明 |
|---|---|
| `GET /admin/api/roles` | 角色列表:`[{id, name, label, pages, is_builtin, admin_count}]`(含每个角色的使用人数) |
| `GET /admin/api/roles/catalog` | 页面权限目录(分组):全部可勾选的页面 key(来自 `app/admin/permissions.py` 常量,不落库),前端渲染勾选面板用 |
| `POST /admin/api/roles` | 新增自定义角色 `{name, pages}`(**name(key)= label = 输入名称**;不能叫 `super_admin`,重名 `409`);带审计 |
| `PATCH /admin/api/roles/{role_id}` | 改展示名/可见页(内建角色的 `pages` 也可调);带审计 |
| `DELETE /admin/api/roles/{role_id}` | 删角色;**内建(`is_builtin`)与在用(有 `admin_user.role` 引用)不可删**(400);带审计 |
## 说明
- 管理员个人覆盖在 [`PATCH /admin/api/admins/{id}`](./admins/admin-admin-update.md) 的 `pages_override` 字段改,不在本组。
- 加新后台页面要同步登记 `permissions.py` 目录,表里只存勾选结果(目录变更无需迁移)。
+12 -5
View File
@@ -8,12 +8,13 @@
|---|---|---|---|
| `admin_id` | int | ✓ | 目标管理员 id |
**application/json**(字段都可选,只改传了的;至少传一个):
**application/json**(字段都可选,只改传了的;至少传一个):
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `role` | string | ✗ | 改角色,枚举:`super_admin` / `finance` / `operator` |
| `role` | string | ✗ | 改角色:内建 `super_admin` / `finance` / `operator` **或自定义角色 name**(#117/#126,见 [admin-roles](../admin-roles.md)) |
| `pages_override` | list[string] \| null | ✗ | 个人可见页覆盖(#126):非空优先于角色 pages;传 `null` 清覆盖回归角色 |
| `status` | string | ✗ | 启停,枚举:`active`(启用)/ `disabled`(禁用) |
| `password` | string | ✗ | 重置密码,872 字(传则覆盖原密码) |
| `password` | string | ✗ | 重置密码,8–72 字(传则覆盖原密码,同时更新 `plain_password` 明文副本) |
## 出参
响应 `200`:`AdminOut`(更新后的管理员)
@@ -24,9 +25,15 @@
| `username` | string | 账号 |
| `role` | string | 角色 |
| `status` | string | 状态 |
| `pages` | list[string] | **有效可见页**(pages_override 优先,否则角色 pages;super_admin 全量) |
| `created_at` | datetime | 创建时间(UTC) |
| `last_login_at` | datetime \| null | 上次登录时间 |
---
## DELETE /admin/api/admins/{admin_id} — 删除管理员(#126)
物理删除(区别于禁用);**不可删自己**(`400`);带审计(`action=admin.delete`)。鉴权同本组(super_admin)。出参 `{"deleted": true}`
## 错误码
- `400` 不能禁用自己(`admin_id == 当前 admin.id``status=disabled`) / 无任何变更字段(三字段全空)
- `401` 未带 admin token / token 无效或过期 / 管理员被禁用
@@ -35,5 +42,5 @@
- `422` `role`/`status` 非法枚举 / `password` 长度不在 872
## 说明
- 更新成功后写一条审计:`action=admin.update``target_type=admin``target_id=admin_id``detail` 为本次实际变更字段(如 `{"role": "...", "status": "...", "password": "reset"}`,密码只记 `reset` 不记明文)。见 [admin_audit_log](../database/admin_audit_log.md)。
- 数据表见 [admin_user](../database/admin_user.md)。
- 更新成功后写一条审计:`action=admin.update``target_type=admin``target_id=admin_id``detail` 为本次实际变更字段(如 `{"role": "...", "status": "...", "password": "reset"}`,密码只记 `reset` 不记明文)。见 [admin_audit_log](../../../database/admin_audit_log.md)。
- 数据表见 [admin_user](../../../database/admin_user.md)。
@@ -1,6 +1,24 @@
# POST /admin/api/feedbacks/{feedback_id}/handle — 标记反馈已处理
# /admin/api/feedbacks — 反馈审核族(采纳/拒绝/标记处理/统计)
> 所属: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")`;summary 任意 admin | [← 返回 API 索引](../../README.md)
>
> 列表见 [admin-feedbacks-list](./admin-feedbacks-list.md)。#94 引入 采纳(发金币)/拒绝 审核语义,#105 加运营回复 `admin_reply`;旧「标记已处理」保留。
## POST /admin/api/feedbacks/{feedback_id}/approve — 采纳并发金币(#94)
- body:`{reward_coins?(int,可 0), admin_reply?(string,用户可见), review_note?(string,内部)}`
- 行为:`status → adopted`;`reward_coins > 0` 时走 `grant_coins` 同事务发币(流水 `coin_transaction`);写审计(`detail.after="adopted"`)。已终态(非 pending/new)→ `400`
- 出参:更新后的 FeedbackOut。
## POST /admin/api/feedbacks/{feedback_id}/reject — 拒绝采纳(#94)
- body:`{reject_reason(string,用户可见), admin_reply?, review_note?}`
- 行为:`status → rejected`;写审计(`detail.after="rejected"`)。已终态 → `400`
## GET /admin/api/feedbacks/summary — 审核统计
- 出参:各状态计数(`pending` / `adopted` / `rejected` / `handled`),审核台顶部卡片用;任意 admin 可读。
---
## POST /admin/api/feedbacks/{feedback_id}/handle — 标记反馈已处理(旧口径)
## 入参
- 路径:`feedback_id`(int)
@@ -18,6 +36,6 @@
- `422` `feedback_id` 非合法 int
## 说明
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action="feedback.handle"``target_type="feedback"``target_id=<feedback_id>``detail={"before": <原 status>, "after": "handled"}``ip=<客户端 IP>`
- 写操作记审计 [admin_audit_log](../../../database/admin_audit_log.md):`action="feedback.handle"``target_type="feedback"``target_id=<feedback_id>``detail={"before": <原 status>, "after": "handled"}``ip=<客户端 IP>`
- 状态变更与审计写入在同一事务(`commit=False` 后统一 `db.commit()`)。
- 关联表 [feedback](../database/feedback.md)。
- 关联表 [feedback](../../../database/feedback.md)。
+4 -3
View File
@@ -10,6 +10,7 @@
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `mode` | string | ✗ | `delta`(默认)=增减 / `set`=设为指定值 |
| `account` | string | ✗ | 目标账户(#95):`coin_cash`(默认,金币兑换的现金)/ `invite_cash`(邀请奖励金)。两本账物理隔离、各调各 |
| `amount_cents` | int | ✓ | `delta` 模式:现金变动(分,正=发放,负=扣减,不可为 0);`set` 模式:目标现金值(分,须 ≥ 0) |
| `reason` | string | ✓ | 操作原因,1–128 字(必填,入审计与流水备注) |
@@ -28,7 +29,7 @@
- 金额单位一律为**分**(`*_cents`);本接口只动现金余额,不涉及金币。
- **set 模式**:读当前余额算出差值 `delta = target - 当前余额`,再复用同一套写入逻辑(故只写一笔差值流水)。目标值须 ≥ 0;差值为 0(已等于目标)直接拒绝。
- 扣减保护:实际写入的 `delta < 0` 时若扣减后现金余额 < 0 直接拒绝(运营误操作保护);set 模式目标值 ≥ 0 天然不会扣成负。
- 现金变动写流水 [cash_transaction](../database/cash_transaction.md):`biz_type` 实际差值为正记 `admin_grant`、为负记 `admin_deduct`(set 模式同理,不新增流水类型),`remark = admin:<reason>`(截断至 128 字)。
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.cash.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount_cents(=实际差值), balance_after_cents, reason}`;set 模式额外带 `{mode:"set", target_cents, before_cents}`。并记录操作 IP。
- 现金变动`account` 写对应账本流水:`coin_cash` → [cash_transaction](../../../database/cash_transaction.md),`invite_cash` → [invite_cash_transaction](../../../database/invite_cash_transaction.md)(#95);`biz_type` 实际差值为正记 `admin_grant`、为负记 `admin_deduct`(set 模式同理,不新增流水类型),`remark = admin:<reason>`(截断至 128 字)。
- 写操作记审计 [admin_audit_log](../../../database/admin_audit_log.md):`action = user.cash.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount_cents(=实际差值), balance_after_cents, reason}`;set 模式额外带 `{mode:"set", target_cents, before_cents}`。并记录操作 IP。
- 现金变动 + 审计在同一事务原子提交(改钱必留痕)。
- 关联用户表 [user](../database/user.md);现金账户 [coin_account](../database/coin_account.md);现金流水 [cash_transaction](../database/cash_transaction.md)。
- 关联用户表 [user](../../../database/user.md);现金账户 [coin_account](../../../database/coin_account.md);现金流水 [cash_transaction](../../../database/cash_transaction.md)。
+11 -2
View File
@@ -38,6 +38,15 @@
- `422` `user_id` 非整数
## 说明
- 金币三项(`coin_balance` / `cash_balance_cents` / `total_coin_earned`)读 [coin_account](../database/coin_account.md);从未发生金币动作(账户不存在)时统一返回 0。
- 金币三项(`coin_balance` / `cash_balance_cents` / `total_coin_earned`)读 [coin_account](../../../database/coin_account.md);从未发生金币动作(账户不存在)时统一返回 0。
- 各 count 为聚合数,明细历史走带 `user_id` 过滤的分页接口(金币流水 / 现金流水 / 提现 / 比价 / 反馈)。
- 关联用户表 [user](../database/user.md);金币账户 [coin_account](../database/coin_account.md);提现单 [withdraw_order](../database/withdraw_order.md);比价记录 [comparison_record](../database/comparison_record.md);反馈 [feedback](../database/feedback.md)。
- 关联用户表 [user](../../../database/user.md);金币账户 [coin_account](../../../database/coin_account.md);提现单 [withdraw_order](../../../database/withdraw_order.md);比价记录 [comparison_record](../../../database/comparison_record.md);反馈 [feedback](../../../database/feedback.md)。
---
## 族内配套端点(提现详情页联查用)
| 方法 + 路径 | 说明 |
|---|---|
| `GET /admin/api/users/{user_id}/reward-stats` | 用户提现/看广告统计(按时间窗口):提现审核时评估该用户金币来源是否健康 |
| `GET /admin/api/users/{user_id}/coin-records` | 用户金币发放记录(按时间窗口分页):提现详情底部表,逐笔看发币来源 |
+8 -2
View File
@@ -21,5 +21,11 @@
## 说明
- 业务写(改用户状态)与审计写在同一事务原子提交:改了就有痕、有痕就真改了。
- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.status.set`,`target_type = user`,`target_id = user_id`,`detail = {before, after}`,并记录操作 IP。
- 关联用户表 [user](../database/user.md)。
- 写操作记审计 [admin_audit_log](../../../database/admin_audit_log.md):`action = user.status.set`,`target_type = user`,`target_id = user_id`,`detail = {before, after}`,并记录操作 IP。
- 关联用户表 [user](../../../database/user.md)。
---
## POST /admin/api/users/{user_id}/debug-trace — 开关调试链接权限
- body:`{"enabled": true|false}` → 写 `user.debug_trace_enabled`(带审计 `action=user.debug_trace.set`)。
- 开了的用户在比价完成弹窗 + 比价记录页可见「复制调试链接」按钮(trace_url);运营按用户灰度排障用。鉴权同本组(operator)。
@@ -0,0 +1,25 @@
# /admin/api/withdraws — 提现审核台(审核/批量/对账族)
> 所属:Admin 子应用(前缀 `/admin/api/withdraws`) | 鉴权:读=admin,写=**finance** | 表 [withdraw_order](../../../database/withdraw_order.md) | [← 返回 API 索引](../../README.md)
>
> 列表见 [admin-withdraws-list](./admin-withdraws-list.md);单笔查单见 [admin-withdraw-refresh](./admin-withdraw-refresh.md);超时对账见 [admin-withdraw-reconcile](./admin-withdraw-reconcile.md)。本文覆盖其余审核台端点。
提现状态机:`reviewing`(发起即扣款待审)→ 通过 `pending`(微信转账在途)→ `success`/`failed`(失败退款);拒绝 `rejected`(退款)。**#121 起按 `withdraw_order.source` 分账**:退款/流水落 `cash_transaction`(coin_cash)或 `invite_cash_transaction`(invite_cash)。
## 端点
| 方法 + 路径 | 鉴权 | 说明 |
|---|---|---|
| `GET /admin/api/withdraws/summary` | admin | 审核台统计:各状态计数(待审/在途/成功/失败/拒绝) |
| `GET /admin/api/withdraws/health-check` | finance | 提现配置健康检查(证书/密钥路径/商户配置就位与否;暴露路径故限 finance+super) |
| `GET /admin/api/withdraws/ledger-check` | admin | **资金账本校验**(#121):按 `source` 分账核对「提现单 ↔ 流水」金额闭环,邀请奖励金提现纳入对账 |
| `GET /admin/api/withdraws/{out_bill_no}` | admin | 提现单详情(审核台抽屉;用户维度联查另走 `users/{id}/reward-stats` + `coin-records`) |
| `POST /admin/api/withdraws/{out_bill_no}/approve` | finance | 审核通过 → 发起微信打款(`reviewing``pending`→查单归一化);带审计 |
| `POST /admin/api/withdraws/{out_bill_no}/reject` | finance | 审核拒绝 → 按 source 退款 + `rejected`;带审计 |
| `POST /admin/api/withdraws/bulk/refresh` | finance | 批量刷新查单(勾选多笔) |
| `POST /admin/api/withdraws/bulk/approve` | finance | 批量审核通过并打款 |
| `POST /admin/api/withdraws/bulk/reject` | finance | 批量审核拒绝并退款 |
## 说明
- 批量接口逐单处理、逐单落审计,单笔失败不中断整批(返回逐单结果)。
- 结果不明时先查单再定夺、绝不盲目退款(防退款后又到账),同 C 端口径。
+5 -5
View File
@@ -1,13 +1,13 @@
# POST /api/v1/intent/recognize — 外卖比价 Phase 1 意图识别(透传到 pricebot
# POST /api/v1/intent/recognize — 外卖比价 Phase 1 意图识别(透传 + 首帧 harvest 建行
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](../README.md)
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**软鉴权 OptionalUser**(带 JWT 则绑 `user_id`,不带也放行) | [← 返回 API 索引](../README.md)
## 入参
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端从中读 `device_id``trace_id``step` 用于日志。
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端从中读 `device_id``trace_id``step``device_info` 用于日志与落库
客户端实际传源平台购物车页的无障碍树采集结果(pricebot 协议里的 `screens`:`cart_page_1` / `cart_page_2`)。
## 出参
pricebot-backend 的响应**原样返回**JSON object)。典型含 `result`(店名)、`calibration`(含 `source_platform_id` / `items` / `price`),客户端在 `step=0` 把它透传进 `/price/step`
pricebot-backend 的响应**原样返回**JSON object,并在顶层补 `trace_id`。典型含 `result`(店名)、`calibration`(含 `source_platform_id` / `items` / `price`),客户端在 `step=0` 把它透传进 `/price/step`
## 错误码
- `400` body 不是合法 JSON
@@ -18,7 +18,7 @@ pricebot-backend 的响应**原样返回**JSON object)。典型含 `result`
外卖比价由客户端无障碍引擎在源平台(淘宝闪购 / 美团 / 京东外卖)购物车页点悬浮球触发 → 调本接口拿 `query` + `calibration` → 进入 `/price/step` 循环。
⚠️ **MVP 阶段不鉴权**(同 `coupon/step`:`device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 行为暂绑不到登录用户。待补 JWT,见 [待办与技术债.md](../guides/待办与技术债.md) P1
**trace_id 签发 + harvest 建行(2026-07 起,`compare.py`)**:客户端首帧可不带 `trace_id`——app-server 用 uuid 签发、注入转发 body、回填响应顶层;**仅签发那帧**按 `trace_id` 建 [comparison_record](../../database/comparison_record.md) 的 `running` 行(幂等,best-effort),done / finalize 帧再补终态。**软鉴权**:带 Bearer 则记录绑 `user_id`,匿名行 `user_id` 暂空、由后续 `/compare/record` 上报补
**相关配置**:
- `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`
+10 -6
View File
@@ -1,22 +1,26 @@
# POST /api/v1/price/step — 外卖比价 Phase 2 步进(透传到 pricebot
# POST /api/v1/price/step — 外卖比价 Phase 2 步进(透传 + done 帧 harvest 落库
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](../README.md)
> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**软鉴权 OptionalUser**(带 JWT 则绑 `user_id`,不带也放行) | [← 返回 API 索引](../README.md)
## 入参
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端从中读 `device_id``trace_id``step` 用于日志。
任意 JSON body,**不做 schema 校验**,原样透传给上游。后端从中读 `device_id``trace_id``step``device_info` 用于日志与落库
客户端逐帧上报 `screen_state` + 上一步 `action_result`;`step=0` 还带 `query` + `calibration`(来自 Phase 1)。
## 出参
pricebot-backend 的响应**原样返回**JSON object)。含 `action`tap / set_text / launch / wait / done…)、`continue``status`;最终 `done` 帧带 `comparison_results`(源 + 各目标平台到手价,按价升序)。
pricebot-backend 的响应**原样返回**JSON object,并在顶层补 `trace_id`(见下「trace_id 签发」)。含 `action`tap / set_text / launch / wait / done…)、`continue``status`、每帧顶层 `trace_url`;最终 `done` 帧带 `comparison_results`(源 + 各目标平台到手价,按价升序)。
## 错误码
- `400` body 不是合法 JSON
- `502` pricebot 上游不可达(网络错误)或返回 5xx
## 说明
把请求体原样转发到 `PRICEBOT_BASE_URL``/api/price/step`(去掉 `/v1`,async httpx)。**多轮循环**:客户端按返回的 `action` 操作手机、再上报下一帧,直到 `continue=false`。真正的目标驱动比价逻辑(多目标平台串行复现订单、读到手价、聚合排序)在 **pricebot-backend**,本接口只是"透传壳"
把请求体原样转发到 `PRICEBOT_BASE_URL``/api/price/step`(去掉 `/v1`,共享 httpx 单例)。**多轮循环**:客户端按返回的 `action` 操作手机、再上报下一帧,直到 `continue=false`。真正的目标驱动比价逻辑(多目标平台串行复现订单、读到手价、聚合排序)在 **pricebot-backend**
⚠️ **MVP 阶段不鉴权**(同 `coupon/step`)。
**不再是纯透传壳(2026-07 起,`compare.py`)**:
- **trace_id 签发**:客户端首帧可不带 `trace_id`——app-server 用 uuid 签发、注入转发 body、回填进响应顶层 `trace_id`,客户端后续帧都带它(老客户端自带则原样用)。
- **harvest 落库**([comparison_record](../../database/comparison_record.md)):首帧(mint 时)建 `running` 行 → **done 帧** `harvest_done``success`/`failed` + 派生 `best_*`/`saved_amount_cents`。写库 best-effort(threadpool 独立 session),失败不连累透传。
- **软鉴权**:新客户端带 Bearer → 记录绑 `user_id`;老客户端/匿名 → `user_id` 暂空,由其后续 `POST /compare/record` 上报补(灰度期两条写路径按 `trace_id` reconcile,success 不降级)。
- 邀请发奖**不在这里**:#113 起口径为好友「比价并下单」,发放在 `POST /order/report`
**相关配置**:
- `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`;生产部署应与 pricebot-backend 同内网——比价一单 30~80 步、逐帧多一跳,走公网延迟会累积)
+5 -4
View File
@@ -14,12 +14,13 @@
| 方法 + 路径 | 落库 | 说明 |
|---|---|---|
| `POST /internal/price-observation` | [`price_observation`](../database/price_observation.md) | 比价 done 帧整批价格事实上报;`(trace_id, platform, scope)` 幂等,返回 `{inserted, skipped}` |
| `GET /internal/store-mapping/lookup` | (只读 [`store_mapping`](../database/store_mapping.md)) | 比价前按 `source_platform`+`name`(+`lat`/`lng`)反查各目标平台已沉淀的店铺 id/deeplink;命中→pricebot 直接 deeplink 省现场搜店 |
| `POST /internal/price-observation` | [`price_observation`](../../database/price_observation.md) | 比价 done 帧整批价格事实上报;`(trace_id, platform, scope)` 幂等,返回 `{inserted, skipped}` |
| `GET /internal/store-mapping/lookup` | (只读 [`store_mapping`](../../database/store_mapping.md)) | 比价前按 `source_platform`+`name`(+`lat`/`lng`)反查各目标平台已沉淀的店铺 id/deeplink;命中→pricebot 直接 deeplink 省现场搜店 |
| `POST /internal/store-mapping` | `store_mapping` | 跨平台「同一家店」身份映射上报;`trace_id` 幂等、填空合并,返回 `{inserted, row_id}` |
| `POST /internal/store-mapping/invalidate` | `store_mapping` | 标记某平台 `shop_id` 的缓存 deeplink 失效(pricebot 撞错误页回退时报);当前支持 `taobao`/`jd`,其它平台 no-op,返回 `{ok, affected}` |
| `POST /internal/launch-confirm-sample` | [`launch_confirm_sample`](../database/launch_confirm_sample.md) | 启动确认窗 LLM 兜底放行后回写样本(host 包 + 弹窗树 + plan + locale);**都上报、不去重**,返回 `{id}` |
| `POST /internal/app-version` | [`app_config`](../database/app_config.md)(key=`latest_app_version`) | **发布流程**(非 pricebot)出 APK 后写最新版本号/下载链接/sha256;客户端再 `GET /api/v1/platform/app-version` 读做 OTA。也是应急改版本信息(紧急下线/改 `apk_url`)入口 |
| `POST /internal/launch-confirm-sample` | [`launch_confirm_sample`](../../database/launch_confirm_sample.md) | 启动确认窗 LLM 兜底放行后回写样本(host 包 + 弹窗树 + plan + locale);**都上报、不去重**,返回 `{id}` |
| `GET /internal/launch-confirm-samples` | (只读 `launch_confirm_sample`) | 样本列表(#91,供 pricebot `distill_launch_confirm.py` 聚合沉淀回静态规则);可选筛 `exec_success` / `host_package` / `since_days`,`limit` 默认 1000 |
| `POST /internal/app-version` | [`app_config`](../../database/app_config.md)(key=`latest_app_version`) | **发布流程**(非 pricebot)出 APK 后写最新版本号/下载链接/sha256;客户端再 `GET /api/v1/platform/app-version` 读做 OTA。也是应急改版本信息(紧急下线/改 `apk_url`)入口 |
## 错误
- `401` 密钥不匹配 / 缺失。
+3 -3
View File
@@ -6,7 +6,7 @@
## POST /bind — 绑定邀请人
把当前登录用户(被邀请人)绑定到某邀请码。支持三种归因路径:clipboard(首启读剪贴板)、manual(手动输入邀请码)、fingerprint(指纹兜底反查)。绑定成功双方各发 1 万金币
把当前登录用户(被邀请人)绑定到某邀请码。支持三种归因路径:clipboard(首启读剪贴板)、manual(手动输入邀请码)、fingerprint(指纹兜底反查)。**#113 起绑定只建关系、不发奖**——发奖后置到被邀请人「比价并实际下单」(`POST /order/report` 触发,给邀请人发**邀请奖励金**`compare_reward_granted` 幂等闸一人一次)
### 入参
@@ -46,14 +46,14 @@ Mock 入参(指纹兜底):
| 字段 | 类型 | 说明 |
|---|---|---|
| `status` | string | `success` / `already_bound` / `invalid_code` / `self_invite` / `not_eligible` / `fp_not_found` |
| `coins_awarded` | int | 本次给当前用户(被邀请人)发的金币 |
| `coins_awarded` | int | 兼容保留字段(#113 前"绑定即发金币"口径)。**#113 起新绑定恒 0**,前端不应再据此展示发奖 |
| `message` | string | 给前端直接展示的文案 |
Mock 出参:
```json
{
"status": "success",
"coins_awarded": 10000,
"coins_awarded": 0,
"message": "邀请绑定成功"
}
```
+6 -3
View File
@@ -25,7 +25,8 @@ GET /api/v1/invite/invitees?limit=5&offset=0
| `items` | list[InviteeItem] | 被邀请人列表 |
| `items[].display_name` | string | 显示名(昵称 → 微信昵称 → 脱敏手机号,后端已兜底) |
| `items[].avatar_url` | string \| null | 头像 URLnull = 前端画默认色块 |
| `items[].coins` | int | 这次邀请给邀请人发的金币 |
| `items[].coins` | int | 这次邀请给邀请人发的金币**历史留痕**#113 前旧口径的发放额;新绑定恒 0 |
| `items[].is_compared` | bool | 该好友是否已完成过一次比价(#113:好友列表据此分「邀请成功 / 去提醒」,在途列表只取 `false` 的) |
| `items[].invited_at` | datetime | 邀请绑定时间(ISO 8601 UTC |
| `total` | int | 我邀请的总人数 |
| `has_more` | bool | 还有下一页吗 |
@@ -37,13 +38,15 @@ Mock 出参:
{
"display_name": "省钱小王",
"avatar_url": "/media/avatars/u2_f1e2d3c4b5a60708.jpg",
"coins": 10000,
"coins": 0,
"is_compared": true,
"invited_at": "2026-06-28T14:30:00Z"
},
{
"display_name": "138****1234",
"avatar_url": null,
"coins": 10000,
"coins": 0,
"is_compared": false,
"invited_at": "2026-07-01T09:15:00Z"
}
],
+5 -4
View File
@@ -2,20 +2,21 @@
> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](../README.md)
>
> 数据来自离线库 [database/meituan_coupon](../database/meituan_coupon.md);**不实时打美团**(美团搜索对销量排序支持差、且有 402 限流)。
> 数据来自离线库 [database/meituan_coupon](../../database/meituan_coupon.md);**不实时打美团**(美团搜索对销量排序支持差、且有 402 限流)。
## 入参
| 字段 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `page` | int | ❌ | 1 | ≥1 |
| `page_size` | int | ❌ | 20 | 150 |
| `platform` | int \| null | ❌ | null | 1 只外卖 / 2 只到店 / 不填=全部(全城销量) |
| `platform` | int \| null | ❌ | null | 1 只外卖 / 2 只到店 / 不填=全部 |
| `longitude` / `latitude` | float \| null | ❌(实际必带) | null | 设备坐标(#116):服务端离线反查城市(`utils/geo` + `meituan_city`)→ **只返回同城券**;老客户端不带坐标 → 返空 + `status=degraded`(不 422、不误返全城) |
## 出参
响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: null, status: "ok"|"empty"|"degraded" }``CouponCard` 见 [API 索引](./README.md#复用数据结构);`status` 语义见 [feed 接口](./meituan-feed.md#status-字段前端据此显示占位)。
响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: null, status: "ok"|"empty"|"degraded" }``CouponCard` 见 [API 索引](../README.md#复用数据结构);`status` 语义见 [feed 接口](./meituan-feed.md#status-字段前端据此显示占位)。
## 说明
- 从 `meituan_coupon``sale_volume_num` 非空的券,`DISTINCT ON(dedup_key)` 跨源去重(每个「品牌|名|价」只留销量最高一条,同销量再按佣金),按销量降序分页;每页只对当前 ~20 条做 `from_raw` 解析(翻页快,不全表拉取)。
- 从 `meituan_coupon``sale_volume_num` 非空 **且 `city_id` = 反查城市** 的券(#116,同城销量榜),`DISTINCT ON(dedup_key)` 跨源去重(每个「品牌|名|价」只留销量最高一条,同销量再按佣金),按销量降序分页;每页只对当前 ~20 条做 `from_raw` 解析(翻页快,不全表拉取)。
- **不依赖 MT 凭证**(纯库查询)。库为空(prod 刚部署 / ETL 未跑完)→ `status=empty`;库查询异常 → `status=degraded`。均返 `200`、不抛 5xx。
- **仅 PostgreSQL**(`DISTINCT ON` 为 PG 专用)。
+17 -3
View File
@@ -1,9 +1,13 @@
# POST /api/v1/trace/finalize — 比价 trace 收尾上云
# POST /api/v1/trace/finalize + /trace/epilogue — 比价 trace 收尾
> 所属:透传端点(前缀 `/api/v1`,外卖比价) | 鉴权:无(MVP 阶段不鉴权) | [← 返回 API 索引](../README.md)
> 所属:透传端点(前缀 `/api/v1`,外卖比价) | 鉴权:软鉴权 OptionalUser | [← 返回 API 索引](../README.md)
## POST /api/v1/trace/finalize — 收尾上云(+夭折落库)
透传到 pricebot-backend。用户终止 / Phase 1 未识别没走到 done 帧时,pricebot 没上云也没回传 `trace_url`。客户端收尾时打这个,pricebot 按 `trace_id` 一致性 hash 落到处理这条 trace 的同一进程(dir_cache 在那才能算对 trace 目录),打包上云返回 `{trace_url}`
**顺手夭折落库(2026-07 起)**:app-server 把该 trace 的 [comparison_record](../../database/comparison_record.md) `running` 行更新成夭折终态(`harvest_abort`,**不降级已 success**)。body 可带 `status`(`cancelled`/`failed`)+ `reason`;老客户端只带 `trace_id` → 默认 `cancelled`,其后续 `/compare/record` 上报再补精确态。
## 入参
透传 pricebot,客户端按 pricebot 协议组装。关键字段:
@@ -41,5 +45,15 @@ Mock 出参:
## 说明
- 一致性 hash 按 `trace_id` 路由到同一 pricebot 实例(确保 dir_cache 命中)
- MVP 阶段不鉴权
- 软鉴权(OptionalUser,同比价透传族)
- 与 `/intent/recognize``/price/step` 等同属外卖比价透传族
---
## POST /api/v1/trace/epilogue — 结果页尾声帧(#112)
App 收到 done、渲染完**结果页**后,把自己页面的截图(base64,body ~几百 KB)传给 pricebot 存进 trace 目录并触发重传——trace 里补上「用户实际看到的汇总页」(步骤帧只有目标 App 画面)。
- **纯透传壳**:不建行、不落库(该 trace 的比价记录已由 done / finalize 落终态)。
- 入参:`{device_id, trace_id, screenshot(base64), ...}`(pricebot 协议);出参:pricebot 原样响应。
- 错误码同 finalize(`400` / `502`)。
+19 -2
View File
@@ -61,7 +61,24 @@ Mock 入参:
---
## POST /api/v1/user/onboarding/reset — 重置新手引导(#114)
删除(当前账号, `device_id`)的完成标记 → 该设备下次登录/进 App 重走引导。给客户端「设置 → 重看新手引导」入口用(此前只能运营在 admin 删记录)。
### 入参
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `device_id` | string | ✅ | 硬件级 ANDROID_ID(与 complete 一致) |
### 出参
```json
{"ok": true}
```
幂等:无标记时也返回 ok。
---
## 说明
- 替代原 `force_onboarding`(按用户)→ 改设备维度后,运营删记录即触发重走
- 幂等:重复标记不报错
- 替代原 `force_onboarding`(按用户)→ 改设备维度后,运营删记录(或用户自己 reset即触发重走
- 幂等:重复标记 / 重复重置都不报错
- `device_id` 为空时 `status` 一律返回未完成
+3 -2
View File
@@ -11,8 +11,9 @@
| 字段 | 类型 | 说明 |
|---|---|---|
| `coin_balance` | int | 当前金币余额 |
| `cash_balance_cents` | int | 当前现金余额(分) |
| `cash_balance_cents` | int | 当前现金余额(分,金币兑换账 |
| `invite_cash_balance_cents` | int | 邀请奖励金余额(分,与现金**物理隔离**的第二本账,#82;好友比价并下单发奖入账,提现走 `source=invite_cash` |
| `total_coin_earned` | int | 累计赚取金币 |
## 说明
账户不存在时自动创建(零余额)。福利页「我的资产」卡的数据源。
账户不存在时自动创建(零余额)。福利页「我的资产」卡的数据源;邀请页「奖励金」余额也读它
+3 -2
View File
@@ -8,9 +8,10 @@
|---|---|---|---|---|
| `limit` | int | ❌ | 20 | 1100 |
| `cursor` | int | ❌ | null | 上一页末条 `id`,首页不传 |
| `source` | string | ❌ | null | 按账户来源过滤:`coin_cash` / `invite_cash`;不传=全部(#121) |
## 出参
响应 `200`:`{ items: WithdrawOrderOut[], next_cursor: int|null }`(分页见 [索引#游标分页约定](./README.md#游标分页约定)
响应 `200`:`{ items: WithdrawOrderOut[], next_cursor: int|null }`(分页见 [索引#游标分页约定](../README.md#游标分页约定)
**WithdrawOrderOut**
@@ -19,7 +20,7 @@
| `id` | int | 单 id(也是游标) |
| `out_bill_no` | string | 商户提现单号 |
| `amount_cents` | int | 提现额(分) |
| `status` | string | `pending` / `success` / `failed` |
| `status` | string | `reviewing`(待审核)/ `pending` / `success` / `failed` / `rejected` |
| `wechat_state` | string \| null | 微信侧原始状态 |
| `fail_reason` | string \| null | 失败原因 |
| `created_at` | datetime | 发起时间 |
+2 -1
View File
@@ -2,13 +2,14 @@
> 所属:Wallet 组(前缀 `/api/v1/wallet` | 鉴权:Bearer | 限流:同 IP ≤20 次/分 | [← 返回 API 索引](../README.md)
>
> 集成实现:见 [integrations/wxpay](../integrations/wxpay.md)(微信 V3 商家转账、签名、实名加密)。
> 集成实现:见 [integrations/wxpay](../../integrations/wxpay.md)(微信 V3 商家转账、签名、实名加密)。
## 入参
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `amount_cents` | int | ✅(>0) | 提现金额(分),须落在 `[min_cents, max_cents]` |
| `source` | string | ❌(默认 `coin_cash` | 提现账户来源(#121 分账):`coin_cash`(金币兑换的现金)/ `invite_cash`(邀请奖励金)。按它扣对应余额、流水落对应账本([cash_transaction](../../database/cash_transaction.md) / [invite_cash_transaction](../../database/invite_cash_transaction.md)) |
| `user_name` | string | ❌ | 实名(达额时微信商家转账要求,可空) |
| `out_bill_no` | string | ❌ | **客户端幂等键(商户单号)**:同号重试不重复转账;不传则服务端生成 |
+69 -28
View File
@@ -2,7 +2,7 @@
> 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。
> 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **40 张业务表** + `alembic_version`(框架的迁移版本指针)。注意「比价/领券**过程**」始终在 pricebot 内存态跑、**不落库**——只有**结果**回 app-server 才落库:领券结果落 `coupon_*` 三张今日状态表;比价结果分两路——客户端带 JWT 上报「我的记录」落 `comparison_record`,pricebot 另经 `app/api/internal/` server→server 把客观价格/门店事实落 `price_observation`/`store_mapping`(不鉴权、匿名也记)+ 启动确认窗兜底样本落 `launch_confirm_sample`。此外好友邀请(`invite_*` 2 张)与美团 CPS 群发联盟(`cps_*` 6 张,含落地页微信身份 `cps_wx_user`)是两个独立子系统。无障碍存活监控 `device_liveness`(#65)按用户设备维度记心跳、检掉线召回。
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **45 张业务表** + `alembic_version`(框架的迁移版本指针)。注意「比价/领券**过程**」始终在 pricebot 内存态跑、**不落库**——只有**结果**回 app-server 才落库:领券结果落 `coupon_*` 三张今日状态表 + `coupon_session` 任务流水(#99);比价记录 2026-07 起**由 app-server 透传壳 harvest 直接落库**(`comparison_record`:帧0 建 running 行 → done/finalize 写终态,老客户端带 JWT 的 `POST /compare/record` 仅作兜底),pricebot 另经 `app/api/internal/` server→server 把客观价格/门店事实落 `price_observation`/`store_mapping`(不鉴权、匿名也记)+ 启动确认窗兜底样本落 `launch_confirm_sample`。此外好友邀请(`invite_*` 2 张 + 独立账本 `invite_cash_transaction`)与 CPS 群发联盟(`cps_*` 6 张,含落地页微信身份 `cps_wx_user`;`cps_order` 已含京东联盟单)是两个独立子系统。无障碍存活监控 `device_liveness`(#65)按用户设备维度记心跳、检掉线召回;客户端埋点落 `analytics_event`(#83)
---
@@ -12,7 +12,7 @@
| App 位置 / 动作 | 表 | 说明 |
|---|---|---|
| 比价/领券**过程**(看屏→决策→操作) | (无) | 在 pricebot-backend 内存态跑,**过程不落库**;只有结果回到 app-server 才落库 |
| 「我的比价记录」列表 / 详情 | [`comparison_record`](./comparison_record.md) | 每次比价 done 后客户端带 JWT 上报一条完整明细 |
| 「我的比价记录」列表 / 详情 | [`comparison_record`](./comparison_record.md) | **app-server 透传壳 harvest 落库**(2026-07 起):帧0 建 `running` 行 → done 帧写 success/failed → `trace/finalize` 写 cancelled/failed;老客户端带 JWT 的 `POST /compare/record` 兜底 |
| 比价战绩里程碑(逐档领金币) | [`comparison_milestone_claim`](./comparison_milestone_claim.md) | 累计成功比价 N 次解锁;进度读 `comparison_record` 计数 |
| profile「累计省了 / 省钱战绩 / 省钱明细」 | [`savings_record`](./savings_record.md) | 真实下单归因(source=compare)+ 无真实数据时 demo 兜底 |
| 「上报更低价」提交 / 列表 | [`price_report`](./price_report.md) | 众包纠偏:用户举证某平台更便宜,人工审核发奖 |
@@ -27,6 +27,7 @@
| 切外卖 App 时是否弹领券引导窗 | [`coupon_prompt_engagement`](./coupon_state.md) | 今天 engage 过(点领/点拒)就不再弹;判断维度 device_id |
| 首页「去领取」卡是否置灰 | [`coupon_daily_completion`](./coupon_state.md) | 今天跑完整轮(到 done)就置灰;判断维度 device_id |
| 每张券领取结果留痕 | [`coupon_claim_record`](./coupon_state.md) | 资产/画像/排查/CPS;当前**不参与**判断 |
| (admin 领券看板)一次领券任务全程流水 | [`coupon_session`](./coupon_session.md) | 客户端 `POST /coupon/session` 两段上报(发起建行/收尾更新);发起数、完成率、中途流失、平均耗时都从这算(#99) |
### 钱包 / 福利(看广告赚钱闭环)
| App 位置 / 动作 | 表 | 说明 |
@@ -37,7 +38,9 @@
| 每日签到 | [`signin_record`](./signin_record.md) + [`signin_boost_record`](./signin_boost_record.md) | 7 天循环发币;签到后看广告可膨胀一次 |
| 一次性任务(开消息提醒等) | [`user_task`](./user_task.md) | 领一次发币 |
| 看激励视频赚金币 | [`ad_reward_record`](./ad_reward_record.md) + [`ad_watch_log`](./ad_watch_log.md) + [`ad_ecpm_record`](./ad_ecpm_record.md) | 独立数据流:发奖 / 旧版观看时长 / 收益对账 |
| 信息流广告结算 | [`ad_feed_reward_record`](./ad_feed_reward_record.md) | 每展示满 10 秒累计一份奖励,完成后一次性入账 |
| 信息流/Draw 广告结算 | [`ad_feed_reward_record`](./ad_feed_reward_record.md) | 每展示满 10 秒累计一份奖励,完成后一次性入账;`ad_type`(feed/draw)+`feed_scene`(compare/coupon)分形态/场景 |
| (无 App UI)穿山甲后台收益对账 | [`ad_pangle_daily_revenue`](./ad_pangle_daily_revenue.md) | 定时脚本拉 GroMore 数据 API 落日表(#92);admin 收益报表/大盘的「真实收益」侧 |
| 好友比价并下单发奖 / 邀请奖励金 | [`invite_cash_transaction`](./invite_cash_transaction.md) + `coin_account.invite_cash_balance_cents` | 与现金**物理隔离**的第二本账(#82/#113):`POST /order/report` 触发 `invite_reward` 入账;`source=invite_cash` 提现出账 |
| 金币兑现金 | `coin_account` + `coin_transaction` + `cash_transaction` | exchange_out + exchange_in 两笔流水 |
| 提现到微信零钱 | [`withdraw_order`](./withdraw_order.md) + [`wechat_transfer_authorization`](./wechat_transfer_authorization.md) + `cash_transaction` | 人工审核 + 微信商家转账 |
| 绑定微信(提现前置) | `user`.wechat_* | openid 唯一,一微信一账号 |
@@ -53,8 +56,9 @@
### 好友邀请(注册增长)
| App 位置 / 动作 | 表 | 说明 |
|---|---|---|
| 输入/剪贴板邀请码绑定 | [`invite_relation`](./invite_relation.md) | 注册即生效,邀请人+被邀请人各发 1 万金币;`invitee_user_id` 唯一=幂等防重复发奖 |
| 输入/剪贴板邀请码绑定 | [`invite_relation`](./invite_relation.md) | 注册即生效(**绑定不发奖**,#113);`invitee_user_id` 唯一;`compare_reward_granted` 幂等闸=好友**比价并下单**后才给邀请人发奖励金 |
| 落地页访问指纹(剪贴板归因兜底) | [`invite_fingerprint`](./invite_fingerprint.md) | 剪贴板没拿到码时,用 (ip+机型+屏幕) 7 天内反查邀请人 |
| 邀请奖励金入账/提现 | [`invite_cash_transaction`](./invite_cash_transaction.md) | 独立账本(见上钱包节);余额在 `coin_account.invite_cash_balance_cents` |
### 美团 CPS 群发联盟(私域社群比价,运营后台驱动 · 群发选品→点击→对账漏斗)
| 后台/用户动作 | 表 | 说明 |
@@ -66,10 +70,19 @@
| 微信内打开落地页授权 | [`cps_wx_user`](./cps_wx_user.md) | 服务号网页授权拿 openid(base 静默)/ 昵称头像 unionid(userinfo,点领券触发),记首次来源群 |
| 定时拉美团联盟订单对账 | [`cps_order`](./cps_order.md) | `query_order` 按 sid 归群,串成点击→下单→佣金漏斗 |
### 埋点 / 首页门面 / 选品缓存
| 位置 / 动作 | 表 | 说明 |
|---|---|---|
| 客户端行为埋点 | [`analytics_event`](./analytics_event.md) | `POST /analytics/events` 批量上报,一事件一行(#83);admin「埋点日志」检索 |
| 首页三统计数字 | [`ops_stat_config`](./ops_stat_config.md) | real/manual/random 三模式,admin 配 |
| 首页轮播(省钱动态) | [`ops_marquee_seed`](./ops_marquee_seed.md) | 种子条目,与真实 `comparison_record` 混播(数据源模式 mixed/real/seed 可切) |
| 首页推荐/销量榜离线选品 | [`meituan_coupon`](./meituan_coupon.md) | 美团 CPS 券缓存,定时 ETL 灌入;`feed?tab=rec``top-sales` 纯库出、不实时打美团 |
### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权)
| 后台模块 | 表 | 说明 |
|---|---|---|
| 管理员账号 / 登录 | [`admin_user`](./admin_user.md) | 与 C 端 `user` 完全隔离,独立 JWT + RBAC |
| 管理员账号 / 登录 | [`admin_user`](./admin_user.md) | 与 C 端 `user` 完全隔离,独立 JWT + RBAC;`pages_override` 个人可见页覆盖 |
| 角色 / 可见页配置 | [`admin_role`](./admin_role.md) | 内建三角色 + 自定义角色(#117/#126);`admin_user.role` 按名引用 |
| 操作审计 | [`admin_audit_log`](./admin_audit_log.md) | 每个写操作落一条,只增不改不删 |
| 运营可配置项(改奖励常量) | [`app_config`](./app_config.md) | 空表 = 用代码默认;后台改了即覆盖 |
| 用户/钱包/提现/反馈管理 | 跨读写上面的 C 端表 | 见下「写入路径」admin 段 |
@@ -92,8 +105,8 @@
| 签到膨胀 `POST /signin/boost` | `signin_boost_record`(C) + `coin_account`(U) + `coin_transaction`(C `signin_boost`) | 同事务;同日一次 |
| 领任务 `POST /tasks/claim` | `user_task`(C) + `coin_account`(U) + `coin_transaction`(C `task_<key>`) | 同事务 |
| 金币兑现金 `POST /wallet/exchange` | `coin_account`(U) + `coin_transaction`(C `exchange_out` ) + `cash_transaction`(C `exchange_in` +) | 同事务 |
| 发起提现 `POST /wallet/withdraw` | `withdraw_order`(C `reviewing`) + `coin_account`(U 扣现金) + `cash_transaction`(C `withdraw` ) | 同事务,**不打款** |
| 查提现状态 / 用户取消 `GET /wallet/withdraw/status` | `withdraw_order`(U) + 失败→`cash_transaction`(C `withdraw_refund` +) | |
| 发起提现 `POST /wallet/withdraw` | `withdraw_order`(C `reviewing`,记 `source`) + `coin_account`(U 按 source 扣对应余额) + 流水(C :`cash_transaction.withdraw``invite_cash_transaction.invite_withdraw`) | 同事务,**不打款**;#121`source` 分账 |
| 查提现状态 / 用户取消 `GET /wallet/withdraw/status` | `withdraw_order`(U) + 失败→对应账本退款流水(C `withdraw_refund` / `invite_withdraw_refund` +) | |
| 穿山甲发奖 S2S 回调 `POST /ad/pangle-callback` | `ad_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `reward_video`/`signin_boost`) | `trans_id` 幂等 |
| 看广告时长上报 `POST /ad/watch-report` | `ad_watch_log`(C) | |
| 广告 eCPM 上报 `POST /ad/ecpm-report` | `ad_ecpm_record`(C) | |
@@ -101,13 +114,17 @@
| 注册设备 / 更新 push token `POST /device/register` | `device_liveness`(C/U upsert) | `(user_id, device_id)` 幂等;只在传入非空时更新 `registration_id`/版本 |
| 无障碍服务心跳 `POST /device/heartbeat` | `device_liveness`(C/U) | 心跳也能自注册;`accessibility_enabled=true` 时刷 `last_heartbeat_at`、置 `alive`、清 `notified_at` |
| 客户端 ack 掉线提醒 `POST /device/liveness/ack` | `device_liveness`(U) | 清 `kill_alert_pending`(幂等;下次真掉线 worker 再置) |
| 比价 done 上报 `POST /compare/record` | `comparison_record`(C 或 U) | `(user_id, trace_id)` 幂等覆盖 |
| 比价透传(帧0 / done / finalize)`POST /intent/recognize``/price/step``/trace/finalize` | `comparison_record`(C `running` → U 终态) | **harvest 三段式**(2026-07):帧0 mint trace_id 建 running 行 → done 写 success/failed → finalize 写 cancelled/failed(不降级 success);best-effort,写失败不连累透传 |
| 比价 done 上报 `POST /compare/record`(老客户端兜底) | `comparison_record`(C 或 U) | `trace_id` 幂等覆盖(唯一键已从 `(user_id,trace_id)` 改单列) |
| 领里程碑 `POST /compare/milestone/claim` | `comparison_milestone_claim`(C) | **当前不发币**(coin_awarded=0) |
| 支付归因上报 `POST /order/report` | `savings_record`(C `source=compare`) | `(user_id, client_event_id)` 幂等 |
| 支付归因上报 `POST /order/report` | `savings_record`(C `source=compare`)+ 触发邀请发奖:`invite_relation`(U `compare_reward_granted`)+`coin_account`(U invite_cash)+`invite_cash_transaction`(C `invite_reward`) | `(user_id, client_event_id)` 幂等;#113 邀请发奖口径=好友**比价并下单**,`compare_reward_granted` 幂等闸,同事务 |
| 批量埋点 `POST /analytics/events` | `analytics_event`(C 批量) | 不强制登录;每批 ≤200 条 |
| 领券任务流水 `POST /coupon/session` | `coupon_session`(C/U upsert) | `trace_id` 幂等:发起建行、收尾更新同一行(#99) |
| 重置新手引导 `POST /user/onboarding/reset` | `onboarding_completion`(**D** 该 设备+账号 行) | #114,删完成标记 → 下次登录重走引导 |
| 首次进 profile 省钱页且无真实记录 | `savings_record`(C `source=demo`) | 懒种子,`ensure_seeded` 按 user 幂等 |
| 上报更低价 `POST /report` | `price_report`(C) | 读 `comparison_record.best_price_cents` 校验 |
| 提交反馈 `POST /feedback` | `feedback`(C) | |
| 绑定邀请 `POST /invite/bind` | `invite_relation`(C `effective`) + `coin_account`(U×2) + `coin_transaction`(C `invite_inviter` + `invite_invitee`) | 同事务;`invitee_user_id` 唯一幂等,双方各发 1 万金币 |
| 绑定邀请 `POST /invite/bind` | `invite_relation`(C `effective`) | `invitee_user_id` 唯一幂等。**#113 起绑定不发奖**——发奖延后到好友比价并下单(`POST /order/report` 行),发的是邀请奖励金非金币 |
| 落地页归因 `POST /invite/landing-track` | `invite_fingerprint`(C) | 剪贴板归因兜底线索,登录后用 (ip+机型+屏幕) 反查 |
| 领券首帧 `POST /api/v1/coupon/step`(step=0) | `coupon_prompt_engagement`(C/U `claim_started`) | `(device_id, package, 北京日)` 幂等;best-effort |
| 领券每帧结果 `POST /api/v1/coupon/step` | `coupon_claim_record`(C/U) | `(device_id, coupon_id, 北京日)` 幂等;best-effort |
@@ -121,11 +138,17 @@
| 后台操作 | 写入 | 操作 |
|---|---|---|
| 手动增减金币 | `coin_account`(U)+`coin_transaction`(C `admin_grant`/`admin_deduct`)+`admin_audit_log`(C) | 同事务 |
| 改用户状态(禁用/启用) | `user`(U)+`admin_audit_log`(C) | 同事务 |
| 审核通过提现 | `withdraw_order`(U→pending/success/failed)+`wechat_transfer_authorization`(C/U)+失败时`cash_transaction`(refund)+`admin_audit_log`(C) | |
| 审核拒绝提现 | `withdraw_order`(U→rejected)+`cash_transaction`(C `withdraw_refund`)+`admin_audit_log`(C) | |
| 处理反馈 | `feedback`(U)+`admin_audit_log`(C) | 同事务 |
| 改运营配置 | `app_config`(C/U)+`admin_audit_log`(C) | 同事务 |
| 手动增减现金(#95 支持目标账户) | `coin_account`(U 按 `account` 选列)+ 流水(C:`cash_transaction``invite_cash_transaction`,`admin_grant`/`admin_deduct`)+`admin_audit_log`(C) | 同事务;`account=coin_cash`/`invite_cash` 两本账各调各 |
| 改用户状态(禁用/启用)/ 调试链接权限 | `user`(U `status` / `debug_trace_enabled`)+`admin_audit_log`(C) | 同事务 |
| 审核通过提现(含批量) | `withdraw_order`(U→pending/success/failed)+`wechat_transfer_authorization`(C/U)+失败时按 source 退款流水+`admin_audit_log`(C) | |
| 审核拒绝提现(含批量) | `withdraw_order`(U→rejected)+按 source 退款流水(C `withdraw_refund`/`invite_withdraw_refund`)+`admin_audit_log`(C) | |
| 反馈采纳/拒绝/标记处理 | `feedback`(U→adopted/rejected/handled + `admin_reply`/`reject_reason`)+采纳发币时 `coin_account`(U)+`coin_transaction`(C)+`admin_audit_log`(C) | 同事务(#94/#105) |
| 上报更低价审核 | `price_report`(U→approved/rejected)+通过发币 `coin_account`(U)+`coin_transaction`(C)+`admin_audit_log`(C) | 同事务 |
| 改运营配置 / 广告配置 / 反馈页二维码 | `app_config`(C/U)+`admin_audit_log`(C) | 同事务 |
| 轮播种子/数据源模式 | `ops_marquee_seed`(C/U/D)+`app_config`(模式 mixed/real/seed)+`admin_audit_log`(C) | #122/#123 预览/浏览只读 |
| 角色管理(#117/#126) | `admin_role`(C/U/D)+`admin_audit_log`(C) | 内建/在用角色不可删 |
| 管理员管理 | `admin_user`(C/U/**D** #126)+`admin_audit_log`(C) | 删除为物理删,不可删自己 |
| CPS 建群/活动/生成短链/拉单对账 | `cps_group`/`cps_activity`(C/U/D)、`cps_link`(C)、`cps_order`(C/U upsert;美团 `query_order` + 京东联盟 #90) | 见 CPS 段 |
| 任意写操作 | `admin_audit_log`(C,**永不 U/D**) | |
> 没有任何表会被业务流程物理 DELETE。注销是软删(改 user 行),其余只 C/U(领券 `/prompt/reset``/completed-today/reset` 是开发用删除,非业务流程)。
@@ -137,7 +160,9 @@
| 后台批量生成短链 `POST /admin/api/cps/referral-links` | `cps_link`(C) | 每 群×活动 一条;美团经 `sid` 转链拿 `target_url`(同群同活动重复生成产生多条) |
| 用户点群发短链 `GET /c/{code}` / `POST /c/{code}/copy` | `cps_click`(C `visit`/`copy`) | 公开端点不鉴权;`group_id`/`sid` 从 link 冗余进来免 join |
| 微信落地页授权回调 `GET /wx/oauth/cb` | `cps_wx_user`(C/U upsert) | 按 `openid` 幂等;base 拿 openid,userinfo 补昵称/unionid(非 None 才覆盖);任何失败兜底回落地页不阻断领券 |
| 定时拉美团联盟订单对账 | `cps_order`(C/U upsert) | `query_order``sid` 归群;`order_id` 幂等(状态会变,重复拉则更新) |
| 定时拉联盟订单对账(美团 `query_order` + 京东联盟 #90) | `cps_order`(C/U upsert) | 按 `sid` 归群;`order_id` 幂等(状态会变,重复拉则更新);京东单 `platform='jd'` + `jd_valid_code` 判有效 |
| 定时拉穿山甲 GroMore 后台收益(#92,systemd 每天 10:30) | `ad_pangle_daily_revenue`(C/U upsert) | 收益报表/大盘「真实收益」侧;与客户端上报的 eCPM 侧互为对照 |
| 定时 ETL 灌美团 CPS 选品库 | `meituan_coupon`(C/U) | `feed?tab=rec` / `top-sales` 纯库出的数据源 |
| pricebot 比价 done 内部上报 `POST /internal/price-observation` | `price_observation`(C 批量) | `(trace_id,platform,scope)` 幂等;**不走 JWT、靠 `X-Internal-Secret`**(未配→503) |
| pricebot 比价 done 内部上报 `POST /internal/store-mapping` | `store_mapping`(C/U 填空合并) | `trace_id` 幂等;另有 `lookup` 反查 + `invalidate` deeplink 失效标记(淘宝/京东) |
| pricebot LLM 兜底放行启动确认窗后上报 `POST /internal/launch-confirm-sample` | `launch_confirm_sample`(C) | **都上报、不去重**;靠 `X-Internal-Secret`(未配→503) |
@@ -150,7 +175,7 @@
## 三、表间关系 & Join Key
### 硬外键(数据库 FK 约束)
- **18 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction``cash_transaction``withdraw_order``wechat_transfer_authorization`(同时是 PK)、`signin_record``signin_boost_record``user_task``comparison_record``comparison_milestone_claim``savings_record``ad_reward_record``ad_watch_log``ad_ecpm_record``ad_feed_reward_record``price_report``feedback``device_liveness`
- **19 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction``cash_transaction``invite_cash_transaction``withdraw_order``wechat_transfer_authorization`(同时是 PK)、`signin_record``signin_boost_record``user_task``comparison_record`(2026-07 起 `user_id` **可空**——harvest 帧0 建行时软鉴权可能拿不到)`comparison_milestone_claim``savings_record``ad_reward_record``ad_watch_log``ad_ecpm_record``ad_feed_reward_record``price_report``feedback``device_liveness`
- `admin_audit_log.admin_id``admin_user.id`
- `price_report.comparison_record_id``comparison_record.id`(可空:关联记录被删后仍留上报历史)。
- **邀请两表**`user.id`:`invite_relation.inviter_user_id``invite_relation.invitee_user_id`(唯一)、`invite_fingerprint.inviter_user_id`——注意 FK 列名是 `inviter`/`invitee_user_id`,不是 `user_id`
@@ -173,13 +198,24 @@
| biz_type | ref_id 指向 | amount 符号 |
|---|---|---|
| `withdraw` / `withdraw_refund` | `withdraw_order.out_bill_no` | / + |
| `withdraw` / `withdraw_refund` | `withdraw_order.out_bill_no`(`source=coin_cash` 的单) | / + |
| `exchange_in` | null | + |
| `admin_grant` / `admin_deduct` | null(原因在 `remark`=`admin:<reason>`) | + / |
- **`invite_cash_transaction.ref_id`**(邀请奖励金账本,#82):
| biz_type | ref_id 指向 | amount 符号 |
|---|---|---|
| `invite_reward` | 被邀请人 `user.id`(字符串) | + |
| `invite_withdraw` / `invite_withdraw_refund` | `withdraw_order.out_bill_no`(`source=invite_cash` 的单) | / + |
| `admin_grant` / `admin_deduct` | null | + / |
- **`comparison_record.store_name``savings_record.shop_name`**:无 id 关联,按**店名字符串相等**给比价记录打「已下单」标记(瞬态,不写库)。两边店名同源 = 比价意图识别阶段的门店 query,语义=**店级**(同店比价多次会一并标已下单)。
- **广告流会话关联**:`ad_reward_record.ad_session_id` 可与 `ad_ecpm_record.ad_session_id` 对齐;`ad_watch_log` 仍是旧版兼容统计,不逐条参与发奖。
- **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record``status='success'``count`
- **领券三表无硬 FK,全靠软关联**:`coupon_prompt_engagement` / `coupon_daily_completion` / `coupon_claim_record``user_id` **软指** `user.id`(可空、有登录态才记、不进唯一键、不阻塞判断);`trace_id` **软指** pricebot work_logs(排查回指);唯一键都以 `device_id` + 北京自然日为主(详见 [`coupon_state.md`](./coupon_state.md))。
- **领券三表无硬 FK,全靠软关联**:`coupon_prompt_engagement` / `coupon_daily_completion` / `coupon_claim_record``user_id` **软指** `user.id`(可空、有登录态才记、不进唯一键、不阻塞判断);`trace_id` **软指** pricebot work_logs(排查回指);唯一键都以 `device_id` + 北京自然日为主(详见 [`coupon_state.md`](./coupon_state.md))。[`coupon_session`](./coupon_session.md)(#99)同口径:`trace_id` 唯一 upsert、`user_id` 软指(admin 明细 LEFT JOIN 出手机号)。
- **`analytics_event`**(#83)与 **`coupon_session`** 均无硬 FK:埋点/流水不鉴权也收,`device_id`/`user_id` 只作维度。
- **`admin_user.role` 按名语义引用 `admin_role.name`**(无硬 FK,#117):删除保护在应用层(在用/内建角色不可删);个人 `pages_override` 优先于角色 `pages`
- **`onboarding_completion.(user_id, device_id)`**:`user_id` 语义关联 `user.id`(无硬 FK,同 `coupon_*` 设备表),`device_id` = 客户端硬件级 `ANDROID_ID`(≠ 领券 per-install `device_id`)。登录读、走完引导写,决定是否再展示新手引导。
- **CPS 群发 6 表全靠 `sid` / id / `openid` 语义串联(无硬 FK)**:`cps_link.group_id``cps_group.id``cps_link.activity_id``cps_activity.id``cps_click.link_id``cps_link.id``cps_wx_user.first_group_id``cps_group.id`;**点击与订单无法对到单笔**,只在群维度(`sid`)汇合——`cps_order.sid``cps_group.sid``cps_link.sid``cps_click.sid`(仅美团有 sid,淘宝/京东无)。统计按群聚合,故 `group_id`/`sid` 冗余进 `cps_click` 免 join。`cps_wx_user``openid` 自成用户身份维度,与 `cps_click`/`cps_order` 无 id 级 join。
- **比价沉淀两表(`price_observation` / `store_mapping`)**:`trace_id` 软指 pricebot work_logs(与 `comparison_record.trace_id` 同源但不互 join,各存各视角);`source_user_id` / `source_device_id` 软指用户/设备(可空,匿名也记)。`store_mapping.lookup` 靠**店名字符串精确相等** + geo 取最近,非 id 级 join。
@@ -188,10 +224,10 @@
```
user ─1:1─ coin_account
user ─1:1─ wechat_transfer_authorization
user ─1:N─ { coin_transaction, cash_transaction, withdraw_order, signin_record,
signin_boost_record, user_task, comparison_record, comparison_milestone_claim,
savings_record, ad_reward_record, ad_watch_log, ad_ecpm_record, ad_feed_reward_record,
price_report, feedback, device_liveness }
user ─1:N─ { coin_transaction, cash_transaction, invite_cash_transaction, withdraw_order,
signin_record, signin_boost_record, user_task, comparison_record(user_id 可空),
comparison_milestone_claim, savings_record, ad_reward_record, ad_watch_log,
ad_ecpm_record, ad_feed_reward_record, price_report, feedback, device_liveness }
(device_liveness 硬 FK; (user_id,device_id) 唯一)
user ─1:N─ onboarding_completion (user_id, 无硬 FK; (user_id,device_id) 去重)
comparison_record ─1:N─ price_report (comparison_record_id, 可空)
@@ -199,6 +235,11 @@ admin_user ─1:N─ admin_audit_log
app_config (独立, 无外键, key 为主键)
coupon_prompt_engagement / coupon_daily_completion / coupon_claim_record
(独立, 无硬 FK; 维度=device_id+北京日, user_id/trace_id 仅软关联)
coupon_session (独立, 无硬 FK; trace_id 唯一=一次领券任务一行, #99)
analytics_event (独立, 无硬 FK; 埋点事件流, device/user 仅维度, #83)
admin_role ◀──语义(role 按 name 引用, 无FK)── admin_user (pages_override 个人覆盖, #117/#126)
ops_stat_config / ops_marquee_seed / meituan_coupon / ad_pangle_daily_revenue
(独立运营/缓存/对账表, 无外键)
user ─1:N─ invite_relation (inviter_user_id 硬 FK); invitee_user_id ─1:1─ user (唯一硬 FK)
user ─1:N─ invite_fingerprint (inviter_user_id 硬 FK)
cps_activity / cps_group ──语义(无FK)──▶ cps_link ─1:N─ cps_click
@@ -212,13 +253,13 @@ launch_confirm_sample (独立, 无硬 FK; 都上报不去
## 四、资金模型(金币 / 现金 / 提现,三层)
1. **余额快照** `coin_account`:`coin_balance`(金币个数)+ `cash_balance_cents`(现金分),一用户一行,读取展示用。
2. **流水账本** `coin_transaction` / `cash_transaction`:每次变动写一笔,`balance_after*` 记变动后余额,可逐笔回溯对账。
3. **唯一发金币入口** `repositories/wallet.grant_coins`:更新快照 + 写流水,**不 commit**,由调用方同一事务 commit(保证"记录"和"加币"原子化)。signin / signin_boost / task / ad_reward / feed_ad_reward / exchange / admin 都走它,靠 `biz_type` 区分来源。
1. **余额快照** `coin_account`:`coin_balance`(金币个数)+ `cash_balance_cents`(现金分)+ `invite_cash_balance_cents`(邀请奖励金分,#82),一用户一行,读取展示用。
2. **流水账本** `coin_transaction` / `cash_transaction` / `invite_cash_transaction`:每次变动写一笔,`balance_after*` 记变动后余额,可逐笔回溯对账。**现金与邀请奖励金是两本物理隔离的账**——发放口径与提现对账各自独立。
3. **唯一变动入口**:金币走 `repositories/wallet.grant_coins`,邀请奖励金走 `grant_invite_cash`——都是「更新快照 + 写流水,**不 commit**,由调用方同一事务 commit。signin / signin_boost / task / ad_reward / feed_ad_reward / exchange / admin `grant_coins`;`invite_reward` / admin 调整走 `grant_invite_cash`,靠 `biz_type` 区分来源。
- **汇率**:`10000 金币 = 1 元 = 100 分`(`rewards.COIN_PER_YUAN`);兑换额必须是整分倍数。
- **提现状态机**:`reviewing`(发起即原子扣现金、待人工审核、**不打款**)→ 审核通过 `pending`(微信转账在途)→ `success` / `failed`(失败自动退款);审核拒绝 `rejected`(退款)。扣款/退款`cash_transaction`,`out_bill_no` 幂等,孤儿 pending 单由 `reconcile_pending_withdraws` 对账兜底。
- **防超额**:扣现金用带条件 `UPDATE ... WHERE cash_balance_cents >= amount`,并发/重试不会双扣。
- **提现状态机**:`reviewing`(发起即原子扣、待人工审核、**不打款**)→ 审核通过 `pending`(微信转账在途)→ `success` / `failed`(失败自动退款);审核拒绝 `rejected`(退款)。**按 `withdraw_order.source` 分账**(#121):`coin_cash` 单的扣款/退款写 `cash_transaction`,`invite_cash` 单写 `invite_cash_transaction`;`out_bill_no` 幂等,孤儿 pending 单由 `reconcile_pending_withdraws` 对账兜底,admin `withdraws/ledger-check` 分账校验「单 ↔ 流水」
- **防超额**:扣用带条件 `UPDATE ... WHERE <对应余额列> >= amount`(按 source 选 `cash_balance_cents` / `invite_cash_balance_cents`),并发/重试不会双扣。
---
+16 -7
View File
@@ -3,13 +3,13 @@
> 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。
> ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。
> 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`
> 最后更新:2026-06-23(补 3 张表文档:`device_liveness`(#65 无障碍存活监控)、`cps_wx_user`(CPS 落地页微信身份)、`launch_confirm_sample`(启动确认窗兜底样本);`comparison_record``input_tokens`/`output_tokens`。上一次 2026-06-17 补全 CPS 群发 5 张 `cps_*` + 好友邀请 2 张 `invite_*` + 比价沉淀 `price_observation`/`store_mapping` 并全表 review 对齐 model;含 [OVERVIEW 总览](./OVERVIEW.md))
> 最后更新:2026-07-09(补 5表文档并入索引:`coupon_session`(#99 领券任务流水)、`analytics_event`(#83 埋点)、`invite_cash_transaction`(#82 邀请奖励金账本)、`admin_role`(#117/#126 RBAC 角色)、`ad_pangle_daily_revenue`(#92,文档已有、补进索引);同步改动列:`comparison_record.product_names``withdraw_order.source``coin_account.invite_cash_balance_cents``feedback` 审核/环境列、`cps_order` 京东列、`device_liveness.first_protected_at``ad_feed_reward_record.ad_type/feed_scene``admin_user.plain_password/pages_override`。上一次 2026-06-23)
> 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。
---
## 表总览(40 张业务表 + `alembic_version` 框架表)
## 表总览(45 张业务表 + `alembic_version` 框架表)
### 账号 / 反馈
| 表 | 用途 | 模型 | 文档 |
@@ -22,7 +22,7 @@
### 好友邀请(注册增长)
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
| `invite_relation` | 邀请绑定关系(注册即生效,双方各发1万金币;`invitee_user_id` 唯一=幂等防重复发奖) | `models/invite.py` | [详情](./invite_relation.md) |
| `invite_relation` | 邀请绑定关系(注册即生效但**绑定不发奖** #113;好友比价并下单后发邀请奖励金,`compare_reward_granted` 幂等闸;`invitee_user_id` 唯一) | `models/invite.py` | [详情](./invite_relation.md) |
| `invite_fingerprint` | 剪贴板归因失败时的指纹兜底(落地页记 ip+屏幕+机型,登录后反查邀请人) | `models/invite_fingerprint.py` | [详情](./invite_fingerprint.md) |
### 钱包 / 福利(看广告赚钱闭环)
@@ -30,8 +30,9 @@
|---|---|---|---|
| `coin_account` | 金币+现金余额快照(一用户一行) | `models/wallet.py` | [详情](./coin_account.md) |
| `coin_transaction` | 金币流水账本 | `models/wallet.py` | [详情](./coin_transaction.md) |
| `cash_transaction` | 现金流水账本(分) | `models/wallet.py` | [详情](./cash_transaction.md) |
| `withdraw_order` | 提现单(现金→微信零钱,含人工审核态) | `models/wallet.py` | [详情](./withdraw_order.md) |
| `cash_transaction` | 现金流水账本(分,金币兑换账) | `models/wallet.py` | [详情](./cash_transaction.md) |
| `invite_cash_transaction` | 邀请奖励金流水账本(分,与现金物理隔离;好友比价并下单发奖 + `source=invite_cash` 提现) | `models/wallet.py` | [详情](./invite_cash_transaction.md) |
| `withdraw_order` | 提现单(现金→微信零钱,含人工审核态;`source` 分账 coin_cash/invite_cash) | `models/wallet.py` | [详情](./withdraw_order.md) |
| `wechat_transfer_authorization` | 微信免确认转账授权(一用户一行) | `models/wallet.py` | [详情](./wechat_transfer_authorization.md) |
| `signin_record` | 签到记录(7 天循环) | `models/signin.py` | [详情](./signin_record.md) |
| `signin_boost_record` | 签到后看广告膨胀记录 | `models/signin.py` | [详情](./signin_boost_record.md) |
@@ -39,7 +40,8 @@
| `ad_reward_record` | 看激励视频发奖记录(S2S 回调,trans_id 幂等) | `models/ad_reward.py` | [详情](./ad_reward_record.md) |
| `ad_watch_log` | 看广告观看时长(旧版兼容字段) | `models/ad_watch_log.py` | [详情](./ad_watch_log.md) |
| `ad_ecpm_record` | 广告展示 eCPM 上报(收益对账) | `models/ad_ecpm.py` | [详情](./ad_ecpm_record.md) |
| `ad_feed_reward_record` | 信息流广告结算记录(10 秒一份,client_event_id 幂等) | `models/ad_feed_reward.py` | [详情](./ad_feed_reward_record.md) |
| `ad_feed_reward_record` | 信息流/Draw 广告结算记录(10 秒一份,client_event_id 幂等;`ad_type`+`feed_scene` 分形态/场景) | `models/ad_feed_reward.py` | [详情](./ad_feed_reward_record.md) |
| `ad_pangle_daily_revenue` | 穿山甲 GroMore 后台收益日表(定时拉取,收益报表/大盘真实收益源,#92) | `models/ad_pangle_revenue.py` | [详情](./ad_pangle_daily_revenue.md) |
### 比价 / 省钱
| 表 | 用途 | 模型 | 文档 |
@@ -62,6 +64,7 @@
| `coupon_prompt_engagement` | 领券引导窗频控源(今日是否已 engage,按 device+package+日) | `models/coupon_state.py` | [详情](./coupon_state.md) |
| `coupon_daily_completion` | 首页「去领取」置灰源(今日是否已跑完整轮) | `models/coupon_state.py` | [详情](./coupon_state.md) |
| `coupon_claim_record` | 每张券领取结果沉淀(资产/画像/排查,不参与判断) | `models/coupon_state.py` | [详情](./coupon_state.md) |
| `coupon_session` | 一次领券任务一行的全程流水(发起/终态/耗时/机型,admin 领券看板数据源,#99) | `models/coupon_state.py` | [详情](./coupon_session.md) |
### 美团 CPS 券缓存
| 表 | 用途 | 模型 | 文档 |
@@ -84,10 +87,16 @@
| `ops_stat_config` | 首页三统计展示配置(real/manual/random) | `models/ops_stat_config.py` | [详情](./ops_stat_config.md) |
| `ops_marquee_seed` | 首页轮播种子(真实不足时兜底混播) | `models/ops_marquee_seed.py` | [详情](./ops_marquee_seed.md) |
### 埋点
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
| `analytics_event` | 客户端埋点事件流(批量上报,一事件一行;admin 埋点日志检索,#83) | `models/analytics_event.py` | [详情](./analytics_event.md) |
### 运营后台 admin(独立子应用 `app/admin/`,独立鉴权)
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
| `admin_user` | 管理员账号(独立 JWT + RBAC) | `models/admin.py` | [详情](./admin_user.md) |
| `admin_user` | 管理员账号(独立 JWT + RBAC;`pages_override` 个人可见页覆盖) | `models/admin.py` | [详情](./admin_user.md) |
| `admin_role` | 后台角色→可见页配置(内建三角色 + 自定义角色,#117/#126) | `models/admin_role.py` | [详情](./admin_role.md) |
| `admin_audit_log` | 操作审计日志(只追加) | `models/admin.py` | [详情](./admin_audit_log.md) |
| `app_config` | 运营可配置项(覆盖 rewards 常量) | `models/app_config.py` | [详情](./app_config.md) |
+2
View File
@@ -11,6 +11,8 @@
| `id` | Integer | PK | 自增主键 |
| `client_event_id` | String(64) | UNIQUE, NOT NULL | 客户端幂等事件 id |
| `ad_session_id` | String(64) | index, nullable | 客户端生成的一次信息流广告会话 id |
| `ad_type` | String(16) | nullable, default `feed` | 广告形态:`feed`(信息流)/ `draw`(Draw 视频流)。旧数据 NULL 视为 feed(迁移随 #83 入库) |
| `feed_scene` | String(16) | nullable | 展示场景:`compare`(比价期)/ `coupon`(领券期)。比价与领券共用同一 Draw 代码位,收益/大盘按它分场景归集(#125 修比价/领券奖励金币恒 0 即改按本列汇总) |
| `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 |
| `reward_date` | String(10) | index, NOT NULL | 北京时间日期 `YYYY-MM-DD` |
| `duration_seconds` | Integer | NOT NULL | 整场比价累计观看秒数(轮播各条相加) |
+32
View File
@@ -0,0 +1,32 @@
# admin_role — 运营后台角色(RBAC 可见页配置)
> 模型 `app/models/admin_role.py` · 仓库 `app/admin/repositories/admin_role.py` · 权限目录 `app/admin/permissions.py` · 接口 admin `GET/POST /admin/api/roles``PATCH/DELETE /admin/api/roles/{id}``GET /admin/api/roles/catalog`(`app/admin/routers/roles.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
运营后台的**角色 → 可见页面**配置表。#117 把 RBAC 从「代码写死三角色」升级为数据驱动:内建角色(`super_admin`/`finance`/`operator`)种子进表(`is_builtin=true`,不可删),`super_admin` 可另建**自定义角色**(#126)勾选任意页面组合。`admin_user.role` 存角色 `name` 按名引用本表;单个管理员还可用 `admin_user.pages_override` 在角色之上覆盖个人可见页。
## 用在哪 / 增删改查
- **C(插入)**:内建三角色随迁移种子;`POST /admin/api/roles`(super_admin)新建自定义角色。
- **U(更新)**:`PATCH /admin/api/roles/{id}` 改展示名/可见页(内建角色的 `pages` 也可调)。
- **D(删除)**:`DELETE /admin/api/roles/{id}`——**内建角色与在用角色(有 admin_user.role 引用)不可删**。
- **R**:登录/每次 admin 鉴权时按 `admin_user.role` 查本表算有效可见页(`pages_override` 非空则以覆盖为准);`GET /admin/api/roles/catalog` 返回全部可配页面目录(分组,来自 `permissions.py` 常量,不落库)。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `name` | String(32) | UNIQUE, index, NOT NULL | 角色标识(被 `admin_user.role` 按名引用):内建 `super_admin`/`finance`/`operator`;**自定义角色 name(key)= label = 创建时的输入名称**(不能叫 super_admin,重名 409) |
| `label` | String(32) | NOT NULL, default `""` | 展示名(后台下拉里显示) |
| `pages` | JSON | NOT NULL, default `[]` | 可见页面 key 列表(全集见 `permissions.py` 目录;前端按它渲染菜单,后端接口守卫同源校验)。**`super_admin` 行 pages 存空数组**,有效可见页特判为全部 |
| `is_builtin` | Boolean | NOT NULL, default false | 内建角色标记(不可删;`super_admin` 恒过所有守卫,不依赖 pages) |
| `created_at` | DateTime(tz) | server_default now() | |
## 关系 / Join Key
- ← `admin_user.role` **按 `name` 语义引用**(无硬 FK;删除保护在应用层:在用角色不可删)。
- 与 `admin_user.pages_override` 的关系:有效可见页 = `pages_override`(个人覆盖,非空优先) 否则取角色 `pages`;`super_admin` 无视两者恒全通。
## 索引与约束
- PK `id`;UNIQUE+index `name`
## 注意
- 页面 key 目录维护在代码 `app/admin/permissions.py`(加新后台页面要同步登记),表里只存勾选结果——目录变更不需要迁移。
- 角色守卫兼容旧语义:`require_role("finance")` 等旧代码路径仍工作,内建角色名不可改。
+8 -6
View File
@@ -1,13 +1,13 @@
# admin_user — 运营后台管理员账号
> 模型 `app/models/admin.py` · 仓库 `app/admin/repositories/admin_user.py` · 接口 [admin-auth-login](../api/admin-auth-login.md) / [admin-admins-list](../api/admin-admins-list.md) / [admin-admin-create](../api/admin-admin-create.md) / [admin-admin-update](../api/admin-admin-update.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/admin.py` · 仓库 `app/admin/repositories/admin_user.py` · 接口 [admin-auth-login](../api/admin/auth/admin-auth-login.md) / [admin-admins-list](../api/admin/admins/admin-admins-list.md) / [admin-admin-create](../api/admin/admins/admin-admin-create.md) / [admin-admin-update](../api/admin/admins/admin-admin-update.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
运营后台(`app/admin/` 子应用,端口 8771)的管理员账号,与 App 用户(`user` 表)**完全隔离**:独立 JWT secret、独立鉴权链。密码 bcrypt 存哈希,带角色做 RBAC 权限分级。
## 用在哪 / 增删改查
- **C(插入)**:① 首个管理员用 `scripts/create_admin.py` 命令行创建(无自助注册);② `super_admin` 在后台「管理员管理」`POST` 新建子管理员。
- **U(更新)**:登录成功刷 `last_login_at`;`super_admin` 改他人 `role`/`status`/重置密码(`admin-admin-update`)。
- **D**:无(禁用走 `status='disabled'`,token 立即失效)。
- **U(更新)**:登录成功刷 `last_login_at`;`super_admin` 改他人 `role`/`status`/重置密码/`pages_override`(`admin-admin-update`)。
- **D**:`DELETE /admin/api/admins/{id}`(#126,super_admin,带审计;不可删自己)。禁用`status='disabled'`(token 立即失效)。
- **R**:每个 admin 请求经 `admin/deps` 解 admin token 查本表(校验 `status=='active'` + 角色守卫);管理员列表。
## 字段
@@ -15,8 +15,10 @@
|---|---|---|---|
| `id` | Integer | PK, autoincrement | 被 `admin_audit_log.admin_id` 引用 |
| `username` | String(64) | UNIQUE, index, NOT NULL | 登录名 |
| `password_hash` | String(255) | NOT NULL | bcrypt 哈希(明文不落库;⚠️ bcrypt 72 字节截断) |
| `role` | String(20) | NOT NULL, default `operator` | 取值:`super_admin`(全权+管账号)/ `finance`(钱:提现+金币)/ `operator`(用户+反馈+大盘) |
| `password_hash` | String(255) | NOT NULL | bcrypt 哈希(⚠️ bcrypt 72 字节截断) |
| `plain_password` | String(128) | nullable | **明文密码副本**(#117,`super_admin` 在管理员列表可见,便于线下派发/找回;创建/重置密码时同步写)。安全上是有意取舍:后台仅内网+super_admin 可见 |
| `role` | String(20) | NOT NULL, default `operator` | 角色名,按 `name` 引用 [`admin_role`](./admin_role.md)(#117 起数据驱动):内建 `super_admin`(恒全权)/ `finance` / `operator`,或自定义角色(#126) |
| `pages_override` | JSON | nullable | **个人可见页覆盖**(#126):非空时优先于角色 `pages`;NULL=跟随角色。页面 key 目录见 `app/admin/permissions.py` |
| `status` | String(20) | NOT NULL, default `active` | 取值:`active` / `disabled`(禁用后 token 立即失效) |
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间 |
| `last_login_at` | DateTime(tz) | nullable | 最近登录时间(登录成功时更新) |
@@ -30,4 +32,4 @@
## 注意
- **鉴权隔离**:admin token `typ=admin` + 独立 `ADMIN_JWT_SECRET`(≠ App 的 `JWT_SECRET_KEY`),App 用户 token 无法当 admin 用;admin 无 refresh,过期(默认 12h)重登。
- **RBAC**:`super_admin` 恒过所有角色守卫(`require_role`);`finance` 管钱、`operator` 管用户/反馈/大盘。具体守卫见各接口文档。
- **RBAC**:`super_admin` 恒过所有角色守卫(`require_role`);`finance` 管钱、`operator` 管用户/反馈/大盘;#117 起可见页由 [`admin_role`](./admin_role.md)`.pages` 数据驱动 + `pages_override` 个人覆盖,自定义角色见 `POST /admin/api/roles`。具体守卫见各接口文档。
+39
View File
@@ -0,0 +1,39 @@
# analytics_event — 客户端埋点事件流
> 模型 `app/models/analytics_event.py` · 仓库 `app/repositories/analytics.py` · 接口 C 端 `POST /api/v1/analytics/events`([analytics-events](../api/other/analytics-events.md),批量,不强制登录);admin `GET /admin/api/event-logs`(`app/admin/routers/event_logs.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
客户端行为埋点的落地表,**一事件一行**(append-only)。客户端攒批上报(每批 ≤200 条),服务端展开逐条插入;`props` 装事件自带的任意维度。当前主要供 admin「埋点日志」检索与手工分析,无自动聚合任务。#83 新增(2026-06-27)。
## 用在哪 / 增删改查
- **C(插入)**:`POST /api/v1/analytics/events`(不强制登录:带 JWT 则记 `user_id`,匿名只记 `device_id`)。服务端补 `client_ip`
- **U / D**:无。只追加。
- **R**:admin `GET /admin/api/event-logs`(游标分页,可按 `event`/`device_id`/`user_id` 筛)。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `event` | String(64) | index, NOT NULL | 事件名(客户端约定,如页面曝光/点击类事件名) |
| `props` | JSON | nullable | 事件属性(任意维度,客户端原样传) |
| `device_id` | String(64) | index, NOT NULL | 客户端 per-install id(匿名也有) |
| `user_id` | Integer | index, nullable | 登录态才有。**无硬 FK** |
| `session_id` | String(64) | index, nullable | 客户端会话 id(一次冷启动一个) |
| `client_ts` | BigInteger | NOT NULL | 事件发生时间(客户端 epoch ms) |
| `sent_at` | BigInteger | nullable | 客户端上报时间(epoch ms);与 `client_ts` 差 = 攒批延迟 |
| `page` | String(64) | nullable | 事件所在页面 |
| `client_ip` | String(64) | nullable | 服务端从请求头取 |
| `oem` / `os` / `model` | String | nullable | 厂商 / 系统版本 / 机型 |
| `app_ver` | String(32) | nullable | App versionName |
| `network` | String(16) | nullable | 网络类型(wifi/cellular) |
| `channel` | String(32) | nullable | 分发渠道 |
| `created_at` | DateTime(tz) | server_default now() | 入库时间 |
## 关系 / Join Key
- `user_id` 软指 `user.id``device_id` 与其他表的 per-install id 同源——均无硬 FK(埋点不鉴权、匿名也收,不能被外键约束卡住)。
## 索引与约束
- PK `id`;index `event``device_id``user_id``session_id`
## 注意
- 每批最多 200 条,超出整批 422;单条字段超长按模型截断口径处理。
- 时间轴分析用 `client_ts`(事件真实发生时刻),`created_at` 只是入库时刻(受攒批影响)。
+6 -3
View File
@@ -1,17 +1,20 @@
# cash_transaction — 现金流水账本(分)
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-cash-transactions](../api/wallet-cash-transactions.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-cash-transactions](../api/wallet/wallet-cash-transactions.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
现金每变动一笔就记一行(单位:**分**,记变动后余额)。金币兑现金、提现、提现退款都落这里。**只增不改不删**。
> **只管「金币兑换现金」这本账**(`coin_account.cash_balance_cents`)。**邀请奖励金**是另一本物理隔离的账 → [`invite_cash_transaction`](./invite_cash_transaction.md)(同构表);`source=invite_cash` 的提现流水**不落本表**。
## 用在哪 / 增删改查
- **C(插入)**:个来源,每次写一笔:
- **C(插入)**:个来源,每次写一笔:
| 动作 / endpoint | `biz_type` | `amount_cents` | `ref_id` 指向 |
|---|---|---|---|
| 金币兑现金 `POST /wallet/exchange` | `exchange_in` | + | null(配套 `coin_transaction.exchange_out`) |
| 发起提现 `POST /wallet/withdraw` | `withdraw` | (扣现金) | `withdraw_order.out_bill_no` |
| 发起提现 `POST /wallet/withdraw`(`source=coin_cash`) | `withdraw` | (扣现金) | `withdraw_order.out_bill_no` |
| 提现失败/取消/审核拒绝退款 | `withdraw_refund` | +(退回) | `withdraw_order.out_bill_no` |
| admin 手动调整 `POST /admin/api/users/{id}/cash`(`account=coin_cash`) | `admin_grant` / `admin_deduct` | + / | null(原因记 `remark`=`admin:<reason>`) |
- **U / D**:无。账本只追加。
- **R**:`GET /wallet/cash-transactions`(现金明细,`id` 倒序游标);admin 跨用户现金流水。
+5 -4
View File
@@ -1,6 +1,6 @@
# coin_account — 金币 + 现金余额快照
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-account](../api/wallet-account.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-account](../api/wallet/wallet-account.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
一用户一行的余额快照,App「资产卡 / 钱包」读它展示。每次余额变动都另写一笔流水(`coin_transaction` / `cash_transaction`)并记 `balance_after`,出问题逐笔回溯。`user_id` 既是主键也是外键(一对一)。详见 [总览 §四 资金模型](./OVERVIEW.md#四资金模型金币--现金--提现三层)。
@@ -15,7 +15,8 @@
|---|---|---|---|
| `user_id` | Integer | **PK + FK→user.id** | 用户(一用户一行);既是主键也是外键 |
| `coin_balance` | Integer | NOT NULL, default 0 | 当前金币余额(个数);= 历次 `coin_transaction.amount` 之和 |
| `cash_balance_cents` | Integer | NOT NULL, default 0 | 当前现金余额(分);= 历次 `cash_transaction.amount_cents` 之和 |
| `cash_balance_cents` | Integer | NOT NULL, default 0 | 当前现金余额(分,**金币兑换账本**);= 历次 `cash_transaction.amount_cents` 之和 |
| `invite_cash_balance_cents` | Integer | NOT NULL, default 0 | 当前**邀请奖励金**余额(分,与现金物理隔离的第二本现金账);= 历次 [`invite_cash_transaction`](./invite_cash_transaction.md)`.amount_cents` 之和。好友比价并下单发奖入账(#113),`source=invite_cash` 提现出账(#121) |
| `total_coin_earned` | Integer | NOT NULL, default 0 | 累计赚取金币(**只增不减**,仅正向 grant 累加),用于"历史总收益"展示 |
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最后更新时间 |
@@ -27,5 +28,5 @@
- PK `user_id`(同时是 FK→user.id)。
## 注意
- **唯一发金币入口** `wallet.grant_coins`:更新本表 + 写 `coin_transaction`,**不 commit**,由调用方同事务提交(发币与业务记录原子化)。
- 扣现金用 `UPDATE ... WHERE cash_balance_cents >= amount` 原子条件扣减,并发/重试不会超额。
- **唯一发金币入口** `wallet.grant_coins`:更新本表 + 写 `coin_transaction`,**不 commit**,由调用方同事务提交(发币与业务记录原子化)。邀请奖励金同款:**唯一变动入口 `wallet.grant_invite_cash`**(更新 `invite_cash_balance_cents` + 写 `invite_cash_transaction`)。
- 扣现金用 `UPDATE ... WHERE <对应余额列> >= amount` 原子条件扣减(按提现 `source``cash_balance_cents``invite_cash_balance_cents`),并发/重试不会超额。
+4 -3
View File
@@ -1,6 +1,6 @@
# comparison_record — 比价记录(每次比价完整明细)
> 模型 `app/models/comparison.py` · 仓库 `app/repositories/comparison.py` · 接口 [compare-record-report](../api/compare-record-report.md) / [compare-records](../api/compare-records.md) / [compare-record-detail](../api/compare-record-detail.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/comparison.py` · 仓库 `app/repositories/comparison.py` · 接口 [compare-record-report](../api/compare/compare-record-report.md) / [compare-records](../api/compare/compare-records.md) / [compare-record-detail](../api/compare/compare-record-detail.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
每完成一次比价(外卖/电商/领券)记一行。**写入以 app-server 后端 harvest 为主**(2026-07 起):比价透传壳 `compare.py` 在帧0(pricebot 出 trace_id)即建 `running` 行,随 done / `trace/finalize` 逐步补全成终态,客户端不再主动 POST 记录;老客户端仍可走**带 JWT** 的 `POST /compare/record` 兜底(灰度期两条写路径按 `trace_id` reconcile)。App「我的比价记录」列表/详情的数据源,也是比价战绩里程碑解锁进度的计数源(`status='success'` 条数),还被「上报更低价」反查原最低价。
@@ -8,9 +8,9 @@
> 与 [`price_observation`](./price_observation.md) / `store_mapping` 的区别:本表是**用户视角**(登录后按 `user_id` 存「我的比价记录」);后两张是 server 侧无条件沉淀的**平台/门店视角客观事实**(价格事实 / 跨平台店铺身份映射),与本表 `trace_id` 同源但不互相 join,各存各的视角。
## 用在哪 / 增删改查
- **C / U(harvest 为主,按 `trace_id` 幂等)**:透传壳 `compare.py` 三段式落库(`app/repositories/comparison.py`)——`harvest_running`(帧0 建 `running` 行)→ `harvest_done`(done 帧转 `success`/`failed` + 派生 `best_*`/`saved_amount_cents` + 返 `newly_success` 供幂等发邀请奖)→ `harvest_abort`(`trace/finalize``cancelled`/`failed`,**不降级已 success**)。老客户端仍可 `POST /compare/record`(`upsert_record`,按 `trace_id` 查、整行覆盖)兜底。`best_*`/`saved_amount_cents`/`is_source_best`/`status` 一律由 `_derive``comparison_results` 算出(协议已按 price 升序、rank=1 最便宜),不信客户端自算。
- **C / U(harvest 为主,按 `trace_id` 幂等)**:透传壳 `compare.py` 三段式落库(`app/repositories/comparison.py`)——`harvest_running`(帧0 建 `running` 行)→ `harvest_done`(done 帧转 `success`/`failed` + 派生 `best_*`/`saved_amount_cents`;`newly_success` 仅留日志观测,**不在此发邀请奖**——#113 已把发奖口径移到「实际下单」`POST /order/report`)→ `harvest_abort`(`trace/finalize``cancelled`/`failed`,**不降级已 success**)。老客户端仍可 `POST /compare/record`(`upsert_record`,按 `trace_id` 查、整行覆盖)兜底。`best_*`/`saved_amount_cents`/`is_source_best`/`status` 一律由 `_derive``comparison_results` 算出(协议已按 price 升序、rank=1 最便宜),不信客户端自算。
- **D**:无(关联的 `price_report` 也只把 `comparison_record_id` 置空,不删本表)。
- **R**:`GET /compare/records`(列表,`created_at` 倒序游标 + 「已下单」标记)、`GET /compare/records/{id}`(详情,限本人);`count_success` 给里程碑;`get_stats`(`status='success'` 计数 + `saved_amount_cents` 求和)给 [`GET /compare/stats`](../api/compare-stats.md) 喂「我的」页省钱战绩卡(完成比价 + 累计发现可省,**比价口径**);`report.py` 反查 `best_*`;admin 大盘/明细。
- **R**:`GET /compare/records`(列表,`created_at` 倒序游标 + 「已下单」标记)、`GET /compare/records/{id}`(详情,限本人);`count_success` 给里程碑;`get_stats`(`status='success'` 计数 + `saved_amount_cents` 求和)给 [`GET /compare/stats`](../api/compare/compare-stats.md) 喂「我的」页省钱战绩卡(完成比价 + 累计发现可省,**比价口径**);`report.py` 反查 `best_*`;admin 大盘/明细。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
@@ -30,6 +30,7 @@
| `saved_amount_cents` | Integer | nullable | 源价 最优价(可 0/负:源平台本就最便宜) |
| `is_source_best` | Boolean | nullable | 源平台就是最便宜(= 这次没省到) |
| `store_name` | String(128) | nullable | 店铺名。**与 `savings_record.shop_name` 按字符串相等关联**,给本记录打「已下单」 |
| `product_names` | String(512) | nullable | 菜品/商品名拼接串(`items[].name` 顿号连接,超长截断),**专供 admin 比价记录按店/商品模糊搜索**(#117:对 JSON 列做 LIKE 不可移植,冗余成扁平列;写入时随 harvest/upsert 同步生成)。C 端不读它 |
| `total_dish_count` / `skipped_dish_count` | Integer | nullable | 菜品总数 / 目标平台没找到被跳过数 |
| `status` | String(16) | NOT NULL, default `success` | 取值:`running`(harvest 帧0 建行、比价进行中)/ `success`(有非源且有价的目标结果)/ `failed`(出错/没采到目标价)/ `cancelled`(用户终止 / Phase1 未识别,`harvest_abort` 写,**不降级已 success**)。**里程碑只数 success** |
| `information` | String(256) | nullable | done 帧文案;成功=摘要,失败=具体原因(前端失败时当原因展示) |
+44
View File
@@ -0,0 +1,44 @@
# coupon_session — 领券任务全程流水(admin「领券数据」看板数据源)
> 模型 `app/models/coupon_state.py`(`CouponSession`) · 仓库 `app/repositories/coupon_state.py`(`upsert_session`) · 接口 C 端 `POST /api/v1/coupon/session`([coupon-session](../api/coupon/coupon-session.md));admin `GET /admin/api/coupon-data/*`(聚合看板 + 明细,`app/admin/routers/coupon_data.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
**一次领券任务一行**(`trace_id` 唯一),记从发起(`started`)到收尾(`completed`/`failed`/`abandoned`)的全程:总耗时、各平台耗时、领到张数、机型/ROM、发起来源。与同文件的三张「今日状态」表([coupon_state](./coupon_state.md))分工不同:那三张按「设备×日」管**频控/置灰**,本表按「一次任务」管**漏斗与体验指标**——admin「领券数据」看板的发起数、完成率、中途流失(started 无终态)、平均耗时都从这算。#99 新增(2026-06-30)。
## 用在哪 / 增删改查
- **C / U(upsert,按 `trace_id`)**:客户端 `POST /api/v1/coupon/session` **两段上报**——发起时建行(`status='started'`,带 platforms/origin_package/机型),收尾时按同 `trace_id` 更新同一行(终态 + `elapsed_ms` + `platform_elapsed` + `claimed_count`)。发起即落库 → 收尾丢失(App 被杀/断网)的行永远停在 `started` = 中途流失,可量化。
- **D**:无。
- **R**:admin `GET /admin/api/coupon-data`(按 `started_date` × `app_env` 聚合趋势/漏斗 + 逐条明细,join `user` 出手机号)+ `GET /admin/api/coupon-data/user-records`(用户抽屉:某用户全部领券记录)。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `trace_id` | String(64) | NOT NULL, **UNIQUE**(`uq_coupon_session_trace`) | 一次领券唯一 id(客户端 UUID,全程贯穿),upsert 幂等键;与 pricebot work_logs / `coupon_claim_record.trace_id` 同源 |
| `device_id` | String(64) | NOT NULL | 客户端 per-install id |
| `user_id` | Integer | index, nullable | 登录态才带(admin join `user` 出手机号/昵称);匿名领券为空。**无硬 FK** |
| `status` | String(16) | NOT NULL | `started`(发起)/ `completed` / `failed` / `abandoned`(用户中止)。`started` 无终态 = 中途流失 |
| `app_env` | String(16) | index, nullable | `prod` / `dev`(客户端 BuildConfig.DEBUG)。admin 报表默认只看 prod,防测试数据串台 |
| `platforms` | JSON(PG: JSONB) | nullable | 发起勾选平台 `["meituan-waimai", ...]`;空=全领 |
| `origin_package` | String(64) | nullable | 发起来源外卖 App 包名;null=App 内(傻瓜比价首页)发起,非空=切到美团/淘宝/京东被弹券引导发起。admin「发起平台」列据此区分 |
| `device_model` | String(128) | nullable | 机型(Build.MANUFACTURER + MODEL) |
| `rom` | String(64) | nullable | ROM(OemDetector,如 `ColorOS 14`) |
| `started_at` | DateTime(tz) | NOT NULL | 发起时刻(客户端墙钟) |
| `started_date` | Date | NOT NULL | 发起的 Asia/Shanghai 自然日;admin 按天聚合/筛选(复合索引) |
| `finished_at` | DateTime(tz) | nullable | 收尾时刻(服务端 now);未收尾(流失)为空 |
| `elapsed_ms` | Integer | nullable | 全程耗时(客户端点发起→收尾计时,权威);平均/分位只统计 completed |
| `platform_elapsed` | JSON(PG: JSONB) | nullable | 各平台领券耗时 `{"meituan-waimai": 3200, ...}`(ms) |
| `claimed_count` | Integer | nullable | 本次领到张数(收尾上报) |
| `trace_url` | String(512) | nullable | pricebot 公网调试链接(admin 明细可点开复盘) |
| `created_at` / `updated_at` | DateTime(tz) | server_default now() / +onupdate | |
## 关系 / Join Key
- `user_id` 软指 `user.id`(无硬 FK,可空;admin 明细 LEFT JOIN 出手机号)。
- `trace_id` 与 pricebot work_logs、[`coupon_claim_record`](./coupon_state.md).`trace_id` 同源(一次任务),无 id 级 join。
- `device_id` 与领券三表同源(per-install id)。
## 索引与约束
- PK `id`;UNIQUE `trace_id`;index `user_id``app_env`;复合 index `ix_coupon_session_date_env`(`started_date`, `app_env`)= admin 看板主查询路径。
## 注意
- 写库 best-effort 口径与领券三表一致:上报失败不影响领券本身(且本表数据源是客户端**独立上报**,不是 `/coupon/step` 透传顺手写)。
- `elapsed_ms` 以客户端计时为准(墙钟差不影响);`finished_at - started_at` 只做时刻留痕,不用来算时长。
+1
View File
@@ -1,6 +1,7 @@
# coupon_state — 领券今日状态三张表(弹窗频控 / 首页置灰 / 领券记录)
> 模型 `app/models/coupon_state.py` · 仓库 `app/repositories/coupon_state.py` · 接口 `app/api/v1/coupon.py`(prefix `/api/v1/coupon`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 同模型文件里还有第四张表 [`coupon_session`](./coupon_session.md)(一次领券任务一行的全程流水,admin「领券数据」看板数据源,#99)——维度与本文三张「设备×日」状态表不同,单独成文。
领券(优惠券自动化)联动产生的三张「今日状态」表,都挂在领券透传端点 `POST /api/v1/coupon/step` 这条链路上(pricebot 跑领券,结果回 app-server 落库;**领券过程本身在 pricebot 内存态跑、不落库**)。三表各管一件事:
+13 -4
View File
@@ -2,7 +2,7 @@
> 模型 `app/models/cps_order.py` · 仓库 `app/admin/repositories/cps.py`(`reconcile_orders` / `_map_order_fields` / `list_orders`、统计 `group_stats`) · 接口 admin `POST /admin/api/cps/orders/reconcile``GET /admin/api/cps/orders``GET /admin/api/cps/stats` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
美团联盟 `query_order` 按时间窗拉回、按 `sid` 归群的订单明细,是 CPS 群发漏斗的**最下游"赚了多少佣金"**:用户点 [`cps_link`](./cps_link.md)(携群 [`cps_group`](./cps_group.md) 的 `sid`)下单后,美团把订单连同 `sid` 回传,这里按 `sid` 归群做对账,与 [`cps_click`](./cps_click.md) 的点击量在 `group_stats` 汇成"点击→下单→佣金"漏斗。**仅美团有此表**——淘宝/京东无对账 API,统计里对账字段显示 `-`
从联盟 API 按时间窗拉回、按 `sid` 归群的订单明细,是 CPS 群发漏斗的**最下游"赚了多少佣金"**:用户点 [`cps_link`](./cps_link.md)(携群 [`cps_group`](./cps_group.md) 的 `sid`)下单后,联盟把订单连同 `sid` 回传,这里按 `sid` 归群做对账,与 [`cps_click`](./cps_click.md) 的点击量在 `group_stats` 汇成"点击→下单→佣金"漏斗。**平台覆盖**:初版仅美团(`query_order`);#90(2026-06-30)接入**京东联盟**订单(拉单进同一张表,`platform='jd'`,配 `jd_*`/`external_*` 列,喂数据大盘);淘宝仍无对账 API,统计里对账字段显示 `-`
## 用在哪 / 增删改查
- **C/U(upsert)**:`POST /admin/api/cps/orders/reconcile`(`reconcile_orders`,需 `finance` 角色)。调 `meituan.query_order(sid?, start_time, end_time, page, limit=100)` 分页拉单(`max_pages=200` 防死循环),每条经 `_map_order_fields` 转字段,按 `order_id` **upsert**:不存在则 insert,存在则逐字段覆盖(订单状态会随时间变 付款→完成→结算/退款,重复拉则更新)。返回 `{fetched, inserted, updated, pages}``order_id` 全局唯一即幂等键。
@@ -15,7 +15,10 @@
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join / 源字段) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `order_id` | String(64) | **UNIQUE, index, NOT NULL** | 美团订单号(加密串),`orderId`。upsert 幂等键 |
| `platform` | String(20) | index, NOT NULL, default `meituan` | 订单来源联盟:`meituan` / `jd`(#90)。统计/大盘按它分平台 |
| `order_id` | String(64) | **UNIQUE, index, NOT NULL** | 订单号(美团加密串 `orderId`;京东为联盟订单号)。upsert 幂等键 |
| `external_order_id` | String(128) | index, nullable | 平台原始订单号(#90,京东 `orderId`;美团 null) |
| `external_row_id` | String(128) | index, nullable | 平台订单行号(#90,京东一单多 sku 时区分行;美团 null) |
| `sid` | String(64) | index, **nullable** | 渠道追踪位 = 群 `sid`,源 `sid`。**按它归群聚合**;历史无 sid 订单为空 |
| `act_id` | String(64) | index, nullable | 活动物料 ID,源 `actId`(转字符串) |
| `biz_line` | Integer | nullable | 业务线,源 `businessLine``1=外卖` |
@@ -25,9 +28,14 @@
| `commission_rate` | String(16) | nullable | 佣金率,源 `commissionRate``"300"=3%``"10"=0.1%`(原样字符串,前端解释) |
| `refund_price_cents` | Integer | nullable | 退款金额(分),源 `refundPrice`(元) |
| `refund_profit_cents` | Integer | nullable | 退款佣金(分),源 `refundProfit`(元) |
| `mt_status` | String(8) | index, nullable | 美团订单状态,源 `status`:`2`付款 `3`完成 `4`取消 `5`风控 `6`结算 |
| `estimated_commission_cents` | Integer | nullable | 预估佣金(分,#90 京东口径;美团用 `commission_cents`) |
| `actual_commission_cents` | Integer | nullable | 实际/结算佣金(分,#90 京东口径) |
| `mt_status` | String(8) | index, nullable | 美团订单状态,源 `status`:`2`付款 `3`完成 `4`取消 `5`风控 `6`结算;京东订单为 null |
| `jd_valid_code` | String(16) | index, nullable | 京东订单有效码(#90,联盟 `validCode`,判有效/无效/风控);美团订单为 null |
| `invalid_reason` | String(128) | nullable | 失效原因,源 `invalidReason` |
| `product_name` | String(512) | nullable | 商品名,源 `productName`(超 500 截断) |
| `settle_month` | String(16) | nullable | 结算月份(#90 京东) |
| `site_id` / `position_id` / `pid` | String(128) | nullable | 京东推广位维度(#90):站点/推广位/联盟 pid,归因用 |
| `pay_time` | DateTime(tz) | index, nullable | 付款时间,源 `payTime`(秒级 ts)。统计按时间窗过滤的就是它 |
| `mt_update_time` | DateTime(tz) | nullable | 美团侧更新时间,源 `updateTime`(秒级 ts) |
| `raw` | JSON / JSONB | NOT NULL, default `{}` | `query_order` 单条原始 dataList,留底排查/补字段 |
@@ -48,4 +56,5 @@
- **"未归群"独立行**:`group_stats` 遍历完所有群后,对剩下的、有订单但 `sid` 不属任何现存群的 sid(历史遗留如 `wonderableai`、或别处来源、或群被删),单列一行 `group_id=None`、有对账无点击。所以本表 `sid` 可空/可孤立是设计内的,不是脏数据。
- **金额/时间统一转换的原因**:`query_order` 返回金额是「元」字符串、时间是秒级 ts;`_yuan_to_cents`(Decimal 防浮点,`"null"`/空 → None)与 `_ts_to_dt`(转 tz-aware UTC,前端按北京展示)在入库时归一,与全站"金额存分、时间存 tz-aware"口径对齐。`raw` 整条留底,字段不够时不用重拉。
- **upsert 而非 append**:同一订单会被多次拉到(状态变化),按 `order_id` 覆盖即可拿到最新状态;`reconcile` 可重复跑(幂等)。
- **本表自始至终只服务美团**:初版(`277f9b1`)就定型,`3a40f61` 接淘宝/京东时**没动本表**——那俩平台无对账 API,统计对账列直接`None`(前端显示 `-`)。这是"对账 = 美团专属"的边界。
- **平台边界的演变**:初版(`277f9b1`)只服务美团;#90(2026-06-30)接入**京东联盟**拉单进同一张表(`platform='jd'` + `external_*`/`jd_valid_code`/`estimated|actual_commission_cents` 等列,有效性按 `jd_valid_code` 判,喂 admin 数据大盘的京东收益)。**淘宝仍无对账 API**,统计对账列给 `None`(前端显示 `-`)。
- **#119`pay_time` 缺失**:早期美团拉单部分订单 `payTime` 空导致 `pay_time` 为 null → 大盘按时间窗过滤漏算美团收益;#119 起入库补齐/回填,时间窗统计以 `pay_time` 为准。
+3 -2
View File
@@ -9,7 +9,7 @@
## 用在哪 / 增删改查
- **C / U(upsert)**:`register_or_update`(`POST /device/register`,App 拿到 push token 时调)按 `(user_id, device_id)` upsert,只在传入非空时更新 `registration_id`/`platform`/`app_version`;`touch_heartbeat`(`POST /device/heartbeat`,无障碍服务存活时周期调,**心跳也能自注册**)在 `accessibility_enabled=true` 时刷 `last_heartbeat_at`、置 `ever_protected=true`、状态机重置回 `alive`、清 `notified_at`(掉线恢复→下次再断才再推一条)。
- **U(worker)**:`mark_notified`(`heartbeat_monitor_worker` 检出掉线后)置 `liveness_state='notified'` + `notified_at` + **`kill_alert_pending=True`**;`ack_kill_alert`(`POST /device/liveness/ack`,客户端弹过引导后)清 `kill_alert_pending`(幂等)。
- **R**:`list_overdue`(worker 扫描:`ever_protected=true` + `liveness_state='alive'` + `last_heartbeat_at` 早于 `now - timeout`)→ 掉线设备列表;`get_device`(`GET /device/liveness`,客户端进 App 拉本机是否被判掉线过)。
- **R**:`list_overdue`(worker 扫描:`ever_protected=true` + `liveness_state='alive'` + `last_heartbeat_at` 早于 `now - timeout`)→ 掉线设备列表;`get_device`(`GET /device/liveness`,客户端进 App 拉本机是否被判掉线过);admin `GET /admin/api/device-liveness/stats` + 列表(#80,后台「设备存活监控」页:总数/在线/掉线卡片 + 明细)
- **D**:无。
## 字段
@@ -22,6 +22,7 @@
| `platform` | String(16) | NOT NULL, default `android` | |
| `app_version` | String(32) | nullable | 上报时 App 版本 |
| `ever_protected` | Boolean | NOT NULL, default false | 收到过 service 心跳即 true(=该设备开过无障碍,功能对它有意义)。`list_overdue` 的过滤前提 |
| `first_protected_at` | DateTime(tz) | nullable | **首次**开启无障碍(首次收到心跳)的时刻(#80);置 `ever_protected=true` 时一并写、之后不再变。admin 设备存活监控用它算「开启保护耗时/转化」 |
| `last_heartbeat_at` | DateTime(tz) | index, nullable | 最近一次 service 心跳时间(存活证明);超时即视为保护掉线 |
| `last_report_protection_on` | Boolean | NOT NULL, default false | 最近一次上报的无障碍开关状态(观测用) |
| `liveness_state` | String(16) | NOT NULL, default `unknown` | 状态机:`unknown``alive`(收到 service 心跳)→ `silent`/`notified`(扫描发现超时并已推送);心跳恢复 handler 重置回 `alive` |
@@ -40,4 +41,4 @@
## 注意
- **`kill_alert_pending` 为什么和 `liveness_state` 解耦**:服务随 App 重启会先发心跳把 `state` 重置回 `alive`,若复用 `state` 判「待提醒」,客户端进 App 这一刻可能恰好已被重置 → 漏看这次掉线。故另设一个只由 worker 置、只由客户端 ack 清的标记,规避竞态。
- **`list_overdue` 本期不要求有 `registration_id`**:本期只做终端打印检测、未真推送,没接极光 token 的设备也要检出。
- 心跳超时阈值由 worker 的 `timeout_minutes` 决定(不在表里)。
- 心跳超时阈值由 worker 的 `timeout_minutes` 决定(不在表里);#107 起默认 **1 小时**(原 10 分钟误报率高:息屏/省电模式下心跳会正常停发)
+25 -11
View File
@@ -1,14 +1,14 @@
# feedback — 用户帮助与反馈
# feedback — 用户帮助与反馈(含审核发奖)
> 模型 `app/models/feedback.py` · 仓库 `app/repositories/feedback.py` · 接口 [feedback](../api/feedback.md) · admin [admin-feedbacks-list](../api/admin-feedbacks-list.md) / [admin-feedback-handle](../api/admin-feedback-handle.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/feedback.py` · 仓库 `app/repositories/feedback.py` · 接口 C 端 [feedback](../api/other/feedback.md) / [feedback-records](../api/other/feedback-records.md);admin `GET /admin/api/feedbacks``/summary``POST /{id}/approve|reject|handle`(`app/admin/routers/feedback.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
App「帮助与反馈」每次提交写一行。`content` 必填;`contact` 原必填,**原型改版后客户端不再采集,新数据存空串**(列保持 NOT NULL、免迁移,历史数据仍有值);`images` 为可选截图(≤6 张)。后台人工处理后置 `handled`。与 `price_report`(结构化上报更低价)不同,本表是**自由文本**反馈。
App「帮助与反馈」每次提交写一行。2026-06 起演进为**轻审核工单**:运营在后台采纳(`adopted`,可发金币)/拒绝(`rejected`,填原因)并可写**运营回复**(#105),C 端「我的反馈」列表把状态与回复展示给用户;提交侧自动采集**来源/场景 + 端环境**(App 版本/机型/ROM/Android 版本,#94)供排障。与 `price_report`(结构化上报更低价)不同,本表是**自由文本**反馈。
## 用在哪 / 增删改查
- **C(插入)**:`POST /api/v1/feedback`(multipart:`content` + 可选 `contact` + 可选 `images`;`create_feedback`)。截图`core.media``/media/feedback/` 拿相对路径,再随反馈写入,`status='new'`
- **U(更新)**:admin 处理反馈 `update_feedback_status``status='handled'`(同事务写 `admin_audit_log`)。
- **C(插入)**:`POST /api/v1/feedback`(multipart:`content` + 可选 `contact`/`images`/`source`/`scene`;客户端自动带 `app_version`/`device_model`/`rom_name`/`android_version`)→ `status='pending'`。截图经 `core.media``/media/feedback/`
- **U(更新,admin,均写 `admin_audit_log`)**:`approve`(→`adopted`,可选发金币 `reward_coins`,走 `grant_coins` 同事务)/ `reject`(→`rejected`,`reject_reason`)/ `handle`(→`handled`,旧口径「标记已处理」保留)。三者均可写 `admin_reply`(用户可见回复)与 `review_note`(内部备注)。
- **D**:无。
- **R**:admin 反馈列表(可按 `status` 筛)。C 端当前无"我的反馈列表"读接口
- **R**:C 端 `GET /api/v1/feedback/records`(我的反馈历史,展示状态/回复/奖励);admin 列表(状态/来源筛选)+ `summary`(各状态计数)
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
@@ -16,17 +16,31 @@ App「帮助与反馈」每次提交写一行。`content` 必填;`contact` 原
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 提交用户 |
| `content` | Text | NOT NULL | 反馈正文 |
| `contact` | String(128) | NOT NULL | 联系方式(微信/QQ/手机)。客户端改版后不再采集,新数据为空串;列仍 NOT NULL |
| `images` | JSON | nullable | 截图相对 URL 列表 `/media/feedback/...`;无图为 NULL |
| `status` | String(16) | NOT NULL, default `new` | 取值:`new`(待处理)/ `handled`(已处理) |
| `contact` | String(128) | NOT NULL | 联系方式。客户端改版后不再采集,新数据为空串;列仍 NOT NULL |
| `source` | String(16) | NOT NULL, default `profile`, index | 提交入口来源(#105):`profile`(设置/我的页普通反馈)/ 比价场景值;客户端显式传优先,未传由 `scene` 有无派生 |
| `scene` | String(32) | nullable | 比价反馈的「问题场景」(找错商品/优惠不对/比价太慢…,#105);比价结果页反馈才有,普通反馈为 NULL |
| `images` | JSON | nullable | 截图相对 URL 列表 `/media/feedback/...` |
| `app_version` | String(32) | nullable | 提交时 App versionName(#94) |
| `device_model` | String(64) | nullable | 机型 Build.MODEL(#94) |
| `rom_name` | String(32) | nullable | ROM(OemDetector:ColorOS/MIUI/…,#94) |
| `android_version` | String(16) | nullable | Android 版本(#94) |
| `status` | String(16) | NOT NULL, default `pending`, index | `pending`(待处理)/ `adopted`(已采纳,#94)/ `rejected`(已拒绝)/ `handled`(旧「已处理」口径,兼容保留;历史另有 `new`) |
| `reject_reason` | String(256) | nullable | 拒绝原因(用户可见) |
| `reward_coins` | Integer | nullable | 采纳发的金币数(未发为 null;发币走 `coin_transaction`) |
| `review_note` | String(256) | nullable | 运营内部备注(不外露) |
| `admin_reply` | String(256) | nullable | **运营回复**(用户可见,#105;C 端反馈历史展示) |
| `reviewed_by_admin_id` | Integer | nullable | 审核管理员 id(软指 `admin_user.id`) |
| `reviewed_at` | DateTime(tz) | nullable | 审核时间 |
| `created_at` | DateTime(tz) | server_default now(), index | 提交时间 |
## 关系 / Join Key
- `user_id``user.id`(多对一)。
- admin 处理时`admin_audit_log` 记录(`target_type='feedback'``target_id`=本行 id)。
- admin 审核动作`admin_audit_log` 记录(`target_type='feedback'`);`reviewed_by_admin_id` 软指 `admin_user.id`(无硬 FK)。
- 采纳发奖时写 `coin_transaction`(发币入口 `grant_coins` 同事务)。
## 索引与约束
- PK `id`;index `user_id``created_at`
- PK `id`;index `user_id``status``created_at`
## 注意
- `images` 用通用 `JSON`(本表**未**用 JSONB variant,与 comparison/savings 不同)。
- 状态机是单向的:`pending → adopted/rejected/handled``approve`/`reject` 只接受 `pending`(或历史 `new`)态,重复审核报 400;旧口径 `handle` **幂等不校验原状态**(重复调用结果一致)。
+42
View File
@@ -0,0 +1,42 @@
# invite_cash_transaction — 邀请奖励金流水账本(分)
> 模型 `app/models/wallet.py`(`InviteCashTransaction`) · 仓库 `app/repositories/wallet.py`(`grant_invite_cash` / 提现路径按 `source` 分账) · 接口 [wallet-account](../api/wallet/wallet-account.md)(余额)/ [wallet-withdraw](../api/wallet/wallet-withdraw.md)(`source=invite_cash` 提现) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
**邀请奖励金**(现金,分)的流水账本,与金币兑换的 `cash_transaction` **物理隔离**、结构同构:`balance_after_cents` 记的是 `coin_account.invite_cash_balance_cents`。隔离原因:邀请奖励金有独立的发放口径(好友**比价并下单**才发,#113)与独立的提现对账(`withdraw_order.source='invite_cash'`,#121),混在现金账本里无法分账校验。#82 新增(2026-06-27)。
## 用在哪 / 增删改查
- **C(插入)**:每次奖励金变动一笔:
| 动作 / endpoint | `biz_type` | `amount_cents` | `ref_id` 指向 |
|---|---|---|---|
| 好友比价并下单发奖(`POST /order/report``invite.try_reward_on_compare``grant_invite_cash`) | `invite_reward` | + | 被邀请人 `user.id`(字符串) |
| 发起提现 `POST /wallet/withdraw`(`source=invite_cash`) | `invite_withdraw` | | `withdraw_order.out_bill_no` |
| 该提现失败/审核拒绝退款 | `invite_withdraw_refund` | + | `withdraw_order.out_bill_no` |
| admin 手动调整 `POST /admin/api/users/{id}/cash`(`account=invite_cash`,#95) | `admin_grant` / `admin_deduct` | + / | null(原因记 `remark`=`admin:<reason>`) |
- **U / D**:无。账本只追加。
- **R**:admin 用户 360 / 提现资金账本校验 `GET /admin/api/withdraws/ledger-check`(按 `source` 分账对账,#121);C 端邀请页战绩(`invite.get_reward_stats`:余额 + 累计已提)。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(= 邀请人) |
| `amount_cents` | Integer | NOT NULL | 本笔变动(分);正=入账(发奖/退款),负=出账(提现) |
| `balance_after_cents` | Integer | NOT NULL | 本笔后奖励金余额(= 当时 `coin_account.invite_cash_balance_cents`) |
| `biz_type` | String(32) | NOT NULL | `invite_reward` / `invite_withdraw` / `invite_withdraw_refund` |
| `ref_id` | String(64) | nullable | 见上表:发奖=被邀请人 id;提现/退款=`withdraw_order.out_bill_no` |
| `remark` | String(128) | nullable | 用户可见备注(如「好友比价奖励」「提现到微信零钱(待审核)」) |
| `created_at` | DateTime(tz) | server_default now(), index | |
## 关系 / Join Key
- `user_id``user.id`(多对一,硬 FK)。
- `ref_id` →(withdraw 类)`withdraw_order.out_bill_no`(软关联;该单 `source='invite_cash'`)/(invite_reward)被邀请人 `user.id`
- 与 `invite_relation` 的联动:发奖同事务置 `invite_relation.compare_reward_granted=true`(幂等闸,一个被邀请人只发一次)。
## 索引与约束
- PK `id`;index `user_id``created_at`
## 注意
- **唯一变动入口 `wallet.grant_invite_cash`**:更新 `coin_account.invite_cash_balance_cents` + 写本表,**不 commit**,调用方同事务提交(与 `grant_coins` 同款约定)。
- 提现按 `source` 走不同账本:`coin_cash``cash_transaction`,`invite_cash` → 本表;`withdraw_order` 两种共用一张表靠 `source` 列区分。
+17 -13
View File
@@ -2,15 +2,16 @@
> 模型 `app/models/invite.py`(`InviteRelation`) · 仓库 `app/repositories/invite.py` · 接口 `app/api/v1/invite.py`:`POST /api/v1/invite/bind`(写)、`GET /api/v1/invite/me`(战绩)、`GET /api/v1/invite/invitees`(列表) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
一行 = 一次**成功的**邀请绑定。被邀请人(B)用邀请人(A)的邀请码完成绑定后写入,**注册即生效**:同一事务里给 A、B 各发 1 万金币(= 1 元,可提现)。数据来自 `bind()`,触发它的归因来源有三种(`channel`)。这是邀请功能的**结果表 / 账本**;邀请码本身存在 `user.invite_code`,不在这张表。
一行 = 一次**成功的**邀请绑定。被邀请人(B)用邀请人(A)的邀请码完成绑定后写入,**绑定关系注册即生效,但绑定本身不发奖**(#113 起):发奖后置到 B **完成比价并实际下单**(`POST /order/report``try_reward_on_compare`),给 A 发**邀请奖励金**(入独立账本 [invite_cash_transaction](./invite_cash_transaction.md),非金币),本表 `compare_reward_granted` 做幂等闸。数据来自 `bind()`,触发它的归因来源有三种(`channel`)。这是邀请功能的**结果表 / 账本**;邀请码本身存在 `user.invite_code`,不在这张表。
## 用在哪 / 增删改查
- **C(插入)**:`POST /api/v1/invite/bind`(`bind_invite`)→ `invite_repo.bind()`。过四道防线后建一行 `status='effective'`,**同事务**复用 `wallet.grant_coins` 给双方发金币(`grant_coins` 只 flush,由 `bind()` 统一 commit)→ "建关系 + 双方加金币"原子
- **幂等键 = `invitee_user_id` 唯一**:一个 B 只能被绑一次,重复请求返回 `already_bound`,不重复发奖(仿 `ad_reward_record.trans_id` 思路)。并发下两请求同时插同一 invitee → 后者撞唯一约束 `IntegrityError`,`bind()` rollback 后改判 `already_bound` 兜底。
- 四道防线(`bind()` 内):① invitee 已绑 → `already_bound`;② 无效码 / 邀请人非 `active``invalid_code`;③ 自邀(inviter==invitee)→ `self_invite`;④ 新人闸 `_is_new_user`(B 的 `created_at``rewards.INVITE_NEW_USER_WINDOW_HOURS`=72h 内)→ 否则 `not_eligible`只有全过才插行 + 发奖。
- **U / D**:无。这张表只增不改不删(纯账本)
- **C(插入)**:`POST /api/v1/invite/bind`(`bind_invite`)→ `invite_repo.bind()`。过四道防线后建一行 `status='effective'`**#113 起绑定只建关系、不发奖**(`inviter_coin`/`invitee_coin` 新行恒 0)
- **幂等键 = `invitee_user_id` 唯一**:一个 B 只能被绑一次,重复请求返回 `already_bound`(仿 `ad_reward_record.trans_id` 思路)。并发下两请求同时插同一 invitee → 后者撞唯一约束 `IntegrityError`,`bind()` rollback 后改判 `already_bound` 兜底。
- 四道防线(`bind()` 内):① invitee 已绑 → `already_bound`;② 无效码 / 邀请人非 `active``invalid_code`;③ 自邀(inviter==invitee)→ `self_invite`;④ 新人闸 `_is_new_user`(B 的 `created_at``rewards.INVITE_NEW_USER_WINDOW_HOURS`=72h 内)→ 否则 `not_eligible`
- **U(发奖,#113)**:B 比价后下单 `POST /order/report``try_reward_on_compare(invitee_user_id)`:`compare_reward_granted=False` 才发 → 置 `True` + 记 `compare_reward_cents`/`compare_rewarded_at`,**同事务** `wallet.grant_invite_cash` 给 A 入邀请奖励金 → "置闸 + 入账"原子,一个 B 只发一次
- **D**:无。
- **R**:
- `GET /api/v1/invite/me`(`my_invite`)→ `get_stats(inviter_id)`:`count(*)` 得已邀人数、`sum(inviter_coin)` 得累计金币。
- `GET /api/v1/invite/me`(`my_invite`)→ `get_stats(inviter_id)`:`count(*)` 得已邀人数、`sum(inviter_coin)` 得累计金币(历史口径);另调 `get_reward_stats` 出邀请奖励金余额/累计已提(读 `coin_account.invite_cash_balance_cents` + `withdraw_order(source=invite_cash)`)
- `GET /api/v1/invite/invitees`(`my_invitees`)→ `get_invitees(inviter_id, limit, offset)`:join `user` 出被邀请人列表(倒序分页),名字降级兜底 `nickname → wechat_nickname → 脱敏手机号`,`coins``inviter_coin`
## 字段
@@ -20,15 +21,18 @@
| `inviter_user_id` | Integer | FK→user.id, index, NOT NULL | 邀请人(A) |
| `invitee_user_id` | Integer | FK→user.id, **UNIQUE**, index, NOT NULL | 被邀请人(B)。**唯一 = 幂等键**,一个 B 只能被归因一次 |
| `channel` | String(16) | NOT NULL, default `clipboard` | 归因来源:`clipboard`(剪贴板自动)/ `manual`(手动填码)/ `fingerprint`(指纹反查兜底)。入库前 `[:16]` 截断 |
| `status` | String(16) | NOT NULL, default `effective` | 当前只有 `effective`(注册即生效)。**预留** `pending`/`effective`:将来若改"完成首单才生效"时启用 |
| `inviter_coin` | Integer | NOT NULL, default 0 | 本次给 A 发的金币(记账留痕,=`rewards.INVITE_INVITER_COINS`=10000) |
| `invitee_coin` | Integer | NOT NULL, default 0 | 本次给 B 发的金币(=`rewards.INVITE_INVITEE_COINS`=10000) |
| `status` | String(16) | NOT NULL, default `effective` | 当前只有 `effective`(绑定关系注册即生效)。「发奖后置」没有走 status,而是用下面的 `compare_reward_granted` 闸表达 |
| `inviter_coin` | Integer | NOT NULL, default 0 | **历史留痕**(#113 前"绑定即发金币"时代给 A 发的金币,当时=10000);#113 起新绑定恒 0 |
| `invitee_coin` | Integer | NOT NULL, default 0 | 同上,给 B 发的金币历史留痕;新绑定恒 0 |
| `compare_reward_granted` | Boolean | NOT NULL, default false | **发奖幂等闸**(#113):B 首次「比价并下单」后置 true,一个 B 只给 A 发一次邀请奖励金 |
| `compare_reward_cents` | Integer | NOT NULL, default 0 | 实发的邀请奖励金(分,留痕;改常量不影响历史行) |
| `compare_rewarded_at` | DateTime(tz) | nullable | 发奖时间 |
| `created_at` | DateTime(tz) | server_default now(), index | 绑定时间(= 列表倒序键) |
## 关系 / Join Key
- `inviter_user_id``user.id`(硬 FK,多对一):一个 A 可邀多个 B。
- `invitee_user_id``user.id`(硬 FK,**一对一**,唯一约束):一个 B 至多一行。
- 发金币`coin_transaction`:`biz_type='invite_inviter'`(给 A,`ref_id=invitee.id`)/ `biz_type='invite_invitee'`(给 B,`ref_id=inviter.id`),双方 `ref_id` 互指对方便于对账
- 流水关联:**#113 前**的绑定发金币落 `coin_transaction`(`biz_type='invite_inviter'`/`'invite_invitee'`,`ref_id` 互指对方)——现为历史类型,新绑定不再产生;**#113 起**发奖落 [`invite_cash_transaction`](./invite_cash_transaction.md)(`biz_type='invite_reward'`,`ref_id=被邀请人 id`)
- `get_invitees``InviteRelation JOIN user ON user.id = invitee_user_id` 取被邀请人资料;`total` 单独 `count``has_more`
## 索引与约束
@@ -39,8 +43,8 @@
## 注意
- **防重复发奖三道**(`repositories/invite.py` docstring):① `invitee_user_id` 唯一(应用层 `_relation_of_invitee` 先查 + DB 唯一约束并发兜底);② 自邀屏蔽;③ 手机号天然唯一(每个 B = 一个真实手机号账号)= 限制刷量规模。
- **`status` 当前恒为 `effective`**:产品取"注册即生效"而非"完成首单才生效",`pending` 取值是为后者预留、目前不写入
- **`inviter_coin`/`invitee_coin` 是留痕字段**:写死当时发的金币值,即便日后改奖励常量,历史行仍保留发奖时的额度,便于对账
- **`status` 当前恒为 `effective`**(绑定关系维度);「发奖后置」由 `compare_reward_granted` 闸表达,没有引入 `pending` 状态
- **金币留痕字段已冻结**:`inviter_coin`/`invitee_coin` 只反映 #113 前旧口径的历史发放额,便于对账;新奖励额看 `compare_reward_cents`
- **`channel` 三种取值**:`clipboard`(deferred-deeplink 主路径,落地页写剪贴板、首启读回)/ `manual`(用户在邀请页手输)/ `fingerprint`(剪贴板被覆盖时走指纹兜底,见 [invite_fingerprint](./invite_fingerprint.md));三者都汇入同一个 `bind()`,只 `channel` 不同。
- **风控缺口(#24 设计文档「上线前还差什么」标注)**:1 万金币可提现且**邀请人无总数上限**,接码平台批量注册新号绑同码即可刷;上线前需加 inviter 上限 + 基础风控。当前 MVP 仅靠"手机号唯一 + 72h 新人闸"挡
- **风控演进**:#24 时代的缺口是"注册即发 1 万可提现金币、接码批量刷"——**#113 把发奖后置到真实比价+下单**(且发的是邀请奖励金独立账本),批量注册空号不再直接得利,刷奖成本显著抬高;inviter 总数上限等进一步风控仍待补
- **alembic 多 head**:本表迁移 `invite_code_and_relation`(`down_revision=11a1d08c6f55`)与 `coupon_state_tables` 是同一父的兄弟迁移,合 main 前需建 merge 迁移,否则 prod `alembic upgrade head``Multiple head revisions`(#24 设计文档已警示)。
+4 -3
View File
@@ -1,6 +1,6 @@
# withdraw_order — 提现单(现金 → 微信零钱)
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-withdraw](../api/wallet-withdraw.md) / [wallet-withdraw-status](../api/wallet-withdraw-status.md) / [wallet-withdraw-orders](../api/wallet-withdraw-orders.md) · admin [admin-withdraws-list](../api/admin-withdraws-list.md) / [admin-withdraw-refresh](../api/admin-withdraw-refresh.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-withdraw](../api/wallet/wallet-withdraw.md) / [wallet-withdraw-status](../api/wallet/wallet-withdraw-status.md) / [wallet-withdraw-orders](../api/wallet/wallet-withdraw-orders.md) · admin [admin-withdraws-list](../api/admin/withdraws/admin-withdraws-list.md) / [admin-withdraw-refresh](../api/admin/withdraws/admin-withdraw-refresh.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
用户把现金余额提到微信零钱的工单。**含人工审核**(2026-06 起):发起即扣现金、进 `reviewing` 待审核、**不打款**;管理员后台审核通过才真正发起微信商家转账,拒绝则退款。
@@ -26,7 +26,8 @@ reviewing ──admin 审核拒绝──▶ rejected(已退款)
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `out_bill_no` | String(64) | UNIQUE, index, NOT NULL | 商户单号(幂等键 + 微信查单键)。客户端可传(`[0-9A-Za-z_-]{8,32}`),不传则服务端 `uuid4().hex`。**被 `cash_transaction.ref_id` 引用** |
| `out_bill_no` | String(64) | UNIQUE, index, NOT NULL | 商户单号(幂等键 + 微信查单键)。客户端可传(`[0-9A-Za-z_-]{8,32}`),不传则服务端 `uuid4().hex`。**被 `cash_transaction.ref_id` / `invite_cash_transaction.ref_id` 引用**(按 `source` 落对应账本) |
| `source` | String(16) | NOT NULL, default `coin_cash` | 提现账户来源(#121 分账):`coin_cash`(金币兑换的现金,扣 `coin_account.cash_balance_cents`、流水落 `cash_transaction`)/ `invite_cash`(邀请奖励金,扣 `invite_cash_balance_cents`、流水落 [`invite_cash_transaction`](./invite_cash_transaction.md)) |
| `amount_cents` | Integer | NOT NULL | 提现金额(分) |
| `user_name` | String(64) | nullable | 提现实名;微信**达额转账要求实名**,发起时存下、审核打款时传给微信 |
| `status` | String(16) | NOT NULL, default `reviewing` | 归一化状态:`reviewing`(待审核,已扣款未打款)/ `pending`(打款在途)/ `success` / `failed`(打款失败已退)/ `rejected`(审核拒绝已退) |
@@ -39,7 +40,7 @@ reviewing ──admin 审核拒绝──▶ rejected(已退款)
## 关系 / Join Key
- `user_id``user.id`(多对一)。
- `out_bill_no` ← 被 `cash_transaction.ref_id` 引用(发起 `withdraw` 一笔 ,失败/拒绝 `withdraw_refund` 一笔 +)。
- `out_bill_no` ← 被流水 `ref_id` 引用,**账本按 `source` 分**:`coin_cash` 单 → `cash_transaction`(`withdraw` / `withdraw_refund` +);`invite_cash` 单 → `invite_cash_transaction`(`invite_withdraw` / `invite_withdraw_refund` +)。admin `GET /admin/api/withdraws/ledger-check` 按 source 分账做「单 ↔ 流水」对账(#121)。
- 打款方式依赖 `wechat_transfer_authorization`(用户有生效授权 → 免确认转账,否则确认模式)。
## 索引与约束
+102 -97
View File
@@ -3,7 +3,7 @@
> 域名:`app-api.shaguabijia.com`(HTTPS,nginx 反代)
> 仓库:`shaguabijia-app-server`
> 接口协议详见 [`docs/api/`](./api/)(索引 + 一接口一文件)
> 最后更新:2026-06-23(§7 数据模型改为指向 OVERVIEW、§5 美团 CPS 对齐多 tab feed/4 接口、§6 厘清 coupon/step 有写库副作用 ≠ 纯透传壳、§6.5 补 `/internal/app-version`、Alembic 迁移数更新)
> 最后更新:2026-07-09(§1/§6.2 比价透传改「软鉴权 + trace_id 签发 + harvest 落库」不再是纯透传;§3 目录树补全到当前 22 个 v1 路由 + core 三 worker + utils/geo;§1 补 邀请奖励金/埋点/领券看板/设备存活;§5 美团 feed/top-sales 按城市过滤 #116;§7 45 张表、98 迁移;§8 部署对齐 ecs1 git-clone + systemd 定时器;§10 刷新。上一次 2026-06-23)
---
@@ -13,17 +13,23 @@
| 能力 | 说明 |
|---|---|
| **账号与登录** | 极光一键登录 + 短信验证码登录(mock)→ 签发 JWT |
| **账号与登录** | 极光一键登录 + 短信验证码登录(SMS_MOCK 切换)→ 签发 JWT;新手引导标记(设备+账号,含用户自助重置 #114) |
| **用户资料** | 昵称 / 头像(上传图片含魔数嗅探) / 注销账号(软删除+匿名化) |
| **美团 CPS 选品** | 透传美团联盟优惠券(外卖/到店)、点击换推广链接(分佣)未配凭证降级返空 |
| **领券透传** | `/coupon/step` 透传到 pricebot-backend(一键领券核心,MVP 不鉴权,前端已接通) |
| **外卖比价透传** | `/intent/recognize` + `/price/step` 透传 pricebot-backend(food MVP,MVP 不鉴权) |
| **金币 / 现金钱包** | 金币账户/流水/兑换/微信绑定/提现单 11 端点 |
| **美团 CPS 选品** | feed 多 tab(rec 离线库 / distance 实时)+ 销量榜,**按设备坐标离线反查城市、只出同城券**(#116);点击换推广链接(分佣);未配凭证降级返空 |
| **领券透传** | `/coupon/step` 透传到 pricebot-backend(一键领券核心,不鉴权,前端已接通)+ best-effort 写领券三表;`/coupon/session` 领券任务流水(admin 看板,#99) |
| **外卖比价透传 + 落库** | `/intent/*` + `/price/step` + `/trace/finalize``/trace/epilogue` 透传 pricebot,**软鉴权 + 首帧签发 trace_id + harvest 三段式落 `comparison_record`**(2026-07 起,见 §6.2) |
| **金币 / 现金钱包** | 金币账户/流水/兑换/微信绑定/提现单;**邀请奖励金独立账本**(#82,与现金物理隔离,`source` 分账提现 #121) |
| **好友邀请** | 邀请码/落地页指纹归因;发奖口径=好友**比价并下单**(#113,发邀请奖励金) |
| **签到 + 任务 + 省钱战绩** | 福利模块 |
| **看广告发奖** | 穿山甲 GroMore 激励视频 S2S 回调(SHA256 验签)+ 4 态 CTA 冷却 |
| **帮助与反馈** | 用户提交反馈(含截图)+ 静态 `/media` 服务 |
| **看广告发奖** | 穿山甲 GroMore 激励视频 S2S 回调(SHA256 验签)+ 信息流/Draw 结算(ad_type/feed_scene 分场景)+ 穿山甲后台收益日表拉取(#92) |
| **帮助与反馈** | 反馈工单(来源/场景/端环境采集 + admin 采纳发币/拒绝/运营回复,#94/#105)+ 静态 `/media` 服务 |
| **埋点** | `/analytics/events` 批量上报落 `analytics_event`(#83) |
| **设备存活监控** | 无障碍心跳 + 掉线检出(worker,超时 1h #107)+ 召回 ack;admin 存活看板(#80) |
| **平台配置 / OTA** | `/platform/*`:门面统计、feature flags、广告配置下发、App 版本检查更新(全不鉴权) |
| **CPS 群发联盟** | `/c/{code}` 短链落地 + 微信授权 + 联盟对账(美团+京东 #90),运营台在 admin |
| **运营后台 admin** | 独立子应用(8771,独立 JWT):用户/钱包/提现审核/反馈/大盘/收益报表/领券看板/RBAC 自定义角色(#117/#126)等,全量端点见 [api/README](./api/README.md) Admin 段 |
**本服务自身没有"比价"实现**——比价/领券业务的核心在 pricebot-backend(另一个 repo),本服务对 step 类接口做"透传壳"。无爬虫、无 LLM,但**有钱包/福利等业务模型**(数据模型从早期 1 张 `user`已扩展到 **40 张业务表**,见 §7 / [database/OVERVIEW.md](./database/OVERVIEW.md))。
**"比价/领券"的执行核心在 pricebot-backend**(另一个 repo),本服务对 step 类接口做透传;但 2026-07 起比价透传**不再是纯壳**——app-server 直接负责比价记录落库(§6.2)。无爬虫、无 LLM,业务模型已扩展到 **45 张业务表**(见 §7 / [database/OVERVIEW.md](./database/OVERVIEW.md))。仓库根另有 `h5/`(mine 我的页 + shared bridge/api,#89,H5 化的我的页静态资源)。
---
@@ -52,81 +58,56 @@
```
app/
├── main.py # FastAPI 入口:注册全部 router、CORS、/health、lifespan
├── main.py # FastAPI 入口:注册 21 个 v1 router + 4 个 internal router、CORS、
│ # lifespan(预热 pricebot client + 离线地理库,启 3 个后台 worker)、
│ # /media 静态服务 + /media/shaguabijia.apk 官网直链(强制下载头)
├── api/
│ ├── deps.py # 共享依赖:get_current_user(鉴权)、get_db(注入 session)
── v1/ # 接口层(薄):解析请求 → 调 repositories/integration → 组装响应 + HTTP 错误码
├── auth.py # 登录 6 端点(极光一键登录 / 短信 send+login / refresh / me / logout)
│ ├── user.py # 用户资料 3 端点(改昵称 / 上传头像 / 注销账号)
│ ├── feedback.py # 帮助与反馈 1 端点(提交反馈含截图)
│ ├── coupon.py # 领券透传 /coupon/step(转发 pricebot,MVP 不鉴权)
│ ├── compare.py # 外卖比价透传 /intent/recognize + /price/step(转发 pricebot,MVP 不鉴权)
│ ├── compare_record.py# 比价记录 3 端点(上报 /compare/record + 列表 /compare/records + 详情;鉴权,区别于上面透传)
│ ├── meituan.py # 美团 3 端点 + feed 拼接(_interleave / _TOPIC_ROUNDS),未配 MT_CPS 凭证降级返空
│ ├── wallet.py # 钱包/提现 11 端点(余额/流水/兑换/绑微信/提现/查单)
│ ├── signin.py # 签到 2 端点(状态 / 执行签到)
│ ├── tasks.py # 一次性任务 2 端点(列表 / 领取)
│ ├── savings.py # 省钱 3 端点(汇总 / 战绩 / 明细)
│ └── ad.py # 看广告发奖 3 端点(穿山甲 S2S 回调 / 进度+本轮冷却 / 联调发奖)
├── schemas/ # Pydantic:API 收发的数据契约(与客户端对齐字段看这里)
│ ├── auth.py
── user.py # 改昵称请求 + OkResponse
├── feedback.py # 反馈出参(请求是 multipart,在 router 直接校验)
├── meituan.py
├── welfare.py # 钱包/签到/任务/省钱 收发模型
│ ├── compare_record.py # 比价记录上报/列表/详情 收发模型(字段对齐 pricebot calibration + done.params)
│ └── ad.py # 看广告发奖收发模型
├── integrations/ # 外部服务/SDK 客户端(重逻辑:签名/加解密/外部 HTTP)
├── jiguang.py # 极光 REST 验 token + RSA 解密(多 padding 试错)
│ ├── meituan.py # 美团 CPS 网关签名 + query_coupon / get_referral_link
│ ├── sms.py # 短信验证码(mock,进程内存冷却表)
│ ├── pangle.py # 穿山甲激励视频发奖回调验签(SHA256,2026-05 从 core 移入)
── wxpay.py # 微信支付 V3 商家转账(提现)+ code 换 openid(2026-05 从 core 移入)
├── core/ # 基础设施(无外部业务集成)
│ ├── config.py # pydantic-settings
├── security.py # JWT 签发/校验
├── ratelimit.py # 同 IP 滑动窗口限流依赖
│ ├── rewards.py # 发奖/兑换/提现额度等业务常量与换算(2026-05 加 VIDEO_ROUND_REQUIRED_COUNT / VIDEO_ROUND_COOLDOWN_SECONDS)
│ ├── media.py # 用户上传文件(头像/反馈截图)落盘 + 魔数嗅探 + 随机文件名
│ └── logging.py
├── repositories/ # 数据访问 + 事务(早期叫 crud,2026-05 统一并入此目录)
│ ├── user.py # get_user_by_id / by_phone / upsert_for_login / update_nickname / set_avatar_url / soft_delete_account
│ ├── feedback.py # 提交反馈写库
│ ├── wallet.py # 账户/流水/兑换/提现单(调 integrations/wxpay)
│ ├── signin.py # 签到记录 / 连续天数 / 档位
│ ├── task.py # 一次性任务领取
│ ├── savings.py # 省钱汇总 / 战绩 / 明细
│ ├── comparison.py # 比价记录 upsert(user_id+trace_id 幂等)+ best/saved/status 派生 + 分页
│ └── ad_reward.py # 看广告发奖(按 trans_id 幂等 + 每日上限 + 本轮冷却派生)
├── models/ # ORM 表结构
│ ├── user.py # user(含微信 openid/nickname/avatar)
│ ├── feedback.py # 用户反馈(content/contact/images JSON 列/status)
│ ├── wallet.py # 金币账户 / 金币流水 / 现金流水 / 提现单
│ ├── signin.py # 签到记录
│ ├── task.py # 任务领取记录
│ ├── savings.py # 省钱明细 / 店铺菜品 / dishes(PG 上 JSONB)
│ ├── comparison.py # 比价记录(完整明细;含 4 个 JSON(B) 列 + raw_payload;独立于 savings)
│ └── ad_reward.py # 看广告发奖记录
└── db/
├── base.py # DeclarativeBase
└── session.py # engine + get_db(非 SQLite 时启 pool: size=10/overflow=20/recycle=3600)
│ ├── deps.py # 共享依赖:get_current_user / OptionalUser(软鉴权) / get_db
── internal/ # server→server 内部端点(X-Internal-Secret):app_version / launch_confirm / price / store
└── v1/ # 接口层(薄),21 个路由文件:
│ ├── auth.py user.py feedback.py # 登录 / 资料+引导(含 reset #114) / 反馈工单
│ ├── coupon.py # 领券透传 step + session 流水 + prompt/completed 频控族
│ ├── compare.py # 比价透传 + trace_id 签发 + harvest 落库(§6.2)
│ ├── compare_record.py compare_milestone.py # 比价记录(鉴权兜底上报/列表/详情/stats)+ 里程碑
│ ├── meituan.py # CPS 选品 4 端点(feed 多 tab / top-sales 按城市 #116)
│ ├── wallet.py wxpay.py # 钱包/提现(source 分账 #121)/transfer-auth 族 + 微信回调 stub
│ ├── signin.py tasks.py savings.py # 福利
│ ├── ad.py # 激励视频 S2S + 信息流/Draw 结算 + eCPM/noshow/watch
│ ├── invite.py order.py report.py # 邀请 / 支付归因(触发邀请发奖 #113) / 上报更低价
│ ├── analytics.py device.py platform.py # 埋点 #83 / 无障碍存活 #65 / 门面+flags+ad-config+OTA
│ └── cps_redirect.py # /c/{code} 短链落地 + 微信 OAuth(挂域名根)
├── admin/ # 运营后台独立子应用(8771,独立 JWT;app.main 不 import 它)
│ ├── main.py deps.py security.py permissions.py # 入口 / 鉴权链 / RBAC 页面目录(#117)
── routers/ # 23 个路由:users wallet withdraw feedback(+qr) dashboard comparison
# coupon_data device_liveness event_logs price_report onboarding
# ops_marquee_seed ops_stat_config ad_audit ad_revenue ad_config
# config admins roles(#126) audit auth cps
├── schemas/ # Pydantic 契约(17 个文件,与客户端对齐字段看这里)
├── integrations/ # 外部 SDK(重逻辑):jiguang / meituan(S-Ca 签名) / sms / pangle / wxpay
├── core/ # 基础设施:config(+config_schema 运营可配项定义) / security / ratelimit /
│ # rewards / media / logging / ad_cooldown / test_account(测试号免验证码 #69)
│ ├── pricebot_router.py # pricebot 多实例一致性 hash(ketama 1000 虚节点,按 trace_id 亲和)
│ ├── pricebot_client.py # 共享 httpx AsyncClient 单例(#87:免每请求重建 SSL 上下文、绕进程代理)
│ ├── withdraw_reconcile_worker.py # 提现对账 worker(lifespan 启动)
── heartbeat_monitor_worker.py # 无障碍心跳掉线检出 worker(#65,超时 1h #107)
│ └── daily_exchange_worker.py # 金币自动兑换 worker
├── utils/ # geo.py(离线经纬度→城市反查,~2.5M 行 CSV+KDTree,启动预热)
# + meituan_city.py(城市→美团 city_id,#116)
├── repositories/ # 数据访问 + 事务(28 个文件;comparison.py 含 harvest_running/done/abort 三段)
├── models/ # ORM 表结构(34 个文件,45 张业务表,见 database/OVERVIEW.md)
└── db/ # DeclarativeBase + engine/get_db(非 SQLite 启 pool 10+20)
alembic/ # 数据库迁移(versions/ 11+ 个迁移含 feedback_table / convert_dishes_jsonb / merge 等)
deploy/ # systemd(.service) + nginx(.conf)
secrets/ # 极光 RSA 私钥 / 微信支付证书(不入 git,仅 .gitkeep 占位)
scripts/
├── init_postgres.py # 一键 PG 初始化:建用户 + 建库 + 写 .env + 跑迁移(2026-05 新增,见已知 bug §10)
├── migrate.sh # 单独跑 alembic upgrade head(部署/CI 用)
├── reset_signin.py # 重置今日签到
├── reset_welfare.py # 重置福利数据
├── reconcile_withdraws.py # 提现对账
└── sim_pangle_callback.py # 模拟穿山甲回调
tests/ # pytest(auth / health / welfare / withdraw / ad_reward / coupon_proxy / compare_proxy)
run.sh # 本地启动脚本(自动先跑迁移再起服务)
docs/api/ # API 接口文档(索引 README + 一接口一文件)
docs/integrations/ # 集成层实现文档(SDK 签名/加解密/协议细节)
docs/database/数据库迁移.md # Alembic 迁移指南(如何建表/升级/新增迁移)
docs/database/postgres-migration.md # SQLite → PostgreSQL 切换指南(配套 scripts/init_postgres.py)
h5/ # H5 静态页(#89):mine 我的页 + shared bridge/api
alembic/ # 数据库迁移(98 个,含 14+ merge;单 head,见 §7)
deploy/ # systemd:app-server + admin 两服务;定时器 meituan-etl(选品 ETL)/
│ # pangle-revenue(穿山甲收益,每天 10:30 #100)/daily-exchange;nginx 配置
secrets/ # 极光 RSA 私钥 / 微信支付证书(不入 git)
scripts/ # init_postgres / migrate.sh / create_admin / 美团券 ETL(pull_meituan_coupons +
│ # load_meituan_coupon_tsv) / sync_pangle_revenue(#92) / publish_apk /
│ # reconcile_withdraws / reset_* / sim_pangle_callback / seed_mock_*(造数)
tests/ # pytest(外部集成全 monkeypatch,不打真 HTTP)
run.sh # 本地启动(钉定 .venv 解释器 #75,先迁移再起服务)
docs/api/ docs/database/ docs/integrations/ docs/guides/ # 文档(各自带索引)
```
> **命名说明**:`api/v1/``v1` 用于 URL 版本化(移动端无法强制即时升级,需新旧版本并存能力);`integrations` 装外部 SDK 集成、`repositories` 装数据访问、`core` 装基础设施,三者分离。**数据访问层统一在 `repositories/`**(早期叫 `crud/`,2026-05 已整体并入,`crud/` 不再存在)。`coupon.py` 是领券透传,勿与 `meituan.py` 里的 `coupons`(券列表)混淆。
@@ -190,15 +171,15 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
### 5.2 对外四个接口
> 接口级入参/出参/各 tab 行为详见 [api/meituan-feed.md](./api/meituan-feed.md) 等,本节只讲后端形态。
> 接口级入参/出参/各 tab 行为详见 [api/meituan/meituan-feed.md](./api/meituan/meituan-feed.md) 等,本节只讲后端形态。
- `coupons`:对外的搜索/榜单接口(底层 `query_coupon`),**客户端暂未接入**。
- `feed`:首页推荐流,**已是多 tab**(入参 `tab`):
- `rec` 智能推荐:走**离线库 `meituan_coupon`**(筛佣金率≥3%、`DISTINCT ON` 去重、按销量降序分页),**纯库查询、不打美团、不依赖 MT 凭证**(实测同城热销中位佣金 ~0.8%,实时筛≥3% 每页剩 0–1 条又撞 402,故从库出)。不显示距离。
- `rec` 智能推荐:走**离线库 `meituan_coupon`**(筛佣金率≥3%、`DISTINCT ON` 去重、按销量降序分页),**纯库查询、不打美团、不依赖 MT 凭证**(实测同城热销中位佣金 ~0.8%,实时筛≥3% 每页剩 0–1 条又撞 402,故从库出)。不显示距离。**#116 起按城市过滤**:设备经纬度经 `utils/geo`(离线反查,启动预热)+ `meituan_city` 映射成美团 `city_id`,只出同城券;拿不到坐标/城市 → 返空 + `status=degraded`
- `distance` 距离最近:实时拉外卖+到店两路,按用户坐标由近及远。
- 默认(空 tab):旧的逐轮分页混合 feed(2 外卖 + 1 到店交叉,写死 3 页爆款/今日必推/精选+限时,第 4 页返空),**仅老客户端兼容**。
- 任何失败场景返 `200` + 空 `items` + `status=degraded`(不抛 5xx)。
- `top-sales`:独立的销量榜接口,离线库 `meituan_coupon` 按销量降序 + 跨源去重,**不实时打美团**。
- `top-sales`:独立的销量榜接口,离线库 `meituan_coupon` 按销量降序 + 跨源去重 + **同城过滤**(#116,同 rec 的城市反查),**不实时打美团**。
- `referral-link`:换推广链接,客户端取 `link_map["3"]`(deeplink)优先跳美团 App。
- `query_coupon`:底层取数(被 `coupons`/`distance` feed 共用),不直接对外。
@@ -212,7 +193,7 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
## 6. 领券透传(coupon/step)
产品"一键领券"的核心接入点,**真正的领券逻辑不在本服务**——在另一个 repo `pricebot-backend`(GoalEngine + 事件驱动)。但 `coupon/step` **不是纯透传壳**:它在转发 pricebot 之余,还**best-effort 写库**(纯透传壳是 `compare.py` `intent/recognize``price/step`,它们只转发不写库)。
产品"一键领券"的核心接入点,**真正的领券逻辑不在本服务**——在另一个 repo `pricebot-backend`(GoalEngine + 事件驱动)。但 `coupon/step` **不是纯透传壳**:它在转发 pricebot 之余,还**best-effort 写库**。(比价透传 `compare.py` 2026-07 起同样带落库副作用,见 §6.2——现在全站已没有"只转发不写库"的透传端点,只有 `trace/epilogue` 例外。)
```
客户端 → POST /api/v1/coupon/step (任意 JSON body,含 device_id/trace_id/step)
@@ -231,6 +212,29 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
- **错误**:body 非合法 JSON → 400;pricebot 不可达或返回 5xx → 502。
- **配置**:`PRICEBOT_BASE_URL`(默认 `http://localhost:8000`)、`PRICEBOT_REQUEST_TIMEOUT_SEC`(默认 30s,因领券单帧最多 wait 6s)。
- **现状**:**前端已接通**——首页「去领取」→ `CouponPromptDialog` → 权限检查 → 无障碍引擎 `startCouponClaim` → 循环调本接口,逐张券下发 launch/wait/done。pricebot-backend 不在本目录。
- **配套**:`POST /coupon/session`(#99)由客户端**独立两段上报**(发起建行/收尾更新)落 `coupon_session` 流水,供 admin「领券数据」看板算发起数/完成率/中途流失/耗时——与 step 透传链路解耦。
---
## 6.2 外卖比价透传:软鉴权 + trace_id 签发 + harvest 落库(2026-07)
`compare.py``/intent/recognize``/intent/step``/intent/precoupon/step``/price/step``/trace/finalize``/trace/epilogue` 透传 pricebot 之余,**由 app-server 直接落库比价记录**(不再依赖客户端 POST /compare/record):
```
客户端首帧(可不带 trace_id)
→ compare._forward:app-server 用 uuid 签发 trace_id、注入转发 body、回填响应顶层
→ [harvest_running] 仅 mint 那帧:按 trace_id 建 comparison_record 的 running 行
→ 后续帧原样透传(原始 bytes 快路,不再写库)
→ done 帧(price/step)→ [harvest_done] 写 success/failed + 派生 best_*/saved_amount_cents
→ 用户终止/未识别 → 客户端打 /trace/finalize → [harvest_abort] 写 cancelled/failed(不降级已 success)
→ /trace/epilogue(#112):App 结果页截图透传入 trace,纯透传、不落库
```
- **软鉴权(OptionalUser)**:新客户端带 JWT → 记录绑 `user_id`;老客户端不带 → `user_id` 暂空,由其后续带 JWT 的 `POST /compare/record` 上报补齐(灰度期两条写路径按 `trace_id` reconcile)。
- **写库全 best-effort**:`run_in_threadpool` + 独立 SessionLocal,失败只 warning、绝不连累比价返回(同 coupon.py 口径)。
- **trace_url** 从 pricebot 响应顶层取(pricebot 每帧都带);查看权限按 `user.debug_trace_enabled` 控制。
- **邀请发奖不在 harvest**:#113 口径 = 好友「比价并下单」,发放在 `POST /order/report`(`invite.try_reward_on_compare`,`compare_reward_granted` 幂等闸,发**邀请奖励金**入独立账本)。
- 透传基建:`core/pricebot_router.pick_pricebot`(按 trace_id 一致性 hash 选实例)+ `core/pricebot_client`(共享 httpx 单例,#87)。
---
@@ -240,13 +244,13 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
**启动确认窗兜底样本**(2026-06):国产 ROM 打开别的 App 时弹"想要打开 XX"确认窗,pricebot 对没见过文案(繁体/英文/ROM 改版)的窗用 LLM 兜底放行后,把样本 POST 到 [`/internal/launch-confirm-sample`](../app/api/internal/launch_confirm.py) 落 `launch_confirm_sample` 表(host 包 + 弹窗树 + LLM plan + 设备 locale/机型),供研发定期人工沉淀回 pricebot 的规则 yaml(回到快路径)。**需 pricebot 与 app-server 两边 `.env` 配同一 `INTERNAL_API_SECRET` 才生效**(未配则 pricebot 侧跳过上报、不影响比价/领券)。
> 同类内部回写端点(同走 `X-Internal-Secret`)还有:`/internal/price-observation`(价格观测)、`/internal/store-mapping` + `/store-mapping/lookup` + `/store-mapping/invalidate`(跨平台店铺映射沉淀/反查/失效)等 pricebot 比价资产沉淀;以及 **`/internal/app-version`(OTA)**——**发布流程**(非 pricebot)出 APK 后写最新版本号/下载链接/sha256 落 `app_config`,客户端再 `GET /api/v1/platform/app-version` 读做检查更新。完整 6 个内部端点见 [api/internal.md](./api/internal.md)。
> 同类内部回写端点(同走 `X-Internal-Secret`)还有:`/internal/price-observation`(价格观测)、`/internal/store-mapping` + `/store-mapping/lookup` + `/store-mapping/invalidate`(跨平台店铺映射沉淀/反查/失效)等 pricebot 比价资产沉淀;`GET /internal/launch-confirm-samples`(#91,样本列表供 pricebot `distill_launch_confirm.py` 聚合沉淀回静态规则);以及 **`/internal/app-version`(OTA)**——**发布流程**(非 pricebot)出 APK 后写最新版本号/下载链接/sha256 落 `app_config`,客户端再 `GET /api/v1/platform/app-version` 读做检查更新。完整 7 个内部端点见 [api/internal/internal.md](./api/internal/internal.md)。
---
## 7. 数据模型
**完整表清单不在本文维护**(避免双份漂移)——共 **40 张业务表** + `alembic_version` 框架表,逐表字段级说明 + 跨表关系/写入路径见 **[database/OVERVIEW.md](./database/OVERVIEW.md)**(总览)与 [database/README.md](./database/README.md)(一表一文件索引)。生产 PG / 开发可回退 SQLite。
**完整表清单不在本文维护**(避免双份漂移)——共 **45 张业务表** + `alembic_version` 框架表,逐表字段级说明 + 跨表关系/写入路径见 **[database/OVERVIEW.md](./database/OVERVIEW.md)**(总览)与 [database/README.md](./database/README.md)(一表一文件索引)。生产 PG / 开发可回退 SQLite。2026-06 下旬以来新增:`coupon_session`(#99)、`analytics_event`(#83)、`invite_cash_transaction`(#82)、`admin_role`(#117)、`ad_pangle_daily_revenue`(#92)。
本文只保留分层与高频维度的速查:数据访问统一在 `repositories/`,ORM 表结构在 `models/`(钱包/福利在 `wallet.py`/`signin.py`/`task.py`、比价在 `comparison.py`、领券今日状态在 `coupon_state.py`、CPS 群发在 `cps_*.py`、pricebot 内部沉淀在 `price_observation.py`/`store_mapping.py`/`launch_confirm_sample.py`、无障碍存活在 `device.py`)。下面单列最常用的 `user` 表字段。
@@ -265,15 +269,15 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
`upsert_user_for_login`:phone 存在则更新 `last_login_at`,不存在则注册(注册即登录)。
**Alembic 迁移**:`alembic/versions/` 当前 **78 个迁移文件**(含 14 个合并迁移),当前单一 head `4dc2af7ebe74`(2026-06-23 合并 #65 device 链 + comparison token 列两条并行链)。多人/多分支并行改表频繁产生多 head,靠 `alembic merge` 收敛回单 head。规模随改表增长,**别背具体链**,以 `alembic history`/`alembic heads` 实时输出为准。详见 [数据库迁移.md](./database/数据库迁移.md)。
**Alembic 迁移**:`alembic/versions/` 当前 **98 个迁移文件**(含 14+ 个合并迁移),当前单一 head `admin_user_pages_override`(2026-07-08,#126)。多人/多分支并行改表频繁产生多 head,靠 `alembic merge` 收敛回单 head。规模随改表增长,**别背具体链**,以 `alembic history`/`alembic heads` 实时输出为准。⚠️ revision id 长度 ≤32 字符(`alembic_version` 列宽,超长部署时截断报错——0.2.1 实踩)。详见 [数据库迁移.md](./database/数据库迁移.md)。
---
## 8. 配置与部署
配置见 `core/config.py`(pydantic-settings 读 `.env`)。分组:环境、`DATABASE_URL`(默认 SQLite,生产应切 PG)、JWT、极光(`JG_*`)、短信(`SMS_MOCK` 默认 true)、美团(`MT_CPS_*`)、**pricebot 上游(`PRICEBOT_BASE_URL` / `PRICEBOT_REQUEST_TIMEOUT_SEC=30` / `PRICEBOT_COMPARE_TIMEOUT_SEC=60`)**、**媒体存储(`MEDIA_ROOT=./data/media` / `MEDIA_URL_PREFIX=/media`)**、CORS
配置见 `core/config.py`(pydantic-settings 读 `.env`)。分组:环境、`DATABASE_URL`(默认 SQLite,生产应切 PG)、JWT(+独立 `ADMIN_JWT_SECRET`)、极光(`JG_*`)、短信(`SMS_MOCK` 默认 true)、美团(`MT_CPS_*`)、**pricebot 上游(`PRICEBOT_BASE_URL` / 多实例 `PRICEBOT_INSTANCES` / `PRICEBOT_REQUEST_TIMEOUT_SEC=30` / `PRICEBOT_COMPARE_TIMEOUT_SEC=60`)**、**内部密钥(`INTERNAL_API_SECRET`,internal 族)**、穿山甲(`PANGLE_*`,含收益 API)、微信支付(`WXPAY_*`)、**媒体存储(`MEDIA_ROOT=./data/media` / `MEDIA_URL_PREFIX=/media`)**、CORS;运营可覆盖的业务常量在 `core/config_schema.py` 定义、`app_config` 表落值(admin `GET/PATCH /config`)
**生产部署**:systemd `shaguabijia-app-server.service`(WorkingDirectory `/opt/shaguabijia-app-server`,`EnvironmentFile=.env`,uvicorn 监听 `127.0.0.1:8770`,`--workers 1`)+ nginx 443 反代 → 8770。**无 Docker**。**PostgreSQL 16** 同机部署
**生产部署(2026-06-06 起在 ecs1)**:systemd 服务 `shaguabijia-app-server.service`(uvicorn `127.0.0.1:8770`,`--workers 1`)+ `shaguabijia-admin.service`(8771),WorkingDirectory `/opt/shaguabijia-app-server`,`EnvironmentFile=.env`,nginx 443 反代 `app-api.shaguabijia.com` → 8770。**无 Docker**。**PostgreSQL 16** 同机部署。代码走 **git clone(main 分支)+ Gitea 只读部署密钥**,日常发布用服务器上的 `deploy` 一键命令 / `release.sh` 发车 / deploy.shaguabijia.com 发车台(不再 rsync 整目录;`secrets/` 私钥证书单独放置、不入 git)
```bash
# 首次 PG 初始化(新机器或新环境):
@@ -281,12 +285,13 @@ ssh server "sudo apt install -y postgresql-16 && sudo systemctl enable --now pos
ssh server "cd /opt/shaguabijia-app-server && .venv/bin/python scripts/init_postgres.py"
# 该脚本会建业务用户 + 建库 + 写 .env 的 DATABASE_URL + 跑 alembic upgrade head
# 后续日常部署:
rsync -avz --exclude='.venv' --exclude='__pycache__' --exclude='data' --exclude='secrets/*.pem' ./ server:/opt/shaguabijia-app-server/
scp secrets/jverify_rsa_private.pem server:/opt/shaguabijia-app-server/secrets/ # 私钥单独传,不入 git
ssh server "cd /opt/shaguabijia-app-server && .venv/bin/alembic upgrade head && systemctl restart shaguabijia-app-server"
# 日常部署(deploy 一键命令等价动作):
ssh server "cd /opt/shaguabijia-app-server && git pull && .venv/bin/alembic upgrade head \
&& systemctl restart shaguabijia-app-server shaguabijia-admin"
```
**systemd 定时器**(`deploy/`,各带 .md 运维手册):`meituan-etl`(美团 CPS 选品 ETL → `meituan_coupon`,rec/top-sales 数据源)、`pangle-revenue`(穿山甲 GroMore 后台收益拉取,每天 10:30,#100)、`daily-exchange`(金币自动兑换;lifespan 里另有 in-process worker,两套并存以部署材料为准)。
完整 PG 切换流程见 [docs/database/postgres-migration.md](./database/postgres-migration.md)。
**生产 checklist(均为上线必查)**:
@@ -325,8 +330,8 @@ conda activate price # 首次:pip install -e .
| logout 无服务端失效 | 靠客户端清 token;后续加 jti 黑名单表(注销账号也是同问题——软删后旧 token 仍能用到自然过期) |
| 美团接口无鉴权 + sid 可覆盖 | 评估加鉴权/锁定 sid(注意首页要求未登录可见) |
| 美团接口未配凭证降级 | 未配 `MT_CPS_APP_KEY` 时 3 端点返空(不报 502),`/feed` 跟"已配但调用失败"路径无法区分——见 [integrations/meituan](./integrations/meituan.md) |
| 领券/比价依赖 pricebot | `coupon/step` / `intent/recognize` / `price/step` 仅透传,真正逻辑在 pricebot-backend;前端已接通领券链路,比价 food MVP 也已接通 |
| agent 系列接口 MVP 不鉴权 | 拿不到 user_id → 无法采集"哪个用户领了/买了什么"用户级画像(商业模式核心资产)。见 [待办与技术债.md](./guides/待办与技术债.md) P1 |
| 领券/比价依赖 pricebot | 执行核心在 pricebot-backend;比价透传 2026-07 起带 harvest 落库(§6.2),领券 `coupon/step` 带三表副作用,均非纯壳 |
| 领券 step 仍不鉴权;比价已软鉴权 | 比价透传族改 OptionalUser:带 JWT 即绑 `user_id`(用户级画像已能落到比价记录);`coupon/step` 仍拿不到 user_id(device 维度),老客户端比价记录靠 `/compare/record` 兜底补绑。见 [待办与技术债.md](./guides/待办与技术债.md) |
| SMS 已接极光(2026-06-03) | real 模式自定义验证码,上线只需 `SMS_MOCK=false`(复用极光凭证)。详见 [integrations/sms](./integrations/sms.md) |
| 短信冷却存内存 | 扩 worker 前需迁移到 Redis |
| `MEDIA_ROOT` 进程内 serve | 头像/反馈截图当前用 FastAPI StaticFiles,生产建议 nginx 直 serve 该目录 |
+89
View File
@@ -320,3 +320,92 @@ def test_read_apis_require_auth(admin_client: TestClient) -> None:
"/admin/api/feedbacks",
]:
assert admin_client.get(path).status_code == 401, path
def test_period_comparison_reward_coin_from_feed_scene(
admin_client: TestClient, admin_token: str
) -> None:
"""比价奖励金币口径:按 ad_feed_reward_record.feed_scene='comparison' 的实发金币
(status=granted)汇总,而非查从不写入的 biz_type (修复大盘该卡恒 0)
too_short(未发奖)不计"""
from app.models.ad_feed_reward import AdFeedRewardRecord
d = "2021-06-15" # 独立历史日,隔离其它用例数据
db = SessionLocal()
try:
uid = user_repo.upsert_user_for_login(db, phone="13800008801", register_channel="sms").id
db.add(AdFeedRewardRecord(
client_event_id="cmp-fs-granted", user_id=uid, reward_date=d,
ecpm_raw="0", coin=123, feed_scene="comparison", status="granted",
))
db.add(AdFeedRewardRecord(
client_event_id="cmp-fs-tooshort", user_id=uid, reward_date=d,
ecpm_raw="0", coin=99, feed_scene="comparison", status="too_short",
))
db.commit()
finally:
db.close()
r = admin_client.get(
"/admin/api/stats/overview", params={"date_from": d, "date_to": d},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
assert r.json()["period"]["coins"]["comparison_reward_coin_total"] == 123
def test_period_coupon_reward_coin_from_feed_scene(
admin_client: TestClient, admin_token: str
) -> None:
"""领券奖励金币口径:按 ad_feed_reward_record.feed_scene='coupon' 的实发金币汇总。"""
from app.models.ad_feed_reward import AdFeedRewardRecord
d = "2021-06-16"
db = SessionLocal()
try:
uid = user_repo.upsert_user_for_login(db, phone="13800008802", register_channel="sms").id
db.add(AdFeedRewardRecord(
client_event_id="cpn-fs-granted", user_id=uid, reward_date=d,
ecpm_raw="0", coin=456, feed_scene="coupon", status="granted",
))
db.commit()
finally:
db.close()
r = admin_client.get(
"/admin/api/stats/overview", params={"date_from": d, "date_to": d},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
assert r.json()["period"]["coins"]["coupon_reward_coin_total"] == 456
def test_period_coupon_reward_excludes_reward_video(
admin_client: TestClient, admin_token: str
) -> None:
"""领券奖励金币不再把激励视频金币算进来(reward_video/ad_reward 从领券桶拆出);
激励视频仍单独计入 reward_video_coin_total"""
from datetime import datetime
from app.models.wallet import CoinTransaction
d = "2021-06-17"
db = SessionLocal()
try:
uid = user_repo.upsert_user_for_login(db, phone="13800008803", register_channel="sms").id
db.add(CoinTransaction(
user_id=uid, amount=50, balance_after=50, biz_type="reward_video",
ref_id="rv-split-1", created_at=datetime(2021, 6, 17, 12, 0, 0),
))
db.commit()
finally:
db.close()
r = admin_client.get(
"/admin/api/stats/overview", params={"date_from": d, "date_to": d},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
coins = r.json()["period"]["coins"]
assert coins["coupon_reward_coin_total"] == 0 # 激励视频不计入领券奖励
assert coins["reward_video_coin_total"] == 50 # 仍计入激励视频卡
+77
View File
@@ -150,3 +150,80 @@ def test_create_admin_rejects_unknown_role(admin_client, super_token) -> None:
headers=_auth(super_token),
)
assert r.status_code == 400
def _login_pages(username: str, password: str = "pass1234") -> list[str]:
"""以某账号登录,取下发的有效可见页(左侧导航过滤依据)。"""
c = TestClient(admin_app)
return c.post(
"/admin/api/auth/login", json={"username": username, "password": password}
).json()["admin"]["pages"]
def test_custom_role_create_pages_take_effect(admin_client, super_token) -> None:
# 建「自定义」账号:role=custom + 勾选页(含一个非法 key,应被过滤)
r = admin_client.post(
"/admin/api/admins",
json={
"username": "cust_user", "password": "pass1234", "role": "custom",
"pages_override": ["dashboard", "withdraws", "xx-bad"],
},
headers=_auth(super_token),
)
assert r.status_code == 200, r.text
# 登录下发的 pages == 勾选集(非法 key 过滤),真正驱动左侧导航
assert set(_login_pages("cust_user")) == {"dashboard", "withdraws"}
# 账号列表回显原始勾选集(供编辑回填),同样已过滤
admins = {a["username"]: a for a in admin_client.get("/admin/api/admins", headers=_auth(super_token)).json()}
assert set(admins["cust_user"]["pages_override"]) == {"dashboard", "withdraws"}
assert admins["cust_user"]["role"] == "custom"
def test_custom_role_update_and_switch_back_clears_override(admin_client, super_token) -> None:
admin_client.post(
"/admin/api/admins",
json={
"username": "cust_sw", "password": "pass1234", "role": "custom",
"pages_override": ["dashboard"],
},
headers=_auth(super_token),
)
aid = next(
a["id"] for a in admin_client.get("/admin/api/admins", headers=_auth(super_token)).json()
if a["username"] == "cust_sw"
)
# 只改勾选集(角色不变)→ 生效
u = admin_client.patch(
f"/admin/api/admins/{aid}", json={"pages_override": ["dashboard", "feedbacks"]},
headers=_auth(super_token),
)
assert u.status_code == 200, u.text
assert set(_login_pages("cust_sw")) == {"dashboard", "feedbacks"}
# 切回普通角色 operator → override 清空,pages 跟随角色(运营页集,且不含 admins)
u2 = admin_client.patch(
f"/admin/api/admins/{aid}", json={"role": "operator"}, headers=_auth(super_token)
)
assert u2.status_code == 200, u2.text
admins = {a["username"]: a for a in admin_client.get("/admin/api/admins", headers=_auth(super_token)).json()}
assert admins["cust_sw"]["pages_override"] is None
pages = set(_login_pages("cust_sw"))
assert "feedbacks" in pages and "admins" not in pages # 运营口径,自定义页已不生效
def test_switch_operator_to_custom_sets_override(admin_client, super_token) -> None:
admin_client.post(
"/admin/api/admins",
json={"username": "op2cust", "password": "pass1234", "role": "operator"},
headers=_auth(super_token),
)
aid = next(
a["id"] for a in admin_client.get("/admin/api/admins", headers=_auth(super_token)).json()
if a["username"] == "op2cust"
)
u = admin_client.patch(
f"/admin/api/admins/{aid}",
json={"role": "custom", "pages_override": ["config"]},
headers=_auth(super_token),
)
assert u.status_code == 200, u.text
assert set(_login_pages("op2cust")) == {"config"}