From 66c1e3ad8f8e0e4592338fdcb87ff69663cc092c Mon Sep 17 00:00:00 2001 From: zhuzihao Date: Tue, 7 Jul 2026 00:16:12 +0800 Subject: [PATCH 01/24] =?UTF-8?q?fix(withdraw):=20=E6=8F=90=E7=8E=B0?= =?UTF-8?q?=E8=B4=A6=E6=9C=AC=E6=A0=A1=E9=AA=8C=E6=8C=89=20source=20?= =?UTF-8?q?=E5=88=86=E8=B4=A6,=E9=82=80=E8=AF=B7=E5=A5=96=E5=8A=B1?= =?UTF-8?q?=E9=87=91=E6=8F=90=E7=8E=B0=E7=BA=B3=E5=85=A5=E5=AF=B9=E8=B4=A6?= =?UTF-8?q?=20(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 线上后台「提现审核」页现金账本报红(缺扣款/缺退款流水),根因是 withdraw_ledger_check 用全部提现单去比对普通现金流水(cash_transaction), 而 source=invite_cash 的提现,其扣款/退款流水写在独立的 invite_cash_transaction 表,于是每笔邀请提现单都在普通现金流水里找不到、被误报为「缺流水」。 改动: - 抽出 _check_withdraw_ledger_side 复用单账核对逻辑;withdraw_ledger_check 改为按 order.source 分两本账各自对账: coin_cash ↔ cash_transaction(withdraw/withdraw_refund), invite_cash ↔ invite_cash_transaction(invite_withdraw/invite_withdraw_refund)。 - 邀请奖励金账户的余额差额也纳入校验(此前完全未对账)。 - 纯只读校验,不改任何资金/流水写入;不掩盖真实缺流水 (coin_cash 单若真缺流水仍照报)。 - WithdrawLedgerCheckOut 新增 7 个 invite_* 字段(默认 0,向后兼容)。 - 补 tests/test_withdraw_ledger_check.py(此前无 ledger-check 测试): 覆盖邀请提现不再误报、邀请账缺流水能被抓、普通现金账未回归。 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/121 Co-authored-by: zhuzihao Co-committed-by: zhuzihao --- app/admin/repositories/queries.py | 132 ++++++++++++++++++------ app/admin/schemas/wallet.py | 9 ++ tests/test_withdraw_ledger_check.py | 150 ++++++++++++++++++++++++++++ 3 files changed, 260 insertions(+), 31 deletions(-) create mode 100644 tests/test_withdraw_ledger_check.py diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index ac2be15..41a30da 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -23,7 +23,13 @@ from app.models.feedback import Feedback from app.models.onboarding import OnboardingCompletion from app.models.price_report import PriceReport from app.models.user import User -from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder +from app.models.wallet import ( + CashTransaction, + CoinAccount, + CoinTransaction, + InviteCashTransaction, + WithdrawOrder, +) # 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取") _NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct") @@ -734,26 +740,19 @@ def withdraw_risk_flags( return flags, score -def withdraw_ledger_check(db: Session) -> dict: - cash_balance_total = int( - db.execute(select(func.coalesce(func.sum(CoinAccount.cash_balance_cents), 0))).scalar_one() - ) - cash_txn_total = int( - db.execute(select(func.coalesce(func.sum(CashTransaction.amount_cents), 0))).scalar_one() - ) +def _check_withdraw_ledger_side( + orders: list[WithdrawOrder], txns: list, *, withdraw_biz: str, refund_biz: str +) -> dict: + """对某一本账(普通现金 / 邀请奖励金)做提现单 ↔ 流水的交叉校验。 - orders = list(db.execute(select(WithdrawOrder)).scalars().all()) - cash_txns = list( - db.execute( - select(CashTransaction).where( - CashTransaction.biz_type.in_(("withdraw", "withdraw_refund")) - ) - ).scalars().all() - ) - withdraw_refs = {txn.ref_id for txn in cash_txns if txn.biz_type == "withdraw"} + orders 已按 source 过滤到本账;txns 是本账流水表里 withdraw_biz/refund_biz 两类流水。 + 规则:每单发起应有一条扣款流水(ref_id=out_bill_no);失败/拒绝单应有且仅一条退款流水; + 非退款终态不应出现退款流水。四个计数全为 0 即本账自洽。 + """ + withdraw_refs = {txn.ref_id for txn in txns if txn.biz_type == withdraw_biz} refund_counts: dict[str, int] = {} - for txn in cash_txns: - if txn.biz_type == "withdraw_refund" and txn.ref_id: + for txn in txns: + if txn.biz_type == refund_biz and txn.ref_id: refund_counts[txn.ref_id] = refund_counts.get(txn.ref_id, 0) + 1 missing_withdraw = 0 @@ -768,24 +767,95 @@ def withdraw_ledger_check(db: Session) -> dict: if has_refund and order.status not in {"failed", "rejected"}: refund_on_non_terminal += 1 - duplicate_refund = sum(1 for count in refund_counts.values() if count > 1) - diff = cash_balance_total - cash_txn_total + return { + "missing_withdraw": missing_withdraw, + "missing_refund": missing_refund, + "duplicate_refund": sum(1 for count in refund_counts.values() if count > 1), + "refund_on_non_terminal": refund_on_non_terminal, + } + + +def withdraw_ledger_check(db: Session) -> dict: + """现金账本校验:两本物理隔离的账各自对账(产品红线:coin_cash / invite_cash 不串)。 + + 普通现金:CoinAccount.cash_balance_cents ↔ cash_transaction(withdraw/withdraw_refund); + 邀请奖励金:CoinAccount.invite_cash_balance_cents ↔ invite_cash_transaction + (invite_withdraw/invite_withdraw_refund)。 + 提现单按 source 分流到对应账核对——邀请提现的流水写在 invite_cash_transaction 表, + 绝不能拿去和普通现金流水比(否则每笔邀请提现单都会被误报「缺扣款/缺退款流水」)。 + 分流口径与 create_withdraw 一致:仅 source==invite_cash 走邀请账,其余(含历史空值)归普通现金。 + """ + orders = list(db.execute(select(WithdrawOrder)).scalars().all()) + coin_orders = [o for o in orders if o.source != "invite_cash"] + invite_orders = [o for o in orders if o.source == "invite_cash"] + + # —— 普通现金账(coin_cash) —— + cash_balance_total = int( + db.execute(select(func.coalesce(func.sum(CoinAccount.cash_balance_cents), 0))).scalar_one() + ) + cash_txn_total = int( + db.execute(select(func.coalesce(func.sum(CashTransaction.amount_cents), 0))).scalar_one() + ) + cash_txns = list( + db.execute( + select(CashTransaction).where( + CashTransaction.biz_type.in_(("withdraw", "withdraw_refund")) + ) + ).scalars().all() + ) + coin = _check_withdraw_ledger_side( + coin_orders, cash_txns, withdraw_biz="withdraw", refund_biz="withdraw_refund" + ) + cash_diff = cash_balance_total - cash_txn_total + + # —— 邀请奖励金账(invite_cash,独立账户 + 独立流水表) —— + invite_balance_total = int( + db.execute( + select(func.coalesce(func.sum(CoinAccount.invite_cash_balance_cents), 0)) + ).scalar_one() + ) + invite_txn_total = int( + db.execute( + select(func.coalesce(func.sum(InviteCashTransaction.amount_cents), 0)) + ).scalar_one() + ) + invite_txns = list( + db.execute( + select(InviteCashTransaction).where( + InviteCashTransaction.biz_type.in_(("invite_withdraw", "invite_withdraw_refund")) + ) + ).scalars().all() + ) + invite = _check_withdraw_ledger_side( + invite_orders, invite_txns, + withdraw_biz="invite_withdraw", refund_biz="invite_withdraw_refund", + ) + invite_diff = invite_balance_total - invite_txn_total + ok = ( - diff == 0 - and missing_withdraw == 0 - and missing_refund == 0 - and duplicate_refund == 0 - and refund_on_non_terminal == 0 + cash_diff == 0 + and invite_diff == 0 + and all(v == 0 for v in coin.values()) + and all(v == 0 for v in invite.values()) ) return { "ok": ok, + # 普通现金账(coin_cash:金币兑换的现金) "cash_balance_total_cents": cash_balance_total, "cash_transaction_total_cents": cash_txn_total, - "balance_diff_cents": diff, - "missing_withdraw_txn_count": missing_withdraw, - "missing_refund_txn_count": missing_refund, - "duplicate_refund_txn_count": duplicate_refund, - "refund_txn_on_non_terminal_count": refund_on_non_terminal, + "balance_diff_cents": cash_diff, + "missing_withdraw_txn_count": coin["missing_withdraw"], + "missing_refund_txn_count": coin["missing_refund"], + "duplicate_refund_txn_count": coin["duplicate_refund"], + "refund_txn_on_non_terminal_count": coin["refund_on_non_terminal"], + # 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账) + "invite_cash_balance_total_cents": invite_balance_total, + "invite_cash_transaction_total_cents": invite_txn_total, + "invite_balance_diff_cents": invite_diff, + "invite_missing_withdraw_txn_count": invite["missing_withdraw"], + "invite_missing_refund_txn_count": invite["missing_refund"], + "invite_duplicate_refund_txn_count": invite["duplicate_refund"], + "invite_refund_txn_on_non_terminal_count": invite["refund_on_non_terminal"], } diff --git a/app/admin/schemas/wallet.py b/app/admin/schemas/wallet.py index 06b4c7e..7f61d7b 100644 --- a/app/admin/schemas/wallet.py +++ b/app/admin/schemas/wallet.py @@ -130,6 +130,7 @@ class WithdrawBulkResult(BaseModel): class WithdrawLedgerCheckOut(BaseModel): ok: bool + # 普通现金账(coin_cash:金币兑换的现金) cash_balance_total_cents: int cash_transaction_total_cents: int balance_diff_cents: int @@ -137,6 +138,14 @@ class WithdrawLedgerCheckOut(BaseModel): missing_refund_txn_count: int duplicate_refund_txn_count: int refund_txn_on_non_terminal_count: int + # 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账)。默认 0 向后兼容。 + invite_cash_balance_total_cents: int = 0 + invite_cash_transaction_total_cents: int = 0 + invite_balance_diff_cents: int = 0 + invite_missing_withdraw_txn_count: int = 0 + invite_missing_refund_txn_count: int = 0 + invite_duplicate_refund_txn_count: int = 0 + invite_refund_txn_on_non_terminal_count: int = 0 class WxpayHealthCheckOut(BaseModel): diff --git a/tests/test_withdraw_ledger_check.py b/tests/test_withdraw_ledger_check.py new file mode 100644 index 0000000..f74206a --- /dev/null +++ b/tests/test_withdraw_ledger_check.py @@ -0,0 +1,150 @@ +"""提现现金账本校验(admin ledger-check)测试:两本物理隔离的账各自对账。 + +历史盲区:`withdraw_ledger_check` 曾拿全部提现单去和**普通现金流水**(cash_transaction)比对, +而 source=invite_cash 的提现单流水其实在 invite_cash_transaction 表,导致每笔邀请提现单都被 +误报「缺扣款/缺退款流水」。这里用真实提现 API 造单 + before/after 差值断言锁定修复: + 1) 邀请提现单不再污染普通现金账的缺流水计数; + 2) 邀请账户已被纳入对账(能抓到它自己的缺流水); + 3) 普通现金账的原有对账未被改坏。 + +conftest 的库是 session 级共享、测试间不清,故一律用 before/after 差值,只反映本用例造的数据。 +""" +from __future__ import annotations + +from sqlalchemy import delete, select + +from app.admin.repositories.queries import withdraw_ledger_check +from app.db.session import SessionLocal +from app.models.user import User +from app.models.wallet import CoinAccount, InviteCashTransaction +from app.repositories import wallet as crud_wallet + + +def _login(client, phone: str) -> str: + client.post("/api/v1/auth/sms/send", json={"phone": phone}) + r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"}) + assert r.status_code == 200, r.text + return r.json()["access_token"] + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _patch_userinfo(monkeypatch, openid: str) -> None: + monkeypatch.setattr( + "app.integrations.wxpay.code_to_userinfo", + lambda code: {"openid": openid, "nickname": None, "avatar_url": None, "raw": {}}, + ) + + +def _seed_balances(client, token: str, phone: str, *, cash: int = 0, invite_cash: int = 0) -> None: + client.get("/api/v1/wallet/account", headers=_auth(token)) + db = SessionLocal() + try: + user = db.execute(select(User).where(User.phone == phone)).scalar_one() + acc = db.get(CoinAccount, user.id) + acc.cash_balance_cents = cash + acc.invite_cash_balance_cents = invite_cash + db.commit() + finally: + db.close() + + +def _reject(bill: str, reason: str = "测试拒绝") -> None: + db = SessionLocal() + try: + crud_wallet.reject_withdraw(db, bill, reason) + finally: + db.close() + + +def _ledger() -> dict: + db = SessionLocal() + try: + return withdraw_ledger_check(db) + finally: + db.close() + + +def test_rejected_invite_withdraw_not_flagged_missing(client, monkeypatch) -> None: + """核心回归:一笔被拒绝的 invite_cash 提现单,扣款/退款流水都在 invite_cash_transaction, + 不应让普通现金账的缺扣款/缺退款计数增加(修复前每笔会各 +1)。""" + before = _ledger() + + _patch_userinfo(monkeypatch, "openid_lc_1") + token = _login(client, "13800005001") + _seed_balances(client, token, "13800005001", cash=0, invite_cash=500) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + r = client.post( + "/api/v1/wallet/withdraw", + json={"amount_cents": 200, "source": "invite_cash"}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + _reject(r.json()["out_bill_no"]) # rejected + 退款流水落 invite_cash_transaction + + after = _ledger() + + # 普通现金账不该因这笔 invite 单产生缺流水(修复前会各 +1 → 就是页面上看到的误报) + assert after["missing_withdraw_txn_count"] == before["missing_withdraw_txn_count"] + assert after["missing_refund_txn_count"] == before["missing_refund_txn_count"] + # 邀请账扣款 + 退款流水齐全,邀请账自身也不该缺 + assert after["invite_missing_withdraw_txn_count"] == before["invite_missing_withdraw_txn_count"] + assert after["invite_missing_refund_txn_count"] == before["invite_missing_refund_txn_count"] + + +def test_invite_ledger_detects_missing_withdraw_txn(client, monkeypatch) -> None: + """删掉一笔 invite 提现单的扣款流水 → 邀请账缺扣款计数 +1、ok=False, + 证明邀请账户已真正纳入对账(修复前邀请账完全不校验、永远报不出问题)。""" + _patch_userinfo(monkeypatch, "openid_lc_2") + token = _login(client, "13800005002") + _seed_balances(client, token, "13800005002", cash=0, invite_cash=500) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + r = client.post( + "/api/v1/wallet/withdraw", + json={"amount_cents": 200, "source": "invite_cash"}, + headers=_auth(token), + ) + bill = r.json()["out_bill_no"] + + before = _ledger() + db = SessionLocal() + try: + db.execute( + delete(InviteCashTransaction).where( + InviteCashTransaction.ref_id == bill, + InviteCashTransaction.biz_type == "invite_withdraw", + ) + ) + db.commit() + finally: + db.close() + after = _ledger() + + assert ( + after["invite_missing_withdraw_txn_count"] + == before["invite_missing_withdraw_txn_count"] + 1 + ) + assert after["ok"] is False + + +def test_coin_cash_withdraw_still_reconciled(client, monkeypatch) -> None: + """普通现金 coin_cash 提现单齐全时不新增缺流水(确保分账改造没弄坏原有普通现金对账)。""" + _patch_userinfo(monkeypatch, "openid_lc_3") + token = _login(client, "13800005003") + _seed_balances(client, token, "13800005003", cash=500, invite_cash=0) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + + before = _ledger() + r = client.post( + "/api/v1/wallet/withdraw", + json={"amount_cents": 200, "source": "coin_cash"}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + after = _ledger() + + # 普通现金提现扣款流水随单写入 cash_transaction,缺扣款计数不变;邀请账更不受影响 + assert after["missing_withdraw_txn_count"] == before["missing_withdraw_txn_count"] + assert after["invite_missing_withdraw_txn_count"] == before["invite_missing_withdraw_txn_count"] From 9e011f699a531a1b326d5344427f30f052dcb2e7 Mon Sep 17 00:00:00 2001 From: zhuzihao Date: Tue, 7 Jul 2026 00:16:20 +0800 Subject: [PATCH 02/24] =?UTF-8?q?feat(marquee):=20=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E5=8F=AF=E6=8C=89=E6=95=B0=E6=8D=AE=E6=BA=90=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=20+=20=E9=BB=98=E8=AE=A4=E6=98=B5=E7=A7=B0?= =?UTF-8?q?=E5=BD=92=E5=85=A5=E3=80=8C=E6=97=A0=E6=98=B5=E7=A7=B0=E3=80=8D?= =?UTF-8?q?=E8=84=B1=E6=95=8F=E6=A1=A3=20(#122)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台「首页轮播种子」预览与用户名脱敏两处改动,对齐《省钱log&平台数据 显示策略》: - 预览按模式实时:get_feed 增加可选 mode 入参(不落库),admin 预览接口 /marquee-seeds/preview 透传 ?mode=,后台切换「混播/只真实/只种子」时预览即时对应; 不传 mode 时读持久化配置,客户端 /savings-feed 行为不变。 - 默认昵称归「无昵称」档:创建时自动分配的默认昵称(「用户」+9 位随机)不算用户主动设的 昵称,脱敏走 id 规则「用户*****+id后2位」而非昵称规则。新增 user.is_default_nickname 精确匹配生成格式,不误伤真人以「用户」开头的昵称(如「用户体验师」)。 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/122 Co-authored-by: zhuzihao Co-committed-by: zhuzihao --- app/admin/routers/ops_marquee_seed.py | 10 ++++++++-- app/repositories/ops_marquee.py | 15 +++++++++++---- app/repositories/user.py | 16 ++++++++++++++++ 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/app/admin/routers/ops_marquee_seed.py b/app/admin/routers/ops_marquee_seed.py index 592dd56..d1d053c 100644 --- a/app/admin/routers/ops_marquee_seed.py +++ b/app/admin/routers/ops_marquee_seed.py @@ -57,9 +57,15 @@ def list_seeds(db: AdminDb) -> list[OpsMarqueeSeedOut]: def preview_feed( db: AdminDb, limit: Annotated[int, Query(ge=1, le=30)] = 8, + mode: Annotated[str | None, Query(description="mixed/real/seed;不传=当前持久化模式")] = None, ) -> OpsSavingsFeedPreviewOut: - """返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。""" - return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit)) + """返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。 + + mode 显式指定则预览该模式(**不改持久化配置**,供前端切换开关时实时预览);不传则用当前持久化模式。 + """ + if mode is not None and mode not in ops_marquee.FEED_MODES: + raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed") + return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit, mode=mode)) # 注:/mode 两个端点须在 /{seed_id} 之前注册,否则 PATCH /mode 会被 /{seed_id} 抢先按 id 解析。 diff --git a/app/repositories/ops_marquee.py b/app/repositories/ops_marquee.py index 2572b3d..abf2978 100644 --- a/app/repositories/ops_marquee.py +++ b/app/repositories/ops_marquee.py @@ -30,6 +30,7 @@ from app.models.comparison import ComparisonRecord from app.models.ops_marquee_seed import OpsMarqueeSeed from app.models.user import User from app.repositories import app_config +from app.repositories.user import is_default_nickname # 首页轮播数据源模式(存 app_config.marquee_feed_mode): # mixed=真实优先+种子补位+合成兜底(默认,原行为);real=只真实(不足则少/空);seed=只种子+合成兜底。 @@ -140,9 +141,12 @@ def _synth_masked_name(rng: random.Random) -> str: def _mask_real(nickname: str | None, user_id: int) -> str: """真实用户脱敏(对齐 PRD「用户标识打码规则」):设过昵称→昵称脱敏(中英文皆可); - 没昵称→「用户」+5星+id 后 2 位(用户*****08),按 user_id 稳定、刷新不变脸。""" + 没昵称→「用户」+5星+id 后 2 位(用户*****08),按 user_id 稳定、刷新不变脸。 + + 创建时自动分配的默认昵称(「用户」+9 位随机,见 user.is_default_nickname)不算用户主动设的昵称, + 按「无昵称」处理走 id 规则(产品决策 2026-07:默认昵称归入「没昵称」档)。""" nick = (nickname or "").strip() - if nick: + if nick and not is_default_nickname(nick): return _mask_nickname(nick) return _mask_anon(user_id) @@ -213,9 +217,11 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]: return out -def get_feed(db: Session, limit: int = 8) -> list[dict]: +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 去重(同一用户只取最新一条,避免单人刷屏)。 不足用启用的种子补齐——**公平随机抽取** need 个(而非固定取前 N),让所有种子都有机会露出; 种子用户名留空则随机合成(避开撞名),金额取**长尾随机**(小额居多、偶尔大额,更像真实分布)。 @@ -223,7 +229,8 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]: 展示时间统一「刷新」成相对现在的最近时刻(从 now 往前**随机抖动**递减),保证轮播永远像刚发生、 节奏自然不机械(真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时时间)。 """ - mode = get_feed_mode(db) # mixed / real / seed(运营可在「首页轮播种子」页切换) + # 预览可显式指定模式(所见=选中模式,不依赖 PATCH 落库时序);None/非法 → 读持久化配置。 + mode = mode if mode in FEED_MODES else get_feed_mode(db) # mixed / real / seed items: list[dict] = [] used_names: set[str] = set() diff --git a/app/repositories/user.py b/app/repositories/user.py index 7397d44..7a560ef 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -42,6 +42,22 @@ def _gen_nickname() -> str: ) +def is_default_nickname(nickname: str | None) -> bool: + """是否为创建时自动分配的默认昵称(= "用户" + 9 位字母数字,见 [_gen_nickname])。 + + 这类不是用户主动设置的昵称,展示脱敏时按「无昵称」处理(走 id 规则,见 ops_marquee._mask_real)。 + 精确匹配生成格式(前缀 + 定长字母数字集),不误伤真人以「用户」开头的昵称(如「用户体验师」含汉字、 + 长度也不符)。用户改过昵称即不再匹配。""" + if not nickname: + return False + s = nickname.strip() + return ( + len(s) == len(_NICKNAME_PREFIX) + _NICKNAME_LEN + and s.startswith(_NICKNAME_PREFIX) + and all(c in _NICKNAME_ALPHABET for c in s[len(_NICKNAME_PREFIX):]) + ) + + def get_user_by_username(db: Session, username: str) -> User | None: return db.execute( select(User).where(User.username == username) From 0e149c83e752c1135bd7650552317ddd9c53eb3f Mon Sep 17 00:00:00 2001 From: Ghost <> Date: Tue, 7 Jul 2026 17:11:00 +0800 Subject: [PATCH 03/24] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BE=8E=E5=9B=A2=20CP?= =?UTF-8?q?S=20=E8=AE=A2=E5=8D=95=20pay=5Ftime=20=E5=85=A5=E5=BA=93?= =?UTF-8?q?=E4=B8=BA=E7=A9=BA=E5=AF=BC=E8=87=B4=E5=A4=A7=E7=9B=98=E7=BE=8E?= =?UTF-8?q?=E5=9B=A2=E6=94=B6=E7=9B=8A=E6=BC=8F=E7=AE=97=20(#119)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: guke Co-authored-by: 陈世睿 <2839904623@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/119 Co-authored-by: Ghost <> Co-committed-by: Ghost <> --- app/admin/repositories/ad_revenue.py | 22 ++++ app/admin/repositories/cps.py | 26 ++++- app/admin/repositories/queries.py | 120 +++++++++++++++++++- app/admin/repositories/stats.py | 164 +++++++++++++++++++++++---- app/admin/routers/ad_revenue.py | 1 + app/admin/routers/users.py | 8 +- app/admin/schemas/ad_revenue.py | 5 + app/admin/schemas/dashboard.py | 22 ++++ app/admin/schemas/user.py | 3 + 9 files changed, 341 insertions(+), 30 deletions(-) diff --git a/app/admin/repositories/ad_revenue.py b/app/admin/repositories/ad_revenue.py index c0d8c94..b1e2162 100644 --- a/app/admin/repositories/ad_revenue.py +++ b/app/admin/repositories/ad_revenue.py @@ -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], } diff --git a/app/admin/repositories/cps.py b/app/admin/repositories/cps.py index b678c38..419c62f 100644 --- a/app/admin/repositories/cps.py +++ b/app/admin/repositories/cps.py @@ -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 # ───────────── 群 ───────────── diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index 41a30da..1142460 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -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: diff --git a/app/admin/repositories/stats.py b/app/admin/repositories/stats.py index f799826..42e7998 100644 --- a/app/admin/repositories/stats.py +++ b/app/admin/repositories/stats.py @@ -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 @@ -299,12 +305,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 +320,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 +348,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, @@ -417,6 +436,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), @@ -480,11 +591,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 +608,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, diff --git a/app/admin/routers/ad_revenue.py b/app/admin/routers/ad_revenue.py index dc6129f..77685d4 100644 --- a/app/admin/routers/ad_revenue.py +++ b/app/admin/routers/ad_revenue.py @@ -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"], diff --git a/app/admin/routers/users.py b/app/admin/routers/users.py index 7e4a81b..fce483a 100644 --- a/app/admin/routers/users.py +++ b/app/admin/routers/users.py @@ -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( diff --git a/app/admin/schemas/ad_revenue.py b/app/admin/schemas/ad_revenue.py index cf875c3..6f9f49a 100644 --- a/app/admin/schemas/ad_revenue.py +++ b/app/admin/schemas/ad_revenue.py @@ -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:登录 + 开始比价 + " diff --git a/app/admin/schemas/dashboard.py b/app/admin/schemas/dashboard.py index 56f2c15..b3e008a 100644 --- a/app/admin/schemas/dashboard.py +++ b/app/admin/schemas/dashboard.py @@ -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] = [] diff --git a/app/admin/schemas/user.py b/app/admin/schemas/user.py index 2e554a8..7d673a6 100644 --- a/app/admin/schemas/user.py +++ b/app/admin/schemas/user.py @@ -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): From 58b59c264d9021d8a201b07af995c663f1ce740c Mon Sep 17 00:00:00 2001 From: guke Date: Tue, 7 Jul 2026 17:46:09 +0800 Subject: [PATCH 04/24] =?UTF-8?q?fix(dashboard):=20=E6=AF=94=E4=BB=B7/?= =?UTF-8?q?=E9=A2=86=E5=88=B8=E5=A5=96=E5=8A=B1=E9=87=91=E5=B8=81=E6=8C=89?= =?UTF-8?q?=20feed=5Fscene=20=E6=B1=87=E6=80=BB(=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=81=92=200=20+=20=E6=BF=80=E5=8A=B1=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E5=8F=8C=E8=AE=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 比价/领券奖励金币此前查 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) --- app/admin/repositories/stats.py | 26 ++++++++-- tests/test_admin_read.py | 89 +++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 3 deletions(-) diff --git a/app/admin/repositories/stats.py b/app/admin/repositories/stats.py index 42e7998..260c159 100644 --- a/app/admin/repositories/stats.py +++ b/app/admin/repositories/stats.py @@ -30,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, @@ -362,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, @@ -384,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, @@ -543,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, diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index 1babd3c..5f5313f 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -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 # 仍计入激励视频卡 From a52fc1973f91988ffbd267bcd7b7a2b6b713bbf3 Mon Sep 17 00:00:00 2001 From: guke Date: Tue, 7 Jul 2026 17:52:57 +0800 Subject: [PATCH 05/24] revert 58b59c264d9021d8a201b07af995c663f1ce740c MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/admin/repositories/stats.py | 26 ++-------- tests/test_admin_read.py | 89 --------------------------------- 2 files changed, 3 insertions(+), 112 deletions(-) diff --git a/app/admin/repositories/stats.py b/app/admin/repositories/stats.py index 260c159..42e7998 100644 --- a/app/admin/repositories/stats.py +++ b/app/admin/repositories/stats.py @@ -30,17 +30,11 @@ from app.models.user import User from app.models.wallet import CoinTransaction, WithdrawOrder _BEIJING = timezone(timedelta(hours=8)) -REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward") -# 领券/比价奖励金币的真实来源是信息流广告发奖(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") +COUPON_REWARD_BIZ_TYPES = ("reward_video", "ad_reward", "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, @@ -368,7 +362,7 @@ def dashboard_overview( period_reward_video_coin_total = _sum( CoinTransaction.amount, *period_coin_conds, - CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES), + CoinTransaction.biz_type.in_(("reward_video", "ad_reward")), ) period_feed_ad_coin_total = _sum( CoinTransaction.amount, @@ -390,29 +384,15 @@ 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, @@ -563,7 +543,7 @@ def dashboard_overview( "reward_video_coin_total": _sum( CoinTransaction.amount, CoinTransaction.amount > 0, - CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES), + CoinTransaction.biz_type.in_(("reward_video", "ad_reward")), ), "reward_video_watch_count": _count( AdRewardRecord, diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index 5f5313f..1babd3c 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -320,92 +320,3 @@ 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 # 仍计入激励视频卡 From 6ec4cfb4be41f382bac9f20d5440a84bc5b5c6f5 Mon Sep 17 00:00:00 2001 From: guke Date: Tue, 7 Jul 2026 17:46:09 +0800 Subject: [PATCH 06/24] =?UTF-8?q?fix(dashboard):=20=E6=AF=94=E4=BB=B7/?= =?UTF-8?q?=E9=A2=86=E5=88=B8=E5=A5=96=E5=8A=B1=E9=87=91=E5=B8=81=E6=8C=89?= =?UTF-8?q?=20feed=5Fscene=20=E6=B1=87=E6=80=BB(=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=81=92=200=20+=20=E6=BF=80=E5=8A=B1=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E5=8F=8C=E8=AE=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 比价/领券奖励金币此前查 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) --- app/admin/repositories/stats.py | 26 ++++++++-- tests/test_admin_read.py | 89 +++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 3 deletions(-) diff --git a/app/admin/repositories/stats.py b/app/admin/repositories/stats.py index 42e7998..260c159 100644 --- a/app/admin/repositories/stats.py +++ b/app/admin/repositories/stats.py @@ -30,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, @@ -362,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, @@ -384,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, @@ -543,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, diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index 1babd3c..5f5313f 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -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 # 仍计入激励视频卡 From e11ad9449c727d799a65a345303936fa2fddf1b2 Mon Sep 17 00:00:00 2001 From: guke Date: Tue, 7 Jul 2026 18:05:33 +0800 Subject: [PATCH 07/24] revert 6ec4cfb4be41f382bac9f20d5440a84bc5b5c6f5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/admin/repositories/stats.py | 26 ++-------- tests/test_admin_read.py | 89 --------------------------------- 2 files changed, 3 insertions(+), 112 deletions(-) diff --git a/app/admin/repositories/stats.py b/app/admin/repositories/stats.py index 260c159..42e7998 100644 --- a/app/admin/repositories/stats.py +++ b/app/admin/repositories/stats.py @@ -30,17 +30,11 @@ from app.models.user import User from app.models.wallet import CoinTransaction, WithdrawOrder _BEIJING = timezone(timedelta(hours=8)) -REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward") -# 领券/比价奖励金币的真实来源是信息流广告发奖(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") +COUPON_REWARD_BIZ_TYPES = ("reward_video", "ad_reward", "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, @@ -368,7 +362,7 @@ def dashboard_overview( period_reward_video_coin_total = _sum( CoinTransaction.amount, *period_coin_conds, - CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES), + CoinTransaction.biz_type.in_(("reward_video", "ad_reward")), ) period_feed_ad_coin_total = _sum( CoinTransaction.amount, @@ -390,29 +384,15 @@ 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, @@ -563,7 +543,7 @@ def dashboard_overview( "reward_video_coin_total": _sum( CoinTransaction.amount, CoinTransaction.amount > 0, - CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES), + CoinTransaction.biz_type.in_(("reward_video", "ad_reward")), ), "reward_video_watch_count": _count( AdRewardRecord, diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index 5f5313f..1babd3c 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -320,92 +320,3 @@ 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 # 仍计入激励视频卡 From a43e0823911669571d0afd56fb23c67a50c3ee41 Mon Sep 17 00:00:00 2001 From: guke Date: Tue, 7 Jul 2026 18:29:23 +0800 Subject: [PATCH 08/24] =?UTF-8?q?fix(dashboard):=20=E6=AF=94=E4=BB=B7/?= =?UTF-8?q?=E9=A2=86=E5=88=B8=E5=A5=96=E5=8A=B1=E9=87=91=E5=B8=81=E6=8C=89?= =?UTF-8?q?=20feed=5Fscene=20=E6=B1=87=E6=80=BB(=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=81=92=200=20+=20=E6=BF=80=E5=8A=B1=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E5=8F=8C=E8=AE=A1)=20(#125)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 世睿之前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) --------- Co-authored-by: guke Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/125 --- app/admin/repositories/stats.py | 26 ++++++++-- tests/test_admin_read.py | 89 +++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 3 deletions(-) diff --git a/app/admin/repositories/stats.py b/app/admin/repositories/stats.py index 42e7998..260c159 100644 --- a/app/admin/repositories/stats.py +++ b/app/admin/repositories/stats.py @@ -30,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, @@ -362,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, @@ -384,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, @@ -543,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, diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index 1babd3c..5f5313f 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -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 # 仍计入激励视频卡 From 93cb283c1b39672d9a748b355334b1298061cbac Mon Sep 17 00:00:00 2001 From: zhuzihao Date: Wed, 8 Jul 2026 00:57:00 +0800 Subject: [PATCH 09/24] =?UTF-8?q?feat(marquee):=20=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E6=9D=A1=E4=B8=8D=E6=8C=89=E7=94=A8=E6=88=B7=E5=8E=BB=E9=87=8D?= =?UTF-8?q?(=E6=89=93=E4=B9=B1+=E5=8E=BB=E8=BF=9E=E7=B0=87)+=20=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E6=8C=89=E6=A8=A1=E5=BC=8F=E5=88=86=E9=A1=B5=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=8F=AF=E5=B1=95=E7=A4=BA=E8=AE=B0=E5=BD=95=20(#123)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - get_feed 真实条不再按 user 去重:改「洗牌 + 去连簇」(相邻尽量不同用户、减少单人连刷), 同一用户可多条露出但被打散、尽量不连续。_shuffle_declustered 支持传入固定种子 rng。 - 新增 GET /admin/api/marquee-seeds/real-records:按**当前模式**分页浏览全部可展示记录(不去重), 供运营逐页审核——只真实=真实记录;只种子=各启用种子按生成逻辑各出一行;混播=真实+种子。 固定种子洗牌+去连簇 → 排列恒定、翻页稳定、能翻遍全部;含 OpsRealRecord* schema。 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/123 Co-authored-by: zhuzihao Co-committed-by: zhuzihao --- app/admin/routers/ops_marquee_seed.py | 18 +++++ app/admin/schemas/ops_marquee_seed.py | 13 ++++ app/repositories/ops_marquee.py | 104 +++++++++++++++++++++++--- 3 files changed, 124 insertions(+), 11 deletions(-) diff --git a/app/admin/routers/ops_marquee_seed.py b/app/admin/routers/ops_marquee_seed.py index d1d053c..0b3ff04 100644 --- a/app/admin/routers/ops_marquee_seed.py +++ b/app/admin/routers/ops_marquee_seed.py @@ -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: diff --git a/app/admin/schemas/ops_marquee_seed.py b/app/admin/schemas/ops_marquee_seed.py index db8c56d..acdf7b9 100644 --- a/app/admin/schemas/ops_marquee_seed.py +++ b/app/admin/schemas/ops_marquee_seed.py @@ -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 # 满足条件的真实记录总数(算页数用) diff --git a/app/repositories/ops_marquee.py b/app/repositories/ops_marquee.py index abf2978..be5c1d3 100644 --- a/app/repositories/ops_marquee.py +++ b/app/repositories/ops_marquee.py @@ -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( From 9e082e6376b01fc55359191b231809668907a6b1 Mon Sep 17 00:00:00 2001 From: liujiahui Date: Thu, 9 Jul 2026 01:09:03 +0800 Subject: [PATCH 10/24] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=9D=83=E9=99=90?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=94=AF=E6=8C=81=E3=80=8C=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E3=80=8D=E8=A7=92=E8=89=B2=EF=BC=88=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=EF=BC=89=20(#126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/126 Co-authored-by: liujiahui Co-committed-by: liujiahui --- alembic/versions/admin_user_pages_override.py | 35 +++++++++ app/admin/permissions.py | 3 + app/admin/repositories/admin_user.py | 5 +- app/admin/routers/admins.py | 33 ++++++-- app/admin/routers/auth.py | 9 ++- app/admin/schemas/admin.py | 6 +- app/admin/schemas/auth.py | 5 +- app/models/admin.py | 5 +- tests/test_admin_roles.py | 77 +++++++++++++++++++ 9 files changed, 167 insertions(+), 11 deletions(-) create mode 100644 alembic/versions/admin_user_pages_override.py diff --git a/alembic/versions/admin_user_pages_override.py b/alembic/versions/admin_user_pages_override.py new file mode 100644 index 0000000..ca49110 --- /dev/null +++ b/alembic/versions/admin_user_pages_override.py @@ -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") diff --git a/app/admin/permissions.py b/app/admin/permissions.py index e17f7db..ca0ee33 100644 --- a/app/admin/permissions.py +++ b/app/admin/permissions.py @@ -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] = [ diff --git a/app/admin/repositories/admin_user.py b/app/admin/repositories/admin_user.py index 828efe7..d9055df 100644 --- a/app/admin/repositories/admin_user.py +++ b/app/admin/repositories/admin_user.py @@ -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() diff --git a/app/admin/routers/admins.py b/app/admin/routers/admins.py index 5273a45..60d9511 100644 --- a/app/admin/routers/admins.py +++ b/app/admin/routers/admins.py @@ -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() diff --git a/app/admin/routers/auth.py b/app/admin/routers/auth.py index 6aa8e35..27b5bb3 100644 --- a/app/admin/routers/auth.py +++ b/app/admin/routers/auth.py @@ -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 diff --git a/app/admin/schemas/admin.py b/app/admin/schemas/admin.py index fcde8bf..ae12752 100644 --- a/app/admin/schemas/admin.py +++ b/app/admin/schemas/admin.py @@ -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): diff --git a/app/admin/schemas/auth.py b/app/admin/schemas/auth.py index 3972fed..ef64114 100644 --- a/app/admin/schemas/auth.py +++ b/app/admin/schemas/auth.py @@ -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 diff --git a/app/models/admin.py b/app/models/admin.py index 0c56963..8bb72b4 100644 --- a/app/models/admin.py +++ b/app/models/admin.py @@ -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") diff --git a/tests/test_admin_roles.py b/tests/test_admin_roles.py index ce3f4ca..85ab010 100644 --- a/tests/test_admin_roles.py +++ b/tests/test_admin_roles.py @@ -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"} From 0cf5b3816ff93fc8010d7d5d987f2e4c4a3d36b2 Mon Sep 17 00:00:00 2001 From: liujiahui Date: Thu, 9 Jul 2026 10:03:09 +0800 Subject: [PATCH 11/24] =?UTF-8?q?feat(coin-history):=20=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E6=B5=81=E5=B9=BF=E5=91=8A=E5=A5=96=E5=8A=B1=E6=8C=89=E7=82=B9?= =?UTF-8?q?=E4=BD=8D=E5=9C=BA=E6=99=AF=E6=8B=86=E6=B5=81=E6=B0=B4=E6=96=87?= =?UTF-8?q?=E6=A1=88(=E6=AF=94=E4=BB=B7/=E9=A2=86=E5=88=B8)=20(#124)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 改动 `grant_feed_reward` 按 `feed_scene` 落不同 `biz_type`/`remark`,让收益明细里比价等候期看的广告与领券时看的广告文案分开: - `comparison` → `feed_ad_reward_comparison`(比价奖励) - `coupon` → `feed_ad_reward_coupon`(领券奖励) - 其余(welfare / 空 / 旧端不传)→ 维持通用 `feed_ad_reward`(信息流广告奖励) 客户端按 `biz_type` 直显固定文案(见 android 侧 `CoinHistoryViewModel.coinTitle`),`remark` 仅作后台留痕/兜底。 ## 附:验收脚手架 `scripts/seed_coinhistory_labels_test.py`(dev-only):往测试号 `11111111111` 塞每种 bizType 各一条金币流水(`ref_id` 前缀 `TESTDOC`),一屏核对收益明细全部新文案;`--clean` 按前缀精确清理,不污染真实数据。 ## 联动 客户端配套改动:`shaguabijia-app-android` 同名分支 `7-6ljh`。 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: no_gen_mu Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/124 Co-authored-by: liujiahui Co-committed-by: liujiahui --- app/repositories/ad_feed_reward.py | 13 +- scripts/seed_coinhistory_labels_test.py | 166 ++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 scripts/seed_coinhistory_labels_test.py diff --git a/app/repositories/ad_feed_reward.py b/app/repositories/ad_feed_reward.py index 6a8a29c..ea6ebc9 100644 --- a/app/repositories/ad_feed_reward.py +++ b/app/repositories/ad_feed_reward.py @@ -176,10 +176,19 @@ def grant_feed_reward( ) return _commit_record(db, rec, client_event_id) + # 按点位场景拆流水文案(2026-07):比价等候期看的广告 vs 领券时看的广告,在收益明细里分开显示。 + # feed_scene=comparison→比价奖励 / coupon→领券奖励;其它(welfare/空/旧端不带)维持通用「信息流广告奖励」。 + # 客户端按此 biz_type 直显固定文案(见 CoinHistoryViewModel.coinTitle),故 remark 只作后台留痕/兜底。 + if feed_scene == "comparison": + reward_biz, reward_remark = "feed_ad_reward_comparison", "比价奖励" + elif feed_scene == "coupon": + reward_biz, reward_remark = "feed_ad_reward_coupon", "领券奖励" + else: + reward_biz, reward_remark = "feed_ad_reward", "信息流广告奖励" crud_wallet.grant_coins( db, user_id, coin, - biz_type="feed_ad_reward", ref_id=client_event_id, - remark="信息流广告奖励", + biz_type=reward_biz, ref_id=client_event_id, + remark=reward_remark, ) rec = AdFeedRewardRecord( client_event_id=client_event_id, diff --git a/scripts/seed_coinhistory_labels_test.py b/scripts/seed_coinhistory_labels_test.py new file mode 100644 index 0000000..9de82c4 --- /dev/null +++ b/scripts/seed_coinhistory_labels_test.py @@ -0,0 +1,166 @@ +"""收益明细「金币记录」文案验证脚手架(2026-07 文案改版验收用)。 + +问题:客户端按 bizType 显示固定文案(路线B,强制覆盖后端 remark),但账号若没有对应 +bizType 的流水,收益明细页就空着,无从验证。本脚本往指定测试用户塞每种 bizType 各一条 +流水,让你在手机收益明细页一屏核对全部新文案;验收完 --clean 一键删除,不污染数据。 + +⚠️ 仅 dev 库用(APP_ENV=dev 时才允许 --seed/--clean)。所有测试流水 ref_id 前缀 TESTDOC, +按前缀精确清理,不会误删真实流水。 + +用法(pricebot env 直调,见项目 CLAUDE.md): + D:/miniconda/envs/pricebot/python.exe scripts/seed_coinhistory_labels_test.py --show + D:/miniconda/envs/pricebot/python.exe scripts/seed_coinhistory_labels_test.py --seed + D:/miniconda/envs/pricebot/python.exe scripts/seed_coinhistory_labels_test.py --clean +""" +from __future__ import annotations + +import argparse +import sys + +from app.core.config import settings +from app.core.rewards import CN_TZ +from datetime import datetime + +from app.db.session import SessionLocal +from app.models.user import User +from app.models.wallet import CoinAccount, CoinTransaction + +TEST_PHONE = "11111111111" +REF_PREFIX = "TESTDOC" # 所有本脚本造的流水都带这个 ref_id 前缀,便于精确清理 + +# 每条 = (bizType, 故意写错/留空的 remark, 金币数)。 +# remark 故意填「错的」→ 若客户端仍显示新文案 = 证明路线B强制覆盖生效(无视后端 remark)。 +# task_ 一条留空 remark → 走客户端兜底映射。顺序即手机上从新到旧的展示顺序(后塞的在最上)。 +CASES: list[tuple[str, str, int]] = [ + ("signin", "每日签到 第99天(旧文案,应被覆盖)", 220), + ("signin_boost", "签到膨胀 第99天", 3000), + ("reward_video", "看视频奖励金币(旧文案,应被覆盖)", 200), + ("feed_ad_reward_comparison", "", 50), # 后端新拆:比价场景 → 比价奖励 + ("feed_ad_reward_coupon", "", 50), # 后端新拆:领券场景 → 领券奖励 + ("feed_ad_reward", "信息流广告奖励(welfare/旧数据兜底)", 50), + ("price_report_reward", "上报更低价审核通过(旧文案,应被覆盖)", 1000), + ("feedback_reward", "意见反馈被采纳(旧文案,应被覆盖)", 10000), + ("task_enable_notification", "", 750), # remark 留空 → 客户端「打开消息提醒奖励」 + # 下两条现实中不会进金币记录(invite 发现金进邀请钱包 / compare_milestone 后端死代码不发钱), + # 仅用于验证「杀掉好友比价奖励 + 兜底改任务奖励」:两条都应显示「任务奖励」(remark 被强制无视)。 + ("invite", "好友比价奖励(旧文案,应被杀→任务奖励)", 200), + ("compare_milestone", "", 120), +] + + +def _client_coin_title(biz_type: str, remark: str | None) -> str: + """复刻 CoinHistoryViewModel.coinTitle 的最新逻辑(路线B),用于 --show 预览。 + + ⚠️ 必须与客户端保持一致;客户端改了这里也要同步,否则预览会骗人。 + """ + fixed = { + "exchange_out": "金币兑换现金", + "signin": "每日签到奖励", + "signin_boost": "签到膨胀奖励", + "reward_video": "看视频赚金币", + "ad_reward": "看视频赚金币", + "feed_ad_reward_comparison": "比价奖励", + "feed_ad_reward_coupon": "领券奖励", + "feed_ad_reward": "信息流广告奖励", + "price_report_reward": "爆料奖励", + "feedback_reward": "反馈奖励", + "invite": "任务奖励", # 杀掉"好友比价奖励",归兜底 + } + if biz_type in fixed: + return fixed[biz_type] + if remark: + return remark + if biz_type == "task_enable_notification": + return "打开消息提醒奖励" + return "任务奖励" + + +def _get_user(db) -> User: + u = db.query(User).filter(User.phone == TEST_PHONE).first() + if not u: + print(f"✗ 库里没有测试号 {TEST_PHONE} —— 先在手机上用这个号登录一次再跑本脚本。") + sys.exit(1) + return u + + +def cmd_show() -> None: + """只打印:每种 bizType 经客户端映射后会显示成什么(不写库)。""" + print("bizType 造流水后,收益明细页预期显示的文案:\n") + print(f" {'bizType':32} {'后端remark(故意填的)':32} → 手机显示") + print(" " + "-" * 90) + for biz, remark, _coin in CASES: + shown = _client_coin_title(biz, remark or None) + rk = (remark or "(空)") + print(f" {biz:32} {rk:32} → {shown}") + print("\n注:remark 列是故意填的『旧/错』文案;'手机显示'若为新文案 = 强制覆盖生效。") + print("invite / compare_milestone 已从客户端映射删除:invite 有 remark 故显示原样,") + print("compare_milestone remark 空故落兜底『奖励』—— 两者都不再有专属新文案(符合『去掉』)。") + + +def cmd_seed() -> None: + if settings.APP_ENV != "dev": + print(f"✗ 拒绝:APP_ENV={settings.APP_ENV},本脚本只在 dev 库造测试数据。") + sys.exit(1) + db = SessionLocal() + u = _get_user(db) + acc = db.query(CoinAccount).filter(CoinAccount.user_id == u.id).first() + if acc is None: + acc = CoinAccount(user_id=u.id, coin_balance=0, cash_balance_cents=0) + db.add(acc) + db.flush() + now = datetime.now(CN_TZ).replace(tzinfo=None) + made = 0 + for i, (biz, remark, coin) in enumerate(CASES): + ref = f"{REF_PREFIX}:{biz}:{i}" + exists = db.query(CoinTransaction).filter(CoinTransaction.ref_id == ref).first() + if exists: + continue + acc.coin_balance += coin + db.add(CoinTransaction( + user_id=u.id, amount=coin, balance_after=acc.coin_balance, + biz_type=biz, ref_id=ref, remark=remark or None, created_at=now, + )) + made += 1 + db.commit() + print(f"✓ 已给 user_id={u.id}({TEST_PHONE})造 {made} 条测试流水,当前金币余额 {acc.coin_balance}。") + print(" → 打开手机 App「收益明细 / 金币记录」下拉刷新,逐条核对文案。") + print(" → 验收完跑 --clean 删除这些测试流水。") + + +def cmd_clean() -> None: + if settings.APP_ENV != "dev": + print(f"✗ 拒绝:APP_ENV={settings.APP_ENV}。") + sys.exit(1) + db = SessionLocal() + u = _get_user(db) + rows = db.query(CoinTransaction).filter( + CoinTransaction.user_id == u.id, + CoinTransaction.ref_id.like(f"{REF_PREFIX}:%"), + ).all() + total = sum(r.amount for r in rows) + for r in rows: + db.delete(r) + acc = db.query(CoinAccount).filter(CoinAccount.user_id == u.id).first() + if acc is not None: + acc.coin_balance -= total # 把造流水时加的余额扣回,还原 + db.commit() + print(f"✓ 已删除 {len(rows)} 条 TESTDOC 测试流水,余额回扣 {total},当前 {acc.coin_balance if acc else 0}。") + + +def main() -> None: + ap = argparse.ArgumentParser(description="收益明细金币文案验证脚手架") + g = ap.add_mutually_exclusive_group(required=True) + g.add_argument("--show", action="store_true", help="只打印每种 bizType 的预期显示文案,不写库") + g.add_argument("--seed", action="store_true", help="往测试号造每种 bizType 各一条流水") + g.add_argument("--clean", action="store_true", help="删除本脚本造的所有测试流水") + args = ap.parse_args() + if args.show: + cmd_show() + elif args.seed: + cmd_seed() + elif args.clean: + cmd_clean() + + +if __name__ == "__main__": + main() From fa4127b9e5a344df07ec99084591969d0b1e9b9b Mon Sep 17 00:00:00 2001 From: marco Date: Thu, 9 Jul 2026 14:24:46 +0800 Subject: [PATCH 12/24] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=96=87=E6=A1=A30709?= =?UTF-8?q?=20(#128)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/128 --- docs/api/README.md | 84 ++++---- docs/api/admin/admin-coupon-data.md | 17 ++ docs/api/admin/admin-cps.md | 28 +++ docs/api/admin/admin-device-liveness.md | 15 ++ docs/api/admin/admin-event-logs.md | 15 ++ docs/api/admin/admin-marquee-seeds.md | 17 +- docs/api/admin/admin-price-reports.md | 16 ++ docs/api/admin/admin-roles.md | 19 ++ docs/api/admin/admins/admin-admin-update.md | 17 +- .../admin/feedbacks/admin-feedback-handle.md | 26 ++- docs/api/admin/users/admin-user-cash.md | 7 +- docs/api/admin/users/admin-user-detail.md | 13 +- docs/api/admin/users/admin-user-status.md | 10 +- .../admin/withdraws/admin-withdraw-review.md | 25 +++ docs/api/intent/compare-intent-recognize.md | 10 +- docs/api/intent/compare-price-step.md | 16 +- docs/api/internal/internal.md | 9 +- docs/api/invite/invite-bind.md | 6 +- docs/api/invite/invite-invitees.md | 9 +- docs/api/meituan/meituan-top-sales.md | 9 +- docs/api/other/trace-finalize.md | 20 +- docs/api/user/user-onboarding.md | 21 +- docs/api/wallet/wallet-account.md | 5 +- docs/api/wallet/wallet-withdraw-orders.md | 5 +- docs/api/wallet/wallet-withdraw.md | 3 +- docs/database/OVERVIEW.md | 97 ++++++--- docs/database/README.md | 23 +- docs/database/ad_feed_reward_record.md | 2 + docs/database/admin_role.md | 32 +++ docs/database/admin_user.md | 14 +- docs/database/analytics_event.md | 39 ++++ docs/database/cash_transaction.md | 9 +- docs/database/coin_account.md | 9 +- docs/database/comparison_record.md | 7 +- docs/database/coupon_session.md | 44 ++++ docs/database/coupon_state.md | 1 + docs/database/cps_order.md | 17 +- docs/database/device_liveness.md | 5 +- docs/database/feedback.md | 36 +++- docs/database/invite_cash_transaction.md | 42 ++++ docs/database/invite_relation.md | 30 +-- docs/database/withdraw_order.md | 7 +- docs/后端技术实现.md | 199 +++++++++--------- 43 files changed, 756 insertions(+), 279 deletions(-) create mode 100644 docs/api/admin/admin-coupon-data.md create mode 100644 docs/api/admin/admin-cps.md create mode 100644 docs/api/admin/admin-device-liveness.md create mode 100644 docs/api/admin/admin-event-logs.md create mode 100644 docs/api/admin/admin-price-reports.md create mode 100644 docs/api/admin/admin-roles.md create mode 100644 docs/api/admin/withdraws/admin-withdraw-review.md create mode 100644 docs/database/admin_role.md create mode 100644 docs/database/analytics_event.md create mode 100644 docs/database/coupon_session.md create mode 100644 docs/database/invite_cash_transaction.md diff --git a/docs/api/README.md b/docs/api/README.md index b1d3377..4ade4e6 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -3,7 +3,7 @@ > Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770` > 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case** > 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer ` -> 最后更新: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/` | 无 | 用户头像;返回二进制图片 | | - | `GET /media/feedback/` | 无 | 反馈截图;返回二进制图片 | -| **运营后台 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`)。 diff --git a/docs/api/admin/admin-coupon-data.md b/docs/api/admin/admin-coupon-data.md new file mode 100644 index 0000000..ad443ee --- /dev/null +++ b/docs/api/admin/admin-coupon-data.md @@ -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)。 diff --git a/docs/api/admin/admin-cps.md b/docs/api/admin/admin-cps.md new file mode 100644 index 0000000..d05a1b0 --- /dev/null +++ b/docs/api/admin/admin-cps.md @@ -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` 入库为空导致大盘时间窗漏算。 diff --git a/docs/api/admin/admin-device-liveness.md b/docs/api/admin/admin-device-liveness.md new file mode 100644 index 0000000..2af7bfd --- /dev/null +++ b/docs/api/admin/admin-device-liveness.md @@ -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)见表文档。 diff --git a/docs/api/admin/admin-event-logs.md b/docs/api/admin/admin-event-logs.md new file mode 100644 index 0000000..9dd0c69 --- /dev/null +++ b/docs/api/admin/admin-event-logs.md @@ -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`(事件真实发生时刻),入库时间受客户端攒批影响。 diff --git a/docs/api/admin/admin-marquee-seeds.md b/docs/api/admin/admin-marquee-seeds.md index 8478cb7..2db6d82 100644 --- a/docs/api/admin/admin-marquee-seeds.md +++ b/docs/api/admin/admin-marquee-seeds.md @@ -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`=金额非法。 diff --git a/docs/api/admin/admin-price-reports.md b/docs/api/admin/admin-price-reports.md new file mode 100644 index 0000000..438c76c --- /dev/null +++ b/docs/api/admin/admin-price-reports.md @@ -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` | 拒绝(填原因,用户端可见)+ 带审计 | diff --git a/docs/api/admin/admin-roles.md b/docs/api/admin/admin-roles.md new file mode 100644 index 0000000..0c00cd3 --- /dev/null +++ b/docs/api/admin/admin-roles.md @@ -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` 目录,表里只存勾选结果(目录变更无需迁移)。 diff --git a/docs/api/admin/admins/admin-admin-update.md b/docs/api/admin/admins/admin-admin-update.md index 9b2247d..d6b57f2 100644 --- a/docs/api/admin/admins/admin-admin-update.md +++ b/docs/api/admin/admins/admin-admin-update.md @@ -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 | ✗ | 重置密码,8–72 字(传则覆盖原密码) | +| `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` 长度不在 8–72 ## 说明 -- 更新成功后写一条审计:`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)。 diff --git a/docs/api/admin/feedbacks/admin-feedback-handle.md b/docs/api/admin/feedbacks/admin-feedback-handle.md index c0ea532..4e45880 100644 --- a/docs/api/admin/feedbacks/admin-feedback-handle.md +++ b/docs/api/admin/feedbacks/admin-feedback-handle.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=`、`detail={"before": <原 status>, "after": "handled"}`、`ip=<客户端 IP>`。 +- 写操作记审计 [admin_audit_log](../../../database/admin_audit_log.md):`action="feedback.handle"`、`target_type="feedback"`、`target_id=`、`detail={"before": <原 status>, "after": "handled"}`、`ip=<客户端 IP>`。 - 状态变更与审计写入在同一事务(`commit=False` 后统一 `db.commit()`)。 -- 关联表 [feedback](../database/feedback.md)。 +- 关联表 [feedback](../../../database/feedback.md)。 diff --git a/docs/api/admin/users/admin-user-cash.md b/docs/api/admin/users/admin-user-cash.md index 7cbc25b..7f11e7a 100644 --- a/docs/api/admin/users/admin-user-cash.md +++ b/docs/api/admin/users/admin-user-cash.md @@ -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:`(截断至 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:`(截断至 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)。 diff --git a/docs/api/admin/users/admin-user-detail.md b/docs/api/admin/users/admin-user-detail.md index d0d14ab..9eb46e0 100644 --- a/docs/api/admin/users/admin-user-detail.md +++ b/docs/api/admin/users/admin-user-detail.md @@ -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` | 用户金币发放记录(按时间窗口分页):提现详情底部表,逐笔看发币来源 | diff --git a/docs/api/admin/users/admin-user-status.md b/docs/api/admin/users/admin-user-status.md index 3645c33..4638f99 100644 --- a/docs/api/admin/users/admin-user-status.md +++ b/docs/api/admin/users/admin-user-status.md @@ -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)。 diff --git a/docs/api/admin/withdraws/admin-withdraw-review.md b/docs/api/admin/withdraws/admin-withdraw-review.md new file mode 100644 index 0000000..a547d97 --- /dev/null +++ b/docs/api/admin/withdraws/admin-withdraw-review.md @@ -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 端口径。 diff --git a/docs/api/intent/compare-intent-recognize.md b/docs/api/intent/compare-intent-recognize.md index e1a3d2c..dc7a2b0 100644 --- a/docs/api/intent/compare-intent-recognize.md +++ b/docs/api/intent/compare-intent-recognize.md @@ -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`) diff --git a/docs/api/intent/compare-price-step.md b/docs/api/intent/compare-price-step.md index ce904eb..370aec2 100644 --- a/docs/api/intent/compare-price-step.md +++ b/docs/api/intent/compare-price-step.md @@ -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 步、逐帧多一跳,走公网延迟会累积) diff --git a/docs/api/internal/internal.md b/docs/api/internal/internal.md index a5dd18c..d335d2f 100644 --- a/docs/api/internal/internal.md +++ b/docs/api/internal/internal.md @@ -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` 密钥不匹配 / 缺失。 diff --git a/docs/api/invite/invite-bind.md b/docs/api/invite/invite-bind.md index afe9bcf..cc93ba7 100644 --- a/docs/api/invite/invite-bind.md +++ b/docs/api/invite/invite-bind.md @@ -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": "邀请绑定成功" } ``` diff --git a/docs/api/invite/invite-invitees.md b/docs/api/invite/invite-invitees.md index 7709457..f402f0b 100644 --- a/docs/api/invite/invite-invitees.md +++ b/docs/api/invite/invite-invitees.md @@ -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 | 头像 URL;null = 前端画默认色块 | -| `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" } ], diff --git a/docs/api/meituan/meituan-top-sales.md b/docs/api/meituan/meituan-top-sales.md index 2e7df8e..08a6c65 100644 --- a/docs/api/meituan/meituan-top-sales.md +++ b/docs/api/meituan/meituan-top-sales.md @@ -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 | 1–50 | -| `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 专用)。 diff --git a/docs/api/other/trace-finalize.md b/docs/api/other/trace-finalize.md index 19b7fc1..42607a1 100644 --- a/docs/api/other/trace-finalize.md +++ b/docs/api/other/trace-finalize.md @@ -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`)。 diff --git a/docs/api/user/user-onboarding.md b/docs/api/user/user-onboarding.md index 3985f29..2adaac4 100644 --- a/docs/api/user/user-onboarding.md +++ b/docs/api/user/user-onboarding.md @@ -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` 一律返回未完成 diff --git a/docs/api/wallet/wallet-account.md b/docs/api/wallet/wallet-account.md index fc8c12f..ba523ba 100644 --- a/docs/api/wallet/wallet-account.md +++ b/docs/api/wallet/wallet-account.md @@ -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 | 累计赚取金币 | ## 说明 -账户不存在时自动创建(零余额)。福利页「我的资产」卡的数据源。 +账户不存在时自动创建(零余额)。福利页「我的资产」卡的数据源;邀请页「奖励金」余额也读它。 diff --git a/docs/api/wallet/wallet-withdraw-orders.md b/docs/api/wallet/wallet-withdraw-orders.md index 3c86f7c..9a1c3d5 100644 --- a/docs/api/wallet/wallet-withdraw-orders.md +++ b/docs/api/wallet/wallet-withdraw-orders.md @@ -8,9 +8,10 @@ |---|---|---|---|---| | `limit` | int | ❌ | 20 | 1–100 | | `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 | 发起时间 | diff --git a/docs/api/wallet/wallet-withdraw.md b/docs/api/wallet/wallet-withdraw.md index 6c0d55f..18d57b1 100644 --- a/docs/api/wallet/wallet-withdraw.md +++ b/docs/api/wallet/wallet-withdraw.md @@ -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 | ❌ | **客户端幂等键(商户单号)**:同号重试不重复转账;不传则服务端生成 | diff --git a/docs/database/OVERVIEW.md b/docs/database/OVERVIEW.md index 2c25d00..5f5f525 100644 --- a/docs/database/OVERVIEW.md +++ b/docs/database/OVERVIEW.md @@ -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_`) | 同事务 | | 金币兑现金 `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:`) | + / − | + +- **`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`),并发/重试不会双扣。 --- diff --git a/docs/database/README.md b/docs/database/README.md index bd4c0d4..e173694 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -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) | diff --git a/docs/database/ad_feed_reward_record.md b/docs/database/ad_feed_reward_record.md index f6edbfb..41b721c 100644 --- a/docs/database/ad_feed_reward_record.md +++ b/docs/database/ad_feed_reward_record.md @@ -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 | 整场比价累计观看秒数(轮播各条相加) | diff --git a/docs/database/admin_role.md b/docs/database/admin_role.md new file mode 100644 index 0000000..71b49ee --- /dev/null +++ b/docs/database/admin_role.md @@ -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")` 等旧代码路径仍工作,内建角色名不可改。 diff --git a/docs/database/admin_user.md b/docs/database/admin_user.md index 7e21f68..7690bbe 100644 --- a/docs/database/admin_user.md +++ b/docs/database/admin_user.md @@ -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`。具体守卫见各接口文档。 diff --git a/docs/database/analytics_event.md b/docs/database/analytics_event.md new file mode 100644 index 0000000..8583d58 --- /dev/null +++ b/docs/database/analytics_event.md @@ -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` 只是入库时刻(受攒批影响)。 diff --git a/docs/database/cash_transaction.md b/docs/database/cash_transaction.md index f782184..93566a6 100644 --- a/docs/database/cash_transaction.md +++ b/docs/database/cash_transaction.md @@ -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:`) | - **U / D**:无。账本只追加。 - **R**:`GET /wallet/cash-transactions`(现金明细,`id` 倒序游标);admin 跨用户现金流水。 diff --git a/docs/database/coin_account.md b/docs/database/coin_account.md index 7401abd..a2fd0b7 100644 --- a/docs/database/coin_account.md +++ b/docs/database/coin_account.md @@ -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`),并发/重试不会超额。 diff --git a/docs/database/comparison_record.md b/docs/database/comparison_record.md index 225f29e..5ea3064 100644 --- a/docs/database/comparison_record.md +++ b/docs/database/comparison_record.md @@ -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 帧文案;成功=摘要,失败=具体原因(前端失败时当原因展示) | diff --git a/docs/database/coupon_session.md b/docs/database/coupon_session.md new file mode 100644 index 0000000..dcbc650 --- /dev/null +++ b/docs/database/coupon_session.md @@ -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` 只做时刻留痕,不用来算时长。 diff --git a/docs/database/coupon_state.md b/docs/database/coupon_state.md index c6559b8..baa7105 100644 --- a/docs/database/coupon_state.md +++ b/docs/database/coupon_state.md @@ -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 内存态跑、不落库**)。三表各管一件事: diff --git a/docs/database/cps_order.md b/docs/database/cps_order.md index ad022a2..44c02fe 100644 --- a/docs/database/cps_order.md +++ b/docs/database/cps_order.md @@ -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` 为准。 diff --git a/docs/database/device_liveness.md b/docs/database/device_liveness.md index 277c367..f2ef1ea 100644 --- a/docs/database/device_liveness.md +++ b/docs/database/device_liveness.md @@ -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 分钟误报率高:息屏/省电模式下心跳会正常停发)。 diff --git a/docs/database/feedback.md b/docs/database/feedback.md index d916b30..f867e0c 100644 --- a/docs/database/feedback.md +++ b/docs/database/feedback.md @@ -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` **幂等不校验原状态**(重复调用结果一致)。 diff --git a/docs/database/invite_cash_transaction.md b/docs/database/invite_cash_transaction.md new file mode 100644 index 0000000..0207feb --- /dev/null +++ b/docs/database/invite_cash_transaction.md @@ -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:`) | + +- **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` 列区分。 diff --git a/docs/database/invite_relation.md b/docs/database/invite_relation.md index 590b20f..61b65a2 100644 --- a/docs/database/invite_relation.md +++ b/docs/database/invite_relation.md @@ -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 设计文档已警示)。 diff --git a/docs/database/withdraw_order.md b/docs/database/withdraw_order.md index 9b34897..e06c0e0 100644 --- a/docs/database/withdraw_order.md +++ b/docs/database/withdraw_order.md @@ -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`(用户有生效授权 → 免确认转账,否则确认模式)。 ## 索引与约束 diff --git a/docs/后端技术实现.md b/docs/后端技术实现.md index 6d39c3b..40f90fa 100644 --- a/docs/后端技术实现.md +++ b/docs/后端技术实现.md @@ -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 该目录 | From 37fd51a498a908d4b5a72d4538c6f1077021b6ba Mon Sep 17 00:00:00 2001 From: guke Date: Thu, 9 Jul 2026 17:31:24 +0800 Subject: [PATCH 13/24] =?UTF-8?q?feat(analytics):=20=E5=9F=8B=E7=82=B9/?= =?UTF-8?q?=E4=B8=8A=E6=8A=A5=E5=81=A5=E5=BA=B7=E5=BA=A6=E8=87=AA=E6=8A=A5?= =?UTF-8?q?=E8=AE=A1=E6=95=B0(selfstat)+=20admin=20=E5=81=A5=E5=BA=B7?= =?UTF-8?q?=E5=BA=A6=E8=81=9A=E5=90=88=E7=AB=AF=E7=82=B9=20(#127)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景 / 目标 客户端埋点从「采集」到「上报落库」全链路存在丢失(采集端丢帧、网络丢包、上报失败),现有 analytics_event(行为事件)无法度量这条链路的健康度。本 MR 引入一条与行为埋点完全独立的自报计数(selfstat)链路:客户端周期上报「自 epoch 起算的累计计数」,服务端只 append 存原始快照,admin 查询时在 Python 侧差分聚合出两段成功率(埋点成功率 / 上报成功率),支持总览 / 按天趋势 / 按维度下钻。 改动概览 ① App 侧 — 上报接入(不强制登录) 新增 POST /api/v1/analytics/selfstat:一份快照 = 设备头 + N 条 event 累计计数,单事务落库,返回 snapshot_id。 稳态兜底:落库异常不裸奔 500,logger.exception 后回 503(计数链路要稳,不阻塞端上主流程)。 events 允许空列表(某次只报设备级计数也合法),上限 200 条/次;与行为埋点 min_length=1 有意不同。 ② Admin 侧 — 健康度聚合(只读,需 admin 鉴权) 新增 GET /admin/api/analytics-health/{overview,trend,breakdown}。 差分聚合:原始累计快照按 (device_id, epoch_id, event) 分区、created_at 升序做相邻差分;分区首行增量=累计值,负值(epoch 重置 / 乱序)夹 0;每条增量按其快照 created_at 归入北京天桶。 基线行:取 date_from 左侧每分区最后一条快照,保证区间第一条增量正确(参与差分后丢弃)。 两段率(分母为 0 → null): 埋点成功率 track_success_rate = (attempted − drop_capture) / attempted 上报成功率 report_success_rate = delivered / (delivered + drop_undelivered) breakdown 维度限 event | app_ver | oem(路由正则校验);结果按上报成功率升序(最差在前,None 垫底)。 ③ 数据模型 / 基建 新表 analytics_selfstat(快照头 + 设备维度 app_ver/oem/os + 设备级诊断量 batches_attempted/ok/fail、retries、queue_depth、端 sent_at + 服务端权威 created_at)与 analytics_selfstat_event(四类累计计数,FK → 快照头)。 Alembic 迁移 11c44afbea58(down_revision admin_user_plain_password):建两表 + 索引。 模型登记进 app/models/__init__.py(供 Alembic 发现);路由挂进 app/admin/main.py。 关键设计取舍 只存原始累计、查询时 Python 差分:admin 低频、量级小,跨 PG/SQLite 无方言坑(与 cps.py/coupon_data.py 同款约定)。 选「最新一条」用 max(id) 而非 max(created_at):id 严格单调,规避 SQLite 秒级时间戳撞车的歧义。 tz 口径统一:SQLite 返回 naive UTC、PG 返回 aware UTC,过滤前统一转 naive UTC 再比较。 基线子查询无下界:扫 date_from 左侧全量(spec §7 已接受的取舍;量级变大再上物化 rollup)。 测试(+14,全绿) tests/test_analytics_selfstat.py(3):正常落库 / 空 events / 落库异常回 503。 tests/test_analytics_health.py(11):差分逻辑(首行=累计值、相邻差、epoch 重置换分区、乱序夹 0、同时间戳按 id 定序)、两段率公式、零分母→None、北京天边界、端点(overview 鉴权 / overview / breakdown)。 迁移 / 部署注意 部署需执行 alembic upgrade head(建两张新表)。纯新增表,无回填、无破坏性改动,向后兼容。 客户端需按约定 payload(snake_case、累计语义)对接 /api/v1/analytics/selfstat;admin 前端(独立仓 shaguabijia-admin-web)消费三个 analytics-health 端点,不在本 MR 范围。 --------- Co-authored-by: guke Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/127 --- .../11c44afbea58_analytics_selfstat_tables.py | 72 ++++++++ app/admin/main.py | 2 + app/admin/repositories/analytics_health.py | 146 ++++++++++++++++ app/admin/routers/analytics_health.py | 49 ++++++ app/admin/schemas/analytics_health.py | 21 +++ app/api/v1/analytics.py | 17 +- app/models/__init__.py | 4 + app/models/analytics_selfstat.py | 59 +++++++ app/repositories/analytics_selfstat.py | 38 +++++ app/schemas/analytics_selfstat.py | 34 ++++ tests/test_analytics_health.py | 160 ++++++++++++++++++ tests/test_analytics_selfstat.py | 43 +++++ 12 files changed, 644 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/11c44afbea58_analytics_selfstat_tables.py create mode 100644 app/admin/repositories/analytics_health.py create mode 100644 app/admin/routers/analytics_health.py create mode 100644 app/admin/schemas/analytics_health.py create mode 100644 app/models/analytics_selfstat.py create mode 100644 app/repositories/analytics_selfstat.py create mode 100644 app/schemas/analytics_selfstat.py create mode 100644 tests/test_analytics_health.py create mode 100644 tests/test_analytics_selfstat.py diff --git a/alembic/versions/11c44afbea58_analytics_selfstat_tables.py b/alembic/versions/11c44afbea58_analytics_selfstat_tables.py new file mode 100644 index 0000000..1184a31 --- /dev/null +++ b/alembic/versions/11c44afbea58_analytics_selfstat_tables.py @@ -0,0 +1,72 @@ +"""analytics_selfstat tables + +Revision ID: 11c44afbea58 +Revises: admin_user_plain_password +Create Date: 2026-07-08 16:32:49.351817 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = '11c44afbea58' +down_revision: str | Sequence[str] | None = 'admin_user_plain_password' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + 'analytics_selfstat', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('device_id', sa.String(length=64), nullable=False), + sa.Column('epoch_id', sa.String(length=64), nullable=False), + sa.Column('app_ver', sa.String(length=32), nullable=True), + sa.Column('oem', sa.String(length=32), nullable=True), + sa.Column('os', sa.String(length=32), nullable=True), + sa.Column('batches_attempted', sa.BigInteger(), nullable=False, server_default='0'), + sa.Column('batches_ok', sa.BigInteger(), nullable=False, server_default='0'), + sa.Column('batches_fail', sa.BigInteger(), nullable=False, server_default='0'), + sa.Column('retries', sa.BigInteger(), nullable=False, server_default='0'), + sa.Column('queue_depth', sa.Integer(), nullable=False, server_default='0'), + sa.Column('sent_at', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), + server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id'), + ) + with op.batch_alter_table('analytics_selfstat', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_analytics_selfstat_created_at'), ['created_at'], unique=False) + batch_op.create_index(batch_op.f('ix_analytics_selfstat_device_id'), ['device_id'], unique=False) + batch_op.create_index(batch_op.f('ix_analytics_selfstat_epoch_id'), ['epoch_id'], unique=False) + + op.create_table( + 'analytics_selfstat_event', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('snapshot_id', sa.Integer(), nullable=False), + sa.Column('event', sa.String(length=64), nullable=False), + sa.Column('attempted', sa.BigInteger(), nullable=False, server_default='0'), + sa.Column('drop_capture', sa.BigInteger(), nullable=False, server_default='0'), + sa.Column('delivered', sa.BigInteger(), nullable=False, server_default='0'), + sa.Column('drop_undelivered', sa.BigInteger(), nullable=False, server_default='0'), + sa.ForeignKeyConstraint(['snapshot_id'], ['analytics_selfstat.id'], ), + sa.PrimaryKeyConstraint('id'), + ) + with op.batch_alter_table('analytics_selfstat_event', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_analytics_selfstat_event_event'), ['event'], unique=False) + batch_op.create_index(batch_op.f('ix_analytics_selfstat_event_snapshot_id'), ['snapshot_id'], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table('analytics_selfstat_event', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_analytics_selfstat_event_snapshot_id')) + batch_op.drop_index(batch_op.f('ix_analytics_selfstat_event_event')) + op.drop_table('analytics_selfstat_event') + with op.batch_alter_table('analytics_selfstat', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_analytics_selfstat_epoch_id')) + batch_op.drop_index(batch_op.f('ix_analytics_selfstat_device_id')) + batch_op.drop_index(batch_op.f('ix_analytics_selfstat_created_at')) + op.drop_table('analytics_selfstat') diff --git a/app/admin/main.py b/app/admin/main.py index 89669fc..7345803 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -26,6 +26,7 @@ from app.admin.routers.cps import router as cps_router from app.admin.routers.dashboard import router as dashboard_router from app.admin.routers.device_liveness import router as device_liveness_router from app.admin.routers.ops_stat_config import router as ops_stat_config_router +from app.admin.routers.analytics_health import router as analytics_health_router from app.admin.routers.event_logs import router as event_logs_router from app.admin.routers.feedback import router as feedback_router from app.admin.routers.feedback_qr import router as feedback_qr_router @@ -97,6 +98,7 @@ admin_app.include_router(withdraw_router) admin_app.include_router(price_report_router) admin_app.include_router(feedback_router) admin_app.include_router(event_logs_router) +admin_app.include_router(analytics_health_router) admin_app.include_router(feedback_qr_router) admin_app.include_router(admins_router) admin_app.include_router(roles_router) diff --git a/app/admin/repositories/analytics_health.py b/app/admin/repositories/analytics_health.py new file mode 100644 index 0000000..6098963 --- /dev/null +++ b/app/admin/repositories/analytics_health.py @@ -0,0 +1,146 @@ +"""埋点健康度聚合(埋点成功率 / 上报成功率)。 + +只存原始累计快照,查询时在 Python 侧差分聚合(admin 低频、量级小,跨 PG/SQLite 无方言坑; +与 cps.py / coupon_data.py 同款约定)。差分按 (device_id, epoch_id, event) 分区、created_at +升序,相邻做差、负值夹 0;每增量按其快照 created_at 归入北京天桶。 +""" +from __future__ import annotations + +from collections import defaultdict +from datetime import UTC, datetime + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.core import rewards +from app.models.analytics_selfstat import AnalyticsSelfStat as H +from app.models.analytics_selfstat import AnalyticsSelfStatEvent as E + +_COUNTS = ("attempted", "drop_capture", "delivered", "drop_undelivered") + + +def diff_snapshots(rows: list[dict]) -> list[dict]: + """累计快照行 → 每快照增量行(纯逻辑)。 + + rows 每行含 device_id/epoch_id/event/created_at/app_ver/oem/os + 四个累计计数。 + 返回每行含 dims + created_at + 四个增量 d_*(分区首行增量=累计值;负值夹 0)。 + """ + parts: dict[tuple, list[dict]] = defaultdict(list) + for r in rows: + parts[(r["device_id"], r["epoch_id"], r["event"])].append(r) + + out: list[dict] = [] + for group in parts.values(): + group.sort(key=lambda r: (r["created_at"], r.get("id", 0))) + prev = {k: 0 for k in _COUNTS} + for r in group: + deltas = {f"d_{k}": max(0, int(r[k]) - prev[k]) for k in _COUNTS} + out.append({ + "device_id": r["device_id"], "epoch_id": r["epoch_id"], "event": r["event"], + "created_at": r["created_at"], "app_ver": r["app_ver"], + "oem": r["oem"], "os": r["os"], **deltas, + }) + prev = {k: int(r[k]) for k in _COUNTS} + return out + + +def _cn_day(dt: datetime) -> str: + """created_at(UTC 口径)→ 北京日期字符串 YYYY-MM-DD。naive 当 UTC,tz-aware 直接换算。""" + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + return dt.astimezone(rewards.CN_TZ).date().isoformat() + + +def _rates(sums: dict) -> dict: + """由四个增量和派生两段率(分母 0 → None)。""" + persisted_denom = sums["attempted"] + report_denom = sums["delivered"] + sums["drop_undelivered"] + return { + **sums, + "track_success_rate": ( + (sums["attempted"] - sums["drop_capture"]) / persisted_denom + if persisted_denom else None + ), + "report_success_rate": ( + sums["delivered"] / report_denom if report_denom else None + ), + } + + +def _sum_deltas(deltas: list[dict]) -> dict: + return {k: sum(d[f"d_{k}"] for d in deltas) for k in _COUNTS} + + +def _fetch_rows(db: Session, date_from: datetime, date_to: datetime) -> list[dict]: + """取 [from, to) 区间行 + 每分区在 from 左侧的最后一条基线行(供第一条区间增量做差)。""" + cols = ( + H.id, H.device_id, H.epoch_id, E.event, H.created_at, + H.app_ver, H.oem, H.os, + E.attempted, E.drop_capture, E.delivered, E.drop_undelivered, + ) + in_range = db.execute( + select(*cols).join(E, E.snapshot_id == H.id) + .where(H.created_at >= date_from, H.created_at < date_to) + ).mappings().all() + + # 注:基线子查询无下界扫 from 左侧全量(spec §7 已接受的取舍;量级变大再上物化 rollup)。 + # 用 max(id) 而非 max(created_at) 选"最新一条":id 严格单调,避免 SQLite 秒级时间戳撞车时选歧义。 + sub = ( + select(H.device_id, H.epoch_id, E.event, func.max(H.id).label("max_id")) + .join(E, E.snapshot_id == H.id) + .where(H.created_at < date_from) + .group_by(H.device_id, H.epoch_id, E.event) + .subquery() + ) + baseline = db.execute( + select(*cols).join(E, E.snapshot_id == H.id).join( + sub, sub.c.max_id == H.id + ) + ).mappings().all() + + return [dict(r) for r in list(baseline) + list(in_range)] + + +def _in_range_deltas(db: Session, date_from: datetime, date_to: datetime) -> list[dict]: + """差分后只保留 created_at ∈ [from, to) 的增量(基线行被差分用后丢弃)。 + + Python 侧过滤需对齐 tz 口径:SQLite 返回 naive UTC,PG 返回 aware UTC。 + 统一转成 naive UTC 再比较,兼容两种后端。 + """ + def _to_naive_utc(dt: datetime) -> datetime: + if dt.tzinfo is not None: + return dt.astimezone(UTC).replace(tzinfo=None) + return dt + + from_naive = _to_naive_utc(date_from) + to_naive = _to_naive_utc(date_to) + deltas = diff_snapshots(_fetch_rows(db, date_from, date_to)) + return [d for d in deltas if from_naive <= _to_naive_utc(d["created_at"]) < to_naive] + + +def overview(db: Session, date_from: datetime, date_to: datetime) -> dict: + deltas = _in_range_deltas(db, date_from, date_to) + return _rates(_sum_deltas(deltas)) + + +def trend(db: Session, date_from: datetime, date_to: datetime) -> list[dict]: + deltas = _in_range_deltas(db, date_from, date_to) + by_day: dict[str, list[dict]] = defaultdict(list) + for d in deltas: + by_day[_cn_day(d["created_at"])].append(d) + return [ + {"day": day, **_rates(_sum_deltas(items))} + for day, items in sorted(by_day.items()) + ] + + +def breakdown(db: Session, date_from: datetime, date_to: datetime, dim: str) -> list[dict]: + if dim not in ("event", "app_ver", "oem"): + raise ValueError(f"invalid dim: {dim!r}") + deltas = _in_range_deltas(db, date_from, date_to) + by_key: dict[str, list[dict]] = defaultdict(list) + for d in deltas: + by_key[d.get(dim) or "(unknown)"].append(d) + rows = [{"key": key, **_rates(_sum_deltas(items))} for key, items in by_key.items()] + rows.sort(key=lambda r: (r["report_success_rate"] is None, r["report_success_rate"] or 0.0)) + return rows diff --git a/app/admin/routers/analytics_health.py b/app/admin/routers/analytics_health.py new file mode 100644 index 0000000..517feb3 --- /dev/null +++ b/app/admin/routers/analytics_health.py @@ -0,0 +1,49 @@ +"""admin 埋点健康度:埋点成功率 / 上报成功率 总览 + 趋势 + 下钻(只读)。""" +from __future__ import annotations + +from datetime import datetime +from typing import Annotated + +from fastapi import APIRouter, Depends, Query + +from app.admin.deps import AdminDb, get_current_admin +from app.admin.repositories import analytics_health as repo +from app.admin.schemas.analytics_health import ( + HealthBreakdownRow, + HealthMetrics, + HealthTrendPoint, +) + +router = APIRouter( + prefix="/admin/api/analytics-health", + tags=["admin-analytics-health"], + dependencies=[Depends(get_current_admin)], +) + + +@router.get("/overview", response_model=HealthMetrics, summary="两段成功率总览") +def overview( + db: AdminDb, + date_from: Annotated[datetime, Query()], + date_to: Annotated[datetime, Query()], +) -> HealthMetrics: + return HealthMetrics(**repo.overview(db, date_from, date_to)) + + +@router.get("/trend", response_model=list[HealthTrendPoint], summary="按北京天趋势") +def trend( + db: AdminDb, + date_from: Annotated[datetime, Query()], + date_to: Annotated[datetime, Query()], +) -> list[HealthTrendPoint]: + return [HealthTrendPoint(**p) for p in repo.trend(db, date_from, date_to)] + + +@router.get("/breakdown", response_model=list[HealthBreakdownRow], summary="按维度下钻") +def breakdown( + db: AdminDb, + date_from: Annotated[datetime, Query()], + date_to: Annotated[datetime, Query()], + dim: Annotated[str, Query(pattern="^(event|app_ver|oem)$")] = "event", +) -> list[HealthBreakdownRow]: + return [HealthBreakdownRow(**r) for r in repo.breakdown(db, date_from, date_to, dim)] diff --git a/app/admin/schemas/analytics_health.py b/app/admin/schemas/analytics_health.py new file mode 100644 index 0000000..4d3328d --- /dev/null +++ b/app/admin/schemas/analytics_health.py @@ -0,0 +1,21 @@ +"""埋点健康度 admin 响应 schema。""" +from __future__ import annotations + +from pydantic import BaseModel + + +class HealthMetrics(BaseModel): + attempted: int + drop_capture: int + delivered: int + drop_undelivered: int + track_success_rate: float | None + report_success_rate: float | None + + +class HealthTrendPoint(HealthMetrics): + day: str + + +class HealthBreakdownRow(HealthMetrics): + key: str diff --git a/app/api/v1/analytics.py b/app/api/v1/analytics.py index 265954d..23d2f82 100644 --- a/app/api/v1/analytics.py +++ b/app/api/v1/analytics.py @@ -6,13 +6,18 @@ POST /api/v1/analytics/events — 批量接收新手引导(及后续)埋点,appe """ from __future__ import annotations -from fastapi import APIRouter, Request +import logging + +from fastapi import APIRouter, HTTPException, Request from app.api.deps import DbSession from app.repositories import analytics as analytics_repo +from app.repositories import analytics_selfstat as selfstat_repo from app.schemas.analytics import AnalyticsBatchIn, AnalyticsIngestOut +from app.schemas.analytics_selfstat import SelfStatBatchIn, SelfStatIngestOut router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"]) +logger = logging.getLogger("shagua.analytics") def _client_ip(request: Request) -> str: @@ -29,3 +34,13 @@ def ingest_events( ) -> AnalyticsIngestOut: n = analytics_repo.record_batch(db, batch, client_ip=_client_ip(request)) return AnalyticsIngestOut(received=n) + + +@router.post("/selfstat", response_model=SelfStatIngestOut, summary="上报自报计数快照") +def ingest_selfstat(batch: SelfStatBatchIn, db: DbSession) -> SelfStatIngestOut: + try: + snap_id = selfstat_repo.record_selfstat(db, batch) + except Exception: # noqa: BLE001 — 计数链路要稳,落库失败不裸奔 500,记日志回明确错误 + logger.exception("selfstat ingest failed device=%s epoch=%s", batch.device_id, batch.epoch_id) + raise HTTPException(status_code=503, detail="selfstat ingest failed") from None + return SelfStatIngestOut(snapshot_id=snap_id) diff --git a/app/models/__init__.py b/app/models/__init__.py index 1f9a0d8..694aa00 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -7,6 +7,10 @@ from app.models.ad_watch_log import AdWatchLog # noqa: F401 from app.models.admin import AdminAuditLog, AdminUser # noqa: F401 from app.models.admin_role import AdminRole # noqa: F401 from app.models.analytics_event import AnalyticsEvent # noqa: F401 +from app.models.analytics_selfstat import ( # noqa: F401 + AnalyticsSelfStat, + AnalyticsSelfStatEvent, +) from app.models.app_config import AppConfig # noqa: F401 from app.models.comparison import ComparisonRecord # noqa: F401 from app.models.cps_activity import CpsActivity # noqa: F401 diff --git a/app/models/analytics_selfstat.py b/app/models/analytics_selfstat.py new file mode 100644 index 0000000..03bb79c --- /dev/null +++ b/app/models/analytics_selfstat.py @@ -0,0 +1,59 @@ +"""埋点/上报成功率自报计数快照表(append-only)。 + +客户端周期上报「自 epoch 起算的累计计数」;服务端只存原始快照,查询时在 Python 侧差分聚合 +(见 app/admin/repositories/analytics_health.py)。与既有 analytics_event 表完全独立。 + +- analytics_selfstat :一快照一行(快照头 + 设备维度 + 设备级诊断量) +- analytics_selfstat_event :一 event 一行(四类累计计数),外键指向快照头 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class AnalyticsSelfStat(Base): + __tablename__ = "analytics_selfstat" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + device_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False) + epoch_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False) + # 设备维度(每设备固定,下钻用) + app_ver: Mapped[str | None] = mapped_column(String(32), nullable=True) + oem: Mapped[str | None] = mapped_column(String(32), nullable=True) + os: Mapped[str | None] = mapped_column(String(32), nullable=True) + # 设备级诊断量(累计;queue_depth 是瞬时 gauge) + batches_attempted: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + batches_ok: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + batches_fail: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + retries: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + queue_depth: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + sent_at: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 端上报时刻 epoch ms + # 服务端接收时间(权威,用于时间分桶与分区排序) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" + + +class AnalyticsSelfStatEvent(Base): + __tablename__ = "analytics_selfstat_event" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + snapshot_id: Mapped[int] = mapped_column( + ForeignKey("analytics_selfstat.id"), index=True, nullable=False + ) + event: Mapped[str] = mapped_column(String(64), index=True, nullable=False) + attempted: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + drop_capture: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + delivered: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + drop_undelivered: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/repositories/analytics_selfstat.py b/app/repositories/analytics_selfstat.py new file mode 100644 index 0000000..ee2a1e4 --- /dev/null +++ b/app/repositories/analytics_selfstat.py @@ -0,0 +1,38 @@ +"""自报计数快照落库。一次事务:插 1 条快照头 + N 条 event 行,返回快照 id。""" +from __future__ import annotations + +from sqlalchemy.orm import Session + +from app.models.analytics_selfstat import AnalyticsSelfStat, AnalyticsSelfStatEvent +from app.schemas.analytics_selfstat import SelfStatBatchIn + + +def record_selfstat(db: Session, batch: SelfStatBatchIn) -> int: + snap = AnalyticsSelfStat( + device_id=batch.device_id, + epoch_id=batch.epoch_id, + app_ver=batch.app_ver, + oem=batch.oem, + os=batch.os, + batches_attempted=batch.batches_attempted, + batches_ok=batch.batches_ok, + batches_fail=batch.batches_fail, + retries=batch.retries, + queue_depth=batch.queue_depth, + sent_at=batch.sent_at, + ) + db.add(snap) + db.flush() # 拿到 snap.id + db.add_all([ + AnalyticsSelfStatEvent( + snapshot_id=snap.id, + event=e.event, + attempted=e.attempted, + drop_capture=e.drop_capture, + delivered=e.delivered, + drop_undelivered=e.drop_undelivered, + ) + for e in batch.events + ]) + db.commit() + return snap.id diff --git a/app/schemas/analytics_selfstat.py b/app/schemas/analytics_selfstat.py new file mode 100644 index 0000000..6edf545 --- /dev/null +++ b/app/schemas/analytics_selfstat.py @@ -0,0 +1,34 @@ +"""自报计数上报 schema。字段名对齐客户端 payload(snake_case),累计值语义。""" +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class SelfStatEventIn(BaseModel): + event: str = Field(max_length=64) + attempted: int = 0 + drop_capture: int = 0 + delivered: int = 0 + drop_undelivered: int = 0 + + +class SelfStatBatchIn(BaseModel): + device_id: str = Field(max_length=64) + epoch_id: str = Field(max_length=64) + sent_at: int | None = None + app_ver: str | None = Field(default=None, max_length=32) + oem: str | None = Field(default=None, max_length=32) + os: str | None = Field(default=None, max_length=32) + batches_attempted: int = 0 + batches_ok: int = 0 + batches_fail: int = 0 + retries: int = 0 + queue_depth: int = 0 + # 允许空列表:某次快照只上报设备级计数(batches_*/retries/queue_depth)、无 event 细分时也合法 + # (与 AnalyticsBatchIn 的 min_length=1 有意不同——那是行为事件、必须至少一条)。 + events: list[SelfStatEventIn] = Field(default_factory=list, max_length=200) + + +class SelfStatIngestOut(BaseModel): + ok: bool = True + snapshot_id: int diff --git a/tests/test_analytics_health.py b/tests/test_analytics_health.py new file mode 100644 index 0000000..39fe093 --- /dev/null +++ b/tests/test_analytics_health.py @@ -0,0 +1,160 @@ +"""埋点健康度聚合:纯差分函数 + admin 端点。""" +from __future__ import annotations + +from datetime import datetime, timezone + +from app.admin.repositories.analytics_health import _cn_day, _rates, diff_snapshots + + +def _row(device, epoch, event, ts, **cum) -> dict: + base = {"attempted": 0, "drop_capture": 0, "delivered": 0, "drop_undelivered": 0} + base.update(cum) + return { + "device_id": device, "epoch_id": epoch, "event": event, + "created_at": datetime(2026, 7, 1, ts, 0, tzinfo=timezone.utc), + "app_ver": "0.2.12(62)", "oem": "ColorOS", "os": "Android 14", + **base, + } + + +def test_diff_first_row_is_full_cumulative() -> None: + rows = [_row("d", "e", "video_play", 1, attempted=100, delivered=90)] + out = diff_snapshots(rows) + assert len(out) == 1 + assert out[0]["d_attempted"] == 100 + assert out[0]["d_delivered"] == 90 + + +def test_diff_consecutive_delta() -> None: + rows = [ + _row("d", "e", "video_play", 1, attempted=100, delivered=90), + _row("d", "e", "video_play", 2, attempted=150, delivered=140), + ] + out = sorted(diff_snapshots(rows), key=lambda r: r["created_at"]) + assert out[1]["d_attempted"] == 50 + assert out[1]["d_delivered"] == 50 + + +def test_diff_epoch_reset_new_partition() -> None: + rows = [ + _row("d", "e1", "video_play", 1, attempted=100), + _row("d", "e2", "video_play", 2, attempted=5), + ] + out = {(r["epoch_id"]): r["d_attempted"] for r in diff_snapshots(rows)} + assert out["e1"] == 100 + assert out["e2"] == 5 + + +def test_diff_clamps_negative_on_reorder() -> None: + rows = [ + _row("d", "e", "video_play", 1, attempted=100), + _row("d", "e", "video_play", 2, attempted=80), + ] + out = sorted(diff_snapshots(rows), key=lambda r: r["created_at"]) + assert out[1]["d_attempted"] == 0 + + +def test_rates_computes_both_formulas() -> None: + out = _rates({"attempted": 150, "drop_capture": 0, "delivered": 140, "drop_undelivered": 10}) + assert out["track_success_rate"] == 1.0 + assert out["report_success_rate"] == 140 / 150 + + +def test_rates_zero_denominator_yields_none() -> None: + out = _rates({"attempted": 0, "drop_capture": 0, "delivered": 0, "drop_undelivered": 0}) + assert out["track_success_rate"] is None + assert out["report_success_rate"] is None + + +def test_cn_day_beijing_boundary() -> None: + # UTC 15:59 → 北京 23:59 同日;UTC 16:00 → 北京 次日 00:00 + assert _cn_day(datetime(2026, 7, 1, 15, 59, tzinfo=timezone.utc)) == "2026-07-01" + assert _cn_day(datetime(2026, 7, 1, 16, 0, tzinfo=timezone.utc)) == "2026-07-02" + + +def test_diff_same_timestamp_ordered_by_id() -> None: + ts = datetime(2026, 7, 1, 1, 0, tzinfo=timezone.utc) + # 相同 created_at,乱序传入(id=2 在前);应按 id 排序 → id=1(cum100) 在前 + rows = [ + {"id": 2, "device_id": "d", "epoch_id": "e", "event": "vp", "created_at": ts, + "app_ver": "v", "oem": "o", "os": "s", + "attempted": 150, "drop_capture": 0, "delivered": 0, "drop_undelivered": 0}, + {"id": 1, "device_id": "d", "epoch_id": "e", "event": "vp", "created_at": ts, + "app_ver": "v", "oem": "o", "os": "s", + "attempted": 100, "drop_capture": 0, "delivered": 0, "drop_undelivered": 0}, + ] + out = diff_snapshots(rows) + assert out[0]["d_attempted"] == 100 # id=1 sorts first → its cumulative + assert out[1]["d_attempted"] == 50 # id=2 second → 150-100 + + +import pytest +from fastapi.testclient import TestClient + +from app.admin.main import admin_app +from app.admin.repositories import admin_user as admin_repo +from app.db.session import SessionLocal + + +@pytest.fixture() +def admin_client() -> TestClient: + return TestClient(admin_app) + + +@pytest.fixture() +def admin_token() -> str: + db = SessionLocal() + try: + if admin_repo.get_by_username(db, "health_admin") is None: + admin_repo.create_admin(db, username="health_admin", password="pw", role="super_admin") + finally: + db.close() + c = TestClient(admin_app) + r = c.post("/admin/api/auth/login", json={"username": "health_admin", "password": "pw"}) + return r.json()["access_token"] + + +def _auth(t: str) -> dict: + return {"Authorization": f"Bearer {t}"} + + +def _seed_two_snapshots(client: TestClient) -> None: + for attempted, delivered in ((100, 90), (150, 140)): + client.post("/api/v1/analytics/selfstat", json={ + "device_id": "d-health", "epoch_id": "e-health", + "app_ver": "0.2.12(62)", "oem": "ColorOS", "os": "Android 14", + "events": [{"event": "video_play", "attempted": attempted, + "drop_capture": 0, "delivered": delivered, "drop_undelivered": 0}], + }) + + +def test_health_overview_requires_auth(admin_client: TestClient) -> None: + r = admin_client.get("/admin/api/analytics-health/overview", + params={"date_from": "2026-07-01T00:00:00Z", "date_to": "2030-01-01T00:00:00Z"}) + assert r.status_code == 401 + + +def test_health_overview(client: TestClient, admin_client: TestClient, admin_token: str) -> None: + _seed_two_snapshots(client) # 播种走公开 app(ingest 端点在 app,不在 admin_app) + r = admin_client.get( + "/admin/api/analytics-health/overview", + params={"date_from": "2026-07-01T00:00:00Z", "date_to": "2030-01-01T00:00:00Z"}, + headers=_auth(admin_token), + ) + assert r.status_code == 200, r.text + data = r.json() + # 两快照累计 100→150,差分后 attempted 总 150(首快照 100 + 增量 50) + assert data["attempted"] == 150 + assert data["track_success_rate"] == 1.0 + + +def test_health_breakdown(client: TestClient, admin_client: TestClient, admin_token: str) -> None: + _seed_two_snapshots(client) # 播种走公开 app + r = admin_client.get( + "/admin/api/analytics-health/breakdown", + params={"date_from": "2026-07-01T00:00:00Z", "date_to": "2030-01-01T00:00:00Z", "dim": "event"}, + headers=_auth(admin_token), + ) + assert r.status_code == 200, r.text + rows = r.json() + assert any(row["key"] == "video_play" for row in rows) diff --git a/tests/test_analytics_selfstat.py b/tests/test_analytics_selfstat.py new file mode 100644 index 0000000..a096806 --- /dev/null +++ b/tests/test_analytics_selfstat.py @@ -0,0 +1,43 @@ +"""自报计数上报端点测试。""" +from __future__ import annotations + +from fastapi.testclient import TestClient + + +def _payload(**over) -> dict: + base = { + "device_id": "dev-1", "epoch_id": "ep-1", "sent_at": 1700000000000, + "app_ver": "0.2.12(62)", "oem": "ColorOS", "os": "Android 14", + "batches_attempted": 10, "batches_ok": 9, "batches_fail": 1, + "retries": 1, "queue_depth": 2, + "events": [ + {"event": "video_play", "attempted": 100, "drop_capture": 1, + "delivered": 95, "drop_undelivered": 2}, + ], + } + base.update(over) + return base + + +def test_selfstat_ingest_ok(client: TestClient) -> None: + r = client.post("/api/v1/analytics/selfstat", json=_payload()) + assert r.status_code == 200, r.text + body = r.json() + assert body["ok"] is True + assert isinstance(body["snapshot_id"], int) + + +def test_selfstat_ingest_empty_events(client: TestClient) -> None: + r = client.post("/api/v1/analytics/selfstat", json=_payload(events=[])) + assert r.status_code == 200, r.text + assert r.json()["ok"] is True + + +def test_selfstat_ingest_db_error_returns_503(client: TestClient, monkeypatch) -> None: + """落库异常时端点回 503(计数链路稳定性守卫),不裸奔 500。""" + def _boom(*_args, **_kwargs): + raise RuntimeError("db gone") + + monkeypatch.setattr("app.repositories.analytics_selfstat.record_selfstat", _boom) + r = client.post("/api/v1/analytics/selfstat", json=_payload()) + assert r.status_code == 503, r.text From 2ddea4159dc54196a8ef9d5070ce2a1cfd3627e1 Mon Sep 17 00:00:00 2001 From: guke Date: Thu, 9 Jul 2026 17:31:48 +0800 Subject: [PATCH 14/24] =?UTF-8?q?feat(coupon-data):=20=E9=A2=86=E5=88=B8?= =?UTF-8?q?=E6=88=90=E5=8A=9F=E7=8E=87=E7=9C=8B=E6=9D=BF(=E6=95=B4?= =?UTF-8?q?=E5=8D=95/=E7=82=B9=E4=BD=8D/=E5=88=86=E5=B9=B3=E5=8F=B0=20+=20?= =?UTF-8?q?=E6=8C=89=E5=88=B8)=20(#130)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台「领券数据」看板此前只有发起/完成数与耗时分位,缺少成功率视角。本 MR 补齐三档平台粒度成功率与一张按券(coupon_id)成功率表,并为看板增加领券状态多选过滤。 服务端埋点 领券每帧按 trace_id 把「成功平台」并集幂等写入 platform_success(merge_session_platform_success;读不到 session 行则静默跳过,不建兜底行;无新平台不写库)。 record_claims 按 session_app_env(trace_id) 反查并打 app_env 标。 新增平台推导:coupon_id_to_platform(前缀 mt_→美团 / tb_·ele_·elm_→淘宝 / jd_→京东,与客户端 couponIdToPlatform 同词表)、succeeded_platforms。成功语义统一为 success + already_claimed。 复用同一 SessionLocal、紧接 record_claims,不新增连接;fire-and-forget,异常已吞。 后台指标与接口 Summary 新增:②整单成功率(勾选平台全领到的 session 占发起数)、③点位成功率(Σ成功平台 / Σ勾选平台)、分平台点位成功率(恒含美团/淘宝/京东三档)。基数与「发起数」一致,含全部 session。 新端点 GET /coupon-data/coupons(coupon_slot_report):按券成功率表,数据源 coupon_claim_record,设备-天粒度,成功率 = 成功 /(成功+失败),skipped 排除,按尝试数倒序。 主表加 status 多选过滤(started/completed/failed/abandoned),汇总/成功率/趋势/明细整体按选中状态算,与 app_env 同级。 兼容性 / 风险 两个新列均可空、旧行按空集/不回填处理,无数据回填;埋点 fire-and-forget,失败不影响领券主流程。 部署务必 alembic upgrade head(因新增了 head 合并迁移)。 --------- Co-authored-by: guke Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/130 --- alembic/versions/coupon_claim_app_env.py | 32 ++ .../coupon_session_platform_success.py | 37 +++ .../merge_pages_override_coupon_slot.py | 28 ++ app/admin/repositories/coupon_data.py | 104 +++++- app/admin/routers/coupon_data.py | 37 ++- app/admin/schemas/coupon_data.py | 29 ++ app/api/v1/coupon.py | 11 +- app/models/coupon_state.py | 7 + app/repositories/coupon_state.py | 84 ++++- docs/database/coupon_state.md | 52 ++- docs/guides/领券成功率指标-设计与埋点.md | 294 +++++++++++++++++ tests/test_coupon_platform_success.py | 299 ++++++++++++++++++ tests/test_coupon_slots.py | 137 ++++++++ 13 files changed, 1141 insertions(+), 10 deletions(-) create mode 100644 alembic/versions/coupon_claim_app_env.py create mode 100644 alembic/versions/coupon_session_platform_success.py create mode 100644 alembic/versions/merge_pages_override_coupon_slot.py create mode 100644 docs/guides/领券成功率指标-设计与埋点.md create mode 100644 tests/test_coupon_platform_success.py create mode 100644 tests/test_coupon_slots.py diff --git a/alembic/versions/coupon_claim_app_env.py b/alembic/versions/coupon_claim_app_env.py new file mode 100644 index 0000000..1b220c1 --- /dev/null +++ b/alembic/versions/coupon_claim_app_env.py @@ -0,0 +1,32 @@ +"""coupon_claim_record 加 app_env 列(领券所属 session 环境;每券成功率表按它过滤 prod/dev) + +Revision ID: coupon_claim_app_env +Revises: coupon_session_platform_success +Create Date: 2026-07-08 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "coupon_claim_app_env" +down_revision: str | Sequence[str] | None = "coupon_session_platform_success" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.batch_alter_table("coupon_claim_record", schema=None) as batch_op: + batch_op.add_column(sa.Column("app_env", sa.String(length=16), nullable=True)) + batch_op.create_index( + "ix_coupon_claim_record_app_env", ["app_env"], unique=False + ) + + +def downgrade() -> None: + with op.batch_alter_table("coupon_claim_record", schema=None) as batch_op: + batch_op.drop_index("ix_coupon_claim_record_app_env") + batch_op.drop_column("app_env") diff --git a/alembic/versions/coupon_session_platform_success.py b/alembic/versions/coupon_session_platform_success.py new file mode 100644 index 0000000..af14669 --- /dev/null +++ b/alembic/versions/coupon_session_platform_success.py @@ -0,0 +1,37 @@ +"""coupon_session 加 platform_success 列(本次至少领到一张的平台 id 列表) + +供 admin「领券数据」算 ②整单成功率 / ③点位成功率(平台粒度)。数据落点:服务端 /step 逐帧 +按 trace_id 并集写入(见 app/repositories/coupon_state.merge_session_platform_success)。旧行 NULL +视作空集,已建表环境靠它补列、全新环境顺序应用不重复加列。设计:docs/guides/领券成功率指标-设计与埋点.md。 + +Revision ID: coupon_session_platform_success +Revises: admin_user_plain_password +Create Date: 2026-07-07 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "coupon_session_platform_success" +down_revision: str | Sequence[str] | None = "admin_user_plain_password" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +# PG 用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 model 的 _JSON variant / 建表迁移)。 +_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql") + + +def upgrade() -> None: + with op.batch_alter_table("coupon_session", schema=None) as batch_op: + batch_op.add_column(sa.Column("platform_success", _JSON, nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("coupon_session", schema=None) as batch_op: + batch_op.drop_column("platform_success") diff --git a/alembic/versions/merge_pages_override_coupon_slot.py b/alembic/versions/merge_pages_override_coupon_slot.py new file mode 100644 index 0000000..1fb6fc4 --- /dev/null +++ b/alembic/versions/merge_pages_override_coupon_slot.py @@ -0,0 +1,28 @@ +"""合并两个 alembic head:admin_user_pages_override(#126 权限)+ coupon_claim_app_env(领券成功率)。 + +两条迁移都从 admin_user_plain_password 分叉——#126 经 pull main 进入本分支,领券成功率为本分支新增—— +于是出现两个 head。本迁移仅把二者收敛成单 head,让 `alembic upgrade head`(单数,部署/run.sh 用) +恢复正常;**不含任何表结构 / 数据改动**(纯 merge)。 + +Revision ID: merge_pages_override_coupon_slot +Revises: admin_user_pages_override, coupon_claim_app_env +Create Date: 2026-07-09 00:00:00.000000 +""" + +from collections.abc import Sequence + +revision: str = "merge_pages_override_coupon_slot" +down_revision: str | Sequence[str] | None = ( + "admin_user_pages_override", + "coupon_claim_app_env", +) +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """纯合并 head,无 schema 改动。""" + + +def downgrade() -> None: + """拆回两个 head,无 schema 改动。""" diff --git a/app/admin/repositories/coupon_data.py b/app/admin/repositories/coupon_data.py index 05b8fbd..a290df1 100644 --- a/app/admin/repositories/coupon_data.py +++ b/app/admin/repositories/coupon_data.py @@ -5,17 +5,20 @@ - 发起数 = 区间内全部 session(含 started/completed/failed/abandoned),= 流失统计的基数。 - 完成数 / 耗时均值 / 分位 = 仅 status==completed 子集(成功跑完才有可比的"领券耗时")。 - summary/daily/hourly/total 在全量上算,不受分页;items 为排序后当前页。 +- 另含 coupon_slot_report(数据源 coupon_claim_record):按 coupon_id「按券成功率」表,见设计 §13。 """ from __future__ import annotations -from datetime import UTC, date as _date, datetime +from datetime import UTC, datetime +from datetime import date as _date -from sqlalchemy import func, or_, select +from sqlalchemy import case, func, or_, select from sqlalchemy.orm import Session from app.core import rewards -from app.models.coupon_state import CouponSession +from app.models.coupon_state import CouponClaimRecord, CouponSession from app.models.user import User +from app.repositories.coupon_state import DEFAULT_PLATFORMS, coupon_id_to_platform def _cn_hour(dt: datetime) -> int: @@ -42,6 +45,46 @@ def _avg(vals: list[int]) -> int | None: return round(sum(vals) / len(vals)) if vals else None +def _success_rates(rows: list) -> dict: + """平台粒度成功率(见 docs/guides/领券成功率指标-设计与埋点.md §3/§12): + + - sel(s) = 勾选平台(`platforms` 空 → 全领三档 DEFAULT_PLATFORMS); + - succ(s) = `platform_success` ∩ sel(至少领到一张的平台); + - ② 整单成功率 = #{sel⊆succ 且 sel≠∅} / 发起数; + - ③ 点位成功率 = Σ|succ| / Σ|sel|;per_platform[p] = 勾了 p 且成功 / 勾了 p。 + 基数含全部 session(started/completed/failed/abandoned),与「发起数」同基数。 + """ + started = len(rows) + full_success = 0 + point_success = 0 + point_total = 0 + per_succ = {p: 0 for p in DEFAULT_PLATFORMS} + per_total = {p: 0 for p in DEFAULT_PLATFORMS} + for r in rows: + sel = set(r.platforms) if r.platforms else set(DEFAULT_PLATFORMS) + succ = set(r.platform_success or []) & sel + point_success += len(succ) + point_total += len(sel) + if sel and succ == sel: + full_success += 1 + for p in sel: + if p in per_total: # 只统计三档已知平台;未知/非法平台 id 不进 per_platform + per_total[p] += 1 + if p in succ: + per_succ[p] += 1 + return { + "full_success_count": full_success, + "full_success_rate": round(full_success / started, 4) if started else None, + "point_success_count": point_success, + "point_total_count": point_total, + "point_success_rate": round(point_success / point_total, 4) if point_total else None, + "per_platform": { + p: (round(per_succ[p] / per_total[p], 4) if per_total[p] else None) + for p in DEFAULT_PLATFORMS + }, + } + + def _session_to_row(r, phone: str | None = None, nickname: str | None = None) -> dict: """CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。""" return { @@ -84,6 +127,7 @@ def coupon_data_report( date_to: str, user: str | None = None, app_env: str | None = None, + statuses: list[str] | None = None, granularity: str = "day", limit: int = 500, offset: int = 0, @@ -93,6 +137,8 @@ def coupon_data_report( - user:手机号/昵称模糊搜(匹配不到任何用户 → 空结果)。 - app_env:prod/dev 精确;None=全部。 + - statuses:领券状态多选(started/completed/failed/abandoned);None/空=全部。整个视图 + (汇总/成功率/趋势/明细)按选中状态算,与 app_env 同级过滤(方案 A)。 - sort:time=发起时刻倒序(默认) / elapsed=全程耗时倒序(None 末尾)。 """ by_hour = granularity == "hour" @@ -115,6 +161,8 @@ def coupon_data_report( ) if app_env is not None: stmt = stmt.where(CouponSession.app_env == app_env) + if statuses: + stmt = stmt.where(CouponSession.status.in_(statuses)) if user_ids is not None: stmt = stmt.where(CouponSession.user_id.in_(user_ids)) rows = list(db.execute(stmt).scalars()) @@ -131,6 +179,7 @@ def coupon_data_report( "p50_ms": _percentile(completed_elapsed, 50), "p95_ms": _percentile(completed_elapsed, 95), "p99_ms": _percentile(completed_elapsed, 99), + **_success_rates(rows), } # ── 按天趋势(柱=发起/完成数,线=平均耗时)── @@ -223,3 +272,52 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict: select(func.count()).select_from(CouponSession).where(CouponSession.user_id == user_id) ).scalar_one() return {"items": [_session_to_row(r) for r in rows], "total": int(total)} + + +_SLOT_OK = ("success", "already_claimed") +_SLOT_TRIED = ("success", "already_claimed", "failed") + + +def coupon_slot_report( + db: Session, *, date_from: str, date_to: str, app_env: str | None = None +) -> dict: + """按 coupon_id(具体券)聚合成功率(见 docs/guides/领券成功率指标-设计与埋点.md §13)。 + + 数据源 coupon_claim_record(粒度=设备-天,唯一键 device+coupon+day)。 + - 尝试 = status ∈ {success, already_claimed, failed}(skipped 排除); + - 成功 = status ∈ {success, already_claimed};成功率 = 成功/尝试; + - claim_date 区间 + app_env(None=全部)过滤;按 tried 倒序返回。 + """ + d_from = _date.fromisoformat(date_from) + d_to = _date.fromisoformat(date_to) + ok = case((CouponClaimRecord.status.in_(_SLOT_OK), 1), else_=0) + stmt = ( + select( + CouponClaimRecord.coupon_id, + func.max(CouponClaimRecord.coupon_name).label("coupon_name"), + func.count().label("tried"), + func.sum(ok).label("succeeded"), + ) + .where( + CouponClaimRecord.claim_date >= d_from, + CouponClaimRecord.claim_date <= d_to, + CouponClaimRecord.status.in_(_SLOT_TRIED), + ) + .group_by(CouponClaimRecord.coupon_id) + ) + if app_env is not None: + stmt = stmt.where(CouponClaimRecord.app_env == app_env) + items = [] + for coupon_id, coupon_name, tried, succeeded in db.execute(stmt).all(): + tried = int(tried or 0) + succeeded = int(succeeded or 0) + items.append({ + "coupon_id": coupon_id, + "coupon_name": coupon_name, + "platform": coupon_id_to_platform(coupon_id), + "tried": tried, + "succeeded": succeeded, + "success_rate": round(succeeded / tried, 4) if tried else None, + }) + items.sort(key=lambda x: (-x["tried"], x["coupon_id"])) + return {"items": items} diff --git a/app/admin/routers/coupon_data.py b/app/admin/routers/coupon_data.py index f3d2548..82a882d 100644 --- a/app/admin/routers/coupon_data.py +++ b/app/admin/routers/coupon_data.py @@ -18,6 +18,8 @@ from app.admin.schemas.coupon_data import ( CouponDataOut, CouponDataRow, CouponDataSummary, + CouponSlotRow, + CouponSlotsOut, CouponUserRecordsOut, ) from app.core.rewards import cn_today @@ -52,6 +54,10 @@ def get_coupon_data( date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None, user: Annotated[str | None, Query(description="用户手机号/昵称模糊搜;不传=全部")] = None, app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod", + status: Annotated[ + list[str] | None, + Query(description="领券状态多选 started/completed/failed/abandoned;不传=全部"), + ] = None, granularity: Annotated[ str, Query(description="day=按天 / hour=按小时(北京);区间>1 天建议 day") ] = "day", @@ -73,7 +79,7 @@ def get_coupon_data( env = None if app_env == "all" else app_env result = coupon_data.coupon_data_report( db, date_from=d_from.isoformat(), date_to=d_to.isoformat(), - user=user, app_env=env, granularity=granularity, + user=user, app_env=env, statuses=status, granularity=granularity, limit=limit, offset=offset, sort=sort, ) return CouponDataOut( @@ -87,6 +93,35 @@ def get_coupon_data( ) +@router.get( + "/coupons", + response_model=CouponSlotsOut, + summary="按券成功率(coupon_id 粒度;成功/(成功+失败),skipped 排除,设备-天口径)", +) +def get_coupon_slots( + db: AdminDb, + date_from: Annotated[str | None, Query(description="起始日 北京 YYYY-MM-DD,默认今天")] = None, + date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None, + app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod", +) -> CouponSlotsOut: + today = cn_today() + d_from = _parse_day(date_from, field="date_from", default=today) + d_to = _parse_day(date_to, field="date_to", default=d_from) + if d_to < d_from: + raise HTTPException(status_code=422, detail="date_to 不能早于 date_from") + if (d_to - d_from).days + 1 > _MAX_RANGE_DAYS: + raise HTTPException(status_code=422, detail=f"区间最长 {_MAX_RANGE_DAYS} 天") + env = None if app_env == "all" else app_env + result = coupon_data.coupon_slot_report( + db, date_from=d_from.isoformat(), date_to=d_to.isoformat(), app_env=env + ) + return CouponSlotsOut( + date_from=d_from.isoformat(), + date_to=d_to.isoformat(), + items=[CouponSlotRow(**r) for r in result["items"]], + ) + + @router.get( "/user-records", response_model=CouponUserRecordsOut, diff --git a/app/admin/schemas/coupon_data.py b/app/admin/schemas/coupon_data.py index c94938d..ec4c56e 100644 --- a/app/admin/schemas/coupon_data.py +++ b/app/admin/schemas/coupon_data.py @@ -19,6 +19,16 @@ class CouponDataSummary(BaseModel): p50_ms: int | None = Field(None, description="耗时 50 分位(ms,中位数)") p95_ms: int | None = Field(None, description="耗时 95 分位(ms)") p99_ms: int | None = Field(None, description="耗时 99 分位(ms)") + # 平台粒度成功率(见 docs/guides/领券成功率指标-设计与埋点.md):基数含全部 session。 + full_success_count: int = Field(0, description="整单成功数(勾选平台全部领到的 session 数)") + full_success_rate: float | None = Field(None, description="整单成功率②=整单成功数/发起数;无数据为空") + point_success_count: int = Field(0, description="成功平台点位数(Σ 每次成功的平台数)") + point_total_count: int = Field(0, description="总平台点位数(Σ 每次勾选平台数;空勾选=全领三档)") + point_success_rate: float | None = Field(None, description="点位成功率③=成功点位/总点位;无数据为空") + per_platform: dict[str, float | None] = Field( + default_factory=dict, + description="分平台点位成功率 {平台id: rate|None};恒含美团/淘宝/京东三档,区间内无人勾选的平台为 None", + ) class CouponDataDaily(BaseModel): @@ -81,3 +91,22 @@ class CouponUserRecordsOut(BaseModel): items: list[CouponDataRow] total: int + + +class CouponSlotRow(BaseModel): + """按券成功率一行(§13):粒度=设备-天;成功率=成功/(成功+失败),skipped 排除。""" + + coupon_id: str + coupon_name: str | None = None + platform: str | None = Field(None, description="美团/淘宝/京东 平台 id;无法识别为空") + tried: int = Field(..., description="尝试数(success+already_claimed+failed 的设备-天数)") + succeeded: int = Field(..., description="成功数(success+already_claimed)") + success_rate: float | None = Field(None, description="成功率=成功/尝试") + + +class CouponSlotsOut(BaseModel): + """按券成功率表响应(§13)。""" + + date_from: str + date_to: str + items: list[CouponSlotRow] diff --git a/app/api/v1/coupon.py b/app/api/v1/coupon.py index 1a5368d..bd2127a 100644 --- a/app/api/v1/coupon.py +++ b/app/api/v1/coupon.py @@ -81,7 +81,16 @@ def _record_claims_blocking( device_id: str, user_id: int | None, trace_id: str | None, results: list[dict] ) -> None: with SessionLocal() as db: - coupon_repo.record_claims(db, device_id, user_id, trace_id, results) + # 取本次 session 环境,给 coupon_claim_record 打 app_env 标(每券成功率表按它过滤;设计 §13)。 + app_env = coupon_repo.session_app_env(db, trace_id) + coupon_repo.record_claims(db, device_id, user_id, trace_id, results, app_env=app_env) + # 顺带把本帧「成功平台」并入 coupon_session.platform_success(admin 领券数据 ②整单/③点位成功率; + # 设计 route B,见 docs/guides/领券成功率指标-设计与埋点.md)。复用同一 SessionLocal、紧接 record_claims, + # 不新增连接;并集幂等(无新平台不写),trace_id 缺失或 session 行未落库则跳过。 + if trace_id: + coupon_repo.merge_session_platform_success( + db, trace_id, coupon_repo.succeeded_platforms(results) + ) def _mark_completed_blocking( diff --git a/app/models/coupon_state.py b/app/models/coupon_state.py index c7ba0b9..d461613 100644 --- a/app/models/coupon_state.py +++ b/app/models/coupon_state.py @@ -66,6 +66,9 @@ class CouponClaimRecord(Base): # success / already_claimed / failed / skipped(原样取 pricebot coupon 结果) status: Mapped[str] = mapped_column(String(24), nullable=False) + # 领券所属 session 的环境 prod/dev(/step 按 trace_id 查 coupon_session.app_env 打标)。 + # 旧行 NULL(不回填)。admin「按券成功率」表据此过滤环境。见设计 §13。 + app_env: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True) vendor: Mapped[str | None] = mapped_column(String(48), nullable=True) coupon_name: Mapped[str | None] = mapped_column(String(128), nullable=True) # 这张领到几张(pricebot display_count;给不出时为 None) @@ -240,6 +243,10 @@ class CouponSession(Base): platform_elapsed: Mapped[dict | None] = mapped_column(_JSON, nullable=True) # 领到总张数(收尾帧带)。 claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + # 本次 session 至少领到一张(status∈{success,already_claimed})的平台 id 列表,如 ["meituan-waimai","jd-waimai"]。 + # admin「领券数据」据此算整单成功率(②)/点位成功率(③);服务端 /step 逐帧按 trace_id 并集写入 + # (见 coupon_state.merge_session_platform_success)。旧行=NULL → 视作空集。 + platform_success: Mapped[list | None] = mapped_column(_JSON, nullable=True) # pricebot done 帧回传的公网调试链接(price.shaguabijia.com/traces/{dir});含落盘时分秒、拼不出,只能存 # (同 ComparisonRecord.trace_url)。admin「领券数据」明细据此渲染可点 trace 链接;未到 done(failed/abandoned)为空。 trace_url: Mapped[str | None] = mapped_column(String(512), nullable=True) diff --git a/app/repositories/coupon_state.py b/app/repositories/coupon_state.py index 336ca0c..508da74 100644 --- a/app/repositories/coupon_state.py +++ b/app/repositories/coupon_state.py @@ -147,12 +147,22 @@ def reset_today_completion(db: Session, device_id: str) -> int: # ===== 领券记录(coupon_claim_record)===== +def session_app_env(db: Session, trace_id: str | None) -> str | None: + """按 trace_id 取 coupon_session.app_env(每券成功率表打环境标用);无 trace_id / 查不到 → None。""" + if not trace_id: + return None + return db.execute( + select(CouponSession.app_env).where(CouponSession.trace_id == trace_id) + ).scalar_one_or_none() + + def record_claims( db: Session, device_id: str, user_id: int | None, trace_id: str | None, results: list[dict], + app_env: str | None = None, ) -> int: """一批券领取结果幂等写入,返回写入(新增 + 更新)条数。 @@ -186,12 +196,15 @@ def record_claims( row.user_id = user_id if count is not None: row.claimed_count = count + if app_env is not None: + row.app_env = app_env row.extra = r else: db.add(CouponClaimRecord( device_id=device_id, user_id=user_id, coupon_id=coupon_id, claim_date=today, - status=status, vendor=r.get("vendor"), coupon_name=r.get("name"), + status=status, app_env=app_env, + vendor=r.get("vendor"), coupon_name=r.get("name"), claimed_count=count, trace_id=trace_id, reason=r.get("reason"), extra=r, )) @@ -235,6 +248,47 @@ def sum_claimed_count(db: Session, user_id: int) -> int: return int(total or 0) +# ===== 领券平台推导(coupon_id → 平台;成功平台集)===== + +# 成功语义:success + already_claimed 算成功(pricebot 代码 emit already_claimed,协议 enum 漏了); +# failed / skipped 不算。与 sum_claimed_count 同口径。 +_SUCCESS_STATUSES = frozenset({"success", "already_claimed"}) + +# 三档平台 id 及固定序(美团→淘宝→京东),与客户端 DEFAULT_PLATFORM_ORDER 对齐。 +DEFAULT_PLATFORMS: tuple[str, ...] = ("meituan-waimai", "taobao-shanguang", "jd-waimai") + + +def coupon_id_to_platform(coupon_id: str | None) -> str | None: + """coupon_id 前缀 → 平台 id;无法识别 / 空 → None。 + + 与客户端 `CouponForegroundService.couponIdToPlatform` 同词表: + mt_→美团外卖 / tb_·ele_·elm_→淘宝闪购 / jd_→京东外卖。 + """ + if not coupon_id: + return None + if coupon_id.startswith("mt_"): + return "meituan-waimai" + if coupon_id.startswith(("tb_", "ele_", "elm_")): + return "taobao-shanguang" + if coupon_id.startswith("jd_"): + return "jd-waimai" + return None + + +def succeeded_platforms(results: list[dict]) -> list[str]: + """一批券结果 → 至少领到一张的平台集(按 DEFAULT_PLATFORMS 去重保序)。 + + 只取 status∈{success, already_claimed} 的券;失败/跳过、无法识别平台的券跳过。 + """ + ok: set[str] = set() + for r in results: + if r.get("status") in _SUCCESS_STATUSES: + platform = coupon_id_to_platform(r.get("coupon_id")) + if platform is not None: + ok.add(platform) + return [p for p in DEFAULT_PLATFORMS if p in ok] + + # ===== 领券任务流水(coupon_session,admin「领券数据」看板数据源)===== def upsert_coupon_session( @@ -321,3 +375,31 @@ def upsert_coupon_session( except IntegrityError: # 并发下另一请求刚插了同 trace_id → 唯一约束撞,回滚忽略(本就幂等)。 db.rollback() + + +def merge_session_platform_success( + db: Session, trace_id: str, platforms: list[str] +) -> None: + """把本帧「成功平台」并入 coupon_session.platform_success(按 trace_id,并集幂等,按 DEFAULT_PLATFORMS 保序)。 + + - 领券 /step 每逢带券结果的帧调一次(平台成败布尔,跨帧取并集天然幂等,不重复计)。 + - 读不到该 trace_id 的行 → **静默跳过**(不建兜底行;设计 §5:started 帧几乎必先落库)。 + - 并集无变化(该平台已记过)→ 不写库,省一次 UPDATE。 + - fire-and-forget:调用方已吞异常;并发唯一冲突回滚忽略。 + """ + if not platforms: + return + row = db.execute( + select(CouponSession).where(CouponSession.trace_id == trace_id) + ).scalar_one_or_none() + if row is None: + return + merged = set(row.platform_success or []) | set(platforms) + new_list = [p for p in DEFAULT_PLATFORMS if p in merged] + if new_list == (row.platform_success or []): + return # 幂等:无新平台,不写 + row.platform_success = new_list + try: + db.commit() + except IntegrityError: + db.rollback() diff --git a/docs/database/coupon_state.md b/docs/database/coupon_state.md index baa7105..f131465 100644 --- a/docs/database/coupon_state.md +++ b/docs/database/coupon_state.md @@ -1,4 +1,4 @@ -# coupon_state — 领券今日状态三张表(弹窗频控 / 首页置灰 / 领券记录) +# coupon_state — 领券状态表(今日状态三张 + 领券流水 coupon_session) > 模型 `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)——维度与本文三张「设备×日」状态表不同,单独成文。 @@ -8,8 +8,9 @@ - **`coupon_prompt_engagement`** — 弹窗频控源。按 `(device, App 包名, 自然日)` 记「今天**这个 App** 是否对领券引导窗表达过**意向**」(弹出即记 `shown` / 点「一键领取」=`claim_started` / 点拒绝关闭=`dismissed` 都算)。切到外卖 App 时据此决定弹不弹:今天**该 App** engage 过就不再弹该 App。频控维度自 2026-06-14 起含 `package`,美团/淘宝/京东各自独立、互不压制。 - **`coupon_daily_completion`** — 首页置灰源。按 `(device, 自然日)` 记「今天是否已**跑完整轮**领券(到 done 帧)」。首页「去领取」卡据此置灰:今天跑完了就不能再领。 - **`coupon_claim_record`** — 资产沉淀层。按 `(device, 券, 自然日)` 记每张券的领取结果(success/already_claimed/failed/skipped),**纯沉淀**(资产/画像/排查/CPS 归因),当前**不参与**「要不要领 / 弹不弹」的判断。 +- **`coupon_session`** — admin「领券数据」看板数据源(**独立流水表,不是「今日状态」表**)。按 `trace_id` 一次领券一行,走 `POST /api/v1/coupon/session` 两段上报(发起/收尾),记全程耗时 + 各平台耗时 + `platform_success`(成功平台,算整单②/点位③成功率)。详见下方专节。 -三表共同口径: +前三张「今日状态」表的共同口径: - **判断维度是 `device_id`,不是 `user_id`**:券发到的是设备上登录的那个外卖账号,device 比 user 更贴近「哪个登录环境」,且 `device_id` 全链路现成、不依赖领券鉴权(领券 MVP 阶段 `/coupon/step` 不鉴权)。客户端 `getOrCreateDeviceId` 生成存 SP,**卸载重装会变 → 当新设备重新弹一次**(产品预期)。 - **日期 = `Asia/Shanghai` 自然日**(`claim_date` / `engage_date` / `complete_date`,`repositories/coupon_state.today_cn()`)。每日可领的券(签到/天天红包)靠这天然每天一条。 - **`user_id` 可空**:领券登录态有就记(资产/画像),可空、**不进唯一键、不阻塞判断**。 @@ -91,7 +92,7 @@ ### 用在哪 / 增删改查 - **C / U(幂等 upsert)**:`record_claims`,由 `POST /api/v1/coupon/step` 写入。一帧的券结果来自 pricebot 的 `last_coupon_result`(最后一张)+ `action.params.coupon_results`(全量)——**会重复带同一张券**,端点 `_extract_coupon_results` 先**按 `coupon_id` 去重**(全量覆盖单张),仓库再靠唯一键幂等:已有则更新 `status`/`reason`/`claimed_count`/`extra`(以最后一次为准),否则插入。 - **U / D**:无业务删除。 -- **R**:**当前无读取端点**(纯写入沉淀,未来做去重/归因/画像时再用)。 +- **R**:`GET /admin/api/coupon-data/coupons`(`coupon_slot_report`)—— admin「按券成功率」表,按 `coupon_id` 聚合 成功/(成功+失败)(`skipped` 排除,设备-天口径,按 `app_env` 过滤)。见设计 §13。 ### 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | @@ -102,6 +103,7 @@ | `coupon_id` | String(64) | NOT NULL | 券标识(取自 pricebot 结果) | | `claim_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`);每日可领的券靠它天然每天一条 | | `status` | String(24) | NOT NULL | `success` / `already_claimed` / `failed` / `skipped`(原样取 pricebot coupon 结果) | +| `app_env` | String(16) | index, 可空 | 领券所属 session 环境 `prod`/`dev`(`/step` 按 `trace_id` 取 `coupon_session.app_env` 打标);旧行 NULL(不回填)。admin「按券成功率」表按它过滤。见设计 §13 | | `vendor` | String(48) | 可空 | 券提供方 | | `coupon_name` | String(128) | 可空 | 取 pricebot `name` | | `claimed_count` | Integer | 可空 | 这张领到几张(pricebot `display_count`,给不出时 None;兼容 `claimed_count`) | @@ -121,7 +123,49 @@ --- -## 三表共性小结 +## coupon_session — 领券任务流水(一次领券一行,admin「领券数据」看板数据源) + +`trace_id` 唯一,一次领券一行。与上面三张「今日状态」表不同:本表走 `POST /api/v1/coupon/session`(客户端**两段上报**:发起 `started` 建行、收尾 `completed`/`failed`/`abandoned` 按 `trace_id` 更新同一行),记从发起到收尾的全程耗时 + 各平台耗时 + 机型/ROM。发起即落库 → admin 可算「发起数」与中途流失(started 无终态 = 未完成)。 + +### 用在哪 / 增删改查 +- **C / U(幂等 upsert)**:`upsert_coupon_session`,由 `POST /api/v1/coupon/session` 两段上报。**状态只前进**(started 帧重复到不覆盖已有终态);终态补 `finished_at`。 +- **U(并集写)**:`merge_session_platform_success`,由 `POST /api/v1/coupon/step` 每逢**带券结果的帧**调用——把本帧「成功平台」(`status∈{success,already_claimed}` 的券 → `coupon_id` 前缀映射平台)**并入** `platform_success`(并集幂等,无新平台不写;读不到该 trace 行则跳过)。复用 `record_claims` 的同一 `SessionLocal`,不新增连接。 +- **R**:admin `GET /admin/api/coupon-data`(`coupon_data_report`)—— 发起/完成数、耗时分位、**整单成功率②/点位成功率③**、按天/小时趋势、逐条明细;`GET /admin/api/coupon-data/user-records` 某用户全部领券。 + +### 字段 +| 列 | 类型 | 约束 / 默认 | 说明 | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `trace_id` | String(64) | NOT NULL, UNIQUE | 一次领券唯一 id(客户端 UUID,全程贯穿),upsert 键 | +| `device_id` | String(64) | NOT NULL | | +| `user_id` | Integer | index, 可空 | 登录态才带(admin join 用户表出手机号/昵称);匿名领券为空 | +| `status` | String(16) | NOT NULL | `started` / `completed` / `failed` / `abandoned`;started 无终态 = 中途流失 | +| `app_env` | String(16) | index, 可空 | `prod` / `dev`;admin 报表默认只看 prod(防测试串台) | +| `platforms` | JSON | 可空 | 发起勾选平台 `["meituan-waimai",…]`(空 = 全领三档);**③点位成功率的分母来源** | +| `origin_package` | String(64) | 可空 | 发起来源外卖 App 包名;null = App 内(傻瓜比价首页)发起,非空 = 外卖侧弹券 | +| `device_model` | String(128) | 可空 | Build.MANUFACTURER + MODEL | +| `rom` | String(64) | 可空 | OemDetector,如 "ColorOS 14" | +| `started_at` | DateTime(tz) | NOT NULL | 发起时刻(客户端墙钟);明细「时间」列、趋势 X 轴 | +| `started_date` | **Date** | NOT NULL | 发起的**北京**自然日;admin 按天聚合/筛选(索引) | +| `finished_at` | DateTime(tz) | 可空 | 收尾时刻(服务端 now);未收尾(流失)为空 | +| `elapsed_ms` | Integer | 可空 | 全程耗时(ms,客户端点发起→收尾);均值/分位只统计 completed | +| `platform_elapsed` | JSON | 可空 | 各平台领券耗时 `{"meituan-waimai":3200,…}`(ms) | +| `claimed_count` | Integer | 可空 | 领到总张数(收尾帧带) | +| `platform_success` | JSON(PG JSONB) | 可空 | **本次至少领到一张(`status∈{success,already_claimed}`)的平台 id 列表**,如 `["meituan-waimai","jd-waimai"]`。`/step` 逐帧按 `trace_id` **并集**写入(`merge_session_platform_success`);旧行 NULL 视作空集。admin 据此算整单成功率②(`platforms`⊆`platform_success`)/点位成功率③(Σ交集/Σ勾选)。设计:[领券成功率指标](../guides/领券成功率指标-设计与埋点.md) | +| `trace_url` | String(512) | 可空 | pricebot done 帧回传的公网 trace 链接;未到 done(failed/abandoned)为空 | +| `created_at` | DateTime(tz) | server_default now() | | +| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | | + +### 索引与约束 +- PK `id`;index `user_id`、`app_env`;UNIQUE(`trace_id`) = `uq_coupon_session_trace`;Index(`started_date`, `app_env`) = `ix_coupon_session_date_env`(admin 主聚合/筛选)。 + +### 注意 +- `platform_success` 是**布尔性质**的平台集,跨帧**并集**天然幂等 → `/step` 每帧并入不重复计;失败/中途退出的 session 也能拿到崩溃前已成的平台。写放大 ≈ 领券券数(仅带券结果的帧写)。 +- 成功率**基数 = 区间全部 session**(含 abandoned/failed),与「发起数」同基数(设计 §3)。`coupon_id → 平台` 用前缀(`mt_`/`tb_`·`ele_`·`elm_`/`jd_`),与客户端 `couponIdToPlatform` 同词表。 + +--- + +## 今日状态三表共性小结 - 数据流向:客户端 → `POST /api/v1/coupon/step`(透传给 pricebot)→ 结果回写这三张表(best-effort,写库失败不影响领券)。 - 唯一键都含 `device_id` + 某个北京自然日列(engagement 还含 `package`,按 App 频控);`user_id` 永远是可空旁路(资产留痕,不进唯一键、不阻塞判断)。 - 无硬外键:`user_id` 软指 `user.id`、`trace_id` 软指 pricebot work_logs(详见 [OVERVIEW → 表间关系 & Join Key](./OVERVIEW.md))。 diff --git a/docs/guides/领券成功率指标-设计与埋点.md b/docs/guides/领券成功率指标-设计与埋点.md new file mode 100644 index 0000000..a1bb0cb --- /dev/null +++ b/docs/guides/领券成功率指标-设计与埋点.md @@ -0,0 +1,294 @@ +# 领券「整单成功率」与「点位成功率」指标 — 设计与埋点 + +- 日期:2026-07-07 +- 状态:待评审 +- 涉及仓库:`shaguabijia-app-server`(**纯服务端**;客户端零改动) +- 数据源表:`coupon_session`(admin「领券数据」看板) + +## 1. 背景与目标 + +admin「领券数据」看板(数据源 `coupon_session`,见 `app/admin/repositories/coupon_data.py`)当前能算:**领券发起数、完成数、全程耗时均值/分位**。产品还想要两个成功率指标,现有埋点算不出来: + +- **② 整单成功率** = 一次发起里勾选的平台**全部**领到券的次数 / 领券发起数 +- **③ 点位成功率** = 平台维度的领取成功率(每个平台「点位」成没成功) + +> 口径决定(2026-07-07):「点位」= **平台粒度**(美团 / 淘宝闪购 / 京东),不是「每张券」。 + +耗时中位数、发起数已由 `coupon_session.elapsed_ms` / `status=started` 计数满足,本设计只补 ②③。 + +## 2. 关键结论:per-slot 信号已在库,缺的是「按 session 可靠归因 + 看板可过滤」 + +服务端 `/api/v1/coupon/step`(`app/api/v1/coupon.py`)每帧都调 `record_claims`,把**每张券**的结果(`status ∈ success / already_claimed / failed / skipped`)写进 `coupon_claim_record`,还带 `trace_id`。原始成败信号**已经落库**。 + +但该表**不能**直接支撑本指标: + +1. 唯一键是 `(device_id, coupon_id, claim_date)`,**不含 trace_id**;且 `record_claims` 冲突更新时**不更新 trace_id**(`app/repositories/coupon_state.py` 的 `record_claims`,仅 INSERT 时写 trace_id)。→ 同一张券当天跨多次 session 会折叠成一行、只归属**最早**那次 → **按 session 归因不可靠**(直接砸 ②「整单全成功」)。 +2. `coupon_claim_record` 无 `app_env` / `origin_package` → 无法像看板那样只看 prod、也无法拆 Path A(App 内发起)/ Path B(外卖侧弹券)。 + +因此采用 **route B**:在 `/step` 里服务端推导「本次 session 哪些平台成功」,直接写到 `coupon_session` 行——该表按 `trace_id` 唯一、已带 `app_env` / `origin_package` / `platforms`,指标干净可过滤、可拆路径。 + +## 3. 指标口径(平台粒度,已定) + +记一次 session 为 `s`: + +- `sel(s)` = **勾选平台集** = `coupon_session.platforms`;为空表示「全领」→ 取默认 `{meituan-waimai, taobao-shanguang, jd-waimai}`。`|sel(s)|` = 该次「点位数」。 +- `succ(s)` = **成功平台集** = 本次 session 里**至少领到一张**(`status ∈ {success, already_claimed}`)的平台集合。 + +指标: + +- **③ 点位成功率** = `Σ_s |succ(s) ∩ sel(s)|` / `Σ_s |sel(s)|` + (分母即「发起数 × 各自点位数」;全部全领时等于 发起数 × 3) +- **② 整单成功率** = `#{ s : sel(s) ⊆ succ(s) 且 sel(s) ≠ ∅ }` / `发起数` + +已定边界: + +1. **③ 分母用「勾选平台」`sel(s)`**。勾了淘宝但淘宝没领到 = 该点位未成功(不特判「平台没券」)。若日后要「真没券的平台不计入分母」,再引入 `platform_attempted`(见 §8)。 +2. **②③ 基数 = 区间内全部 session**(含 `started` / `failed` / `abandoned`),与看板「发起数」同基数。**失败 / 中途退出的 session 的 `succ(s)` 取它崩溃前真领到的平台**(不一律算 0)。 +3. **成功语义**:`status ∈ {success, already_claimed}` = 成功;`failed` / `skipped` = 未成功。与 `sum_claimed_count`(`app/repositories/coupon_state.py`)一致。 + > pricebot 协议文档把 `status` enum 写作 `success|failed|skipped`,但**代码实际还会 emit `already_claimed`**(pricebot `app/services/coupon_provider.py` 等多处)——以代码为准,含 already_claimed 是对的。`skipped` 目前 MVP 阶段基本不出现。 + +## 4. 数据模型改动 + +`coupon_session`(`app/models/coupon_state.py::CouponSession`)新增一列: + +| 列 | 类型 | 说明 | +|----|------|------| +| `platform_success` | `_JSON`(PG→JSONB / SQLite→JSON),nullable | 本次 session **至少领到一张**的平台 id 列表,如 `["meituan-waimai","jd-waimai"]`。旧行 = `NULL` → 视作空集。 | + +- alembic 新迁移:`add_column coupon_session.platform_success`,nullable、无 server_default。 +- **分母 `sel(s)` 复用已有 `platforms` 列,不新增字段。** 净新增仅此一列。 +- 同步更新表字典 `docs/database/coupon_state.md`。 + +## 5. 服务端推导逻辑(`/step`) + +在 `app/api/v1/coupon.py::coupon_step` 内(已有 `results = _extract_coupon_results(resp_json)`)新增: + +1. 对每条 result 求平台:**按 `coupon_id` 前缀映射**(与客户端 `CouponForegroundService.couponIdToPlatform` 对齐,且与 `platforms` / `platform_elapsed` 用同一套平台 id 词表): + - `mt_` → `meituan-waimai` + - `tb_` / `ele_` / `elm_` → `taobao-shanguang` + - `jd_` → `jd-waimai` + - 其余 → 跳过(无法识别) + > 不用券的 `vendor` 字段做映射:vendor 词表未必等于这三档平台 id,而 `sel(s)` 用的就是这三档,`succ(s)` 必须同词表。 +2. 收集 `status ∈ {success, already_claimed}` 的平台集合 `ok_platforms`。 +3. 若 `device_id` 且 `ok_platforms` 非空:调用新 repo 函数把 `ok_platforms` **并入** `coupon_session.platform_success`(按 `trace_id`)。 + +新增 `app/repositories/coupon_state.py::merge_session_platform_success(db, trace_id, ok_platforms)`: + +- 读现有行 → `platform_success = 现有 ∪ ok_platforms`(去重、保序)→ 写回、commit;`IntegrityError` 回滚忽略(同现有 upsert 兜底)。 +- **并集幂等**:跨帧多次并入同一平台不会重复;done 帧的全量 `coupon_results` 保证完整;失败 / 中途退出的 session 靠中间帧 `last_coupon_result` 已并入的平台拿到「部分成功」。 +- **fire-and-forget**:包 `run_in_threadpool` + 整段 try/except 只 log,绝不连累 `/step` 返回(与现有 `record_claims` / `mark_completed` 同规格)。 + +**建行 / 时序(已定:行不存在则跳过本次并入)**: + +- 客户端 `/session started` 在 `start()`(任务发起那刻)就发,而首个带券的 `/step` 要等领券循环跑起来、晚几秒;到 `/step` 有 `ok_platforms` 时,`coupon_session` 行几乎必然已存在。故 `merge_session_platform_success` **读不到行就跳过**,不建兜底行——实现最简,也不引入 `started_at` 不精确的脏行。 +- 残留丢数窗口:仅当「`started` 上报丢失」**且**「`/step` done 先于 `/session` terminal 落库」两者同时成立,该 session 的 `platform_success` 才会缺(terminal 帧会建行但那之后没有 `/step` 再并入)。两条件叠加概率极低,且指标是聚合口径、可容忍个别缺失。 +- 若上线后观测到该缺失不可忽略,再降级为「读不到行则 upsert 建最小兜底行(`started_at=now()`)」——届时改 `merge_session_platform_success` 一处即可,不影响其余设计。 + +**写放大(重要,非每 step)**:`merge_session_platform_success` **只在「本帧带券结果」时触发**——即 pricebot 在**单券完成帧**给 `last_coupon_result`、**最终 done 帧**给全量 `coupon_results` 的那些帧;领单张券途中的导航/点击帧(占 step 大头)`_extract_coupon_results` 返回空 → **不写**。所以写频次 ≈ **本次领的券数**(通常个位数),且落在**服务端今天已有的** `record_claims` 写的**同一批帧**上,不新增写的帧。 + +实现:把 merge 放进**现有 `_record_claims_blocking` 的同一个 `SessionLocal`**(紧接 `record_claims`),边际成本 = 每张券完成时多一条 `UPDATE coupon_session`(按 `trace_id` 唯一索引),不新增连接 / 不新增 `run_in_threadpool` 调用。 + +可选降级(若要「一次 session 只写一次」):只在 done 帧写 `platform_success`(全量 `coupon_results` 一次算完)。代价:`failed` / `abandoned`(没 done 帧)拿不到「崩溃前已成平台」→ 失败单部分成功丢失,与 §3「失败单取实际成的平台」相悖。**默认取每券帧并入**(失败单也如实统计),此降级留作观测到写压力后再启用。 + +## 6. admin 聚合与呈现 + +`app/admin/repositories/coupon_data.py::coupon_data_report` 的 `summary` 增加(沿用「全量拉区间 → Python 聚合」风格,与分位一致): + +- `full_success_rate`(②)、`point_success_rate`(③) +- 可选 `per_platform`:`{platform: rate}`(各平台点位成功率,拆美团/淘宝/京东) + +计算:对区间内 sessions,`sel = platforms or 默认三档`,`succ = set(platform_success) ∩ sel`;按 §3 公式汇总。 + +- 过滤:默认 `app_env == 'prod'`(同现有分位口径,防测试串台)。 +- 拆路径:`origin_package` 已在表上 → 看板后续可加「Path A / Path B」筛选项(本设计不含前端图表细节)。 +- schema:`app/admin/schemas/coupon_data.py` 的 summary 加对应字段(+ 可选 `per_platform`)。 + +## 7. 改动清单 + +- [x] `app/models/coupon_state.py`:`CouponSession` 加 `platform_success` +- [x] `alembic/versions/`:新迁移 add column `coupon_session.platform_success` +- [x] `app/repositories/coupon_state.py`:新增 `merge_session_platform_success` +- [x] `app/api/v1/coupon.py`:`/step` 推导 `ok_platforms` 并 union(前缀映射 + fire-and-forget) +- [x] `app/admin/repositories/coupon_data.py`:`summary` 加 ②③(+ 可选 `per_platform`) +- [x] `app/admin/schemas/coupon_data.py`:`summary` schema 加字段 +- [x] `docs/database/coupon_state.md`:补 `platform_success` 列说明(并补 coupon_session 整节) +- [x] `tests/`:`tests/test_coupon_platform_success.py`(10 测试,见 §9) + +## 8. 不做(YAGNI / 边界) + +- **客户端不改、历史不回填**:`platform_success` 只对新 session 生效(route B 的固有取舍,用户已接受)。 +- **不引入 `platform_attempted`**:③ 分母用勾选平台。若日后要「排除真没券的平台」,注意 `platform_elapsed.keys()` 已近似「被处理过的平台」,可作 attempted 的现成来源,多半仍不必加列。 +- **不动 `coupon_claim_record`** 的去重 / 归因:本指标绕开它,避免牵动频控 / 资产 / CPS 语义。 +- **不做券级(每张券)成功率**:已选平台粒度。 + +## 9. 测试口径要点 + +- **union 幂等**:同一 `trace_id` 多帧并入同一平台,`platform_success` 不重复、保序。 +- **失败单部分成功**:session `failed`,但美团已成 → `succ = {meituan-waimai}`,计入 ③ 分子;② 仅当 `sel ⊆ succ` 才算整单成功。 +- **空 `platforms` → `sel` 取默认三档**(全领)。 +- **成功语义**:`already_claimed` 计成功;`skipped` 不计。 +- **基数**:`abandoned` 计入 ②③ 基数,`succ` 取实际成的平台。 +- **prod 过滤**:`dev` 环境 session 不进指标。 + +## 10. 对 pricebot-backend 的影响 + +**结论:不需要改 pricebot,也不改发往 pricebot 的请求 / 不加调用 / 不加负载。** + +- `/step` 仍原样透传请求 bytes 给 pricebot;本设计只**多解析 pricebot 的响应**(`coupon_results` / `last_coupon_result`),而这两个字段服务端**今天已在** `_extract_coupon_results` / `record_claims` 里解析。零新增字段需求、零额外上游调用、pricebot 负载不变。 +- **只读依赖(既有耦合,非新引入)**:平台映射靠 pricebot 的 `coupon_id` 前缀约定(`mt_` / `tb_` / `ele_` / `elm_` / `jd_`)。客户端 `couponIdToPlatform` 早就依赖同一套;本设计只是加了这份映射的第二个消费者。维护耦合:pricebot 若改 `coupon_id` 前缀,客户端与本指标会**一起**失效——但这是既有风险,依赖方向不变。无法识别前缀的券按「跳过」处理(与客户端一致)。 +- 认账的 pricebot 事实(源:`app/models/response.py` + `docs/projects/领券-客户端对接协议.md`): + - `CouponResult = {coupon_id, name, vendor, status, reason?, duration_ms?}`;`coupon_results` 仅最终 done 帧全量,中间帧走 `last_coupon_result`(单张)。 + - `vendor` 是来源标签(`meituan_internal` / `dianping_cps` …),**不等于**三档平台 id;且 `mt_dianping_xxx`(大众点评 CPS)也带 `mt_` 前缀归美团 → 印证「用 `coupon_id` 前缀、不用 `vendor`」正确。 + +--- + +## 11. 实现状态(交付记录 · 2026-07-07) + +**状态:实现完成、TDD 全绿、未提交、迁移未应用。** 纯服务端(shaguabijia-app-server),客户端 / pricebot 未动。 + +### 已交付改动 +| 文件 | 改动 | +|---|---| +| `app/models/coupon_state.py` | `CouponSession` 加 `platform_success`(`_JSON`, nullable) | +| `app/repositories/coupon_state.py` | `coupon_id_to_platform` / `succeeded_platforms` / `merge_session_platform_success` + 常量 `DEFAULT_PLATFORMS` / `_SUCCESS_STATUSES` | +| `app/api/v1/coupon.py` | `_record_claims_blocking` 内、`record_claims` 之后并入本帧成功平台(同一 `SessionLocal`) | +| `app/admin/repositories/coupon_data.py` | `_success_rates(rows)` → summary 加 `full_success_rate②` / `point_success_rate③` + 3 个支撑计数 | +| `app/admin/schemas/coupon_data.py` | `CouponDataSummary` 加 5 字段 | +| `alembic/versions/coupon_session_platform_success.py` | add column;revision=`coupon_session_platform_success`,down=`admin_user_plain_password`(当前 head) | +| `docs/database/coupon_state.md` | 补 `coupon_session` 整节(表原本无文档)+ 新列 | +| `tests/test_coupon_platform_success.py` | 10 个测试(TDD) | + +> §7 里「可选 `per_platform`」原标 YAGNI 延后;**已在 §12(2026-07-08)补做**(随 admin 前端看板卡一并接入,见下)。 + +### 测试与验证 +- 本特性:`Set-Location e:\project\shaguabijia-app-server; & .\.venv\Scripts\python.exe -m pytest tests\test_coupon_platform_success.py -q` → **10 passed**。 +- 全量 `pytest -q`:**306 passed / 5 failed**。5 个失败**全部预存、与本次无关**: + - `test_coupon_proxy.py::test_coupon_step_passes_body_through`(测试断言 `json=` 但 handler 用 `content=` 转发;写代码前 sanity run 就红)。 + - `test_invite.py` + `test_invite_compare_reward.py` 共 4 个(单独跑也红;本次改动集零 invite 文件)。 +- 迁移:临时库 `alembic upgrade head` 通过、列已建、单一 head。 +- lint:新增代码 `ruff` 全清;`coupon_state.py` 剩 3 处 pre-existing UP017(`upsert_coupon_session` 的 `timezone.utc`)未动。 + +### 待办(在后端项目里继续) +1. **应用迁移**:`alembic upgrade head`(DDL 已验;线上只前进统计、历史不回填)。 +2. **提交**:尚未提交;建议先开分支再提交。 +3. **admin 前端图表**:后端指标已就绪(summary 的 `full_success_rate` / `point_success_rate` 等),看板卡 / 趋势展示待接前端。 +4. (可选)修预存 `test_coupon_step_passes_body_through`(一行:capture `content` 而非 `json`)。 + +### 续开发须知 +- `platform_success` 只在**带券结果的帧**写(`/step` 里 `succeeded_platforms(results)` 非空才 merge),≈ 领券券数量级、非每 step;复用 `record_claims` 同一 `SessionLocal`、不新增连接。 +- merge **读不到 session 行则跳过**(不建兜底行);并集幂等、无新平台不写。 +- 口径:成功=`status∈{success,already_claimed}`;基数含 `abandoned`/`failed`;③ 分母=`platforms`(空→全领三档);`coupon_id`→平台走前缀,与客户端 `couponIdToPlatform` 同词表。 + +--- + +## 12. 续做:admin 前端看板卡 + 分平台点位成功率(设计 · 2026-07-08) + +承 §11 待办 #3(前端图表)与 §7「可选 `per_platform`」。本轮把 ②③ 接入 admin「领券数据」页,并把 ③ 按平台拆(`per_platform`)。**改前端 + 后端 + 测试**;客户端 / pricebot 仍零改动。 + +- 状态:设计已评审通过(2026-07-08),待实现。 +- 涉及仓库:`shaguabijia-app-server`(后端)+ `shaguabijia-admin-web`(admin 前端,Next.js + antd)。 + +### 12.1 后端:`per_platform` 分平台点位成功率 + +`app/admin/repositories/coupon_data.py::_success_rates(rows)` 在现有合计基础上,对每个 `p ∈ DEFAULT_PLATFORMS`(美团/淘宝/京东)累加: + +- 分母 `per_total[p]` = 勾选了 p 的 session 数(`p ∈ sel`,空勾选 `sel` 按全领三档); +- 分子 `per_succ[p]` = 其中 `p ∈ succ`(该平台至少领到一张)的 session 数; +- `per_platform[p]` = `round(per_succ[p] / per_total[p], 4)`;分母 0 → `None`。 + +**不变量**:`Σ_p per_succ[p] == point_success_count`、`Σ_p per_total[p] == point_total_count`(三档词表下恒成立;非三档平台 id 不计入 `per_platform`,由 `if p in per_total` 守卫)。用 §9 / `test_coupon_data_success_rates` 数据自检:美团 3/4=0.75、淘宝 2/3=0.6667、京东 1/2=0.5;合计仍 6/9=0.6667。 + +- summary 加一项 `per_platform`:**恒含三档键**,如 `{"meituan-waimai":0.75,"taobao-shanguang":0.6667,"jd-waimai":0.5}`(区间内无人勾选的平台 → 值 `None`)。 +- schema `app/admin/schemas/coupon_data.py::CouponDataSummary` 加 `per_platform: dict[str, float | None]`。 + +### 12.2 前端:admin-web「领券数据」汇总卡补一段 + +`shaguabijia-admin-web/src/app/(main)/coupon-data/page.tsx`(单文件,`CouponDataSummary` 为该页内联类型): + +- 内联 `CouponDataSummary` 补:`full_success_count` / `full_success_rate` / `point_success_count` / `point_total_count` / `point_success_rate` + `per_platform: Record`。 +- 新 helper `fmtPct(v) = v == null ? '-' : ${(v*100).toFixed(1)}%`(沿用本页 `-` 空值风格,数学同大盘 `pct`);antd 导入补 `Tooltip`,新增 `import { InfoCircleOutlined } from '@ant-design/icons'`。 +- 汇总卡「耗时分位」行之后,`Divider` + 两行 `Statistic`(各 `Col flex="1 1 0"`): + - 行1:**整单成功率** `fmtPct(full_success_rate)` · **点位成功率(合计)** `fmtPct(point_success_rate)`;标题各带 ⓘ `Tooltip`(口径说明;合计率注明「平台粒度、= 三档之和,与『数据大盘』券粒度口径不同」)。 + - 行2:**美团 / 淘宝 / 京东 点位成功率** `fmtPct(per_platform['meituan-waimai' | 'taobao-shanguang' | 'jd-waimai'])`(平台名同「美团耗时」列既有叫法)。 +- 不显示支撑数(合计与分平台均纯百分比);tooltip 不带分母。 + +### 12.3 测试 + +- 扩 `tests/test_coupon_platform_success.py::test_coupon_data_success_rates`:断言 `per_platform == {"meituan-waimai":0.75,"taobao-shanguang":round(2/3,4),"jd-waimai":0.5}`,并断言和不变量(`Σ 分子 == point_success_count == 6`、`Σ 分母 == point_total_count == 9`)。 +- 前端 `shaguabijia-admin-web` lint / type-check 通过。 +- 端到端:随下一步「真实 /step 实测」在跑起来的 admin-web + 后端页面上核对卡片渲染。 + +### 12.4 不做(YAGNI) + +- 成功率**趋势线**:后端 `daily` / `hourly` 不含率字段,加趋势要另改聚合,超出本轮范围。 +- 合计 / 分平台的**支撑数副文本**、tooltip 带分母。 +- 客户端 / pricebot 改动;历史回填。 + +--- + +## 13. 续做:每券(coupon_id)成功率明细表(设计 · 2026-07-08) + +产品要更细粒度:到**具体领券点位**(如「美团外卖红包天天领」「美团甄选好店」),即按 `coupon_id` 算成功/失败率,比 §12 的平台粒度再细一层。**改前端 + 后端 + 测试**;客户端 / pricebot 仍零改动。 + +- 状态:设计已评审通过(2026-07-08),待实现。 +- 关键取舍(已定):数据源用 **`coupon_claim_record`**(它本就是「单张券一天一条」的系统记录,已带 `coupon_name` / `status` / `vendor` / `trace_id`),唯一缺 `app_env` → 补一列即可。§2 当初绕开它是因为**平台指标要按 session 归因**;而**每券成功率是全局聚合、不需要 session 归因**,`(device,券,天)` 折叠反而天然去重防刷,故这里用它是对的。 + +### 13.1 指标口径(已定) + +- **成功** = `status ∈ {success, already_claimed}`;**尝试(分母)** = `status ∈ {success, already_claimed, failed}`;**`skipped` 排除**(无券可领/不适用,不算尝试、不进分母、不展示)。 +- **成功率** = 成功 / 尝试(某券区间内无 tried 行 → 不出现在表里,无除零)。 +- **粒度 = 「设备-天」**(非「每次点击」):`coupon_claim_record` 唯一键 `(device, coupon_id, claim_date)`,同设备当天同券只留最后状态。所以「尝试」= 有多少**设备-天**尝试过该券,「成功」= 其中最终领到的。要每次点击级须换源(route B / 原始事件),本轮不做。 +- **环境**:跟随页面「环境」(prod/dev/全部),按 `app_env` 过滤;旧行 `app_env=NULL` **不回填** → 仅「全部」视图可见。 +- **日期**:按 `claim_date`(Asia/Shanghai 自然日),与页面日期范围一致。 + +### 13.2 数据模型 + +`CouponClaimRecord`(`app/models/coupon_state.py`)加一列: + +| 列 | 类型 | 说明 | +|----|------|------| +| `app_env` | `String(16)`,index,nullable | prod / dev。`/step` 落库时按 session 的 app_env 打标;旧行 NULL(不回填)。 | + +- alembic 新迁移:`add_column coupon_claim_record.app_env`,nullable + index,无 server_default。 +- 同步更新 `docs/database/coupon_state.md` 的 coupon_claim_record 节。 + +### 13.3 写路径(`/step`) + +- `app/repositories/coupon_state.py::record_claims` 加参数 `app_env: str | None = None`;INSERT 时写入,UPDATE 时 `if app_env is not None: row.app_env = app_env`(不用 None 覆盖已有)。 +- 新增轻量 repo 助手 `session_app_env(db, trace_id) -> str | None`(按 trace_id 取 `coupon_session.app_env`,查不到返回 None)。 +- `app/api/v1/coupon.py::_record_claims_blocking`:`trace_id` 存在则先 `app_env = coupon_repo.session_app_env(db, trace_id)`,传给 `record_claims(..., app_env=app_env)`;`merge_session_platform_success` 保持原样(其自身 select 不变)。查不到 session / 无 trace_id → `app_env=None`(行为同旧)。 + > 写放大:仅「带券结果的帧」触发(同 §5),每帧多一次 `session_app_env` 小查询(按 trace_id 唯一索引),可忽略。 + +### 13.4 admin 聚合(新 repo 函数) + +`app/admin/repositories/coupon_data.py` 新增 `coupon_slot_report(db, *, date_from, date_to, app_env)`(与 `coupon_user_records` 同居本文件,同属「领券数据」看板;更新模块 docstring 注明本文件现读 `coupon_session` + `coupon_claim_record` 两源): + +- `SELECT coupon_id, MAX(coupon_name) AS coupon_name, COUNT(*) AS tried, SUM(CASE WHEN status IN (success,already_claimed) THEN 1 ELSE 0 END) AS succeeded` + `WHERE claim_date ∈ [from,to] AND status IN (success,already_claimed,failed)` (+ `AND app_env = :env` 当 env 非「全部」) `GROUP BY coupon_id`。 +- 每行:`platform = coupon_id_to_platform(coupon_id)`(复用前缀映射,无法识别→None),`success_rate = round(succeeded/tried, 4)`。 +- 按 `tried` 倒序、`coupon_id` 次序返回 `{"items": [...]}`。 + +### 13.5 接口 + schema(新子端点) + +- 路由 `app/admin/routers/coupon_data.py`:加 `@router.get("/coupons")` → `get_coupon_slots`,参数 `date_from` / `date_to` / `app_env`(同主 report 的解析与 `_MAX_RANGE_DAYS` 校验,`app_env="all"→None`),调 `coupon_slot_report`。路径全称 `/admin/api/coupon-data/coupons`(与现有 `/coupon-data/user-records` 同款子端点)。 +- schema `app/admin/schemas/coupon_data.py`: + - `CouponSlotRow{coupon_id: str, coupon_name: str|None, platform: str|None, tried: int, succeeded: int, success_rate: float|None}` + - `CouponSlotsOut{date_from: str, date_to: str, items: list[CouponSlotRow]}` + +### 13.6 前端 + +`shaguabijia-admin-web/src/app/(main)/coupon-data/page.tsx`: + +- 汇总卡下方(或趋势图下方)新增一张「按券成功率」表:列 **券名**(`coupon_name || coupon_id`)/ **平台**(美团/淘宝/京东/其他)/ **尝试** / **成功** / **成功率**(`fmtPct`)。默认按尝试倒序;支持 antd 列排序。 +- 点「查询」时,除主 report 外并行 `api.get('/admin/api/coupon-data/coupons', {params:{date_from,date_to,app_env}})`;空则不显示表。 +- 新增内联类型 `CouponSlotRow`;平台名复用映射(`meituan-waimai→美团` 等,null→其他)。 + +### 13.7 测试 + +- 后端:`record_claims` stamp `app_env`(INSERT/UPDATE 两路);`session_app_env` 助手;`/step` 集成写入 `app_env`;`coupon_slot_report` 聚合(多券 × success/already_claimed/failed/skipped × 两 env:断言 tried/succeeded/rate、**skipped 排除**、env 过滤、排序);schema 契约。 +- 前端:`tsc --noEmit`。 + +### 13.8 不做(YAGNI) + +- 每次点击级成功率(需换数据源);成功率趋势线;`app_env` 历史回填(可选一次性 `join trace_id→session.app_env`,默认不做);券级 tooltip / 明细下钻。 +- 券表为**全局聚合**,**不随页面「用户」搜索框过滤**(用户维度非本需求;`coupon_claim_record` 虽有 `user_id`,YAGNI)。与主明细表按用户过滤的行为不同,属有意为之。 diff --git a/tests/test_coupon_platform_success.py b/tests/test_coupon_platform_success.py new file mode 100644 index 0000000..adc04a9 --- /dev/null +++ b/tests/test_coupon_platform_success.py @@ -0,0 +1,299 @@ +"""领券「平台成功率」埋点:coupon_id→平台映射 / 成功平台推导 / session platform_success 并集。 + +设计:docs/guides/领券成功率指标-设计与埋点.md +""" +from __future__ import annotations + +from datetime import UTC, date, datetime + +from sqlalchemy import delete, func, select + +from app.admin.repositories.coupon_data import coupon_data_report +from app.db.session import SessionLocal +from app.models.coupon_state import CouponSession +from app.repositories.coupon_state import ( + coupon_id_to_platform, + merge_session_platform_success, + succeeded_platforms, +) + + +def _agg_session( + trace: str, platforms, platform_success, *, status: str = "completed" +) -> CouponSession: + """构造一条聚合测试用 session(started_date 固定 2020-01-02、app_env=prod,不 commit)。""" + return CouponSession( + trace_id=trace, + device_id="d-agg", + status=status, + app_env="prod", + platforms=platforms, + platform_success=platform_success, + started_at=datetime(2020, 1, 2, tzinfo=UTC), + started_date=date(2020, 1, 2), + ) + + +def _make_session(db, trace_id: str, **kw) -> CouponSession: + row = CouponSession( + trace_id=trace_id, + device_id="dev-merge", + status=kw.pop("status", "started"), + started_at=datetime(2020, 1, 1, tzinfo=UTC), + started_date=date(2020, 1, 1), + **kw, + ) + db.add(row) + db.commit() + return row + + +def test_coupon_id_to_platform_prefix_mapping() -> None: + """coupon_id 前缀 → 三档平台 id(与客户端 couponIdToPlatform 同词表)。""" + assert coupon_id_to_platform("mt_banjia_zhoumo") == "meituan-waimai" + assert coupon_id_to_platform("mt_cps_waimai_redpacket") == "meituan-waimai" # 大众点评CPS 也挂 mt_ → 归美团 + assert coupon_id_to_platform("tb_vip_shangou_voucher") == "taobao-shanguang" + assert coupon_id_to_platform("ele_hongbao") == "taobao-shanguang" + assert coupon_id_to_platform("elm_hongbao") == "taobao-shanguang" + assert coupon_id_to_platform("jd_redpacket") == "jd-waimai" + + +def test_coupon_id_to_platform_unknown_returns_none() -> None: + """无法识别的前缀 / 空 → None(调用方跳过,不计入平台)。""" + assert coupon_id_to_platform("weird_xxx") is None + assert coupon_id_to_platform("") is None + assert coupon_id_to_platform(None) is None # type: ignore[arg-type] + + +def test_succeeded_platforms_filters_status_and_dedups() -> None: + """只取 status∈{success,already_claimed} 的券,映射平台后去重;失败/跳过/无法识别的不计。""" + results = [ + {"coupon_id": "mt_a", "status": "success"}, + {"coupon_id": "mt_b", "status": "already_claimed"}, # 也算成功 → 仍是美团 + {"coupon_id": "mt_c", "status": "failed"}, # 不算 + {"coupon_id": "tb_a", "status": "success"}, + {"coupon_id": "jd_a", "status": "skipped"}, # 不算 + {"coupon_id": "weird", "status": "success"}, # 无法识别平台 → 跳过 + ] + assert set(succeeded_platforms(results)) == {"meituan-waimai", "taobao-shanguang"} + + +def test_succeeded_platforms_empty() -> None: + assert succeeded_platforms([]) == [] + + +def test_merge_platform_success_union_idempotent() -> None: + """按 trace_id 把成功平台并入 platform_success:并集去重 + 按 DEFAULT_PLATFORMS 保序 + 重复并入幂等。""" + db = SessionLocal() + trace = "merge-union-1" + try: + _make_session(db, trace) + merge_session_platform_success(db, trace, ["meituan-waimai"]) + merge_session_platform_success(db, trace, ["jd-waimai", "meituan-waimai"]) # 并集 + 已有幂等 + db.expire_all() + row = db.execute( + select(CouponSession).where(CouponSession.trace_id == trace) + ).scalar_one() + # 美团→淘宝→京东固定序;只含实际成功的美团/京东 + assert row.platform_success == ["meituan-waimai", "jd-waimai"] + finally: + db.execute(delete(CouponSession).where(CouponSession.trace_id == trace)) + db.commit() + db.close() + + +def test_merge_platform_success_missing_row_skips() -> None: + """trace_id 无对应行 → 静默跳过:不建兜底行、不抛异常(设计 §5)。""" + db = SessionLocal() + try: + merge_session_platform_success(db, "no-such-trace", ["meituan-waimai"]) + n = db.execute( + select(func.count()).select_from(CouponSession).where( + CouponSession.trace_id == "no-such-trace" + ) + ).scalar_one() + assert n == 0 + finally: + db.close() + + +def test_coupon_data_success_rates() -> None: + """admin 聚合 ②整单成功率 / ③点位成功率:平台粒度,含空 platforms→全领三档、abandoned 入基数。 + + A 勾美团+淘宝、两个都成 → 整单成功;点位 2/2 + B 勾三档、只成美团 → 非整单;点位 1/3 + C 全领(platforms空→3)、三档全成 → 整单成功;点位 3/3 + D abandoned、勾美团、无成功平台 → 非整单;点位 0/1(仍进基数) + 发起数=4;整单成功=2 → 0.5;点位 6/9 → 0.6667 + """ + db = SessionLocal() + try: + db.add_all([ + _agg_session("agg-A", ["meituan-waimai", "taobao-shanguang"], + ["meituan-waimai", "taobao-shanguang"]), + _agg_session("agg-B", ["meituan-waimai", "taobao-shanguang", "jd-waimai"], + ["meituan-waimai"]), + _agg_session("agg-C", [], ["meituan-waimai", "taobao-shanguang", "jd-waimai"]), + _agg_session("agg-D", ["meituan-waimai"], None, status="abandoned"), + ]) + db.flush() # 同会话可见,不 commit(finally 回滚保持隔离) + s = coupon_data_report( + db, date_from="2020-01-02", date_to="2020-01-02", app_env="prod" + )["summary"] + assert s["started_count"] == 4 + assert s["full_success_count"] == 2 + assert s["full_success_rate"] == 0.5 + assert s["point_success_count"] == 6 + assert s["point_total_count"] == 9 + assert s["point_success_rate"] == round(6 / 9, 4) + # ③ 分平台点位成功率(§12):美团 3/4、淘宝 2/3、京东 1/2。 + # 分平台(成功,总)= 美团(3,4)+淘宝(2,3)+京东(1,2) = (6,9), + # 其和正好等于上面已断言的 point_success_count=6 / point_total_count=9(和不变量)。 + assert s["per_platform"] == { + "meituan-waimai": 0.75, + "taobao-shanguang": round(2 / 3, 4), + "jd-waimai": 0.5, + } + finally: + db.rollback() + db.close() + + +def test_coupon_data_success_rates_empty_range() -> None: + """区间无 session → 发起数 0、两个率为 None(不除零)。""" + db = SessionLocal() + try: + s = coupon_data_report( + db, date_from="2019-01-01", date_to="2019-01-01", app_env="prod" + )["summary"] + assert s["started_count"] == 0 + assert s["full_success_rate"] is None + assert s["point_success_rate"] is None + finally: + db.close() + + +def test_coupon_data_summary_schema_exposes_rates() -> None: + """schema 契约:CouponDataSummary 暴露 ②③ 字段,键名与 repo 输出一致(router 直接 **summary 构造)。""" + from app.admin.schemas.coupon_data import CouponDataSummary + + db = SessionLocal() + try: + db.add(_agg_session("agg-schema-1", ["meituan-waimai"], ["meituan-waimai"])) + db.flush() + summary = coupon_data_report( + db, date_from="2020-01-02", date_to="2020-01-02", app_env="prod" + )["summary"] + dumped = CouponDataSummary(**summary).model_dump() + assert dumped["full_success_rate"] == summary["full_success_rate"] + assert dumped["point_success_rate"] == summary["point_success_rate"] + assert dumped["full_success_count"] == summary["full_success_count"] + assert dumped["point_success_count"] == summary["point_success_count"] + assert dumped["point_total_count"] == summary["point_total_count"] + assert dumped["per_platform"] == summary["per_platform"] + finally: + db.rollback() + db.close() + + +def test_step_writes_platform_success_to_session(client) -> None: + """/step 集成:pricebot 返 coupon_results → 本 trace 的 coupon_session.platform_success 落库(仅成功平台)。""" + from unittest.mock import MagicMock, patch + + import httpx + + from app.models.coupon_state import CouponClaimRecord, CouponDailyCompletion + + trace = "int-trace-1" + device = "dev-int-1" + + # 预置一条 started session(模拟 /session started 已先落库) + db = SessionLocal() + try: + db.add(CouponSession( + trace_id=trace, device_id=device, status="started", app_env="dev", + platforms=["meituan-waimai", "taobao-shanguang"], + started_at=datetime(2020, 1, 5, tzinfo=UTC), + started_date=date(2020, 1, 5), + )) + db.commit() + finally: + db.close() + + fake_resp = { + "success": True, + "action": {"command": "done", "params": { + "information": "已领 1 张", + "coupon_results": [ + {"coupon_id": "mt_x", "name": "美团券", "vendor": "meituan_internal", "status": "success"}, + {"coupon_id": "tb_y", "name": "淘宝券", "vendor": "taobao", "status": "failed"}, + ], + }}, + "continue": False, + } + + async def fake_post(self, url, **kw): + m = MagicMock() + m.status_code = 200 + m.json = lambda: fake_resp + return m + + body = { + "device_id": device, "trace_id": trace, "step": 5, + "screen_state": {"screen": {"width": 1080, "height": 2340, "density": 3.0}, + "foreground": {"package": "x", "activity": ""}, "windows": []}, + } + + try: + with patch.object(httpx.AsyncClient, "post", fake_post): + r = client.post("/api/v1/coupon/step", json=body) + assert r.status_code == 200, r.text + + db = SessionLocal() + try: + row = db.execute( + select(CouponSession).where(CouponSession.trace_id == trace) + ).scalar_one() + assert row.platform_success == ["meituan-waimai"] # tb 失败不计入 + claims = db.execute( + select(CouponClaimRecord).where(CouponClaimRecord.device_id == device) + ).scalars().all() + assert claims and all(c.app_env == "dev" for c in claims) # session app_env 打标 + finally: + db.close() + finally: + db = SessionLocal() + try: + db.execute(delete(CouponSession).where(CouponSession.trace_id == trace)) + db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.device_id == device)) + db.execute(delete(CouponDailyCompletion).where(CouponDailyCompletion.device_id == device)) + db.commit() + finally: + db.close() + + +def test_coupon_data_status_filter() -> None: + """状态多选过滤(方案 A):整个视图按选中状态算;None/空=全部。""" + db = SessionLocal() + try: + db.add_all([ + _agg_session("st-A", ["meituan-waimai"], ["meituan-waimai"], status="completed"), + _agg_session("st-B", ["meituan-waimai"], ["meituan-waimai"], status="started"), + _agg_session("st-C", ["meituan-waimai"], None, status="failed"), + _agg_session("st-D", ["meituan-waimai"], ["meituan-waimai"], status="abandoned"), + ]) + db.flush() + base = dict(date_from="2020-01-02", date_to="2020-01-02", app_env="prod") + # None = 全部 4 发起 + assert coupon_data_report(db, **base)["summary"]["started_count"] == 4 + # 排除 started → 3 发起(整个视图,发起数也随之变) + sub = coupon_data_report(db, **base, statuses=["completed", "failed", "abandoned"])["summary"] + assert sub["started_count"] == 3 + assert sub["completed_count"] == 1 + # 只 completed → 发起数=1、整单成功率基数=1(该 completed 是整单成功) + comp = coupon_data_report(db, **base, statuses=["completed"])["summary"] + assert comp["started_count"] == 1 + assert comp["full_success_rate"] == 1.0 + finally: + db.rollback() + db.close() diff --git a/tests/test_coupon_slots.py b/tests/test_coupon_slots.py new file mode 100644 index 0000000..b4f2a5b --- /dev/null +++ b/tests/test_coupon_slots.py @@ -0,0 +1,137 @@ +"""每券成功率(§13):app_env 落库 + coupon_slot_report 聚合。""" +from __future__ import annotations + +from datetime import UTC, date, datetime + +from sqlalchemy import delete, select + +from app.admin.repositories.coupon_data import coupon_slot_report +from app.db.session import SessionLocal +from app.models.coupon_state import CouponClaimRecord, CouponSession +from app.repositories.coupon_state import record_claims, session_app_env + + +def test_session_app_env_lookup() -> None: + db = SessionLocal() + trace = "slot-env-1" + try: + db.add(CouponSession( + trace_id=trace, device_id="d-slot", status="started", app_env="dev", + started_at=datetime(2020, 2, 1, tzinfo=UTC), started_date=date(2020, 2, 1), + )) + db.commit() + assert session_app_env(db, trace) == "dev" + assert session_app_env(db, "no-such-trace") is None + assert session_app_env(db, None) is None + finally: + db.execute(delete(CouponSession).where(CouponSession.trace_id == trace)) + db.commit() + db.close() + + +def test_record_claims_stamps_app_env() -> None: + db = SessionLocal() + dev = "d-slot-stamp" + try: + record_claims(db, dev, None, "t-stamp", + [{"coupon_id": "mt_x", "status": "success", "name": "美团券"}], + app_env="prod") + row = db.execute(select(CouponClaimRecord).where( + CouponClaimRecord.device_id == dev)).scalar_one() + assert row.app_env == "prod" + # UPDATE 路:app_env=None 不覆盖已有值 + record_claims(db, dev, None, "t-stamp", + [{"coupon_id": "mt_x", "status": "already_claimed", "name": "美团券"}], + app_env=None) + db.expire_all() + row = db.execute(select(CouponClaimRecord).where( + CouponClaimRecord.device_id == dev)).scalar_one() + assert row.app_env == "prod" + assert row.status == "already_claimed" + finally: + db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.device_id == dev)) + db.commit() + db.close() + + +def _claim(db, device, coupon_id, status, app_env, name=None, d=date(2020, 2, 2)): + db.add(CouponClaimRecord( + device_id=device, coupon_id=coupon_id, claim_date=d, + status=status, coupon_name=name, app_env=app_env, + )) + + +def test_coupon_slot_report_aggregates() -> None: + """按 coupon_id 聚合:tried=成功+失败(skipped 排除)、rate、env 过滤、tried 倒序。""" + db = SessionLocal() + try: + # mt_a(prod):dev1 success + dev2 already_claimed + dev3 failed → 2/3;dev4 skipped 不计 + _claim(db, "sdev1", "mt_a", "success", "prod", "美团外卖红包天天领") + _claim(db, "sdev2", "mt_a", "already_claimed", "prod", "美团外卖红包天天领") + _claim(db, "sdev3", "mt_a", "failed", "prod", "美团外卖红包天天领") + _claim(db, "sdev4", "mt_a", "skipped", "prod", "美团外卖红包天天领") + _claim(db, "sdev1", "tb_b", "success", "prod", "淘宝券") # 1/1 + _claim(db, "sdev9", "mt_a", "failed", "dev", "美团外卖红包天天领") # 仅 dev/全部 + db.commit() + + prod = coupon_slot_report( + db, date_from="2020-02-02", date_to="2020-02-02", app_env="prod" + )["items"] + by_id = {r["coupon_id"]: r for r in prod} + assert by_id["mt_a"]["tried"] == 3 # skipped 排除 + assert by_id["mt_a"]["succeeded"] == 2 + assert by_id["mt_a"]["success_rate"] == round(2 / 3, 4) + assert by_id["mt_a"]["platform"] == "meituan-waimai" + assert by_id["mt_a"]["coupon_name"] == "美团外卖红包天天领" + assert by_id["tb_b"]["success_rate"] == 1.0 + assert by_id["tb_b"]["platform"] == "taobao-shanguang" + assert [r["coupon_id"] for r in prod] == ["mt_a", "tb_b"] # tried 倒序 + + dev = coupon_slot_report( + db, date_from="2020-02-02", date_to="2020-02-02", app_env="dev" + )["items"] + assert {r["coupon_id"]: r["tried"] for r in dev} == {"mt_a": 1} + allenv = coupon_slot_report( + db, date_from="2020-02-02", date_to="2020-02-02", app_env=None + )["items"] + assert {r["coupon_id"]: r["tried"] for r in allenv}["mt_a"] == 4 + finally: + db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.claim_date == date(2020, 2, 2))) + db.commit() + db.close() + + +def test_coupon_slots_endpoint() -> None: + from fastapi.testclient import TestClient + + from app.admin.main import admin_app + from app.admin.repositories import admin_user as admin_repo + from app.admin.security import create_admin_token + + db = SessionLocal() + try: + _claim(db, "epdev1", "mt_ep", "success", "prod", "端点券", d=date(2020, 2, 3)) + _claim(db, "epdev2", "mt_ep", "failed", "prod", "端点券", d=date(2020, 2, 3)) + admin = admin_repo.create_admin( + db, username="slot_admin", password="pass1234", role="super_admin" + ) + token, _exp = create_admin_token(admin_id=admin.id, role=admin.role) + db.commit() + finally: + db.close() + try: + c = TestClient(admin_app) + r = c.get( + "/admin/api/coupon-data/coupons", + params={"date_from": "2020-02-03", "date_to": "2020-02-03", "app_env": "prod"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert r.status_code == 200, r.text + row = next(x for x in r.json()["items"] if x["coupon_id"] == "mt_ep") + assert row["tried"] == 2 and row["succeeded"] == 1 and row["success_rate"] == 0.5 + assert row["coupon_name"] == "端点券" and row["platform"] == "meituan-waimai" + finally: + db = SessionLocal() + db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.claim_date == date(2020, 2, 3))) + db.commit() + db.close() From 3630fb7b3a872b0117b762ebfc69cba6006c8980 Mon Sep 17 00:00:00 2001 From: liujiahui Date: Thu, 9 Jul 2026 22:03:07 +0800 Subject: [PATCH 15/24] =?UTF-8?q?=E6=8F=90=E7=8E=B0=E6=A1=A3=E4=BD=8D?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E6=9D=83=E5=A8=81=E5=8C=96:tiers=E4=B8=8B?= =?UTF-8?q?=E5=8F=91+=E6=AF=8F=E6=97=A5=E9=99=90=E6=AC=A1/=E9=80=89?= =?UTF-8?q?=E4=B8=80=E9=A2=9D=E5=BA=A6+=E4=B8=8B=E5=8D=95=E6=A1=A3?= =?UTF-8?q?=E4=BD=8D=E9=97=B8(7-9)=20(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 提现档位后端权威化(7-9,配套 android 同名分支 PR) ### 规则(2026-07-09 与产品逐条拍板) - 档位硬编码 `rewards.WITHDRAW_TIERS_COIN_CASH`:0.1/0.3(新人,历史一次性)+ 0.5(日3次)/10/20(日1次) - 计次口径「发起就算」:当天创建的单不论最终状态(含被拒/失败)都占名额 - 新人档:任意状态发起过即永久消失;两档独立同天可各提一次;不参与「每日选一个额度」互斥 - 常规三档每天只能选一个;invite_cash 无档位概念(tiers 空、下单不走档位闸,邀请页行为不变) - 「今天」= 北京时 cn_today();计次与 admin 看板同口径(Beijing 0点转 UTC 比较 created_at) ### 改动 - `GET /wallet/withdraw-info` 新增 `source` 参数 + 响应 `tiers[]`(amount/label/badge/available/disabled_reason/remaining_today) - `create_withdraw` 加档位闸:coin_cash 仅可提预设档位且该档可提,否则 400/409(防绕过客户端刷);放在幂等返回/在途互斥之后,不破坏同号重试 - 0.01 调试直发(allow_sub_min)不受档位约束,保持原样 ### 测试 - 新增 `tests/test_withdraw_tiers.py` 6 项全过(档位下发/新人独立+一次性/选一额度/次数耗尽/非档位金额拒绝/invite 不受影响) - 全量回归 305 过;5 项失败为 main 既有(coupon_proxy/invite_compare_reward,stash 验证与本 PR 无关) - 3 处旧测试的 coin_cash 金额从 100/200 调整为合法档位 50 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: no_gen_mu Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/129 Co-authored-by: liujiahui Co-committed-by: liujiahui --- app/api/v1/wallet.py | 16 ++- app/core/rewards.py | 25 ++++ app/repositories/wallet.py | 102 ++++++++++++++- app/schemas/welfare.py | 19 +++ tests/test_invite_cash_withdraw.py | 8 +- tests/test_withdraw_ledger_check.py | 3 +- tests/test_withdraw_tiers.py | 186 ++++++++++++++++++++++++++++ 7 files changed, 352 insertions(+), 7 deletions(-) create mode 100644 tests/test_withdraw_tiers.py diff --git a/app/api/v1/wallet.py b/app/api/v1/wallet.py index 29a0407..454ff7c 100644 --- a/app/api/v1/wallet.py +++ b/app/api/v1/wallet.py @@ -43,6 +43,7 @@ from app.schemas.welfare import ( WithdrawRequest, WithdrawResultOut, WithdrawStatusOut, + WithdrawTierOut, ) logger = logging.getLogger("shagua.wallet") @@ -173,8 +174,15 @@ def unbind_wechat( return UnbindWechatResultOut(bound=False) -@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关") -def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut: +@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关/档位") +def withdraw_info( + user: CurrentUser, + db: DbSession, + source: str = Query( + "coin_cash", + description="提现账户:coin_cash(福利页,下发 tiers 档位) / invite_cash(邀请页,tiers 为空走旧逻辑)", + ), +) -> WithdrawInfoOut: u = db.get(User, user.id) # 顺带同步免确认授权状态(捕获首单确认后已生效的授权 pending→active),让开关展示实时 auth = crud_wallet.sync_transfer_auth(db, user.id) @@ -185,6 +193,7 @@ def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut: wechat_nickname=u.wechat_nickname if u else None, wechat_avatar_url=u.wechat_avatar_url if u else None, transfer_auth_enabled=bool(auth and auth.state == "active"), + tiers=[WithdrawTierOut(**t) for t in crud_wallet.withdraw_tier_states(db, user.id, source)], ) @@ -218,6 +227,9 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw status_code=status.HTTP_409_CONFLICT, detail="已有提现申请正在审核或打款中,请处理完成后再申请", ) from e + except crud_wallet.WithdrawTierUnavailableError as e: + # 福利页档位闸(7-9):次数满/已选其他额度。正常客户端已按 tiers 预拦,此处兜底防绕过。 + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="今日额度已达上限") from e except crud_wallet.InsufficientCashError as e: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="现金余额不足") from e diff --git a/app/core/rewards.py b/app/core/rewards.py index d931db4..eccdc25 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -6,6 +6,7 @@ from __future__ import annotations from datetime import date, datetime, timedelta, timezone +from typing import NamedTuple # 业务时区:签到的"今天"按北京时间算,不能用 UTC。 # 否则 UTC+8 的凌晨 0~8 点会被算成 UTC 的前一天,导致签到日期错位。 @@ -52,6 +53,30 @@ WITHDRAW_MIN_CENTS: int = 10 WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元 +# ===== 提现档位(福利页 coin_cash;7-9 对齐原型 withdrawal.html)===== +# 后端是档位唯一真相源:withdraw-info 按此下发,create_withdraw 按此校验(防绕过客户端刷)。 +# 规则(2026-07-09 拍板): +# - 新人档(is_newbie):账号历史一次性,"发起就算用过"(任意状态含被拒),用过即不再下发; +# 0.1 与 0.3 各自独立同天可各提一次,且不参与常规档"每日选一个额度"互斥。 +# - 常规档:按北京日计次(0.5×3 / 10×1 / 20×1),三档每天只能选一个。 +# invite_cash(邀请页)本轮无档位概念,不在此表。改档位=改这里发版。 +class WithdrawTier(NamedTuple): + amount_cents: int + label: str # 客户端档位方块展示文案 + badge: str | None # 角标文案;None=无角标 + daily_limit: int # 每日次数上限(新人档的"历史一次性"另由 is_newbie 判定) + is_newbie: bool + + +WITHDRAW_TIERS_COIN_CASH: tuple[WithdrawTier, ...] = ( + WithdrawTier(10, "0.1", "新人福利", 1, True), + WithdrawTier(30, "0.3", "新人福利", 1, True), + WithdrawTier(50, "0.5", None, 3, False), + WithdrawTier(1000, "10", None, 1, False), + WithdrawTier(2000, "20", None, 1, False), +) + + # ===== 一次性任务(领一次,user_task 去重)===== TASK_ENABLE_NOTIFICATION = "enable_notification" diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index 345cda7..fe1e23e 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -11,7 +11,7 @@ import unicodedata import uuid from datetime import datetime, timedelta, timezone -from sqlalchemy import select, update +from sqlalchemy import func, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -67,6 +67,10 @@ class WithdrawTooFrequentError(Exception): """提现申请过于频繁,或已有未完成提现单。""" +class WithdrawTierUnavailableError(Exception): + """该档位今日不可提:次数已满,或今天已选了其他额度(7-9 福利页档位规则)。""" + + class WithdrawTransferError(Exception): """调用微信转账失败(已退回余额)。""" @@ -605,6 +609,89 @@ def _settle_after_ambiguous(db: Session, order: WithdrawOrder, reason: str) -> N db.commit() +def _beijing_today_start_utc() -> datetime: + """北京时今日 0 点(转 UTC)。WithdrawOrder.created_at 是 func.now()(UTC)存储, + 比较时统一转 UTC,与 admin 看板 today_start 同口径(admin/repositories/queries.py)。""" + return ( + datetime.now(rewards.CN_TZ) + .replace(hour=0, minute=0, second=0, microsecond=0) + .astimezone(timezone.utc) + ) + + +def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -> list[dict]: + """福利页(coin_cash)提现档位的可提现状态。withdraw-info 下发与 create_withdraw 校验共用此口径。 + + 规则(2026-07-09 拍板,7-9提现ui对齐): + - 新人档(0.1/0.3):账号历史一次性——只要发起过(**任意状态**,含被拒/失败,"发起就算") + 即视为已用,直接**从返回列表消失**;两档各自独立互不影响,不参与"每日选一个额度"互斥。 + - 常规档(0.5×3 / 10×1 / 20×1):按北京日计次,"发起就算占用"(当天创建的单不论最终状态 + 都计入,被拒/失败不退当天名额);三档每天只能选一个,选定后其余两档当天 other_tier_selected。 + - invite_cash 本轮无档位概念 → 返回空列表(邀请页客户端仍用本地写死档位,行为不变)。 + 余额是否足够由客户端本地判断(余额随兑换实时变化,不在此快照)。 + """ + if source != "coin_cash": + return [] + tiers = rewards.WITHDRAW_TIERS_COIN_CASH + amounts = [t.amount_cents for t in tiers] + newbie_amounts = [t.amount_cents for t in tiers if t.is_newbie] + # 新人档历史是否用过:任意时间、任意状态("发起就算") + used_newbie: set[int] = set( + db.execute( + select(WithdrawOrder.amount_cents) + .distinct() + .where( + WithdrawOrder.user_id == user_id, + WithdrawOrder.source == "coin_cash", + WithdrawOrder.amount_cents.in_(newbie_amounts), + ) + ).scalars() + ) if newbie_amounts else set() + # 今日(北京日)每档已发起次数(任意状态) + today_counts: dict[int, int] = { + int(amount): int(cnt) + for amount, cnt in db.execute( + select(WithdrawOrder.amount_cents, func.count(WithdrawOrder.id)) + .where( + WithdrawOrder.user_id == user_id, + WithdrawOrder.source == "coin_cash", + WithdrawOrder.amount_cents.in_(amounts), + WithdrawOrder.created_at >= _beijing_today_start_utc(), + ) + .group_by(WithdrawOrder.amount_cents) + ) + } + # "每日选一个额度":今天发起过的常规档(新人档不算) + selected_regular = next( + (t.amount_cents for t in tiers if not t.is_newbie and today_counts.get(t.amount_cents, 0) > 0), + None, + ) + out: list[dict] = [] + for t in tiers: + if t.is_newbie: + if t.amount_cents in used_newbie: + continue # 用过即消失,不再下发 + out.append({ + "amount_cents": t.amount_cents, "label": t.label, "badge": t.badge, + "is_newbie": True, "available": True, "disabled_reason": None, + "remaining_today": 1, + }) + continue + used = today_counts.get(t.amount_cents, 0) + if selected_regular is not None and selected_regular != t.amount_cents: + available, reason, remaining = False, "other_tier_selected", 0 + elif used >= t.daily_limit: + available, reason, remaining = False, "quota_exhausted", 0 + else: + available, reason, remaining = True, None, t.daily_limit - used + out.append({ + "amount_cents": t.amount_cents, "label": t.label, "badge": t.badge, + "is_newbie": False, "available": available, "disabled_reason": reason, + "remaining_today": remaining, + }) + return out + + def create_withdraw( db: Session, user_id: int, @@ -660,6 +747,19 @@ def create_withdraw( if active_order_id is not None: raise WithdrawTooFrequentError + # 福利页档位闸(7-9):coin_cash 只能提预设档位,且该档今日可提(服务端权威口径,防绕过 + # 客户端刷)。放在幂等返回/在途互斥之后:同号重试仍原样返回旧单,不被档位闸误杀。 + # allow_sub_min(0.01 调试直发)保持原样放行,不受档位约束;invite_cash 本轮无档位概念不校验。 + if source == "coin_cash" and not allow_sub_min: + tier_state = next( + (t for t in withdraw_tier_states(db, user_id, source) if t["amount_cents"] == amount_cents), + None, + ) + if tier_state is None: # 非预设档位金额,或新人档已用过(已从列表消失) + raise InvalidWithdrawAmountError + if not tier_state["available"]: + raise WithdrawTierUnavailableError + # 账户须存在(原子扣款的 UPDATE 不会建账户) get_or_create_account(db, user_id, commit=True) diff --git a/app/schemas/welfare.py b/app/schemas/welfare.py index df13be3..d6ba63a 100644 --- a/app/schemas/welfare.py +++ b/app/schemas/welfare.py @@ -75,6 +75,21 @@ class ExchangeResultOut(BaseModel): # ===== 提现(现金 → 微信零钱) ===== +class WithdrawTierOut(BaseModel): + """提现档位(福利页 coin_cash;7-9 对齐原型)。served by rewards.WITHDRAW_TIERS_COIN_CASH。""" + + amount_cents: int = Field(..., description="档位金额(分)") + label: str = Field(..., description="档位方块展示文案,如 0.1 / 10") + badge: str | None = Field(None, description="角标文案(如 新人福利);无则空") + is_newbie: bool = Field(False, description="新人档:历史一次性,用过后不再下发;免广告直提") + available: bool = Field(True, description="当前是否可提(次数/选一额度口径;余额由客户端自判)") + disabled_reason: str | None = Field( + None, + description="不可提原因:quota_exhausted(今日次数满) / other_tier_selected(今日已选其他额度)", + ) + remaining_today: int = Field(0, description="今日剩余可提次数") + + class WithdrawInfoOut(BaseModel): min_cents: int = Field(..., description="单次最低提现(分)") max_cents: int = Field(..., description="单次最高提现(分)") @@ -84,6 +99,10 @@ class WithdrawInfoOut(BaseModel): transfer_auth_enabled: bool = Field( False, description="是否已开启免确认到账(开启后提现免跳微信确认,直接到账)" ) + tiers: list[WithdrawTierOut] = Field( + default_factory=list, + description="提现档位(source=coin_cash 下发;invite_cash 为空,客户端走旧逻辑)", + ) # ===== 免确认收款授权(用户授权免确认模式)===== diff --git a/tests/test_invite_cash_withdraw.py b/tests/test_invite_cash_withdraw.py index 26a2d7d..5b83110 100644 --- a/tests/test_invite_cash_withdraw.py +++ b/tests/test_invite_cash_withdraw.py @@ -143,14 +143,15 @@ def test_two_accounts_withdraw_independent(client, monkeypatch) -> None: _reject(r1.json()["out_bill_no"]) # 退回 invite_cash + 结清活跃单 r2 = client.post( "/api/v1/wallet/withdraw", - json={"amount_cents": 100, "source": "coin_cash"}, + # 50 分 = 0.5 元档(7-9 起 coin_cash 只能提预设档位) + json={"amount_cents": 50, "source": "coin_cash"}, headers=_auth(token), ) assert r2.json()["status"] == "reviewing" cash, invite_cash = _balances(client, token) assert invite_cash == 500 # 已退回 - assert cash == 300 # 扣了 cash 100 + assert cash == 350 # 扣了 cash 50 def test_invite_me_returns_reward_stats(client) -> None: @@ -177,7 +178,8 @@ def test_withdraw_orders_source_filter(client, monkeypatch) -> None: _reject(r1.json()["out_bill_no"]) # 结清,才能提第二笔 client.post( "/api/v1/wallet/withdraw", - json={"amount_cents": 100, "source": "coin_cash"}, + # 50 分 = 0.5 元档(7-9 起 coin_cash 只能提预设档位) + json={"amount_cents": 50, "source": "coin_cash"}, headers=_auth(token), ) diff --git a/tests/test_withdraw_ledger_check.py b/tests/test_withdraw_ledger_check.py index f74206a..a9054d9 100644 --- a/tests/test_withdraw_ledger_check.py +++ b/tests/test_withdraw_ledger_check.py @@ -139,7 +139,8 @@ def test_coin_cash_withdraw_still_reconciled(client, monkeypatch) -> None: before = _ledger() r = client.post( "/api/v1/wallet/withdraw", - json={"amount_cents": 200, "source": "coin_cash"}, + # 50 分 = 0.5 元档(7-9 起 coin_cash 只能提预设档位) + json={"amount_cents": 50, "source": "coin_cash"}, headers=_auth(token), ) assert r.status_code == 200, r.text diff --git a/tests/test_withdraw_tiers.py b/tests/test_withdraw_tiers.py new file mode 100644 index 0000000..73e8de9 --- /dev/null +++ b/tests/test_withdraw_tiers.py @@ -0,0 +1,186 @@ +"""福利页(coin_cash)提现档位规则测试(7-9提现ui对齐)。 + +规则(2026-07-09 拍板): + - 档位 0.1/0.3(新人,历史一次性,免广告)+ 0.5(日3次)/10/20(日1次)。 + - 计次口径"发起就算":当天创建的单不论最终状态(含被拒)都占名额;新人档任何状态都算用过。 + - 常规三档每天只能选一个;新人档不参与该互斥,两个新人档同天可各提一次。 + - invite_cash 无档位概念:tiers 为空、下单不走档位闸(邀请页行为不变)。 +wxpay 调用全部 monkeypatch;现金余额 DB 直灌(同 test_withdraw.py 套路)。 +""" +from __future__ import annotations + +from sqlalchemy import select + +from app.db.session import SessionLocal +from app.models.user import User +from app.models.wallet import CoinAccount +from app.repositories import wallet as crud_wallet + + +def _login(client, phone: str) -> str: + client.post("/api/v1/auth/sms/send", json={"phone": phone}) + r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"}) + assert r.status_code == 200, r.text + return r.json()["access_token"] + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _seed_balances(client, token: str, phone: str, cash: int = 0, invite_cash: int = 0) -> None: + client.get("/api/v1/wallet/account", headers=_auth(token)) + db = SessionLocal() + try: + user = db.execute(select(User).where(User.phone == phone)).scalar_one() + acc = db.get(CoinAccount, user.id) + acc.cash_balance_cents = cash + acc.invite_cash_balance_cents = invite_cash + db.commit() + finally: + db.close() + + +def _patch_userinfo(monkeypatch, openid: str) -> None: + monkeypatch.setattr( + "app.integrations.wxpay.code_to_userinfo", + lambda code: {"openid": openid, "nickname": "昵称", "avatar_url": None, "raw": {}}, + ) + + +def _reject(bill: str) -> None: + """管理员拒绝:退款结清活跃单(便于同用户继续发起下一笔;名额按'发起就算'仍占)。""" + db = SessionLocal() + try: + crud_wallet.reject_withdraw(db, bill, "test") + finally: + db.close() + + +def _withdraw(client, token: str, cents: int): + return client.post( + "/api/v1/wallet/withdraw", + json={"amount_cents": cents, "source": "coin_cash"}, + headers=_auth(token), + ) + + +def _tiers(client, token: str, source: str = "coin_cash") -> list[dict]: + r = client.get( + "/api/v1/wallet/withdraw-info", params={"source": source}, headers=_auth(token) + ) + assert r.status_code == 200, r.text + return r.json()["tiers"] + + +def test_withdraw_info_tiers_full_and_invite_empty(client, monkeypatch) -> None: + """新用户 coin_cash 下发 5 档(新人角标齐);invite_cash tiers 为空。""" + token = _login(client, "13800006001") + tiers = _tiers(client, token) + assert [t["amount_cents"] for t in tiers] == [10, 30, 50, 1000, 2000] + assert [t["label"] for t in tiers] == ["0.1", "0.3", "0.5", "10", "20"] + assert tiers[0]["badge"] == "新人福利" and tiers[0]["is_newbie"] is True + assert tiers[1]["badge"] == "新人福利" and tiers[1]["is_newbie"] is True + assert all(t["available"] for t in tiers) + assert tiers[2]["remaining_today"] == 3 # 0.5 日 3 次 + assert _tiers(client, token, source="invite_cash") == [] + + +def test_newbie_tiers_independent_and_once_forever(client, monkeypatch) -> None: + """0.1 提过(即使被拒)→ 永久消失;同天 0.3 仍可提;新人档不锁常规档。""" + _patch_userinfo(monkeypatch, "openid_tier_2") + token = _login(client, "13800006002") + _seed_balances(client, token, "13800006002", cash=5000) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + + r = _withdraw(client, token, 10) # 0.1 新人档 + assert r.status_code == 200, r.text + _reject(r.json()["out_bill_no"]) # 被拒也算用过("发起就算") + + tiers = _tiers(client, token) + amounts = [t["amount_cents"] for t in tiers] + assert 10 not in amounts # 0.1 消失 + assert 30 in amounts # 0.3 还在,同天仍可提 + # 新人档不参与"选一个额度":常规三档全部仍可提 + regular = {t["amount_cents"]: t for t in tiers if not t["is_newbie"]} + assert all(regular[a]["available"] for a in (50, 1000, 2000)) + + r = _withdraw(client, token, 30) # 同天 0.3 照提 + assert r.status_code == 200, r.text + _reject(r.json()["out_bill_no"]) + + # 0.1 已用过,重提 → 非法金额(档位已消失) + r = _withdraw(client, token, 10) + assert r.status_code == 400, r.text + + +def test_regular_daily_select_one_tier(client, monkeypatch) -> None: + """当天提过 0.5 → 10/20 置灰 other_tier_selected,下单 409;0.5 还能继续提(3 次内)。""" + _patch_userinfo(monkeypatch, "openid_tier_3") + token = _login(client, "13800006003") + _seed_balances(client, token, "13800006003", cash=10_000) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + + r = _withdraw(client, token, 50) + assert r.status_code == 200, r.text + _reject(r.json()["out_bill_no"]) # 结清活跃单;当天名额仍占("发起就算") + + tiers = {t["amount_cents"]: t for t in _tiers(client, token)} + assert tiers[50]["available"] and tiers[50]["remaining_today"] == 2 + assert not tiers[1000]["available"] and tiers[1000]["disabled_reason"] == "other_tier_selected" + assert not tiers[2000]["available"] and tiers[2000]["disabled_reason"] == "other_tier_selected" + + r = _withdraw(client, token, 1000) # 选一额度互斥 → 409 + assert r.status_code == 409, r.text + assert "今日额度已达上限" in r.json()["detail"] + + r = _withdraw(client, token, 50) # 0.5 第 2 次照常 + assert r.status_code == 200, r.text + + +def test_regular_daily_quota_exhausted(client, monkeypatch) -> None: + """0.5 日 3 次:第 4 次 409;tiers 显示 quota_exhausted。""" + _patch_userinfo(monkeypatch, "openid_tier_4") + token = _login(client, "13800006004") + _seed_balances(client, token, "13800006004", cash=10_000) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + + for _ in range(3): + r = _withdraw(client, token, 50) + assert r.status_code == 200, r.text + _reject(r.json()["out_bill_no"]) + + tiers = {t["amount_cents"]: t for t in _tiers(client, token)} + assert not tiers[50]["available"] + assert tiers[50]["disabled_reason"] == "quota_exhausted" + assert tiers[50]["remaining_today"] == 0 + + r = _withdraw(client, token, 50) + assert r.status_code == 409, r.text + + +def test_non_tier_amount_rejected_for_coin_cash(client, monkeypatch) -> None: + """coin_cash 只能提预设档位:任意其他金额 400(防绕过客户端刷)。""" + _patch_userinfo(monkeypatch, "openid_tier_5") + token = _login(client, "13800006005") + _seed_balances(client, token, "13800006005", cash=10_000) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + + r = _withdraw(client, token, 123) + assert r.status_code == 400, r.text + + +def test_invite_cash_not_gated_by_tiers(client, monkeypatch) -> None: + """invite_cash 不走档位闸:非档位金额(200=2元)照常下单——邀请页行为不变。""" + _patch_userinfo(monkeypatch, "openid_tier_6") + token = _login(client, "13800006006") + _seed_balances(client, token, "13800006006", invite_cash=500) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + + r = client.post( + "/api/v1/wallet/withdraw", + json={"amount_cents": 200, "source": "invite_cash"}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + assert r.json()["status"] == "reviewing" From 285e46ebafbedae4123dabf944b1025cf48c0a61 Mon Sep 17 00:00:00 2001 From: marco Date: Fri, 10 Jul 2026 19:23:12 +0800 Subject: [PATCH 16/24] =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../versions/merge_selfstat_coupon_slot.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 alembic/versions/merge_selfstat_coupon_slot.py diff --git a/alembic/versions/merge_selfstat_coupon_slot.py b/alembic/versions/merge_selfstat_coupon_slot.py new file mode 100644 index 0000000..fbebd37 --- /dev/null +++ b/alembic/versions/merge_selfstat_coupon_slot.py @@ -0,0 +1,29 @@ +"""合并两个 alembic head:11c44afbea58(#127 埋点健康度 selfstat)+ merge_pages_override_coupon_slot(#130 自带的合并迁移)。 + +三条分支都从 admin_user_plain_password 分叉(#126 权限 / #127 selfstat / #130 领券成功率)。 +#130 自带的 merge 创建时本地 main 尚无 #127 的 11c44afbea58,只收敛了 #126 + 自身两条, +#130 合入后 main 上仍留两个 head → `alembic upgrade head`(单数,部署/run.sh 用)直接报错、服务起不来。 +本迁移仅把二者收敛成单 head;**不含任何表结构 / 数据改动**(纯 merge)。 + +Revision ID: merge_selfstat_coupon_slot +Revises: 11c44afbea58, merge_pages_override_coupon_slot +Create Date: 2026-07-10 00:00:00.000000 +""" + +from collections.abc import Sequence + +revision: str = "merge_selfstat_coupon_slot" +down_revision: str | Sequence[str] | None = ( + "11c44afbea58", + "merge_pages_override_coupon_slot", +) +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """纯合并 head,无 schema 改动。""" + + +def downgrade() -> None: + """拆回两个 head,无 schema 改动。""" From 930eff822c2a93d62b2ad1288a30149ab5a15edb Mon Sep 17 00:00:00 2001 From: guke Date: Fri, 10 Jul 2026 22:14:00 +0800 Subject: [PATCH 17/24] =?UTF-8?q?feat(ad-revenue):=20=E9=A2=86=E5=88=B8/?= =?UTF-8?q?=E6=AF=94=E4=BB=B7=E7=9C=8B=E6=9D=BF=E9=80=90=E6=AC=A1=E5=B9=BF?= =?UTF-8?q?=E5=91=8A=E6=94=B6=E7=9B=8A(ad=5Fecpm.trace=5Fid=20+=20?= =?UTF-8?q?=E9=80=90=E9=A1=B5=E8=81=9A=E5=90=88)=20(#131)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景 admin「领券数据」「比价记录」两个看板此前只能看到场景级(所有领券/比价)的广告收益, 无法定位「这一次领券/比价具体赚了多少」。根因:收益表 `ad_ecpm_record` 缺 `trace_id`, 无法与领券会话 / 比价记录按 trace 关联。 ## 改动 - **模型/迁移**:`ad_ecpm_record` 新增 `trace_id`(String(64), index, nullable); 迁移 `ad_ecpm_trace_id` 加列 + 索引 `ix_ad_ecpm_record_trace_id`,并**收敛当前两个 alembic head**(`11c44afbea58` selfstat + `merge_pages_override_coupon_slot`)为单 head。 - **上报链路**:`EcpmReportIn` 增 `trace_id` 字段;`/api/v1/ad/ecpm-report` 透传; `create_ecpm_record` 落库。 - **收益聚合**:新增 `revenue_yuan_by_trace(db, trace_ids)`——按 trace_id 聚合展示收益, 单条 = `min(eCPM元, ¥500钳顶)/1000`,与广告收益报表 `ad_revenue.py` **同口径**; 只吃当前页的 trace_id(逐页批量,索引命中,无 N+1)。 - **两个看板**:`CouponDataRow` / `AdminComparisonListItem` 增 `ad_revenue_yuan`; `coupon_data_report`(主表 + 用户抽屉)与 `list_comparison_records` 分页后逐页补该字段。 - **测试**:`tests/test_ad_ecpm_trace_revenue.py`(聚合/钳顶/落库)、 `tests/test_board_ad_revenue.py`(两看板 + 抽屉)。 --------- Co-authored-by: guke Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/131 --- alembic/versions/ad_ecpm_trace_id.py | 45 + app/admin/repositories/coupon_data.py | 13 +- app/admin/repositories/queries.py | 6 + app/admin/schemas/comparison.py | 1 + app/admin/schemas/coupon_data.py | 3 + app/api/v1/ad.py | 1 + app/models/ad_ecpm.py | 3 + app/repositories/ad_ecpm.py | 27 + app/schemas/ad.py | 6 + .../2026-07-10-per-session-ad-revenue.md | 830 ++++++++++++++++++ tests/test_ad_ecpm_trace_revenue.py | 73 ++ tests/test_board_ad_revenue.py | 87 ++ 12 files changed, 1092 insertions(+), 3 deletions(-) create mode 100644 alembic/versions/ad_ecpm_trace_id.py create mode 100644 docs/superpowers/plans/2026-07-10-per-session-ad-revenue.md create mode 100644 tests/test_ad_ecpm_trace_revenue.py create mode 100644 tests/test_board_ad_revenue.py diff --git a/alembic/versions/ad_ecpm_trace_id.py b/alembic/versions/ad_ecpm_trace_id.py new file mode 100644 index 0000000..ba501f0 --- /dev/null +++ b/alembic/versions/ad_ecpm_trace_id.py @@ -0,0 +1,45 @@ +"""ad_ecpm_record.trace_id(展示收益归属到比价/领券 trace)+ 收敛双 head + +信息流(Draw)展示 eCPM 上报时带上本场比价/领券 trace_id,落此列;领券数据 / 比价记录看板 +按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端为 NULL。 + +顺带把当前两个 head(11c44afbea58 selfstat 表 + merge_pages_override_coupon_slot)收敛成 +单 head,让 `alembic upgrade head`(单数,部署/run.sh 用)恢复正常。 + +Revision ID: ad_ecpm_trace_id +Revises: 11c44afbea58, merge_pages_override_coupon_slot +Create Date: 2026-07-10 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "ad_ecpm_trace_id" +down_revision: Union[str, Sequence[str], None] = ( + "11c44afbea58", + "merge_pages_override_coupon_slot", +) +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # SQLite 下 ADD COLUMN(可空)与 CREATE INDEX 均原生支持,无需 batch_alter_table + # (同 ad_feed_reward_trace_id 迁移)。 + op.add_column( + "ad_ecpm_record", + sa.Column("trace_id", sa.String(length=64), nullable=True), + ) + op.create_index( + op.f("ix_ad_ecpm_record_trace_id"), + "ad_ecpm_record", + ["trace_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_ad_ecpm_record_trace_id"), table_name="ad_ecpm_record") + op.drop_column("ad_ecpm_record", "trace_id") diff --git a/app/admin/repositories/coupon_data.py b/app/admin/repositories/coupon_data.py index a290df1..0450404 100644 --- a/app/admin/repositories/coupon_data.py +++ b/app/admin/repositories/coupon_data.py @@ -18,6 +18,7 @@ from sqlalchemy.orm import Session from app.core import rewards from app.models.coupon_state import CouponClaimRecord, CouponSession from app.models.user import User +from app.repositories import ad_ecpm as crud_ecpm from app.repositories.coupon_state import DEFAULT_PLATFORMS, coupon_id_to_platform @@ -85,7 +86,7 @@ def _success_rates(rows: list) -> dict: } -def _session_to_row(r, phone: str | None = None, nickname: str | None = None) -> dict: +def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad_revenue_yuan: float = 0.0) -> dict: """CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。""" return { "id": r.id, @@ -104,6 +105,7 @@ def _session_to_row(r, phone: str | None = None, nickname: str | None = None) -> "started_at": r.started_at, "claimed_count": r.claimed_count, "trace_url": r.trace_url, + "ad_revenue_yuan": ad_revenue_yuan, } @@ -246,10 +248,11 @@ def coupon_data_report( select(User.id, User.phone, User.nickname).where(User.id.in_(uids)) ).all() } + rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in page]) items = [] for r in page: phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None) - items.append(_session_to_row(r, phone, nickname)) + items.append(_session_to_row(r, phone, nickname, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0))) return { "summary": summary, @@ -271,7 +274,11 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict: total = db.execute( select(func.count()).select_from(CouponSession).where(CouponSession.user_id == user_id) ).scalar_one() - return {"items": [_session_to_row(r) for r in rows], "total": int(total)} + rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows]) + return { + "items": [_session_to_row(r, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)) for r in rows], + "total": int(total), + } _SLOT_OK = ("success", "already_claimed") diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index 1142460..501dba9 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -32,6 +32,7 @@ from app.models.wallet import ( InviteCashTransaction, WithdrawOrder, ) +from app.repositories import ad_ecpm # 「最近活跃」计入的行为事件(与大盘 DAU/留存活跃口径一致:开始比价 + 开始领券) _ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT) @@ -298,6 +299,11 @@ def list_comparison_records( limit=limit, cursor=cursor, ) _attach_user_info(db, items) + # 「本次比价看广告的预估收益」:按本页 trace_id 一次性聚合(同 _attach_user_info 逐页范式)。 + # ad_revenue_yuan 非 ORM 列,仅瞬态挂实例上供 AdminComparisonListItem(from_attributes)读出。 + rev = ad_ecpm.revenue_yuan_by_trace(db, [it.trace_id for it in items]) + for it in items: + it.ad_revenue_yuan = rev.get(it.trace_id, 0.0) return items, next_cursor, total diff --git a/app/admin/schemas/comparison.py b/app/admin/schemas/comparison.py index 5d4155c..3a5ae19 100644 --- a/app/admin/schemas/comparison.py +++ b/app/admin/schemas/comparison.py @@ -41,6 +41,7 @@ class AdminComparisonListItem(BaseModel): rom_name: str | None = None android_version: str | None = None app_version: str | None = None + ad_revenue_yuan: float = 0.0 # 本次比价看的信息流广告预估收益(元),queries 瞬态挂 ORM 实例上 created_at: datetime diff --git a/app/admin/schemas/coupon_data.py b/app/admin/schemas/coupon_data.py index ec4c56e..c560397 100644 --- a/app/admin/schemas/coupon_data.py +++ b/app/admin/schemas/coupon_data.py @@ -70,6 +70,9 @@ class CouponDataRow(BaseModel): started_at: datetime = Field(..., description="发起时刻(明细「时间」列)") claimed_count: int | None = None trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id") + ad_revenue_yuan: float = Field( + 0.0, description="本次领券看的信息流广告预估收益(元);按 trace_id 聚合 ad_ecpm_record" + ) class CouponDataOut(BaseModel): diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index 2badd23..3b9cbaf 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -286,6 +286,7 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm ad_session_id=payload.ad_session_id, adn=payload.adn, slot_id=payload.slot_id, feed_scene=payload.feed_scene, + trace_id=payload.trace_id, app_env=payload.app_env, our_code_id=payload.our_code_id, ) logger.info( diff --git a/app/models/ad_ecpm.py b/app/models/ad_ecpm.py index a766210..d7c5b64 100644 --- a/app/models/ad_ecpm.py +++ b/app/models/ad_ecpm.py @@ -32,6 +32,9 @@ class AdEcpmRecord(Base): # 点位场景:comparison(比价) / coupon(领券) / welfare(福利),供收益报表区分比价/领券 Draw 收益; # 仅信息流/Draw 上报(比价与领券共用同一代码位,只能客户端各调用点显式打标),激励视频为 NULL。 feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True) + # 本次比价/领券 trace_id(信息流场景客户端带上):把这条展示收益归属到对应比价/领券记录。 + # 领券数据 / 比价记录看板按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端 = NULL。 + trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) # 客户端生成的一次广告会话 id;激励视频 S2S 回调 extra 会透传同值 ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) # 实际投放的 ADN(穿山甲 getShowEcpm().getSdkName(),如 pangle / gdt) diff --git a/app/repositories/ad_ecpm.py b/app/repositories/ad_ecpm.py index 7ffa08a..c8a1fbe 100644 --- a/app/repositories/ad_ecpm.py +++ b/app/repositories/ad_ecpm.py @@ -10,6 +10,7 @@ from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from app.core import rewards from app.core.rewards import cn_today from app.models.ad_ecpm import AdEcpmRecord @@ -24,6 +25,7 @@ def create_ecpm_record( adn: str | None = None, slot_id: str | None = None, feed_scene: str | None = None, + trace_id: str | None = None, app_env: str | None = None, our_code_id: str | None = None, ) -> AdEcpmRecord: @@ -43,6 +45,7 @@ def create_ecpm_record( adn=adn, slot_id=slot_id, feed_scene=feed_scene, + trace_id=trace_id, app_env=app_env, our_code_id=our_code_id, ecpm_raw=ecpm_raw, @@ -105,3 +108,27 @@ def count_today(db: Session, user_id: int) -> int: AdEcpmRecord.report_date == cn_today().isoformat(), ) ).scalar_one() + + +def revenue_yuan_by_trace(db: Session, trace_ids: list[str]) -> dict[str, float]: + """各 trace_id 的广告预估收益(元):按 trace_id 聚合 ad_ecpm_record 的展示收益。 + + 单条展示收益 = min(eCPM元, AD_ECPM_MAX_FEN/100) / 1000(与 admin 广告收益报表同口径)。 + ecpm_raw 是字符串且需逐条钳顶,故取回后 Python 求和(行数=本页各 trace 的展示条数,很小)。 + trace_id 仅信息流(比价/领券)场景客户端带,激励视频/旧数据为 NULL,按 trace_id 过滤天然只算对应场景。 + 只喂**当前页**的 trace_id(≤ 一页条数);空集合直接返回(避免 IN () 非法)。 + """ + if not trace_ids: + return {} + rows = db.execute( + select(AdEcpmRecord.trace_id, AdEcpmRecord.ecpm_raw).where( + AdEcpmRecord.trace_id.in_(trace_ids), + ) + ).all() + cap_yuan = rewards.AD_ECPM_MAX_FEN / 100.0 + out: dict[str, float] = {} + for tid, ecpm_raw in rows: + if not tid: + continue + out[tid] = out.get(tid, 0.0) + min(rewards.parse_ecpm_yuan(ecpm_raw), cap_yuan) / 1000.0 + return {tid: round(v, 6) for tid, v in out.items()} diff --git a/app/schemas/ad.py b/app/schemas/ad.py index aa2ecc1..ceb74af 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -63,6 +63,12 @@ class EcpmReportIn(BaseModel): description="点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页);" "比价与领券共用同一 Draw 代码位,需客户端在各调用点显式标注,供收益报表区分比价/领券;激励视频为空", ) + trace_id: str | None = Field( + None, + max_length=64, + description="本次比价/领券 trace_id(信息流场景带上):把这条展示收益归属到对应比价/领券," + "供领券数据/比价记录看板聚合本场广告收益;激励视频/福利为空", + ) app_env: str | None = Field( None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)" ) diff --git a/docs/superpowers/plans/2026-07-10-per-session-ad-revenue.md b/docs/superpowers/plans/2026-07-10-per-session-ad-revenue.md new file mode 100644 index 0000000..2dd363f --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-per-session-ad-revenue.md @@ -0,0 +1,830 @@ +# 逐次比价/领券广告收益 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让 admin「领券数据」和「比价记录」两个看板的 table 每一行显示这一次领券/比价产生的广告收益(预估元)。 + +**Architecture:** 客户端在信息流(Draw)展示上报 eCPM 时带上本场 `trace_id`,后端落到 `ad_ecpm_record.trace_id`(新列 + 索引)。两个看板在分页后,对**当前页**的 trace_id 批量聚合一次 `ad_ecpm_record` 的展示收益(单条收益 = min(eCPM元,¥500)/1000,与广告收益报表同口径),挂到每行。查询是按 trace_id 索引的单条聚合,与已上线的 `_ad_coins_by_trace`(比价记录页「比价赚N金币」)同一性能剖面。 + +**Tech Stack:** 后端 FastAPI + SQLAlchemy 2.0 + Alembic;客户端 Android(Kotlin/OkHttp);admin 前端 Next.js + React + Ant Design(shaguabijia-admin-web)。 + +--- + +## 背景与约束(执行前必读) + +- **收益 ≠ 金币**。本功能查的是「我们赚的广告收益」(数据源 `ad_ecpm_record`,客户端自报 eCPM 折算的预估),不是发给用户的金币(那是 `ad_feed_reward_record`,已有 `trace_id`)。 +- **只能到「场景 + 单次」粒度**。`trace_id` 由客户端在比价(comparisonTraceId)/领券(sessionTraceId)全流程保持不变。激励视频、福利页、旧客户端不带 trace_id → 该列为 NULL,历史数据无法回填,只对升级后新数据生效。 +- **收益口径**(与 `app/admin/repositories/ad_revenue.py:165-167` 完全一致): + 单条展示收益(元) = `min(parse_ecpm_yuan(ecpm_raw), AD_ECPM_MAX_FEN/100) / 1000` + 其中 `AD_ECPM_MAX_FEN = 50000`(分)= ¥500 CPM 封顶,`parse_ecpm_yuan(x) = parse_ecpm_fen(x)/100`。 +- **性能前提**:`ad_ecpm_record` 是全库写入量最大的表。必须有 `ix_ad_ecpm_record_trace_id` 索引(本计划 Task A2 建),且只对**当前页**的 trace_id 聚合,绝不对整个日期区间聚合。 +- **跨仓库**:本计划涉及三个仓库,路径前缀: + - 后端 `e:\project\shaguabijia-app-server`(相对路径即以此为根) + - 客户端 `E:\project\shaguabijia-app-android` + - admin 前端 `e:\project\shaguabijia-admin-web` + +--- + +## File Structure + +### 后端(shaguabijia-app-server) +- Modify `app/models/ad_ecpm.py` — `AdEcpmRecord` 加 `trace_id` 列(索引) +- Create `alembic/versions/ad_ecpm_trace_id.py` — 加列 + 索引,并收敛当前双 head +- Modify `app/schemas/ad.py` — `EcpmReportIn` 加 `trace_id` 字段 +- Modify `app/repositories/ad_ecpm.py` — `create_ecpm_record` 持久化 `trace_id`;新增 `revenue_yuan_by_trace` 聚合器 +- Modify `app/api/v1/ad.py` — `ecpm_report` 透传 `trace_id` +- Modify `app/admin/schemas/coupon_data.py` — `CouponDataRow` 加 `ad_revenue_yuan` +- Modify `app/admin/repositories/coupon_data.py` — 逐页补 `ad_revenue_yuan` +- Modify `app/admin/schemas/comparison.py` — `AdminComparisonListItem` 加 `ad_revenue_yuan` +- Modify `app/admin/repositories/queries.py` — `list_comparison_records` 逐页补 `ad_revenue_yuan` +- Create `tests/test_ad_ecpm_trace_revenue.py` — 聚合器 + 落库单测 +- Create `tests/test_board_ad_revenue.py` — 两个看板收益列单测 + +> `app/models/__init__.py` **不需改**:`AdEcpmRecord` 已注册,只是加列。 + +### 客户端(shaguabijia-app-android) +- Modify `app/src/main/java/com/jishisongfu/shaguabijia/agent/network/ApiClient.kt` — `reportAdImpression` 加 `traceId` 参数 +- Modify `app/src/main/java/com/jishisongfu/shaguabijia/agent/service/ad/CompareAdController.kt` — 比价展示上报带 `traceId` +- Modify `app/src/main/java/com/jishisongfu/shaguabijia/service/CouponForegroundService.kt` — 领券展示上报带 `traceId` + +### admin 前端(shaguabijia-admin-web) +- Modify `src/lib/types.ts` — `ComparisonRecordListItem` 加 `ad_revenue_yuan` +- Modify `src/app/(main)/comparison-records/page.tsx` — 加「广告收益」列 +- Modify `src/app/(main)/coupon-data/page.tsx` — `CouponDataRow` 加字段 + 加「广告收益」列 + +--- + +## Phase A — 后端数据打通(落 trace_id + 收益聚合器) + +### Task A1: `AdEcpmRecord` 加 `trace_id` 列 + +**Files:** +- Modify: `app/models/ad_ecpm.py` + +- [ ] **Step 1: 加列** + +在 `app/models/ad_ecpm.py` 中,找到 `feed_scene` 这一行: + +```python + feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True) +``` + +在其**下方**插入: + +```python + # 本次比价/领券 trace_id(信息流场景客户端带上):把这条展示收益归属到对应比价/领券记录。 + # 领券数据 / 比价记录看板按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端 = NULL。 + trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) +``` + +- [ ] **Step 2: 提交** + +```bash +git add app/models/ad_ecpm.py +git commit -m "feat(ad-ecpm): add trace_id column to AdEcpmRecord model" +``` + +--- + +### Task A2: 迁移 — 加列 + 索引,并收敛双 head + +**Files:** +- Create: `alembic/versions/ad_ecpm_trace_id.py` + +> ⚠️ 当前 `alembic heads` 有**两个 head**:`11c44afbea58`(selfstat 表)与 `merge_pages_override_coupon_slot`(#126+领券合并)。本迁移用元组 `down_revision` 把二者收敛成单 head,同时加列,让 `alembic upgrade head`(单数,run.sh 用)恢复正常。 + +- [ ] **Step 1: 确认当前 heads 未漂移** + +Run: `alembic heads` +Expected: 恰好两行 — +``` +11c44afbea58 (head) +merge_pages_override_coupon_slot (head) +``` +若不同(他人已合并/新增),把下面 `down_revision` 改成此刻实际的 head 列表。 + +- [ ] **Step 2: 建迁移文件** + +Create `alembic/versions/ad_ecpm_trace_id.py`: + +```python +"""ad_ecpm_record.trace_id(展示收益归属到比价/领券 trace)+ 收敛双 head + +信息流(Draw)展示 eCPM 上报时带上本场比价/领券 trace_id,落此列;领券数据 / 比价记录看板 +按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端为 NULL。 + +顺带把当前两个 head(11c44afbea58 selfstat 表 + merge_pages_override_coupon_slot)收敛成 +单 head,让 `alembic upgrade head`(单数,部署/run.sh 用)恢复正常。 + +Revision ID: ad_ecpm_trace_id +Revises: 11c44afbea58, merge_pages_override_coupon_slot +Create Date: 2026-07-10 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "ad_ecpm_trace_id" +down_revision: Union[str, Sequence[str], None] = ( + "11c44afbea58", + "merge_pages_override_coupon_slot", +) +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # SQLite 下 ADD COLUMN(可空)与 CREATE INDEX 均原生支持,无需 batch_alter_table + # (同 ad_feed_reward_trace_id 迁移)。 + op.add_column( + "ad_ecpm_record", + sa.Column("trace_id", sa.String(length=64), nullable=True), + ) + op.create_index( + op.f("ix_ad_ecpm_record_trace_id"), + "ad_ecpm_record", + ["trace_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_ad_ecpm_record_trace_id"), table_name="ad_ecpm_record") + op.drop_column("ad_ecpm_record", "trace_id") +``` + +- [ ] **Step 3: 应用迁移** + +Run: `alembic upgrade head` +Expected: 无报错(不再报 "multiple heads")。 + +- [ ] **Step 4: 验证单 head + 列存在** + +Run: `alembic heads` +Expected: 只有一行 `ad_ecpm_trace_id (head)`。 + +Run: `python -c "from sqlalchemy import inspect; from app.db.session import engine; print([c['name'] for c in inspect(engine).get_columns('ad_ecpm_record')])"` +Expected: 输出的列名列表包含 `trace_id`。 + +- [ ] **Step 5: 提交** + +```bash +git add alembic/versions/ad_ecpm_trace_id.py +git commit -m "feat(migration): add ad_ecpm_record.trace_id + index, converge heads" +``` + +--- + +### Task A3: `EcpmReportIn` 加 `trace_id` 字段 + +**Files:** +- Modify: `app/schemas/ad.py` + +- [ ] **Step 1: 加字段** + +在 `app/schemas/ad.py` 的 `EcpmReportIn` 里,找到 `feed_scene` 字段定义(以 `feed_scene: str | None = Field(` 开头的那段)。在该字段**之后**插入: + +```python + trace_id: str | None = Field( + None, + max_length=64, + description="本次比价/领券 trace_id(信息流场景带上):把这条展示收益归属到对应比价/领券," + "供领券数据/比价记录看板聚合本场广告收益;激励视频/福利为空", + ) +``` + +- [ ] **Step 2: 提交** + +```bash +git add app/schemas/ad.py +git commit -m "feat(ad-schema): EcpmReportIn accepts trace_id" +``` + +--- + +### Task A4: `create_ecpm_record` 持久化 trace_id + 新增 `revenue_yuan_by_trace` + +**Files:** +- Modify: `app/repositories/ad_ecpm.py` +- Test: `tests/test_ad_ecpm_trace_revenue.py` + +- [ ] **Step 1: 写失败测试** + +Create `tests/test_ad_ecpm_trace_revenue.py`: + +```python +"""ad_ecpm_record.trace_id 落库 + 按 trace 聚合广告收益(元)。""" +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import delete + +from app.db.session import SessionLocal +from app.models.ad_ecpm import AdEcpmRecord +from app.repositories import ad_ecpm as crud_ecpm + + +def _ecpm(trace_id: str, ecpm_raw: str, session_id: str) -> AdEcpmRecord: + """构造一条 Draw 展示 eCPM(不 commit;ad_session_id 全局唯一,须各不相同)。""" + return AdEcpmRecord( + user_id=1, + ad_type="draw", + feed_scene="comparison", + ad_session_id=session_id, + ecpm_raw=ecpm_raw, + trace_id=trace_id, + report_date="2020-01-02", + created_at=datetime(2020, 1, 2, tzinfo=UTC), + ) + + +def test_revenue_yuan_by_trace_sums_and_clamps() -> None: + """同一 trace 多条展示求和;收益=min(eCPM元,¥500)/1000;无展示的 trace 不出现。 + + ecpm 200 分→2.0 元/千次→0.002 元/次;300 分→0.003;合计 0.005。 + """ + db = SessionLocal() + try: + db.add_all([ + _ecpm("t1", "200", "sess-t1-a"), + _ecpm("t1", "300", "sess-t1-b"), + _ecpm("t2", "0", "sess-t2-a"), + ]) + db.flush() + rev = crud_ecpm.revenue_yuan_by_trace(db, ["t1", "t2", "t3"]) + assert rev["t1"] == 0.005 + assert rev.get("t2", 0.0) == 0.0 + assert "t3" not in rev # 无展示的 trace 不出现在结果里 + finally: + db.rollback() + db.close() + + +def test_revenue_yuan_by_trace_empty() -> None: + """空 trace 列表直接返回 {}(避免 IN () 非法)。""" + db = SessionLocal() + try: + assert crud_ecpm.revenue_yuan_by_trace(db, []) == {} + finally: + db.close() + + +def test_create_ecpm_record_persists_trace_id() -> None: + """create_ecpm_record 落 trace_id。""" + db = SessionLocal() + try: + rec = crud_ecpm.create_ecpm_record( + db, 1, ad_type="draw", ecpm_raw="150", + ad_session_id="sess-trace-persist", feed_scene="coupon", + trace_id="trace-xyz", + ) + assert rec.trace_id == "trace-xyz" + finally: + db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.ad_session_id == "sess-trace-persist")) + db.commit() + db.close() +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pytest tests/test_ad_ecpm_trace_revenue.py -q` +Expected: FAIL — `create_ecpm_record` 无 `trace_id` 参数(TypeError)/ `revenue_yuan_by_trace` 不存在(AttributeError)。 + +- [ ] **Step 3: 实现** + +在 `app/repositories/ad_ecpm.py`: + +(a) 顶部 import 区加(与现有 `from app.core.rewards import cn_today` 并列): + +```python +from app.core import rewards +``` + +(b) `create_ecpm_record` 的签名里,在 `feed_scene: str | None = None,` 之后加一行参数: + +```python + trace_id: str | None = None, +``` + +(c) 同函数体内构造 `AdEcpmRecord(...)` 处,在 `feed_scene=feed_scene,` 之后加一行: + +```python + trace_id=trace_id, +``` + +(d) 文件末尾新增聚合器: + +```python +def revenue_yuan_by_trace(db: Session, trace_ids: list[str]) -> dict[str, float]: + """各 trace_id 的广告预估收益(元):按 trace_id 聚合 ad_ecpm_record 的展示收益。 + + 单条展示收益 = min(eCPM元, AD_ECPM_MAX_FEN/100) / 1000(与 admin 广告收益报表同口径)。 + ecpm_raw 是字符串且需逐条钳顶,故取回后 Python 求和(行数=本页各 trace 的展示条数,很小)。 + trace_id 仅信息流(比价/领券)场景客户端带,激励视频/旧数据为 NULL,按 trace_id 过滤天然只算对应场景。 + 只喂**当前页**的 trace_id(≤ 一页条数);空集合直接返回(避免 IN () 非法)。 + """ + if not trace_ids: + return {} + rows = db.execute( + select(AdEcpmRecord.trace_id, AdEcpmRecord.ecpm_raw).where( + AdEcpmRecord.trace_id.in_(trace_ids), + ) + ).all() + cap_yuan = rewards.AD_ECPM_MAX_FEN / 100.0 + out: dict[str, float] = {} + for tid, ecpm_raw in rows: + if not tid: + continue + out[tid] = out.get(tid, 0.0) + min(rewards.parse_ecpm_yuan(ecpm_raw), cap_yuan) / 1000.0 + return {tid: round(v, 6) for tid, v in out.items()} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `pytest tests/test_ad_ecpm_trace_revenue.py -q` +Expected: PASS(3 passed)。 + +- [ ] **Step 5: 提交** + +```bash +git add app/repositories/ad_ecpm.py tests/test_ad_ecpm_trace_revenue.py +git commit -m "feat(ad-ecpm): persist trace_id + revenue_yuan_by_trace aggregator" +``` + +--- + +### Task A5: `ecpm_report` 端点透传 trace_id + +**Files:** +- Modify: `app/api/v1/ad.py` + +- [ ] **Step 1: 透传字段** + +在 `app/api/v1/ad.py` 的 `ecpm_report` 函数里,找到 `crud_ecpm.create_ecpm_record(` 调用,在 `feed_scene=payload.feed_scene,` 之后加一行: + +```python + trace_id=payload.trace_id, +``` + +- [ ] **Step 2: 冒烟验证(手动,可选)** + +启动后端(`./run.sh`),用一个有效用户 JWT 调: + +Run: +```bash +curl -s -X POST http://127.0.0.1:8770/api/v1/ad/ecpm-report \ + -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -d '{"ad_type":"draw","ecpm":"200","ad_session_id":"smoke-sess-1","feed_scene":"comparison","trace_id":"smoke-trace-1"}' +``` +Expected: `{"ok":true}`;库里 `ad_ecpm_record` 出现一条 `trace_id='smoke-trace-1'` 的记录。 + +> 端点逻辑是纯透传,已由 A4 的 schema/repo 测试覆盖;此步仅人工确认接线。 + +- [ ] **Step 3: 提交** + +```bash +git add app/api/v1/ad.py +git commit -m "feat(ad-api): ecpm-report forwards trace_id to record" +``` + +--- + +## Phase B — 后端两个看板补收益列 + +### Task B1: 领券数据看板逐行补 `ad_revenue_yuan` + +**Files:** +- Modify: `app/admin/schemas/coupon_data.py` +- Modify: `app/admin/repositories/coupon_data.py` +- Test: `tests/test_board_ad_revenue.py` + +- [ ] **Step 1: 写失败测试** + +Create `tests/test_board_ad_revenue.py`: + +```python +"""两个看板逐行「本次广告收益」(元):按 trace_id 聚合 ad_ecpm_record。""" +from __future__ import annotations + +from datetime import UTC, date, datetime + +from sqlalchemy import delete + +from app.admin.repositories import queries +from app.admin.repositories.coupon_data import coupon_data_report +from app.db.session import SessionLocal +from app.models.ad_ecpm import AdEcpmRecord +from app.models.comparison import ComparisonRecord +from app.models.coupon_state import CouponSession + + +def _ecpm(trace_id: str, ecpm_raw: str, session_id: str, scene: str) -> AdEcpmRecord: + return AdEcpmRecord( + user_id=1, ad_type="draw", feed_scene=scene, ad_session_id=session_id, + ecpm_raw=ecpm_raw, trace_id=trace_id, report_date="2020-01-02", + created_at=datetime(2020, 1, 2, tzinfo=UTC), + ) + + +def test_coupon_data_report_includes_ad_revenue() -> None: + """领券看板明细行带本次广告收益;200+300 分 → 0.005 元。""" + db = SessionLocal() + try: + db.add(CouponSession( + trace_id="rev-cp-1", device_id="d1", status="completed", app_env="prod", + platforms=["meituan-waimai"], platform_success=["meituan-waimai"], + started_at=datetime(2020, 1, 2, tzinfo=UTC), started_date=date(2020, 1, 2), + )) + db.add_all([ + _ecpm("rev-cp-1", "200", "cp-sess-a", "coupon"), + _ecpm("rev-cp-1", "300", "cp-sess-b", "coupon"), + ]) + db.flush() + res = coupon_data_report(db, date_from="2020-01-02", date_to="2020-01-02", app_env="prod") + row = next(r for r in res["items"] if r["trace_id"] == "rev-cp-1") + assert row["ad_revenue_yuan"] == 0.005 + finally: + db.rollback() + db.close() + + +def test_comparison_list_includes_ad_revenue() -> None: + """比价记录列表项带本次广告收益;200 分 → 0.002 元。 + + 用独有 user_id 过滤,确保本行必落在第一页(避免共享测试库里同 user 记录多、分页把它挤掉)。 + """ + db = SessionLocal() + try: + db.add(ComparisonRecord( + trace_id="rev-cmp-1", user_id=987654, status="success", business_type="food", + created_at=datetime(2020, 1, 2, tzinfo=UTC), + )) + db.add(_ecpm("rev-cmp-1", "200", "cmp-sess-a", "comparison")) + db.commit() + items, _next, _total = queries.list_comparison_records(db, user_id=987654) + row = next(it for it in items if it.trace_id == "rev-cmp-1") + assert row.ad_revenue_yuan == 0.002 + finally: + db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.trace_id.in_(["rev-cmp-1"]))) + db.execute(delete(ComparisonRecord).where(ComparisonRecord.trace_id == "rev-cmp-1")) + db.commit() + db.close() +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pytest tests/test_board_ad_revenue.py -q` +Expected: FAIL — `coupon_data_report` 明细行无 `ad_revenue_yuan` 键(KeyError);`ComparisonRecord` 无 `ad_revenue_yuan`(AttributeError)。 + +- [ ] **Step 3: 领券 schema 加字段** + +在 `app/admin/schemas/coupon_data.py` 的 `CouponDataRow` 里,`trace_url` 字段**之后**加: + +```python + ad_revenue_yuan: float = Field( + 0.0, description="本次领券看的信息流广告预估收益(元);按 trace_id 聚合 ad_ecpm_record" + ) +``` + +- [ ] **Step 4: 领券 repo 逐页补收益** + +在 `app/admin/repositories/coupon_data.py`: + +(a) import 区(现有 `from app.models.user import User` 附近)加: + +```python +from app.repositories import ad_ecpm as crud_ecpm +``` + +(b) `_session_to_row` 签名改为(加末位参数): + +```python +def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad_revenue_yuan: float = 0.0) -> dict: +``` + +并在其返回的 dict 里,`"trace_url": r.trace_url,` 之后加一行: + +```python + "ad_revenue_yuan": ad_revenue_yuan, +``` + +(c) 在 `coupon_data_report` 里,找到构造明细的这段: + +```python + items = [] + for r in page: + phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None) + items.append(_session_to_row(r, phone, nickname)) +``` + +替换为(新增 `rev_map` + 传入): + +```python + rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in page]) + items = [] + for r in page: + phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None) + items.append(_session_to_row(r, phone, nickname, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0))) +``` + +> `coupon_user_records`(手机号抽屉)仍走 `_session_to_row(r)`,`ad_revenue_yuan` 取默认 0.0——抽屉不展示收益列,无需补;字段有默认值故 schema 校验不受影响。 + +- [ ] **Step 5: 跑领券用例确认通过** + +Run: `pytest tests/test_board_ad_revenue.py::test_coupon_data_report_includes_ad_revenue -q` +Expected: PASS。 + +- [ ] **Step 6: 提交** + +```bash +git add app/admin/schemas/coupon_data.py app/admin/repositories/coupon_data.py tests/test_board_ad_revenue.py +git commit -m "feat(admin-coupon-data): per-session ad revenue column" +``` + +--- + +### Task B2: 比价记录看板逐行补 `ad_revenue_yuan` + +**Files:** +- Modify: `app/admin/schemas/comparison.py` +- Modify: `app/admin/repositories/queries.py` + +- [ ] **Step 1: 比价 schema 加字段** + +在 `app/admin/schemas/comparison.py` 的 `AdminComparisonListItem` 里,`created_at: datetime` **之前**加: + +```python + ad_revenue_yuan: float = 0.0 # 本次比价看的信息流广告预估收益(元),queries 瞬态挂 ORM 实例上 +``` + +- [ ] **Step 2: 比价 repo 逐页补收益** + +在 `app/admin/repositories/queries.py`: + +(a) import 区加: + +```python +from app.repositories import ad_ecpm +``` + +(b) 在 `list_comparison_records` 里,找到: + +```python + _attach_user_info(db, items) + return items, next_cursor, total +``` + +替换为: + +```python + _attach_user_info(db, items) + # 「本次比价看广告的预估收益」:按本页 trace_id 一次性聚合(同 _attach_user_info 逐页范式)。 + # ad_revenue_yuan 非 ORM 列,仅瞬态挂实例上供 AdminComparisonListItem(from_attributes)读出。 + rev = ad_ecpm.revenue_yuan_by_trace(db, [it.trace_id for it in items]) + for it in items: + it.ad_revenue_yuan = rev.get(it.trace_id, 0.0) + return items, next_cursor, total +``` + +- [ ] **Step 3: 跑比价用例确认通过** + +Run: `pytest tests/test_board_ad_revenue.py::test_comparison_list_includes_ad_revenue -q` +Expected: PASS。 + +- [ ] **Step 4: 跑全量后端测试(确认无回归)** + +Run: `pytest -q` +Expected: 全绿(新增用例通过,原有用例不受影响)。 + +- [ ] **Step 5: Lint** + +Run: `ruff check app/ tests/` +Expected: 无新增告警。 + +- [ ] **Step 6: 提交** + +```bash +git add app/admin/schemas/comparison.py app/admin/repositories/queries.py +git commit -m "feat(admin-comparison): per-comparison ad revenue column" +``` + +--- + +## Phase C — 客户端上报带 trace_id(Android) + +> 三处都在 `E:\project\shaguabijia-app-android`。改动极小:eCPM 上报点的 trace_id 已在作用域内(比价是 `showAd(traceId)` 参数,领券是 `sessionTraceId` 类字段),只是当前没往上带。发奖(`reportFeedReward`)已在带 traceId,可作参照。 + +### Task C1: `ApiClient.reportAdImpression` 加 `traceId` 参数 + +**Files:** +- Modify: `app/src/main/java/com/jishisongfu/shaguabijia/agent/network/ApiClient.kt` + +- [ ] **Step 1: 加参数** + +找到 `reportAdImpression` 的参数列表,末尾 `feedScene: String? = null,` 之后加一行: + +```kotlin + traceId: String? = null, +``` + +- [ ] **Step 2: 写进 payload** + +同函数内,找到: + +```kotlin + if (!feedScene.isNullOrBlank()) payload.put("feed_scene", feedScene) +``` + +在其**下方**加一行: + +```kotlin + if (!traceId.isNullOrBlank()) payload.put("trace_id", traceId) +``` + +- [ ] **Step 3: 提交** + +```bash +git add app/src/main/java/com/jishisongfu/shaguabijia/agent/network/ApiClient.kt +git commit -m "feat(ad-report): reportAdImpression carries trace_id" +``` + +--- + +### Task C2: 比价展示上报带 traceId + +**Files:** +- Modify: `app/src/main/java/com/jishisongfu/shaguabijia/agent/service/ad/CompareAdController.kt` + +- [ ] **Step 1: 传 traceId** + +在 `showAd(traceId: String)` 内的 `onAdImpression` 回调里,找到 `apiClient.reportAdImpression(` 调用,其中 `feedScene = "comparison",` 之后加一行(`traceId` 即 `showAd` 的入参): + +```kotlin + traceId = traceId, // 本场比价 trace → 展示收益归属到该次比价 +``` + +- [ ] **Step 2: 提交** + +```bash +git add app/src/main/java/com/jishisongfu/shaguabijia/agent/service/ad/CompareAdController.kt +git commit -m "feat(compare-ad): report comparison impression with trace_id" +``` + +--- + +### Task C3: 领券展示上报带 traceId + +**Files:** +- Modify: `app/src/main/java/com/jishisongfu/shaguabijia/service/CouponForegroundService.kt` + +- [ ] **Step 1: 传 traceId** + +在 `onAdImpression` 回调里,找到 `apiClient.reportAdImpression(` 调用,其中 `feedScene = "coupon",` 之后加一行(`sessionTraceId` 为本类字段,整场领券不变): + +```kotlin + traceId = sessionTraceId, // 本场领券 trace → 展示收益归属到该次领券 +``` + +- [ ] **Step 2: 编译验证** + +Run(在 `E:\project\shaguabijia-app-android`): `./gradlew :app:compileDebugKotlin` +Expected: BUILD SUCCESSFUL。 + +- [ ] **Step 3: 提交** + +```bash +git add app/src/main/java/com/jishisongfu/shaguabijia/service/CouponForegroundService.kt +git commit -m "feat(coupon-ad): report coupon impression with trace_id" +``` + +--- + +## Phase D — admin 前端加「广告收益」列 + +> 两个页面都在 `e:\project\shaguabijia-admin-web`。收益值单位是**元**(小数,单次很小如 ¥0.0050),用 `.toFixed(4)` 展示。 + +### Task D1: 比价记录页加列 + +**Files:** +- Modify: `src/lib/types.ts` +- Modify: `src/app/(main)/comparison-records/page.tsx` + +- [ ] **Step 1: 类型加字段** + +在 `src/lib/types.ts` 的 `ComparisonRecordListItem` 接口里,`created_at: string;` **之前**加: + +```typescript + ad_revenue_yuan: number; // 本次比价看的信息流广告预估收益(元) +``` + +- [ ] **Step 2: 表格加列** + +在 `src/app/(main)/comparison-records/page.tsx` 的 `columns` 数组里,找到「省」这一列(以 `title: '省',` 开头的对象),在其**之后**插入一列: + +```tsx + { + title: '广告收益', + key: 'ad_revenue', + width: 96, + align: 'right', + render: (_, r) => + r.ad_revenue_yuan > 0 ? ( + ¥{r.ad_revenue_yuan.toFixed(4)} + ) : ( + - + ), + }, +``` + +- [ ] **Step 3: 加宽横向滚动** + +同文件找到比价记录主表的 `scroll={{ x: 1820 }}`,改为: + +```tsx + scroll={{ x: 1920 }} +``` + +- [ ] **Step 4: 提交** + +```bash +git add src/lib/types.ts "src/app/(main)/comparison-records/page.tsx" +git commit -m "feat(admin-web): ad revenue column in comparison records table" +``` + +--- + +### Task D2: 领券数据页加列 + +**Files:** +- Modify: `src/app/(main)/coupon-data/page.tsx` + +- [ ] **Step 1: 接口加字段** + +在 `src/app/(main)/coupon-data/page.tsx` 的 `interface CouponDataRow` 里,`trace_url: string | null;` **之后**加: + +```typescript + ad_revenue_yuan: number; // 本次领券看的信息流广告预估收益(元) +``` + +- [ ] **Step 2: 加金额格式化辅助** + +在 `fmtPct` 定义(以 `const fmtPct =` 开头)之后加: + +```typescript +// 元(小数)→ "¥0.0050"(空/≤0 显示 -)。单次广告收益很小,保留 4 位。 +const fmtYuan = (v: number | null | undefined): string => + v == null || v <= 0 ? '-' : `¥${v.toFixed(4)}`; +``` + +- [ ] **Step 3: 主表加列** + +找到主表 `columns`(`CouponDataPage` 组件内的 `const columns: ColumnsType = [`)。在「耗时」列(`dataIndex: 'elapsed_ms'` 的对象)**之后**插入: + +```tsx + { + title: '广告收益', + dataIndex: 'ad_revenue_yuan', + width: 100, + align: 'right', + render: (v: number) => fmtYuan(v), + }, +``` + +- [ ] **Step 4: 加宽横向滚动** + +找到主表 `scroll={{ x: 1450 }}`,改为: + +```tsx + scroll={{ x: 1560 }} +``` + +- [ ] **Step 5: 前端类型检查 / 构建** + +Run(在 `e:\project\shaguabijia-admin-web`): `npm run build` +Expected: 构建成功、无 TS 类型错误。 + +- [ ] **Step 6: 提交** + +```bash +git add "src/app/(main)/coupon-data/page.tsx" +git commit -m "feat(admin-web): ad revenue column in coupon data table" +``` + +--- + +## 端到端验证(全部任务完成后) + +- [ ] **后端**:`pytest -q` 全绿;`alembic heads` 只有 `ad_ecpm_trace_id` 单 head。 +- [ ] **客户端**:装 debug 包,跑一次比价 + 一次领券(要有信息流广告展示);后端库 `ad_ecpm_record` 出现带 `trace_id`、`feed_scene in (comparison, coupon)` 的记录。 +- [ ] **看板**:admin 打开「比价记录」「领券数据」两页,新「广告收益」列对刚才那两次显示 > ¥0 的金额;旧数据(无 trace_id 上报)显示 `-`。 +- [ ] **口径核对**:任取一行,手动核对 `ad_ecpm_record` 中该 `trace_id` 的各条 `ecpm_raw`,按 `Σ min(ecpm/100, 500)/1000` 算出的值与页面一致。 + +--- + +## 回滚 + +- 前端/客户端:回退对应 commit 即可(纯展示/上报,无副作用)。 +- 后端:`alembic downgrade -1` 撤 `ad_ecpm_trace_id`(会拆回两个 head——与本计划实施前状态一致);看板收益列在无 trace_id 列时会因查询报错,故 downgrade 迁移前需先回退 Phase A/B 的 commit。正常情况不需回滚。 diff --git a/tests/test_ad_ecpm_trace_revenue.py b/tests/test_ad_ecpm_trace_revenue.py new file mode 100644 index 0000000..cd800e0 --- /dev/null +++ b/tests/test_ad_ecpm_trace_revenue.py @@ -0,0 +1,73 @@ +"""ad_ecpm_record.trace_id 落库 + 按 trace 聚合广告收益(元)。""" +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import delete + +from app.db.session import SessionLocal +from app.models.ad_ecpm import AdEcpmRecord +from app.repositories import ad_ecpm as crud_ecpm + + +def _ecpm(trace_id: str, ecpm_raw: str, session_id: str) -> AdEcpmRecord: + """构造一条 Draw 展示 eCPM(不 commit;ad_session_id 全局唯一,须各不相同)。""" + return AdEcpmRecord( + user_id=1, + ad_type="draw", + feed_scene="comparison", + ad_session_id=session_id, + ecpm_raw=ecpm_raw, + trace_id=trace_id, + report_date="2020-01-02", + created_at=datetime(2020, 1, 2, tzinfo=UTC), + ) + + +def test_revenue_yuan_by_trace_sums_and_clamps() -> None: + """同一 trace 多条展示求和;收益=min(eCPM元,¥500)/1000;无展示的 trace 不出现。 + + ecpm 200 分→2.0 元/千次→0.002 元/次;300 分→0.003;合计 0.005。 + """ + db = SessionLocal() + try: + db.add_all([ + _ecpm("t1", "200", "sess-t1-a"), + _ecpm("t1", "300", "sess-t1-b"), + _ecpm("t2", "0", "sess-t2-a"), + _ecpm("t_cap", "60000", "sess-cap-a"), # 600 元 CPM > ¥500 钳顶 + ]) + db.flush() + rev = crud_ecpm.revenue_yuan_by_trace(db, ["t1", "t2", "t3", "t_cap"]) + assert rev["t1"] == 0.005 + assert rev["t2"] == 0.0 # 有展示但 eCPM=0 → 0 元(仍在结果里) + assert "t3" not in rev # 无展示的 trace 不出现在结果里 + assert rev["t_cap"] == 0.5 # min(¥600, ¥500)/1000,证明钳顶生效(非 0.6) + finally: + db.rollback() + db.close() + + +def test_revenue_yuan_by_trace_empty() -> None: + """空 trace 列表直接返回 {}(避免 IN () 非法)。""" + db = SessionLocal() + try: + assert crud_ecpm.revenue_yuan_by_trace(db, []) == {} + finally: + db.close() + + +def test_create_ecpm_record_persists_trace_id() -> None: + """create_ecpm_record 落 trace_id。""" + db = SessionLocal() + try: + rec = crud_ecpm.create_ecpm_record( + db, 1, ad_type="draw", ecpm_raw="150", + ad_session_id="sess-trace-persist", feed_scene="coupon", + trace_id="trace-xyz", + ) + assert rec.trace_id == "trace-xyz" + finally: + db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.ad_session_id == "sess-trace-persist")) + db.commit() + db.close() diff --git a/tests/test_board_ad_revenue.py b/tests/test_board_ad_revenue.py new file mode 100644 index 0000000..6f54d86 --- /dev/null +++ b/tests/test_board_ad_revenue.py @@ -0,0 +1,87 @@ +"""两个看板逐行「本次广告收益」(元):按 trace_id 聚合 ad_ecpm_record。""" +from __future__ import annotations + +from datetime import UTC, date, datetime + +from sqlalchemy import delete + +from app.admin.repositories import queries +from app.admin.repositories.coupon_data import coupon_data_report, coupon_user_records +from app.db.session import SessionLocal +from app.models.ad_ecpm import AdEcpmRecord +from app.models.comparison import ComparisonRecord +from app.models.coupon_state import CouponSession + + +def _ecpm(trace_id: str, ecpm_raw: str, session_id: str, scene: str) -> AdEcpmRecord: + return AdEcpmRecord( + user_id=1, ad_type="draw", feed_scene=scene, ad_session_id=session_id, + ecpm_raw=ecpm_raw, trace_id=trace_id, report_date="2020-01-02", + created_at=datetime(2020, 1, 2, tzinfo=UTC), + ) + + +def test_coupon_data_report_includes_ad_revenue() -> None: + """领券看板明细行带本次广告收益;200+300 分 → 0.005 元。""" + db = SessionLocal() + try: + db.add(CouponSession( + trace_id="rev-cp-1", device_id="d1", status="completed", app_env="prod", + platforms=["meituan-waimai"], platform_success=["meituan-waimai"], + started_at=datetime(2020, 1, 2, tzinfo=UTC), started_date=date(2020, 1, 2), + )) + db.add_all([ + _ecpm("rev-cp-1", "200", "cp-sess-a", "coupon"), + _ecpm("rev-cp-1", "300", "cp-sess-b", "coupon"), + ]) + db.flush() + res = coupon_data_report(db, date_from="2020-01-02", date_to="2020-01-02", app_env="prod") + row = next(r for r in res["items"] if r["trace_id"] == "rev-cp-1") + assert row["ad_revenue_yuan"] == 0.005 + finally: + db.rollback() + db.close() + + +def test_comparison_list_includes_ad_revenue() -> None: + """比价记录列表项带本次广告收益;200 分 → 0.002 元。 + + 用独有 user_id 过滤,确保本行必落在第一页(避免共享测试库里同 user 记录多、分页把它挤掉)。 + """ + db = SessionLocal() + try: + db.add(ComparisonRecord( + trace_id="rev-cmp-1", user_id=987654, status="success", business_type="food", + created_at=datetime(2020, 1, 2, tzinfo=UTC), + )) + db.add(_ecpm("rev-cmp-1", "200", "cmp-sess-a", "comparison")) + db.commit() + items, _next, _total = queries.list_comparison_records(db, user_id=987654) + row = next(it for it in items if it.trace_id == "rev-cmp-1") + assert row.ad_revenue_yuan == 0.002 + finally: + db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.trace_id.in_(["rev-cmp-1"]))) + db.execute(delete(ComparisonRecord).where(ComparisonRecord.trace_id == "rev-cmp-1")) + db.commit() + db.close() + + +def test_coupon_user_records_includes_ad_revenue() -> None: + """手机号抽屉的用户领券记录也带本次广告收益(400 分 → 0.004 元)。""" + db = SessionLocal() + try: + db.add(CouponSession( + trace_id="rev-drawer-1", device_id="d2", status="completed", app_env="prod", + user_id=987655, platforms=["meituan-waimai"], platform_success=["meituan-waimai"], + started_at=datetime(2020, 1, 2, tzinfo=UTC), started_date=date(2020, 1, 2), + )) + db.add(_ecpm("rev-drawer-1", "400", "drawer-sess-a", "coupon")) + db.commit() + res = coupon_user_records(db, user_id=987655) + row = next(r for r in res["items"] if r["trace_id"] == "rev-drawer-1") + assert row["ad_revenue_yuan"] == 0.004 + finally: + db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.trace_id == "rev-drawer-1")) + db.execute(delete(CouponSession).where(CouponSession.trace_id == "rev-drawer-1")) + db.commit() + db.close() From 824045dd19783478f6362d6ce8b521dd8d7df388 Mon Sep 17 00:00:00 2001 From: marco Date: Sat, 11 Jul 2026 13:47:12 +0800 Subject: [PATCH 18/24] =?UTF-8?q?fix=E8=BF=81=E7=A7=BB=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- alembic/versions/ad_ecpm_trace_id.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/alembic/versions/ad_ecpm_trace_id.py b/alembic/versions/ad_ecpm_trace_id.py index ba501f0..9d2b5a2 100644 --- a/alembic/versions/ad_ecpm_trace_id.py +++ b/alembic/versions/ad_ecpm_trace_id.py @@ -1,13 +1,15 @@ -"""ad_ecpm_record.trace_id(展示收益归属到比价/领券 trace)+ 收敛双 head +"""ad_ecpm_record.trace_id(展示收益归属到比价/领券 trace) 信息流(Draw)展示 eCPM 上报时带上本场比价/领券 trace_id,落此列;领券数据 / 比价记录看板 按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端为 NULL。 -顺带把当前两个 head(11c44afbea58 selfstat 表 + merge_pages_override_coupon_slot)收敛成 -单 head,让 `alembic upgrade head`(单数,部署/run.sh 用)恢复正常。 +本迁移原以 (11c44afbea58, merge_pages_override_coupon_slot) 为双亲、顺带收敛双 head, +但与它并行落 main 的 merge_selfstat_coupon_slot 已用同一对双亲做了纯收敛 → 同一对 +父节点出现两个收敛点、main 上又成双 head。故重挂到该 merge 之后成单链(仅改链接、 +schema 改动不变;两文件都保留,已 stamp 在 merge 上的库可直接线性升级)。 Revision ID: ad_ecpm_trace_id -Revises: 11c44afbea58, merge_pages_override_coupon_slot +Revises: merge_selfstat_coupon_slot Create Date: 2026-07-10 """ from typing import Sequence, Union @@ -17,10 +19,7 @@ import sqlalchemy as sa revision: str = "ad_ecpm_trace_id" -down_revision: Union[str, Sequence[str], None] = ( - "11c44afbea58", - "merge_pages_override_coupon_slot", -) +down_revision: Union[str, Sequence[str], None] = "merge_selfstat_coupon_slot" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None From 5c6840dd7106a55d246e730855af70e2e5c80d4b Mon Sep 17 00:00:00 2001 From: guke Date: Mon, 13 Jul 2026 17:46:11 +0800 Subject: [PATCH 19/24] =?UTF-8?q?feat(compare):=20=E6=AF=94=E4=BB=B7?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=20LLM=20token=20=E6=88=90=E6=9C=AC=E8=90=BD?= =?UTF-8?q?=E5=BA=93=E4=B8=8E=E5=B1=95=E7=A4=BA(=E6=8C=89=E5=BD=93?= =?UTF-8?q?=E6=97=B6=E4=BB=B7=E5=86=BB=E7=BB=93)=20(#133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - comparison_record 加 llm_cost_yuan(元/float)+ llm_price_snapshot(JSON)两列 - _backfill_llm_calls 回填时按 app_config 当时单价逐模型算成本、冻结成本+快照到记录 - app_config 新增 llm_token_price 配置(per_model + default 兜底,运营在系统配置页可改) - services/llm_cost.py:compute_llm_cost 纯函数(按 model 分桶、error/无 usage 跳过、 脏价格当 unpriced 不抛异常以免连累 token 回填)+ get_llm_prices reader - admin schema 暴露成本:列表项带 llm_cost_yuan,详情另带价格快照 - tests/test_llm_cost.py(10 测试);scripts/seed_mock_llm_cost.py(mock seeder) Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: guke Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/133 --- alembic/versions/comparison_llm_cost.py | 33 ++++ app/admin/schemas/comparison.py | 4 + app/api/v1/compare_record.py | 3 + app/core/config_schema.py | 15 ++ app/models/comparison.py | 6 + app/services/llm_cost.py | 53 +++++ docs/database/comparison_record.md | 2 + scripts/seed_mock_llm_cost.py | 248 ++++++++++++++++++++++++ tests/test_llm_cost.py | 184 ++++++++++++++++++ 9 files changed, 548 insertions(+) create mode 100644 alembic/versions/comparison_llm_cost.py create mode 100644 app/services/llm_cost.py create mode 100644 scripts/seed_mock_llm_cost.py create mode 100644 tests/test_llm_cost.py diff --git a/alembic/versions/comparison_llm_cost.py b/alembic/versions/comparison_llm_cost.py new file mode 100644 index 0000000..ef0bfdd --- /dev/null +++ b/alembic/versions/comparison_llm_cost.py @@ -0,0 +1,33 @@ +"""comparison_record: llm_cost_yuan + llm_price_snapshot(比价 LLM 调用成本 + 当时单价快照) + +回填 llm_calls 时按「当时的价」逐模型算出本次比价 LLM 总成本(元),连同所用单价快照一起冻结到 +记录上;admin 比价记录详情展示实际成本(旧记录 NULL → 前端回退估算)。见 services/llm_cost.py。 + +Revision ID: comparison_llm_cost +Revises: ad_ecpm_trace_id +Create Date: 2026-07-13 +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision: str = "comparison_llm_cost" +down_revision: str | Sequence[str] | None = "ad_ecpm_trace_id" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_JSONB = sa.JSON().with_variant(postgresql.JSONB(), "postgresql") + + +def upgrade() -> None: + # 均可空、无索引;SQLite 原生支持 ADD COLUMN,无需 batch_alter_table(同 comparison_debug_fields)。 + op.add_column("comparison_record", sa.Column("llm_cost_yuan", sa.Float(), nullable=True)) + op.add_column("comparison_record", sa.Column("llm_price_snapshot", _JSONB, nullable=True)) + + +def downgrade() -> None: + op.drop_column("comparison_record", "llm_price_snapshot") + op.drop_column("comparison_record", "llm_cost_yuan") diff --git a/app/admin/schemas/comparison.py b/app/admin/schemas/comparison.py index 3a5ae19..ec1d3aa 100644 --- a/app/admin/schemas/comparison.py +++ b/app/admin/schemas/comparison.py @@ -36,6 +36,8 @@ class AdminComparisonListItem(BaseModel): retry_count: int | None = None input_tokens: int | None = None # Σ usage.prompt_tokens(server 派生) output_tokens: int | None = None # Σ usage.completion_tokens(server 派生) + # 本次比价 LLM 总成本(元,按当时价冻结);旧记录/未回填为 None → 前端「成本」列回退估算。见 services/llm_cost.py。 + llm_cost_yuan: float | None = None device_model: str | None = None rom_vendor: str | None = None rom_name: str | None = None @@ -72,3 +74,5 @@ class AdminComparisonDetail(AdminComparisonListItem): # 原始上报全量;「卡在哪一步」从 raw_payload.platform_results[*].status 读 # (store_not_found/items_not_found/below_minimum/unsupported = 卡在 找店/加菜/起送/读价)。 raw_payload: dict | None = None + # 算成本所用单价快照 {mode, prices:{model:{...}}}(llm_cost_yuan 继承自列表项)。见 services/llm_cost.py。 + llm_price_snapshot: dict | None = None diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index 2677dad..1c34215 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -27,6 +27,7 @@ from app.schemas.compare_record import ( ComparisonRecordOut, ComparisonRecordPage, ) +from app.services.llm_cost import compute_llm_cost, get_llm_prices from app.services.pricebot_llm_calls import fetch_llm_calls logger = logging.getLogger("shagua.compare_record") @@ -81,6 +82,8 @@ def _backfill_llm_calls(record_id: int, trace_id: str) -> None: # error 的调用 usage 可能为 None,or {} 兜底) rec.input_tokens = sum((c.get("usage") or {}).get("prompt_tokens") or 0 for c in calls) rec.output_tokens = sum((c.get("usage") or {}).get("completion_tokens") or 0 for c in calls) + # 本次比价 LLM 成本(元)+ 当时单价快照:按 app_config 现价逐模型算好冻结(services/llm_cost.py)。 + rec.llm_cost_yuan, rec.llm_price_snapshot = compute_llm_cost(calls, get_llm_prices(db)) db.commit() logger.info( "backfill llm_calls trace=%s n=%d in_tok=%d out_tok=%d", diff --git a/app/core/config_schema.py b/app/core/config_schema.py index afc5dd5..9edb043 100644 --- a/app/core/config_schema.py +++ b/app/core/config_schema.py @@ -96,4 +96,19 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { "group": "首页轮播", "type": "enum", "hidden": True, "help": "mixed=真实优先+种子补位(默认);real=只用真实比价记录;seed=只用种子/合成(演示)。", }, + # 比价 LLM 调用成本计价。值是嵌套 JSON(非 str→int),借 dict_str_int 类型在配置页走原始 JSON + # 编辑框;set_value 不校验类型,嵌套 JSON 照存。 + "llm_token_price": { + "default": { + "per_model": {"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0}}, + "default": {"input_per_1m": 3.0, "output_per_1m": 15.0}, + "currency": "CNY", "unit": "per_1m_tokens", + }, + "label": "LLM 模型单价(元/百万 token)", + "group": "LLM 成本", "type": "dict_str_int", + "help": ( + "比价 LLM 调用成本计价。JSON:per_model 按模型配 input/output 单价(元/1M token)," + "default 兜底未登记的模型。改价只影响之后回填的新记录,历史记录用当时价格快照。" + ), + }, } diff --git a/app/models/comparison.py b/app/models/comparison.py index d21a201..d4078e3 100644 --- a/app/models/comparison.py +++ b/app/models/comparison.py @@ -137,6 +137,12 @@ class ComparisonRecord(Base): # 每次 LLM 调用明细 [{scene,model,input_messages,output,usage,latency_ms,error}]; # server 收上报后按 trace_id 同机拉 pricebot 落库(见 compare_record 端点)。旧记录/未采集为 None。 llm_calls: Mapped[list | None] = mapped_column(_JSON, nullable=True) + # 本次比价 LLM 总成本(元):回填时按「当时的价」逐模型算好冻结(见 services/llm_cost.py)。 + # 单次亚分级 → float「元」(不用 *_cents)。旧记录/未回填为 None,前端回退「估算成本」。 + llm_cost_yuan: Mapped[float | None] = mapped_column(Float, nullable=True) + # 算成本所用单价快照 {mode, prices:{model:{input_per_1m,output_per_1m,_source}}}:app_config 只存 + # 当前价、不留历史,故把当时价冻结进来供审计/复算。 + llm_price_snapshot: Mapped[dict | None] = mapped_column(_JSON, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True, nullable=False diff --git a/app/services/llm_cost.py b/app/services/llm_cost.py new file mode 100644 index 0000000..fcf4edf --- /dev/null +++ b/app/services/llm_cost.py @@ -0,0 +1,53 @@ +"""LLM 调用成本计算(纯逻辑,无 DB):按 model 分桶累加 token × 单价,返回总成本(元)+ 价格快照。 + +用量取自 comparison_record.llm_calls[].usage(pricebot 已归一为 prompt/completion_tokens); +error / 无 usage 的调用跳过。price_cfg = {per_model:{model:{input_per_1m,output_per_1m}}, default:{...}}。 +成本单位「元」——单次亚分级,用 float(不用 *_cents);snapshot 只含本次用到的模型的价(审计用, +不存整张价表)。用到但没配价(既无 per_model 又无 default)的模型 → 快照标 unpriced,成本按 0 计。 +""" +from __future__ import annotations + +_PRICE_KEY = "llm_token_price" + + +def get_llm_prices(db) -> dict: + """读 LLM 单价配置(app_config;表内无则回退 CONFIG_DEFS 默认)。返回 compute_llm_cost 的 price_cfg。""" + from app.repositories import app_config # 延迟 import:compute_llm_cost 纯逻辑不牵连 DB 层 + return app_config.get_value(db, _PRICE_KEY) + + +def compute_llm_cost(calls: list[dict], price_cfg: dict) -> tuple[float | None, dict | None]: + """遍历 calls 按 model 分桶,cost = Σ(入/1e6*入价 + 出/1e6*出价);无有效调用 → (None, None)。""" + if not calls: + return None, None + per_model = price_cfg.get("per_model") or {} + default = price_cfg.get("default") + buckets: dict[str, list[int]] = {} # model -> [Σprompt_tokens, Σcompletion_tokens] + for c in calls: + if c.get("error"): + continue + usage = c.get("usage") or {} + model = c.get("model") or "unknown" + b = buckets.setdefault(model, [0, 0]) + b[0] += usage.get("prompt_tokens") or 0 + b[1] += usage.get("completion_tokens") or 0 + if not buckets: # 全是 error / 无 usage + return None, None + total = 0.0 + prices: dict[str, dict] = {} + for model, (tin, tout) in buckets.items(): + price = per_model.get(model, default) + in_p = price.get("input_per_1m") if isinstance(price, dict) else None + out_p = price.get("output_per_1m") if isinstance(price, dict) else None + # 没配价 / 无 default / 单价残缺或非法(配置页手改 JSON 可能存出脏数据)→ 标记待补价、 + # 不计入成本;绝不抛异常,以免连累同一回填里的 token/llm_calls 落库。 + if not isinstance(in_p, (int, float)) or not isinstance(out_p, (int, float)): + prices[model] = {"input_per_1m": in_p, "output_per_1m": out_p, "unpriced": True} + continue + total += tin / 1e6 * in_p + tout / 1e6 * out_p + prices[model] = { + "input_per_1m": in_p, + "output_per_1m": out_p, + "_source": "per_model" if model in per_model else "default", + } + return round(total, 6), {"mode": "per_model", "prices": prices} diff --git a/docs/database/comparison_record.md b/docs/database/comparison_record.md index 5ea3064..0545631 100644 --- a/docs/database/comparison_record.md +++ b/docs/database/comparison_record.md @@ -40,6 +40,8 @@ | `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报全量(calibration + done.params),取数兜底 | | `input_tokens` | Integer | nullable | 本次 LLM 累计输入 token = Σ `llm_calls[].usage.prompt_tokens`(server 收上报后从 `llm_calls` 累加;旧记录/未采集为 null) | | `output_tokens` | Integer | nullable | 本次 LLM 累计输出 token = Σ `llm_calls[].usage.completion_tokens`(同上) | +| `llm_cost_yuan` | Float | nullable | 本次比价 LLM 总成本(元),回填时按「当时价」逐模型算好冻结(见 `services/llm_cost.py`);旧记录/未回填为 null → 前端回退「估算成本」 | +| `llm_price_snapshot` | JSON(PG: JSONB) | nullable | 算成本所用单价快照 `{mode, prices:{model:{input_per_1m,output_per_1m,_source}}}`;`app_config` 只存当前价、不留历史,故冻结当时价供审计/复算 | | `created_at` | DateTime(tz) | server_default now(), index | 时间 | > `ordered`(已下单)是**瞬态字段**,不在表里:`list_records` 读取时按 `store_name ∈ 该用户 source='compare' 的 savings_record.shop_name 集合` 现挂到实例上供出参用。 diff --git a/scripts/seed_mock_llm_cost.py b/scripts/seed_mock_llm_cost.py new file mode 100644 index 0000000..3102a67 --- /dev/null +++ b/scripts/seed_mock_llm_cost.py @@ -0,0 +1,248 @@ +"""一次性 mock:造带 LLM token 成本的比价记录 + 配好 app_config 模型单价,用于测「管理后端」LLM 成本展示。 + +覆盖 admin「比价记录」详情抽屉的「LLM 成本」展示分支: + • app_config.llm_token_price ← 写一条多模型单价(= 配置页「LLM 成本」卡片「已改」态,get_llm_prices 读它) + • comparison_record ← 造 5 条,逐条**复用生产的 compute_llm_cost + 与 _backfill_llm_calls 同款派生** + (llm_call_count/retry_count/input_tokens/output_tokens/llm_cost_yuan/llm_price_snapshot), + 确保 mock 行 = 真实回填产出。5 条刻意覆盖: + ① 单模型真实样本(qwen3.5-flash ×4) → ¥0.006184(核对精确值) + ② 多模型(flash + plus) → 快照含两个模型、各自 _source=per_model + ③ 未登记模型(deepseek-v3) → 走 default,快照 _source=default + ④ 旧记录(有 token、无 cost) → llm_cost_yuan=NULL → 前端回退「估算成本」 + ⑤ 含 error 调用 → error 那次跳过计费、retry_count+1 + +记录挂到库里第一个真实用户(admin 列表能显示手机号);无用户则 user_id=NULL(孤儿行,admin 照样全看)。 +created_at 用北京 naive、最近几分钟内错开,详情列表倒序即 ①→⑤ 置顶。 + +幂等:重跑先按 trace_id 前缀「MOCKLLM-」清旧再建。app_config 单价是 upsert(不随 --clean-only 删, +因该 key 本就是本需求新增、无历史真实值;要改价直接去配置页或重跑本脚本)。 + + python -m scripts.seed_mock_llm_cost # 造价格 + 5 条记录 + python -m scripts.seed_mock_llm_cost --clean-only # 只清 MOCKLLM- 记录(保留单价) + +验收:admin「比价记录」→ 找 trace「MOCKLLM-」的 5 条 → 点开详情看「LLM 成本」: + ①②③⑤ 显示「实际·当时价」+ 价格快照;④ 显示「估算」。 +""" +from __future__ import annotations + +import argparse +import sys +from datetime import datetime, timedelta, timezone + +from sqlalchemy import delete, select + +from app.db.session import SessionLocal +from app.models.comparison import ComparisonRecord +from app.models.user import User +from app.repositories import app_config +from app.services.llm_cost import compute_llm_cost + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") # Windows 控制台输出中文/¥ + +_BJ = timezone(timedelta(hours=8)) +ID_PREFIX = "MOCKLLM-" + +# ── 写进 app_config 的模型单价(get_llm_prices 读它;配置页「LLM 成本」卡片可再改)── +PRICE_CFG = { + "per_model": { + "qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0}, + "qwen3.5-plus": {"input_per_1m": 4.0, "output_per_1m": 12.0}, + }, + "default": {"input_per_1m": 3.0, "output_per_1m": 15.0}, + "currency": "CNY", + "unit": "per_1m_tokens", +} + + +def _c(scene: str, model: str, pin: int, cout: int, error: str | None = None) -> dict: + """一条 llm_calls 明细,结构对齐真实 pricebot 归一后契约: + {scene, model, input_messages:[{role,content}], output, usage:{prompt/completion/total_tokens}, + latency_ms, error}(详情抽屉会遍历 input_messages,缺了会崩)。error 的调用无 usage/output。""" + return { + "scene": scene, + "model": model, + "error": error, + "input_messages": [ + {"role": "system", "content": f"你是比价助手,负责 {scene} 环节。"}, + {"role": "user", "content": f"[mock] 请处理本次比价的 {scene} 任务。"}, + ], + "output": None if error else f"[mock] {scene} 环节完成。", + "usage": None if error else { + "prompt_tokens": pin, "completion_tokens": cout, "total_tokens": pin + cout, + }, + "latency_ms": 780, + } + + +# ── 5 条记录蓝本:calls 决定成本;freeze=False 模拟旧记录(有 token 无 cost)── +RECORDS = [ + { + "label": "①单模型·真实样本", + "source": ("美团外卖", 4280), "best": ("京东秒送", 3680), + "store": "肯德基(建国路店)", "product": "疯狂星期四全家桶", + "info": "在京东秒送找到同款,到手价 ¥36.80,省 ¥6.00", + "freeze": True, + "calls": [ + _c("store_match", "qwen3.5-flash", 1512, 22), + _c("dish_match", "qwen3.5-flash", 2111, 160), + _c("dish_match", "qwen3.5-flash", 1940, 142), + _c("summary", "qwen3.5-flash", 1325, 13), + ], + }, + { + "label": "②多模型·flash+plus", + "source": ("淘宝闪购", 5900), "best": ("美团外卖", 5200), + "store": "瑞幸咖啡(国贸店)", "product": "生椰拿铁×2、丝绒拿铁", + "info": "在美团外卖找到同款,到手价 ¥52.00,省 ¥7.00", + "freeze": True, + "calls": [ + _c("store_match", "qwen3.5-flash", 2000, 50), + _c("dish_match", "qwen3.5-flash", 1800, 40), + _c("reasoning", "qwen3.5-plus", 3000, 500), + ], + }, + { + "label": "③未登记模型走 default", + "source": ("京东秒送", 3100), "best": ("美团外卖", 2650), + "store": "麦当劳(soho店)", "product": "麦辣鸡腿堡套餐", + "info": "在美团外卖找到同款,到手价 ¥26.50,省 ¥4.50", + "freeze": True, + "calls": [ + _c("store_match", "deepseek-v3", 5000, 800), + ], + }, + { + "label": "④旧记录·有token无成本(回退估算)", + "source": ("美团外卖", 3600), "best": ("淘宝闪购", 3200), + "store": "华莱士(双井店)", "product": "全鸡汉堡套餐", + "info": "在淘宝闪购找到同款,到手价 ¥32.00,省 ¥4.00", + "freeze": False, # 模拟本需求上线前的老记录:llm_cost_yuan=NULL → 前端回退估算 + "calls": [ + _c("store_match", "qwen3.5-flash", 2000, 100), + ], + }, + { + "label": "⑤含 error 调用(跳过计费)", + "source": ("淘宝闪购", 4100), "best": ("京东秒送", 3750), + "store": "海底捞(合生汇店)", "product": "番茄锅底、肥牛卷", + "info": "在京东秒送找到同款,到手价 ¥37.50,省 ¥3.50", + "freeze": True, + "calls": [ + _c("store_match", "qwen3.5-flash", 0, 0, error="timeout"), + _c("store_match", "qwen3.5-flash", 1500, 30), + ], + }, +] + +_PLATFORM_ID = { # 展示名 → 平台代号(comparison_results / source/best 列用) + "美团外卖": "meituan", "京东秒送": "jd", "淘宝闪购": "taobao", +} + + +def _naive_bj_now() -> datetime: + return datetime.now(_BJ).replace(tzinfo=None) + + +def clean(db) -> int: + n = db.execute( + delete(ComparisonRecord).where(ComparisonRecord.trace_id.like(f"{ID_PREFIX}%")) + ).rowcount or 0 + db.commit() + return n + + +def _build_record(spec: dict, owner_id: int | None, created_at: datetime) -> tuple[ComparisonRecord, float | None]: + """按蓝本造一条记录,LLM 派生完全对齐 _backfill_llm_calls;返回 (记录, 冻结成本或 None)。""" + calls = spec["calls"] + src_name, src_cents = spec["source"] + best_name, best_cents = spec["best"] + + # —— 与 _backfill_llm_calls 同款派生 —— + llm_call_count = len(calls) + retry_count = sum(1 for c in calls if c.get("error")) + input_tokens = sum((c.get("usage") or {}).get("prompt_tokens") or 0 for c in calls) + output_tokens = sum((c.get("usage") or {}).get("completion_tokens") or 0 for c in calls) + if spec["freeze"]: + cost, snapshot = compute_llm_cost(calls, PRICE_CFG) # 复用生产纯函数 + else: + cost, snapshot = None, None # 旧记录:回填这段代码上线前就有,只有 token 没成本 + + rec = ComparisonRecord( + user_id=owner_id, + device_id=f"{ID_PREFIX.lower()}dev", + business_type="food", + trace_id=f"{ID_PREFIX}{spec['label'][0]}", # ①..⑤ 各一,唯一 + status="success", + source_platform_id=_PLATFORM_ID.get(src_name), source_platform_name=src_name, + source_price_cents=src_cents, + best_platform_id=_PLATFORM_ID.get(best_name), best_platform_name=best_name, + best_price_cents=best_cents, + saved_amount_cents=src_cents - best_cents, + is_source_best=False, + store_name=spec["store"], + product_names=spec["product"], + information=spec["info"], + items=[{"name": spec["product"], "qty": 1}], + comparison_results=[ + {"platform_id": _PLATFORM_ID.get(src_name), "platform_name": src_name, + "price": src_cents / 100, "is_source": True, "rank": 2}, + {"platform_id": _PLATFORM_ID.get(best_name), "platform_name": best_name, + "price": best_cents / 100, "is_source": False, "rank": 1}, + ], + total_ms=90_000 + llm_call_count * 1000, + step_count=llm_call_count * 3, + llm_call_count=llm_call_count, + retry_count=retry_count, + input_tokens=input_tokens, + output_tokens=output_tokens, + llm_calls=calls, + llm_cost_yuan=cost, + llm_price_snapshot=snapshot, + created_at=created_at, + ) + return rec, cost + + +def seed(db) -> list[tuple[str, float | None]]: + app_config.set_value(db, "llm_token_price", PRICE_CFG, admin_id=None) # upsert 单价 + owner_id = db.execute(select(User.id).order_by(User.id).limit(1)).scalar() + base = _naive_bj_now() + out: list[tuple[str, float | None]] = [] + for i, spec in enumerate(RECORDS): + rec, cost = _build_record(spec, owner_id, base - timedelta(minutes=i * 3)) + db.add(rec) + out.append((spec["label"], cost)) + db.commit() + return out, owner_id + + +def main() -> None: + parser = argparse.ArgumentParser(description="造带 LLM 成本的比价记录 + app_config 模型单价(测管理后端)") + parser.add_argument("--clean-only", action="store_true", help="只清 MOCKLLM- 记录,不重建(保留单价)") + args = parser.parse_args() + + db = SessionLocal() + try: + removed = clean(db) + if removed: + print(f"🧹 已清理旧 mock 记录 {removed} 条") + if args.clean_only: + print("✅ 仅清理,已完成(app_config 单价保留)。") + return + + results, owner_id = seed(db) + print(f"\n✅ 已写入 app_config.llm_token_price(单价)+ {len(results)} 条比价记录" + f"(挂 user_id={owner_id or 'NULL(孤儿行)'})") + print("\n📋 每条冻结成本(admin 详情「LLM 成本」应显示):") + for label, cost in results: + shown = "NULL → 前端回退「估算」" if cost is None else f"¥{cost}" + print(f" {label:<20} {shown}") + print("\n👉 验收:admin「比价记录」→ trace 搜「MOCKLLM-」→ 点开详情核对 LLM 成本 + 价格快照。") + print(" 配置页「系统配置」→「福利页」Tab →「LLM 成本」卡片,单价应为「已改」态。") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/tests/test_llm_cost.py b/tests/test_llm_cost.py new file mode 100644 index 0000000..1e1c1c3 --- /dev/null +++ b/tests/test_llm_cost.py @@ -0,0 +1,184 @@ +"""LLM 调用成本计算 compute_llm_cost:按模型分桶累加 token × 单价;error/无 usage 跳过。""" +from __future__ import annotations + +from app.services.llm_cost import compute_llm_cost + +_PRICE = { + "per_model": {"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0}}, + "default": {"input_per_1m": 3.0, "output_per_1m": 15.0}, +} + + +def test_sums_per_model_single_model(): + # 真实样本:4 次 qwen3.5-flash;Σprompt=6888、Σcompletion=337 + calls = [ + {"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1512, "completion_tokens": 22}}, + {"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 2111, "completion_tokens": 160}}, + {"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1940, "completion_tokens": 142}}, + {"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1325, "completion_tokens": 13}}, + ] + cost, snapshot = compute_llm_cost(calls, _PRICE) + # 6888/1e6*0.8 + 337/1e6*2.0 = 0.0055104 + 0.000674 = 0.0061844 → round(6) + assert cost == 0.006184 + assert snapshot == { + "mode": "per_model", + "prices": { + "qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0, "_source": "per_model"}, + }, + } + + +def test_multi_model_prices_each_bucket_separately(): + calls = [ + {"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 0}}, + {"model": "gpt-x", "error": None, "usage": {"prompt_tokens": 0, "completion_tokens": 1_000_000}}, + ] + price = { + "per_model": { + "qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0}, + "gpt-x": {"input_per_1m": 10.0, "output_per_1m": 30.0}, + }, + "default": {"input_per_1m": 3.0, "output_per_1m": 15.0}, + } + cost, snap = compute_llm_cost(calls, price) + assert cost == 30.8 # qwen 1M入×0.8=0.8 + gpt-x 1M出×30=30.0 + assert set(snap["prices"]) == {"qwen3.5-flash", "gpt-x"} + + +def test_unknown_model_falls_back_to_default(): + calls = [{"model": "mystery", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 0}}] + price = {"per_model": {}, "default": {"input_per_1m": 3.0, "output_per_1m": 15.0}} + cost, snap = compute_llm_cost(calls, price) + assert cost == 3.0 + assert snap["prices"]["mystery"]["_source"] == "default" + + +def test_unpriced_model_marked_and_zero_cost(): + calls = [{"model": "mystery", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 999}}] + cost, snap = compute_llm_cost(calls, {"per_model": {}}) # 无 default + assert cost == 0.0 + assert snap["prices"]["mystery"]["unpriced"] is True + + +def test_error_and_missing_usage_calls_skipped(): + calls = [ + {"model": "qwen3.5-flash", "error": "boom", "usage": {"prompt_tokens": 9_999_999, "completion_tokens": 9_999_999}}, + {"model": "qwen3.5-flash", "error": None, "usage": None}, # 无 usage + {"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 0}}, + ] + cost, _ = compute_llm_cost(calls, _PRICE) + assert cost == 0.8 # 只有第 3 条计入 + + +def test_empty_or_all_error_returns_none(): + assert compute_llm_cost([], _PRICE) == (None, None) + assert compute_llm_cost(None, _PRICE) == (None, None) + all_error = [{"model": "x", "error": "boom", "usage": {"prompt_tokens": 100, "completion_tokens": 100}}] + assert compute_llm_cost(all_error, _PRICE) == (None, None) + + +def test_malformed_price_entry_is_treated_as_unpriced_not_raised(): + # 手改配置页可能存出残缺/非法单价(缺 output_per_1m、非 dict);不能抛异常连累 token 回填。 + calls = [ + {"model": "bad-a", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 5}}, + {"model": "bad-b", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 5}}, + {"model": "ok", "error": None, "usage": {"prompt_tokens": 1_000_000, "completion_tokens": 0}}, + ] + price = { + "per_model": { + "bad-a": {"input_per_1m": 0.8}, # 缺 output_per_1m + "bad-b": 5, # 非 dict + "ok": {"input_per_1m": 3.0, "output_per_1m": 15.0}, + }, + } + cost, snap = compute_llm_cost(calls, price) # 不得抛异常 + assert cost == 3.0 # 只有 ok(1M 入 × 3.0)计入;两个残缺项按 unpriced + assert snap["prices"]["bad-a"].get("unpriced") is True + assert snap["prices"]["bad-b"].get("unpriced") is True + + +def test_get_llm_prices_falls_back_to_default_then_uses_override(): + from app.db.session import SessionLocal + from app.models.app_config import AppConfig + from app.repositories import app_config + from app.services.llm_cost import get_llm_prices + + db = SessionLocal() + try: + # 无 override → CONFIG_DEFS 默认(含 per_model / default) + prices = get_llm_prices(db) + assert "per_model" in prices and "default" in prices + # 有 override → 用 DB 值 + app_config.set_value( + db, "llm_token_price", + {"per_model": {"m": {"input_per_1m": 1.0, "output_per_1m": 2.0}}, + "default": {"input_per_1m": 0.0, "output_per_1m": 0.0}}, + admin_id=1, + ) + assert get_llm_prices(db)["per_model"]["m"]["input_per_1m"] == 1.0 + finally: + row = db.get(AppConfig, "llm_token_price") + if row is not None: + db.delete(row) + db.commit() + db.close() + + +def test_backfill_llm_calls_stores_cost_and_snapshot(monkeypatch): + from datetime import UTC, datetime + + from app.api.v1 import compare_record + from app.db.session import SessionLocal + from app.models.app_config import AppConfig + from app.models.comparison import ComparisonRecord + from app.repositories import app_config + + sample = [ + {"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1512, "completion_tokens": 22}}, + {"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 2111, "completion_tokens": 160}}, + {"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1940, "completion_tokens": 142}}, + {"model": "qwen3.5-flash", "error": None, "usage": {"prompt_tokens": 1325, "completion_tokens": 13}}, + ] + monkeypatch.setattr(compare_record, "fetch_llm_calls", lambda trace_id: sample) + + db = SessionLocal() + try: + app_config.set_value( + db, "llm_token_price", + {"per_model": {"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0}}, + "default": {"input_per_1m": 3.0, "output_per_1m": 15.0}}, + admin_id=1, + ) + rec = ComparisonRecord( + trace_id="llmcost-bf-1", status="success", + created_at=datetime.now(UTC).replace(tzinfo=None), + ) + db.add(rec) + db.commit() + rid = rec.id + finally: + db.close() + + compare_record._backfill_llm_calls(rid, "llmcost-bf-1") # 独立 session 内回填 + + db = SessionLocal() + try: + rec = db.get(ComparisonRecord, rid) + assert rec.llm_cost_yuan == 0.006184 + assert rec.llm_price_snapshot["prices"]["qwen3.5-flash"]["input_per_1m"] == 0.8 + assert rec.input_tokens == 6888 # 现有 token 派生仍在 + finally: + db.delete(db.get(ComparisonRecord, rid)) + row = db.get(AppConfig, "llm_token_price") + if row is not None: + db.delete(row) + db.commit() + db.close() + + +def test_admin_detail_schema_exposes_llm_cost_fields(): + from app.admin.schemas.comparison import AdminComparisonDetail + + fields = AdminComparisonDetail.model_fields + assert "llm_cost_yuan" in fields + assert "llm_price_snapshot" in fields From e9fd51d119befad69eb04282a850593b0108b3d7 Mon Sep 17 00:00:00 2001 From: zhuzihao Date: Thu, 16 Jul 2026 09:40:56 +0800 Subject: [PATCH 20/24] =?UTF-8?q?fix(auth):=20=E5=8F=91=E7=A0=81=E9=98=B2?= =?UTF-8?q?=E5=88=B7=E6=94=B9=E4=B8=BA=E6=8C=89=E6=88=90=E5=8A=9F=E8=AE=A1?= =?UTF-8?q?=E6=95=B0=20+=20=E6=96=B0=E5=A2=9E=E6=AF=8F=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E6=AF=8F=E6=97=A5=E5=8F=91=E7=A0=81=E4=B8=8A=E9=99=90=20(#136)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复:发码限流原为原子「判+记」,被单号 60s 冷却挡下的重发也占设备额度 → 正常用户连点重发可能被误锁 1 小时。改为「先判后记、只对成功发码计数」: check 判在真发之前(超限直接 429、不真发),record 只在 send_code 成功后调; 被单号冷却 / 供应商失败抛 429 时直接返回、不计数。 - 新增:同一设备(device_id)+ IP 每天最多 20 次发码上限,与原每小时 5 次两道闸并存, 均按成功计数,叠一层日封顶挡低频长时间轰炸。 - 基建:ratelimit.py 新增 RateLimitRule + check_rate_limits / record_rate_limits (peek/commit 拆分);原子的 enforce_rate_limit 仍保留给登录爆破(失败也计)不变。 - 测试:补 2 个用例(冷却挡下不占额度 / 每日上限)。 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/136 Co-authored-by: zhuzihao Co-committed-by: zhuzihao --- app/api/v1/auth.py | 39 ++++++++++------ app/core/ratelimit.py | 101 ++++++++++++++++++++++++++++++++++++---- app/integrations/sms.py | 3 +- tests/test_auth.py | 61 ++++++++++++++++++++++++ tests/test_ratelimit.py | 57 +++++++++++++++++++++++ 5 files changed, 237 insertions(+), 24 deletions(-) create mode 100644 tests/test_ratelimit.py diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index 6cee047..b2b0382 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -12,11 +12,16 @@ from __future__ import annotations import logging -from fastapi import APIRouter, HTTPException, Request, status +from fastapi import APIRouter, HTTPException, Request from app.api.deps import CurrentUser, DbSession from app.core import test_account -from app.core.ratelimit import enforce_rate_limit +from app.core.ratelimit import ( + RateLimitRule, + check_rate_limits, + enforce_rate_limit, + record_rate_limits, +) from app.core.security import TokenError, decode_token, issue_token_pair from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone from app.integrations.sms import SmsError, send_code, verify_code @@ -40,9 +45,10 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) # 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。 SMS_LOGIN_MAX_PER_HOUR = 5 -# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。 +# 发码防刷(同一设备 device_id + 同一 IP,**只按成功发码计数**;被单号 60s 冷却挡下的重发不占额度): # 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。 -SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 +SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 # 每小时上限 +SMS_SEND_MAX_PER_DAY_PER_DEVICE = 20 # 每天上限(再叠一层日封顶,挡低频长时间轰炸) def _login_response( @@ -99,23 +105,26 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse: logger.info("test_account sms_send short-circuit (不真发)") return SmsSendResponse(sent=True, mock=True, cooldown_sec=0) - # 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码。 - # 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶, - # 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。 - enforce_rate_limit( - request, - scope="sms-send-device", - subject=req.device_id, - limit=SMS_SEND_MAX_PER_HOUR_PER_DEVICE, - window_sec=3600, - detail="操作过于频繁,请稍后再试", - ) + # 发码防刷:同一设备(device_id) + 同一 IP,每小时 / 每天两道闸,**均只按成功发码计数**。 + # 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,挡短信轰炸/烧钱。 + # 关键:被单号 60s 冷却挡下的重发是「没真发、没烧钱」→ 不该占额度。故 check(先判)放在真发之前 + # (超限直接 429、不真发),record(计数)只在 send_code 成功后调 —— 冷却/供应商失败抛 429 时直接返回、不计数。 + send_rules = [ + RateLimitRule("sms-send-device", SMS_SEND_MAX_PER_HOUR_PER_DEVICE, 3600, + "操作过于频繁,请稍后再试"), + RateLimitRule("sms-send-device-daily", SMS_SEND_MAX_PER_DAY_PER_DEVICE, 86400, + "今日验证码发送次数过多,请明天再试"), + ] + check_rate_limits(request, subject=req.device_id, rules=send_rules) try: cooldown = send_code(req.phone) except SmsError as e: raise HTTPException(status_code=e.status_code, detail=str(e)) from e + # 发码成功 → 两道闸各 +1(被单号冷却挡下的重发走不到这里,故不占额度) + record_rate_limits(request, subject=req.device_id, rules=send_rules) + from app.core.config import settings # 局部 import 避免循环 return SmsSendResponse(sent=True, mock=settings.SMS_MOCK, cooldown_sec=cooldown) diff --git a/app/core/ratelimit.py b/app/core/ratelimit.py index cff6545..f516ae2 100644 --- a/app/core/ratelimit.py +++ b/app/core/ratelimit.py @@ -9,29 +9,41 @@ from __future__ import annotations import threading import time +from typing import NamedTuple from fastapi import HTTPException, Request, status from app.core.config import settings -# key -> (window_start_ts, count) -_buckets: dict[str, tuple[float, int]] = {} +# key -> (window_start_ts, count, window_sec) +# 存每个 key 自己的 window_sec:_buckets 混着不同窗口(60s 广告 / 3600s 登录 / 86400s 日闸)的 key, +# GC 必须按各 key 自己的窗口判过期(见 [_purge_expired]),否则短窗口调用触发的 GC 会误删长窗口 key。 +_buckets: dict[str, tuple[float, int, float]] = {} _lock = threading.Lock() +_GC_THRESHOLD = 10000 # _buckets 超此阈值才顺手清过期 key(仿 sms.py;测试可 monkeypatch 调小强制每次扫) + + +def _purge_expired(now: float) -> None: + """清过期 key(**仅在持有 _lock 时调用**)。按每个 key 自己存的 window_sec 判过期,而非调用方的窗口 + —— _buckets 是全局共享、混着 60s(广告)/3600s(登录)/86400s(日闸)不同窗口的 key;若用调用方窗口, + 高频的 60s 广告端点触发 GC 时会把本该活 3600s/86400s 的登录/日闸计数一并删掉,使其在规模上(超阈值才 + 触发本清理)被反复清零而失效。仅在超阈值时扫,低频、开销可忽略。""" + if len(_buckets) <= _GC_THRESHOLD: + return + for k in [k for k, (s, _, w) in _buckets.items() if now - s >= w]: + _buckets.pop(k, None) def _hit(key: str, limit: int, window_sec: float) -> bool: """记一次访问。返回 True=放行,False=超限。""" now = time.monotonic() with _lock: - start, count = _buckets.get(key, (now, 0)) + start, count, _ = _buckets.get(key, (now, 0, window_sec)) if now - start >= window_sec: # 窗口过期,重置 start, count = now, 0 count += 1 - _buckets[key] = (start, count) - # 顺手清理过期 key,防内存无限涨(低频访问足够) - if len(_buckets) > 10000: - for k in [k for k, (s, _) in _buckets.items() if now - s >= window_sec]: - _buckets.pop(k, None) + _buckets[key] = (start, count, window_sec) + _purge_expired(now) # 顺手清过期 key(按各自窗口),防内存无限涨 return count <= limit @@ -83,3 +95,76 @@ def enforce_rate_limit( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=detail, ) + + +# ===================== 先判 / 后记(只按「成功」计数)===================== +# _hit 是原子「判+记」:一调用就 +1,适合登录爆破(失败尝试也要计)。但对「短信发码」这类 +# **只想给成功动作计数**的场景不合适 —— 被单号冷却挡下的重发没真发、没烧钱,不该占额度。 +# 故拆成 _peek(只判不记)+ _commit(只记):check_rate_limits 先判 → 动作 → 成功后 record。 + + +class RateLimitRule(NamedTuple): + """一条限流规则。scope 区分不同闸(不同 key 前缀);同一 (subject, IP) 在 window_sec + 内最多 limit 次,超限抛 429 用 detail 文案。 + + (scope, window_sec) 成对绑在一条规则里 —— check(先判)与 record(计数)复用同一条, + 避免两处把窗口/scope 写歪导致 key 对不上。 + """ + + scope: str + limit: int + window_sec: float + detail: str = "操作过于频繁,请稍后再试" + + +def _peek(key: str, limit: int, window_sec: float) -> bool: + """只读:当前窗口内是否还没到上限(count < limit)。**不改计数**。 + 与 [_commit] 配对实现「先判后记」——只在动作成功后才 _commit。""" + now = time.monotonic() + with _lock: + start, count, _ = _buckets.get(key, (now, 0, window_sec)) + if now - start >= window_sec: # 窗口已过期 → 视作已重置(count 归零) + count = 0 + return count < limit + + +def _commit(key: str, window_sec: float) -> None: + """记一次访问(+1)。窗口过期则以本次为起点重置。仅在动作成功后调用。""" + now = time.monotonic() + with _lock: + start, count, _ = _buckets.get(key, (now, 0, window_sec)) + if now - start >= window_sec: # 窗口过期,重置 + start, count = now, 0 + _buckets[key] = (start, count + 1, window_sec) + _purge_expired(now) # 顺手清过期 key(按各自窗口,同 [_hit]) + + +def check_rate_limits(request: Request, subject: str, rules: list[RateLimitRule]) -> None: + """【先判】一组限流:任一规则已达上限即抛 429,且**不改计数**。 + + 配合 [record_rate_limits] 实现「只按成功计数」:先 check 所有闸(全未超才继续)→ 执行动作 + → 动作**成功后**再 record。动作被下游挡下(如短信单号冷却)、没真正发生时不 record → 不占额度。 + key = `scope:subject:client_ip`(与 [enforce_rate_limit] 同款)。 + """ + if not settings.RATE_LIMIT_ENABLED: + return + ip = _client_ip(request) + for rule in rules: + if not _peek(f"{rule.scope}:{subject}:{ip}", rule.limit, rule.window_sec): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=rule.detail, + ) + + +def record_rate_limits(request: Request, subject: str, rules: list[RateLimitRule]) -> None: + """【记一次】一组限流(每条规则 +1)。仅在动作成功后调用,与 [check_rate_limits] 配对。 + + ⚠️ check→动作→record 非原子:并发突发下计数可能略超 limit(每个在途请求各 +1)。对 + 「防脚本/防轰炸」的安全网定位可接受;要精确配额需迁 Redis(见模块 docstring)。 + """ + if not settings.RATE_LIMIT_ENABLED: + return + ip = _client_ip(request) + for rule in rules: + _commit(f"{rule.scope}:{subject}:{ip}", rule.window_sec) diff --git a/app/integrations/sms.py b/app/integrations/sms.py index 3841f96..eca2a71 100644 --- a/app/integrations/sms.py +++ b/app/integrations/sms.py @@ -13,7 +13,8 @@ worker / 多机时内存不共享 → 冷却、校验都会失效,届时迁移 防刷两层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权): 1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件) - 2. 单设备(device_id)每小时频控(api 层 auth.sms_send 内 enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)。 + 2. 单设备(device_id)+ IP 每小时 / 每天频控(api 层 auth.sms_send 的 check/record_rate_limits, + **只按成功发码计数** —— 被本文件单号冷却挡下的重发不占额度)+ 极光控制台 IP 白名单/防轰炸(运维侧)。 ⚠️ 原「单 IP 频控(rate_limit 依赖)」2026-06-26 按产品要求删除、改设备维度;但 device_id 客户端可伪造/轮换, 脚本轮换 id 能绕过本层 → 挡脚本狂发主要靠极光控制台侧(+ 可选 nginx 限流)。 ⚠️ 原「单号每日上限」2026-07-03 按精简要求删除(mentor 定:登录风控只留单号冷却 + 单设备频控); diff --git a/tests/test_auth.py b/tests/test_auth.py index 810c25a..fe6b79b 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -107,6 +107,67 @@ def test_sms_send_device_ip_rate_limit(client, monkeypatch) -> None: assert r.status_code == 200, r.text +def test_sms_send_cooldown_reject_not_counted(client, monkeypatch) -> None: + """发码额度只算「成功发码」:被单号 60s 冷却挡下的重发(429)不占设备额度。 + 做法:同号狂发只成功 1 次、其余被冷却挡下;把小时额度设 2,证明换号后仍能再成功发 1 次 + —— 若冷却重发也计数,额度早被那几次耗尽。""" + from app.api.v1 import auth + from app.core import ratelimit + + monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True) + monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_HOUR_PER_DEVICE", 2) + ratelimit._buckets.clear() + + device = "dev-cooldown" + phone_a = "13710137000" + # 首发成功(小时闸计 1/2) + assert client.post( + "/api/v1/auth/sms/send", json={"phone": phone_a, "device_id": device} + ).status_code == 200 + # 同号连发 3 次:都被单号 60s 冷却挡下 → 429,且**不占**设备额度 + for _ in range(3): + r = client.post( + "/api/v1/auth/sms/send", json={"phone": phone_a, "device_id": device} + ) + assert r.status_code == 429, r.text + # 换号再发:设备额度只用了 1/2(冷却那几次没算)→ 仍放行(计到 2/2) + assert client.post( + "/api/v1/auth/sms/send", json={"phone": "13710137001", "device_id": device} + ).status_code == 200 + # 又换号:此时小时闸已 2/2 → 429(反证成功发码确实各计了 1) + r = client.post( + "/api/v1/auth/sms/send", json={"phone": "13710137002", "device_id": device} + ) + assert r.status_code == 429, r.text + + +def test_sms_send_daily_cap(client, monkeypatch) -> None: + """每天发码上限(设备 + IP):成功发码累计到日上限即 429(用不同手机号绕开单号冷却)。 + 抬高小时闸单独测日闸;超限文案含「今日」以便前端提示明天再来。""" + from app.api.v1 import auth + from app.core import ratelimit + + monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True) + monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_HOUR_PER_DEVICE", 100) # 抬高小时闸,不干扰 + monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_DAY_PER_DEVICE", 3) + ratelimit._buckets.clear() + + device = "dev-daily" + for i in range(3): + r = client.post( + "/api/v1/auth/sms/send", + json={"phone": f"13720137{i:03d}", "device_id": device}, + ) + assert r.status_code == 200, f"第 {i + 1} 次应放行: {r.text}" + # 第 4 次:同设备同 IP 当日超限 → 429 + r = client.post( + "/api/v1/auth/sms/send", + json={"phone": "13720137999", "device_id": device}, + ) + assert r.status_code == 429, r.text + assert "今日" in r.json()["detail"] + + def test_sms_login_device_ip_rate_limit(client, monkeypatch) -> None: """防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试,超出 429。 conftest 默认 RATE_LIMIT_ENABLED=false(内存计数跨用例累加),本用例临时打开并清空计数隔离。""" diff --git a/tests/test_ratelimit.py b/tests/test_ratelimit.py new file mode 100644 index 0000000..0e5603e --- /dev/null +++ b/tests/test_ratelimit.py @@ -0,0 +1,57 @@ +"""ratelimit 内存桶过期清理(GC)测试。 + +回归重点:_buckets 是**全局共享**、混着不同窗口(60s 广告 / 3600s 登录 / 86400s 日闸)的 key。 +GC 必须按【每个 key 自己存的 window_sec】判过期,而不是当前调用方的窗口 —— 否则高频的 60s 端点 +触发 GC 时会把本该存活更久的 3600s/86400s 计数(如短信日闸)一并删掉,使其被反复清零、限流失效。 +用 monkeypatch 把 _GC_THRESHOLD 调 0 强制每次都扫,免造上万条(仿 test_auth 里对 sms._GC_THRESHOLD 的做法)。 +""" +from __future__ import annotations + +from app.core import ratelimit + + +def test_purge_expired_respects_each_key_own_window(monkeypatch) -> None: + """短窗口(60s)触发的 GC 只删真正过期的 key,不得删掉仍在自身窗口内的长窗口 key。""" + monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 0) # 强制每次都扫 + ratelimit._buckets.clear() + + now = 1_000_000.0 + # 日闸:100s 前开窗、window=86400 → 远未过期,必须保留 + ratelimit._buckets["sms-send-device-daily:D:IP"] = (now - 100, 7, 86400.0) + # 登录:1800s、window=3600 → 未过期,保留 + ratelimit._buckets["sms-login-device:D:IP"] = (now - 1800, 2, 3600.0) + # 广告:120s、window=60 → 已过期,应删 + ratelimit._buckets["ad-watch-report:IP2"] = (now - 120, 3, 60.0) + + ratelimit._purge_expired(now) + + assert "sms-send-device-daily:D:IP" in ratelimit._buckets + assert "sms-login-device:D:IP" in ratelimit._buckets + assert "ad-watch-report:IP2" not in ratelimit._buckets + + +def test_purge_expired_keeps_long_window_key_older_than_short_window(monkeypatch) -> None: + """反证旧 bug:日闸 key 已老于 3600s,旧代码在 60s/3600s 端点触发 GC 时会误删它; + 现在按自身 86400s 窗口判 → 未过期 → 必须保留。""" + monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 0) + ratelimit._buckets.clear() + + now = 2_000_000.0 + # 3700s 前开窗(> 1 小时),但 window=86400 → 未过期 + ratelimit._buckets["sms-send-device-daily:D:IP"] = (now - 3700, 20, 86400.0) + + ratelimit._purge_expired(now) + + assert "sms-send-device-daily:D:IP" in ratelimit._buckets + + +def test_purge_expired_noop_below_threshold(monkeypatch) -> None: + """未超阈值时不扫(即便有过期 key 也不动),避免每次请求都 O(n) 扫全表。""" + monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 10) + ratelimit._buckets.clear() + + now = 3_000_000.0 + ratelimit._buckets["stale:IP"] = (now - 999, 1, 60.0) # 早过期,但没超阈值 + ratelimit._purge_expired(now) + + assert "stale:IP" in ratelimit._buckets # 桶数没超阈值 → 不清理 From b395648b7cc645eff7a721d48486ec35be88834a Mon Sep 17 00:00:00 2001 From: liujiahui Date: Fri, 17 Jul 2026 16:34:55 +0800 Subject: [PATCH 21/24] =?UTF-8?q?fix(withdraw):=20=E6=96=B0=E4=BA=BA?= =?UTF-8?q?=E6=A1=A3=E6=8F=90=E7=8E=B0=E6=B2=A1=E6=88=90=E5=8A=9F=E4=B9=9F?= =?UTF-8?q?=E8=A2=AB=E5=88=A4=E5=AE=9A=E5=B7=B2=E7=94=A8(=E8=A7=A3?= =?UTF-8?q?=E7=BB=91=E5=BE=AE=E4=BF=A1=E9=80=80=E5=9B=9E=E5=90=8E=200.1=20?= =?UTF-8?q?=E6=B0=B8=E4=B9=85=E9=94=81=E6=AD=BB)=20(#142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 现象 新人提现 0.1 元,提现审核中时解绑微信 —— 现金正常退回,但「新人领取的 0.1 元」被判定为已使用:该档位从提现页永久消失,重提报 400。 ## 根因 新人档(0.1/0.3)「一次性」的判定口径只看**有没有发起过**,完全不看提现单的最终状态。`withdraw_tier_states()` 里的 `used_newbie` 查询没有任何 `status` 过滤: ```python select(WithdrawOrder.amount_cents).distinct().where( WithdrawOrder.user_id == user_id, WithdrawOrder.source == "coin_cash", WithdrawOrder.amount_cents.in_(newbie_amounts), # ← 没有 status 条件:只要「存在这条单」就算用过 ) ``` 而解绑微信时,`refund_reviewing_withdraws_on_unbind` → `_refund_withdraw` 把待审核单**退回现金 + 置 `failed`**,但记录仍在库里。两者相撞:那条 `failed` 的单照样落进 `used_newbie` → **钱退了,资格没退**。 ## 改法 判定口径从「发起过没有」改为「钱是不是真到手 / 正在路上」——只认 `reviewing`/`pending`/`success`: | status | 含义 | 钱在哪 | 新人档 | |---|---|---|---| | `reviewing` / `pending` | 待审核 / 打款在途 | 已扣,挂在单上 | 占用(防重复发起) | | `success` | 打款成功 | **进了用户微信** | 用掉,永久锁死 | | `failed` | 转账失败 / **解绑退回** | 已退回余额 | **恢复可提** | | `rejected` | 审核拒绝 | 已退回余额 | **恢复可提** | 核心即查询加一行 `WithdrawOrder.status.in_(_NEWBIE_TIER_HELD_STATUSES)`。 - 下发(`withdraw-info`)与下单校验(`create_withdraw`)共用 `withdraw_tier_states`,**改一处两处同时生效**,不会出现「显示能提、点了 400」。 - 复用现有 `status` 语义,**无新字段、无 alembic 迁移**。 - **存量受害用户上线后自动恢复**(他们那条单是 `failed`,新查询天然排除它),无需数据修复脚本;已成功提现过的(`success`)仍正确锁死,不会误恢复。 ## ⚠️ 行为变更 7-9 定的「新人档发起就算(含被拒/失败)」按本次拍板**放宽为「没成功打款就恢复」**。 常规档(0.5 / 10 / 20)的「当天名额发起就算、被拒不退」规则**一行未动**。 ## 🔴 测试按要求不进本 PR —— 但既有测试会失败,合入前需同步处理 本 PR 只含源码改动。**既有测试 `tests/test_withdraw_tiers.py::test_newbie_tiers_independent_and_once_forever` 锁死的正是本次推翻的旧口径**(它断言「被拒后 0.1 消失、重提 400」)。 已实测(main 上的原版测试 + 本 PR 的新代码): ``` FAILED tests/test_withdraw_tiers.py::test_newbie_tiers_independent_and_once_forever 1 failed, 5 passed ``` **本 PR 合入前需同步调整该测试,否则 main 会红。** 本地已备好可用的测试改动(反转该断言,另加「解绑退回后恢复」回归 + 「成功打款后永久锁死」),需要即可单独提供。 ## 待办 - 上述既有测试需同步调整(否则合入后 main 红) - 纯后端逻辑改动,**未真机验证** 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: no_gen_mu Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/142 Co-authored-by: liujiahui Co-committed-by: liujiahui --- app/repositories/wallet.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index fe1e23e..29b7a2c 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -34,6 +34,10 @@ _WX_STATE_SUCCESS = "SUCCESS" _WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"} _WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM" # 用户还没在微信确认页确认 _WITHDRAW_ACTIVE_STATUSES = {"reviewing", "pending"} +# 占用新人档「一次性」资格的提现状态:进行中(reviewing/pending)或成功打款(success)。 +# 被拒/转账失败/解绑退回(rejected/failed,均已退款、钱没到手)不在此列 → 新人档恢复可提 +# (2026-07-16 修正:此前判定不看状态,解绑微信退回后 0.1 被误判已用、资格永久锁死)。 +_NEWBIE_TIER_HELD_STATUSES = {"reviewing", "pending", "success"} # 免确认收款授权状态 _WX_AUTH_ACTIVE = "TAKING_EFFECT" # 已生效,可免确认转账 _WX_AUTH_CLOSED = "CLOSED" # 已关闭(用户/商户/风控),需重新开启 @@ -622,9 +626,10 @@ def _beijing_today_start_utc() -> datetime: def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -> list[dict]: """福利页(coin_cash)提现档位的可提现状态。withdraw-info 下发与 create_withdraw 校验共用此口径。 - 规则(2026-07-09 拍板,7-9提现ui对齐): - - 新人档(0.1/0.3):账号历史一次性——只要发起过(**任意状态**,含被拒/失败,"发起就算") - 即视为已用,直接**从返回列表消失**;两档各自独立互不影响,不参与"每日选一个额度"互斥。 + 规则(2026-07-09 拍板,7-9提现ui对齐;新人档判定 2026-07-16 修正): + - 新人档(0.1/0.3):账号历史一次性——进行中(reviewing/pending)或成功打款(success)即视为 + 已用,直接**从返回列表消失**;被拒/转账失败/解绑退回(均已退款、钱没到手)则恢复可提,不永久 + 占用资格。两档各自独立互不影响,不参与"每日选一个额度"互斥。 - 常规档(0.5×3 / 10×1 / 20×1):按北京日计次,"发起就算占用"(当天创建的单不论最终状态 都计入,被拒/失败不退当天名额);三档每天只能选一个,选定后其余两档当天 other_tier_selected。 - invite_cash 本轮无档位概念 → 返回空列表(邀请页客户端仍用本地写死档位,行为不变)。 @@ -635,7 +640,7 @@ def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") - tiers = rewards.WITHDRAW_TIERS_COIN_CASH amounts = [t.amount_cents for t in tiers] newbie_amounts = [t.amount_cents for t in tiers if t.is_newbie] - # 新人档历史是否用过:任意时间、任意状态("发起就算") + # 新人档历史是否用过:进行中或已成功打款的单占用资格;被拒/失败/解绑退回(已退款)不算(恢复可提) used_newbie: set[int] = set( db.execute( select(WithdrawOrder.amount_cents) @@ -644,6 +649,7 @@ def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") - WithdrawOrder.user_id == user_id, WithdrawOrder.source == "coin_cash", WithdrawOrder.amount_cents.in_(newbie_amounts), + WithdrawOrder.status.in_(_NEWBIE_TIER_HELD_STATUSES), ) ).scalars() ) if newbie_amounts else set() From 061f6baaf15b810db6c126befb5b7633fa4c8c66 Mon Sep 17 00:00:00 2001 From: guke Date: Fri, 17 Jul 2026 21:49:10 +0800 Subject: [PATCH 22/24] =?UTF-8?q?feat(auth):=20=E5=BE=AE=E4=BF=A1=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E4=B8=8E=E6=89=8B=E6=9C=BA=E5=8F=B7=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=20/=20=E5=8D=A0=E7=94=A8=E5=86=B2=E7=AA=81=E5=A4=84=E7=90=86(M?= =?UTF-8?q?2=20+=20M3=20=C2=A710)=20(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景 / 需求 新增「微信登录」链路:用户用微信授权登录 App。openid 已绑账号即直接登入; 未绑则走「绑手机号」建号,并处理「手机号已被别的账号占用」的冲突(M2), 以及绑微信后展示昵称/头像的替换策略(M3 §10)。 关键取舍:微信登录**不套用**钱包提现里 bind-wechat 的「撞号即 409」逻辑 ——登录场景 openid 命中就应登入,不能因撞号把用户挡在门外。 ## 改动概览:新增 5 个端点(前缀 `/api/v1/auth`) | 方法 | 路径 | 作用 | |---|---|---| | POST | `/wechat-login` | code→openid。命中已绑用户→直接登入;未命中→签发 `bind_ticket`,返回 `need_bind_phone`(**此刻不建号**);未配 APP_ID/SECRET→503 | | POST | `/wechat/bind-phone/sms` | 持 `bind_ticket` + 手机号 + 短信码绑号 | | POST | `/wechat/bind-phone/jverify` | 持 `bind_ticket` + 极光本机号一键取号绑号 | | POST | `/wechat/conflict/continue` | 占用冲突·继续绑定=**登录老账号**(老号没绑微信则把 openid 绑上;已绑别的微信则只登入、丢弃本次 openid) | | POST | `/wechat/conflict/rebind` | 占用冲突·换绑=**单事务**注销老账号 X(腾号)+ 用该号重建新账号 Y + 写换绑台账;受 30 天限制 | 绑号两条取号路径(sms / jverify)尾段共用 `_finish_wechat_bind`: 手机号未注册→建微信账号(channel=wechat,昵称头像取微信)登入; 已被占用→返回 `phone_occupied`(带占用账号昵称/头像/注册时间/`has_wechat`、 `conflict_ticket`、`rebind_available`、`rebind_blocked_days`),交前端冲突页。 冲突页三选一,后端提供 continue / rebind 两个动作(「取消」为前端本地行为)。 ## 关键设计 **两种短时令牌(`core/security.py`,复用 `JWT_SECRET_KEY`,靠 `typ` 区分):** - `bind_ticket`(typ=wechat_bind,sub=openid,附微信昵称/头像,TTL 10min): openid 未命中时下发,覆盖「授权→输手机号→收码→验码」整个绑定流程。 - `conflict_ticket`(typ=wechat_conflict):比 bind_ticket **多编码已验证的手机号**; 换绑 / 继续绑定只认票里的 phone,堵住「拿别人手机号去夺号」的接管漏洞。 **30 天换绑限制:** 新增台账表 `phone_rebind_log`(手机号级、渠道无关), 记录「腾号重建」这一破坏性事件,靠 `rebound_at` 算窗口; 阈值走配置 `PHONE_REBIND_LIMIT_DAYS`。命中限制的换绑请求返回 409。 **M3 §10 昵称/头像替换:** 绑微信默认用微信昵称/头像替换展示, 抽成共享 helper,登录绑号路径与钱包绑微信路径两处共用 (故 `wallet.py`、`tests/test_withdraw.py` 一并有改动)。 --------- Co-authored-by: guke Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/139 --- alembic/versions/phone_rebind_log.py | 32 ++++ app/api/v1/auth.py | 276 ++++++++++++++++++++++++++- app/core/config.py | 9 + app/core/security.py | 85 +++++++++ app/models/__init__.py | 1 + app/models/phone_rebind_log.py | 31 +++ app/repositories/phone_rebind.py | 39 ++++ app/repositories/user.py | 132 +++++++++++++ app/repositories/wallet.py | 2 + app/schemas/auth.py | 61 ++++++ tests/test_wechat_conflict.py | 232 ++++++++++++++++++++++ tests/test_wechat_login.py | 198 +++++++++++++++++++ tests/test_withdraw.py | 88 +++++++++ 13 files changed, 1185 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/phone_rebind_log.py create mode 100644 app/models/phone_rebind_log.py create mode 100644 app/repositories/phone_rebind.py create mode 100644 tests/test_wechat_conflict.py create mode 100644 tests/test_wechat_login.py diff --git a/alembic/versions/phone_rebind_log.py b/alembic/versions/phone_rebind_log.py new file mode 100644 index 0000000..d6c0265 --- /dev/null +++ b/alembic/versions/phone_rebind_log.py @@ -0,0 +1,32 @@ +"""phone_rebind_log 表(M2 换绑 30 天限制台账) + +Revision ID: phone_rebind_log +Revises: comparison_llm_cost +""" +from alembic import op +import sqlalchemy as sa + +revision = "phone_rebind_log" +down_revision = "comparison_llm_cost" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "phone_rebind_log", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("phone", sa.String(length=20), nullable=False), + sa.Column("old_user_id", sa.Integer(), nullable=True), + sa.Column("new_user_id", sa.Integer(), nullable=False), + sa.Column("source", sa.String(length=32), nullable=False, server_default="wechat_conflict"), + sa.Column("rebound_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_phone_rebind_log_phone", "phone_rebind_log", ["phone"]) + op.create_index("ix_phone_rebind_log_rebound_at", "phone_rebind_log", ["rebound_at"]) + + +def downgrade() -> None: + op.drop_index("ix_phone_rebind_log_rebound_at", table_name="phone_rebind_log") + op.drop_index("ix_phone_rebind_log_phone", table_name="phone_rebind_log") + op.drop_table("phone_rebind_log") diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index b2b0382..e3728be 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -13,6 +13,7 @@ from __future__ import annotations import logging from fastapi import APIRouter, HTTPException, Request +from sqlalchemy.exc import IntegrityError from app.api.deps import CurrentUser, DbSession from app.core import test_account @@ -22,14 +23,25 @@ from app.core.ratelimit import ( enforce_rate_limit, record_rate_limits, ) -from app.core.security import TokenError, decode_token, issue_token_pair +from app.core.security import ( + TokenError, + create_bind_ticket, + create_conflict_ticket, + decode_bind_ticket, + decode_conflict_ticket, + decode_token, + issue_token_pair, +) +from app.integrations import wxpay from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone from app.integrations.sms import SmsError, send_code, verify_code from app.repositories import onboarding as onboarding_repo +from app.repositories import phone_rebind as rebind_repo from app.repositories import user as user_repo from app.schemas.auth import ( JverifyLoginRequest, LogoutResponse, + OccupiedAccountInfo, RefreshRequest, SmsLoginRequest, SmsSendRequest, @@ -37,6 +49,13 @@ from app.schemas.auth import ( TokenPair, TokenWithUser, UserOut, + WechatBindPhoneJverifyRequest, + WechatBindPhoneSmsRequest, + WechatBindResultResponse, + WechatConflictContinueRequest, + WechatConflictRebindRequest, + WechatLoginRequest, + WechatLoginResponse, ) logger = logging.getLogger("shagua.auth") @@ -175,6 +194,261 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit return _login_response(user, onboarding_completed=completed) +# ===================== 微信登录 ===================== + +@router.post( + "/wechat-login", + response_model=WechatLoginResponse, + summary="微信登录(openid 命中即登入,否则发绑号令牌)", +) +def wechat_login(req: WechatLoginRequest, db: DbSession) -> WechatLoginResponse: + from app.core.config import settings # 局部 import,避免循环 + + # 微信登录只需 code→openid(sns/oauth2),不需要商户转账证书;故只校验 APP_ID/SECRET。 + if not (settings.WECHAT_APP_ID and settings.WECHAT_APP_SECRET): + raise HTTPException(status_code=503, detail="wechat login not configured") + + try: + info = wxpay.code_to_userinfo(req.code) # {openid, nickname, avatar_url, raw};失败抛 ValueError + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + openid = info["openid"] + user = user_repo.get_user_by_wechat_openid(db, openid) + if user is not None: + # openid 命中 → 直接登入(绝不套用提现 bind-wechat 的"撞号即 409"逻辑) + if user.status != "active": + raise HTTPException(status_code=403, detail="account disabled") + user_repo.touch_last_login(db, user) + completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id) + logger.info("wechat_login hit user_id=%d openid=%s*** onboarded=%s", user.id, openid[:6], completed) + return WechatLoginResponse( + status="logged_in", + token=_login_response(user, onboarding_completed=completed), + ) + + # 未命中 → 签发短时 bind_ticket,进手机号绑定流程(账号此刻还不建) + ticket = create_bind_ticket( + openid=openid, + wechat_nickname=info["nickname"], + wechat_avatar_url=info["avatar_url"], + ) + logger.info("wechat_login new openid=%s*** issue bind_ticket", openid[:6]) + return WechatLoginResponse( + status="need_bind_phone", + bind_ticket=ticket, + wechat_nickname=info["nickname"], + wechat_avatar_url=info["avatar_url"], + ) + + +def _finish_wechat_bind( + db, + *, + openid: str, + wechat_nickname: str | None, + wechat_avatar_url: str | None, + phone: str, + device_id: str, +) -> WechatBindResultResponse: + """绑手机建号的公共尾段:手机号被占用 → 返回 phone_occupied(M2 处理 3 选 1); + 未占用 → 新建微信账号(channel=wechat,昵称头像取微信)→ 签 token 登入。""" + existing = user_repo.get_user_by_phone(db, phone) + if existing is not None: + from app.core.config import settings # 局部 import,避免循环 + + ticket = create_conflict_ticket( + openid=openid, + wechat_nickname=wechat_nickname, + wechat_avatar_url=wechat_avatar_url, + phone=phone, + ) + blocked = rebind_repo.rebound_within_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS) + logger.info( + "wechat bind phone occupied phone=%s by user_id=%d has_wechat=%s", + mask_phone(phone), existing.id, bool(existing.wechat_openid), + ) + return WechatBindResultResponse( + status="phone_occupied", + occupied_account=OccupiedAccountInfo( + nickname=existing.nickname, + avatar_url=existing.avatar_url, + created_at=existing.created_at, + has_wechat=bool(existing.wechat_openid), + ), + conflict_ticket=ticket, + rebind_available=not blocked, + rebind_blocked_days=( + rebind_repo.remaining_block_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS) + if blocked else 0 + ), + ) + user = user_repo.create_wechat_user( + db, + phone=phone, + openid=openid, + wechat_nickname=wechat_nickname, + wechat_avatar_url=wechat_avatar_url, + ) + completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=device_id) + logger.info("wechat bind ok user_id=%d phone=%s openid=%s*** onboarded=%s", + user.id, mask_phone(phone), openid[:6], completed) + return WechatBindResultResponse( + status="logged_in", + token=_login_response(user, onboarding_completed=completed), + ) + + +@router.post( + "/wechat/bind-phone/sms", + response_model=WechatBindResultResponse, + summary="微信登录·其他手机号(短信)绑定", +) +def wechat_bind_phone_sms( + req: WechatBindPhoneSmsRequest, request: Request, db: DbSession +) -> WechatBindResultResponse: + try: + claims = decode_bind_ticket(req.bind_ticket) + except TokenError as e: + raise HTTPException(status_code=401, detail="授权已过期,请重新用微信登录") from e + + # 防刷:同 sms/login,按 设备+IP 每小时限流(放在验证码校验之前,失败也计数) + enforce_rate_limit( + request, + scope="wechat-bind-sms-device", + subject=req.device_id, + limit=SMS_LOGIN_MAX_PER_HOUR, + window_sec=3600, + detail="登录尝试过于频繁,请稍后再试", + ) + + if not verify_code(req.phone, req.code): + raise HTTPException(status_code=400, detail="invalid sms code") + + return _finish_wechat_bind( + db, + openid=claims["openid"], + wechat_nickname=claims["wnk"], + wechat_avatar_url=claims["wav"], + phone=req.phone, + device_id=req.device_id, + ) + + +@router.post( + "/wechat/bind-phone/jverify", + response_model=WechatBindResultResponse, + summary="微信登录·本机号(极光)绑定", +) +def wechat_bind_phone_jverify( + req: WechatBindPhoneJverifyRequest, db: DbSession +) -> WechatBindResultResponse: + try: + claims = decode_bind_ticket(req.bind_ticket) + except TokenError as e: + raise HTTPException(status_code=401, detail="授权已过期,请重新用微信登录") from e + + try: + phone = verify_and_get_phone(req.login_token) + except JiguangError as e: + logger.error("[JG] verify+decrypt failed: %s", e, exc_info=True) + raise HTTPException(status_code=502, detail=f"jiguang verify failed: {e}") from e + + return _finish_wechat_bind( + db, + openid=claims["openid"], + wechat_nickname=claims["wnk"], + wechat_avatar_url=claims["wav"], + phone=phone, + device_id=req.device_id, + ) + + +# ===================== 微信占用冲突(M2) ===================== + +@router.post( + "/wechat/conflict/continue", + response_model=WechatBindResultResponse, + summary="微信占用冲突·继续绑定(登录老账号,能绑就绑)", +) +def wechat_conflict_continue( + req: WechatConflictContinueRequest, request: Request, db: DbSession +) -> WechatBindResultResponse: + try: + claims = decode_conflict_ticket(req.conflict_ticket) + except TokenError as e: + raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e + + enforce_rate_limit( + request, scope="wechat-conflict-device", subject=req.device_id, + limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="操作过于频繁,请稍后再试", + ) + + user = user_repo.get_user_by_phone(db, claims["phone"]) + if user is None: + # P 期间被腾空(老账号改号/注销)→ 前提已变,让前端重走 + raise HTTPException(status_code=409, detail="账号状态已变化,请重新登录") + if user.status != "active": + raise HTTPException(status_code=403, detail="account disabled") + + if user.wechat_openid is None: + try: + user_repo.attach_wechat_to_user( + db, user, openid=claims["openid"], + wechat_nickname=claims["wnk"], wechat_avatar_url=claims["wav"], + ) + except IntegrityError: + db.rollback() # openid 被别处绑走 → 只登入不绑 + user_repo.touch_last_login(db, user) + else: + user_repo.touch_last_login(db, user) # X 已绑别的微信 → 只登入,丢弃本次 openid + + completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id) + logger.info("wechat conflict continue user_id=%d openid=%s***", user.id, claims["openid"][:6]) + return WechatBindResultResponse( + status="logged_in", + token=_login_response(user, onboarding_completed=completed), + ) + + +@router.post( + "/wechat/conflict/rebind", + response_model=WechatBindResultResponse, + summary="微信占用冲突·换绑(注销老账号+用该号重建全新账号)", +) +def wechat_conflict_rebind( + req: WechatConflictRebindRequest, request: Request, db: DbSession +) -> WechatBindResultResponse: + from app.core.config import settings # 局部 import,避免循环 + + try: + claims = decode_conflict_ticket(req.conflict_ticket) + except TokenError as e: + raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e + + enforce_rate_limit( + request, scope="wechat-conflict-device", subject=req.device_id, + limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="操作过于频繁,请稍后再试", + ) + + phone = claims["phone"] + if rebind_repo.rebound_within_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS): + days = rebind_repo.remaining_block_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS) + raise HTTPException(status_code=409, detail=f"该手机号 {days} 天内已换绑过,暂不能再次换绑") + + user = user_repo.rebind_account( + db, phone=phone, openid=claims["openid"], + wechat_nickname=claims["wnk"], wechat_avatar_url=claims["wav"], + ) + completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id) + logger.info("wechat conflict rebind new_user_id=%d phone=%s openid=%s***", + user.id, mask_phone(phone), claims["openid"][:6]) + return WechatBindResultResponse( + status="logged_in", + token=_login_response(user, onboarding_completed=completed), + ) + + # ===================== Refresh ===================== @router.post("/refresh", response_model=TokenPair, summary="用 refresh_token 换新 token 对") diff --git a/app/core/config.py b/app/core/config.py index bf27932..fe01e31 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -44,6 +44,11 @@ class Settings(BaseSettings): JWT_ALGORITHM: str = "HS256" JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 120 JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30 + # 微信登录未命中 openid 时签发的"待绑手机"令牌有效期(JWT_SECRET_KEY 签名,typ=wechat_bind; + # 见 security.create_bind_ticket)。需覆盖"授权→输手机号→收短信→输验证码"整个绑定流程。 + WECHAT_BIND_TICKET_EXPIRE_MINUTES: int = 10 + # 一个手机号 30 天内最多换绑一次(微信占用冲突页的"换绑"动作)。见 phone_rebind_log。 + PHONE_REBIND_LIMIT_DAYS: int = 30 # ===== Admin 后台 ===== # admin 用独立 JWT secret(≠ JWT_SECRET_KEY),App 用户 token 无法越权访问后台。 @@ -81,6 +86,7 @@ class Settings(BaseSettings): SMS_SIGN_ID: int = 31729 # 极光短信签名 ID(非机密,可被 .env 覆盖) SMS_TEMPLATE_ID: int = 1 # 极光短信模板 ID(变量名 code,有效期 5 分钟) SMS_CODE_LENGTH: int = 6 # 验证码位数(本服务生成;前端 code 字段 4-8 位兼容) + SMS_DAILY_LIMIT_PER_PHONE: int = 10 # 单手机号每日发送上限(防刷 + 控费) SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破) # ===== 测试账号(release 包全流程联调用)===== @@ -106,6 +112,9 @@ class Settings(BaseSettings): # 美团调用走的代理。本机开发直连美团会 SSL EOF,需填 http://127.0.0.1:7897; # 线上国内服务器留空(=直连)。见 .env.example 与 integrations/meituan.py。 MT_CPS_PROXY: str = "" + # 本地开发:开启后 /feed 接口直接返回 mock 数据,不调美团 API、不查离线库, + # 方便前端联调 feed 卡片样式、分页、距离排序等 UI。生产必须 false。 + MT_CPS_MOCK_FEED: bool = True @property def mt_cps_configured(self) -> bool: diff --git a/app/core/security.py b/app/core/security.py index 8e852be..ebbbac0 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -87,6 +87,91 @@ def issue_token_pair(user_id: int) -> dict[str, Any]: } +def create_bind_ticket( + *, openid: str, wechat_nickname: str | None, wechat_avatar_url: str | None +) -> str: + """微信登录未命中 openid 时,签发短时"待绑手机"令牌,承载 openid + 微信昵称头像。 + + typ='wechat_bind'、sub=openid;有效期 settings.WECHAT_BIND_TICKET_EXPIRE_MINUTES 分钟。 + 与 access/refresh 用同一 JWT_SECRET_KEY 签名,靠 typ 区分,decode_bind_ticket 校验 typ。 + """ + now = _now() + expire = now + timedelta(minutes=settings.WECHAT_BIND_TICKET_EXPIRE_MINUTES) + payload: dict[str, Any] = { + "sub": openid, + "typ": "wechat_bind", + "wnk": wechat_nickname, + "wav": wechat_avatar_url, + "iat": int(now.timestamp()), + "exp": int(expire.timestamp()), + } + return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + + +def decode_bind_ticket(token: str) -> dict[str, Any]: + """解析"待绑手机"令牌,校验签名/过期/类型。失败抛 TokenError。 + + 返回 {'openid': str, 'wnk': str|None, 'wav': str|None}。 + """ + try: + payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) + except jwt.ExpiredSignatureError as e: + raise TokenError("bind ticket expired") from e + except jwt.InvalidTokenError as e: + raise TokenError(f"invalid bind ticket: {e}") from e + if payload.get("typ") != "wechat_bind": + raise TokenError(f"wrong token type: want=wechat_bind got={payload.get('typ')}") + if "sub" not in payload: + raise TokenError("bind ticket missing sub") + return {"openid": payload["sub"], "wnk": payload.get("wnk"), "wav": payload.get("wav")} + + +def create_conflict_ticket( + *, openid: str, wechat_nickname: str | None, wechat_avatar_url: str | None, phone: str +) -> str: + """手机号占用时签发的短时"冲突处理"令牌。 + + 比 bind_ticket 多编码 **已验证的手机号 phone** —— 换绑/继续绑定只认它,证明"这对 + openid/手机号刚在绑号时验证通过",免用户重输验证码,又堵住"拿自己 openid + 任意手机号 + 去夺号"的接管漏洞。typ='wechat_conflict';有效期复用 WECHAT_BIND_TICKET_EXPIRE_MINUTES。 + """ + now = _now() + expire = now + timedelta(minutes=settings.WECHAT_BIND_TICKET_EXPIRE_MINUTES) + payload: dict[str, Any] = { + "sub": openid, + "typ": "wechat_conflict", + "wnk": wechat_nickname, + "wav": wechat_avatar_url, + "phn": phone, + "iat": int(now.timestamp()), + "exp": int(expire.timestamp()), + } + return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + + +def decode_conflict_ticket(token: str) -> dict[str, Any]: + """解析"冲突处理"令牌,校验签名/过期/类型。失败抛 TokenError。 + + 返回 {'openid': str, 'wnk': str|None, 'wav': str|None, 'phone': str}。 + """ + try: + payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) + except jwt.ExpiredSignatureError as e: + raise TokenError("conflict ticket expired") from e + except jwt.InvalidTokenError as e: + raise TokenError(f"invalid conflict ticket: {e}") from e + if payload.get("typ") != "wechat_conflict": + raise TokenError(f"wrong token type: want=wechat_conflict got={payload.get('typ')}") + if "sub" not in payload or "phn" not in payload: + raise TokenError("conflict ticket missing sub/phn") + return { + "openid": payload["sub"], + "wnk": payload.get("wnk"), + "wav": payload.get("wav"), + "phone": payload["phn"], + } + + # ===================== 密码 hash(admin 后台账号用)===================== # 用户侧是手机号+验证码登录,不存密码;仅 admin 账号用 username+password 登录。 diff --git a/app/models/__init__.py b/app/models/__init__.py index 694aa00..b6303b7 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -32,6 +32,7 @@ from app.models.invite_fingerprint import InviteFingerprint # noqa: F401 from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401 from app.models.meituan_coupon import MeituanCoupon # noqa: F401 from app.models.onboarding import OnboardingCompletion # noqa: F401 +from app.models.phone_rebind_log import PhoneRebindLog # noqa: F401 from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401 from app.models.ops_stat_config import OpsStatConfig # noqa: F401 from app.models.price_observation import PriceObservation # noqa: F401 diff --git a/app/models/phone_rebind_log.py b/app/models/phone_rebind_log.py new file mode 100644 index 0000000..c28be70 --- /dev/null +++ b/app/models/phone_rebind_log.py @@ -0,0 +1,31 @@ +"""手机号换绑台账。 + +记录"手机号从老账号被夺走、重建为新账号(X 注销 → Y)"这一破坏性事件,支撑"一个手机号 +30 天内最多换绑一次"的限制。手机号级、渠道无关(source 标来源);普通微信绑定不写此表。 +见 M2 spec §4.1。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class PhoneRebindLog(Base): + __tablename__ = "phone_rebind_log" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # 被换绑的真实手机号(注意:存真实号,不是老账号被腾号后的 deleted_) + phone: Mapped[str] = mapped_column(String(20), index=True, nullable=False) + # 被注销的老账号 X;P 换绑时已被腾空(极边界)则为空 + old_user_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + # 换绑后新建的账号 Y + new_user_id: Mapped[int] = mapped_column(Integer, nullable=False) + # 换绑来源。手机号级配额、渠道无关,留字段给未来其他换绑路径共用同一份 30 天限制。 + source: Mapped[str] = mapped_column(String(32), nullable=False, default="wechat_conflict") + rebound_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) diff --git a/app/repositories/phone_rebind.py b/app/repositories/phone_rebind.py new file mode 100644 index 0000000..34275d3 --- /dev/null +++ b/app/repositories/phone_rebind.py @@ -0,0 +1,39 @@ +"""手机号换绑台账(phone_rebind_log)的查询与写入。见 M2 spec §4.1。""" +from __future__ import annotations + +import math +from datetime import datetime, timedelta, timezone + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models.phone_rebind_log import PhoneRebindLog + + +def rebound_within_days(db: Session, phone: str, days: int) -> bool: + """该手机号在最近 days 天内是否换绑过(命中 → 禁止再次换绑)。""" + since = datetime.now(timezone.utc) - timedelta(days=days) + stmt = ( + select(PhoneRebindLog.id) + .where(PhoneRebindLog.phone == phone, PhoneRebindLog.rebound_at >= since) + .limit(1) + ) + return db.execute(stmt).first() is not None + + +def remaining_block_days(db: Session, phone: str, days: int) -> int: + """距离该手机号可再次换绑还剩几天(向上取整;无记录返回 0)。""" + last = db.execute( + select(func.max(PhoneRebindLog.rebound_at)).where(PhoneRebindLog.phone == phone) + ).scalar_one_or_none() + if last is None: + return 0 + if last.tzinfo is None: # SQLite 取回 naive datetime,按 UTC 归一 + last = last.replace(tzinfo=timezone.utc) + remaining = (last + timedelta(days=days) - datetime.now(timezone.utc)).total_seconds() + return max(0, math.ceil(remaining / 86400)) + + +def add_rebind_log(db: Session, *, phone: str, old_user_id: int | None, new_user_id: int, source: str) -> None: + """写一条换绑台账(**不 commit**,交给调用方 rebind_account 的单事务)。""" + db.add(PhoneRebindLog(phone=phone, old_user_id=old_user_id, new_user_id=new_user_id, source=source)) diff --git a/app/repositories/user.py b/app/repositories/user.py index 7a560ef..2157cfc 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -12,6 +12,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from app.models.user import User +from app.repositories import phone_rebind # ===== 创建时分配的标识:用户名(对外展示账号 ID)+ 默认昵称 ===== @@ -58,6 +59,17 @@ def is_default_nickname(nickname: str | None) -> bool: ) +def apply_wechat_display_identity( + user: User, *, wechat_nickname: str | None, wechat_avatar_url: str | None +) -> None: + """§10:用已有账号绑微信时,仅当展示字段仍为默认才用微信昵称/头像替换(两规则独立); + 自定义(改过昵称/传过头像)则保留。只改内存对象,由调用方 commit。""" + if is_default_nickname(user.nickname) and wechat_nickname: + user.nickname = wechat_nickname + if user.avatar_url is None and wechat_avatar_url: + user.avatar_url = wechat_avatar_url + + def get_user_by_username(db: Session, username: str) -> User | None: return db.execute( select(User).where(User.username == username) @@ -86,6 +98,85 @@ def get_user_by_phone(db: Session, phone: str) -> User | None: return db.execute(stmt).scalar_one_or_none() +def get_user_by_wechat_openid(db: Session, openid: str) -> User | None: + stmt = select(User).where(User.wechat_openid == openid) + return db.execute(stmt).scalar_one_or_none() + + +def touch_last_login(db: Session, user: User) -> User: + """openid 命中登录时更新 last_login_at(手机号登录在 upsert_user_for_login 里已更新)。""" + user.last_login_at = datetime.now(timezone.utc) + db.commit() + db.refresh(user) + return user + + +def attach_wechat_to_user( + db: Session, user: User, *, openid: str, wechat_nickname: str | None, wechat_avatar_url: str | None +) -> User: + """继续绑定:把微信 openid + 微信源字段并入已存在账号(调用方保证 user.wechat_openid 为空)。 + + 写 wechat_openid / wechat_nickname / wechat_avatar_url,并按 §10 规则回填展示字段: + 仅当昵称仍为默认值(is_default_nickname)时用微信昵称替换,仅当头像为 null 时用微信头像替换; + 用户已自定义的展示昵称/头像始终保留,两规则相互独立。 + 撞 openid 唯一约束(O 期间被别处绑走,极罕见)时由调用方捕获 IntegrityError 兜底降级为"只登入不绑"。 + """ + user.wechat_openid = openid + user.wechat_nickname = wechat_nickname + user.wechat_avatar_url = wechat_avatar_url + user.last_login_at = datetime.now(timezone.utc) + apply_wechat_display_identity(user, wechat_nickname=wechat_nickname, wechat_avatar_url=wechat_avatar_url) + db.commit() + db.refresh(user) + return user + + +def _build_wechat_user( + db: Session, + *, + phone: str, + openid: str, + wechat_nickname: str | None, + wechat_avatar_url: str | None, +) -> User: + """构造并 db.add 一个微信账号行(register_channel='wechat',展示昵称头像取微信,缺则默认), + **不 commit**。create_wechat_user 与 rebind_account 共用,保证建号逻辑单一来源。""" + user = User( + phone=phone, + username=_gen_unique_username(db), + nickname=wechat_nickname or _gen_nickname(), + avatar_url=wechat_avatar_url, + register_channel="wechat", + wechat_openid=openid, + wechat_nickname=wechat_nickname, + wechat_avatar_url=wechat_avatar_url, + last_login_at=datetime.now(timezone.utc), + ) + db.add(user) + return user + + +def create_wechat_user( + db: Session, + *, + phone: str, + openid: str, + wechat_nickname: str | None, + wechat_avatar_url: str | None, +) -> User: + """微信登录新建账号(未占用分支)。见 _build_wechat_user。 + + openid 唯一约束是并发/重复绑定的最终防线(极罕见,openid 在 wechat-login 刚查过为空)。 + """ + user = _build_wechat_user( + db, phone=phone, openid=openid, + wechat_nickname=wechat_nickname, wechat_avatar_url=wechat_avatar_url, + ) + db.commit() + db.refresh(user) + return user + + def upsert_user_for_login( db: Session, *, @@ -154,3 +245,44 @@ def soft_delete_account(db: Session, user: User) -> None: # 释放邀请码唯一槽 user.invite_code = None db.commit() + + +def rebind_account( + db: Session, + *, + phone: str, + openid: str, + wechat_nickname: str | None, + wechat_avatar_url: str | None, + source: str = "wechat_conflict", +) -> User: + """换绑:**单事务内**注销老账号 X(腾出手机号)+ 用该号建全新微信账号 Y + 写换绑台账。 + + - 老账号可能已不存在(P 被腾空)→ old_user_id=None,直接建 Y(幂等更稳)。 + - 手机号唯一约束靠时序:先把 X.phone 改名并 flush 腾号,再插 Y。 + - 全程不中途 commit,任一步失败整体回滚,绝不出现"X 删了 Y 没建"。 + X 的字段变更等价 soft_delete_account(软删 + 匿名化 + 释放 openid/邀请码唯一槽),但不在此 commit。 + """ + old = get_user_by_phone(db, phone) + old_id = old.id if old is not None else None + if old is not None: + old.status = "deleted" + old.phone = f"deleted_{old.id}" + old.nickname = None + old.avatar_url = None + old.wechat_openid = None + old.wechat_nickname = None + old.wechat_avatar_url = None + old.invite_code = None + db.flush() # 先落 phone 改名,腾出手机号唯一约束,才能给 Y 用 + new_user = _build_wechat_user( + db, phone=phone, openid=openid, + wechat_nickname=wechat_nickname, wechat_avatar_url=wechat_avatar_url, + ) + db.flush() # 拿 new_user.id + phone_rebind.add_rebind_log( + db, phone=phone, old_user_id=old_id, new_user_id=new_user.id, source=source + ) + db.commit() + db.refresh(new_user) + return new_user diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index 29b7a2c..c5f42d0 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -20,6 +20,7 @@ from app.core.config import settings from app.core.rewards import COIN_PER_CENT, coins_to_cents from app.integrations import wxpay from app.models.user import User +from app.repositories.user import apply_wechat_display_identity from app.models.wallet import ( CashTransaction, CoinAccount, @@ -378,6 +379,7 @@ def bind_wechat_openid(db: Session, user_id: int, code: str) -> dict: user.wechat_openid = info["openid"] user.wechat_nickname = info["nickname"] user.wechat_avatar_url = info["avatar_url"] + apply_wechat_display_identity(user, wechat_nickname=info["nickname"], wechat_avatar_url=info["avatar_url"]) db.commit() return info diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 3870a52..03a03b1 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -102,3 +102,64 @@ class RefreshRequest(BaseModel): class LogoutResponse(BaseModel): ok: bool = True + + +# ===== 微信登录 ===== + +class WechatLoginRequest(BaseModel): + code: str = Field(..., min_length=1, description="微信 App 授权拿到的 code(单次有效)") + device_id: str = Field( + "", max_length=64, + description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理", + ) + + +class WechatLoginResponse(BaseModel): + # status="logged_in" → openid 命中,token 有值;"need_bind_phone" → 未命中,bind_ticket 有值 + status: str + token: TokenWithUser | None = None + bind_ticket: str | None = None + wechat_nickname: str | None = None + wechat_avatar_url: str | None = None + + +class OccupiedAccountInfo(BaseModel): + """手机号被占用时返回的原账号脱敏展示信息(供冲突页)。""" + nickname: str | None = None + avatar_url: str | None = None + created_at: datetime + has_wechat: bool = False + + +class WechatBindResultResponse(BaseModel): + # status="logged_in" → 未占用,已建号登入,token 有值; + # "phone_occupied" → 手机号被占用,occupied_account + conflict_ticket 有值,token 为 None + status: str + token: TokenWithUser | None = None + occupied_account: OccupiedAccountInfo | None = None + conflict_ticket: str | None = None # 占用时签发,换绑/继续绑定只认它 + rebind_available: bool | None = None # 该手机号 30 天内是否还能换绑(给换绑按钮预置禁用态) + rebind_blocked_days: int | None = None # 被限时剩余天数(rebind_available=False 时>0) + + +class WechatBindPhoneSmsRequest(BaseModel): + bind_ticket: str = Field(..., min_length=1) + phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$") + code: str = Field(..., min_length=4, max_length=8) + device_id: str = Field("", max_length=64) + + +class WechatBindPhoneJverifyRequest(BaseModel): + bind_ticket: str = Field(..., min_length=1) + login_token: str = Field(..., min_length=1, description="客户端 loginAuth 拿到的 loginToken") + device_id: str = Field("", max_length=64) + + +class WechatConflictContinueRequest(BaseModel): + conflict_ticket: str = Field(..., min_length=1) + device_id: str = Field("", max_length=64) + + +class WechatConflictRebindRequest(BaseModel): + conflict_ticket: str = Field(..., min_length=1) + device_id: str = Field("", max_length=64) diff --git a/tests/test_wechat_conflict.py b/tests/test_wechat_conflict.py new file mode 100644 index 0000000..5ba4b84 --- /dev/null +++ b/tests/test_wechat_conflict.py @@ -0,0 +1,232 @@ +"""微信登录 M2 测试:conflict_ticket 令牌、继续绑定(attach/只登入)、换绑(建号+软删+30天限)。 + +沿用 tests/test_wechat_login.py 风格:HTTP 走 client;微信 code→openid 用 monkeypatch; +短信走 SMS_MOCK(任意 6 位过)。数据变更用"再走一遍 wechat-login 看 openid 落在哪个账号"做行为断言。 +""" +from __future__ import annotations + +import pytest + +from app.api.v1 import auth # noqa: F401 (后续测试打桩 verify_and_get_phone 用) +from app.core import security +from app.integrations import wxpay +from app.models.phone_rebind_log import PhoneRebindLog + + +def _fake_userinfo(openid: str, nickname: str | None = "微信昵称", avatar: str | None = "http://x/a.png"): + def _f(code: str) -> dict: + return {"openid": openid, "nickname": nickname, "avatar_url": avatar, "raw": {}} + return _f + + +def _sms_occupy(client, phone: str) -> int: + """用普通短信登录占用一个手机号(register_channel=sms),返回该账号 id。""" + assert client.post("/api/v1/auth/sms/send", json={"phone": phone}).status_code == 200 + r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"}) + assert r.status_code == 200, r.text + return r.json()["user"]["id"] + + +def _occupy_via_conflict(client, monkeypatch, openid: str, phone: str, device_id: str) -> dict: + """微信登录(新 openid)→ 绑同一手机号 → 返回 phone_occupied 的响应体(含 conflict_ticket)。""" + monkeypatch.setattr(wxpay, "code_to_userinfo", _fake_userinfo(openid)) + ticket = client.post( + "/api/v1/auth/wechat-login", json={"code": "c", "device_id": device_id} + ).json()["bind_ticket"] + r = client.post( + "/api/v1/auth/wechat/bind-phone/sms", + json={"bind_ticket": ticket, "phone": phone, "code": "123456", "device_id": device_id}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "phone_occupied" + return body + + +# ===== Task 1: 模型可导入(建表由 conftest 的 create_all 完成) ===== + +def test_phone_rebind_log_model_importable() -> None: + assert PhoneRebindLog.__tablename__ == "phone_rebind_log" + + +# ===== Task 2: conflict_ticket 令牌 ===== + +def test_conflict_ticket_roundtrip() -> None: + token = security.create_conflict_ticket( + openid="oid1", wechat_nickname="昵", wechat_avatar_url="http://a", phone="13900139000" + ) + claims = security.decode_conflict_ticket(token) + assert claims["openid"] == "oid1" + assert claims["wnk"] == "昵" + assert claims["wav"] == "http://a" + assert claims["phone"] == "13900139000" + + +def test_conflict_ticket_wrong_type_rejected() -> None: + # bind_ticket 冒充 conflict_ticket → TokenError(typ 不匹配) + bind = security.create_bind_ticket(openid="oid", wechat_nickname=None, wechat_avatar_url=None) + with pytest.raises(security.TokenError): + security.decode_conflict_ticket(bind) + + +def test_conflict_ticket_expired_rejected(monkeypatch) -> None: + monkeypatch.setattr(security.settings, "WECHAT_BIND_TICKET_EXPIRE_MINUTES", -1) + token = security.create_conflict_ticket( + openid="oid", wechat_nickname=None, wechat_avatar_url=None, phone="13900139000" + ) + with pytest.raises(security.TokenError): + security.decode_conflict_ticket(token) + + +# ===== Task 3: 占用响应扩展 ===== + +def test_phone_occupied_returns_conflict_ticket_and_flags(client, monkeypatch) -> None: + phone = "13900139101" + _sms_occupy(client, phone) # 老账号 X(sms,无微信) + body = _occupy_via_conflict(client, monkeypatch, "openid_occ_101", phone, "devO1") + assert body["conflict_ticket"] + assert body["rebind_available"] is True # 首次,未换绑过 + assert body["rebind_blocked_days"] == 0 + assert body["occupied_account"]["has_wechat"] is False # X 是 sms 账号 + + +# ===== Task 4: 继续绑定 ===== + +def test_continue_attaches_wechat_and_logs_into_existing(client, monkeypatch) -> None: + """X 无微信 → 继续绑定并入 openid + 登入 X;之后同 openid 登录直接命中 X。""" + phone = "13900139201" + x_id = _sms_occupy(client, phone) # X:sms 账号,无微信 + body = _occupy_via_conflict(client, monkeypatch, "openid_cont_201", phone, "devC1") + + r = client.post( + "/api/v1/auth/wechat/conflict/continue", + json={"conflict_ticket": body["conflict_ticket"], "device_id": "devC1"}, + ) + assert r.status_code == 200, r.text + assert r.json()["status"] == "logged_in" + assert r.json()["token"]["user"]["id"] == x_id # 登入的是老账号 X + + # openid 现已并入 X:再走 wechat-login 直接命中 X + r = client.post("/api/v1/auth/wechat-login", json={"code": "c", "device_id": "devC1"}) + assert r.json()["status"] == "logged_in" + assert r.json()["token"]["user"]["id"] == x_id + + +def test_continue_when_existing_has_wechat_logs_in_and_discards_openid(client, monkeypatch) -> None: + """X 已绑别的微信 → 继续绑定只登入 X、丢弃本次 openid(不覆盖)。""" + phone = "13900139202" + # 先建一个已绑微信 O1 的账号 X(微信登录 O1 + 短信绑号) + monkeypatch.setattr(wxpay, "code_to_userinfo", _fake_userinfo("openid_o1_202")) + t = client.post("/api/v1/auth/wechat-login", json={"code": "c", "device_id": "devC2"}).json()["bind_ticket"] + x = client.post( + "/api/v1/auth/wechat/bind-phone/sms", + json={"bind_ticket": t, "phone": phone, "code": "123456", "device_id": "devC2"}, + ).json() + x_id = x["token"]["user"]["id"] + + # 新 openid O2 撞同号 → 占用(has_wechat=True)→ 继续绑定 + body = _occupy_via_conflict(client, monkeypatch, "openid_o2_202", phone, "devC2b") + assert body["occupied_account"]["has_wechat"] is True + r = client.post( + "/api/v1/auth/wechat/conflict/continue", + json={"conflict_ticket": body["conflict_ticket"], "device_id": "devC2b"}, + ) + assert r.status_code == 200, r.text + assert r.json()["token"]["user"]["id"] == x_id # 登入 X + + # O2 被丢弃:再走 wechat-login(O2)→ 仍未命中(need_bind_phone) + monkeypatch.setattr(wxpay, "code_to_userinfo", _fake_userinfo("openid_o2_202")) + assert client.post( + "/api/v1/auth/wechat-login", json={"code": "c", "device_id": "devC2b"} + ).json()["status"] == "need_bind_phone" + + +def test_continue_expired_ticket_returns_401(client, monkeypatch) -> None: + monkeypatch.setattr(security.settings, "WECHAT_BIND_TICKET_EXPIRE_MINUTES", -1) + expired = security.create_conflict_ticket( + openid="oid", wechat_nickname=None, wechat_avatar_url=None, phone="13900139209" + ) + r = client.post( + "/api/v1/auth/wechat/conflict/continue", + json={"conflict_ticket": expired, "device_id": "devC3"}, + ) + assert r.status_code == 401, r.text + + +# ===== Task 5: 换绑 ===== + +def test_rebind_creates_new_account_and_binds_openid(client, monkeypatch) -> None: + """换绑 → 建全新微信账号 Y(≠X)+ openid 落到 Y;老账号 X 被注销(手机号归 Y)。""" + phone = "13900139301" + x_id = _sms_occupy(client, phone) + body = _occupy_via_conflict(client, monkeypatch, "openid_rb_301", phone, "devR1") + + r = client.post( + "/api/v1/auth/wechat/conflict/rebind", + json={"conflict_ticket": body["conflict_ticket"], "device_id": "devR1"}, + ) + assert r.status_code == 200, r.text + y = r.json()["token"]["user"] + assert r.json()["status"] == "logged_in" + assert y["phone"] == phone + assert y["register_channel"] == "wechat" + assert y["id"] != x_id # 是全新账号,不是老账号 + + # openid 落到 Y:再走 wechat-login 命中 Y + r = client.post("/api/v1/auth/wechat-login", json={"code": "c", "device_id": "devR1"}) + assert r.json()["status"] == "logged_in" + assert r.json()["token"]["user"]["id"] == y["id"] + + +# ===== §10: continue 路径也应用展示身份回填规则 ===== + +def test_continue_applies_section10(client, monkeypatch) -> None: + """§10 via M2 attach 路径: X 是默认昵称+null头像的 sms 账号; + continue 绑定微信后,展示昵称/头像应被微信值替换,并体现在响应的 token.user 中。""" + phone = "13900139211" + _sms_occupy(client, phone) # 建 X:默认昵称, null avatar + body = _occupy_via_conflict( + client, monkeypatch, "openid_s10", phone, "devS10" + ) # fake_userinfo 默认 nickname="微信昵称", avatar="http://x/a.png" + + r = client.post( + "/api/v1/auth/wechat/conflict/continue", + json={"conflict_ticket": body["conflict_ticket"], "device_id": "devS10"}, + ) + assert r.status_code == 200, r.text + user_out = r.json()["token"]["user"] + assert user_out["nickname"] == "微信昵称" + assert user_out["avatar_url"] == "http://x/a.png" + + +def test_rebind_blocked_within_30_days(client, monkeypatch) -> None: + """同一手机号 30 天内二次换绑 → 409;占用响应 rebind_available=False。""" + phone = "13900139302" + _sms_occupy(client, phone) + body = _occupy_via_conflict(client, monkeypatch, "openid_rb_302a", phone, "devR2") + assert client.post( + "/api/v1/auth/wechat/conflict/rebind", + json={"conflict_ticket": body["conflict_ticket"], "device_id": "devR2"}, + ).status_code == 200 + + # 第二次:新 openid 撞同号 → 占用响应此时 rebind_available=False + body2 = _occupy_via_conflict(client, monkeypatch, "openid_rb_302b", phone, "devR2b") + assert body2["rebind_available"] is False + assert body2["rebind_blocked_days"] >= 1 + r = client.post( + "/api/v1/auth/wechat/conflict/rebind", + json={"conflict_ticket": body2["conflict_ticket"], "device_id": "devR2b"}, + ) + assert r.status_code == 409, r.text + + +def test_rebind_expired_ticket_returns_401(client, monkeypatch) -> None: + monkeypatch.setattr(security.settings, "WECHAT_BIND_TICKET_EXPIRE_MINUTES", -1) + expired = security.create_conflict_ticket( + openid="oid", wechat_nickname=None, wechat_avatar_url=None, phone="13900139309" + ) + r = client.post( + "/api/v1/auth/wechat/conflict/rebind", + json={"conflict_ticket": expired, "device_id": "devR3"}, + ) + assert r.status_code == 401, r.text diff --git a/tests/test_wechat_login.py b/tests/test_wechat_login.py new file mode 100644 index 0000000..84504c5 --- /dev/null +++ b/tests/test_wechat_login.py @@ -0,0 +1,198 @@ +"""微信登录 M1 测试:bind_ticket 令牌、wechat-login(openid 命中/未命中)、 +bind-phone(建号/占用/令牌过期)。 + +沿用 tests/test_auth.py 风格:HTTP 走 client fixture;微信 code→openid 用 monkeypatch +拦掉(conftest 里 WECHAT_APP_ID/SECRET 是 dummy,不真连微信);短信走 SMS_MOCK(任意 6 位通过)。 +""" +from __future__ import annotations + +import pytest + +from app.api.v1 import auth +from app.core import security +from app.integrations import wxpay + + +def _fake_userinfo(openid: str, nickname: str | None = "微信昵称", avatar: str | None = "http://x/a.png"): + """返回一个可传给 monkeypatch 的假 code_to_userinfo(忽略 code,固定返回给定 openid)。""" + def _f(code: str) -> dict: + return {"openid": openid, "nickname": nickname, "avatar_url": avatar, "raw": {}} + return _f + + +# ===== Task 1: bind_ticket 令牌 ===== + +def test_bind_ticket_roundtrip() -> None: + token = security.create_bind_ticket(openid="oid1", wechat_nickname="昵", wechat_avatar_url="http://a") + claims = security.decode_bind_ticket(token) + assert claims["openid"] == "oid1" + assert claims["wnk"] == "昵" + assert claims["wav"] == "http://a" + + +def test_bind_ticket_wrong_type_rejected() -> None: + # 用 access token 冒充 bind_ticket → TokenError(typ 不匹配) + access, _ = security.create_token(user_id=1, token_type="access") + with pytest.raises(security.TokenError): + security.decode_bind_ticket(access) + + +def test_bind_ticket_expired_rejected(monkeypatch) -> None: + monkeypatch.setattr(security.settings, "WECHAT_BIND_TICKET_EXPIRE_MINUTES", -1) + token = security.create_bind_ticket(openid="oid", wechat_nickname=None, wechat_avatar_url=None) + with pytest.raises(security.TokenError): + security.decode_bind_ticket(token) + + +# ===== Task 2: wechat-login ===== + +def test_wechat_login_new_openid_returns_bind_ticket(client, monkeypatch) -> None: + monkeypatch.setattr(wxpay, "code_to_userinfo", _fake_userinfo("openid_new_1", "小明", "http://x/m.png")) + r = client.post("/api/v1/auth/wechat-login", json={"code": "wxcode1", "device_id": "devA"}) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "need_bind_phone" + assert body["bind_ticket"] + assert body["wechat_nickname"] == "小明" + assert body["wechat_avatar_url"] == "http://x/m.png" + assert body["token"] is None + + +def test_wechat_login_invalid_code_returns_400(client, monkeypatch) -> None: + def _raise(code: str) -> dict: + raise ValueError("微信授权失败: invalid code") + monkeypatch.setattr(wxpay, "code_to_userinfo", _raise) + r = client.post("/api/v1/auth/wechat-login", json={"code": "bad", "device_id": "devA"}) + assert r.status_code == 400, r.text + + +# ===== Task 3: bind-phone/sms ===== + +def test_wechat_bind_sms_creates_account_then_openid_logs_in(client, monkeypatch) -> None: + """未占用 → 建微信账号(channel=wechat,昵称头像取微信);再次同 openid 登录 → 直接登入同一账号。""" + monkeypatch.setattr(wxpay, "code_to_userinfo", _fake_userinfo("openid_flow_2", "阿花", "http://x/h.png")) + phone = "13900139002" + + # 1) 微信登录 → 未命中 → 拿 ticket + r = client.post("/api/v1/auth/wechat-login", json={"code": "c1", "device_id": "devB"}) + ticket = r.json()["bind_ticket"] + assert ticket + + # 2) 短信绑号(SMS_MOCK:任意 6 位通过)→ 建号 + 登入 + r = client.post( + "/api/v1/auth/wechat/bind-phone/sms", + json={"bind_ticket": ticket, "phone": phone, "code": "123456", "device_id": "devB"}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "logged_in" + user = body["token"]["user"] + assert user["phone"] == phone + assert user["register_channel"] == "wechat" + assert user["nickname"] == "阿花" + assert user["avatar_url"] == "http://x/h.png" + uid = user["id"] + + # 3) 再次微信登录(同 openid)→ 命中 → 直接登入同一账号 + r = client.post("/api/v1/auth/wechat-login", json={"code": "c2", "device_id": "devB"}) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "logged_in" + assert body["token"]["user"]["id"] == uid + + +def test_wechat_bind_sms_phone_occupied(client, monkeypatch) -> None: + """手机号已被其他账号占用 → 返回 phone_occupied + 原账号信息(不建号)。""" + phone = "13900139003" + # 先用普通短信登录占用该手机号(register_channel=sms) + assert client.post("/api/v1/auth/sms/send", json={"phone": phone}).status_code == 200 + r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"}) + assert r.status_code == 200, r.text + occupied_nickname = r.json()["user"]["nickname"] + + # 微信登录(新 openid)→ 未命中 → ticket + monkeypatch.setattr(wxpay, "code_to_userinfo", _fake_userinfo("openid_occ_3")) + ticket = client.post( + "/api/v1/auth/wechat-login", json={"code": "c", "device_id": "devC"} + ).json()["bind_ticket"] + + # 绑同一手机号 → 占用 + r = client.post( + "/api/v1/auth/wechat/bind-phone/sms", + json={"bind_ticket": ticket, "phone": phone, "code": "123456", "device_id": "devC"}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "phone_occupied" + assert body["token"] is None + assert body["occupied_account"]["nickname"] == occupied_nickname + assert body["occupied_account"]["avatar_url"] is None # 短信注册账号无头像 → 序列化为 null + assert body["occupied_account"]["created_at"] + + +def test_wechat_bind_sms_expired_ticket_returns_401(client, monkeypatch) -> None: + """过期 bind_ticket → 401。""" + monkeypatch.setattr(security.settings, "WECHAT_BIND_TICKET_EXPIRE_MINUTES", -1) + expired = security.create_bind_ticket(openid="openid_exp", wechat_nickname="x", wechat_avatar_url=None) + r = client.post( + "/api/v1/auth/wechat/bind-phone/sms", + json={"bind_ticket": expired, "phone": "13900139009", "code": "123456", "device_id": "devD"}, + ) + assert r.status_code == 401, r.text + + +# ===== Task 4: bind-phone/jverify ===== + +def test_wechat_bind_jverify_creates_account(client, monkeypatch) -> None: + """本机号(极光)绑定路径:verify_and_get_phone 拦掉,未占用 → 建号登入。""" + monkeypatch.setattr(wxpay, "code_to_userinfo", _fake_userinfo("openid_jv_5", "极光用户", None)) + phone = "13900139005" + # 极光 loginToken→手机号 在 auth 模块命名空间打桩(auth.py 顶部 from ...jiguang import verify_and_get_phone) + monkeypatch.setattr(auth, "verify_and_get_phone", lambda token: phone) + + ticket = client.post( + "/api/v1/auth/wechat-login", json={"code": "c", "device_id": "devE"} + ).json()["bind_ticket"] + + r = client.post( + "/api/v1/auth/wechat/bind-phone/jverify", + json={"bind_ticket": ticket, "login_token": "jgtoken", "device_id": "devE"}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "logged_in" + user = body["token"]["user"] + assert user["phone"] == phone + assert user["register_channel"] == "wechat" + assert user["nickname"] == "极光用户" + # 微信 userinfo 隐私脱敏 avatar=None → 头像为空(客户端兜底默认头像) + assert user["avatar_url"] is None + + +def test_wechat_bind_jverify_expired_ticket_returns_401(client, monkeypatch) -> None: + """过期 bind_ticket → 401(极光绑号路径,decode 先于极光核验)。""" + monkeypatch.setattr(security.settings, "WECHAT_BIND_TICKET_EXPIRE_MINUTES", -1) + expired = security.create_bind_ticket(openid="openid_jv_exp", wechat_nickname="x", wechat_avatar_url=None) + r = client.post( + "/api/v1/auth/wechat/bind-phone/jverify", + json={"bind_ticket": expired, "login_token": "jgtoken", "device_id": "devE"}, + ) + assert r.status_code == 401, r.text + + +def test_wechat_bind_jverify_jiguang_error_returns_502(client, monkeypatch) -> None: + """极光核验失败(JiguangError)→ 502。""" + monkeypatch.setattr(wxpay, "code_to_userinfo", _fake_userinfo("openid_jv_err")) + + def _raise(token: str) -> str: + raise auth.JiguangError("mock jg failure") + + monkeypatch.setattr(auth, "verify_and_get_phone", _raise) + ticket = client.post( + "/api/v1/auth/wechat-login", json={"code": "c", "device_id": "devE"} + ).json()["bind_ticket"] + r = client.post( + "/api/v1/auth/wechat/bind-phone/jverify", + json={"bind_ticket": ticket, "login_token": "badtoken", "device_id": "devE"}, + ) + assert r.status_code == 502, r.text diff --git a/tests/test_withdraw.py b/tests/test_withdraw.py index 55e6a2f..55a978a 100644 --- a/tests/test_withdraw.py +++ b/tests/test_withdraw.py @@ -312,6 +312,94 @@ def test_bind_rejects_openid_already_bound(client, monkeypatch) -> None: assert r.status_code == 409, r.text +# ===== §10 绑微信时展示身份回填规则 ===== + +def test_bind_replaces_default_nickname_and_null_avatar(client, monkeypatch) -> None: + """§10-A: 全默认(昵称=系统默认,头像=null) → 绑微信后两个展示字段都替换为微信值。""" + monkeypatch.setattr( + "app.integrations.wxpay.code_to_userinfo", + lambda code: {"openid": "openid_s10_a", "nickname": "微信昵称A", "avatar_url": "https://x/a.png", "raw": {}}, + ) + token = _login(client, "13800003001") + r = client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + assert r.status_code == 200, r.text + + r = client.get("/api/v1/auth/me", headers=_auth(token)) + assert r.status_code == 200, r.text + body = r.json() + assert body["nickname"] == "微信昵称A" + assert body["avatar_url"] == "https://x/a.png" + + +def test_bind_keeps_customized_nickname(client, monkeypatch) -> None: + """§10-B: 用户已改过昵称 → 绑微信后昵称保留,但 null 头像仍替换为微信头像。""" + monkeypatch.setattr( + "app.integrations.wxpay.code_to_userinfo", + lambda code: {"openid": "openid_s10_b", "nickname": "微信昵称B", "avatar_url": "https://x/b.png", "raw": {}}, + ) + token = _login(client, "13800003002") + # 先把昵称改成非默认值 + r = client.patch( + "/api/v1/user/profile", json={"nickname": "我的名字"}, headers=_auth(token) + ) + assert r.status_code == 200, r.text + + r = client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + assert r.status_code == 200, r.text + + r = client.get("/api/v1/auth/me", headers=_auth(token)) + body = r.json() + assert body["nickname"] == "我的名字" # 保留自定义昵称 + assert body["avatar_url"] == "https://x/b.png" # null 头像被微信头像替换 + + +def test_bind_keeps_customized_avatar(client, monkeypatch) -> None: + """§10-C: 已有自定义头像 → 绑微信后头像保留,但默认昵称替换为微信昵称。""" + monkeypatch.setattr( + "app.integrations.wxpay.code_to_userinfo", + lambda code: {"openid": "openid_s10_c", "nickname": "微信昵称C", "avatar_url": "https://x/c.png", "raw": {}}, + ) + token = _login(client, "13800003003") + phone = "13800003003" + # 直接用 DB 给用户写入自定义头像(保持默认昵称) + db = SessionLocal() + try: + user = db.execute(select(User).where(User.phone == phone)).scalar_one() + user.avatar_url = "https://custom/av.png" + db.commit() + finally: + db.close() + + r = client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + assert r.status_code == 200, r.text + + r = client.get("/api/v1/auth/me", headers=_auth(token)) + body = r.json() + assert body["nickname"] == "微信昵称C" # 默认昵称被替换 + assert body["avatar_url"] == "https://custom/av.png" # 自定义头像保留 + + +def test_bind_wechat_empty_keeps_default(client, monkeypatch) -> None: + """§10-D: 微信侧 nickname/avatar 均为 None → 不覆盖,展示字段维持原默认值。""" + monkeypatch.setattr( + "app.integrations.wxpay.code_to_userinfo", + lambda code: {"openid": "openid_s10_d", "nickname": None, "avatar_url": None, "raw": {}}, + ) + token = _login(client, "13800003004") + + r = client.get("/api/v1/auth/me", headers=_auth(token)) + original_nickname = r.json()["nickname"] # 系统分配的默认昵称 + assert original_nickname.startswith("用户") + + r = client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + assert r.status_code == 200, r.text + + r = client.get("/api/v1/auth/me", headers=_auth(token)) + body = r.json() + assert body["nickname"] == original_nickname # 默认昵称不变 + assert body["avatar_url"] is None # 头像仍为 null + + def test_withdraw_reject_refunds(client, monkeypatch) -> None: """管理员审核拒绝 → 退回现金 + 单 rejected + 理由写入 fail_reason(用户可见)。""" monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_reject", "nickname": None, "avatar_url": None, "raw": {}}) From a86688ccfb05f550935630bbd1d4f00e03061ebb Mon Sep 17 00:00:00 2001 From: guke Date: Sat, 18 Jul 2026 19:11:45 +0800 Subject: [PATCH 23/24] =?UTF-8?q?feat(welfare):=2015=20=E5=A4=A9=E4=B8=8D?= =?UTF-8?q?=E6=B4=BB=E8=B7=83=E6=B8=85=E9=9B=B6(=E9=87=91=E5=B8=81+?= =?UTF-8?q?=E6=8A=98=E7=AE=97=E7=8E=B0=E9=87=91,=E9=82=80=E8=AF=B7?= =?UTF-8?q?=E9=87=91=E4=B8=8D=E6=B8=85)+=20=E9=A2=84=E8=AD=A6=20+=20admin?= =?UTF-8?q?=20=E6=B4=BB=E8=B7=83=E5=8F=A3=E5=BE=84=E7=BB=9F=E4=B8=80=20(#1?= =?UTF-8?q?43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景 / 目标 连续 15 天(北京自然日)没有「首页可见 / 发起比价 / 发起领券」行为的用户判定为流失,每日自动清零其金币 + 折算现金;清零前按可配置节奏预警;全程留审计。纯登录不算活跃;邀请奖励金物理隔离、不清(产品红线)。 改了什么 活跃口径共享模块 app/repositories/activity.py:worker(清零/预警)与 admin(最近活跃)单一真源,防漂移。 清零业务逻辑 inactivity.py:选取 / 逐用户清零(行锁 + 幂等)/ 预警分档 + streak 去重 / run_once 组合,预警故障逐用户隔离、绝不阻塞清零。 每日 worker inactivity_reset_worker.py(仿 daily_exchange:文件锁 + 北京日守卫 + RUN_HOUR 门槛 + 总闸)+ main.py lifespan 接线。 可插拔通知器 notifier.py(v1 LogNotifier 日志占位,预留 JPush/短信)。 配置 INACTIVITY_*(阈值 / 预警档 / 执行点 / 通道 / 开关)。 admin 最近活跃口径改用共享模块(移除 last_login_at、纳入 show/home、以 created_at 为基线)。 审计:inactivity_reset_log + inactivity_notification_log 两表 + 钱包流水双写(biz_type=inactivity_reset,ref_id 交叉)。 文档:设计 spec / 实现 plan / docs/database/ 两表字典。 关键产品决策 邀请金不清:只清金币 + 折算现金,invite_cash_balance_cents 原封(仅快照入审计)。 活跃口径 = max(created_at, 首页可见, 比价, 领券),不含 last_login_at;首页可见 = event=show + page=home。 时间边界:北京自然日 0 点对齐(见 activity.reset_cutoff)。 总闸默认关,灰度验证后再开。 数据库变更 新表:inactivity_reset_log、inactivity_notification_log。 analytics_event 新增复合覆盖索引 ix_analytics_event_active (event, page, user_id, created_at)(活跃口径聚合热点)。 修复了 base 上的迁移多头(135e79414fd0 与 phone_rebind_log 同从 comparison_llm_cost 分叉)→ 加空 merge 修订,alembic upgrade head 恢复单头正常。 ⚠️ 上线注意(合并后 / 开总闸前) INACTIVITY_RESET_ENABLED 默认 false;开启前提 = show/home 埋点全量铺满——否则"只登录不操作"且注册满 15 天的老用户会落到 created_at 基线被误清。 前端依赖:Android 端需在首页可见上报 event=show + page=home(携带登录后的 user_id)。 admin「最近活跃」口径变化(去登录、纳入 home_view、created_at 基线):属预期变化,需产品/运营知会;与 DAU(_period_active_user_ids,仍含登录)是两套指标。 清零对 C 端「金币流水」可见(biz_type=inactivity_reset,备注「15天不活跃清零」)。 灰度:先只看预警/清零名单对不对,再开总闸。 --------- Co-authored-by: guke Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/143 --- .../135e79414fd0_add_inactivity_tables.py | 68 + ...ytics_active_idx_active_composite_index.py | 36 + ..._merge_inactivity_analytics_active_idx_.py | 25 + app/admin/repositories/queries.py | 73 +- app/core/config.py | 20 + app/core/inactivity_reset_worker.py | 143 ++ app/integrations/notifier.py | 43 + app/main.py | 6 + app/models/__init__.py | 4 + app/models/analytics_event.py | 8 +- app/models/inactivity.py | 58 + app/repositories/activity.py | 101 ++ app/repositories/inactivity.py | 183 +++ docs/database/README.md | 2 + docs/database/inactivity_notification_log.md | 36 + docs/database/inactivity_reset_log.md | 35 + .../plans/2026-07-16-inactivity-reset.md | 1396 +++++++++++++++++ .../2026-07-16-inactivity-reset-design.md | 296 ++++ scripts/seed_inactivity_cases.py | 139 ++ tests/test_inactivity_reset.py | 416 +++++ 20 files changed, 3025 insertions(+), 63 deletions(-) create mode 100644 alembic/versions/135e79414fd0_add_inactivity_tables.py create mode 100644 alembic/versions/analytics_active_idx_active_composite_index.py create mode 100644 alembic/versions/merge_active_phone_merge_inactivity_analytics_active_idx_.py create mode 100644 app/core/inactivity_reset_worker.py create mode 100644 app/integrations/notifier.py create mode 100644 app/models/inactivity.py create mode 100644 app/repositories/activity.py create mode 100644 app/repositories/inactivity.py create mode 100644 docs/database/inactivity_notification_log.md create mode 100644 docs/database/inactivity_reset_log.md create mode 100644 docs/superpowers/plans/2026-07-16-inactivity-reset.md create mode 100644 docs/superpowers/specs/2026-07-16-inactivity-reset-design.md create mode 100644 scripts/seed_inactivity_cases.py create mode 100644 tests/test_inactivity_reset.py diff --git a/alembic/versions/135e79414fd0_add_inactivity_tables.py b/alembic/versions/135e79414fd0_add_inactivity_tables.py new file mode 100644 index 0000000..e522bae --- /dev/null +++ b/alembic/versions/135e79414fd0_add_inactivity_tables.py @@ -0,0 +1,68 @@ +"""add inactivity tables + +Revision ID: 135e79414fd0 +Revises: comparison_llm_cost +Create Date: 2026-07-16 18:31:02.105929 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '135e79414fd0' +down_revision: Union[str, Sequence[str], None] = 'comparison_llm_cost' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "inactivity_reset_log", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("coin_balance_before", sa.Integer(), nullable=False), + sa.Column("cash_balance_cents_before", sa.Integer(), nullable=False), + sa.Column("invite_cash_balance_cents_before", sa.Integer(), nullable=False), + sa.Column("last_active_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("inactive_days", sa.Integer(), nullable=False), + sa.Column("reason", sa.String(length=32), nullable=False), + sa.Column("reset_at", sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("inactivity_reset_log", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_inactivity_reset_log_user_id"), ["user_id"], unique=False) + batch_op.create_index(batch_op.f("ix_inactivity_reset_log_reset_at"), ["reset_at"], unique=False) + + op.create_table( + "inactivity_notification_log", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("stage", sa.Integer(), nullable=False), + sa.Column("inactive_days", sa.Integer(), nullable=False), + sa.Column("coin_balance", sa.Integer(), nullable=False), + sa.Column("cash_balance_cents", sa.Integer(), nullable=False), + sa.Column("invite_cash_balance_cents", sa.Integer(), nullable=False), + sa.Column("channel", sa.String(length=16), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("inactivity_notification_log", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_inactivity_notification_log_user_id"), ["user_id"], unique=False) + batch_op.create_index(batch_op.f("ix_inactivity_notification_log_created_at"), ["created_at"], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table("inactivity_notification_log", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_inactivity_notification_log_created_at")) + batch_op.drop_index(batch_op.f("ix_inactivity_notification_log_user_id")) + op.drop_table("inactivity_notification_log") + with op.batch_alter_table("inactivity_reset_log", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_inactivity_reset_log_reset_at")) + batch_op.drop_index(batch_op.f("ix_inactivity_reset_log_user_id")) + op.drop_table("inactivity_reset_log") diff --git a/alembic/versions/analytics_active_idx_active_composite_index.py b/alembic/versions/analytics_active_idx_active_composite_index.py new file mode 100644 index 0000000..8cdaa5c --- /dev/null +++ b/alembic/versions/analytics_active_idx_active_composite_index.py @@ -0,0 +1,36 @@ +"""analytics_event 活跃口径复合索引 + +Revision ID: analytics_active_idx +Revises: 135e79414fd0 +Create Date: 2026-07-18 17:35:00.000000 + +给 analytics_event 加活跃口径热点复合索引 (event, page, user_id, created_at): +activity.active_event_condition 按 (event=show & page=home) ∪ 比价 ∪ 领券 过滤后 +group by user_id、max(created_at)。覆盖索引让该聚合走 index-only,避免高频 show 事件全表扫。 + +⚠️ 本分支迁移树有**既有多头**:135e79414fd0(不活跃两表)与 phone_rebind_log 同从 +comparison_llm_cost 分叉,`alembic upgrade head` 会多头报错。本迁移挂在 135e79414fd0 +一侧;集成到 main 时需 `alembic merge` 合并 phone_rebind_log 那个头(与本迁移无关的既有问题)。 +""" +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "analytics_active_idx" +down_revision: Union[str, Sequence[str], None] = "135e79414fd0" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_index( + "ix_analytics_event_active", + "analytics_event", + ["event", "page", "user_id", "created_at"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_analytics_event_active", table_name="analytics_event") diff --git a/alembic/versions/merge_active_phone_merge_inactivity_analytics_active_idx_.py b/alembic/versions/merge_active_phone_merge_inactivity_analytics_active_idx_.py new file mode 100644 index 0000000..081f9b4 --- /dev/null +++ b/alembic/versions/merge_active_phone_merge_inactivity_analytics_active_idx_.py @@ -0,0 +1,25 @@ +"""merge inactivity(analytics_active_idx) + phone_rebind_log heads + +Revision ID: merge_active_phone +Revises: analytics_active_idx, phone_rebind_log +Create Date: 2026-07-18 18:52:34.001148 + +""" +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = 'merge_active_phone' +down_revision: Union[str, Sequence[str], None] = ('analytics_active_idx', 'phone_rebind_log') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index 501dba9..c9e0b74 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -11,7 +11,6 @@ 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 @@ -32,10 +31,7 @@ from app.models.wallet import ( InviteCashTransaction, WithdrawOrder, ) -from app.repositories import ad_ecpm - -# 「最近活跃」计入的行为事件(与大盘 DAU/留存活跃口径一致:开始比价 + 开始领券) -_ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT) +from app.repositories import activity, ad_ecpm # 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取") _NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct") @@ -88,49 +84,6 @@ 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 读)。 @@ -144,7 +97,7 @@ def _attach_last_active(db: Session, users: list[User]) -> None: select(AnalyticsEvent.user_id, func.max(AnalyticsEvent.created_at)) .where( AnalyticsEvent.user_id.in_(uids), - AnalyticsEvent.event.in_(_ACTIVE_EVENTS), + activity.active_event_condition(), ) .group_by(AnalyticsEvent.user_id) ).all() @@ -161,9 +114,9 @@ def _attach_last_active(db: Session, users: list[User]) -> None: ) 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)), + activity.norm_utc(u.created_at), # baseline 由 last_login_at 改为 created_at(登录不算活跃) + activity.norm_utc(ev_map.get(u.id)), + activity.norm_utc(eng_map.get(u.id)), ] u.last_active_at = max((c for c in candidates if c is not None), default=None) @@ -191,16 +144,12 @@ def list_users( (口径见 [_last_active_expr])。**offset 分页**(cursor=offset):任意列排序下游标语义统一, 代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。 日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。""" - # 最近活跃 = 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), + # 最近活跃 = max(注册时间, 最近行为事件, 最近领券发起)。baseline 由 last_login_at 改为 created_at + #(登录不代表在用 App;口径统一到 activity.py,含 home_view + 比价 + 领券,见 activity.ACTIVE_EVENTS)。 + # 未命中侧 coalesce 到 created_at(恒非空基线)。派生表 1:1,outerjoin 不放大行数。 + ev_agg, eng_agg = activity.last_active_subqueries(db) + last_active = activity.last_active_expr( + User.created_at, ev_agg, eng_agg, db.get_bind().dialect.name ) stmt = ( select(User) diff --git a/app/core/config.py b/app/core/config.py index fe01e31..5e88d11 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -178,6 +178,13 @@ class Settings(BaseSettings): # 进程内自动兑换 worker 的检查间隔(秒):每隔这么久醒一次,跨过北京 0 点就跑一轮。 # 默认 600s=10min,即 0 点后最多 10 分钟内兑完(客户端文案已注明「可能存在延迟」)。 AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600 + # === 15 天不活跃清零(app.core.inactivity_reset_worker)=== + INACTIVITY_RESET_ENABLED: bool = False # 总闸,默认关;灰度验证后再开 + INACTIVITY_RESET_DAYS: int = 15 # 不活跃阈值(天),第 (N+1) 日 0 点清 + INACTIVITY_WARN_DAYS_BEFORE: str = "7,2" # 清零前几天各推一次;""=不推。逗号分隔 + INACTIVITY_RESET_RUN_HOUR: int = 3 # 北京时间每日执行点(0-23) + INACTIVITY_NOTIFY_CHANNEL: str = "log" # log(占位) / jpush / sms + INACTIVITY_RESET_CHECK_INTERVAL_SEC: int = 1800 # worker 唤醒间隔(秒) # 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。 # 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容 # (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。 @@ -198,6 +205,19 @@ class Settings(BaseSettings): """免确认收款授权可用 = 微信支付凭证齐全 + 授权回调地址已配。""" return bool(self.wxpay_configured and self.WXPAY_AUTH_NOTIFY_URL) + @property + def inactivity_warn_stages(self) -> list[int]: + """解析 INACTIVITY_WARN_DAYS_BEFORE → 降序去重的提前天数列表。 + 丢弃非数字 / <=0 / >=RESET_DAYS 的项(空串 → 空列表 = 不推)。""" + out: list[int] = [] + for part in (self.INACTIVITY_WARN_DAYS_BEFORE or "").split(","): + part = part.strip() + if part.isdigit(): + v = int(part) + if 0 < v < self.INACTIVITY_RESET_DAYS and v not in out: + out.append(v) + return sorted(out, reverse=True) + # ===== 穿山甲激励视频(服务端发奖回调)===== # 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。 # 穿山甲后台配置的"奖励校验密钥"(m-key),验签用。每个 GroMore 广告位 m-key 不同(后台各自 diff --git a/app/core/inactivity_reset_worker.py b/app/core/inactivity_reset_worker.py new file mode 100644 index 0000000..7296d59 --- /dev/null +++ b/app/core/inactivity_reset_worker.py @@ -0,0 +1,143 @@ +"""15 天不活跃清零的进程内每日任务。 + +仿 daily_exchange_worker:App 启动自带,每 `INACTIVITY_RESET_CHECK_INTERVAL_SEC` 醒一次, +跨进北京新的一天且到达 `INACTIVITY_RESET_RUN_HOUR`(默认 3 点)后跑一轮 `run_once`(预警 + 清零)。 + +健壮性: +- **逐用户幂等**:清完余额=0 次日不再匹配;预警按 streak 去重。启动补跑 / 多次唤醒 / 重启都安全。 +- **同机多进程互斥**:文件锁保证多 worker 只有一个实际跑。 +- **开关**:settings.INACTIVITY_RESET_ENABLED=false 时不启动(默认关,灰度验证后再开)。 + +⚠️ 这是不可逆批量资金操作(清空金币 + 折算现金,**邀请现金不清**)。口径见 +app.repositories.inactivity / app.repositories.activity。 +""" +from __future__ import annotations + +import asyncio +import contextlib +import logging +import os +import time +from collections.abc import Iterator +from datetime import date, datetime +from pathlib import Path + +from sqlalchemy.exc import SQLAlchemyError + +from app.core.config import settings +from app.core.rewards import CN_TZ, cn_today +from app.db.session import SessionLocal +from app.integrations.notifier import get_notifier +from app.repositories import inactivity as inactivity_repo + +logger = logging.getLogger("shagua.inactivity") +_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "inactivity_reset.lock" + + +def _cn_today() -> date: + return cn_today() + + +def _touch_lock() -> None: + with contextlib.suppress(FileNotFoundError): + os.utime(_LOCK_PATH, None) + + +@contextlib.contextmanager +def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]: + """同机多进程保护:同一时间只允许一个清零 worker 运行。""" + _LOCK_PATH.parent.mkdir(parents=True, exist_ok=True) + fd: int | None = None + try: + try: + fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except FileExistsError: + try: + age = time.time() - _LOCK_PATH.stat().st_mtime + except FileNotFoundError: + age = stale_after_sec + 1 + if age > stale_after_sec: + with contextlib.suppress(FileNotFoundError): + _LOCK_PATH.unlink() + try: + fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except FileExistsError: + fd = None + + if fd is None: + yield False + return + + os.write(fd, f"pid={os.getpid()} started_at={int(time.time())}\n".encode("ascii")) + yield True + finally: + if fd is not None: + os.close(fd) + with contextlib.suppress(FileNotFoundError): + _LOCK_PATH.unlink() + + +def _run_once_entry() -> dict: + """跑一轮(预警 + 清零)。独立开 Session。""" + notifier = get_notifier(settings.INACTIVITY_NOTIFY_CHANNEL) + with SessionLocal() as db: + return inactivity_repo.run_once( + db, + notifier=notifier, + reset_days=settings.INACTIVITY_RESET_DAYS, + warn_stages=settings.inactivity_warn_stages, + today=_cn_today(), + ) + + +async def _run_loop() -> None: + interval = max(60, int(settings.INACTIVITY_RESET_CHECK_INTERVAL_SEC)) + lock_stale_after = max(interval * 3, 1800) + with _single_instance_lock(lock_stale_after) as lock_acquired: + if not lock_acquired: + logger.warning("inactivity reset skipped: another worker owns lock") + return + await _run_locked_loop(interval) + + +async def _run_locked_loop(interval: int) -> None: + logger.info( + "inactivity reset worker started interval=%ss run_hour=%s", + interval, + settings.INACTIVITY_RESET_RUN_HOUR, + ) + # 本进程上次跑过的北京日;None=尚未跑过本进程(当天到点即补)。 + last_run: date | None = None + try: + while True: + try: + _touch_lock() + today = _cn_today() + hour = datetime.now(CN_TZ).hour + if last_run != today and hour >= int(settings.INACTIVITY_RESET_RUN_HOUR): + result = await asyncio.to_thread(_run_once_entry) + last_run = today + logger.info("inactivity reset done date=%s result=%s", today, result) + except SQLAlchemyError: + logger.exception("inactivity reset db error") + except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出 + logger.exception("inactivity reset unexpected error") + await asyncio.sleep(interval) + except asyncio.CancelledError: + logger.info("inactivity reset worker stopped") + raise + + +def start_inactivity_reset_worker() -> asyncio.Task | None: + if not settings.INACTIVITY_RESET_ENABLED: + logger.info("inactivity reset disabled (INACTIVITY_RESET_ENABLED=false)") + return None + return asyncio.create_task(_run_loop(), name="inactivity-reset") + + +async def stop_inactivity_reset_worker(task: asyncio.Task | None) -> None: + if task is None: + return + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task diff --git a/app/integrations/notifier.py b/app/integrations/notifier.py new file mode 100644 index 0000000..5731d12 --- /dev/null +++ b/app/integrations/notifier.py @@ -0,0 +1,43 @@ +"""不活跃预警通知器(可插拔)。 + +v1 仅日志占位(LogNotifier):现状无真实推送能力(极光只用于一键登录解密 + 设备心跳告警, +心跳 worker 也只打印),先把清零主流程 + 审计做扎实。后续实现同协议的 JPushNotifier / +SmsNotifier 即可替换,worker/repo 不改。 +""" +from __future__ import annotations + +import logging +from typing import Protocol + +logger = logging.getLogger("shagua.inactivity") + + +class InactivityNotifier(Protocol): + channel: str + + def warn(self, *, user_id: int, coin: int, cash_cents: int, + stage: int, days_until_reset: int) -> str: + """发预警(只涉及会被清的金币 + 折算现金;邀请现金不清、不预警)。 + 返回状态:'sent' / 'failed' / 'placeholder'。""" + ... + + +class LogNotifier: + """占位实现:只打印,不真推。参照 heartbeat_monitor_worker「本期先不接推送」先例。""" + + channel = "log" + + def warn(self, *, user_id: int, coin: int, cash_cents: int, + stage: int, days_until_reset: int) -> str: + logger.warning( + "[inactivity-warn] user=%s coin=%s cash_cents=%s stage=T-%s days_until_reset=%s", + user_id, coin, cash_cents, stage, days_until_reset, + ) + return "placeholder" + + +def get_notifier(channel: str) -> InactivityNotifier: + """按配置返回通知器。未实现的通道(jpush/sms)暂回退 LogNotifier 占位。""" + # 后续:if channel == "jpush": return JPushNotifier() + # if channel == "sms": return SmsNotifier() + return LogNotifier() diff --git a/app/main.py b/app/main.py index 3b3b6ff..f99611e 100644 --- a/app/main.py +++ b/app/main.py @@ -49,6 +49,10 @@ from app.core.heartbeat_monitor_worker import ( start_heartbeat_monitor, stop_heartbeat_monitor, ) +from app.core.inactivity_reset_worker import ( + start_inactivity_reset_worker, + stop_inactivity_reset_worker, +) from app.core.logging import setup_logging from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client from app.core.withdraw_reconcile_worker import ( @@ -80,12 +84,14 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: reconcile_task = start_withdraw_reconcile_worker() heartbeat_task = start_heartbeat_monitor() daily_exchange_task = start_daily_exchange_worker() + inactivity_task = start_inactivity_reset_worker() try: yield finally: await stop_heartbeat_monitor(heartbeat_task) await stop_withdraw_reconcile_worker(reconcile_task) await stop_daily_exchange_worker(daily_exchange_task) + await stop_inactivity_reset_worker(inactivity_task) await aclose_pricebot_client() logger.info("shutting down") diff --git a/app/models/__init__.py b/app/models/__init__.py index b6303b7..f2c2fc0 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -27,6 +27,10 @@ from app.models.coupon_state import ( # noqa: F401 CouponSession, ) from app.models.feedback import Feedback # noqa: F401 +from app.models.inactivity import ( # noqa: F401 + InactivityNotificationLog, + InactivityResetLog, +) from app.models.invite import InviteRelation # noqa: F401 from app.models.invite_fingerprint import InviteFingerprint # noqa: F401 from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401 diff --git a/app/models/analytics_event.py b/app/models/analytics_event.py index 63110c3..21f03c5 100644 --- a/app/models/analytics_event.py +++ b/app/models/analytics_event.py @@ -15,7 +15,7 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import JSON, BigInteger, DateTime, Integer, String, func +from sqlalchemy import JSON, BigInteger, DateTime, Index, Integer, String, func from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -23,6 +23,12 @@ from app.db.base import Base class AnalyticsEvent(Base): __tablename__ = "analytics_event" + __table_args__ = ( + # 活跃口径聚合热点(activity.active_event_condition + last_active_subqueries): + # 按 (event,page) 过滤 首页可见(show/home)∪比价∪领券,再 group by user_id 取 + # max(created_at)。覆盖索引 → 该聚合走 index-only,避免高频 show 事件全表扫。 + Index("ix_analytics_event_active", "event", "page", "user_id", "created_at"), + ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) diff --git a/app/models/inactivity.py b/app/models/inactivity.py new file mode 100644 index 0000000..e67fa27 --- /dev/null +++ b/app/models/inactivity.py @@ -0,0 +1,58 @@ +"""15 天不活跃清零相关表。 + +- inactivity_reset_log:每次清零一行,记清零前三桶余额快照 + 原因 + 判定时活跃时间/不活跃天数, + 供纠纷排查(需求①)。清零同时另写 2 条钱包流水(金币 + 折算现金,biz_type=inactivity_reset), + 资金流可逐笔回溯。**邀请现金是产品红线、不清零**,invite_cash_balance_cents_before 仅为清零时 + 仍保留的邀请现金快照(便于排查、非被清金额;见 wallet.CoinAccount 注释)。 +- inactivity_notification_log:每次预警一行,记推送时余额快照 + 档位 + 通道 + 状态, + 兼作"预警去重"依据(created_at > last_active)与"待推送"占位 outbox(v1 通道=log)。 + +append-only,不更新。user_id 只索引、不设外键(同 analytics_event,避免删用户级联/历史留痕)。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class InactivityResetLog(Base): + __tablename__ = "inactivity_reset_log" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) + coin_balance_before: Mapped[int] = mapped_column(Integer, nullable=False) + cash_balance_cents_before: Mapped[int] = mapped_column(Integer, nullable=False) + invite_cash_balance_cents_before: Mapped[int] = mapped_column(Integer, nullable=False) + last_active_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + inactive_days: Mapped[int] = mapped_column(Integer, nullable=False) + reason: Mapped[str] = mapped_column(String(32), nullable=False) + reset_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" + + +class InactivityNotificationLog(Base): + __tablename__ = "inactivity_notification_log" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) + stage: Mapped[int] = mapped_column(Integer, nullable=False) # 提前天数档(如 7 / 2) + inactive_days: Mapped[int] = mapped_column(Integer, nullable=False) + coin_balance: Mapped[int] = mapped_column(Integer, nullable=False) + cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False) + invite_cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False) + channel: Mapped[str] = mapped_column(String(16), nullable=False) # log / jpush / sms + status: Mapped[str] = mapped_column(String(16), nullable=False) # placeholder / sent / failed + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/repositories/activity.py b/app/repositories/activity.py new file mode 100644 index 0000000..f3e83b1 --- /dev/null +++ b/app/repositories/activity.py @@ -0,0 +1,101 @@ +"""活跃口径唯一真源:worker(不活跃清零)与 admin(最近活跃/DAU)共用,防两处漂移。 + +口径 = max(User.created_at, AnalyticsEvent[首页可见 show/home + 比价 + 领券], CouponPromptEngagement[claim_started])。 +**不含 last_login_at**(登录/re-login 不代表在用 App);created_at 为恒非空基线。 +清零/预警按北京自然日 0 点对齐(见 reset_cutoff)。 +""" +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.orm import Session + +from app.core.rewards import CN_TZ, cn_today +from app.models.analytics_event import AnalyticsEvent +from app.models.coupon_state import CouponPromptEngagement + +# —— 活跃口径事件(与"用户管理"口径一致)—— +# 首页可见:前端埋点 event=show + page=home(组合判定,单个 event 名不足以区分,见 +# active_event_condition);其余为纯 event 名。 +HOME_VIEW_EVENT = "show" +HOME_VIEW_PAGE = "home" +COMPARE_START_EVENT = "real_compare_start" # 发起比价(含浮窗触发) +COUPON_START_EVENT = "real_coupon_start" # 发起领券 +# 纯 event 名即可判定的活跃事件(首页可见是 event+page 组合、不在此列) +ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT) +ACTIVE_ENGAGE_TYPE = "claim_started" # coupon_prompt_engagement 一键领取 + + +def active_event_condition(): + """analytics_event 中算"活跃"的行为过滤:首页可见(event=show & page=home) + ∪ 发起比价 ∪ 发起领券。worker 子查询与 admin 展示共用,单一真源。""" + return or_( + and_(AnalyticsEvent.event == HOME_VIEW_EVENT, AnalyticsEvent.page == HOME_VIEW_PAGE), + AnalyticsEvent.event.in_(ACTIVE_EVENTS), + ) + + +def as_utc(value: datetime) -> datetime: + """任意 datetime → tz-aware UTC(无时区按 UTC 解释)。用于与 DateTime(timezone=True) 列比较, + 比较绝对时刻、与会话时区无关(口径同 admin queries._as_utc)。""" + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +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 cn_midnight_utc(d: date) -> datetime: + """北京 d 日 00:00 → tz-aware UTC datetime。""" + return as_utc(datetime(d.year, d.month, d.day, tzinfo=CN_TZ)) + + +def reset_cutoff(reset_days: int, today: date | None = None) -> datetime: + """应清零边界(tz-aware UTC):last_active < 此值 ⟺ 距末次活跃已满 reset_days 天(北京 0 点对齐)。 + = 北京 00:00 of (today − (reset_days − 1))。例:reset_days=15、today=1/20 → 北京 1/6 00:00。""" + today = today or cn_today() + return cn_midnight_utc(today - timedelta(days=reset_days - 1)) + + +def last_active_subqueries(db: Session): + """两个按 user_id 预聚合的派生表:最近活跃事件(见 active_event_condition)、 + 最近领券发起(claim_started)。返回 (ev_sub, eng_sub)。口径同 admin,LEFT JOIN 用。""" + ev_sub = ( + select( + AnalyticsEvent.user_id.label("user_id"), + func.max(AnalyticsEvent.created_at).label("last_at"), + ) + .where(AnalyticsEvent.user_id.is_not(None), active_event_condition()) + .group_by(AnalyticsEvent.user_id) + .subquery() + ) + eng_sub = ( + 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 == ACTIVE_ENGAGE_TYPE, + ) + .group_by(CouponPromptEngagement.user_id) + .subquery() + ) + return ev_sub, eng_sub + + +def last_active_expr(base_col, ev_sub, eng_sub, dialect: str): + """max(base_col, 最近活跃事件, 最近领券) 的 SQL 表达式。PG 用 greatest、SQLite 用 max。 + 子聚合缺失(未命中)时 coalesce 到 base_col(= User.created_at,恒非空基线)。""" + greatest = func.greatest if dialect == "postgresql" else func.max + return greatest( + base_col, + func.coalesce(ev_sub.c.last_at, base_col), + func.coalesce(eng_sub.c.last_at, base_col), + ) diff --git a/app/repositories/inactivity.py b/app/repositories/inactivity.py new file mode 100644 index 0000000..71970cc --- /dev/null +++ b/app/repositories/inactivity.py @@ -0,0 +1,183 @@ +"""15 天不活跃清零业务逻辑(纯同步,可单测)。worker 只是它的 asyncio 外壳。 + +活跃口径复用 app.repositories.activity;清零走 wallet.grant_*(负数出账、写流水、不 commit)。 +逐用户独立事务,一个失败不影响其余。 +""" +from __future__ import annotations + +import logging +from datetime import date, datetime + +from sqlalchemy import or_, select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +from app.core.rewards import CN_TZ +from app.integrations.notifier import InactivityNotifier +from app.models.inactivity import InactivityNotificationLog, InactivityResetLog +from app.models.user import User +from app.models.wallet import CoinAccount +from app.repositories import activity +from app.repositories import wallet as wallet_repo + +logger = logging.getLogger("shagua.inactivity") + +RESET_BIZ_TYPE = "inactivity_reset" +RESET_REMARK = "15天不活跃清零" + +# 清零候选口径:金币或折算现金有余额即入选。**邀请现金不算**——它是产品红线、不清零 +# (见 wallet.CoinAccount 注释),只有邀请现金余额的用户没有可清项,故不入选。 +_ANY_BALANCE = or_( + CoinAccount.coin_balance > 0, + CoinAccount.cash_balance_cents > 0, +) + + +def _base_query(db: Session): + """select(user_id, last_active, 三桶余额),join CoinAccount + 两活跃子查询。""" + ev_sub, eng_sub = activity.last_active_subqueries(db) + dialect = db.get_bind().dialect.name + last_active = activity.last_active_expr(User.created_at, ev_sub, eng_sub, dialect) + stmt = ( + select( + User.id.label("user_id"), + last_active.label("last_active"), + CoinAccount.coin_balance, + CoinAccount.cash_balance_cents, + CoinAccount.invite_cash_balance_cents, + ) + .join(CoinAccount, CoinAccount.user_id == User.id) + .outerjoin(ev_sub, ev_sub.c.user_id == User.id) + .outerjoin(eng_sub, eng_sub.c.user_id == User.id) + ) + return stmt, last_active + + +def _cn_date(dt: datetime) -> date: + """datetime → 北京自然日(naive 视为 UTC)。""" + return activity.norm_utc(dt).astimezone(CN_TZ).date() + + +def _inactive_days(last_active: datetime, today: date) -> int: + return (today - _cn_date(last_active)).days + + +def select_inactive_users(db: Session, *, cutoff: datetime): + """应清零用户:last_active < cutoff 且金币/折算现金有余额(邀请现金不清、不计)。 + 返回 Row 列表(值已快照,可跨 commit)。""" + stmt, last_active = _base_query(db) + stmt = stmt.where(_ANY_BALANCE, last_active < activity.as_utc(cutoff)) + return db.execute(stmt).all() + + +def clear_user(db: Session, *, user_id: int, last_active: datetime, inactive_days: int, reason: str) -> bool: + """单用户清零(独立事务、行锁)。金币 + 折算现金归零 + 写审计 + 2 条流水;**邀请现金不清** + (产品红线,见 wallet.CoinAccount 注释),仅作快照记入审计。返回是否真清了(有可清余额)。""" + acc = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True) + coin, cash, invite = acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents + if coin == 0 and cash == 0: # 邀请现金不清,故不算"有可清余额" + return False + log = InactivityResetLog( + user_id=user_id, coin_balance_before=coin, cash_balance_cents_before=cash, + invite_cash_balance_cents_before=invite, last_active_at=activity.norm_utc(last_active), + inactive_days=inactive_days, reason=reason, + ) + db.add(log) + db.flush() # 拿 log.id 作 ref_id 交叉链接审计↔流水 + ref = str(log.id) + if coin: + wallet_repo.grant_coins(db, user_id, -coin, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK) + if cash: + wallet_repo.grant_cash(db, user_id, -cash, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK) + # 邀请现金(invite_cash_balance_cents)刻意不动:两本账物理隔离、邀请金是产品红线。 + db.commit() + return True + + +def run_reset_once(db: Session, *, reset_days: int, today: date) -> dict: + """扫一轮清零。逐用户独立 commit,失败隔离。""" + stats = {"scanned": 0, "cleared": 0, "failed": 0} + cutoff = activity.reset_cutoff(reset_days, today) + reason = f"inactive_{reset_days}d" + rows = select_inactive_users(db, cutoff=cutoff) # 先物化,避免边遍历边 commit + for row in rows: + stats["scanned"] += 1 + idays = _inactive_days(row.last_active, today) + try: + if clear_user(db, user_id=row.user_id, last_active=row.last_active, + inactive_days=idays, reason=reason): + stats["cleared"] += 1 + except SQLAlchemyError: + db.rollback() + stats["failed"] += 1 + return stats + + +def select_warn_candidates(db: Session, *, clear_cutoff: datetime, warn_hi: datetime): + """预警候选:clear_cutoff <= last_active < warn_hi 且有可清余额(即已进预警窗、尚未到清零)。""" + stmt, last_active = _base_query(db) + stmt = stmt.where( + _ANY_BALANCE, + last_active >= activity.as_utc(clear_cutoff), + last_active < activity.as_utc(warn_hi), + ) + return db.execute(stmt).all() + + +def run_warn_once(db: Session, notifier: InactivityNotifier, *, + reset_days: int, warn_stages: list[int], today: date) -> dict: + """扫一轮预警。每人取"最紧急的已到达档",按 streak 去重(notification_log.created_at > last_active)。 + 预警只涉及会被清的金币 + 折算现金;邀请现金不清、不预警(仅在 notification_log 记快照)。 + 逐用户 try/except 隔离:单用户通知器抛错 / DB 错不阻断其余,也绝不能拖累后续清零。""" + stats = {"warned": 0, "warn_skipped": 0, "warn_failed": 0} + if not warn_stages: + return stats + clear_cutoff = activity.reset_cutoff(reset_days, today) # 到此即清零,不再预警 + warn_hi = activity.reset_cutoff(reset_days - max(warn_stages), today) # 最早预警档边界 + ascending = sorted(warn_stages) # 最紧急(最小 k)在前 + for row in select_warn_candidates(db, clear_cutoff=clear_cutoff, warn_hi=warn_hi): + idays = _inactive_days(row.last_active, today) + stage = next((k for k in ascending if idays >= reset_days - k), None) + if stage is None: # 防御:候选已在预警窗内、stage 必命中,此分支实际不可达 + continue + try: + already = db.execute( + select(InactivityNotificationLog.id).where( + InactivityNotificationLog.user_id == row.user_id, + InactivityNotificationLog.stage == stage, + InactivityNotificationLog.created_at > activity.as_utc(row.last_active), + ).limit(1) + ).first() + if already: + stats["warn_skipped"] += 1 + continue + status = notifier.warn( + user_id=row.user_id, coin=row.coin_balance, cash_cents=row.cash_balance_cents, + stage=stage, days_until_reset=reset_days - idays, + ) + db.add(InactivityNotificationLog( + user_id=row.user_id, stage=stage, inactive_days=idays, + coin_balance=row.coin_balance, cash_balance_cents=row.cash_balance_cents, + invite_cash_balance_cents=row.invite_cash_balance_cents, # 快照,不参与"将清"额度 + channel=notifier.channel, status=status, + )) + db.commit() + stats["warned"] += 1 + except Exception: # noqa: BLE001 - 单用户预警失败(通知器抛错/DB 错)隔离,不阻断其余、不拖累清零 + db.rollback() + stats["warn_failed"] += 1 + return stats + + +def run_once(db: Session, *, notifier: InactivityNotifier, reset_days: int, + warn_stages: list[int], today: date) -> dict: + """一轮完整任务:先预警(阶段 A)再清零(阶段 B)。返回合并统计。 + 预警整段异常也**绝不阻塞清零**——清零是核心、不可逆资金操作,不能被通知故障拖住。""" + try: + warn = run_warn_once(db, notifier, reset_days=reset_days, warn_stages=warn_stages, today=today) + except Exception: # noqa: BLE001 - 预警阶段整体失败(如候选查询失败)也要继续清零 + logger.exception("inactivity warn phase failed; proceeding to reset") + db.rollback() + warn = {"warned": 0, "warn_skipped": 0, "warn_failed": 0, "warn_phase_error": 1} + reset = run_reset_once(db, reset_days=reset_days, today=today) + return {**warn, **reset} diff --git a/docs/database/README.md b/docs/database/README.md index e173694..5f93769 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -42,6 +42,8 @@ | `ad_ecpm_record` | 广告展示 eCPM 上报(收益对账) | `models/ad_ecpm.py` | [详情](./ad_ecpm_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) | +| `inactivity_reset_log` | 15 天不活跃清零审计(每次清零一行;清零前三桶余额快照+原因+不活跃天数;只清金币+现金,邀请金仅快照) | `models/inactivity.py` | [详情](./inactivity_reset_log.md) | +| `inactivity_notification_log` | 不活跃清零前预警记录(余额快照+档位+通道+状态;streak 去重依据 + 占位 outbox) | `models/inactivity.py` | [详情](./inactivity_notification_log.md) | ### 比价 / 省钱 | 表 | 用途 | 模型 | 文档 | diff --git a/docs/database/inactivity_notification_log.md b/docs/database/inactivity_notification_log.md new file mode 100644 index 0000000..e5c3b6e --- /dev/null +++ b/docs/database/inactivity_notification_log.md @@ -0,0 +1,36 @@ +# inactivity_notification_log — 不活跃清零前预警记录 + +> 模型 `app/models/inactivity.py` · 仓库 `app/repositories/inactivity.py` · 通知器 `app/integrations/notifier.py` · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +清零前按可配置节奏(`INACTIVITY_WARN_DAYS_BEFORE`,默认清零前 7 天、2 天各一次)向用户预警"账户里的 xx 金币和 xx 现金将被清零"。每发一次预警写一行,记推送时的余额快照 + 提前天数档 + 通道 + 状态。兼作两用:**预警去重**依据(同 streak 内 `stage==k 且 created_at > last_active` 即已推过、不重推)与**占位 outbox**(v1 通道=`log`,只打日志不真推;后续接 JPush/短信同层扩展)。append-only,不更新。**预警只涉及会被清的金币 + 折算现金;邀请奖励金不清、不预警**(`invite_cash_balance_cents` 仅作账户状态快照)。 + +## 用在哪 / 增删改查 +- **C(插入)**:`inactivity.run_warn_once` 命中预警档、且本 streak 未推过时,调 `notifier.warn` 后写一行(`status` = 通知器返回,占位实现为 `placeholder`)。 +- **U / D**:无(append-only)。 +- **R**:预警去重查询(`user_id + stage + created_at > last_active`);未来接真实推送时作待推送 outbox。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | **PK**, autoincrement | 主键 | +| `user_id` | Integer | NOT NULL, index | 预警对象;只索引不设外键(同 `analytics_event`) | +| `stage` | Integer | NOT NULL | 提前天数档(如 `7` / `2`,即清零前第几天推) | +| `inactive_days` | Integer | NOT NULL | 推送时的不活跃天数(北京自然日) | +| `coin_balance` | Integer | NOT NULL | 推送时金币余额快照(将被清) | +| `cash_balance_cents` | Integer | NOT NULL | 推送时折算现金余额快照(分,将被清) | +| `invite_cash_balance_cents` | Integer | NOT NULL | 推送时**邀请奖励金**余额快照(分,**不清、不在预警额度内**) | +| `channel` | String(16) | NOT NULL | 通道:`log`(占位) / `jpush` / `sms` | +| `status` | String(16) | NOT NULL | 状态:`placeholder`(占位未真推) / `sent` / `failed` | +| `created_at` | DateTime(tz) | server_default now(), index | 推送时刻;去重比 `created_at > last_active`(用户回归后 `last_active` 前移 → 旧行自然失效、开启新 streak) | + +## 关系 / Join Key +- `user_id` → `user.id`(无外键直连,靠 `user_id` 关联)。 +- 与 `inactivity_reset_log` 无直接外键;同一 streak 内先有若干预警行,到期后有一行清零。 + +## 索引与约束 +- PK `id`;`ix_inactivity_notification_log_user_id`、`ix_inactivity_notification_log_created_at`。 + +## 注意 +- **预警去重按 streak**:判据是 `created_at > last_active`;用户一有活跃(`home_view`/比价/领券),`last_active` 前移,旧预警行"失效",回归后可重新进入预警。 +- **占位实现**:v1 `LogNotifier` 只 `logger.warning("[inactivity-warn] ...")`、返回 `placeholder`,不真推(参照心跳告警"本期先不接推送"先例)。 +- **漏跑补发**:worker 漏跑数天后某用户可能同时满足多档,只补发**最紧急的未推档**(最小提前天数),避免刷屏。 diff --git a/docs/database/inactivity_reset_log.md b/docs/database/inactivity_reset_log.md new file mode 100644 index 0000000..4de9578 --- /dev/null +++ b/docs/database/inactivity_reset_log.md @@ -0,0 +1,35 @@ +# inactivity_reset_log — 15 天不活跃清零审计 + +> 模型 `app/models/inactivity.py` · 仓库 `app/repositories/inactivity.py` · worker `app/core/inactivity_reset_worker.py` · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +连续 15 天不活跃(北京自然日,活跃口径见 `app/repositories/activity.py`:`home_view` + 发起比价 + 发起领券,**不含登录**)的用户,worker 每日自动清零其**金币 + 折算现金**。每清一个用户写一行,记清零前三桶余额快照 + 原因 + 判定时的活跃时间/不活跃天数,供纠纷排查。清零同时另写 2 条钱包流水(`coin_transaction` / `cash_transaction`,`biz_type=inactivity_reset`,`ref_id=` 本表 `id`),资金流可逐笔回溯、人工恢复。**邀请奖励金(`invite_cash_balance_cents`)是产品红线、不清零**,本表 `invite_cash_balance_cents_before` 仅为清零时仍保留的邀请金快照(非被清金额)。append-only,不更新。 + +## 用在哪 / 增删改查 +- **C(插入)**:`inactivity.clear_user` 逐用户清零(独立事务、行锁)时写一行,`db.flush()` 拿 `id` 作流水 `ref_id` 交叉链接。 +- **U / D**:无(append-only 审计)。 +- **R**:纠纷排查 / 对账(与 `coin_transaction` / `cash_transaction` 的 `ref_id` 交叉核对)。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | **PK**, autoincrement | 主键;作 `ref_id` 写入两条清零流水 | +| `user_id` | Integer | NOT NULL, index | 被清零用户;只索引不设外键(同 `analytics_event`,避免删用户级联 / 留历史) | +| `coin_balance_before` | Integer | NOT NULL | 清零前金币余额(个数);= 对应 `coin_transaction.amount` 绝对值 | +| `cash_balance_cents_before` | Integer | NOT NULL | 清零前折算现金余额(分);= 对应 `cash_transaction.amount_cents` 绝对值 | +| `invite_cash_balance_cents_before` | Integer | NOT NULL | 清零时的**邀请奖励金**余额快照(分)——**不清、原封保留**,仅记录以证明"未动邀请金" | +| `last_active_at` | DateTime(tz) | nullable | 判定时的最近活跃时刻(UTC);无任何活跃信号时兜底为 `user.created_at` | +| `inactive_days` | Integer | NOT NULL | 判定时的不活跃天数(北京自然日) | +| `reason` | String(32) | NOT NULL | 清零原因,如 `inactive_15d` | +| `reset_at` | DateTime(tz) | server_default now(), index | 清零时刻 | + +## 关系 / Join Key +- `user_id` → `user.id`(无外键直连,靠 `user_id` 关联)。 +- `id` → `coin_transaction.ref_id` / `cash_transaction.ref_id`(`biz_type=inactivity_reset`):审计行 ↔ 资金流水交叉对账。 + +## 索引与约束 +- PK `id`;`ix_inactivity_reset_log_user_id`(按用户查)、`ix_inactivity_reset_log_reset_at`(按时间查)。 + +## 注意 +- **只清 2 桶**:金币 + 折算现金;**邀请现金不清**(两本账物理隔离,见 [`coin_account`](./coin_account.md) / `wallet.CoinAccount` 注释)。 +- **天然幂等**:清完余额=0,次日不再匹配;worker 重启 / 多次唤醒 / 补跑都不会重复清零或重复流水。 +- **总闸默认关**(`INACTIVITY_RESET_ENABLED=false`),灰度验证清零名单后再开。 diff --git a/docs/superpowers/plans/2026-07-16-inactivity-reset.md b/docs/superpowers/plans/2026-07-16-inactivity-reset.md new file mode 100644 index 0000000..94fcddd --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-inactivity-reset.md @@ -0,0 +1,1396 @@ +# 15 天不活跃清零(金币/现金) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 连续 15 天不活跃(北京自然日、第 16 日 0 点对齐)的用户,自动清零金币+折算现金(**邀请现金不清**——产品红线),清零前按可配置节奏预警,全程留审计。 + +> **更新(2026-07-18):** 邀请现金(invite_cash_balance_cents)由"三桶全清"改为**不清**——遵循 wallet.CoinAccount 的"物理隔离/产品红线"、与另一实现分支对齐。**以 `inactivity.clear_user` 现码为准**;下方 Task 7 的历史代码片段仍写三桶归零,阅读时以现码为准。另:**首页可见事件已定名 `event=show` + `page=home`**(原占位 `home_view`),见 `activity.active_event_condition`;下文/片段的 `home_view` 均指此信号。 + +**Architecture:** 活跃口径抽成共享模块 `app/repositories/activity.py`(worker 与 admin 共用,单一真源);业务逻辑在 `app/repositories/inactivity.py`(纯同步、可单测);进程内每日 asyncio worker `app/core/inactivity_reset_worker.py`(仿 `daily_exchange_worker`,文件锁 + 北京日守卫 + 总闸)。预警走可插拔 `InactivityNotifier`,v1 为日志占位。审计双写:`inactivity_reset_log` 专表 + 钱包流水 `biz_type=inactivity_reset`。 + +**Tech Stack:** FastAPI · SQLAlchemy 2.0 (`Mapped`) · Alembic (`render_as_batch`) · pydantic-settings · pytest + TestClient/SQLite。 + +**Spec:** `docs/superpowers/specs/2026-07-16-inactivity-reset-design.md` + +**活跃口径(最终):** `last_active = max(User.created_at, AnalyticsEvent[home_view/real_compare_start/real_coupon_start], CouponPromptEngagement[claim_started])`。**不含 last_login_at**;`created_at` 为恒非空基线。`home_view` 事件名前端明天敲定,后端以常量 `HOME_VIEW_EVENT` 占位(暂 `"home_view"`)。 + +**清零边界:** 末次活跃日记为「第 1 日」→ 第 16 日 0 点(北京)清零 = `北京 00:00 of (last_active_date + RESET_DAYS)`。等价 `应清零 ⟺ last_active < reset_cutoff = 北京 00:00 of (cn_today() − (RESET_DAYS−1))`。 + +--- + +## File Structure + +**新增** +- `app/models/inactivity.py` — `InactivityResetLog` + `InactivityNotificationLog`(两个小关联模型同文件,仿 `wallet.py` 多模型同文件) +- `app/repositories/activity.py` — 活跃口径唯一真源(常量 + tz 助手 + cutoff + 子查询 + `last_active_expr`) +- `app/integrations/notifier.py` — `InactivityNotifier` 协议 + `LogNotifier` + `get_notifier` +- `app/repositories/inactivity.py` — 选取/清零/预警业务逻辑(纯同步) +- `app/core/inactivity_reset_worker.py` — 每日 worker(asyncio + 文件锁 + 守卫 + 启停) +- `alembic/versions/_add_inactivity_tables.py` — 建两表迁移 +- `tests/test_inactivity_reset.py` — 单测/集成 + +**改动** +- `app/models/__init__.py` — 注册两模型 +- `app/core/config.py` — `INACTIVITY_*` 配置 + `inactivity_warn_stages` 属性 +- `app/main.py` — lifespan 接线 start/stop worker +- `app/admin/repositories/queries.py`、`app/admin/repositories/stats.py` — 改用 `activity.py`(移除本地重复口径) + +--- + +## Task 1: 两张新表模型 + 注册 + +**Files:** +- Create: `app/models/inactivity.py` +- Modify: `app/models/__init__.py` +- Test: `tests/test_inactivity_reset.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_inactivity_reset.py`: + +```python +"""15 天不活跃清零:模型 / 活跃口径 / 清零 / 预警 / 配置 / worker。""" +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone + +from sqlalchemy import select + +from app.db.session import SessionLocal +from app.models.inactivity import InactivityNotificationLog, InactivityResetLog + + +def test_reset_and_notification_models_persist() -> None: + db = SessionLocal() + try: + db.add(InactivityResetLog( + user_id=1, coin_balance_before=10, cash_balance_cents_before=20, + invite_cash_balance_cents_before=30, + last_active_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + inactive_days=15, reason="inactive_15d", + )) + db.add(InactivityNotificationLog( + user_id=1, stage=7, inactive_days=8, coin_balance=10, + cash_balance_cents=20, invite_cash_balance_cents=30, + channel="log", status="placeholder", + )) + db.commit() + r = db.execute(select(InactivityResetLog).where(InactivityResetLog.user_id == 1)).scalar_one() + assert r.reason == "inactive_15d" and r.reset_at is not None + n = db.execute(select(InactivityNotificationLog).where(InactivityNotificationLog.user_id == 1)).scalar_one() + assert n.stage == 7 and n.created_at is not None + finally: + db.rollback() + db.close() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_inactivity_reset.py::test_reset_and_notification_models_persist -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.models.inactivity'` + +- [ ] **Step 3: Create the models** + +Create `app/models/inactivity.py`: + +```python +"""15 天不活跃清零相关表。 + +- inactivity_reset_log:每次清零一行,记清零前三桶余额 + 原因 + 判定时活跃时间/不活跃天数, + 供纠纷排查(需求①)。清零同时另写 3 条钱包流水(biz_type=inactivity_reset),资金流可逐笔回溯。 +- inactivity_notification_log:每次预警一行,记推送时余额快照 + 档位 + 通道 + 状态, + 兼作"预警去重"依据(created_at > last_active)与"待推送"占位 outbox(v1 通道=log)。 + +append-only,不更新。user_id 只索引、不设外键(同 analytics_event,避免删用户级联/历史留痕)。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class InactivityResetLog(Base): + __tablename__ = "inactivity_reset_log" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) + coin_balance_before: Mapped[int] = mapped_column(Integer, nullable=False) + cash_balance_cents_before: Mapped[int] = mapped_column(Integer, nullable=False) + invite_cash_balance_cents_before: Mapped[int] = mapped_column(Integer, nullable=False) + last_active_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + inactive_days: Mapped[int] = mapped_column(Integer, nullable=False) + reason: Mapped[str] = mapped_column(String(32), nullable=False) + reset_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" + + +class InactivityNotificationLog(Base): + __tablename__ = "inactivity_notification_log" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) + stage: Mapped[int] = mapped_column(Integer, nullable=False) # 提前天数档(如 7 / 2) + inactive_days: Mapped[int] = mapped_column(Integer, nullable=False) + coin_balance: Mapped[int] = mapped_column(Integer, nullable=False) + cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False) + invite_cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False) + channel: Mapped[str] = mapped_column(String(16), nullable=False) # log / jpush / sms + status: Mapped[str] = mapped_column(String(16), nullable=False) # placeholder / sent / failed + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" +``` + +- [ ] **Step 4: Register in models/__init__.py** + +In `app/models/__init__.py`, add after the `from app.models.invite import ...` line (keep alphabetical-ish grouping near other domain models): + +```python +from app.models.inactivity import ( # noqa: F401 + InactivityNotificationLog, + InactivityResetLog, +) +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pytest tests/test_inactivity_reset.py::test_reset_and_notification_models_persist -q` +Expected: PASS (conftest builds all tables via `Base.metadata.create_all`, so the new tables exist in the SQLite test DB.) + +- [ ] **Step 6: Commit** + +```bash +git add app/models/inactivity.py app/models/__init__.py tests/test_inactivity_reset.py +git commit -m "feat(welfare): inactivity_reset_log + inactivity_notification_log 模型" +``` + +--- + +## Task 2: Alembic 迁移建两表 + +**Files:** +- Create: `alembic/versions/_add_inactivity_tables.py` (由 autogenerate 生成文件名/revision) + +- [ ] **Step 1: Confirm single head** + +Run: `alembic heads` +Expected: 恰好一个 head(单行)。若多个 head,先 `alembic merge -m "merge heads" ` 再继续。 + +- [ ] **Step 2: Autogenerate the migration** + +Run: `alembic revision --autogenerate -m "add inactivity tables"` +Expected: 在 `alembic/versions/` 生成一个新文件,`down_revision` 自动指向当前 head。 + +- [ ] **Step 3: Replace the migration body** + +打开生成的文件,**只保留新两表**(如 autogenerate 顺带检出其它表的历史索引漂移,删掉那些无关 op,仿 `1699fc2c069f_add_analytics_event_table.py:50-51` 的做法)。`upgrade`/`downgrade` 改成: + +```python +def upgrade() -> None: + op.create_table( + "inactivity_reset_log", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("coin_balance_before", sa.Integer(), nullable=False), + sa.Column("cash_balance_cents_before", sa.Integer(), nullable=False), + sa.Column("invite_cash_balance_cents_before", sa.Integer(), nullable=False), + sa.Column("last_active_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("inactive_days", sa.Integer(), nullable=False), + sa.Column("reason", sa.String(length=32), nullable=False), + sa.Column("reset_at", sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("inactivity_reset_log", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_inactivity_reset_log_user_id"), ["user_id"], unique=False) + batch_op.create_index(batch_op.f("ix_inactivity_reset_log_reset_at"), ["reset_at"], unique=False) + + op.create_table( + "inactivity_notification_log", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("stage", sa.Integer(), nullable=False), + sa.Column("inactive_days", sa.Integer(), nullable=False), + sa.Column("coin_balance", sa.Integer(), nullable=False), + sa.Column("cash_balance_cents", sa.Integer(), nullable=False), + sa.Column("invite_cash_balance_cents", sa.Integer(), nullable=False), + sa.Column("channel", sa.String(length=16), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("inactivity_notification_log", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_inactivity_notification_log_user_id"), ["user_id"], unique=False) + batch_op.create_index(batch_op.f("ix_inactivity_notification_log_created_at"), ["created_at"], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table("inactivity_notification_log", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_inactivity_notification_log_created_at")) + batch_op.drop_index(batch_op.f("ix_inactivity_notification_log_user_id")) + op.drop_table("inactivity_notification_log") + with op.batch_alter_table("inactivity_reset_log", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_inactivity_reset_log_reset_at")) + batch_op.drop_index(batch_op.f("ix_inactivity_reset_log_user_id")) + op.drop_table("inactivity_reset_log") +``` + +确保文件顶部保留自动生成的 `import sqlalchemy as sa` / `from alembic import op` / `revision` / `down_revision`。 + +- [ ] **Step 4: Apply and verify round-trips** + +Run: `alembic upgrade head && alembic downgrade -1 && alembic upgrade head` +Expected: 三步都无错;`upgrade` 建表、`downgrade` 删表、再 `upgrade` 重建。 + +- [ ] **Step 5: Commit** + +```bash +git add alembic/versions/ +git commit -m "feat(welfare): 迁移新增 inactivity_reset_log / inactivity_notification_log 两表" +``` + +--- + +## Task 3: 活跃口径共享模块 — 常量 + tz 助手 + cutoff(纯函数) + +**Files:** +- Create: `app/repositories/activity.py` +- Test: `tests/test_inactivity_reset.py` + +- [ ] **Step 1: Write the failing test** + +追加到 `tests/test_inactivity_reset.py`: + +```python +from app.repositories import activity + + +def test_reset_cutoff_is_cn_midnight_of_today_minus_days_minus_1() -> None: + # RESET_DAYS=15, today=1/20 → cutoff = 北京 00:00 of 1/6 = 1/5 16:00 UTC + cutoff = activity.reset_cutoff(15, today=date(2026, 1, 20)) + assert cutoff == datetime(2026, 1, 5, 16, 0, tzinfo=timezone.utc) + + +def test_active_event_constants() -> None: + assert activity.HOME_VIEW_EVENT in activity.ACTIVE_EVENTS + assert "real_compare_start" in activity.ACTIVE_EVENTS + assert "real_coupon_start" in activity.ACTIVE_EVENTS + assert activity.ACTIVE_ENGAGE_TYPE == "claim_started" + + +def test_as_utc_normalizes() -> None: + assert activity.as_utc(datetime(2026, 1, 1)) == datetime(2026, 1, 1, tzinfo=timezone.utc) + cn = datetime(2026, 1, 1, tzinfo=activity.CN_TZ) # 北京 0 点 = 前一天 16:00 UTC + assert activity.as_utc(cn) == datetime(2025, 12, 31, 16, 0, tzinfo=timezone.utc) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_inactivity_reset.py -k "cutoff or constants or as_utc" -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.repositories.activity'` + +- [ ] **Step 3: Create the module (constants + helpers only for now)** + +Create `app/repositories/activity.py`: + +```python +"""活跃口径唯一真源:worker(不活跃清零)与 admin(最近活跃/DAU)共用,防两处漂移。 + +口径 = max(User.created_at, AnalyticsEvent[ACTIVE_EVENTS], CouponPromptEngagement[claim_started])。 +**不含 last_login_at**(登录/re-login 不代表在用 App);created_at 为恒非空基线。 +清零/预警按北京自然日 0 点对齐(见 reset_cutoff)。 +""" +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.core.rewards import CN_TZ, cn_today +from app.models.analytics_event import AnalyticsEvent +from app.models.coupon_state import CouponPromptEngagement + +# —— 活跃事件名(与"用户管理"口径一致)—— +# home_view:进首页(前端埋点,名称前端明天敲定,此处占位;定名后仅改这一常量)。 +HOME_VIEW_EVENT = "home_view" +COMPARE_START_EVENT = "real_compare_start" # 发起比价(含浮窗触发) +COUPON_START_EVENT = "real_coupon_start" # 发起领券 +ACTIVE_EVENTS = (HOME_VIEW_EVENT, COMPARE_START_EVENT, COUPON_START_EVENT) +ACTIVE_ENGAGE_TYPE = "claim_started" # coupon_prompt_engagement 一键领取 + + +def as_utc(value: datetime) -> datetime: + """任意 datetime → tz-aware UTC(无时区按 UTC 解释)。用于与 DateTime(timezone=True) 列比较, + 比较绝对时刻、与会话时区无关(口径同 admin queries._as_utc)。""" + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +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 cn_midnight_utc(d: date) -> datetime: + """北京 d 日 00:00 → tz-aware UTC datetime。""" + return as_utc(datetime(d.year, d.month, d.day, tzinfo=CN_TZ)) + + +def reset_cutoff(reset_days: int, today: date | None = None) -> datetime: + """应清零边界(tz-aware UTC):last_active < 此值 ⟺ 距末次活跃已满 reset_days 天(北京 0 点对齐)。 + = 北京 00:00 of (today − (reset_days − 1))。例:reset_days=15、today=1/20 → 北京 1/6 00:00。""" + today = today or cn_today() + return cn_midnight_utc(today - timedelta(days=reset_days - 1)) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_inactivity_reset.py -k "cutoff or constants or as_utc" -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add app/repositories/activity.py tests/test_inactivity_reset.py +git commit -m "feat(welfare): 活跃口径共享模块-常量与北京0点cutoff助手" +``` + +--- + +## Task 4: 活跃口径共享模块 — 子查询 + last_active_expr(DB) + +**Files:** +- Modify: `app/repositories/activity.py` +- Test: `tests/test_inactivity_reset.py` + +- [ ] **Step 1: Write the failing test** + +追加测试助手 + 用例到 `tests/test_inactivity_reset.py`: + +```python +from app.core.rewards import CN_TZ +from app.models.analytics_event import AnalyticsEvent +from app.models.coupon_state import CouponPromptEngagement +from app.models.user import User +from app.models.wallet import CoinAccount +from app.repositories import wallet as wallet_repo + +_PHONE_SEQ = [0] + + +def _new_user(db, *, created_at, coin=0, cash=0, invite=0) -> int: + """直接建一个 User + CoinAccount,created_at 可控。返回 user_id。""" + _PHONE_SEQ[0] += 1 + u = User(phone=f"139{_PHONE_SEQ[0]:08d}", created_at=created_at, + last_login_at=created_at, status="active") + db.add(u) + db.flush() + acc = wallet_repo.get_or_create_account(db, u.id, commit=False) + acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents = coin, cash, invite + acc.total_coin_earned = coin + db.flush() + return u.id + + +def _add_event(db, user_id, event, when: datetime) -> None: + db.add(AnalyticsEvent(event=event, device_id="d", user_id=user_id, client_ts=0, created_at=when)) + + +def _add_engage(db, user_id, when: datetime, engage_type="claim_started") -> None: + db.add(CouponPromptEngagement(device_id=f"dev{user_id}", package="p", user_id=user_id, + engage_date=when.date(), engage_type=engage_type, created_at=when)) + + +def test_last_active_expr_takes_max_of_baseline_and_events() -> None: + from sqlalchemy import select + db = SessionLocal() + try: + base = datetime(2026, 1, 1, tzinfo=timezone.utc) + uid = _new_user(db, created_at=base, coin=5) + _add_event(db, uid, "real_compare_start", datetime(2026, 1, 10, tzinfo=timezone.utc)) + db.commit() + ev_sub, eng_sub = activity.last_active_subqueries(db) + dialect = db.get_bind().dialect.name + expr = activity.last_active_expr(User.created_at, ev_sub, eng_sub, dialect) + stmt = (select(expr).select_from(User) + .outerjoin(ev_sub, ev_sub.c.user_id == User.id) + .outerjoin(eng_sub, eng_sub.c.user_id == User.id) + .where(User.id == uid)) + got = activity.norm_utc(db.execute(stmt).scalar_one()) + assert got == datetime(2026, 1, 10, tzinfo=timezone.utc) # 事件 > 基线 + finally: + db.rollback() + db.close() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_inactivity_reset.py::test_last_active_expr_takes_max_of_baseline_and_events -q` +Expected: FAIL — `AttributeError: module 'app.repositories.activity' has no attribute 'last_active_subqueries'` + +- [ ] **Step 3: Add subqueries + expr to activity.py** + +在 `app/repositories/activity.py` 末尾追加: + +```python +def last_active_subqueries(db: Session): + """两个按 user_id 预聚合的派生表:最近活跃事件(ACTIVE_EVENTS)、最近领券发起(claim_started)。 + 返回 (ev_sub, eng_sub)。口径同 admin,LEFT JOIN 用,借事件索引只扫活跃事件。""" + ev_sub = ( + 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_sub = ( + 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 == ACTIVE_ENGAGE_TYPE, + ) + .group_by(CouponPromptEngagement.user_id) + .subquery() + ) + return ev_sub, eng_sub + + +def last_active_expr(base_col, ev_sub, eng_sub, dialect: str): + """max(base_col, 最近活跃事件, 最近领券) 的 SQL 表达式。PG 用 greatest、SQLite 用 max。 + 子聚合缺失(未命中)时 coalesce 到 base_col(= User.created_at,恒非空基线)。""" + greatest = func.greatest if dialect == "postgresql" else func.max + return greatest( + base_col, + func.coalesce(ev_sub.c.last_at, base_col), + func.coalesce(eng_sub.c.last_at, base_col), + ) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_inactivity_reset.py::test_last_active_expr_takes_max_of_baseline_and_events -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add app/repositories/activity.py tests/test_inactivity_reset.py +git commit -m "feat(welfare): 活跃口径共享模块-子查询与 last_active_expr" +``` + +--- + +## Task 5: 配置项 + +**Files:** +- Modify: `app/core/config.py` +- Test: `tests/test_inactivity_reset.py` + +- [ ] **Step 1: Write the failing test** + +追加: + +```python +def test_inactivity_warn_stages_parsing() -> None: + from app.core.config import Settings + s = Settings(INACTIVITY_WARN_DAYS_BEFORE="7,2", INACTIVITY_RESET_DAYS=15) + assert s.inactivity_warn_stages == [7, 2] # 降序去重 + s2 = Settings(INACTIVITY_WARN_DAYS_BEFORE="", INACTIVITY_RESET_DAYS=15) + assert s2.inactivity_warn_stages == [] # 空=不推 + s3 = Settings(INACTIVITY_WARN_DAYS_BEFORE="2,20,7,2", INACTIVITY_RESET_DAYS=15) + assert s3.inactivity_warn_stages == [7, 2] # 去重 + 丢弃 >=RESET_DAYS(20) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_inactivity_reset.py::test_inactivity_warn_stages_parsing -q` +Expected: FAIL — `TypeError: ... unexpected keyword argument 'INACTIVITY_WARN_DAYS_BEFORE'` + +- [ ] **Step 3: Add settings** + +在 `app/core/config.py` 的 `Settings` 类里,紧接 `AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600`(约 line 171)之后加: + +```python + # === 15 天不活跃清零(app.core.inactivity_reset_worker)=== + INACTIVITY_RESET_ENABLED: bool = False # 总闸,默认关;灰度验证后再开 + INACTIVITY_RESET_DAYS: int = 15 # 不活跃阈值(天),第 (N+1) 日 0 点清 + INACTIVITY_WARN_DAYS_BEFORE: str = "7,2" # 清零前几天各推一次;""=不推。逗号分隔 + INACTIVITY_RESET_RUN_HOUR: int = 3 # 北京时间每日执行点(0-23) + INACTIVITY_NOTIFY_CHANNEL: str = "log" # log(占位) / jpush / sms + INACTIVITY_RESET_CHECK_INTERVAL_SEC: int = 1800 # worker 唤醒间隔(秒) +``` + +并在类内(与其它 `@property` 放一起,如 `wxpay_configured` 附近)加解析属性: + +```python + @property + def inactivity_warn_stages(self) -> list[int]: + """解析 INACTIVITY_WARN_DAYS_BEFORE → 降序去重的提前天数列表。 + 丢弃非数字 / <=0 / >=RESET_DAYS 的项(空串 → 空列表 = 不推)。""" + out: list[int] = [] + for part in (self.INACTIVITY_WARN_DAYS_BEFORE or "").split(","): + part = part.strip() + if part.isdigit(): + v = int(part) + if 0 < v < self.INACTIVITY_RESET_DAYS and v not in out: + out.append(v) + return sorted(out, reverse=True) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_inactivity_reset.py::test_inactivity_warn_stages_parsing -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add app/core/config.py tests/test_inactivity_reset.py +git commit -m "feat(welfare): INACTIVITY_* 配置项 + warn stages 解析" +``` + +--- + +## Task 6: 可插拔通知器 + +**Files:** +- Create: `app/integrations/notifier.py` +- Test: `tests/test_inactivity_reset.py` + +- [ ] **Step 1: Write the failing test** + +```python +def test_log_notifier_returns_placeholder(caplog) -> None: + from app.integrations.notifier import LogNotifier, get_notifier + n = get_notifier("log") + assert isinstance(n, LogNotifier) and n.channel == "log" + status = n.warn(user_id=1, coin=10, cash_cents=20, invite_cash_cents=30, stage=7, days_until_reset=8) + assert status == "placeholder" + # 未实现通道回退 LogNotifier(占位) + assert get_notifier("jpush").channel == "log" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_inactivity_reset.py::test_log_notifier_returns_placeholder -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.integrations.notifier'` + +- [ ] **Step 3: Create the notifier** + +Create `app/integrations/notifier.py`: + +```python +"""不活跃预警通知器(可插拔)。 + +v1 仅日志占位(LogNotifier):现状无真实推送能力(极光只用于一键登录解密 + 设备心跳告警, +心跳 worker 也只打印),先把清零主流程 + 审计做扎实。后续实现同协议的 JPushNotifier / +SmsNotifier 即可替换,worker/repo 不改。 +""" +from __future__ import annotations + +import logging +from typing import Protocol + +logger = logging.getLogger("shagua.inactivity") + + +class InactivityNotifier(Protocol): + channel: str + + def warn(self, *, user_id: int, coin: int, cash_cents: int, invite_cash_cents: int, + stage: int, days_until_reset: int) -> str: + """发预警,返回状态:'sent' / 'failed' / 'placeholder'。""" + ... + + +class LogNotifier: + """占位实现:只打印,不真推。参照 heartbeat_monitor_worker「本期先不接推送」先例。""" + + channel = "log" + + def warn(self, *, user_id: int, coin: int, cash_cents: int, invite_cash_cents: int, + stage: int, days_until_reset: int) -> str: + logger.warning( + "[inactivity-warn] user=%s coin=%s cash_cents=%s invite_cash_cents=%s " + "stage=T-%s days_until_reset=%s", + user_id, coin, cash_cents, invite_cash_cents, stage, days_until_reset, + ) + return "placeholder" + + +def get_notifier(channel: str) -> InactivityNotifier: + """按配置返回通知器。未实现的通道(jpush/sms)暂回退 LogNotifier 占位。""" + # 后续:if channel == "jpush": return JPushNotifier() + # if channel == "sms": return SmsNotifier() + return LogNotifier() +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_inactivity_reset.py::test_log_notifier_returns_placeholder -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add app/integrations/notifier.py tests/test_inactivity_reset.py +git commit -m "feat(welfare): 可插拔不活跃预警通知器 + LogNotifier 占位" +``` + +--- + +## Task 7: 业务逻辑 — 选取 + 清零 + +**Files:** +- Create: `app/repositories/inactivity.py` +- Test: `tests/test_inactivity_reset.py` + +- [ ] **Step 1: Write the failing test** + +```python +def test_run_reset_clears_three_buckets_and_writes_audit_and_flows() -> None: + from sqlalchemy import select + from app.models.wallet import CoinAccount, CoinTransaction, CashTransaction, InviteCashTransaction + from app.repositories import inactivity + + db = SessionLocal() + try: + today = date(2026, 2, 1) + # 末次活跃 = created_at 基线 = 1/10(距 today 22 天 → 应清) + old = _new_user(db, created_at=datetime(2026, 1, 10, tzinfo=timezone.utc), + coin=100, cash=200, invite=300) + # 活跃用户:昨天有 home_view → 不清 + fresh = _new_user(db, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), coin=50) + _add_event(db, fresh, "home_view", datetime(2026, 1, 31, tzinfo=timezone.utc)) + db.commit() + + stats = inactivity.run_reset_once(db, reset_days=15, today=today) + assert stats["cleared"] == 1 and stats["failed"] == 0 + + acc = db.get(CoinAccount, old) + assert (acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents) == (0, 0, 0) + assert acc.total_coin_earned == 100 # 历史累计不动 + + log = db.execute(select(InactivityResetLog).where(InactivityResetLog.user_id == old)).scalar_one() + assert (log.coin_balance_before, log.cash_balance_cents_before, + log.invite_cash_balance_cents_before) == (100, 200, 300) + assert log.inactive_days == 22 and log.reason == "inactive_15d" + + ct = db.execute(select(CoinTransaction).where( + CoinTransaction.user_id == old, CoinTransaction.biz_type == "inactivity_reset")).scalar_one() + assert ct.amount == -100 and ct.balance_after == 0 and ct.ref_id == str(log.id) + assert db.execute(select(CashTransaction).where( + CashTransaction.user_id == old, CashTransaction.biz_type == "inactivity_reset")).scalar_one().amount_cents == -200 + assert db.execute(select(InviteCashTransaction).where( + InviteCashTransaction.user_id == old, InviteCashTransaction.biz_type == "inactivity_reset")).scalar_one().amount_cents == -300 + + # 活跃用户不动;再跑一次幂等(已清零 → 不再匹配) + assert db.get(CoinAccount, fresh).coin_balance == 50 + assert inactivity.run_reset_once(db, reset_days=15, today=today)["cleared"] == 0 + finally: + db.rollback() + db.close() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_inactivity_reset.py::test_run_reset_clears_three_buckets_and_writes_audit_and_flows -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.repositories.inactivity'` + +- [ ] **Step 3: Create the repository (selection + clear + run_reset_once)** + +Create `app/repositories/inactivity.py`: + +```python +"""15 天不活跃清零业务逻辑(纯同步,可单测)。worker 只是它的 asyncio 外壳。 + +活跃口径复用 app.repositories.activity;清零走 wallet.grant_*(负数出账、写流水、不 commit)。 +逐用户独立事务,一个失败不影响其余。 +""" +from __future__ import annotations + +from datetime import date, datetime, timezone + +from sqlalchemy import or_, select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +from app.core.rewards import CN_TZ +from app.integrations.notifier import InactivityNotifier +from app.models.inactivity import InactivityNotificationLog, InactivityResetLog +from app.models.user import User +from app.models.wallet import CoinAccount +from app.repositories import activity +from app.repositories import wallet as wallet_repo + +RESET_BIZ_TYPE = "inactivity_reset" +RESET_REMARK = "15天不活跃清零" + +_ANY_BALANCE = or_( + CoinAccount.coin_balance > 0, + CoinAccount.cash_balance_cents > 0, + CoinAccount.invite_cash_balance_cents > 0, +) + + +def _base_query(db: Session): + """select(user_id, last_active, 三桶余额),join CoinAccount + 两活跃子查询。""" + ev_sub, eng_sub = activity.last_active_subqueries(db) + dialect = db.get_bind().dialect.name + last_active = activity.last_active_expr(User.created_at, ev_sub, eng_sub, dialect) + stmt = ( + select( + User.id.label("user_id"), + last_active.label("last_active"), + CoinAccount.coin_balance, + CoinAccount.cash_balance_cents, + CoinAccount.invite_cash_balance_cents, + ) + .join(CoinAccount, CoinAccount.user_id == User.id) + .outerjoin(ev_sub, ev_sub.c.user_id == User.id) + .outerjoin(eng_sub, eng_sub.c.user_id == User.id) + ) + return stmt, last_active + + +def _cn_date(dt: datetime) -> date: + """datetime → 北京自然日(naive 视为 UTC)。""" + return activity.norm_utc(dt).astimezone(CN_TZ).date() + + +def _inactive_days(last_active: datetime, today: date) -> int: + return (today - _cn_date(last_active)).days + + +def select_inactive_users(db: Session, *, cutoff: datetime): + """应清零用户:last_active < cutoff 且三桶有余额。返回 Row 列表(值已快照,可跨 commit)。""" + stmt, last_active = _base_query(db) + stmt = stmt.where(_ANY_BALANCE, last_active < activity.as_utc(cutoff)) + return db.execute(stmt).all() + + +def clear_user(db: Session, *, user_id: int, last_active: datetime, inactive_days: int, reason: str) -> bool: + """单用户清零(独立事务、行锁)。三桶归零 + 写审计 + 3 条流水。返回是否真清了(有余额)。""" + acc = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True) + coin, cash, invite = acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents + if coin == 0 and cash == 0 and invite == 0: + return False + log = InactivityResetLog( + user_id=user_id, coin_balance_before=coin, cash_balance_cents_before=cash, + invite_cash_balance_cents_before=invite, last_active_at=activity.norm_utc(last_active), + inactive_days=inactive_days, reason=reason, + ) + db.add(log) + db.flush() # 拿 log.id 作 ref_id 交叉链接审计↔流水 + ref = str(log.id) + if coin: + wallet_repo.grant_coins(db, user_id, -coin, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK) + if cash: + wallet_repo.grant_cash(db, user_id, -cash, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK) + if invite: + wallet_repo.grant_invite_cash(db, user_id, -invite, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK) + db.commit() + return True + + +def run_reset_once(db: Session, *, reset_days: int, today: date) -> dict: + """扫一轮清零。逐用户独立 commit,失败隔离。""" + stats = {"scanned": 0, "cleared": 0, "failed": 0} + cutoff = activity.reset_cutoff(reset_days, today) + reason = f"inactive_{reset_days}d" + rows = select_inactive_users(db, cutoff=cutoff) # 先物化,避免边遍历边 commit + for row in rows: + stats["scanned"] += 1 + idays = _inactive_days(row.last_active, today) + try: + if clear_user(db, user_id=row.user_id, last_active=row.last_active, + inactive_days=idays, reason=reason): + stats["cleared"] += 1 + except SQLAlchemyError: + db.rollback() + stats["failed"] += 1 + return stats +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_inactivity_reset.py::test_run_reset_clears_three_buckets_and_writes_audit_and_flows -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add app/repositories/inactivity.py tests/test_inactivity_reset.py +git commit -m "feat(welfare): 不活跃清零-选取与逐用户清零(三桶归零+审计+流水)" +``` + +--- + +## Task 8: 业务逻辑 — 预警 + run_once 组合 + +**Files:** +- Modify: `app/repositories/inactivity.py` +- Test: `tests/test_inactivity_reset.py` + +- [ ] **Step 1: Write the failing test** + +```python +def test_run_warn_picks_stage_and_dedups_within_streak() -> None: + from app.integrations.notifier import LogNotifier + from app.repositories import inactivity + + db = SessionLocal() + try: + today = date(2026, 2, 1) + # 末次活跃 1/22(距 today 10 天)→ 档 7 命中(idays>=8),档 2 未到(需>=13) + uid = _new_user(db, created_at=datetime(2026, 1, 22, tzinfo=timezone.utc), coin=100) + db.commit() + + stats = inactivity.run_warn_once(db, LogNotifier(), reset_days=15, warn_stages=[7, 2], today=today) + assert stats["warned"] == 1 + from sqlalchemy import select + rows = db.execute(select(InactivityNotificationLog).where( + InactivityNotificationLog.user_id == uid)).scalars().all() + assert len(rows) == 1 and rows[0].stage == 7 and rows[0].status == "placeholder" + assert rows[0].inactive_days == 10 and rows[0].coin_balance == 100 + + # 同一 streak 再跑 → 不重推 + assert inactivity.run_warn_once(db, LogNotifier(), reset_days=15, warn_stages=[7, 2], today=today)["warned"] == 0 + + # 无余额用户不预警 + _new_user(db, created_at=datetime(2026, 1, 22, tzinfo=timezone.utc), coin=0) + db.commit() + assert inactivity.run_warn_once(db, LogNotifier(), reset_days=15, warn_stages=[7, 2], today=today)["warned"] == 0 + finally: + db.rollback() + db.close() + + +def test_run_once_warns_then_resets() -> None: + from app.integrations.notifier import LogNotifier + from app.repositories import inactivity + db = SessionLocal() + try: + today = date(2026, 2, 1) + warn_uid = _new_user(db, created_at=datetime(2026, 1, 22, tzinfo=timezone.utc), coin=10) # 10天→预警 + clear_uid = _new_user(db, created_at=datetime(2026, 1, 5, tzinfo=timezone.utc), coin=10) # 27天→清零 + db.commit() + stats = inactivity.run_once(db, notifier=LogNotifier(), reset_days=15, warn_stages=[7, 2], today=today) + assert stats["warned"] == 1 and stats["cleared"] == 1 + from app.models.wallet import CoinAccount + assert db.get(CoinAccount, clear_uid).coin_balance == 0 + assert db.get(CoinAccount, warn_uid).coin_balance == 10 # 预警不动钱 + finally: + db.rollback() + db.close() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_inactivity_reset.py -k "run_warn or run_once" -q` +Expected: FAIL — `AttributeError: module 'app.repositories.inactivity' has no attribute 'run_warn_once'` + +- [ ] **Step 3: Add warn + run_once to inactivity.py** + +在 `app/repositories/inactivity.py` 末尾追加: + +```python +def select_warn_candidates(db: Session, *, clear_cutoff: datetime, warn_hi: datetime): + """预警候选:clear_cutoff <= last_active < warn_hi 且有余额(即已进预警窗、尚未到清零)。""" + stmt, last_active = _base_query(db) + stmt = stmt.where( + _ANY_BALANCE, + last_active >= activity.as_utc(clear_cutoff), + last_active < activity.as_utc(warn_hi), + ) + return db.execute(stmt).all() + + +def run_warn_once(db: Session, notifier: InactivityNotifier, *, + reset_days: int, warn_stages: list[int], today: date) -> dict: + """扫一轮预警。每用户取"最紧急的已到达档",按 streak 去重(notification_log.created_at > last_active)。""" + stats = {"warned": 0, "warn_skipped": 0} + if not warn_stages: + return stats + clear_cutoff = activity.reset_cutoff(reset_days, today) # 到此即清零,不再预警 + warn_hi = activity.reset_cutoff(reset_days - max(warn_stages), today) # 最早预警档边界 + ascending = sorted(warn_stages) # 最紧急(最小 k)在前 + for row in select_warn_candidates(db, clear_cutoff=clear_cutoff, warn_hi=warn_hi): + idays = _inactive_days(row.last_active, today) + stage = next((k for k in ascending if idays >= reset_days - k), None) + if stage is None: + continue + already = db.execute( + select(InactivityNotificationLog.id).where( + InactivityNotificationLog.user_id == row.user_id, + InactivityNotificationLog.stage == stage, + InactivityNotificationLog.created_at > activity.as_utc(row.last_active), + ).limit(1) + ).first() + if already: + stats["warn_skipped"] += 1 + continue + status = notifier.warn( + user_id=row.user_id, coin=row.coin_balance, cash_cents=row.cash_balance_cents, + invite_cash_cents=row.invite_cash_balance_cents, stage=stage, + days_until_reset=reset_days - idays, + ) + db.add(InactivityNotificationLog( + user_id=row.user_id, stage=stage, inactive_days=idays, + coin_balance=row.coin_balance, cash_balance_cents=row.cash_balance_cents, + invite_cash_balance_cents=row.invite_cash_balance_cents, + channel=notifier.channel, status=status, + )) + db.commit() + stats["warned"] += 1 + return stats + + +def run_once(db: Session, *, notifier: InactivityNotifier, reset_days: int, + warn_stages: list[int], today: date) -> dict: + """一轮完整任务:先预警(阶段 A)再清零(阶段 B)。返回合并统计。""" + warn = run_warn_once(db, notifier, reset_days=reset_days, warn_stages=warn_stages, today=today) + reset = run_reset_once(db, reset_days=reset_days, today=today) + return {**warn, **reset} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_inactivity_reset.py -k "run_warn or run_once" -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add app/repositories/inactivity.py tests/test_inactivity_reset.py +git commit -m "feat(welfare): 不活跃预警(分档+streak去重)+ run_once 组合" +``` + +--- + +## Task 9: 进程内每日 worker + lifespan 接线 + +**Files:** +- Create: `app/core/inactivity_reset_worker.py` +- Modify: `app/main.py` +- Test: `tests/test_inactivity_reset.py` + +- [ ] **Step 1: Write the failing test** + +```python +def test_worker_disabled_returns_none(monkeypatch) -> None: + from app.core import inactivity_reset_worker as w + from app.core.config import settings + monkeypatch.setattr(settings, "INACTIVITY_RESET_ENABLED", False) + assert w.start_inactivity_reset_worker() is None + + +def test_worker_run_once_entry_executes(monkeypatch) -> None: + """_run_once_entry 用真实 SessionLocal 跑一轮,总闸开时能清掉一个不活跃用户。""" + from app.core import inactivity_reset_worker as w + from app.core.config import settings + from app.models.wallet import CoinAccount + + monkeypatch.setattr(settings, "INACTIVITY_RESET_ENABLED", True) + monkeypatch.setattr(settings, "INACTIVITY_RESET_DAYS", 15) + monkeypatch.setattr(settings, "INACTIVITY_WARN_DAYS_BEFORE", "") # 只测清零 + # 固定"今天"避免依赖真实时钟 + monkeypatch.setattr(w, "_cn_today", lambda: date(2026, 2, 1)) + + db = SessionLocal() + try: + uid = _new_user(db, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), coin=100) + db.commit() + finally: + db.close() + + stats = w._run_once_entry() + assert stats["cleared"] >= 1 + + db = SessionLocal() + try: + assert db.get(CoinAccount, uid).coin_balance == 0 + finally: + db.close() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_inactivity_reset.py -k "worker" -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'app.core.inactivity_reset_worker'` + +- [ ] **Step 3: Create the worker** + +Create `app/core/inactivity_reset_worker.py`(结构、文件锁完全仿 `app/core/daily_exchange_worker.py`): + +```python +"""15 天不活跃清零的进程内每日任务。 + +仿 daily_exchange_worker:App 启动自带,每 INACTIVITY_RESET_CHECK_INTERVAL_SEC 醒一次, +跨进北京新的一天且到达 INACTIVITY_RESET_RUN_HOUR 后跑一轮(预警 + 清零)。 +- 逐用户幂等:清完余额=0 次日不再匹配;预警 streak 去重。启动补跑 / 多次唤醒 / 重启都安全。 +- 同机多进程互斥:文件锁。 +- 开关:INACTIVITY_RESET_ENABLED=false 时不启动。 +""" +from __future__ import annotations + +import asyncio +import contextlib +import logging +import os +import time +from collections.abc import Iterator +from datetime import date, datetime +from pathlib import Path + +from sqlalchemy.exc import SQLAlchemyError + +from app.core.config import settings +from app.core.rewards import CN_TZ, cn_today +from app.db.session import SessionLocal +from app.integrations.notifier import get_notifier +from app.repositories import inactivity as inactivity_repo + +logger = logging.getLogger("shagua.inactivity") +_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "inactivity_reset.lock" + + +def _cn_today() -> date: + return cn_today() + + +def _touch_lock() -> None: + with contextlib.suppress(FileNotFoundError): + os.utime(_LOCK_PATH, None) + + +@contextlib.contextmanager +def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]: + _LOCK_PATH.parent.mkdir(parents=True, exist_ok=True) + fd: int | None = None + try: + try: + fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except FileExistsError: + try: + age = time.time() - _LOCK_PATH.stat().st_mtime + except FileNotFoundError: + age = stale_after_sec + 1 + if age > stale_after_sec: + with contextlib.suppress(FileNotFoundError): + _LOCK_PATH.unlink() + try: + fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except FileExistsError: + fd = None + if fd is None: + yield False + return + os.write(fd, f"pid={os.getpid()} started_at={int(time.time())}\n".encode("ascii")) + yield True + finally: + if fd is not None: + os.close(fd) + with contextlib.suppress(FileNotFoundError): + _LOCK_PATH.unlink() + + +def _run_once_entry() -> dict: + """跑一轮(预警 + 清零)。独立开 Session。""" + notifier = get_notifier(settings.INACTIVITY_NOTIFY_CHANNEL) + with SessionLocal() as db: + return inactivity_repo.run_once( + db, + notifier=notifier, + reset_days=settings.INACTIVITY_RESET_DAYS, + warn_stages=settings.inactivity_warn_stages, + today=_cn_today(), + ) + + +async def _run_loop() -> None: + interval = max(60, int(settings.INACTIVITY_RESET_CHECK_INTERVAL_SEC)) + lock_stale_after = max(interval * 3, 1800) + with _single_instance_lock(lock_stale_after) as lock_acquired: + if not lock_acquired: + logger.warning("inactivity reset skipped: another worker owns lock") + return + await _run_locked_loop(interval) + + +async def _run_locked_loop(interval: int) -> None: + logger.info("inactivity reset worker started interval=%ss", interval) + last_run: date | None = None + try: + while True: + try: + _touch_lock() + today = _cn_today() + hour = datetime.now(CN_TZ).hour + if last_run != today and hour >= int(settings.INACTIVITY_RESET_RUN_HOUR): + result = await asyncio.to_thread(_run_once_entry) + last_run = today + logger.info("inactivity reset done date=%s result=%s", today, result) + except SQLAlchemyError: + logger.exception("inactivity reset db error") + except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出 + logger.exception("inactivity reset unexpected error") + await asyncio.sleep(interval) + except asyncio.CancelledError: + logger.info("inactivity reset worker stopped") + raise + + +def start_inactivity_reset_worker() -> asyncio.Task | None: + if not settings.INACTIVITY_RESET_ENABLED: + logger.info("inactivity reset disabled (INACTIVITY_RESET_ENABLED=false)") + return None + return asyncio.create_task(_run_loop(), name="inactivity-reset") + + +async def stop_inactivity_reset_worker(task: asyncio.Task | None) -> None: + if task is None: + return + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task +``` + +- [ ] **Step 4: Wire into lifespan** + +在 `app/main.py`: + +(a) 导入(紧接 `from app.core.daily_exchange_worker import (...)` 之后,约 line 47): + +```python +from app.core.inactivity_reset_worker import ( + start_inactivity_reset_worker, + stop_inactivity_reset_worker, +) +``` + +(b) 启动(在 `daily_exchange_task = start_daily_exchange_worker()` 之后,约 line 82): + +```python + inactivity_task = start_inactivity_reset_worker() +``` + +(c) 停止(在 `await stop_daily_exchange_worker(daily_exchange_task)` 之后,约 line 88): + +```python + await stop_inactivity_reset_worker(inactivity_task) +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pytest tests/test_inactivity_reset.py -k "worker" -q` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add app/core/inactivity_reset_worker.py app/main.py tests/test_inactivity_reset.py +git commit -m "feat(welfare): 不活跃清零每日 worker + lifespan 接线(总闸默认关)" +``` + +--- + +## Task 10: 重构 admin 改用共享口径(R4:单一真源) + +**Files:** +- Modify: `app/admin/repositories/stats.py` +- Modify: `app/admin/repositories/queries.py` +- Test: 现有 `tests/test_admin_read.py` 回归 + 追加一条 + +**背景:** 现 admin 口径散落且**含 last_login_at**。改造后 = 复用 `activity.py`(含 `home_view`、不含 last_login_at、以 created_at 为基线)。这会改变 admin「最近活跃/DAU」口径(登录不再计活跃、纳入 home_view),属预期变化。 + +- [ ] **Step 1: Write the failing test** + +追加到 `tests/test_inactivity_reset.py`(验证共享口径 = admin 会用到的排序列口径): + +```python +def test_admin_last_active_uses_shared_expr_without_login() -> None: + """admin 用户列表的 last_active 计算与共享口径一致:登录不推进、home_view 推进。""" + from sqlalchemy import select + from app.admin.repositories.queries import _last_active_parts # 改造后仍在,内部委托 activity + db = SessionLocal() + try: + # 仅有旧 created_at + 很新的 last_login_at,无任何活跃事件 → last_active 应=created_at(不看登录) + uid = _new_user(db, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc)) + u = db.get(User, uid) + u.last_login_at = datetime(2026, 6, 1, tzinfo=timezone.utc) # 登录很新 + db.commit() + ev_sub, eng_sub = _last_active_parts() + dialect = db.get_bind().dialect.name + expr = activity.last_active_expr(User.created_at, ev_sub, eng_sub, dialect) + stmt = (select(expr).select_from(User) + .outerjoin(ev_sub, ev_sub.c.user_id == User.id) + .outerjoin(eng_sub, eng_sub.c.user_id == User.id) + .where(User.id == uid)) + assert activity.norm_utc(db.execute(stmt).scalar_one()) == datetime(2026, 1, 1, tzinfo=timezone.utc) + finally: + db.rollback() + db.close() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_inactivity_reset.py::test_admin_last_active_uses_shared_expr_without_login -q` +Expected: FAIL — 现 `_last_active_parts()` 无参、且 `_last_active_expr`/`list_users` 仍以 `last_login_at` 为基线,断言不成立(得到 2026-06-01)。 + +- [ ] **Step 3: Refactor stats.py** + +在 `app/admin/repositories/stats.py`,把本地事件常量改为复用共享(约 line 51-52): + +```python +from app.repositories.activity import COMPARE_START_EVENT, COUPON_START_EVENT # noqa: F401 +``` + +删除原来的 `COMPARE_START_EVENT = "real_compare_start"` / `COUPON_START_EVENT = "real_coupon_start"` 两行赋值(其它文件从 stats 导入它们的地方不受影响,因为已 re-export)。`_period_active_user_ids`(约 line 130)保持不变——它是"区间活跃"(含 last_login_at 落在区间),与"最近活跃排序列"是两套用途,本次不动其登录口径;仅统一事件名常量来源。 + +- [ ] **Step 4: Refactor queries.py** + +在 `app/admin/repositories/queries.py`: + +(a) 顶部改为从 activity 导入常量/助手,并让 `_last_active_parts` / `_norm_utc` / `_as_utc` 委托共享实现: + +```python +from app.repositories.activity import ( + ACTIVE_EVENTS as _ACTIVE_EVENTS, # noqa: F401 (= home_view + 比价 + 领券) + as_utc as _as_utc, + last_active_expr as _shared_last_active_expr, + last_active_subqueries, + norm_utc as _norm_utc, +) +``` + +删除本地重复定义:`_ACTIVE_EVENTS = (...)`(约 line 38)、`_norm_utc`(约 line 127-131)、`_as_utc`(约 line 625-634)。若 `_as_utc_naive`(约 line 1012)仍被引用,保留它并让其调用导入来的 `_as_utc`。 + +(b) `_last_active_parts()` 改为直接委托: + +```python +def _last_active_parts(): + """(保留函数名以兼容调用点)最近活跃两个聚合子查询,委托共享口径。""" + return last_active_subqueries(_session_of_current_call) # 见下:改签名 +``` + +> 说明:共享 `last_active_subqueries(db)` 需要 `db`。现 `_last_active_parts()` 无参(用全局 `select(...)` 构造,不需 session)。共享实现同样只用 `select(...).subquery()`、不真执行,可把 `db` 参数忽略/设可选。**因此把共享函数签名放宽**:在 `app/repositories/activity.py` 把 + +```python +def last_active_subqueries(db: Session): +``` + +改为 + +```python +def last_active_subqueries(db: Session | None = None): +``` + +(函数体不使用 db,只构造子查询;`db` 仅为语义占位)。然后 queries.py: + +```python +def _last_active_parts(): + return last_active_subqueries() +``` + +(c) `list_users` 里构造 `last_active`(约 line 199-204)改用共享 expr、基线换 `User.created_at`: + +```python + ev_agg, eng_agg = _last_active_parts() + last_active = _shared_last_active_expr( + User.created_at, ev_agg, eng_agg, db.get_bind().dialect.name + ) +``` + +删除原先 `greatest = func.greatest if ... else func.max` 与手写 `greatest(User.last_login_at, coalesce(..., User.last_login_at), ...)` 那几行。 + +(d) `_attach_last_active`(约 line 134-168)把基线由 `last_login_at` 换 `created_at`,事件集用共享常量: + +```python + for u in users: + candidates = [ + _norm_utc(u.created_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) +``` + +并把该函数内两处 `AnalyticsEvent.event.in_(_ACTIVE_EVENTS)` 保持(现 `_ACTIVE_EVENTS` 已是导入来的三事件集,含 home_view)。 + +- [ ] **Step 5: Run new test + full admin regression** + +Run: `pytest tests/test_inactivity_reset.py::test_admin_last_active_uses_shared_expr_without_login tests/test_admin_read.py -q` +Expected: 新用例 PASS。若 `test_admin_read.py` 有断言依赖旧口径(把 last_login_at 当活跃、或未含 home_view),按新口径**更新其预期值**(这是设计明确的口径变化,见 spec §12);非活跃口径断言应保持通过。 + +- [ ] **Step 6: Run the whole suite** + +Run: `pytest -q` +Expected: 全绿。重点看 `test_admin_*`、`test_analytics_*`。任何红都要判断是"口径预期变化需更新断言"还是"真回归需修实现"。 + +- [ ] **Step 7: Commit** + +```bash +git add app/admin/repositories/queries.py app/admin/repositories/stats.py app/repositories/activity.py tests/ +git commit -m "refactor(admin): 最近活跃口径改用共享 activity 模块(移除 last_login_at,纳入 home_view)" +``` + +--- + +## Task 11: 表字典文档(轻量) + +**Files:** +- Create: `docs/database/inactivity_reset_log.md` +- Create: `docs/database/inactivity_notification_log.md` + +- [ ] **Step 1: Write the docs** + +仿 `docs/database/` 现有条目风格(字段表 + 用途一句话)。`inactivity_reset_log`:记录每次清零的清零前三桶余额 + 原因 + 判定时活跃时间/不活跃天数,供纠纷排查;与钱包流水 `biz_type=inactivity_reset`(ref_id=本表 id)交叉对账。`inactivity_notification_log`:记录每次预警的余额快照 + 档位 + 通道 + 状态,兼预警去重依据与占位 outbox。 + +- [ ] **Step 2: Commit** + +```bash +git add docs/database/inactivity_reset_log.md docs/database/inactivity_notification_log.md +git commit -m "docs(database): 新增 inactivity 两表字典条目" +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- R1 清零(15天/北京0点边界) → Task 3(reset_cutoff)+ Task 7(run_reset)+ Task 9(worker)✓ +- R2 审计(原因+清零前余额)→ Task 1(inactivity_reset_log)+ Task 7(写审计+流水 ref 交叉)✓ +- R3 预警(xx金币xx现金)→ Task 1(notification_log)+ Task 6(notifier)+ Task 8(run_warn,快照余额)✓ +- R4 活跃口径与用户管理一致 → Task 3/4(activity 共享)+ Task 10(admin 重构)✓ +- R5 完全可配置 → Task 5(天数/次数/执行点/通道)✓ +- 活跃口径 = created_at 基线 + home_view/比价/领券,不含 last_login_at → Task 3/4/10 ✓ +- 幂等/重新活跃/streak 去重 → Task 7(清完=0)/Task 8(created_at>last_active 去重)✓ +- 触发=进程内每日 worker + 总闸默认关 → Task 9 ✓ + +**2. Placeholder scan:** 无 TBD/TODO 泛化步骤;`home_view` 事件名以 `HOME_VIEW_EVENT` 常量显式占位(前端明天定名后仅改该常量,已在 Task 3 注明)。migration 的 revision/down_revision 由 autogenerate 生成(非占位)。 + +**3. Type consistency:** 跨任务一致 —— `activity.reset_cutoff/as_utc/norm_utc/last_active_subqueries/last_active_expr/ACTIVE_EVENTS/ACTIVE_ENGAGE_TYPE/HOME_VIEW_EVENT`;`inactivity.run_once/run_reset_once/run_warn_once/clear_user/select_inactive_users/select_warn_candidates/RESET_BIZ_TYPE`;`notifier.get_notifier/InactivityNotifier.warn(...)->str/channel`;模型 `InactivityResetLog/InactivityNotificationLog` 字段名与 Task 1 定义一致;`wallet.grant_coins/grant_cash/grant_invite_cash(db, uid, amount, *, biz_type, ref_id, remark)` 与仓库实际签名一致;`get_or_create_account(db, uid, commit=False, lock=True)` 一致。 + +**已知跨仓依赖:** `home_view` 埋点由 Android 端明天新增(spec §11);后端先以常量占位、总闸默认关,`home_view` 覆盖稳定后再开总闸(spec §13)。 diff --git a/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md b/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md new file mode 100644 index 0000000..3df388d --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md @@ -0,0 +1,296 @@ +# 15 天不活跃自动清零(金币 + 现金)设计 + +- **日期**:2026-07-16 +- **状态**:Draft — 待评审 +- **所属**:app-server(`app/`),含一处 admin 侧重构 + 一项 Android 端埋点依赖 +- **一句话**:连续 15 天不活跃的用户,自动清零其金币与现金;清零前按可配置节奏预警;全过程留审计以备纠纷排查。 + +--- + +## 1. 背景与目标 + +运营需要对**长期不活跃**用户的钱包余额做清理。两条硬性要求: + +1. **可审计**:记录清零原因与**清零前的三桶余额**,便于后续排查与处理客户纠纷。 +2. **临清预警**:在临近清零前推送信息告知用户"因账号不活跃,账户里的 xx 金币和 xx 现金将被清零"。 + +### 非目标(本期不做) + +- 不做真实推送通道(极光 JPush / 短信)的对接 —— 仅做**可插拔通知器 + 日志占位**,接口预留、后续无缝替换。 +- 不改动提现(`WithdrawOrder`)流程。 +- 不新增 `User.last_active_at` 列、不改鉴权热路径。 + +--- + +## 2. 需求 + +| # | 需求 | 落地 | +|---|---|---| +| R1 | 连续 15 天不活跃 → 清零金币 + 现金 | 每日 worker 扫描 + 逐用户事务清零(§6) | +| R2 | 记录清零原因 + 清零前余额 | `inactivity_reset_log` 审计表 + 3 条钱包流水(§5、§7) | +| R3 | 临清前预警"xx 金币 xx 现金将清零" | 阶段 A 预警 + `inactivity_notification_log`(§6、§7) | +| R4 | 活跃口径与"用户管理"一致 | 抽共享模块 `activity.py`,admin 与 worker 共用(§4、§12) | +| R5 | 预警时机完全可配置 | `INACTIVITY_*` 配置项(§8) | + +--- + +## 3. 决策记录(来自评审问答) + +| 决策点 | 结论 | 理由 | +|---|---|---| +| **活跃口径** | 与"用户管理"一致:`max(首页可见 show/home, 比价, 领券)`,**不含 last_login_at**;无任何信号时以 `created_at` 为非空基线 | 比价可从**浮窗**触发、不进首页;`last_login_at` 只在登录/换绑动作更新(re-login 也算),代表不了"在用 App",故彻底排除 | +| **"进首页"信号落地** | **方案 A:前端上报 `home_view` 埋点**(复用 `/analytics/events`),非新接口 | 三个活跃信号统一为同类埋点事件;零新接口零新列;与 admin 口径天然一致。B(鉴权接口 + 列)"更权威"的优势是假的——比价/领券仍是端上报事件,最弱环决定整体可信度 | +| **清零范围** | **金币 + 折算现金**(**邀请现金不清**——产品红线,仅快照入审计) | 对应"账户里的金币和现金";邀请奖励金与金币现金物理隔离、不可累加,见 `wallet.CoinAccount` 注释 | +| **预警推送** | **可插拔通知器 + 日志占位**(v1),后续接 JPush/短信 | 现状无真实推送能力;先把清零主流程 + 审计做扎实,不阻塞 | +| **预警时机** | **完全可配置**(提前天数列表 + 次数 + 执行点 + 通道) | R5 | +| **触发方式** | **进程内每日 worker**,仿 `daily_exchange_worker` | 与项目最新模式一致,无需外部 cron | +| **admin 共享口径** | 共享模块 + **重构 admin 改用它** | 单一真源,永不漂移(R4) | + +### 已知取舍(可接受) + +- analytics 的 `user_id` 是**端上报、未鉴权**(可伪造)。但伪造只能"保自己活跃、避免被清",无收益,且正是本功能要防的行为,风险良性。活跃时间的非空基线由服务端权威的 `User.created_at` 提供(见 §4),不再依赖 `last_login_at`。与"用户管理"口径一致。 + +--- + +## 4. 活跃口径与共享模块 `app/repositories/activity.py`(新建) + +活跃口径的**唯一真源**。app 侧模块,admin 可 import(`app.main` 不 import `app.admin`,反向允许)。 + +### 口径 + +``` +last_active = max( + User.created_at, # 注册基线(恒非空;re-login 不推进,只有真实使用才推进) + max AnalyticsEvent.created_at WHERE event IN ACTIVE_EVENTS, + max CouponPromptEngagement.created_at WHERE engage_type == "claim_started", +) +不活跃判定:按北京自然日、0 点对齐(非从末次活跃时刻滚动 15×24h) + last_active_date = 北京(last_active).date() # 末次活跃的北京日,记为「第 1 日」 + 清零边界 = 北京 00:00 of (last_active_date + RESET_DAYS 天) =「第 (RESET_DAYS+1) 日 0 点」 # 15 → 第16日0点 + 应清零 ⟺ (cn_today() − last_active_date).days ≥ RESET_DAYS + ⟺ last_active < cutoff, cutoff = 北京 00:00 of (cn_today() − (RESET_DAYS−1)) # 供 SQL 比较 + inactive_days = (cn_today() − last_active_date).days # 清零当日恰 = RESET_DAYS + 例:末次活跃 1/1 → 1/16 00:00(第16日0点)清零,当日 inactive_days=15;1/15 及之前不清 +``` + +### 模块内容 + +- 常量: + - **首页可见活跃信号已定名:`event=show` + `page=home`**(前端确认,原占位 `home_view`;下文出现的 `home_view` 均指此信号)。活跃行为过滤见 `activity.active_event_condition()`:首页可见 ∪ 比价 `real_compare_start` ∪ 领券 `real_coupon_start`;`ACTIVE_EVENTS` 仅含后两个纯 event 名(首页可见是 event+page 组合、单列)。 + - `ACTIVE_ENGAGE_TYPE = "claim_started"` +- `last_active_subqueries(db)` —— 复刻现 admin `queries._last_active_parts()`:两个按 `user_id` 的 `GROUP BY max(created_at)` 聚合子查询。 +- `last_active_expr(base_col, ev_sub, eng_sub, dialect)` —— 生成 `greatest`/`max`(PG `func.greatest`/SQLite `func.max`);子聚合缺失时 `coalesce(子聚合, User.created_at)` 兜底(注册基线恒非空,**替代原 last_login_at**)。 +- `_norm_utc()` —— 沿用现 admin 的 naive→UTC 归一(SQLite naive / PG aware 混算保护)。 +- `reset_cutoff(reset_days)` / `warn_cutoff(reset_days, k)` —— 生成**北京 0 点对齐**的边界 datetime(见口径):`reset_cutoff = 北京 00:00 of (cn_today() − (reset_days−1))`,供下面查询按 `last_active < cutoff` 比较。 +- `select_inactive_users(db, *, cutoff, with_balance=True)` —— **worker 专用**:join `CoinAccount`,筛 `last_active < cutoff`(cutoff = 北京 0 点对齐边界,见口径)且(`coin_balance>0 OR cash_balance_cents>0`;**邀请现金不清、不计入候选**),返回 `(user, account, last_active, inactive_days)`。 +- `select_warn_targets(db, *, reset_days, warn_days_before)` —— **worker 专用**:返回 `(user, account, last_active, inactive_days, stage)` 元组——各"提前天数"窗口内、有余额、本 streak 未推过档 `stage` 的用户(去重结合 `notification_log`,逻辑见 §9)。 + +> **参考现状**:现口径散落在 `app/admin/repositories/queries.py:38,91-124,199-204`(`_ACTIVE_EVENTS`/`_last_active_parts`/`greatest`)与 `app/admin/repositories/stats.py:51-52,138-146`(`COMPARE_START_EVENT`/`COUPON_START_EVENT`/活跃用户集)。这些改为从 `activity.py` 导入(§12)。 + +--- + +## 5. 数据模型(2 张新表,不动 `User`) + +两表均登记进 `app/models/__init__.py`;一个 Alembic 迁移建两表(`render_as_batch`,SQLite 兼容)。 + +### ① `inactivity_reset_log` —— 清零审计(R2) + +仿 `app/models/phone_rebind_log.py` 的简单审计表风格。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | int PK autoincrement | | +| `user_id` | int, index, not null | | +| `coin_balance_before` | int, not null | 清零前金币 | +| `cash_balance_cents_before` | int, not null | 清零前折算现金(分) | +| `invite_cash_balance_cents_before` | int, not null | 清零前邀请现金(分) | +| `last_active_at` | DateTime(tz), nullable | 判定时的最近活跃时间 | +| `inactive_days` | int, not null | 判定时不活跃天数 | +| `reason` | String(32), not null | 如 `"inactive_15d"` | +| `reset_at` | DateTime(tz), server_default now(), index, not null | 清零时刻 | + +### ② `inactivity_notification_log` —— 预警记录 + 去重 + 占位 outbox(R3) + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | int PK autoincrement | | +| `user_id` | int, index, not null | | +| `stage` | int, not null | 提前天数档(如 7 / 2) | +| `inactive_days` | int, not null | 推送时不活跃天数 | +| `coin_balance` | int, not null | 推送快照:告知用户的金币数 | +| `cash_balance_cents` | int, not null | 推送快照:折算现金 | +| `invite_cash_balance_cents` | int, not null | 推送快照:邀请现金 | +| `channel` | String(16), not null | `"log"` / `"jpush"` / `"sms"` | +| `status` | String(16), not null | `"placeholder"` / `"sent"` / `"failed"` | +| `created_at` | DateTime(tz), server_default now(), index, not null | 去重锚点(见 §9) | + +> 备注:不新增 `User.last_active_at` 列,不改 `get_current_user`。活跃时间由 §4 口径**实时计算**。 + +--- + +## 6. 清零 worker `app/core/inactivity_reset_worker.py`(新建) + +**完全仿 [`app/core/daily_exchange_worker.py`](../../../app/core/daily_exchange_worker.py)**:App 启动自带 asyncio 任务,文件锁(`data/inactivity_reset.lock`)防同机多进程并发,总闸 `INACTIVITY_RESET_ENABLED`(默认关)。 + +### 调度 + +- 每 `INACTIVITY_RESET_CHECK_INTERVAL_SEC` 秒醒一次;`last_run: date` 守卫**北京日**,保证每日只跑一轮。 +- 仅当 `cn_today() != last_run` 且当前北京小时 `>= INACTIVITY_RESET_RUN_HOUR` 时执行(启动补跑同 daily_exchange 语义)。 +- **清零资格边界 = 第 16 日 0 点(北京,见 §4),与 worker 执行点解耦**:worker 于当日 `RUN_HOUR`(默认 3 点)跑,把已过边界者一并清;若要严格 0 点触发可置 `RUN_HOUR=0`,但注意与 `daily_auto_exchange` 的 0 点任务错峰。 +- lifespan 里 `start_inactivity_reset_worker()` / `stop_...`(仿 `start_daily_exchange_worker` 在 `app/main.py` 的接线)。 + +### 一轮 `run_once(db)` 两阶段(同一次运行、各自逐用户独立 commit) + +**阶段 A — 预警** +``` +for user, acc, last_active, inactive_days, stage in activity.select_warn_targets(...): + notifier.send_inactivity_warning(user, balances=snapshot(acc), stage=stage, days_until_reset=RESET_DAYS-inactive_days) + db.add(InactivityNotificationLog(..., channel=notifier.channel, status=notifier.last_status)) + db.commit() # 逐条独立 +``` + +**阶段 B — 清零**(`biz_type="inactivity_reset"`) +``` +for user, acc, last_active, inactive_days in activity.select_inactive_users(db, cutoff=activity.reset_cutoff(RESET_DAYS)): # 北京 00:00 of (今天−(RESET_DAYS−1)) + try: + acc = wallet.get_or_create_account(db, user.id, commit=False, lock=True) # 行锁 + before = (acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents) + if acc.coin_balance == 0 and acc.cash_balance_cents == 0: continue # 邀请现金不清,不算可清余额 + log = InactivityResetLog(user_id=user.id, coin_balance_before=before[0], + cash_balance_cents_before=before[1], invite_cash_balance_cents_before=before[2], # 邀请现金仅快照 + last_active_at=last_active, inactive_days=inactive_days, reason=f"inactive_{RESET_DAYS}d") + db.add(log); db.flush() # 拿 log.id 作 ref_id 交叉链接 + if acc.coin_balance: wallet.grant_coins(db, user.id, -acc.coin_balance, biz_type="inactivity_reset", ref_id=str(log.id), remark="15天不活跃清零") + if acc.cash_balance_cents: wallet.grant_cash(db, user.id, -acc.cash_balance_cents, biz_type="inactivity_reset", ref_id=str(log.id), remark="15天不活跃清零") + # 邀请现金(invite_cash_balance_cents)不清:产品红线、两本账物理隔离,仅快照记入审计。 + db.commit() + except SQLAlchemyError: + db.rollback(); stats["failed"] += 1 +``` + +- `grant_*` 负数出账、`balance_after=0`、写**两条**流水(金币 + 折算现金;**邀请现金不清**);`grant_coins` 负数**不**动 `total_coin_earned`(历史累计保留)。 +- 逐用户独立 commit:一个失败不影响其余。返回 `stats = {warned, warn_skipped, warn_failed, scanned, cleared, failed}` 并 `logger.info`。**预警逐用户 try/except 隔离、且预警整段异常也绝不阻塞清零**(清零是不可逆资金操作,不能被通知故障拖住)。 + +--- + +## 7. 预警与可插拔通知器 + +`app/integrations/notifier.py` 定义协议(外部投递属 integrations 层): + +```python +class InactivityNotifier(Protocol): + channel: str # "log" / "jpush" / "sms" + last_status: str # "placeholder" / "sent" / "failed" + def send_inactivity_warning(self, user, *, balances, stage, days_until_reset) -> None: ... +``` + +- **v1 `LogNotifier`**(`channel="log"`):`logger.warning("[inactivity-warn] user=%s coin=%s cash=%s invite=%s T-%s", ...)`,`last_status="placeholder"`。参照 `heartbeat_monitor_worker` 先例("本期先不接推送,用终端打印代替")。 +- 未来 `JPushNotifier` / `SmsNotifier`:实现同协议即可替换,worker 不改。 +- 选择:`INACTIVITY_NOTIFY_CHANNEL` → 工厂返回对应实现(未配到真实实现时回退 `LogNotifier`)。 +- 预警文案数据来自快照 `balances`,满足 R3"告知 xx 金币 xx 现金"。 + +--- + +## 8. 配置项(`app/core/config.py`) + +``` +INACTIVITY_RESET_ENABLED = False # 总闸,默认关;灰度验证后再开 +INACTIVITY_RESET_DAYS = 15 # 不活跃阈值(天) +INACTIVITY_WARN_DAYS_BEFORE = "7,2" # 清零前几天各推一次;空串=不推。逗号分隔,降序解析 +INACTIVITY_RESET_RUN_HOUR = 3 # 北京时间每日执行点(0-23) +INACTIVITY_NOTIFY_CHANNEL = "log" # log(占位) / jpush / sms +INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现有间隔常量) +``` + +- 清零范围(三桶)固定为常量,不做配置。 +- `INACTIVITY_WARN_DAYS_BEFORE` 语义(`inactive_days` 为北京自然日,见 §4):档位 `k` ⟹ 当 `inactive_days >= RESET_DAYS-k` 且 `< RESET_DAYS` 且本 streak 未推过档 `k` 时预警,即在北京日 `last_active_date + (RESET_DAYS−k)` 触发(漏跑某天时补发最紧急未推档,§9)。 +- `INACTIVITY_RESET_RUN_HOUR` 只决定 worker 每日执行点,**不改变**"第 16 日 0 点"这一资格边界(§4/§6)。 + +--- + +## 9. 幂等与重新活跃 + +- **重新活跃自动退出**:`inactive_days` 由 §4 口径**实时算**。用户一有 `home_view`/比价/领券(**登录本身不算**),`last_active` 前移,自动移出预警与清零队列。**无需**显式"重置标记"。 +- **预警去重**:`inactivity_notification_log` 中存在 `stage==k 且 created_at > last_active` 的行 ⟹ 本 streak 已推过档 `k`,不重推。用户回归后 `last_active` 前移,旧预警行自然"失效",开启新 streak。 +- **清零幂等**:阶段 B 只处理三桶非全 0 者;清完 = 0,次日不再匹配。worker 重启 / 多次唤醒 / 补跑均安全,不产生重复清零或重复流水。 +- **稳健补发**:worker 漏跑数天后,某用户可能同时满足多档;只补发**最紧急的未推档**(最小 `k`),避免一次刷屏。 + +--- + +## 10. 边界与安全 + +| 场景 | 处理 | +|---|---| +| 新用户 | `created_at` 作活跃基线(恒非空)→ 注册即"第 1 日活跃";注册后连续 15 天无 home_view/比价/领券 才清 | +| 在途提现 | 提现申请时现金已扣入 `WithdrawOrder`,当前余额已不含在途;只清当前余额、不动提现单。提现失败退款到已清账户 = 用户的钱,正常 | +| 与 `daily_auto_exchange` 并存 | 各自逐用户幂等;金币多已日结折现金,三桶全清正好覆盖 | +| 时区/日界 | 统一北京(`rewards.cn_today()`/`CN_TZ`);**清零/预警按北京自然日 0 点对齐**(末次活跃记为第 1 日 → 第 16 日 0 点清零,见 §4),非滚动 24h;流水 `created_at` 沿用北京 wall-clock naive | +| 误清防护 | 总闸默认关;先灰度只看预警/清零**名单**对不对,再开清零(§13) | + +--- + +## 11. 前端依赖:`home_view` 埋点(跨仓 — Android) + +- **Android 端**(`shaguabijia-app-android`)需在**首页可见**(`onResume`/Tab 切入)时,向现有 `POST /api/v1/analytics/events` 批量上报里加一条 `event=<首页可见事件名>`(名称明天加埋点时定,暂记 `"home_view"`) 的事件,**携带登录后的 `user_id`**。 +- 客户端按会话/前台去重即可(服务端只取 `max(created_at)`,多报无害)。 +- **上线顺序依赖**:`home_view` 全量覆盖前,"进首页"信号缺失,只有比价/领券能推进活跃、其余落到 `created_at` 基线("只开首页不操作"且注册满 15 天的用户会被误清)—— 故清零总闸必须待 `home_view` 铺满后再开(§13)。 + +--- + +## 12. admin 重构范围与影响(R4) + +- `app/admin/repositories/queries.py`:删本地 `_ACTIVE_EVENTS`/`_last_active_parts()`,改用 `activity.py` 的常量与子查询构造;`list_users` 的 `greatest(...)` 排序/筛选、`_attach_last_active` 均改走共享构造器。 +- `app/admin/repositories/stats.py`:`COMPARE_START_EVENT`/`COUPON_START_EVENT`/活跃用户集(`:138-146`)改用共享常量与口径。 +- **行为变化(预期内、需产品知会)**:admin 的"最近活跃 / DAU"口径变化——**移除 `last_login_at`(登录不再计为活跃)、以 `created_at` 为基线、纳入 `home_view`**。net:`home_view` 铺满后更准(真正把"开首页"算进活跃);铺满前"只登录不操作"的用户活跃度会下降。 +- **回归底线**:现有 admin 用户列表 / stats 测试按新口径**更新预期**(last_login_at 移除 + created_at 基线 + home_view 纳入);非活跃口径部分行为不变。 + +--- + +## 13. 灰度与上线顺序(安全优先) + +1. **后端先行**:合入共享模块 + 两表 + worker + 通知器,`INACTIVITY_RESET_ENABLED=False`;活跃口径以 `created_at` 为非空基线、**不含 last_login_at**。 +2. **Android 发版**:上报 `home_view`;观察 analytics 覆盖率。 +3. **只读灰度**:临时开一个"dry-run/只出名单不清零"路径或看阶段 A 预警日志,核对预警/清零**名单**准确。 +4. **开清零**:确认无误后置 `INACTIVITY_RESET_ENABLED=True`。 +5. **收尾/监控**:持续观察 `home_view` 覆盖率与预警/清零名单;发现"活跃却被判不活跃"的漏报即回查埋点覆盖(口径已不含 last_login_at,登录不再兜底)。 + +--- + +## 14. 测试计划 + +- **活跃口径(共享模块)**:`home_view`/比价/领券 各单独命中都算活跃;**纯登录不算**;无信号用户以 `created_at` 计;`max` 取最新;naive/aware 混算不崩。 +- **admin 回归**:用户列表 / stats 按新口径更新预期(移除 last_login_at + created_at 基线 + home_view)。 +- **不活跃判定**:`last_active` 分别 `<15d / =15d / >15d` × 有/无余额 的命中矩阵。 +- **清零**:三桶归零;`inactivity_reset_log` 清前值正确;三条流水 `biz_type=inactivity_reset`、`balance_after=0`、`ref_id=log.id`;`total_coin_earned` 不变。 +- **预警**:命中窗口调 notifier + 写 `notification_log`;同 streak 不重推;回归后 `last_active` 前移可再次预警;漏跑补发最紧急档。 +- **worker**:总闸关不启动;文件锁互斥;逐用户失败隔离(一个抛错不影响其余,`failed` 计数);重复跑幂等。 +- **配置**:`INACTIVITY_WARN_DAYS_BEFORE` 解析(含空串=不推);`RESET_DAYS`/`RUN_HOUR` 生效。 +- 沿用 `tests/conftest.py`(临时 SQLite、`RATE_LIMIT_ENABLED=false`);外部通知 monkeypatch。 + +--- + +## 15. 未来工作 + +- 接真实 `JPushNotifier`(需用户级 `registration_id` 覆盖 + JPush push API)/ `SmsNotifier`。 +- 如需 admin 后台可视化:不活跃/预警/清零名单与历史查询接口。 +- 如量级增长导致每日 join 扫描变慢:再考虑物化 `last_active_at`(当前每日一次可接受)。 + +--- + +## 附:涉及文件清单 + +**新增** +- `app/repositories/activity.py` — 活跃口径唯一真源 +- `app/models/inactivity_reset_log.py` — 审计表 +- `app/models/inactivity_notification_log.py` — 预警/占位表 +- `app/core/inactivity_reset_worker.py` — 每日 worker(仿 daily_exchange_worker) +- `app/integrations/notifier.py` — 通知器协议 + `LogNotifier`(真实 JPush/短信后续同层扩展) +- `alembic/versions/<...>_add_inactivity_tables.py` — 建两表迁移 +- `docs/database/inactivity_reset_log.md` / `inactivity_notification_log.md` — 表字典(随实现补) +- 对应 `tests/test_inactivity_reset.py` + +**改动** +- `app/models/__init__.py` — 注册两模型 +- `app/core/config.py` — `INACTIVITY_*` 配置 +- `app/main.py` — lifespan 接线 start/stop worker +- `app/admin/repositories/queries.py`、`stats.py` — 改用 `activity.py`(§12) diff --git a/scripts/seed_inactivity_cases.py b/scripts/seed_inactivity_cases.py new file mode 100644 index 0000000..a1293d6 --- /dev/null +++ b/scripts/seed_inactivity_cases.py @@ -0,0 +1,139 @@ +"""人工验证用:按「金币/现金/邀请」排列组合 + 活跃/新用户对照,造一批账号。 + +用法(仓库根目录,venv 解释器): + .venv/Scripts/python.exe scripts/seed_inactivity_cases.py # 造号(会先清掉上次 vcase*) + .venv/Scripts/python.exe scripts/seed_inactivity_cases.py --clean # 只清理,不造 + +配合默认配置 INACTIVITY_RESET_DAYS=15 / INACTIVITY_WARN_DAYS_BEFORE=7,2 验证。 +造完把 worker 打开(见 README/对话里的 .env),启动服务即会在 RUN_HOUR 后跑一轮。 + +⚠️ worker 清零针对**库里所有**符合条件的用户,不止 vcase*——dev 库里若有其它"老且有余额、 +无近期活跃事件"的用户,也会被一起清。要干净验证建议用一个空/副本 dev 库。 +""" +from __future__ import annotations + +import os +import sys +from datetime import UTC, datetime, timedelta + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import delete, select # noqa: E402 + +from app.db.session import SessionLocal # noqa: E402 +from app.models.analytics_event import AnalyticsEvent # noqa: E402 +from app.models.inactivity import ( # noqa: E402 + InactivityNotificationLog, + InactivityResetLog, +) +from app.models.user import User # noqa: E402 +from app.models.wallet import ( # noqa: E402 + CashTransaction, + CoinAccount, + CoinTransaction, + InviteCashTransaction, +) +from app.repositories import wallet as wallet_repo # noqa: E402 + +MARK = "vcase" # username 前缀,用于清理 + +# label, 创建于N天前, coin, cash, invite, 近期事件(N天前)or None, 预期 +CASES = [ + ("1 三桶全有", 30, 100, 200, 300, None, "清 coin+cash;invite=300 保留;审计1行+2流水"), + ("2 金币+现金", 30, 100, 200, 0, None, "清 coin+cash;审计1行+2流水"), + ("3 金币+邀请", 30, 100, 0, 300, None, "清 coin;invite=300 保留;审计1行+1流水"), + ("4 现金+邀请", 30, 0, 200, 300, None, "清 cash;invite=300 保留;审计1行+1流水"), + ("5 只有金币", 30, 100, 0, 0, None, "清 coin;审计1行+1流水"), + ("6 只有现金", 30, 0, 200, 0, None, "清 cash;审计1行+1流水"), + ("7 只有邀请(红线)", 30, 0, 0, 300, None, "不选中/不清/无审计/无流水;invite=300 原封"), + ("8 预警窗(10天)", 10, 50, 60, 70, None, "不清;发 T-7 预警;notification_log 1行;余额不动"), + ("9 活跃兜底", 30, 100, 200, 300, 1, "昨日 home_view→last_active 近→不清不警"), + ("10 新用户(3天)", 3, 100, 200, 0, None, "created_at 近→不清不警"), +] + + +def _mark_uids(db) -> list[int]: + return list(db.execute(select(User.id).where(User.username.like(f"{MARK}%"))).scalars()) + + +def clean(db) -> int: + uids = _mark_uids(db) + if uids: + for model in ( + InactivityResetLog, InactivityNotificationLog, + CoinTransaction, CashTransaction, InviteCashTransaction, + AnalyticsEvent, CoinAccount, + ): + db.execute(delete(model).where(model.user_id.in_(uids))) + db.execute(delete(User).where(User.id.in_(uids))) + db.commit() + return len(uids) + + +def seed(db) -> None: + now = datetime.now(UTC) + print(f"{'#':>3} {'uid':>5} {'案例':<16} {'coin/cash/invite':<18} {'创建':<7} 预期") + for i, (label, days_ago, coin, cash, invite, ev_days, expected) in enumerate(CASES, 1): + u = User( + phone=f"seed_tmp_{i}", username=f"{MARK}{i}", status="active", + created_at=now - timedelta(days=days_ago), + last_login_at=now, # 登录很新——但登录不算活跃,清零该发生照发生 + ) + db.add(u) + db.flush() # 拿自增 id + u.phone = f"1{u.id:010d}" # 用全局唯一 id 拼 "100…" 段手机号,dev 库里绝不撞 + acc = wallet_repo.get_or_create_account(db, u.id, commit=False) + acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents = coin, cash, invite + acc.total_coin_earned = coin + if ev_days is not None: + db.add(AnalyticsEvent( # 首页可见 = event=show + page=home + event="show", page="home", device_id=MARK, user_id=u.id, + client_ts=0, created_at=now - timedelta(days=ev_days), + )) + db.flush() + print(f"{i:>3} {u.id:>5} {label:<16} {f'{coin}/{cash}/{invite}':<18} {f'{days_ago}天前':<7} {expected}") + db.commit() + + +def check(db) -> None: + """worker 跑完后:打印每个 vcase 账号的当前三桶余额 + 是否有审计/预警行。""" + rows = db.execute( + select(User.id, User.username).where(User.username.like(f"{MARK}%")).order_by(User.id) + ).all() + if not rows: + print("没有 vcase* 账号(先跑一次不带参数造号)") + return + print(f"{'uid':>5} {'账号':<8} {'coin/cash/invite(现在)':<24} {'审计':<5} 预警") + for uid, uname in rows: + acc = db.get(CoinAccount, uid) + bal = f"{acc.coin_balance}/{acc.cash_balance_cents}/{acc.invite_cash_balance_cents}" if acc else "—" + has_reset = db.execute( + select(InactivityResetLog.id).where(InactivityResetLog.user_id == uid).limit(1) + ).first() + stages = db.execute( + select(InactivityNotificationLog.stage).where(InactivityNotificationLog.user_id == uid) + ).scalars().all() + warn = ",".join(f"T-{s}" for s in stages) if stages else "—" + print(f"{uid:>5} {uname:<8} {bal:<24} {'有' if has_reset else '—':<5} {warn}") + + +def main() -> None: + db = SessionLocal() + try: + if "--check" in sys.argv: + check(db) + return + removed = clean(db) + if removed: + print(f"已清理上次 {removed} 个 {MARK}* 账号") + if "--clean" in sys.argv: + return + seed(db) + print("\n造号完成。打开 worker(INACTIVITY_RESET_ENABLED=true, RUN_HOUR=17)后启动服务," + "≥17:00 首个 tick 即跑一轮。验完 `--clean` 清理。") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/tests/test_inactivity_reset.py b/tests/test_inactivity_reset.py new file mode 100644 index 0000000..6860d2c --- /dev/null +++ b/tests/test_inactivity_reset.py @@ -0,0 +1,416 @@ +"""15 天不活跃清零:模型 / 活跃口径 / 清零 / 预警 / 配置 / worker。""" +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone + +import pytest +from sqlalchemy import delete, select, update + +from app.db.session import SessionLocal +from app.models.inactivity import InactivityNotificationLog, InactivityResetLog +from app.repositories import activity + + +def test_reset_and_notification_models_persist() -> None: + db = SessionLocal() + try: + db.add(InactivityResetLog( + user_id=1, coin_balance_before=10, cash_balance_cents_before=20, + invite_cash_balance_cents_before=30, + last_active_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + inactive_days=15, reason="inactive_15d", + )) + db.add(InactivityNotificationLog( + user_id=1, stage=7, inactive_days=8, coin_balance=10, + cash_balance_cents=20, invite_cash_balance_cents=30, + channel="log", status="placeholder", + )) + db.commit() + r = db.execute(select(InactivityResetLog).where(InactivityResetLog.user_id == 1)).scalar_one() + assert r.reason == "inactive_15d" and r.reset_at is not None + n = db.execute(select(InactivityNotificationLog).where(InactivityNotificationLog.user_id == 1)).scalar_one() + assert n.stage == 7 and n.created_at is not None + finally: + db.rollback() + db.close() + + +def test_reset_cutoff_is_cn_midnight_of_today_minus_days_minus_1() -> None: + # RESET_DAYS=15, today=1/20 → cutoff = 北京 00:00 of 1/6 = 1/5 16:00 UTC + cutoff = activity.reset_cutoff(15, today=date(2026, 1, 20)) + assert cutoff == datetime(2026, 1, 5, 16, 0, tzinfo=timezone.utc) + + +def test_active_event_constants() -> None: + # 首页可见 = event=show + page=home 组合,不在纯 event 名集合里 + assert activity.HOME_VIEW_EVENT == "show" and activity.HOME_VIEW_PAGE == "home" + assert activity.HOME_VIEW_EVENT not in activity.ACTIVE_EVENTS + assert "real_compare_start" in activity.ACTIVE_EVENTS + assert "real_coupon_start" in activity.ACTIVE_EVENTS + assert activity.ACTIVE_ENGAGE_TYPE == "claim_started" + + +def test_as_utc_normalizes() -> None: + assert activity.as_utc(datetime(2026, 1, 1)) == datetime(2026, 1, 1, tzinfo=timezone.utc) + cn = datetime(2026, 1, 1, tzinfo=activity.CN_TZ) # 北京 0 点 = 前一天 16:00 UTC + assert activity.as_utc(cn) == datetime(2025, 12, 31, 16, 0, tzinfo=timezone.utc) + + +from app.core.rewards import CN_TZ +from app.models.analytics_event import AnalyticsEvent +from app.models.coupon_state import CouponPromptEngagement +from app.models.user import User +from app.models.wallet import CoinAccount +from app.repositories import wallet as wallet_repo + +_PHONE_SEQ = [0] + + +@pytest.fixture(autouse=True) +def _isolate_inactivity_state(): + """本文件的测试都做全表扫描 + 全局计数,而 SQLite 测试库 session 级共享、无逐用例回滚 + (commit 后的 rollback 是 no-op),故先把可能泄漏的余额清零 + 清掉活跃事件/审计行, + 保证每个用例干净起步。不删 User(零余额用户不会被扫描选中,避免跨文件/外键影响)。""" + db = SessionLocal() + try: + db.execute(update(CoinAccount).values( + coin_balance=0, cash_balance_cents=0, invite_cash_balance_cents=0)) + for model in (AnalyticsEvent, CouponPromptEngagement, + InactivityResetLog, InactivityNotificationLog): + db.execute(delete(model)) + db.commit() + finally: + db.close() + yield + + +def _new_user(db, *, created_at, coin=0, cash=0, invite=0) -> int: + """直接建一个 User + CoinAccount,created_at 可控。返回 user_id。""" + _PHONE_SEQ[0] += 1 + # 199 前缀 + 递增序号:共享测试库跨文件累积用户,别的文件用固定手机号(如 test_admin_write + # 的 13900000001..),这里用没人用的 199 段避免撞 user.phone / username 的 UNIQUE。 + u = User(phone=f"199{_PHONE_SEQ[0]:08d}", created_at=created_at, + last_login_at=created_at, status="active", + username=f"inact{_PHONE_SEQ[0]}") + db.add(u) + db.flush() + acc = wallet_repo.get_or_create_account(db, u.id, commit=False) + acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents = coin, cash, invite + acc.total_coin_earned = coin + db.flush() + return u.id + + +def _add_event(db, user_id, event, when: datetime, page=None) -> None: + db.add(AnalyticsEvent(event=event, device_id="d", user_id=user_id, client_ts=0, + created_at=when, page=page)) + + +def _add_engage(db, user_id, when: datetime, engage_type="claim_started") -> None: + db.add(CouponPromptEngagement(device_id=f"dev{user_id}", package="p", user_id=user_id, + engage_date=when.date(), engage_type=engage_type, created_at=when)) + + +def test_last_active_expr_takes_max_of_baseline_and_events() -> None: + from sqlalchemy import select + db = SessionLocal() + try: + base = datetime(2026, 1, 1, tzinfo=timezone.utc) + uid = _new_user(db, created_at=base, coin=5) + _add_event(db, uid, "real_compare_start", datetime(2026, 1, 10, tzinfo=timezone.utc)) + db.commit() + ev_sub, eng_sub = activity.last_active_subqueries(db) + dialect = db.get_bind().dialect.name + expr = activity.last_active_expr(User.created_at, ev_sub, eng_sub, dialect) + stmt = (select(expr).select_from(User) + .outerjoin(ev_sub, ev_sub.c.user_id == User.id) + .outerjoin(eng_sub, eng_sub.c.user_id == User.id) + .where(User.id == uid)) + got = activity.norm_utc(db.execute(stmt).scalar_one()) + assert got == datetime(2026, 1, 10, tzinfo=timezone.utc) # 事件 > 基线 + finally: + db.rollback() + db.close() + + +def test_home_signal_uses_show_event_on_home_page() -> None: + """首页可见活跃口径 = event=show + page=home 组合;show 但非 home 页不算活跃。""" + from sqlalchemy import select + db = SessionLocal() + try: + base = datetime(2026, 1, 1, tzinfo=timezone.utc) + seen = _new_user(db, created_at=base) # show/home → 活跃 + _add_event(db, seen, "show", datetime(2026, 1, 10, tzinfo=timezone.utc), page="home") + other = _new_user(db, created_at=base) # show/其他页 → 不算活跃 + _add_event(db, other, "show", datetime(2026, 1, 10, tzinfo=timezone.utc), page="coupon") + db.commit() + + ev_sub, eng_sub = activity.last_active_subqueries(db) + dialect = db.get_bind().dialect.name + expr = activity.last_active_expr(User.created_at, ev_sub, eng_sub, dialect) + + def last_active(uid): + stmt = (select(expr).select_from(User) + .outerjoin(ev_sub, ev_sub.c.user_id == User.id) + .outerjoin(eng_sub, eng_sub.c.user_id == User.id) + .where(User.id == uid)) + return activity.norm_utc(db.execute(stmt).scalar_one()) + + assert last_active(seen) == datetime(2026, 1, 10, tzinfo=timezone.utc) # show/home 算 + assert last_active(other) == base # show/其他页 不算 + finally: + db.rollback() + db.close() + + +def test_inactivity_warn_stages_parsing() -> None: + from app.core.config import Settings + s = Settings(INACTIVITY_WARN_DAYS_BEFORE="7,2", INACTIVITY_RESET_DAYS=15) + assert s.inactivity_warn_stages == [7, 2] # 降序去重 + s2 = Settings(INACTIVITY_WARN_DAYS_BEFORE="", INACTIVITY_RESET_DAYS=15) + assert s2.inactivity_warn_stages == [] # 空=不推 + s3 = Settings(INACTIVITY_WARN_DAYS_BEFORE="2,20,7,2", INACTIVITY_RESET_DAYS=15) + assert s3.inactivity_warn_stages == [7, 2] # 去重 + 丢弃 >=RESET_DAYS(20) + + +def test_log_notifier_returns_placeholder(caplog) -> None: + from app.integrations.notifier import LogNotifier, get_notifier + n = get_notifier("log") + assert isinstance(n, LogNotifier) and n.channel == "log" + status = n.warn(user_id=1, coin=10, cash_cents=20, stage=7, days_until_reset=8) + assert status == "placeholder" + # 未实现通道回退 LogNotifier(占位) + assert get_notifier("jpush").channel == "log" + + +def test_run_reset_clears_coin_and_cash_but_preserves_invite_cash() -> None: + from sqlalchemy import select + from app.models.wallet import CoinAccount, CoinTransaction, CashTransaction, InviteCashTransaction + from app.repositories import inactivity + + db = SessionLocal() + try: + today = date(2026, 2, 1) + # 末次活跃 = created_at 基线 = 1/10(距 today 22 天 → 应清) + old = _new_user(db, created_at=datetime(2026, 1, 10, tzinfo=timezone.utc), + coin=100, cash=200, invite=300) + # 活跃用户:昨天有 home_view → 不清 + fresh = _new_user(db, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), coin=50) + _add_event(db, fresh, "show", datetime(2026, 1, 31, tzinfo=timezone.utc), page="home") + db.commit() + + stats = inactivity.run_reset_once(db, reset_days=15, today=today) + assert stats["cleared"] == 1 and stats["failed"] == 0 + + acc = db.get(CoinAccount, old) + # 金币 + 折算现金清零;邀请现金是产品红线,原封不动(见 wallet.CoinAccount 注释) + assert (acc.coin_balance, acc.cash_balance_cents) == (0, 0) + assert acc.invite_cash_balance_cents == 300 + assert acc.total_coin_earned == 100 # 历史累计不动 + + log = db.execute(select(InactivityResetLog).where(InactivityResetLog.user_id == old)).scalar_one() + # 审计仍快照三桶余额(邀请现金记为"清零时仍保留"的余额,便于纠纷排查) + assert (log.coin_balance_before, log.cash_balance_cents_before, + log.invite_cash_balance_cents_before) == (100, 200, 300) + assert log.inactive_days == 22 and log.reason == "inactive_15d" + + ct = db.execute(select(CoinTransaction).where( + CoinTransaction.user_id == old, CoinTransaction.biz_type == "inactivity_reset")).scalar_one() + assert ct.amount == -100 and ct.balance_after == 0 and ct.ref_id == str(log.id) + assert db.execute(select(CashTransaction).where( + CashTransaction.user_id == old, CashTransaction.biz_type == "inactivity_reset")).scalar_one().amount_cents == -200 + # 关键:不写邀请现金流水(邀请现金不清) + assert db.execute(select(InviteCashTransaction).where( + InviteCashTransaction.user_id == old, + InviteCashTransaction.biz_type == "inactivity_reset")).first() is None + + # 活跃用户不动;再跑一次幂等(coin+cash 已 0、邀请现金不算候选 → 不再匹配) + assert db.get(CoinAccount, fresh).coin_balance == 50 + assert inactivity.run_reset_once(db, reset_days=15, today=today)["cleared"] == 0 + finally: + db.rollback() + db.close() + + +def test_user_with_only_invite_cash_is_not_cleared() -> None: + """只有邀请现金余额的久不活跃用户:邀请现金是产品红线,不清 → 根本不该被选中。""" + from app.models.wallet import CoinAccount + from app.repositories import inactivity + + db = SessionLocal() + try: + today = date(2026, 2, 1) + uid = _new_user(db, created_at=datetime(2026, 1, 10, tzinfo=timezone.utc), + coin=0, cash=0, invite=500) + db.commit() + stats = inactivity.run_reset_once(db, reset_days=15, today=today) + assert stats["cleared"] == 0 + assert db.get(CoinAccount, uid).invite_cash_balance_cents == 500 # 原封不动 + finally: + db.rollback() + db.close() + + +def test_run_warn_picks_stage_and_dedups_within_streak() -> None: + from app.integrations.notifier import LogNotifier + from app.repositories import inactivity + + db = SessionLocal() + try: + today = date(2026, 2, 1) + # 末次活跃 1/22(距 today 10 天)→ 档 7 命中(idays>=8),档 2 未到(需>=13) + uid = _new_user(db, created_at=datetime(2026, 1, 22, tzinfo=timezone.utc), coin=100) + db.commit() + + stats = inactivity.run_warn_once(db, LogNotifier(), reset_days=15, warn_stages=[7, 2], today=today) + assert stats["warned"] == 1 + from sqlalchemy import select + rows = db.execute(select(InactivityNotificationLog).where( + InactivityNotificationLog.user_id == uid)).scalars().all() + assert len(rows) == 1 and rows[0].stage == 7 and rows[0].status == "placeholder" + assert rows[0].inactive_days == 10 and rows[0].coin_balance == 100 + + # 同一 streak 再跑 → 不重推 + assert inactivity.run_warn_once(db, LogNotifier(), reset_days=15, warn_stages=[7, 2], today=today)["warned"] == 0 + + # 无余额用户不预警 + _new_user(db, created_at=datetime(2026, 1, 22, tzinfo=timezone.utc), coin=0) + db.commit() + assert inactivity.run_warn_once(db, LogNotifier(), reset_days=15, warn_stages=[7, 2], today=today)["warned"] == 0 + finally: + db.rollback() + db.close() + + +def test_run_once_warns_then_resets() -> None: + from app.integrations.notifier import LogNotifier + from app.models.wallet import CoinAccount + from app.repositories import inactivity + + db = SessionLocal() + try: + today = date(2026, 2, 1) + warn_uid = _new_user(db, created_at=datetime(2026, 1, 22, tzinfo=timezone.utc), coin=10) # 10天→预警 + clear_uid = _new_user(db, created_at=datetime(2026, 1, 5, tzinfo=timezone.utc), coin=10) # 27天→清零 + db.commit() + stats = inactivity.run_once(db, notifier=LogNotifier(), reset_days=15, warn_stages=[7, 2], today=today) + assert stats["warned"] == 1 and stats["cleared"] == 1 + assert db.get(CoinAccount, clear_uid).coin_balance == 0 + assert db.get(CoinAccount, warn_uid).coin_balance == 10 # 预警不动钱 + finally: + db.rollback() + db.close() + + +def test_worker_disabled_returns_none(monkeypatch) -> None: + from app.core import inactivity_reset_worker as w + from app.core.config import settings + monkeypatch.setattr(settings, "INACTIVITY_RESET_ENABLED", False) + assert w.start_inactivity_reset_worker() is None + + +def test_worker_run_once_entry_executes(monkeypatch) -> None: + """_run_once_entry 用真实 SessionLocal 跑一轮,总闸开时能清掉一个不活跃用户。""" + from app.core import inactivity_reset_worker as w + from app.core.config import settings + from app.models.wallet import CoinAccount + + monkeypatch.setattr(settings, "INACTIVITY_RESET_ENABLED", True) + monkeypatch.setattr(settings, "INACTIVITY_RESET_DAYS", 15) + monkeypatch.setattr(settings, "INACTIVITY_WARN_DAYS_BEFORE", "") # 只测清零 + # 固定"今天"避免依赖真实时钟 + monkeypatch.setattr(w, "_cn_today", lambda: date(2026, 2, 1)) + + db = SessionLocal() + try: + uid = _new_user(db, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), coin=100) + db.commit() + finally: + db.close() + + stats = w._run_once_entry() + assert stats["cleared"] >= 1 + + db = SessionLocal() + try: + assert db.get(CoinAccount, uid).coin_balance == 0 + finally: + db.close() + + +def test_admin_list_users_last_active_ignores_login() -> None: + """admin 用户列表 last_active_at 改用共享口径:登录不算活跃(baseline=created_at)、只认活跃事件。""" + from app.admin.repositories import queries + + db = SessionLocal() + try: + created = datetime(2026, 1, 1, tzinfo=timezone.utc) + uid = _new_user(db, created_at=created) + u = db.get(User, uid) + u.last_login_at = datetime(2026, 6, 1, tzinfo=timezone.utc) # 登录很新、但无任何活跃事件 + db.commit() + phone = db.get(User, uid).phone + users, _cursor, _total = queries.list_users(db, phone=phone) + item = next(x for x in users if x.id == uid) + assert activity.norm_utc(item.last_active_at) == created # 登录不算 → last_active=created_at + finally: + db.rollback() + db.close() + + +def test_run_warn_isolates_notifier_failure_and_does_not_block_reset() -> None: + """单用户通知器抛错:预警计 warn_failed、不外抛,且清零(reset)照常执行。""" + from app.models.wallet import CoinAccount + from app.repositories import inactivity + + class BoomNotifier: + channel = "log" + + def warn(self, *, user_id, coin, cash_cents, stage, days_until_reset) -> str: + raise RuntimeError("push service down") + + db = SessionLocal() + try: + today = date(2026, 2, 1) + warn_uid = _new_user(db, created_at=datetime(2026, 1, 22, tzinfo=timezone.utc), coin=10) # 10天→预警 + clear_uid = _new_user(db, created_at=datetime(2026, 1, 5, tzinfo=timezone.utc), coin=10) # 27天→清零 + db.commit() + + stats = inactivity.run_once(db, notifier=BoomNotifier(), reset_days=15, + warn_stages=[7, 2], today=today) + assert stats["warned"] == 0 and stats["warn_failed"] >= 1 # 预警失败被隔离 + assert stats["cleared"] == 1 # 关键:清零没被阻塞 + assert db.get(CoinAccount, clear_uid).coin_balance == 0 + assert db.get(CoinAccount, warn_uid).coin_balance == 10 # 预警用户不动钱 + # 预警失败已回滚,不留半条 notification_log + from sqlalchemy import select + assert db.execute(select(InactivityNotificationLog).where( + InactivityNotificationLog.user_id == warn_uid)).first() is None + finally: + db.rollback() + db.close() + + +def test_run_once_reset_runs_even_if_warn_phase_throws(monkeypatch) -> None: + """预警整段异常(如候选查询失败)也绝不阻塞清零。""" + from app.integrations.notifier import LogNotifier + from app.models.wallet import CoinAccount + from app.repositories import inactivity + + def boom(*a, **k): + raise RuntimeError("warn phase blew up") + + monkeypatch.setattr(inactivity, "run_warn_once", boom) + + db = SessionLocal() + try: + today = date(2026, 2, 1) + clear_uid = _new_user(db, created_at=datetime(2026, 1, 5, tzinfo=timezone.utc), coin=10) + db.commit() + stats = inactivity.run_once(db, notifier=LogNotifier(), reset_days=15, + warn_stages=[7, 2], today=today) + assert stats["cleared"] == 1 + assert db.get(CoinAccount, clear_uid).coin_balance == 0 + finally: + db.rollback() + db.close() From 130a7dff29347bd98f751a0af93f62cc4ecc4e24 Mon Sep 17 00:00:00 2001 From: guke Date: Sun, 19 Jul 2026 00:14:03 +0800 Subject: [PATCH 24/24] =?UTF-8?q?docs(welfare):=2015=E5=A4=A9=E4=B8=8D?= =?UTF-8?q?=E6=B4=BB=E8=B7=83=E6=B8=85=E9=9B=B6=E9=87=91=E5=B8=81/?= =?UTF-8?q?=E7=8E=B0=E9=87=91=20=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3(spec)?= =?UTF-8?q?=20(#144)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修改INACTIVITY_RESET_ENABLED语义,为false时清空金币操作只记录审计日志,不执行操作。 --------- Co-authored-by: guke Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/144 --- app/core/config.py | 6 +- app/core/inactivity_reset_worker.py | 12 ++-- app/repositories/inactivity.py | 57 ++++++++++------ .../2026-07-16-inactivity-reset-design.md | 14 ++-- tests/test_inactivity_reset.py | 68 ++++++++++++++++++- 5 files changed, 119 insertions(+), 38 deletions(-) diff --git a/app/core/config.py b/app/core/config.py index 5e88d11..bc3d1c7 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -178,8 +178,10 @@ class Settings(BaseSettings): # 进程内自动兑换 worker 的检查间隔(秒):每隔这么久醒一次,跨过北京 0 点就跑一轮。 # 默认 600s=10min,即 0 点后最多 10 分钟内兑完(客户端文案已注明「可能存在延迟」)。 AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600 - # === 15 天不活跃清零(app.core.inactivity_reset_worker)=== - INACTIVITY_RESET_ENABLED: bool = False # 总闸,默认关;灰度验证后再开 + # === 15 天不活跃清零(app.core.inactivity_reset_worker,worker 常驻)=== + # ENABLED 只决定是否**真清**:false(默认)= 只记审计名单、不动钱(dry-run,灰度看名单); + # true = 真清金币 + 折算现金(邀请金不清)。看准名单后再置 true。 + INACTIVITY_RESET_ENABLED: bool = False INACTIVITY_RESET_DAYS: int = 15 # 不活跃阈值(天),第 (N+1) 日 0 点清 INACTIVITY_WARN_DAYS_BEFORE: str = "7,2" # 清零前几天各推一次;""=不推。逗号分隔 INACTIVITY_RESET_RUN_HOUR: int = 3 # 北京时间每日执行点(0-23) diff --git a/app/core/inactivity_reset_worker.py b/app/core/inactivity_reset_worker.py index 7296d59..b7e7425 100644 --- a/app/core/inactivity_reset_worker.py +++ b/app/core/inactivity_reset_worker.py @@ -6,7 +6,8 @@ 健壮性: - **逐用户幂等**:清完余额=0 次日不再匹配;预警按 streak 去重。启动补跑 / 多次唤醒 / 重启都安全。 - **同机多进程互斥**:文件锁保证多 worker 只有一个实际跑。 -- **开关**:settings.INACTIVITY_RESET_ENABLED=false 时不启动(默认关,灰度验证后再开)。 +- **常驻 + dry-run 默认**:worker 一直跑;INACTIVITY_RESET_ENABLED=false(默认)只记审计名单、 + 不动钱(dry-run 灰度看名单),=true 才真清。 ⚠️ 这是不可逆批量资金操作(清空金币 + 折算现金,**邀请现金不清**)。口径见 app.repositories.inactivity / app.repositories.activity。 @@ -87,6 +88,7 @@ def _run_once_entry() -> dict: reset_days=settings.INACTIVITY_RESET_DAYS, warn_stages=settings.inactivity_warn_stages, today=_cn_today(), + dry_run=not settings.INACTIVITY_RESET_ENABLED, # ENABLED=false → 只记审计名单、不清 ) @@ -102,9 +104,10 @@ async def _run_loop() -> None: async def _run_locked_loop(interval: int) -> None: logger.info( - "inactivity reset worker started interval=%ss run_hour=%s", + "inactivity reset worker started interval=%ss run_hour=%s mode=%s", interval, settings.INACTIVITY_RESET_RUN_HOUR, + "clear" if settings.INACTIVITY_RESET_ENABLED else "dry-run(audit-only)", ) # 本进程上次跑过的北京日;None=尚未跑过本进程(当天到点即补)。 last_run: date | None = None @@ -129,9 +132,8 @@ async def _run_locked_loop(interval: int) -> None: def start_inactivity_reset_worker() -> asyncio.Task | None: - if not settings.INACTIVITY_RESET_ENABLED: - logger.info("inactivity reset disabled (INACTIVITY_RESET_ENABLED=false)") - return None + # worker 常驻(不再有"完全关"档);INACTIVITY_RESET_ENABLED 只决定是否**真清**: + # false(默认)= 只记审计名单(dry-run,不动钱),true = 真清金币+现金。 return asyncio.create_task(_run_loop(), name="inactivity-reset") diff --git a/app/repositories/inactivity.py b/app/repositories/inactivity.py index 71970cc..59335de 100644 --- a/app/repositories/inactivity.py +++ b/app/repositories/inactivity.py @@ -70,13 +70,24 @@ def select_inactive_users(db: Session, *, cutoff: datetime): return db.execute(stmt).all() -def clear_user(db: Session, *, user_id: int, last_active: datetime, inactive_days: int, reason: str) -> bool: +def clear_user(db: Session, *, user_id: int, last_active: datetime, inactive_days: int, + reason: str, dry_run: bool = False) -> bool: """单用户清零(独立事务、行锁)。金币 + 折算现金归零 + 写审计 + 2 条流水;**邀请现金不清** - (产品红线,见 wallet.CoinAccount 注释),仅作快照记入审计。返回是否真清了(有可清余额)。""" + (产品红线,见 wallet.CoinAccount 注释),仅作快照记入审计。返回是否真处理了(有可清余额)。 + + dry_run=True:**只写审计名单、不动钱不写流水**(灰度看名单)。按 streak 去重——本 streak + 已记过(reset_at > last_active)就跳,避免 worker 每日重复记。""" acc = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True) coin, cash, invite = acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents if coin == 0 and cash == 0: # 邀请现金不清,故不算"有可清余额" return False + if dry_run and db.execute( + select(InactivityResetLog.id).where( + InactivityResetLog.user_id == user_id, + InactivityResetLog.reset_at > activity.as_utc(last_active), + ).limit(1) + ).first(): + return False # dry-run:本 streak 已记过审计,不重复记 log = InactivityResetLog( user_id=user_id, coin_balance_before=coin, cash_balance_cents_before=cash, invite_cash_balance_cents_before=invite, last_active_at=activity.norm_utc(last_active), @@ -84,28 +95,29 @@ def clear_user(db: Session, *, user_id: int, last_active: datetime, inactive_day ) db.add(log) db.flush() # 拿 log.id 作 ref_id 交叉链接审计↔流水 - ref = str(log.id) - if coin: - wallet_repo.grant_coins(db, user_id, -coin, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK) - if cash: - wallet_repo.grant_cash(db, user_id, -cash, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK) - # 邀请现金(invite_cash_balance_cents)刻意不动:两本账物理隔离、邀请金是产品红线。 + if not dry_run: # dry-run 只记审计名单,不真出账 + ref = str(log.id) + if coin: + wallet_repo.grant_coins(db, user_id, -coin, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK) + if cash: + wallet_repo.grant_cash(db, user_id, -cash, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK) + # 邀请现金(invite_cash_balance_cents)刻意不动:两本账物理隔离、邀请金是产品红线。 db.commit() return True -def run_reset_once(db: Session, *, reset_days: int, today: date) -> dict: - """扫一轮清零。逐用户独立 commit,失败隔离。""" +def run_reset_once(db: Session, *, reset_days: int, today: date, dry_run: bool = False) -> dict: + """扫一轮清零。逐用户独立 commit,失败隔离。dry_run=True 只记审计名单、不动钱(见 clear_user)。""" stats = {"scanned": 0, "cleared": 0, "failed": 0} cutoff = activity.reset_cutoff(reset_days, today) - reason = f"inactive_{reset_days}d" + reason = f"inactive_{reset_days}d" + ("_dryrun" if dry_run else "") rows = select_inactive_users(db, cutoff=cutoff) # 先物化,避免边遍历边 commit for row in rows: stats["scanned"] += 1 idays = _inactive_days(row.last_active, today) try: if clear_user(db, user_id=row.user_id, last_active=row.last_active, - inactive_days=idays, reason=reason): + inactive_days=idays, reason=reason, dry_run=dry_run): stats["cleared"] += 1 except SQLAlchemyError: db.rollback() @@ -170,14 +182,17 @@ def run_warn_once(db: Session, notifier: InactivityNotifier, *, def run_once(db: Session, *, notifier: InactivityNotifier, reset_days: int, - warn_stages: list[int], today: date) -> dict: + warn_stages: list[int], today: date, dry_run: bool = False) -> dict: """一轮完整任务:先预警(阶段 A)再清零(阶段 B)。返回合并统计。 - 预警整段异常也**绝不阻塞清零**——清零是核心、不可逆资金操作,不能被通知故障拖住。""" - try: - warn = run_warn_once(db, notifier, reset_days=reset_days, warn_stages=warn_stages, today=today) - except Exception: # noqa: BLE001 - 预警阶段整体失败(如候选查询失败)也要继续清零 - logger.exception("inactivity warn phase failed; proceeding to reset") - db.rollback() - warn = {"warned": 0, "warn_skipped": 0, "warn_failed": 0, "warn_phase_error": 1} - reset = run_reset_once(db, reset_days=reset_days, today=today) + 预警整段异常也**绝不阻塞清零**——清零是核心、不可逆资金操作,不能被通知故障拖住。 + dry_run=True(灰度默认):只记审计名单、不清、**也不预警**(不通知一个不会发生的清零)。""" + warn = {"warned": 0, "warn_skipped": 0, "warn_failed": 0} + if not dry_run: + try: + warn = run_warn_once(db, notifier, reset_days=reset_days, warn_stages=warn_stages, today=today) + except Exception: # noqa: BLE001 - 预警阶段整体失败(如候选查询失败)也要继续清零 + logger.exception("inactivity warn phase failed; proceeding to reset") + db.rollback() + warn = {"warned": 0, "warn_skipped": 0, "warn_failed": 0, "warn_phase_error": 1} + reset = run_reset_once(db, reset_days=reset_days, today=today, dry_run=dry_run) return {**warn, **reset} diff --git a/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md b/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md index 3df388d..98de060 100644 --- a/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md +++ b/docs/superpowers/specs/2026-07-16-inactivity-reset-design.md @@ -130,7 +130,7 @@ last_active = max( ## 6. 清零 worker `app/core/inactivity_reset_worker.py`(新建) -**完全仿 [`app/core/daily_exchange_worker.py`](../../../app/core/daily_exchange_worker.py)**:App 启动自带 asyncio 任务,文件锁(`data/inactivity_reset.lock`)防同机多进程并发,总闸 `INACTIVITY_RESET_ENABLED`(默认关)。 +**完全仿 [`app/core/daily_exchange_worker.py`](../../../app/core/daily_exchange_worker.py)**:App 启动自带 asyncio 任务,文件锁(`data/inactivity_reset.lock`)防同机多进程并发。**worker 常驻**;`INACTIVITY_RESET_ENABLED` 只决定是否**真清**:false(默认)= 只记审计名单、不动钱(dry-run),true = 真清。 ### 调度 @@ -194,7 +194,7 @@ class InactivityNotifier(Protocol): ## 8. 配置项(`app/core/config.py`) ``` -INACTIVITY_RESET_ENABLED = False # 总闸,默认关;灰度验证后再开 +INACTIVITY_RESET_ENABLED = False # false(默认)=只记审计名单(dry-run,不动钱);true=真清 INACTIVITY_RESET_DAYS = 15 # 不活跃阈值(天) INACTIVITY_WARN_DAYS_BEFORE = "7,2" # 清零前几天各推一次;空串=不推。逗号分隔,降序解析 INACTIVITY_RESET_RUN_HOUR = 3 # 北京时间每日执行点(0-23) @@ -225,7 +225,7 @@ INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现 | 在途提现 | 提现申请时现金已扣入 `WithdrawOrder`,当前余额已不含在途;只清当前余额、不动提现单。提现失败退款到已清账户 = 用户的钱,正常 | | 与 `daily_auto_exchange` 并存 | 各自逐用户幂等;金币多已日结折现金,三桶全清正好覆盖 | | 时区/日界 | 统一北京(`rewards.cn_today()`/`CN_TZ`);**清零/预警按北京自然日 0 点对齐**(末次活跃记为第 1 日 → 第 16 日 0 点清零,见 §4),非滚动 24h;流水 `created_at` 沿用北京 wall-clock naive | -| 误清防护 | 总闸默认关;先灰度只看预警/清零**名单**对不对,再开清零(§13) | +| 误清防护 | worker 常驻默认 **dry-run**(`ENABLED=false` 只记审计名单、不动钱);看准名单再置 `true` 真清(§13) | --- @@ -233,7 +233,7 @@ INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现 - **Android 端**(`shaguabijia-app-android`)需在**首页可见**(`onResume`/Tab 切入)时,向现有 `POST /api/v1/analytics/events` 批量上报里加一条 `event=<首页可见事件名>`(名称明天加埋点时定,暂记 `"home_view"`) 的事件,**携带登录后的 `user_id`**。 - 客户端按会话/前台去重即可(服务端只取 `max(created_at)`,多报无害)。 -- **上线顺序依赖**:`home_view` 全量覆盖前,"进首页"信号缺失,只有比价/领券能推进活跃、其余落到 `created_at` 基线("只开首页不操作"且注册满 15 天的用户会被误清)—— 故清零总闸必须待 `home_view` 铺满后再开(§13)。 +- **上线顺序依赖**:`home_view` 全量覆盖前,"进首页"信号缺失,只有比价/领券能推进活跃、其余落到 `created_at` 基线("只开首页不操作"且注册满 15 天的用户会被误清)—— 故**开真清(`ENABLED=true`)必须待 `home_view` 铺满后再开**(§13);dry-run 只记名单不动钱、可先开着看。 --- @@ -250,8 +250,8 @@ INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现 1. **后端先行**:合入共享模块 + 两表 + worker + 通知器,`INACTIVITY_RESET_ENABLED=False`;活跃口径以 `created_at` 为非空基线、**不含 last_login_at**。 2. **Android 发版**:上报 `home_view`;观察 analytics 覆盖率。 -3. **只读灰度**:临时开一个"dry-run/只出名单不清零"路径或看阶段 A 预警日志,核对预警/清零**名单**准确。 -4. **开清零**:确认无误后置 `INACTIVITY_RESET_ENABLED=True`。 +3. **dry-run 灰度(默认即是)**:`INACTIVITY_RESET_ENABLED=False` 时 worker 常驻只写审计名单(`reason=inactive_Nd_dryrun`)、不动钱、不预警;核对名单准确。 +4. **开真清**:确认无误后置 `INACTIVITY_RESET_ENABLED=True`(转为真清 + 预警)。 5. **收尾/监控**:持续观察 `home_view` 覆盖率与预警/清零名单;发现"活跃却被判不活跃"的漏报即回查埋点覆盖(口径已不含 last_login_at,登录不再兜底)。 --- @@ -263,7 +263,7 @@ INACTIVITY_RESET_CHECK_INTERVAL_SEC = 1800 # worker 唤醒间隔(可复用现 - **不活跃判定**:`last_active` 分别 `<15d / =15d / >15d` × 有/无余额 的命中矩阵。 - **清零**:三桶归零;`inactivity_reset_log` 清前值正确;三条流水 `biz_type=inactivity_reset`、`balance_after=0`、`ref_id=log.id`;`total_coin_earned` 不变。 - **预警**:命中窗口调 notifier + 写 `notification_log`;同 streak 不重推;回归后 `last_active` 前移可再次预警;漏跑补发最紧急档。 -- **worker**:总闸关不启动;文件锁互斥;逐用户失败隔离(一个抛错不影响其余,`failed` 计数);重复跑幂等。 +- **worker**:常驻;`ENABLED=false` 走 dry-run(只记审计名单、不清、不预警);文件锁互斥;逐用户失败隔离(一个抛错不影响其余,`failed` 计数);重复跑幂等。 - **配置**:`INACTIVITY_WARN_DAYS_BEFORE` 解析(含空串=不推);`RESET_DAYS`/`RUN_HOUR` 生效。 - 沿用 `tests/conftest.py`(临时 SQLite、`RATE_LIMIT_ENABLED=false`);外部通知 monkeypatch。 diff --git a/tests/test_inactivity_reset.py b/tests/test_inactivity_reset.py index 6860d2c..33fb217 100644 --- a/tests/test_inactivity_reset.py +++ b/tests/test_inactivity_reset.py @@ -302,11 +302,35 @@ def test_run_once_warns_then_resets() -> None: db.close() -def test_worker_disabled_returns_none(monkeypatch) -> None: +def test_worker_run_once_entry_dry_run(monkeypatch) -> None: + """ENABLED=false(默认语义)→ worker 常驻但只记审计不清(dry_run = not ENABLED)。""" + from sqlalchemy import select + from app.core import inactivity_reset_worker as w from app.core.config import settings - monkeypatch.setattr(settings, "INACTIVITY_RESET_ENABLED", False) - assert w.start_inactivity_reset_worker() is None + from app.models.wallet import CoinAccount + + monkeypatch.setattr(settings, "INACTIVITY_RESET_ENABLED", False) # false = 只记审计 + monkeypatch.setattr(settings, "INACTIVITY_RESET_DAYS", 15) + monkeypatch.setattr(settings, "INACTIVITY_WARN_DAYS_BEFORE", "") + monkeypatch.setattr(w, "_cn_today", lambda: date(2026, 2, 1)) + + db = SessionLocal() + try: + uid = _new_user(db, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), coin=100) + db.commit() + finally: + db.close() + + w._run_once_entry() + + db = SessionLocal() + try: + assert db.get(CoinAccount, uid).coin_balance == 100 # 没清 + log = db.execute(select(InactivityResetLog).where(InactivityResetLog.user_id == uid)).scalar_one() + assert log.reason.endswith("dryrun") # 记了审计 + finally: + db.close() def test_worker_run_once_entry_executes(monkeypatch) -> None: @@ -338,6 +362,44 @@ def test_worker_run_once_entry_executes(monkeypatch) -> None: db.close() +def test_run_once_dry_run_records_audit_but_does_not_clear() -> None: + """dry-run:只写审计(标 dryrun)、不动钱、不预警;重复跑不重复记(streak dedup)。""" + from sqlalchemy import select + + from app.integrations.notifier import LogNotifier + from app.models.wallet import CoinAccount, CoinTransaction + from app.repositories import inactivity + + db = SessionLocal() + try: + today = date(2026, 2, 1) + old = _new_user(db, created_at=datetime(2026, 1, 10, tzinfo=timezone.utc), coin=100, cash=200, invite=300) + warn_uid = _new_user(db, created_at=datetime(2026, 1, 22, tzinfo=timezone.utc), coin=50) # 预警窗 + db.commit() + + stats = inactivity.run_once(db, notifier=LogNotifier(), reset_days=15, + warn_stages=[7, 2], today=today, dry_run=True) + acc = db.get(CoinAccount, old) + assert (acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents) == (100, 200, 300) # 原封 + log = db.execute(select(InactivityResetLog).where(InactivityResetLog.user_id == old)).scalar_one() + assert log.coin_balance_before == 100 and log.reason.endswith("dryrun") # 审计标 dryrun + assert db.execute(select(CoinTransaction).where( + CoinTransaction.user_id == old, CoinTransaction.biz_type == "inactivity_reset")).first() is None # 无流水 + assert stats["warned"] == 0 # dry-run 不预警 + assert db.execute(select(InactivityNotificationLog).where( + InactivityNotificationLog.user_id == warn_uid)).first() is None + assert stats["cleared"] == 1 # dry-run:cleared=记了几条 + + # 再跑一次 → 不重复记(dedup),余额仍原封 + inactivity.run_once(db, notifier=LogNotifier(), reset_days=15, warn_stages=[7, 2], today=today, dry_run=True) + assert len(db.execute(select(InactivityResetLog).where( + InactivityResetLog.user_id == old)).scalars().all()) == 1 + assert db.get(CoinAccount, old).coin_balance == 100 + finally: + db.rollback() + db.close() + + def test_admin_list_users_last_active_ignores_login() -> None: """admin 用户列表 last_active_at 改用共享口径:登录不算活跃(baseline=created_at)、只认活跃事件。""" from app.admin.repositories import queries