From 25484aadb87cb8e37c3ed19c4bd2267376e43b73 Mon Sep 17 00:00:00 2001 From: zhuzihao Date: Fri, 12 Jun 2026 23:27:35 +0800 Subject: [PATCH 01/36] =?UTF-8?q?feat(user):=20=E6=98=B5=E7=A7=B0=E4=B8=8A?= =?UTF-8?q?=E9=99=90=E6=94=BE=E5=AE=BD=E5=88=B0=2020=20=E5=AD=97(=E4=B8=8E?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF/=E5=8E=9F=E5=9E=8B=E4=B8=80?= =?UTF-8?q?=E8=87=B4)=20(#47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProfileUpdateRequest.nickname 的 max_length 16 → 20,与客户端 take(20) 和 原型 settings.html(maxlength=20)对齐。修复 17–20 字昵称保存时后端返回 422、 客户端弹「更新失败,请重试」的问题。DB 列为 String(64),放得下,无需迁移。 Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/47 Co-authored-by: zhuzihao Co-committed-by: zhuzihao --- app/schemas/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/schemas/user.py b/app/schemas/user.py index c5420b6..1c6487a 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, Field, field_validator class ProfileUpdateRequest(BaseModel): - nickname: str = Field(..., min_length=1, max_length=16, description="昵称,1-16 字") + nickname: str = Field(..., min_length=1, max_length=20, description="昵称,1-20 字") @field_validator("nickname") @classmethod From 8d7b91219a343a9946357b0f60254984e16848bf Mon Sep 17 00:00:00 2001 From: ouzhou Date: Sat, 13 Jun 2026 03:53:43 +0800 Subject: [PATCH 02/36] =?UTF-8?q?feat(wallet):=20=E8=A7=A3=E7=BB=91?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E5=89=8D=E5=AF=B9=E5=BE=85=E5=AE=A1=E6=A0=B8?= =?UTF-8?q?=E6=8F=90=E7=8E=B0=E4=BA=8C=E6=AC=A1=E7=A1=AE=E8=AE=A4=20(#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 改动 解绑微信前若有**待审核(reviewing)**提现单,首次解绑被拦下返回二次确认,用户确认后带 `force=true` 才真解绑。 - `schemas`: `UnbindWechatRequest{force}` + `UnbindWechatResultOut{needs_confirm, message}` - `repositories`: `has_reviewing_withdraw`(只看 reviewing) - `api`: `unbind-wechat` 接受可选 body(兼容旧无 body 客户端);`bound` 返回真实绑定态 - `docs/api`: 同步协议 ## 为什么只拦 reviewing reviewing 单解绑后审核打款会回读空 openid → 自动退回现金(钱不丢、只为让用户知情);pending 转账已在途、解绑影响不到,不拦。 配套客户端见 shaguabijia-app-android 同名分支。 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/46 Co-authored-by: ouzhou Co-committed-by: ouzhou --- .../coin_transaction_task_ref_unique.py | 40 +++++++++++ app/admin/routers/config.py | 5 +- app/api/v1/wallet.py | 20 +++++- app/core/config_schema.py | 6 +- app/core/rewards.py | 41 ++++++++++-- app/models/wallet.py | 15 +++++ app/repositories/ops_marquee.py | 18 ++--- app/repositories/signin.py | 9 +-- app/repositories/task.py | 66 +++++++++++++++++-- app/repositories/wallet.py | 56 +++++++++++++++- app/schemas/welfare.py | 8 +++ docs/api/tasks-claim.md | 4 +- docs/api/tasks-list.md | 11 +++- docs/api/wallet-unbind-wechat.md | 14 +++- tests/test_admin_config.py | 7 +- tests/test_welfare.py | 56 ++++++++++++---- 16 files changed, 318 insertions(+), 58 deletions(-) create mode 100644 alembic/versions/coin_transaction_task_ref_unique.py diff --git a/alembic/versions/coin_transaction_task_ref_unique.py b/alembic/versions/coin_transaction_task_ref_unique.py new file mode 100644 index 0000000..11b8067 --- /dev/null +++ b/alembic/versions/coin_transaction_task_ref_unique.py @@ -0,0 +1,40 @@ +"""add coin_transaction task ref unique index + +可重复任务(task_enable_notification「打开消息提醒」逐次减半领取)的发放路径是无锁的 +读-算-写:并发/连点的两个 claim 会都读到同一已领次数、算出同一 ref_id(task_key:N)、 +各发一笔,导致双倍发钱。给 coin_transaction 加 (user_id, biz_type, ref_id) 部分唯一索引 +(仅 biz_type LIKE 'task%' 且 ref_id 非空),第二笔撞唯一约束 → IntegrityError,被 +task.claim_task 兜底成 AlreadyClaimedError(409)。与 cash_transaction 提现退款、 +withdraw_order 在途单的「唯一约束 + IntegrityError 兜底」同模式。 + +Revision ID: coin_txn_task_ref_uq +Revises: drop_force_onboarding +Create Date: 2026-06-12 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "coin_txn_task_ref_uq" +down_revision: Union[str, Sequence[str], None] = "drop_force_onboarding" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_index( + "ux_coin_transaction_task_ref", + "coin_transaction", + ["user_id", "biz_type", "ref_id"], + unique=True, + sqlite_where=sa.text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"), + postgresql_where=sa.text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"), + ) + + +def downgrade() -> None: + op.drop_index("ux_coin_transaction_task_ref", table_name="coin_transaction") diff --git a/app/admin/routers/config.py b/app/admin/routers/config.py index 6ab6c41..e581d24 100644 --- a/app/admin/routers/config.py +++ b/app/admin/routers/config.py @@ -13,6 +13,7 @@ from app.admin.audit import write_audit from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role from app.admin.schemas.config import ConfigItemOut, ConfigUpdateRequest from app.core.config_schema import CONFIG_DEFS +from app.core.rewards import SIGNIN_CYCLE_LEN from app.models.admin import AdminUser from app.repositories import app_config @@ -34,8 +35,8 @@ def _validate(key: str, value: Any) -> None: raise ValueError("需为非空整数列表") if not all(isinstance(x, int) and not isinstance(x, bool) and x >= 0 for x in value): raise ValueError("列表元素需为非负整数") - if key == "signin_rewards" and len(value) != 14: - raise ValueError("签到档位必须正好 14 个(对应 14 天循环)") + if key == "signin_rewards" and len(value) != SIGNIN_CYCLE_LEN: + raise ValueError(f"签到档位必须正好 {SIGNIN_CYCLE_LEN} 个(对应 {SIGNIN_CYCLE_LEN} 天循环)") elif t == "dict_str_int": if not isinstance(value, dict) or not all( isinstance(k, str) and isinstance(v, int) and not isinstance(v, bool) diff --git a/app/api/v1/wallet.py b/app/api/v1/wallet.py index 8b64c23..55a6f5e 100644 --- a/app/api/v1/wallet.py +++ b/app/api/v1/wallet.py @@ -35,6 +35,7 @@ from app.schemas.welfare import ( ExchangeResultOut, TransferAuthResultOut, TransferAuthStatusOut, + UnbindWechatRequest, UnbindWechatResultOut, WithdrawInfoOut, WithdrawOrderOut, @@ -151,9 +152,24 @@ def bind_wechat(req: BindWechatRequest, user: CurrentUser, db: DbSession) -> Bin @router.post("/unbind-wechat", response_model=UnbindWechatResultOut, summary="解绑微信(清空 openid)") -def unbind_wechat(user: CurrentUser, db: DbSession) -> UnbindWechatResultOut: +def unbind_wechat( + user: CurrentUser, db: DbSession, req: UnbindWechatRequest | None = None +) -> UnbindWechatResultOut: + # 有「待审核(reviewing)」提现单时,首次解绑拦下要求确认:这类单还没打款,确认解绑(force) + # 时会被立即退回现金余额(refund_reviewing_withdraws_on_unbind),后台不再挂单,用户需先知情。 + # (pending 单转账已在途、解绑影响不到,不拦;见 has_reviewing_withdraw。) + force = req.force if req is not None else False + if not force and crud_wallet.has_reviewing_withdraw(db, user.id): + logger.info("unbind wechat needs_confirm(有待审核提现) user_id=%d", user.id) + return UnbindWechatResultOut( + bound=bool(user.wechat_openid), + needs_confirm=True, + message="你有提现正在审核中,解绑微信后这笔会自动退回到现金余额。确定解绑吗?", + ) + # 先把待审核提现立即退回现金(无则 no-op),再清 openid —— 让"解绑即退"名副其实 + refunded = crud_wallet.refund_reviewing_withdraws_on_unbind(db, user.id) crud_wallet.unbind_wechat_openid(db, user.id) - logger.info("unbind wechat ok user_id=%d", user.id) + logger.info("unbind wechat ok user_id=%d force=%s refunded_withdraws=%d", user.id, force, refunded) return UnbindWechatResultOut(bound=False) diff --git a/app/core/config_schema.py b/app/core/config_schema.py index 1546223..65d2a9a 100644 --- a/app/core/config_schema.py +++ b/app/core/config_schema.py @@ -14,9 +14,9 @@ from app.core import rewards as r # type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int CONFIG_DEFS: dict[str, dict[str, Any]] = { "signin_rewards": { - "default": list(r.SIGNIN_REWARDS), "label": "签到 14 天金币档位", + "default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位", "group": "签到", "type": "int_list", - "help": "第 1~14 天每天签到发的金币;断签重置回第 1 天。长度需为 14。", + "help": "第 1~7 天每天签到发的金币;断签重置回第 1 天。长度需为 7。", }, "min_exchange_coin": { "default": r.MIN_EXCHANGE_COIN, "label": "最低兑换金币", @@ -63,6 +63,6 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { "signin_boost_coin": { "default": r.SIGNIN_BOOST_COIN, "label": "签到膨胀固定金币", "group": "签到", "type": "int", - "help": "Day1-Day13 签到后看完激励视频额外发放的固定金币;Day14 不展示也不允许膨胀。", + "help": "Day1-Day6 签到后看完激励视频额外发放的固定金币;Day7 不展示也不允许膨胀。", }, } diff --git a/app/core/rewards.py b/app/core/rewards.py index 84de0c7..713282f 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -17,11 +17,10 @@ def cn_today() -> date: return datetime.now(CN_TZ).date() -# ===== 签到:14 天循环,断签重置回第 1 天 ===== -# 第 1→14 天的金币奖励,签到逻辑用 cycle_day(1..14)索引。 +# ===== 签到:7 天循环,断签重置回第 1 天(原型 2026-06 由 14 天改 7 天一轮)===== +# 第 1→7 天的金币奖励,签到逻辑用 cycle_day(1..7)索引;一轮 7 天累计 2500 金币(对原型 banner)。 SIGNIN_REWARDS: tuple[int, ...] = ( - 200, 120, 150, 180, 200, 250, 500, - 200, 250, 300, 350, 400, 500, 3000, + 200, 200, 300, 200, 400, 400, 800, ) SIGNIN_CYCLE_LEN: int = len(SIGNIN_REWARDS) @@ -57,13 +56,40 @@ WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元 TASK_ENABLE_NOTIFICATION = "enable_notification" # task_key -> 奖励金币 -# 打开消息提醒: 1000 金币(=¥0.1, 客户端原型展示口径; 量级与签到/里程碑相称)。 +# 打开消息提醒: 750 金币(客户端原型展示口径 2026-06 调 1000→750; 量级与签到/里程碑相称)。 # 注意: 不再 = 兑换下限(下限已降到 MIN_EXCHANGE_COIN=COIN_PER_CENT=100), test_exchange_flow 改走 grant_coins 直接供款。 +# ⚠️打开消息提醒是「可重复领取」任务(2026-06):每次开启通知发一次,金额逐次减半(见 notification_reward), +# 这里的 750 是首次(基数)。客户端只在「通知权限关闭」时展示该任务,开着就隐藏。 TASK_REWARDS: dict[str, int] = { - TASK_ENABLE_NOTIFICATION: 1000, + TASK_ENABLE_NOTIFICATION: 750, } +def notification_reward(base: int, times_claimed: int) -> int: + """打开消息提醒第 (times_claimed+1) 次领取的金币。 + + 基数 base(=750)起,每次减半、四舍五入到整数、最低 1: + 750 → 375 → 188 → 94 → 47 → 24 → 12 → 6 → 3 → 2 → 1 → 1 … + (四舍五入用 (x+1)//2,匹配产品给的 750/375/188/94 序列。) + """ + r = max(1, base) + for _ in range(max(0, times_claimed)): + r = max(1, (r + 1) // 2) + return r + + +def notification_max_claims(base: int) -> int: + """打开消息提醒最多可领取的次数:减半到最低 1 那次为止(含),之后不再可领。 + + 防止「通知一直关着」时无限刷最低档 1 金币(发放上限闸,服务端硬限,不依赖客户端只在 + 通知关闭时才展示的约定)。base=750 时序列 750→…→1 在第 11 次落到 1,故上限 11 次。 + """ + n = 0 + while notification_reward(base, n) > 1: + n += 1 + return n + 1 # 含第一次减半到 1 的那一次 + + # ===== 比价战绩里程碑(累计成功比价 N 次,逐档解锁领金币)===== # 第 1→6 次的金币奖励(1-based:第 N 次比价解锁第 N 档)。值沿用客户端原型档位。 # 数据源是 comparison_record 里 status='success' 的条数;每档领一次, @@ -167,7 +193,8 @@ def calculate_ad_reward_coin(ecpm: str | int | float | None, today_count_after_t return max(0, round(yuan * COIN_PER_YUAN)) -SIGNIN_BOOST_COIN: int = 2000 +# 签到看广告膨胀:S2S 固定补发(原型 2026-06 由 2000 提到 3000,对应 CTA「看广告最高膨胀至3000金币」)。 +SIGNIN_BOOST_COIN: int = 3000 # ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)===== diff --git a/app/models/wallet.py b/app/models/wallet.py index 8c5b849..bb71b4c 100644 --- a/app/models/wallet.py +++ b/app/models/wallet.py @@ -41,6 +41,21 @@ class CoinAccount(Base): class CoinTransaction(Base): __tablename__ = "coin_transaction" + __table_args__ = ( + # 可重复任务(如 task_enable_notification)按 ref_id 序号(task_key:N)去重,挡并发/连点 + # 重复发放——读-算-写无锁,两个并发 claim 会都算出同一序号双倍发钱(见 task.claim_task)。 + # 仅 task_* 且 ref_id 非空生效;一次性任务 ref_id=task_key 本就每人一条,纳入无害; + # 不影响 signin / exchange / withdraw 等其它 biz_type。 + Index( + "ux_coin_transaction_task_ref", + "user_id", + "biz_type", + "ref_id", + unique=True, + sqlite_where=text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"), + postgresql_where=text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"), + ), + ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) user_id: Mapped[int] = mapped_column( diff --git a/app/repositories/ops_marquee.py b/app/repositories/ops_marquee.py index 688652e..717e449 100644 --- a/app/repositories/ops_marquee.py +++ b/app/repositories/ops_marquee.py @@ -57,19 +57,19 @@ def _phone_name(rng: random.Random) -> str: def _nickname(rng: random.Random) -> str: - """昵称打码式,中文为主(约 11/12),掺少量中英混 / 纯字母。""" + """昵称打码式,中文为主(约 11/12),掺少量中英混 / 纯字母。 + ⚠️ 脱敏星号一律夹在中间(xx**yy),星号前后都要有可见字——不出现结尾打码(省钱**)的形态。 + """ p = rng.randint(0, 11) - if p in (0, 1, 2): - return rng.choice(_NICK_WORDS) + "**" # 省钱** - if p in (3, 4): - return rng.choice(_NICK_WORDS) + "**" + rng.choice(_NICK_SUFFIX) # 吃货**达人 + if p in (0, 1, 2, 3, 4): + return rng.choice(_NICK_WORDS) + "**" + rng.choice(_NICK_SUFFIX) # 省钱**党 / 吃货**达人 if p in (5, 6): - return rng.choice(_SURNAMES) + "**" # 张** + return rng.choice(_SURNAMES) + "**" + rng.choice(_NICK_SUFFIX) # 张**侠 if p in (7, 8): - return rng.choice(_NICK_PREFIX) + rng.choice(_SURNAMES) + "**" # 小张** + return rng.choice(_NICK_PREFIX) + rng.choice(_SURNAMES) + "**" + rng.choice(_NICK_SUFFIX) # 小张**达人 if p in (9, 10): - return rng.choice(_NICK_WORDS) + rng.choice(_LETTER_U) + "**" # 中英混 省钱A** - return rng.choice(_LETTER_U) + "**" + rng.choice(_LETTER_L) # 纯字母(约 1/12) + return rng.choice(_NICK_WORDS) + "**" + rng.choice(_LETTER_U) # 中英混 省钱**A(字母移到星号后) + return rng.choice(_LETTER_U) + "**" + rng.choice(_LETTER_L) # 纯字母(约 1/12) A**b def _realistic_name(rng: random.Random) -> str: diff --git a/app/repositories/signin.py b/app/repositories/signin.py index 3d0927e..c65ce38 100644 --- a/app/repositories/signin.py +++ b/app/repositories/signin.py @@ -1,7 +1,7 @@ """签到 CRUD:状态查询 + 执行签到。 -14 天循环规则: - - 昨天签过 → 今天 cycle_day = 昨天 % 14 + 1,streak += 1(连续) +7 天循环规则(2026-06 由 14 天改 7 天一轮,周期长度统一取 SIGNIN_CYCLE_LEN): + - 昨天签过 → 今天 cycle_day = 昨天 % SIGNIN_CYCLE_LEN + 1,streak += 1(连续) - 否则(首签 / 断签) → cycle_day = 1,streak = 1(重置) 今天的 cycle_day 决定发多少金币(档位来自 rewards.get_signin_rewards,运营后台可改)。 """ @@ -33,7 +33,7 @@ class AlreadyBoostedError(Exception): class LastCycleDayBoostBlockedError(Exception): - """14 天循环最后一天不允许签到膨胀。""" + """循环最后一天(第 SIGNIN_CYCLE_LEN 天)不允许签到膨胀。""" @dataclass @@ -77,7 +77,8 @@ def get_status(db: Session, user_id: int) -> SigninStatus: today_signed = last is not None and last.signin_date == today if today_signed: - today_cycle_day = last.cycle_day + # 14→7 改制遗留:老记录 cycle_day 可能 >SIGNIN_CYCLE_LEN,归一化到 1..LEN 防 rewards_list 越界 + today_cycle_day = (last.cycle_day - 1) % SIGNIN_CYCLE_LEN + 1 consecutive_days = last.streak else: today_cycle_day = _next_cycle_day(last, today) diff --git a/app/repositories/task.py b/app/repositories/task.py index 9b592b6..309f5e8 100644 --- a/app/repositories/task.py +++ b/app/repositories/task.py @@ -7,14 +7,31 @@ from __future__ import annotations from dataclasses import dataclass -from sqlalchemy import select +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.core import rewards from app.models.task import UserTask +from app.models.wallet import CoinTransaction from app.repositories import wallet as crud_wallet +def _notification_grant_times(db: Session, user_id: int) -> int: + """打开消息提醒已领取次数 = 该 biz_type 的【正向】金币流水条数(可重复任务不写 user_task)。 + + 限 amount>0 只数发放笔,避免日后出现该 biz_type 的回滚/冲正负向流水时把次数算大、减半档位错。 + """ + biz = f"task_{rewards.TASK_ENABLE_NOTIFICATION}" + return db.execute( + select(func.count()).select_from(CoinTransaction).where( + CoinTransaction.user_id == user_id, + CoinTransaction.biz_type == biz, + CoinTransaction.amount > 0, + ) + ).scalar_one() or 0 + + class UnknownTaskError(Exception): """task_key 不在已知任务表里。""" @@ -31,14 +48,30 @@ class TaskState: def list_tasks(db: Session, user_id: int) -> list[TaskState]: - """返回所有已知一次性任务及其领取状态。""" + """返回所有已知任务及其领取状态。 + + 打开消息提醒是可重复任务:claimed 恒 False,coin = 下一次(减半后)的金额; + 客户端按「通知权限是否开启」决定展不展示(开着就隐藏,不在这里判断)。 + 其余为一次性任务,user_task 唯一约束去重。 + """ task_rewards = rewards.get_task_rewards(db) stmt = select(UserTask.task_key).where(UserTask.user_id == user_id) claimed_keys = set(db.execute(stmt).scalars().all()) - return [ - TaskState(task_key=key, coin=coin, claimed=key in claimed_keys) - for key, coin in task_rewards.items() - ] + result: list[TaskState] = [] + for key, coin in task_rewards.items(): + if key == rewards.TASK_ENABLE_NOTIFICATION: + times = _notification_grant_times(db, user_id) + exhausted = times >= rewards.notification_max_claims(coin) + result.append( + TaskState( + task_key=key, + coin=rewards.notification_reward(coin, times), + claimed=exhausted, # 领满(减半到底)后置 True,客户端据此可隐藏 + ) + ) + else: + result.append(TaskState(task_key=key, coin=coin, claimed=key in claimed_keys)) + return result def claim_task(db: Session, user_id: int, task_key: str) -> tuple[int, int]: @@ -50,6 +83,27 @@ def claim_task(db: Session, user_id: int, task_key: str) -> tuple[int, int]: if task_key not in task_rewards: raise UnknownTaskError + # 打开消息提醒:可重复领取,每次减半。不写 user_task(不去重),已领次数用金币流水条数算; + # ref_id 带序号(task_key:N)让流水可读、且 (user_id,biz_type,ref_id) 唯一索引能挡并发/连点 + # 重复发放。减半到底(notification_max_claims)后不再可领。客户端只在通知权限关闭时展示+触发。 + if task_key == rewards.TASK_ENABLE_NOTIFICATION: + base = task_rewards[task_key] + times = _notification_grant_times(db, user_id) + if times >= rewards.notification_max_claims(base): + raise AlreadyClaimedError + coin = rewards.notification_reward(base, times) + try: + acc, _ = crud_wallet.grant_coins( + db, user_id, coin, + biz_type=f"task_{task_key}", ref_id=f"{task_key}:{times + 1}", + ) + db.commit() + except IntegrityError as e: + # 并发/连点:另一个请求已抢先发了同一序号(撞唯一索引)。回滚,按已领处理,不双发。 + db.rollback() + raise AlreadyClaimedError from e + return coin, acc.coin_balance + existing = db.execute( select(UserTask).where( UserTask.user_id == user_id, UserTask.task_key == task_key diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index 7be86b4..3438928 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -334,6 +334,26 @@ def bind_wechat_openid(db: Session, user_id: int, code: str) -> dict: return info +def has_reviewing_withdraw(db: Session, user_id: int) -> bool: + """用户是否有「待审核(reviewing)」的提现单。 + + 只看 reviewing:这类单还没打款,用户确认解绑时会被立即退回现金 + (refund_reviewing_withdraws_on_unbind),解绑前据此提示用户知情确认。 + pending 单转账已在途(execute 已过 openid 闸),解绑影响不到、照常打款,故不计入。 + """ + return ( + db.execute( + select(WithdrawOrder.id) + .where( + WithdrawOrder.user_id == user_id, + WithdrawOrder.status == "reviewing", + ) + .limit(1) + ).first() + is not None + ) + + def unbind_wechat_openid(db: Session, user_id: int) -> None: """解绑微信:清空 openid。已绑定才清,未绑定为幂等空操作。""" user = db.get(User, user_id) @@ -346,6 +366,33 @@ def unbind_wechat_openid(db: Session, user_id: int) -> None: db.commit() +def refund_reviewing_withdraws_on_unbind(db: Session, user_id: int) -> int: + """解绑微信时,把该用户所有「待审核(reviewing)」提现单立即退回现金并置终态。 + + reviewing 单还没打款(钱在 create_withdraw 时已从现金扣进待审核单),解绑后这笔无法 + 打款到微信,直接退回现金最干净:用户即时拿回钱、后台不再挂死单(不必等管理员审核)。 + 复用 _refund_withdraw(退现金 + 流水 + 幂等)。返回退掉的单数。 + """ + orders = ( + db.execute( + select(WithdrawOrder).where( + WithdrawOrder.user_id == user_id, + WithdrawOrder.status == "reviewing", + ) + ) + .scalars() + .all() + ) + for order in orders: + _refund_withdraw( + db, + order, + reason="用户解绑微信,待审核提现自动退回", + remark="解绑微信,提现已退回现金余额", + ) + return len(orders) + + _OUT_BILL_NO_RE = re.compile(r"^[0-9A-Za-z_-]{8,32}$") @@ -381,7 +428,8 @@ def _add_cash(db: Session, user_id: int, amount_cents: int) -> int: def _refund_withdraw( - db: Session, order: WithdrawOrder, reason: str, *, final_status: str = "failed" + db: Session, order: WithdrawOrder, reason: str, *, final_status: str = "failed", + remark: str | None = None, ) -> None: """退回现金 + 写 withdraw_refund 流水 + 单子置终态。幂等:已是退款终态不重复退。 @@ -389,6 +437,7 @@ def _refund_withdraw( - "failed" (默认):微信转账失败/取消 → 自动退款 - "rejected":管理员审核拒绝 → 退款(走 reject_withdraw) 两种都已退款,用 in 判定防重复退(并发/对账/拒绝重复点)。 + remark:给用户可见的现金流水文案;省略则按 final_status 取默认(解绑退回等场景可自定义)。 """ if order.status in ("failed", "rejected"): return # 防重复退款(并发/对账与查单/重复拒绝同时触发) @@ -413,7 +462,10 @@ def _refund_withdraw( biz_type="withdraw_refund", ref_id=order.out_bill_no, # 用户可见文案区分"未成功(自动退)"vs"审核未通过";技术原因记在 order.fail_reason - remark="提现审核未通过,金额已退回" if final_status == "rejected" else "提现未成功,金额已退回", + remark=( + remark + or ("提现审核未通过,金额已退回" if final_status == "rejected" else "提现未成功,金额已退回") + ), created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None), ) ) diff --git a/app/schemas/welfare.py b/app/schemas/welfare.py index 8977faf..2cb6027 100644 --- a/app/schemas/welfare.py +++ b/app/schemas/welfare.py @@ -109,8 +109,16 @@ class BindWechatResultOut(BaseModel): wechat_avatar_url: str | None = None +class UnbindWechatRequest(BaseModel): + # 有进行中提现单时,首次解绑会被拦(needs_confirm),用户确认后带 force=true 再调一次才真解绑。 + force: bool = Field(False, description="跳过「进行中提现」二次确认,强制解绑") + + class UnbindWechatResultOut(BaseModel): bound: bool = False + # 有进行中提现单且未 force → True:本次未解绑(bound 仍 True),客户端弹确认弹窗。 + needs_confirm: bool = False + message: str | None = None class WithdrawRequest(BaseModel): diff --git a/docs/api/tasks-claim.md b/docs/api/tasks-claim.md index 64b8a21..5ef1f2c 100644 --- a/docs/api/tasks-claim.md +++ b/docs/api/tasks-claim.md @@ -19,7 +19,9 @@ ## 错误码 - `404` 未知任务(`unknown task`) -- `409` 任务已领取(`task already claimed`) +- `409` 任务已领取(`task already claimed`)。一次性任务为已领过;可重复任务(`enable_notification`)为 + 并发/连点撞同一序号、或已「减半到底领满」不再可领。客户端遇 `409` 视为本次无发放(不再加币)。 ## 说明 发奖端 gate 由后端把控(如「打开消息提醒」须客户端确认权限已开后才调)。金币已计入余额。 +可重复任务每次领取金额逐次减半,按 `(user_id, biz_type, ref_id)` 唯一索引去重挡并发重复发放。 diff --git a/docs/api/tasks-list.md b/docs/api/tasks-list.md index 63cc73d..d826f1d 100644 --- a/docs/api/tasks-list.md +++ b/docs/api/tasks-list.md @@ -13,8 +13,13 @@ | 字段 | 类型 | 说明 | |---|---|---| | `task_key` | string | 任务标识,如 `enable_notification`(打开消息提醒) | -| `coin` | int | 完成可得金币 | -| `claimed` | bool | 是否已领取 | +| `coin` | int | 下一次完成可得金币(可重复任务为减半后的当前档) | +| `claimed` | bool | 是否已领取(可重复任务恒 `false`,仅「减半到底领满」后置 `true`) | ## 说明 -福利页一次性任务行(如「打开消息提醒」)的状态源。领取走 [tasks-claim](./tasks-claim.md)。 +福利页任务行的状态源。领取走 [tasks-claim](./tasks-claim.md)。 + +`enable_notification`(打开消息提醒)是**可重复领取**任务:每次开启通知发一次,金额逐次减半 +(750→375→188→…→1),`coin` 返回下一次的金额;客户端按「通知权限是否开启」决定展不展示 +(开着就隐藏)。`claimed` 平时恒 `false`,减半到底(领满,约 11 次)后置 `true`,客户端据此隐藏。 +其余为一次性任务,`claimed` 领过即 `true`。 diff --git a/docs/api/wallet-unbind-wechat.md b/docs/api/wallet-unbind-wechat.md index daba1da..01211dc 100644 --- a/docs/api/wallet-unbind-wechat.md +++ b/docs/api/wallet-unbind-wechat.md @@ -5,14 +5,24 @@ > 集成实现:见 [integrations/wxpay](../integrations/wxpay.md)。 ## 入参 -无(用户由 token 确定)。 +请求体 `UnbindWechatRequest`(**可选**:旧客户端可不带 body,等价 `force=false`): + +| 字段 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `force` | bool | `false` | 跳过「有待审核提现」二次确认,强制解绑。首次解绑传 `false`;命中 `needs_confirm` 后用户确认,再带 `true` 调一次才真解绑 | ## 出参 响应 `200`:`UnbindWechatResultOut` | 字段 | 类型 | 说明 | |---|---|---| -| `bound` | bool | 固定 `false` | +| `bound` | bool | 解绑成功为 `false`;命中二次确认(本次未解绑)时为当前真实绑定态(通常 `true`) | +| `needs_confirm` | bool | `true` = 有待审核提现且未 `force`,**本次未解绑**,客户端应弹确认弹窗 | +| `message` | string \| null | `needs_confirm=true` 时的提示文案,直接展示给用户 | ## 说明 清空当前用户的 `wechat_openid` / `wechat_nickname` / `wechat_avatar_url`。幂等:未绑定时调用也返回 `bound=false`。解绑后该微信可被其他账号绑定。 + +**有待审核提现时的二次确认**:用户有 `reviewing`(待审核)状态的提现单且未带 `force=true` 时,**首次解绑被拦下**——不清 openid,返回 `bound`(真实绑定态)+ `needs_confirm=true` + `message`。客户端据此弹确认弹窗,用户确认后带 `force=true` 再调一次即放行解绑。 + +> 只拦 `reviewing`:这类单还没打款,用户确认解绑(`force=true`)时**立即退回现金余额**(写 `withdraw_refund` 流水、单子置终态),后台不再挂单、不必等管理员审核。`pending`(打款在途)单转账已发起、解绑影响不到、照常打款到微信,故**不拦**。 diff --git a/tests/test_admin_config.py b/tests/test_admin_config.py index 12797bc..4f51fbe 100644 --- a/tests/test_admin_config.py +++ b/tests/test_admin_config.py @@ -70,8 +70,7 @@ def test_list_config(admin_client: TestClient, token: str) -> None: items = {i["key"]: i for i in r.json()} assert "signin_rewards" in items and "ad_daily_limit" in items assert items["signin_rewards"]["value"] == [ - 200, 120, 150, 180, 200, 250, 500, - 200, 250, 300, 350, 400, 500, 3000, + 200, 200, 300, 200, 400, 400, 800, ] assert items["signin_rewards"]["overridden"] is False @@ -79,7 +78,7 @@ def test_list_config(admin_client: TestClient, token: str) -> None: def test_update_signin_takes_effect(admin_client: TestClient, token: str) -> None: r = admin_client.patch( "/admin/api/config/signin_rewards", - json={"value": [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400]}, + json={"value": [100, 200, 300, 400, 500, 600, 700]}, headers=_auth(token), ) assert r.status_code == 200, r.text @@ -119,7 +118,7 @@ def test_update_ad_limit_takes_effect(admin_client: TestClient, token: str) -> N def test_config_validation(admin_client: TestClient, token: str) -> None: - # 签到档位长度≠14 + # 签到档位长度≠7 assert admin_client.patch( "/admin/api/config/signin_rewards", json={"value": [1, 2, 3]}, headers=_auth(token) ).status_code == 400 diff --git a/tests/test_welfare.py b/tests/test_welfare.py index 7904be2..d011907 100644 --- a/tests/test_welfare.py +++ b/tests/test_welfare.py @@ -145,37 +145,67 @@ def test_signin_boost_flow(client) -> None: def test_task_claim_flow(client) -> None: - """任务列表 → 领"打开消息提醒" → 余额到账 → 重复领 409。""" + """打开消息提醒=可重复任务:每次领取金额减半(750/375/188),claimed 恒 False,余额累加。""" token = _login(client, "13800001003") - coin = TASK_REWARDS[TASK_ENABLE_NOTIFICATION] + base = TASK_REWARDS[TASK_ENABLE_NOTIFICATION] # 750 - # 1. 任务列表:未领取 + # 1. 任务列表:可重复任务 claimed 恒 False,coin=首次金额(750) r = client.get("/api/v1/tasks", headers=_auth(token)) assert r.status_code == 200, r.text items = {it["task_key"]: it for it in r.json()["items"]} assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is False - assert items[TASK_ENABLE_NOTIFICATION]["coin"] == coin + assert items[TASK_ENABLE_NOTIFICATION]["coin"] == base # 750 - # 2. 领奖 + # 2. 第一次领 → 750,余额 750 r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token)) assert r.status_code == 200, r.text - assert r.json()["coin_awarded"] == coin - assert r.json()["coin_balance"] == coin + assert r.json()["coin_awarded"] == 750 + assert r.json()["coin_balance"] == 750 - # 3. 重复领 → 409 - r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token)) - assert r.status_code == 409 - - # 4. 列表变为已领取 + # 3. 列表:下一次金额减半=375,仍 claimed False(可重复) r = client.get("/api/v1/tasks", headers=_auth(token)) items = {it["task_key"]: it for it in r.json()["items"]} - assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is True + assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is False + assert items[TASK_ENABLE_NOTIFICATION]["coin"] == 375 + + # 4. 第二次领 → 375(余额 1125),第三次 → 188(四舍五入) + r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token)) + assert r.status_code == 200 and r.json()["coin_awarded"] == 375 + assert r.json()["coin_balance"] == 1125 + r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token)) + assert r.status_code == 200 and r.json()["coin_awarded"] == 188 # 5. 未知任务 → 404 r = client.post("/api/v1/tasks/no_such_task/claim", headers=_auth(token)) assert r.status_code == 404 +def test_task_notification_claim_capped(client) -> None: + """可重复任务领满(减半到底)后封顶:不再可领 → 409,列表 claimed 置 True。""" + from app.core.rewards import notification_max_claims + + token = _login(client, "13800001009") + base = TASK_REWARDS[TASK_ENABLE_NOTIFICATION] + cap = notification_max_claims(base) # 750 → 11 + + # 领满 cap 次,每次都应 200(最后几次发 1 金币) + last_coin = None + for _ in range(cap): + r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token)) + assert r.status_code == 200, r.text + last_coin = r.json()["coin_awarded"] + assert last_coin == 1 # 末次是减半到底的 1 + + # 再领 → 409(封顶,不再发放) + r = client.post(f"/api/v1/tasks/{TASK_ENABLE_NOTIFICATION}/claim", headers=_auth(token)) + assert r.status_code == 409, r.text + + # 列表:领满后 claimed 置 True(客户端据此隐藏) + r = client.get("/api/v1/tasks", headers=_auth(token)) + items = {it["task_key"]: it for it in r.json()["items"]} + assert items[TASK_ENABLE_NOTIFICATION]["claimed"] is True + + def test_exchange_info(client) -> None: token = _login(client, "13800001004") r = client.get("/api/v1/wallet/exchange-info", headers=_auth(token)) From cce3a01de166650346d4ddd555cc2729ba18cea2 Mon Sep 17 00:00:00 2001 From: ouzhou Date: Sat, 13 Jun 2026 23:44:57 +0800 Subject: [PATCH 03/36] =?UTF-8?q?fix(marquee):=20=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E8=BD=AE=E6=92=AD=E8=84=B1=E6=95=8F=E5=90=8D=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=8C=89=20user=5Fid=20=E6=81=92=E5=AE=9A=20+=20=E5=8E=BB=20Fa?= =?UTF-8?q?ker=20=E4=BE=9D=E8=B5=96=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/49 Co-authored-by: ouzhou Co-committed-by: ouzhou --- app/admin/repositories/ad_audit.py | 90 ++++++++++-- app/admin/repositories/queries.py | 39 ++++- app/admin/routers/ad_audit.py | 14 +- app/admin/routers/users.py | 103 +++++++++----- app/admin/schemas/ad_audit.py | 13 +- app/admin/schemas/user.py | 16 ++- app/core/rewards.py | 12 +- app/repositories/ad_feed_reward.py | 7 +- app/repositories/ad_reward.py | 18 ++- app/repositories/ops_marquee.py | 158 ++++++++++++++------- docs/api/README.md | 41 +++--- docs/api/admin-ad-coin-audit.md | 10 +- docs/api/admin-user-cash.md | 34 +++++ docs/api/admin-user-coins.md | 17 ++- docs/api/admin-users-list.md | 16 ++- docs/api/admin-wallet-cash-transactions.md | 2 +- docs/api/admin-wallet-coin-transactions.md | 2 +- docs/api/platform-savings-feed.md | 12 +- docs/database/ops_marquee_seed.md | 2 +- tests/test_admin_read.py | 133 +++++++++++++++++ tests/test_admin_write.py | 96 +++++++++++++ 21 files changed, 673 insertions(+), 162 deletions(-) create mode 100644 docs/api/admin-user-cash.md diff --git a/app/admin/repositories/ad_audit.py b/app/admin/repositories/ad_audit.py index c882b12..064b036 100644 --- a/app/admin/repositories/ad_audit.py +++ b/app/admin/repositories/ad_audit.py @@ -1,16 +1,18 @@ """看广告金币审计:复算 expected_coin 并与实发对比。 只读。复用 [app.core.rewards] 的公式函数(不另写公式,避免与正式发奖口径漂移): -- 看视频:每条 granted = 1 份,第 N 份 = 当日该用户 granted 的 reward_video 顺序号 - (与 ad_reward.grant_ad_reward 里 `_granted_today + 1` 一致)。 -- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 当日该用户已 granted 份数累计 - (与 ad_feed_reward._unit_reward_total 的 existing_units 一致)。 +- 看视频:每条 granted = 1 份,第 N 份 = 该用户 granted 的 reward_video **账号累计**顺序号 + (与 ad_reward.grant_ad_reward 里 `_granted_cumulative + 1` 一致;LT 因子不按天重置, + 故复算时要把当日序号叠加上该用户在本日**之前**的累计已发份数)。 +- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 该用户 granted 份数**账号累计** + (与 ad_feed_reward._unit_reward_total 的 existing_units 一致;同样不按天重置, + 复算需叠加本日之前的累计份数)。 非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。 """ from __future__ import annotations -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session from app.core import rewards @@ -19,10 +21,29 @@ from app.models.ad_reward import AdRewardRecord from app.repositories.ad_feed_reward import FEED_REWARD_UNIT_SECONDS +def _prior_granted_counts( + db: Session, *, date: str, user_id: int | None +) -> dict[int, int]: + """各用户在 date **之前**已发奖的 reward_video 累计份数,作为当日复算的 LT 序号起点。 + LT 因子改账号累计后,当日第 1 份并非全局第 1 份,需叠加历史累计。""" + stmt = ( + select(AdRewardRecord.user_id, func.count()) + .where( + AdRewardRecord.reward_date < date, + AdRewardRecord.reward_scene == "reward_video", + AdRewardRecord.status == "granted", + ) + .group_by(AdRewardRecord.user_id) + ) + if user_id is not None: + stmt = stmt.where(AdRewardRecord.user_id == user_id) + return {uid: n for uid, n in db.execute(stmt).all()} + + def _reward_video_rows( db: Session, *, date: str, user_id: int | None ) -> list[dict]: - """看视频记录复算。按 (user_id, created_at) 升序还原当日第 N 份。""" + """看视频记录复算。按 (user_id, created_at) 升序还原账号累计第 N 份(含本日之前的累计)。""" stmt = ( select(AdRewardRecord) .where( @@ -34,7 +55,8 @@ def _reward_video_rows( if user_id is not None: stmt = stmt.where(AdRewardRecord.user_id == user_id) - granted_n: dict[int, int] = {} # user_id -> 已 granted 份数 + # 用本日之前的累计份数做起点,当日 granted 在其上继续递增 → 与 _granted_cumulative+1 对齐 + granted_n: dict[int, int] = _prior_granted_counts(db, date=date, user_id=user_id) rows: list[dict] = [] for rec in db.execute(stmt).scalars(): if rec.status == "granted": @@ -80,8 +102,28 @@ def _reward_video_rows( return rows +def _feed_prior_granted_units( + db: Session, *, date: str, user_id: int | None +) -> dict[int, int]: + """各用户在 date **之前** granted 的信息流份数累计,作为当日复算的 LT 序号起点。""" + stmt = ( + select( + AdFeedRewardRecord.user_id, + func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0), + ) + .where( + AdFeedRewardRecord.reward_date < date, + AdFeedRewardRecord.status == "granted", + ) + .group_by(AdFeedRewardRecord.user_id) + ) + if user_id is not None: + stmt = stmt.where(AdFeedRewardRecord.user_id == user_id) + return {uid: int(n) for uid, n in db.execute(stmt).all()} + + def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: - """信息流记录复算。granted 记录逐份累加,LT 序号沿用当日累计份数。""" + """信息流记录复算。granted 记录逐份累加,LT 序号沿用账号累计份数(含本日之前)。""" stmt = ( select(AdFeedRewardRecord) .where(AdFeedRewardRecord.reward_date == date) @@ -90,7 +132,8 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: if user_id is not None: stmt = stmt.where(AdFeedRewardRecord.user_id == user_id) - granted_units: dict[int, int] = {} # user_id -> 已 granted 份数累计 + # 本日之前的累计份数做起点,与 _unit_reward_total 的 existing_units(累计)对齐 + granted_units: dict[int, int] = _feed_prior_granted_units(db, date=date, user_id=user_id) rows: list[dict] = [] for rec in db.execute(stmt).scalars(): if rec.status == "granted": @@ -142,12 +185,20 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: def ad_coin_audit( - db: Session, *, date: str, user_id: int | None, scene: str | None, limit: int -) -> list[dict]: - """返回当日发奖复算明细,按 created_at 倒序(最新在前)截断到 limit。 + db: Session, + *, + date: str, + user_id: int | None, + scene: str | None, + limit: int, + only_mismatch: bool = False, +) -> dict: + """当日发奖复算。返回 {total, mismatch_count, truncated, items}。 - scene: None=两类都要 / "reward_video" / "feed"。 - 份序号在截断前已基于全天数据算好,故 limit 只影响展示条数、不影响 expected 复算正确性。 + scene: None=两类都要 / "reward_video" / "feed";only_mismatch=True 只展示不一致(✗)行。 + 关键:`total` 与 `mismatch_count` 在**全量**(截断前)上统计,故对账数字始终可信,不受 limit + 影响;`items` 才是展示集(only_mismatch 时只取 ✗ 行)按 created_at 倒序截断到 limit。 + 份序号在全天数据上已算好,limit 只影响展示条数、不影响 expected 复算正确性。 """ rows: list[dict] = [] if scene in (None, "reward_video"): @@ -155,7 +206,16 @@ def ad_coin_audit( if scene in (None, "feed"): rows.extend(_feed_rows(db, date=date, user_id=user_id)) rows.sort(key=lambda r: r["created_at"], reverse=True) - return rows[:limit] + + total = len(rows) + mismatch_count = sum(1 for r in rows if not r["matched"]) + display = [r for r in rows if not r["matched"]] if only_mismatch else rows + return { + "total": total, + "mismatch_count": mismatch_count, + "truncated": len(display) > limit, + "items": display[:limit], + } def formula_snapshot() -> dict: diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index ab306d1..d7293f0 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -44,9 +44,20 @@ def list_users( phone: str | None = None, register_channel: str | None = None, status: str | None = None, + nickname: str | None = None, + created_from: datetime | None = None, + created_to: datetime | None = None, + last_login_from: datetime | None = None, + last_login_to: datetime | None = None, + sort_by: str = "id", + sort_order: str = "desc", limit: int = 20, cursor: int | None = None, ) -> tuple[list[User], int | None]: + """用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录时间范围筛选, + 按 id·注册时间·最近登录排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一, + 代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。 + 日期入参统一转 UTC naive 比较(User 时间均为 UTC naive,见 _as_utc_naive)。""" stmt = select(User) if phone: stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配 @@ -54,7 +65,33 @@ def list_users( stmt = stmt.where(User.register_channel == register_channel) if status: stmt = stmt.where(User.status == status) - return cursor_paginate(db, stmt, User.id, limit=limit, cursor=cursor) + if nickname and nickname.strip(): + stmt = stmt.where(User.nickname.ilike(f"%{nickname.strip()}%")) + if created_from is not None: + stmt = stmt.where(User.created_at >= _as_utc_naive(created_from)) + if created_to is not None: + stmt = stmt.where(User.created_at <= _as_utc_naive(created_to)) + if last_login_from is not None: + stmt = stmt.where(User.last_login_at >= _as_utc_naive(last_login_from)) + if last_login_to is not None: + stmt = stmt.where(User.last_login_at <= _as_utc_naive(last_login_to)) + + sort_cols = { + "id": User.id, + "created_at": User.created_at, + "last_login_at": User.last_login_at, + } + 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) + stmt = stmt.order_by(order_fn(sort_col), id_order) + + offset = max(cursor or 0, 0) + rows = list(db.execute(stmt.offset(offset).limit(limit + 1)).scalars().all()) + has_more = len(rows) > limit + items = rows[:limit] + next_cursor = offset + limit if has_more else None + return items, next_cursor def list_onboarding_devices(db: Session, *, limit: int = 500) -> list[dict]: diff --git a/app/admin/routers/ad_audit.py b/app/admin/routers/ad_audit.py index fd4b057..69f98f3 100644 --- a/app/admin/routers/ad_audit.py +++ b/app/admin/routers/ad_audit.py @@ -29,16 +29,20 @@ def get_ad_coin_audit( str | None, Query(description="reward_video / feed;不传=两类都要") ] = None, limit: Annotated[int, Query(ge=1, le=500)] = 100, + only_mismatch: Annotated[ + bool, Query(description="只看不一致(✗)行;统计数仍按全量,不受影响") + ] = False, ) -> AdCoinAuditOut: audit_date = date or cn_today().isoformat() - rows = ad_audit.ad_coin_audit( + result = ad_audit.ad_coin_audit( db, date=audit_date, user_id=user_id, scene=scene, limit=limit, + only_mismatch=only_mismatch, ) - items = [AdCoinAuditRow(**r) for r in rows] return AdCoinAuditOut( date=audit_date, formula=AdCoinFormulaOut(**ad_audit.formula_snapshot()), - total=len(items), - mismatch_count=sum(1 for it in items if not it.matched), - items=items, + total=result["total"], + mismatch_count=result["mismatch_count"], + truncated=result["truncated"], + items=[AdCoinAuditRow(**r) for r in result["items"]], ) diff --git a/app/admin/routers/users.py b/app/admin/routers/users.py index c7bb408..e992e20 100644 --- a/app/admin/routers/users.py +++ b/app/admin/routers/users.py @@ -1,6 +1,7 @@ """admin 用户管理:列表 + 360 详情(读)+ 封禁/解封 + 手动调金币(写,带审计)。""" from __future__ import annotations +from datetime import datetime from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, Request @@ -28,18 +29,27 @@ router = APIRouter( ) -@router.get("", response_model=CursorPage[AdminUserListItem], summary="用户列表(筛选+分页)") +@router.get("", response_model=CursorPage[AdminUserListItem], summary="用户列表(筛选+排序+分页)") def list_users( db: AdminDb, phone: Annotated[str | None, Query()] = None, register_channel: Annotated[str | None, Query()] = None, status: Annotated[str | None, Query()] = None, + nickname: Annotated[str | None, Query(max_length=100)] = None, + created_from: Annotated[datetime | None, Query()] = None, + 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", + sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc", limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[AdminUserListItem]: items, next_cursor = queries.list_users( db, phone=phone, register_channel=register_channel, status=status, - limit=limit, cursor=cursor, + nickname=nickname, created_from=created_from, created_to=created_to, + last_login_from=last_login_from, last_login_to=last_login_to, + sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor, ) return CursorPage( items=[AdminUserListItem.model_validate(u) for u in items], @@ -101,7 +111,7 @@ def set_user_debug_trace( return OkResponse() -@router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减金币(带审计)") +@router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减/设值金币(带审计)") def grant_user_coins( user_id: int, body: GrantCoinsRequest, @@ -109,33 +119,45 @@ def grant_user_coins( admin: Annotated[AdminUser, Depends(require_role("finance"))], db: AdminDb, ) -> OkResponse: - if body.amount == 0: - raise HTTPException(status_code=400, detail="amount 不能为 0") user = user_repo.get_user_by_id(db, user_id) if user is None: raise HTTPException(status_code=404, detail="用户不存在") - # 负数扣减时不允许扣成负余额(运营误操作保护) - if body.amount < 0: - acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False) - if acc_now.coin_balance + body.amount < 0: - raise HTTPException( - status_code=400, detail=f"扣减后金币为负(当前余额 {acc_now.coin_balance})" - ) - biz_type = "admin_grant" if body.amount > 0 else "admin_deduct" + # set=设为目标值:读当前余额算出要写的差值,仍复用 grant_coins 写一笔流水(沿用原子/审计/扣负保护) + if body.mode == "set": + if body.amount < 0: + raise HTTPException(status_code=400, detail="目标金币值不能为负") + before = wallet_repo.get_or_create_account(db, user_id, commit=False).coin_balance + delta = body.amount - before + if delta == 0: + raise HTTPException(status_code=400, detail=f"当前金币已为 {body.amount},无需调整") + else: + if body.amount == 0: + raise HTTPException(status_code=400, detail="amount 不能为 0") + delta = body.amount + # 负数扣减时不允许扣成负余额(运营误操作保护) + if delta < 0: + acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False) + if acc_now.coin_balance + delta < 0: + raise HTTPException( + status_code=400, detail=f"扣减后金币为负(当前余额 {acc_now.coin_balance})" + ) + biz_type = "admin_grant" if delta > 0 else "admin_deduct" # grant_coins 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕) acc, _ = wallet_repo.grant_coins( - db, user_id, body.amount, biz_type=biz_type, remark=f"admin:{body.reason}"[:128], + db, user_id, delta, biz_type=biz_type, remark=f"admin:{body.reason}"[:128], ) + detail = {"amount": delta, "balance_after": acc.coin_balance, "reason": body.reason} + if body.mode == "set": + detail.update({"mode": "set", "target": body.amount, "before": before}) write_audit( db, admin, action="user.coins.grant", target_type="user", target_id=user_id, - detail={"amount": body.amount, "balance_after": acc.coin_balance, "reason": body.reason}, - ip=get_client_ip(request), commit=False, + detail=detail, ip=get_client_ip(request), commit=False, ) db.commit() return OkResponse() -@router.post("/{user_id}/cash", response_model=OkResponse, summary="手动增减现金(带审计)") +@router.post("/{user_id}/cash", response_model=OkResponse, summary="手动增减/设值现金(带审计)") def grant_user_cash( user_id: int, body: GrantCashRequest, @@ -143,32 +165,47 @@ def grant_user_cash( admin: Annotated[AdminUser, Depends(require_role("finance"))], db: AdminDb, ) -> OkResponse: - """给指定用户增/减现金(分)。正=发放、负=扣减;主要用于让无现金用户直接测试提现。""" - if body.amount_cents == 0: - raise HTTPException(status_code=400, detail="amount_cents 不能为 0") + """给指定用户增/减或设值现金(分)。delta:正=发放、负=扣减;set:直接设为目标值。 + 主要用于让无现金用户直接测试提现。""" user = user_repo.get_user_by_id(db, user_id) if user is None: raise HTTPException(status_code=404, detail="用户不存在") - # 负数扣减时不允许扣成负余额(运营误操作保护) - if body.amount_cents < 0: - acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False) - if acc_now.cash_balance_cents + body.amount_cents < 0: + # set=设为目标值:读当前余额算差值,仍复用 grant_cash 写一笔流水(沿用原子/审计/扣负保护) + if body.mode == "set": + if body.amount_cents < 0: + raise HTTPException(status_code=400, detail="目标现金值不能为负") + before = wallet_repo.get_or_create_account(db, user_id, commit=False).cash_balance_cents + delta = body.amount_cents - before + if delta == 0: raise HTTPException( - status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents} 分)" + status_code=400, detail=f"当前现金已为 {body.amount_cents} 分,无需调整" ) - biz_type = "admin_grant" if body.amount_cents > 0 else "admin_deduct" + else: + if body.amount_cents == 0: + raise HTTPException(status_code=400, detail="amount_cents 不能为 0") + delta = body.amount_cents + # 负数扣减时不允许扣成负余额(运营误操作保护) + if delta < 0: + acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False) + if acc_now.cash_balance_cents + delta < 0: + raise HTTPException( + status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents} 分)" + ) + biz_type = "admin_grant" if delta > 0 else "admin_deduct" # grant_cash 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕) acc, _ = wallet_repo.grant_cash( - db, user_id, body.amount_cents, biz_type=biz_type, remark=f"admin:{body.reason}"[:128], + db, user_id, delta, biz_type=biz_type, remark=f"admin:{body.reason}"[:128], ) + detail = { + "amount_cents": delta, + "balance_after_cents": acc.cash_balance_cents, + "reason": body.reason, + } + if body.mode == "set": + detail.update({"mode": "set", "target_cents": body.amount_cents, "before_cents": before}) write_audit( db, admin, action="user.cash.grant", target_type="user", target_id=user_id, - detail={ - "amount_cents": body.amount_cents, - "balance_after_cents": acc.cash_balance_cents, - "reason": body.reason, - }, - ip=get_client_ip(request), commit=False, + detail=detail, ip=get_client_ip(request), commit=False, ) db.commit() return OkResponse() diff --git a/app/admin/schemas/ad_audit.py b/app/admin/schemas/ad_audit.py index cd5f949..b268d8d 100644 --- a/app/admin/schemas/ad_audit.py +++ b/app/admin/schemas/ad_audit.py @@ -48,10 +48,15 @@ class AdCoinFormulaOut(BaseModel): class AdCoinAuditOut(BaseModel): - """审计响应:公式参照 + 命中条数 + 明细。""" + """审计响应:公式参照 + 全量统计 + 明细。""" date: str = Field(..., description="审计日期(北京时间 YYYY-MM-DD)") formula: AdCoinFormulaOut - total: int = Field(..., description="返回的明细条数") - mismatch_count: int = Field(..., description="其中 matched=false 的条数(=0 说明公式全部生效)") - items: list[AdCoinAuditRow] + total: int = Field(..., description="该筛选下复算总条数(全量,不受 limit/only_mismatch 影响)") + mismatch_count: int = Field( + ..., description="全量不一致条数(=0 说明公式全部生效;在截断前统计,可信)" + ) + truncated: bool = Field( + ..., description="展示集是否被 limit 截断(true=还有未返回的明细,请缩小范围或调大 limit)" + ) + items: list[AdCoinAuditRow] = Field(..., description="展示明细;only_mismatch=true 时只含 ✗ 行") diff --git a/app/admin/schemas/user.py b/app/admin/schemas/user.py index 0b49582..f781fe5 100644 --- a/app/admin/schemas/user.py +++ b/app/admin/schemas/user.py @@ -38,12 +38,24 @@ class AdminUserOverview(BaseModel): class GrantCoinsRequest(BaseModel): - amount: int = Field(..., description="金币变动:正=增加,负=扣减(不可为 0)") + mode: Literal["delta", "set"] = Field( + "delta", description="delta=增减(amount 为变动量) / set=设为(amount 为目标值,须≥0)" + ) + amount: int = Field( + ..., + description="delta 模式:金币变动(正=增加,负=扣减,不可为 0);set 模式:目标金币值(须≥0)", + ) reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)") class GrantCashRequest(BaseModel): - amount_cents: int = Field(..., description="现金变动(分):正=增加,负=扣减(不可为 0)") + mode: Literal["delta", "set"] = Field( + "delta", description="delta=增减(amount_cents 为变动量) / set=设为(amount_cents 为目标值,须≥0)" + ) + amount_cents: int = Field( + ..., + description="delta 模式:现金变动(分,正=增加,负=扣减,不可为 0);set 模式:目标现金值(分,须≥0)", + ) reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)") diff --git a/app/core/rewards.py b/app/core/rewards.py index 713282f..3870af0 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -172,24 +172,24 @@ def ad_ecpm_factor(ecpm_yuan: float) -> float: return 0.1 -def ad_lt_factor(today_count_after_this: int) -> float: - """LT 因子。today_count_after_this 是当天累计第 N 条/份广告奖励。""" - count = max(1, today_count_after_this) +def ad_lt_factor(count_after_this: int) -> float: + """LT 因子。count_after_this 是该账号累计第 N 条/份看视频奖励(不按天重置)。""" + count = max(1, count_after_this) for factor, lo, hi in AD_LT_FACTOR_TABLE: if count >= lo and (hi is None or count <= hi): return factor return 1.0 -def calculate_ad_reward_coin(ecpm: str | int | float | None, today_count_after_this: int) -> int: +def calculate_ad_reward_coin(ecpm: str | int | float | None, count_after_this: int) -> int: """按金币数值体系计算单份广告奖励金币。 eCPM 是穿山甲 getEcpm 原值,单位【分/千次展示】;先 ÷100 转成元(因子判档 + 收益换算都用元)。 单次收益(元)= eCPM元 ÷ 1000(每千次→单次) × 因子1(eCPM 元档) × 因子2(LT); - 再按 1 元=10000 金币取整。 + 再按 1 元=10000 金币取整。count_after_this 为账号累计第 N 次看视频(LT 因子用,不按天重置)。 """ ecpm_yuan = parse_ecpm_yuan(ecpm) - yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(today_count_after_this) + yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(count_after_this) return max(0, round(yuan * COIN_PER_YUAN)) diff --git a/app/repositories/ad_feed_reward.py b/app/repositories/ad_feed_reward.py index fdf0059..894c928 100644 --- a/app/repositories/ad_feed_reward.py +++ b/app/repositories/ad_feed_reward.py @@ -38,15 +38,14 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int: ).scalar_one() -def _unit_reward_total(db: Session, user_id: int, ecpm: str, unit_count: int, today: str) -> int: - """按每个 10 秒单位逐份计算奖励,LT 使用当天累计奖励份序号。""" +def _unit_reward_total(db: Session, user_id: int, ecpm: str, unit_count: int) -> int: + """按每个 10 秒单位逐份计算奖励,LT 使用**账号累计**奖励份序号(不按天重置)。""" if unit_count <= 0: return 0 existing_units = db.execute( select(func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0)) .where( AdFeedRewardRecord.user_id == user_id, - AdFeedRewardRecord.reward_date == today, AdFeedRewardRecord.status == "granted", ) ).scalar_one() @@ -92,7 +91,7 @@ def grant_feed_reward( ) return _commit_record(db, rec, client_event_id) - coin = _unit_reward_total(db, user_id, ecpm, unit_count, today) + coin = _unit_reward_total(db, user_id, ecpm, unit_count) if coin > 0: crud_wallet.grant_coins( db, user_id, coin, diff --git a/app/repositories/ad_reward.py b/app/repositories/ad_reward.py index e018ec0..7c433d5 100644 --- a/app/repositories/ad_reward.py +++ b/app/repositories/ad_reward.py @@ -54,6 +54,20 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int: ).scalar_one() +def _granted_cumulative(db: Session, user_id: int) -> int: + """账号累计已发奖的看视频次数(不按天重置)——LT 因子(因子2)用它定"第 N 次广告"。 + 与 _granted_today 区别仅在去掉 reward_date 过滤;每日次数上限/冷却仍按当日统计。""" + return db.execute( + select(func.count()) + .select_from(AdRewardRecord) + .where( + AdRewardRecord.user_id == user_id, + AdRewardRecord.status == "granted", + AdRewardRecord.reward_scene == "reward_video", + ) + ).scalar_one() + + def grant_ad_reward( db: Session, user_id: int, @@ -104,7 +118,9 @@ def grant_ad_reward( ) return _commit_record(db, rec, trans_id) - coin = rewards.calculate_ad_reward_coin(ecpm_raw, _granted_today(db, user_id, today) + 1) + # LT 因子(因子2)按"账号累计第 N 次看广告"递减(2.0→1.0),不再按天重置; + # 每日次数上限/冷却仍按当日统计(over_count 用 _granted_today)。 + coin = rewards.calculate_ad_reward_coin(ecpm_raw, _granted_cumulative(db, user_id) + 1) # 发金币 + 记一笔,同事务 crud_wallet.grant_coins( diff --git a/app/repositories/ops_marquee.py b/app/repositories/ops_marquee.py index 717e449..87128b7 100644 --- a/app/repositories/ops_marquee.py +++ b/app/repositories/ops_marquee.py @@ -6,9 +6,16 @@ feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条 种子是「生成规则」:用户名可空(空→随机合成脱敏名,避开同屏撞名)、金额是区间(每次随机取值)、 feed 公平随机抽取(不看 sort_order),所有启用种子都有机会露出。 -脱敏名风格:手机尾号(138****5678)+ 中文昵称(省钱**、小张**、吃货**达人)混合,看起来像真实 -异质用户群。真实条按 user_id 确定性合成(同用户恒定);种子留空 / 旧「用户****xxx」模板名一律随机 -合成(自愈历史种子,无需迁移)。 +脱敏名规则(全 feed 统一,按字符算、中英文皆适用): + - 有昵称 → 昵称脱敏:≥3 字「首+隐藏字数个星+末」(省钱小能手→省***手 / SaveKing→S******g), + 2 字「首+星」(阿强→阿*),1 字「用户+该字」(喵→用户喵); + - 没昵称 → 合成一个假名(见下)再脱敏,少数露「用户+id 后 3 位」(用户********618)。 +真实条:设过昵称的按真实昵称脱敏;当前多数用户没设昵称,直接展示「用户****」会清一色,故给它们合成 +假名——后期真实昵称多了,真实条会直接用真实昵称,合成占比自然下降。种子 / 兜底同样合成、避开同屏撞名; +种子留空 / 旧「用户****xxx」模板名一律重新合成(自愈历史种子,无需迁移)。 +假名合成:纯本地语料**组合生成**(中文姓池×名字池 拼真名 / 中文网络昵称 / 英文昵称 / 英文名带数字尾), +组合空间上万、中文为主英文为辅,脱敏后像真实异质用户群。**真实条按 `user_id` 播种确定性合成**(同一用户 +每次展示恒定、不同用户各异),刷新/翻页不变脸;种子 / 兜底用运行时随机源出多样填充。不依赖外部库。 """ from __future__ import annotations @@ -21,6 +28,7 @@ from sqlalchemy.orm import Session from app.core.rewards import CN_TZ from app.models.comparison import ComparisonRecord from app.models.ops_marquee_seed import OpsMarqueeSeed +from app.models.user import User # feed 运行时随机源(每次请求结果不同 = 轮播想要的「鲜活感」) _rng = random.Random() @@ -36,59 +44,111 @@ _REAL_ROWS_TTL_SECONDS = 30 _REAL_ROWS_FETCH_CAP = 600 # 一次多取些,够 limit≤30 去重后取数;命中缓存后复用 _real_rows_cache: dict = {"at": None, "rows": None} -# ===== 脱敏名生成:手机尾号 + 中文昵称 混合,看起来像真实异质用户群(替代旧的「用户********xxx」统一模板)===== -_PHONE_2ND = "3456789" # 手机号第 2 位(13x~19x) -_SURNAMES = "王李张刘陈杨黄赵周吴徐孙马朱胡郭何高林罗郑梁谢宋唐许韩冯邓曹彭曾肖田董袁潘蒋蔡余杜叶程苏魏吕丁任沈姚卢" -_NICK_WORDS = [ - "省钱", "吃货", "羊毛", "薅羊毛", "爱比价", "精打细算", "持家", "干饭", - "美食控", "外卖", "剁手", "捡漏", "搞钱", "打工人", "摸鱼", "养生", - "佛系", "躺平", "暴富", "锦鲤", "夜宵", "奶茶", "热爱生活", "会过日子", +# ===== 用户标识脱敏 + 没真实昵称时的假名合成 ===== +# 脱敏规则(中英文皆适用,按字符算):有昵称→≥3 字「首+隐藏字数个星+末」(省钱小能手→省***手 / +# SaveKing→S******g)、2 字「首+星」(阿强→阿*)、1 字「用户+该字」(喵→用户喵);没昵称→用户+8星+id 后 3 位。 +# 没真实昵称时(当前多数用户 / 种子 / 兜底)合成假名:本地姓/名字池组合出真名 + 网络昵称语料(中/英) +# 混播再走同一套脱敏,像真实异质用户群。后期真实昵称多了,真实条直接用真实昵称,这里占比自然下降。 +_ANON_STARS = "*" * 8 # 无昵称匿名串固定 8 星(用户********xxx) + +# 中文真名组合池:姓(百家姓常见 ~100)× 名字单字(~120)拼「姓 + 1~2 字」,组合空间上万。 +# 脱敏后只露「姓*」或「姓*末」,故无需真实姓名库——组合够多即看着各异(替代原 Faker zh_CN)。 +_SURNAME_ZH = list( + "王李张刘陈杨赵黄周吴徐孙胡朱高林何郭马罗梁宋郑谢韩唐冯于董萧程曹袁邓许傅沈曾彭吕苏卢蒋蔡贾丁" + "魏薛叶阎余潘杜戴夏钟汪田任姜范方石姚谭廖邹熊金陆郝孔白崔康毛邱秦江史顾侯邵孟龙万段钱汤尹黎" + "易常武乔贺赖龚文施洪丰房邢" +) +_GIVEN_ZH = list( + "伟芳娜秀英敏静丽强磊军洋勇艳杰娟涛明超霞平刚桂兰华健世广义兴良海山仁波宁贵福生龙元全国胜学祥" + "才发新利清飞彬富顺信子昌成康星光天达安岩中茂进有坚和彪博诚先敬震振壮会思群豪心邦承乐绍功松善" + "厚庆民友裕河哲亮政谦亨奇之轮翰朗伯宏言若鸣朋斌栋维启克伦翔旭鹏泽晨辰士以建家致树炎德行时泰盛雄" +) +# 英文名池:与英文昵称互补,偶尔带数字尾(jay87)更像注册用户名(替代原 Faker en_US)。 +_FIRST_EN = [ + "Alex", "Chris", "Sam", "Jamie", "Taylor", "Jordan", "Casey", "Morgan", "Riley", "Jessie", + "Robin", "Drew", "Lee", "Max", "Kim", "Ray", "Dana", "Pat", "Terry", "Jay", + "Kelly", "Sky", "Ash", "Nico", "Remy", "Quinn", "Reese", "Sage", "Toni", "Val", +] + +# 网络昵称语料:补一份「省钱小能手 / SaveKing」式网络昵称更像真实 app 用户。想更丰富往里加即可。 +_NICK_ZH = [ + "省钱小能手", "薅羊毛专业户", "吃货一枚", "干饭人", "奶茶续命", "精打细算过日子", + "会过日子的人", "捡漏王", "外卖救星", "养生少年", "持家有道", "比价狂魔", + "月光族逆袭", "摸鱼达人", "暴富锦鲤", "美食猎人", "折扣猎手", "隐形贫困人口", + "打工不打烊", "佛系青年", "元气满满", "一只小馋猫", "爱吃的小朋友", "省钱才是硬道理", +] +_NICK_EN = [ + "SaveKing", "DealHunter", "FoodieLife", "BudgetBoss", "CouponQueen", "ThriftyOne", + "SnackLover", "BargainHero", "PennyWise", "SmartShopper", "DiscountNinja", "HungryPanda", + "MoneyMaster", "CheapEats", "SaverPro", "FrugalFox", "NoodleKing", "BubbleTeaFan", + "GoldenSaver", "ValueHunter", "SnackAttack", "LazyFoodie", ] -_NICK_PREFIX = ["小", "大", "老", "阿", "一只", "爱吃的", "快乐的"] -_NICK_SUFFIX = ["达人", "小能手", "侠", "党", "一族", "星人", "鸭", "酱"] -_LETTER_U = "ABCDEFGHJKLMNPQRSTUVWXYZ" -_LETTER_L = "abcdefghijkmnpqrstuvwxyz" -def _phone_name(rng: random.Random) -> str: - """手机尾号式:1[3-9]X****XXXX(合成、非真实号)。""" - head = "1" + rng.choice(_PHONE_2ND) + str(rng.randint(0, 9)) - return f"{head}****{rng.randint(0, 9999):04d}" +def _mask_nickname(nick: str) -> str: + """昵称脱敏(按字符,中英文皆可):≥3 字→首+(隐藏字数个)星+末(省钱小能手→省***手 / SaveKing→S******g); + 2 字→首+星(阿强→阿*);1 字→「用户」+该字(喵→用户喵)。调用方需保证 nick 已 strip 且非空。""" + n = len(nick) + if n >= 3: + return nick[0] + "*" * (n - 2) + nick[-1] + if n == 2: + return nick[0] + "*" + return "用户" + nick -def _nickname(rng: random.Random) -> str: - """昵称打码式,中文为主(约 11/12),掺少量中英混 / 纯字母。 - ⚠️ 脱敏星号一律夹在中间(xx**yy),星号前后都要有可见字——不出现结尾打码(省钱**)的形态。 - """ - p = rng.randint(0, 11) - if p in (0, 1, 2, 3, 4): - return rng.choice(_NICK_WORDS) + "**" + rng.choice(_NICK_SUFFIX) # 省钱**党 / 吃货**达人 - if p in (5, 6): - return rng.choice(_SURNAMES) + "**" + rng.choice(_NICK_SUFFIX) # 张**侠 - if p in (7, 8): - return rng.choice(_NICK_PREFIX) + rng.choice(_SURNAMES) + "**" + rng.choice(_NICK_SUFFIX) # 小张**达人 - if p in (9, 10): - return rng.choice(_NICK_WORDS) + "**" + rng.choice(_LETTER_U) # 中英混 省钱**A(字母移到星号后) - return rng.choice(_LETTER_U) + "**" + rng.choice(_LETTER_L) # 纯字母(约 1/12) A**b +def _anon_by_id(user_id: int) -> str: + """没昵称匿名:固定「用户」+ 8 星 + id 后 3 位(不足 3 位前补 0):用户********618。""" + return "用户" + _ANON_STARS + f"{user_id % 1000:03d}" -def _realistic_name(rng: random.Random) -> str: - """~45% 手机尾号 + ~55% 中文昵称,混合出真实异质感。""" - return _phone_name(rng) if rng.random() < 0.45 else _nickname(rng) +def _synth_full_name(rng: random.Random) -> str: + """合成一个完整(未脱敏)假名:中文真名(姓+1~2字) / 中文网络昵称 / 英文昵称 / 英文名(可带数字尾) 混播, + 中文为主(~70%)英文为辅(~30%)。**全程用传入 rng** → 同种子复现、不同种子各异、组合空间上万。""" + r = rng.random() + if r < 0.35: + # 中文真名:姓 + 1~2 个名字字(60% 概率两字),脱敏后露「姓*」或「姓*末」 + given = rng.choice(_GIVEN_ZH) + (rng.choice(_GIVEN_ZH) if rng.random() < 0.6 else "") + return rng.choice(_SURNAME_ZH) + given # 王伟 / 李秀兰 + if r < 0.70: + return rng.choice(_NICK_ZH) # 中文网络昵称:省钱小能手 + if r < 0.85: + return rng.choice(_NICK_EN) # 英文昵称:SaveKing + base = rng.choice(_FIRST_EN) # 英文名,半数带数字尾:Jay87 / Alex + return base + (str(rng.randint(1, 99)) if rng.random() < 0.5 else "") -def _mask_user(user_id: int) -> str: - """真实用户脱敏:按 user_id 确定性合成一个名(同用户恒定、不暴露真实信息)。""" - return _realistic_name(random.Random(user_id)) +def _synth_masked_name(rng: random.Random) -> str: + """合成一条「已脱敏」假名(供没真实昵称的真实用户 / 种子 / 兜底用)。用传入 rng 决定一切随机。""" + full = _synth_full_name(rng).strip() + return _mask_nickname(full) if full else "用户" + _ANON_STARS + f"{rng.randint(0, 999):03d}" + + +def _mask_real(nickname: str | None, user_id: int) -> str: + """真实用户脱敏:设过昵称→昵称脱敏(中英文皆可);没昵称→**按 user_id 播种确定性合成假名**再脱敏 + (同一用户每次展示恒定、刷新不变脸,不同用户各异),避免无昵称用户清一色「用户****xxx」; + 少数(~15%)露「用户+真实 id 后 3 位」。当前多数用户没昵称,后期真实昵称多了占比下降。""" + nick = (nickname or "").strip() + if nick: + return _mask_nickname(nick) + # 关键:按 user_id 播种本地 rng → 同一用户的合成名跨请求/刷新恒定(0.15 抽签也用它)。 + seeded = random.Random(user_id) + return _anon_by_id(user_id) if seeded.random() < 0.15 else _synth_masked_name(seeded) + + +def _synth_name(rng: random.Random) -> str: + """合成一条脱敏名(种子留空 / 旧模板名 / 兜底用):绝大多数=假名脱敏,少数=用户+随机 3 位(用户********618)。""" + if rng.random() < 0.15: + return "用户" + _ANON_STARS + f"{rng.randint(0, 999):03d}" + return _synth_masked_name(rng) def _unique_name(used: set[str]) -> str: - """随机合成一个不与 used 撞的脱敏名(种子留空 / 旧模板名用),防同屏撞名。""" + """随机合成一个不与 used 撞的脱敏名(种子留空 / 旧模板名 / 兜底用),防同屏撞名。""" for _ in range(60): - n = _realistic_name(_rng) + n = _synth_name(_rng) if n not in used: return n - return _realistic_name(_rng) + str(_rng.randint(0, 99)) # 兜底(几乎不会到) + return "用户" + _ANON_STARS + f"{_rng.randint(0, 999):03d}" # 兜底:数字尾号天然好去重 def _validate_amount(min_cents: int, max_cents: int) -> None: @@ -116,8 +176,9 @@ _FALLBACK_MAX_CENTS = 3500 # ===== 用户侧:轮播 feed(真实 + 种子混播) ===== -def _recent_real_rows(db: Session) -> list[tuple[int, int]]: - """近期 success 且省到钱的 (user_id, saved_cents),按 created_at desc;带 ~30s 进程内缓存。 +def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]: + """近期 success 且省到钱的 (user_id, saved_cents, nickname),按 created_at desc;带 ~30s 进程内缓存。 + join user 取真实昵称用于脱敏(无昵称则展示层走「用户+id 后 3 位」)。 返回纯元组(脱离 session),可安全跨请求复用。极端并发下偶尔多查一次(无锁、幂等),纯门面无副作用。 """ now = datetime.now(CN_TZ) @@ -125,7 +186,8 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int]]: if cached is not None and at is not None and (now - at).total_seconds() < _REAL_ROWS_TTL_SECONDS: return cached rows = db.execute( - select(ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents) + select(ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents, User.nickname) + .join(User, User.id == ComparisonRecord.user_id) .where( ComparisonRecord.status == "success", ComparisonRecord.saved_amount_cents > 0, @@ -134,7 +196,7 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int]]: .order_by(ComparisonRecord.created_at.desc()) .limit(_REAL_ROWS_FETCH_CAP) ).all() - out = [(int(uid), int(sc)) for uid, sc in rows] + out = [(int(uid), int(sc), nick) for uid, sc, nick in rows] _real_rows_cache["rows"], _real_rows_cache["at"] = out, now return out @@ -155,12 +217,12 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]: items: list[dict] = [] used_names: set[str] = set() seen_users: set[int] = set() - for uid, sc in rows: + for uid, sc, nick in rows: if uid in seen_users: continue seen_users.add(uid) - name = _mask_user(uid) - used_names.add(name) # 真实名按 user_id 稳定;偶发撞名可接受 + name = _mask_real(nick, uid) + used_names.add(name) # 真实名按昵称/id 稳定;偶发撞名可接受 items.append({"masked_user": name, "saved_amount_cents": int(sc)}) if len(items) >= limit: break diff --git a/docs/api/README.md b/docs/api/README.md index 7ff8981..79bea57 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -83,26 +83,27 @@ | A5 | `GET /admin/api/users/{user_id}` | admin | [详情](./admin-user-detail.md) | | A6 | `POST /admin/api/users/{user_id}/status` | operator | [详情](./admin-user-status.md) | | A7 | `POST /admin/api/users/{user_id}/coins` | finance | [详情](./admin-user-coins.md) | -| A8 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin-wallet-coin-transactions.md) | -| A9 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin-wallet-cash-transactions.md) | -| A10 | `GET /admin/api/withdraws` | admin | [详情](./admin-withdraws-list.md) | -| A11 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin-withdraw-reconcile.md) | -| A12 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin-withdraw-refresh.md) | -| A13 | `GET /admin/api/feedbacks` | admin | [详情](./admin-feedbacks-list.md) | -| A14 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin-feedback-handle.md) | -| A15 | `GET /admin/api/admins` | super_admin | [详情](./admin-admins-list.md) | -| A16 | `POST /admin/api/admins` | super_admin | [详情](./admin-admin-create.md) | -| A17 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin-admin-update.md) | -| A18 | `GET /admin/api/audit-logs` | admin | [详情](./admin-audit-logs.md) | -| A19 | `GET /admin/api/dashboard-display` | admin | [详情](./admin-dashboard-display.md) | -| A20 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin-dashboard-display.md) | -| A21 | `GET /admin/api/marquee-seeds` | admin | [详情](./admin-marquee-seeds.md) | -| A22 | `POST /admin/api/marquee-seeds` | operator | [详情](./admin-marquee-seeds.md) | -| A23 | `PATCH /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) | -| A24 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) | -| A25 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) | -| A26 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) | -| A27 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) | +| A8 | `POST /admin/api/users/{user_id}/cash` | finance | [详情](./admin-user-cash.md) | +| A9 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin-wallet-coin-transactions.md) | +| A10 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin-wallet-cash-transactions.md) | +| A11 | `GET /admin/api/withdraws` | admin | [详情](./admin-withdraws-list.md) | +| A12 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin-withdraw-reconcile.md) | +| A13 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin-withdraw-refresh.md) | +| A14 | `GET /admin/api/feedbacks` | admin | [详情](./admin-feedbacks-list.md) | +| A15 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin-feedback-handle.md) | +| A16 | `GET /admin/api/admins` | super_admin | [详情](./admin-admins-list.md) | +| A17 | `POST /admin/api/admins` | super_admin | [详情](./admin-admin-create.md) | +| A18 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin-admin-update.md) | +| A19 | `GET /admin/api/audit-logs` | admin | [详情](./admin-audit-logs.md) | +| A20 | `GET /admin/api/dashboard-display` | admin | [详情](./admin-dashboard-display.md) | +| A21 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin-dashboard-display.md) | +| A22 | `GET /admin/api/marquee-seeds` | admin | [详情](./admin-marquee-seeds.md) | +| A23 | `POST /admin/api/marquee-seeds` | operator | [详情](./admin-marquee-seeds.md) | +| A24 | `PATCH /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) | +| A25 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) | +| A26 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) | +| A27 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) | +| A28 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) | | - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) | > ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。 diff --git a/docs/api/admin-ad-coin-audit.md b/docs/api/admin-ad-coin-audit.md index 8b69e71..2956994 100644 --- a/docs/api/admin-ad-coin-audit.md +++ b/docs/api/admin-ad-coin-audit.md @@ -25,16 +25,18 @@ eCPM元 = getEcpm分 ÷ 100 | `date` | string | 今天 | 北京时间 `YYYY-MM-DD`,审计某天 | | `user_id` | int | 全部 | 只看某用户;不传=所有用户 | | `scene` | string | 两类 | `reward_video` / `feed`;不传=两类都返回 | - | `limit` | int(1~500) | 100 | 返回明细条数(按时间倒序截断;**份序号在截断前已按全天数据算好**,不影响复算正确性) | + | `limit` | int(1~500) | 100 | **展示**明细条数(按时间倒序截断;**份序号在截断前已按全天数据算好**,不影响复算正确性) | + | `only_mismatch` | bool | false | 只展示不一致(✗)行;统计数仍按全量,不受影响 | - 出参 `200`:`AdCoinAuditOut` | 字段 | 类型 | 说明 | |---|---|---| | `date` | string | 审计日期 | | `formula` | object | 当前公式参数快照(见下) | - | `total` | int | 返回明细条数 | - | `mismatch_count` | int | 其中 `matched=false` 的条数;**=0 说明全部按公式发放** | - | `items` | `AdCoinAuditRow[]` | 明细(见下) | + | `total` | int | 该筛选下复算**总条数**(全量,不受 `limit`/`only_mismatch` 影响) | + | `mismatch_count` | int | **全量**不一致条数(截断前统计,可信);**=0 说明全部按公式发放** | + | `truncated` | bool | 展示集是否被 `limit` 截断(true=还有未返回的明细,请缩小范围或调大 `limit`) | + | `items` | `AdCoinAuditRow[]` | 展示明细(见下);`only_mismatch=true` 时只含 ✗ 行 | ### AdCoinFormulaOut(`formula`) | 字段 | 类型 | 说明 | diff --git a/docs/api/admin-user-cash.md b/docs/api/admin-user-cash.md new file mode 100644 index 0000000..c9ea739 --- /dev/null +++ b/docs/api/admin-user-cash.md @@ -0,0 +1,34 @@ +# POST /admin/api/users/{user_id}/cash — 手动增减/设值现金(带审计) + +> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md) + +主要用于给无现金用户直接发钱、好让其测试提现链路。 + +## 入参 +- 路径:`user_id`(int) +- **application/json**: +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `mode` | string | ✗ | `delta`(默认)=增减 / `set`=设为指定值 | +| `amount_cents` | int | ✓ | `delta` 模式:现金变动(分,正=发放,负=扣减,不可为 0);`set` 模式:目标现金值(分,须 ≥ 0) | +| `reason` | string | ✓ | 操作原因,1–128 字(必填,入审计与流水备注) | + +## 出参 +响应 `200`:`OkResponse` = `{ "ok": true }` + +## 错误码 +- `400`(`delta`)`amount_cents == 0`(`detail: "amount_cents 不能为 0"`);或负数扣减后余额会变负(`detail: "扣减后现金为负(当前余额 N 分)"`) +- `400`(`set`)目标值为负(`detail: "目标现金值不能为负"`);或目标值等于当前余额(`detail: "当前现金已为 N 分,无需调整"`) +- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`) +- `403` 角色不足(需 `finance` 或 `super_admin`) +- `404` 用户不存在(`detail: "用户不存在"`) +- `422` 缺 `amount_cents`/`reason`、`reason` 长度不在 1–128、`mode` 非 `delta`/`set`、字段类型不合法 / `user_id` 非整数 + +## 说明 +- 金额单位一律为**分**(`*_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。 +- 现金变动 + 审计在同一事务原子提交(改钱必留痕)。 +- 关联用户表 [user](../database/user.md);现金账户 [coin_account](../database/coin_account.md);现金流水 [cash_transaction](../database/cash_transaction.md)。 diff --git a/docs/api/admin-user-coins.md b/docs/api/admin-user-coins.md index c04f5dc..c126afc 100644 --- a/docs/api/admin-user-coins.md +++ b/docs/api/admin-user-coins.md @@ -1,4 +1,4 @@ -# POST /admin/api/users/{user_id}/coins — 手动增减金币(带审计) +# POST /admin/api/users/{user_id}/coins — 手动增减/设值金币(带审计) > 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md) @@ -7,23 +7,26 @@ - **application/json**: | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| -| `amount` | int | ✓ | 金币变动(个数):正=增加,负=扣减;不可为 0 | +| `mode` | string | ✗ | `delta`(默认)=增减 / `set`=设为指定值 | +| `amount` | int | ✓ | `delta` 模式:金币变动(正=增加,负=扣减,不可为 0);`set` 模式:目标金币值(须 ≥ 0) | | `reason` | string | ✓ | 操作原因,1–128 字(必填,入审计与流水备注) | ## 出参 响应 `200`:`OkResponse` = `{ "ok": true }` ## 错误码 -- `400` `amount == 0`(`detail: "amount 不能为 0"`);或负数扣减后余额会变负(`detail: "扣减后金币为负(当前余额 N)"`) +- `400`(`delta`)`amount == 0`(`detail: "amount 不能为 0"`);或负数扣减后余额会变负(`detail: "扣减后金币为负(当前余额 N)"`) +- `400`(`set`)目标值为负(`detail: "目标金币值不能为负"`);或目标值等于当前余额(`detail: "当前金币已为 N,无需调整"`) - `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`) - `403` 角色不足(需 `finance` 或 `super_admin`) - `404` 用户不存在(`detail: "用户不存在"`) -- `422` 缺 `amount`/`reason`、`reason` 长度不在 1–128、字段类型不合法 / `user_id` 非整数 +- `422` 缺 `amount`/`reason`、`reason` 长度不在 1–128、`mode` 非 `delta`/`set`、字段类型不合法 / `user_id` 非整数 ## 说明 - 金币 = 个数(非现金);本接口只动金币余额,不涉及现金(`*_cents`)。 -- 扣减保护:`amount < 0` 时若扣减后金币余额 < 0 直接拒绝(运营误操作保护)。 -- 金币变动写流水 [coin_transaction](../database/coin_transaction.md):`biz_type` 增加为 `admin_grant`、扣减为 `admin_deduct`,`remark = admin:`(截断至 128 字)。 -- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.coins.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount, balance_after, reason}`,并记录操作 IP。 +- **set 模式**:读当前余额算出差值 `delta = target - 当前余额`,再复用同一套写入逻辑(故只写一笔差值流水)。目标值须 ≥ 0;差值为 0(已等于目标)直接拒绝。 +- 扣减保护:实际写入的 `delta < 0` 时若扣减后金币余额 < 0 直接拒绝(运营误操作保护);set 模式目标值 ≥ 0 天然不会扣成负。 +- 金币变动写流水 [coin_transaction](../database/coin_transaction.md):`biz_type` 实际差值为正记 `admin_grant`、为负记 `admin_deduct`(set 模式同理,不新增流水类型),`remark = admin:`(截断至 128 字)。 +- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.coins.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount(=实际差值), balance_after, reason}`;set 模式额外带 `{mode:"set", target, before}`。并记录操作 IP。 - 金币变动 + 审计在同一事务原子提交(改钱必留痕)。 - 关联用户表 [user](../database/user.md);金币账户 [coin_account](../database/coin_account.md);金币流水 [coin_transaction](../database/coin_transaction.md)。 diff --git a/docs/api/admin-users-list.md b/docs/api/admin-users-list.md index 287e057..f4ec2ba 100644 --- a/docs/api/admin-users-list.md +++ b/docs/api/admin-users-list.md @@ -1,4 +1,4 @@ -# GET /admin/api/users — 用户列表(筛选+分页) +# GET /admin/api/users — 用户列表(筛选+排序+分页) > 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](./README.md) @@ -8,8 +8,13 @@ | `phone` | string | ❌ | null | 手机号**前缀**匹配(`phone LIKE '<值>%'`) | | `register_channel` | string | ❌ | null | 注册渠道,精确匹配 | | `status` | string | ❌ | null | 用户状态,精确匹配:`active` / `disabled` / `deleted` | +| `nickname` | string | ❌ | null | 昵称**模糊**匹配(`ILIKE '%<值>%'`) | +| `created_from` / `created_to` | datetime | ❌ | null | 注册时间范围(ISO 8601;按 UTC 比较) | +| `last_login_from` / `last_login_to` | datetime | ❌ | null | 最近登录时间范围(ISO 8601;按 UTC 比较) | +| `sort_by` | string | ❌ | `id` | 排序列:`id` / `created_at` / `last_login_at` | +| `sort_order` | string | ❌ | `desc` | `asc` / `desc` | | `limit` | int | ❌ | 20 | 1–100 | -| `cursor` | int | ❌ | null | 上一页 next_cursor(按 user id 倒序,查 `id < cursor`) | +| `cursor` | int | ❌ | null | 上一页 next_cursor(**offset 偏移量**) | ## 出参 响应 `200`:`{ items: AdminUserListItem[], next_cursor: int|null }`(`next_cursor=null` 表示末页) @@ -28,10 +33,11 @@ ## 错误码 - `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`) -- `422` `limit` 超出 1–100 范围 / 字段类型不合法 +- `422` `limit` 超出 1–100 范围 / `sort_by`·`sort_order` 不在白名单 / 日期格式不合法 / 字段类型不合法 ## 说明 -- 游标分页约定:结果按 user `id` 倒序;`cursor` 传上一页返回的 `next_cursor`;`next_cursor=null` 即末页。 -- `phone` 为前缀匹配(`LIKE '<值>%'`),`register_channel` / `status` 为精确匹配;三者可叠加。 +- **分页为 offset 偏移式**(为支持任意列排序):`cursor` 传上一页返回的 `next_cursor`(实为 offset);`next_cursor=null` 即末页。代价:翻页期间数据变动可能错位一条,admin 低频场景可接受(同提现列表)。 +- 排序:`sort_by` 限 `id`/`created_at`/`last_login_at`,非法值回落 `id`;同值再按 `id` 同向兜底,次序稳定。 +- 筛选:`phone` 前缀、`nickname` 模糊(`ILIKE`)、`register_channel`/`status` 精确、`created_*`/`last_login_*` 时间范围;均可叠加。 - 关联用户表 [user](../database/user.md)。 - 历史明细(金币流水、提现、比价、反馈等)不在本列表,走各自带 `user_id` 过滤的分页接口。 diff --git a/docs/api/admin-wallet-cash-transactions.md b/docs/api/admin-wallet-cash-transactions.md index f5f5da5..e3942be 100644 --- a/docs/api/admin-wallet-cash-transactions.md +++ b/docs/api/admin-wallet-cash-transactions.md @@ -25,7 +25,7 @@ | `biz_type` | string | 业务类型 | | `ref_id` | string \| null | 关联业务 ID | | `remark` | string \| null | 备注 | -| `created_at` | datetime | 创建时间(ISO 8601 UTC) | +| `created_at` | datetime | 创建时间(北京 wall-clock naive,非 UTC;前端按字面显示,不做时区换算) | ## 错误码 - `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`) diff --git a/docs/api/admin-wallet-coin-transactions.md b/docs/api/admin-wallet-coin-transactions.md index e4ca061..fc40802 100644 --- a/docs/api/admin-wallet-coin-transactions.md +++ b/docs/api/admin-wallet-coin-transactions.md @@ -25,7 +25,7 @@ | `biz_type` | string | 业务类型 | | `ref_id` | string \| null | 关联业务 ID | | `remark` | string \| null | 备注 | -| `created_at` | datetime | 创建时间(ISO 8601 UTC) | +| `created_at` | datetime | 创建时间(北京 wall-clock naive,非 UTC;前端按字面显示,不做时区换算) | ## 错误码 - `401` 未带/无效/过期 admin token、管理员被禁用(响应头带 `WWW-Authenticate: Bearer`) diff --git a/docs/api/platform-savings-feed.md b/docs/api/platform-savings-feed.md index 321dc69..511e8c4 100644 --- a/docs/api/platform-savings-feed.md +++ b/docs/api/platform-savings-feed.md @@ -2,7 +2,11 @@ > 所属:Platform 组(前缀 `/api/v1/platform`) | 鉴权:**无** | [← 返回 API 索引](./README.md) -客户端首页顶部「用户****xxx 比价后节省 xx 元」滚动条数据源。全平台真实比价记录优先;不足时用运营配的种子([ops_marquee_seed](../database/ops_marquee_seed.md))补齐到 `limit` 条「混播」,保证轮播不空。 +客户端首页顶部「某用户 比价后节省 xx 元」滚动条数据源。全平台真实比价记录优先;不足时用运营配的种子([ops_marquee_seed](../database/ops_marquee_seed.md))补齐到 `limit` 条「混播」,保证轮播不空。 + +**用户标识脱敏规则(全 feed 统一)**:① 有昵称 → 昵称脱敏:≥3 字「首+隐藏字数个星+末」(省钱小能手→`省***手`)、2 字「首+星」(阿强→`阿*`)、1 字「用户+该字」(喵→`用户喵`);② 没昵称 → 按 `user_id` **确定性合成一个假昵称再脱敏**(同用户恒定,避免无昵称用户清一色 `用户****`),少数露「用户」+ 8 星 + id 后 3 位(`用户********618`)。 + +> 合成假名走**本地语料组合生成**(无外部库):中文真名(姓池 ×名字字池 拼「姓+1~2字」) / 中文网络昵称 / 英文昵称 / 英文名(可带数字尾) 混播,中文为主英文为辅,组合空间上万。真实条按 `user_id` 播种 → 同一用户名字恒定、不同用户各异(刷新/翻页不变脸,同屏几乎不撞名);种子/兜底用运行时随机源出多样。当前真实用户大多没设昵称,故走合成。 ## 入参 | 参数 | 类型 | 说明 | @@ -15,15 +19,15 @@ | 字段 | 类型 | 说明 | |---|---|---| | `items` | list | 轮播条目数组(最多 `limit` 条) | -| `items[].masked_user` | string | 脱敏用户名,手机尾号(`138****5678`)/ 中文昵称(`省钱**`)混合风格 | +| `items[].masked_user` | string | 脱敏用户名:昵称/合成名脱敏(`省***手` / `王*` / `SaveK**g`)或匿名 `用户********618` | | `items[].saved_amount_cents` | int | 节省(分),客户端 ÷100 显示「x.xx 元」 | | `items[].time` | string | 北京时间 `HH:MM:SS` | ## 说明 -- 真实条:`comparison_record` 中 `status='success'` 且 `0 < saved_amount_cents ≤ 300 元` 的近期记录(金额超 300 元视为异常 / bug 值剔除,防「节省 999 元」穿帮),**按 `user_id` 去重**(同一用户只取最新一条,避免单人刷屏);用户名按 `user_id` **确定性合成**手机尾号 / 中文昵称混合脱敏名(同用户恒定)。 +- 真实条:`comparison_record` 中 `status='success'` 且 `0 < saved_amount_cents ≤ 300 元` 的近期记录(金额超 300 元视为异常 / bug 值剔除,防「节省 999 元」穿帮),**JOIN `user` 表取昵称 → 仅 user 表内用户的记录入选**(孤儿/已删用户记录不计,设计如此;当前用户量少,缺口由种子补足),**按 `user_id` 去重**(同一用户只取最新一条,避免单人刷屏);有昵称按真实昵称脱敏,无昵称按 `user_id` 播种**确定性合成**脱敏名(同用户恒定、刷新不变脸)。 - 种子条:`ops_marquee_seed`(`enabled=true`),仅在真实去重后不足 `limit` 时补齐;**从启用种子中公平随机抽取**(不再固定取前 N,所有种子都有机会露出)。每条种子:用户名留空或旧 `用户****xxx` 模板名则**随机合成混合风格名并避开同屏撞名**(自愈历史种子),金额在 `[min_cents, max_cents]` 取**长尾随机值**(小额居多、偶尔大额,固定金额则 min==max)。 - 兜底:真实 + 种子仍凑不满 `limit`(种子被全停用 / 太少)时,用**内置合成条补满**,保证轮播既不空也不稀疏。 - **`time` 为合成的「最近」时间**(从当前北京时间往前**随机抖动**递减,首条几十秒前,其余多数 1~5 分钟、偶尔扎堆或较长):社会证明轮播保证永远像刚发生且节奏自然不机械,不受旧测试数据 / 低谷期记录影响。真实的用户/金额不变,只换展示时间。 -- 因含随机(抽取 / 金额 / 合成名),**每次请求结果都不同**——这是轮播想要的鲜活感。 +- 因含随机(种子抽取 / 金额 / 展示时间 / 种子合成名),**每次请求结果都不同**——这是轮播想要的鲜活感;但**真实用户的脱敏名按 `user_id` 恒定**,同一用户跨请求不变脸(避免「同一人换张脸」露馅)。 - **性能**:首页人人都看、真实数据变化慢,真实记录原始行带 **~30s 进程内缓存**(展示层随机仍每次重算,只省 DB 往返;新记录最多晚 30s 进轮播)。查询走复合索引 `ix_comparison_status_created (status, created_at)`,避免随数据量增大全表扫。 - 逻辑见 `app/repositories/ops_marquee.py`;运营管理种子见 [admin-marquee-seeds](./admin-marquee-seeds.md)。 diff --git a/docs/database/ops_marquee_seed.md b/docs/database/ops_marquee_seed.md index 7de9bdb..a001611 100644 --- a/docs/database/ops_marquee_seed.md +++ b/docs/database/ops_marquee_seed.md @@ -8,7 +8,7 @@ | 列 | 类型 | 约束 / 默认 | 说明 | |---|---|---|---| | `id` | Integer | PK, autoincrement | | -| `masked_user` | String(64) | NULL 可空 | 脱敏用户名,手机尾号(`138****5678`)/ 中文昵称(`省钱**`)风格(整串存)。**留空或旧 `用户****xxx` 模板名 → feed 展示时随机合成混合风格名**(避开同屏撞名、自愈历史种子),风格与真实条一致、永不穿帮 | +| `masked_user` | String(64) | NULL 可空 | 脱敏用户名(整串存)。**留空或旧 `用户****xxx` 模板名 → feed 展示时按脱敏规则随机合成**(本地姓/名字池组合的真名 + 中英网络昵称语料,首末字+星;避开同屏撞名、自愈历史种子),风格与真实条一致、永不穿帮。详见 [platform-savings-feed](../api/platform-savings-feed.md) | | `min_cents` | Integer | NOT NULL | 节省金额区间下限(分) | | `max_cents` | Integer | NOT NULL | 节省金额区间上限(分);feed 每次在 `[min,max]` 随机取值,**固定金额则 min==max** | | `enabled` | Boolean | NOT NULL, default true | 停用的不参与混播 | diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index 4111d50..90005ad 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -91,6 +91,80 @@ def test_user_filter_by_status(admin_client: TestClient, admin_token: str) -> No assert all(u["status"] == "active" for u in r.json()["items"]) +def test_user_list_sort_filter_range(admin_client: TestClient, admin_token: str) -> None: + """用户列表:渠道/昵称筛选 + id 升降序 + 时间范围 + 非法 sort_by 422。""" + db = SessionLocal() + try: + u1 = user_repo.upsert_user_for_login(db, phone="13811110001", register_channel="sms") + u1.nickname = "排序测试甲" + u2 = user_repo.upsert_user_for_login(db, phone="13811110002", register_channel="wechat") + u2.nickname = "排序测试乙" + db.commit() + id1, id2 = u1.id, u2.id + finally: + db.close() + + # 渠道精确筛选 + r = admin_client.get( + "/admin/api/users", params={"register_channel": "wechat"}, headers=_auth(admin_token) + ) + assert r.status_code == 200, r.text + assert all(u["register_channel"] == "wechat" for u in r.json()["items"]) + + # 昵称模糊筛选 + r = admin_client.get( + "/admin/api/users", params={"nickname": "排序测试", "limit": 100}, headers=_auth(admin_token) + ) + ids = {u["id"] for u in r.json()["items"]} + assert id1 in ids and id2 in ids + + # id 升序 / 降序 + asc_ids = [ + u["id"] + for u in admin_client.get( + "/admin/api/users", + params={"sort_by": "id", "sort_order": "asc", "limit": 100}, + headers=_auth(admin_token), + ).json()["items"] + ] + assert asc_ids == sorted(asc_ids) + desc_ids = [ + u["id"] + for u in admin_client.get( + "/admin/api/users", + params={"sort_by": "id", "sort_order": "desc", "limit": 100}, + headers=_auth(admin_token), + ).json()["items"] + ] + assert desc_ids == sorted(desc_ids, reverse=True) + + # 时间范围:2000 年之前无人;之后有人(验证范围条件确实生效) + assert ( + admin_client.get( + "/admin/api/users", params={"created_to": "2000-01-01T00:00:00Z"}, + headers=_auth(admin_token), + ).json()["items"] + == [] + ) + assert ( + len( + admin_client.get( + "/admin/api/users", params={"created_from": "2000-01-01T00:00:00Z", "limit": 100}, + headers=_auth(admin_token), + ).json()["items"] + ) + >= 1 + ) + + # 非法 sort_by → 422 + assert ( + admin_client.get( + "/admin/api/users", params={"sort_by": "phone"}, headers=_auth(admin_token) + ).status_code + == 422 + ) + + def test_wallet_and_withdraw_lists(admin_client: TestClient, admin_token: str) -> None: uid = _seed_user_with_data("13800000004") r = admin_client.get( @@ -112,6 +186,65 @@ def test_feedback_list(admin_client: TestClient, admin_token: str) -> None: assert all(f["status"] == "new" for f in r.json()["items"]) +def test_ad_coin_audit_full_count_truncate_and_only_mismatch( + admin_client: TestClient, admin_token: str +) -> None: + """A+B:total/mismatch_count 按全量统计(不受 limit 影响),truncated 旗标 + only_mismatch 过滤。 + + 用 capped 行造确定性数据(应发恒 0,coin==0 即一致),不依赖发奖公式: + 3 条 coin=0(一致) + 2 条 coin=7(不一致) → 全量 total=5、mismatch=2。 + """ + from app.models.ad_reward import AdRewardRecord + + d = "2020-01-15" # 固定历史日 + 独立 user,隔离其它用例数据 + db = SessionLocal() + try: + uid = user_repo.upsert_user_for_login(db, phone="13800009999", register_channel="sms").id + for i in range(3): + db.add(AdRewardRecord( + trans_id=f"adaudit-ok-{i}", user_id=uid, coin=0, status="capped", + reward_scene="reward_video", reward_date=d, + )) + for i in range(2): + db.add(AdRewardRecord( + trans_id=f"adaudit-bad-{i}", user_id=uid, coin=7, status="capped", + reward_scene="reward_video", reward_date=d, + )) + db.commit() + finally: + db.close() + + base = {"date": d, "user_id": uid} + + # 全量:total=5、mismatch=2、不截断、返回 5 条 + body = admin_client.get( + "/admin/api/ad-coin-audit", params={**base, "limit": 100}, headers=_auth(admin_token) + ).json() + assert body["total"] == 5 + assert body["mismatch_count"] == 2 + assert body["truncated"] is False + assert len(body["items"]) == 5 + + # 截断:limit=2 → 统计仍全量、truncated=True、只回 2 条 + body = admin_client.get( + "/admin/api/ad-coin-audit", params={**base, "limit": 2}, headers=_auth(admin_token) + ).json() + assert body["total"] == 5 and body["mismatch_count"] == 2 + assert body["truncated"] is True + assert len(body["items"]) == 2 + + # only_mismatch:只回 ✗ 行(2 条),统计仍全量、不截断 + body = admin_client.get( + "/admin/api/ad-coin-audit", + params={**base, "limit": 100, "only_mismatch": True}, + headers=_auth(admin_token), + ).json() + assert body["total"] == 5 and body["mismatch_count"] == 2 + assert body["truncated"] is False + assert len(body["items"]) == 2 + assert all(it["matched"] is False for it in body["items"]) + + def test_read_apis_require_auth(admin_client: TestClient) -> None: """所有 M2 读接口未带 token → 401(router 级 get_current_admin 守卫)。""" for path in [ diff --git a/tests/test_admin_write.py b/tests/test_admin_write.py index 67bc719..bd8cd8e 100644 --- a/tests/test_admin_write.py +++ b/tests/test_admin_write.py @@ -136,6 +136,102 @@ def test_grant_zero_rejected(admin_client: TestClient, finance_token: str) -> No assert r.status_code == 400 +def test_set_coins_writes_delta_txn(admin_client: TestClient, finance_token: str) -> None: + """set 模式:先有余额,再设为目标值,只写一笔差值流水,审计带 mode/target/before。""" + uid = _seed_user("13900000009") + # 先增到 300 + admin_client.post( + f"/admin/api/users/{uid}/coins", json={"amount": 300, "reason": "底"}, + headers=_auth(finance_token), + ) + # 设为 100 → 差值 -200(扣减) + r = admin_client.post( + f"/admin/api/users/{uid}/coins", json={"mode": "set", "amount": 100, "reason": "设值"}, + headers=_auth(finance_token), + ) + assert r.status_code == 200, r.text + db = SessionLocal() + try: + assert db.get(CoinAccount, uid).coin_balance == 100 + txns = db.execute( + select(CoinTransaction).where(CoinTransaction.user_id == uid) + .order_by(CoinTransaction.id.desc()) + ).scalars().all() + # 两笔:+300(admin_grant)、-200(admin_deduct) + assert txns[0].amount == -200 and txns[0].biz_type == "admin_deduct" + logs = db.execute( + select(AdminAuditLog).where( + AdminAuditLog.action == "user.coins.grant", AdminAuditLog.target_id == str(uid) + ).order_by(AdminAuditLog.id.desc()) + ).scalars().all() + assert logs[0].detail["mode"] == "set" + assert logs[0].detail["target"] == 100 + assert logs[0].detail["before"] == 300 + assert logs[0].detail["amount"] == -200 + finally: + db.close() + + +def test_set_coins_equal_balance_rejected(admin_client: TestClient, finance_token: str) -> None: + """set 为当前余额(差值 0)→ 拒绝,不写流水。""" + uid = _seed_user("13900000010") + admin_client.post( + f"/admin/api/users/{uid}/coins", json={"amount": 50, "reason": "底"}, + headers=_auth(finance_token), + ) + r = admin_client.post( + f"/admin/api/users/{uid}/coins", json={"mode": "set", "amount": 50, "reason": "x"}, + headers=_auth(finance_token), + ) + assert r.status_code == 400 + db = SessionLocal() + try: + txns = db.execute( + select(CoinTransaction).where(CoinTransaction.user_id == uid) + ).scalars().all() + assert len(txns) == 1 # 仅初始那笔,设值被拒后无新流水 + finally: + db.close() + + +def test_set_coins_negative_target_rejected(admin_client: TestClient, finance_token: str) -> None: + uid = _seed_user("13900000011") + r = admin_client.post( + f"/admin/api/users/{uid}/coins", json={"mode": "set", "amount": -1, "reason": "x"}, + headers=_auth(finance_token), + ) + assert r.status_code == 400 + + +def test_set_cash_writes_delta_txn(admin_client: TestClient, finance_token: str) -> None: + """现金 set 模式:设为目标分值,写一笔差值流水。""" + uid = _seed_user("13900000012") + admin_client.post( + f"/admin/api/users/{uid}/cash", json={"amount_cents": 500, "reason": "底"}, + headers=_auth(finance_token), + ) + r = admin_client.post( + f"/admin/api/users/{uid}/cash", + json={"mode": "set", "amount_cents": 200, "reason": "设值"}, + headers=_auth(finance_token), + ) + assert r.status_code == 200, r.text + db = SessionLocal() + try: + assert db.get(CoinAccount, uid).cash_balance_cents == 200 + logs = db.execute( + select(AdminAuditLog).where( + AdminAuditLog.action == "user.cash.grant", AdminAuditLog.target_id == str(uid) + ).order_by(AdminAuditLog.id.desc()) + ).scalars().all() + assert logs[0].detail["mode"] == "set" + assert logs[0].detail["target_cents"] == 200 + assert logs[0].detail["before_cents"] == 500 + assert logs[0].detail["amount_cents"] == -300 + finally: + db.close() + + # ===== 封号 ===== def test_set_user_status_and_audit(admin_client: TestClient, operator_token: str) -> None: From 8659a7ed2bad733eb2b08020ae7e63fa9bcf0720 Mon Sep 17 00:00:00 2001 From: marco Date: Sun, 14 Jun 2026 01:24:29 +0800 Subject: [PATCH 04/36] =?UTF-8?q?feat(store=5Fmapping):=20=E8=B7=A8?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E5=BA=97=E9=93=BA=E6=98=A0=E5=B0=84=E8=A1=A8?= =?UTF-8?q?=20+=20pricebot=20=E5=86=85=E9=83=A8=E4=B8=8A=E6=8A=A5=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=20(#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增「平台店铺表」资产层: 淘宝比价拿到 shopId 后, pricebot server→server 把跨平台 店铺身份(各平台 id/名 + 地理 + 来源)落库, 作为未来"我见过这家店→跳过重搜/匹配"的源头。 与 price_observation(价格事实)平行、独立。 - models/store_mapping.py: store_mapping 表 22 列 —— 跨平台身份(id/name_taobao/meituan/jd) + 地理(city/geohash/lng/lat/taobao_address) + 溯源(source_platform/trace_id/device/user) + 淘宝原料(share_url/resolved_url/deeplink) + attrs(JSONB) + created_at。 - schemas/store_mapping.py + repositories/store_mapping.py: append-only, trace_id 幂等 (pricebot 重试/replay 不重复写; 并发 IntegrityError 兜底返已存在行), 跨方言安全。 - api/internal/store.py: POST /internal/store-mapping(复用 price.py 共享密钥 X-Internal-Secret 校验)。 - 注册 model(__init__) + router(main.py); 迁移 store_mapping_table 接现 head coin_txn_task_ref_uq。 验证: alembic 单 head + 零模型/迁移漂移; TestClient 全链(无密钥401/有密钥inserted=1/幂等inserted=0/错密钥401)。 Co-Authored-By: Claude Fable 5 Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/48 --- alembic/versions/store_mapping_jd_cols.py | 40 ++++ .../versions/store_mapping_meituan_cols.py | 39 ++++ alembic/versions/store_mapping_table.py | 77 ++++++++ app/api/internal/store.py | 79 ++++++++ app/main.py | 4 +- app/models/__init__.py | 1 + app/models/store_mapping.py | 117 ++++++++++++ app/repositories/store_mapping.py | 172 ++++++++++++++++++ app/schemas/store_mapping.py | 62 +++++++ 9 files changed, 590 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/store_mapping_jd_cols.py create mode 100644 alembic/versions/store_mapping_meituan_cols.py create mode 100644 alembic/versions/store_mapping_table.py create mode 100644 app/api/internal/store.py create mode 100644 app/models/store_mapping.py create mode 100644 app/repositories/store_mapping.py create mode 100644 app/schemas/store_mapping.py diff --git a/alembic/versions/store_mapping_jd_cols.py b/alembic/versions/store_mapping_jd_cols.py new file mode 100644 index 0000000..02acf32 --- /dev/null +++ b/alembic/versions/store_mapping_jd_cols.py @@ -0,0 +1,40 @@ +"""store_mapping 加京东原料列(jd_vender_id + 分享链/反查 URL/deeplink) + +Revision ID: store_mapping_jd_cols +Revises: store_mapping_meituan_cols +Create Date: 2026-06-13 00:00:00.000000 + +京东秒送接入店内搜索 deeplink 链路: 3.cn 短链 → 跟随重定向反查 venderId+storeId → +openapp.jdmobile:// deeplink。京东店铺身份是**两个**稳定数字 id: storeId 进 id_jd(稳定 +店主键, 同 taobao 的 shopId→id_taobao), venderId 进单列 jd_vender_id(deeplink 还需它)。 +其余三列与 taobao_*/meituan_* 原料列平行。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'store_mapping_jd_cols' +down_revision: Union[str, Sequence[str], None] = 'store_mapping_meituan_cols' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('store_mapping', schema=None) as batch_op: + batch_op.add_column(sa.Column('jd_vender_id', sa.String(length=64), nullable=True)) + batch_op.add_column(sa.Column('jd_share_url', sa.String(length=256), nullable=True)) + batch_op.add_column(sa.Column('jd_resolved_url', sa.Text(), nullable=True)) + batch_op.add_column(sa.Column('jd_deeplink', sa.Text(), nullable=True)) + batch_op.create_index(batch_op.f('ix_store_mapping_jd_vender_id'), ['jd_vender_id'], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table('store_mapping', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_store_mapping_jd_vender_id')) + batch_op.drop_column('jd_deeplink') + batch_op.drop_column('jd_resolved_url') + batch_op.drop_column('jd_share_url') + batch_op.drop_column('jd_vender_id') diff --git a/alembic/versions/store_mapping_meituan_cols.py b/alembic/versions/store_mapping_meituan_cols.py new file mode 100644 index 0000000..e9494a2 --- /dev/null +++ b/alembic/versions/store_mapping_meituan_cols.py @@ -0,0 +1,39 @@ +"""store_mapping 加美团原料列(poi_id_str + 分享链/反查 URL/deeplink) + +Revision ID: store_mapping_meituan_cols +Revises: store_mapping_table +Create Date: 2026-06-13 00:00:00.000000 + +美团接入店内搜索 deeplink 链路: dpurl.cn 短链 → 302 反查 poi_id_str → imeituan:// deeplink。 +poi_id_str 每次分享重新加密、非稳定主键, 单列 meituan_poi_id_str 存(不占 id_meituan, +后者留给将来 CPS API 的稳定数字 poi_id)。其余三列与 taobao_* 原料列平行。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'store_mapping_meituan_cols' +down_revision: Union[str, Sequence[str], None] = 'store_mapping_table' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('store_mapping', schema=None) as batch_op: + batch_op.add_column(sa.Column('meituan_poi_id_str', sa.String(length=64), nullable=True)) + batch_op.add_column(sa.Column('meituan_share_url', sa.String(length=256), nullable=True)) + batch_op.add_column(sa.Column('meituan_resolved_url', sa.Text(), nullable=True)) + batch_op.add_column(sa.Column('meituan_deeplink', sa.Text(), nullable=True)) + batch_op.create_index(batch_op.f('ix_store_mapping_meituan_poi_id_str'), ['meituan_poi_id_str'], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table('store_mapping', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_store_mapping_meituan_poi_id_str')) + batch_op.drop_column('meituan_deeplink') + batch_op.drop_column('meituan_resolved_url') + batch_op.drop_column('meituan_share_url') + batch_op.drop_column('meituan_poi_id_str') diff --git a/alembic/versions/store_mapping_table.py b/alembic/versions/store_mapping_table.py new file mode 100644 index 0000000..7e3e709 --- /dev/null +++ b/alembic/versions/store_mapping_table.py @@ -0,0 +1,77 @@ +"""store_mapping table (平台店铺表:跨平台同店 id/名 映射,server 侧无条件落库) + +Revision ID: store_mapping_table +Revises: coin_txn_task_ref_uq +Create Date: 2026-06-13 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'store_mapping_table' +down_revision: Union[str, Sequence[str], None] = 'coin_txn_task_ref_uq' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'store_mapping', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + # 跨平台身份 + sa.Column('id_taobao', sa.String(length=64), nullable=True), + sa.Column('name_taobao', sa.String(length=128), nullable=True), + sa.Column('id_meituan', sa.String(length=64), nullable=True), + sa.Column('name_meituan', sa.String(length=128), nullable=True), + sa.Column('id_jd', sa.String(length=64), nullable=True), + sa.Column('name_jd', sa.String(length=128), nullable=True), + # 地理 + sa.Column('city', sa.String(length=64), nullable=True), + sa.Column('geohash', sa.String(length=16), nullable=True), + sa.Column('lng', sa.Float(), nullable=True), + sa.Column('lat', sa.Float(), nullable=True), + sa.Column('taobao_address', sa.String(length=256), nullable=True), + # 溯源 + sa.Column('source_platform', sa.String(length=32), nullable=True), + sa.Column('business_type', sa.String(length=16), nullable=False), + sa.Column('trace_id', sa.String(length=64), nullable=False), + sa.Column('source_device_id', sa.String(length=64), nullable=True), + sa.Column('source_user_id', sa.Integer(), nullable=True), + # 淘宝原料(URL 可能很长 → Text) + sa.Column('taobao_share_url', sa.String(length=256), nullable=True), + sa.Column('taobao_resolved_url', sa.Text(), nullable=True), + sa.Column('taobao_deeplink', sa.Text(), nullable=True), + # PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐 + sa.Column('attrs', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id'), + # 一次比价一行,trace_id 幂等去重(防 pricebot 重试 / replay 重复写) + sa.UniqueConstraint('trace_id', name='uq_store_mapping_trace'), + ) + with op.batch_alter_table('store_mapping', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_store_mapping_id_taobao'), ['id_taobao'], unique=False) + batch_op.create_index(batch_op.f('ix_store_mapping_id_meituan'), ['id_meituan'], unique=False) + batch_op.create_index(batch_op.f('ix_store_mapping_id_jd'), ['id_jd'], unique=False) + batch_op.create_index(batch_op.f('ix_store_mapping_geohash'), ['geohash'], unique=False) + batch_op.create_index(batch_op.f('ix_store_mapping_source_platform'), ['source_platform'], unique=False) + batch_op.create_index(batch_op.f('ix_store_mapping_source_device_id'), ['source_device_id'], unique=False) + batch_op.create_index(batch_op.f('ix_store_mapping_source_user_id'), ['source_user_id'], unique=False) + batch_op.create_index(batch_op.f('ix_store_mapping_created_at'), ['created_at'], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table('store_mapping', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_store_mapping_created_at')) + batch_op.drop_index(batch_op.f('ix_store_mapping_source_user_id')) + batch_op.drop_index(batch_op.f('ix_store_mapping_source_device_id')) + batch_op.drop_index(batch_op.f('ix_store_mapping_source_platform')) + batch_op.drop_index(batch_op.f('ix_store_mapping_geohash')) + batch_op.drop_index(batch_op.f('ix_store_mapping_id_jd')) + batch_op.drop_index(batch_op.f('ix_store_mapping_id_meituan')) + batch_op.drop_index(batch_op.f('ix_store_mapping_id_taobao')) + + op.drop_table('store_mapping') diff --git a/app/api/internal/store.py b/app/api/internal/store.py new file mode 100644 index 0000000..916398a --- /dev/null +++ b/app/api/internal/store.py @@ -0,0 +1,79 @@ +"""平台店铺映射内部上报端点(pricebot → app-server)。 + +pricebot 在淘宝比价拿到 shopId 后,把这一行跨平台店铺映射 POST 到这里落库。 +**不是给客户端的接口**:不走用户 JWT,靠 server 间共享密钥头 `X-Internal-Secret` 校验 +(复用 price.py 的 _check_secret,与 price-observation 同一密钥)。 + +与 price.py 的 /internal/price-observation 平行:那个落价格事实,这个落店铺身份映射。 +""" +from __future__ import annotations + +import logging + +from typing import Annotated + +from fastapi import APIRouter, Header + +from app.api.deps import DbSession +from app.api.internal.price import _check_secret +from app.repositories import store_mapping as repo +from app.schemas.store_mapping import StoreMappingIn, StoreMappingOut + +logger = logging.getLogger("shagua.internal.store") + +router = APIRouter(prefix="/internal", tags=["internal"]) + + +@router.get( + "/store-mapping/lookup", + summary="比价前按源平台店名反查各目标平台已沉淀的店铺 id(命中→pricebot 直接 deeplink)", +) +def lookup_store_mapping( + source_platform: str, + name: str, + db: DbSession, + lat: float | None = None, + lng: float | None = None, + x_internal_secret: Annotated[str | None, Header()] = None, +) -> dict: + _check_secret(x_internal_secret) + result = repo.lookup_nearest(db, source_platform, name, lat, lng) + if result: + hits = ", ".join( + f"{t}:row{v['row_id']}" + f"{'(' + str(v['dist_km']) + 'km)' if 'dist_km' in v else ''}" + f"→{v.get('deeplink') or '(无deeplink)'}" + for t, v in result.items() + ) + logger.info( + "store_mapping lookup source=%s name=%r geo=(%s,%s) → 命中 %s", + source_platform, name, lat, lng, hits, + ) + else: + logger.info( + "store_mapping lookup source=%s name=%r geo=(%s,%s) → MISS", + source_platform, name, lat, lng, + ) + return result + + +@router.post( + "/store-mapping", + response_model=StoreMappingOut, + summary="平台店铺映射内部上报(pricebot→app-server,落 store_mapping)", +) +def report_store_mapping( + payload: StoreMappingIn, + db: DbSession, + x_internal_secret: Annotated[str | None, Header()] = None, +) -> StoreMappingOut: + _check_secret(x_internal_secret) + created, row_id = repo.upsert(db, payload) + logger.info( + "store_mapping trace=%s %s row_id=%s source=%s " + "taobao=(%s,%s) jd=(%s,%s) device=%s user=%s", + payload.trace_id, "新建" if created else "合并", row_id, payload.source_platform, + payload.id_taobao, payload.name_taobao, payload.id_jd, payload.name_jd, + payload.source_device_id, payload.source_user_id, + ) + return StoreMappingOut(inserted=created, row_id=row_id) diff --git a/app/main.py b/app/main.py index fe296f2..cea4568 100644 --- a/app/main.py +++ b/app/main.py @@ -21,6 +21,7 @@ from app.api.v1.compare_milestone import router as compare_milestone_router from app.api.v1.compare_record import router as compare_record_router from app.api.v1.coupon import router as coupon_router from app.api.internal.price import router as internal_price_router +from app.api.internal.store import router as internal_store_router from app.api.v1.feedback import router as feedback_router from app.api.v1.invite import router as invite_router from app.api.v1.meituan import router as meituan_router @@ -102,8 +103,9 @@ app.include_router(savings_router) app.include_router(ad_router) app.include_router(order_router) app.include_router(report_router) -# 内部(server→server)端点:pricebot 上报价格观测,靠共享密钥头校验,不对客户端开放。 +# 内部(server→server)端点:pricebot 上报价格观测 / 店铺映射,靠共享密钥头校验,不对客户端开放。 app.include_router(internal_price_router) +app.include_router(internal_store_router) app.include_router(platform_router) # 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。 diff --git a/app/models/__init__.py b/app/models/__init__.py index 4ed0d1b..a089a8a 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -23,6 +23,7 @@ from app.models.price_observation import PriceObservation # noqa: F401 from app.models.price_report import PriceReport # noqa: F401 from app.models.savings import SavingsRecord # noqa: F401 from app.models.signin import SigninBoostRecord, SigninRecord # noqa: F401 +from app.models.store_mapping import StoreMapping # noqa: F401 from app.models.task import UserTask # noqa: F401 from app.models.user import User # noqa: F401 from app.models.wallet import ( # noqa: F401 diff --git a/app/models/store_mapping.py b/app/models/store_mapping.py new file mode 100644 index 0000000..a76f8aa --- /dev/null +++ b/app/models/store_mapping.py @@ -0,0 +1,117 @@ +"""平台店铺表(store_mapping)—— 跨平台"同一家店"的 id/名 映射资产层。 + +每完成一次淘宝比价(在目标淘宝店通过 更多操作→分享→复制链接 拿到分享短链、 +HTTP 解析出 shopId 后),pricebot server→server 内部上报落这里一行。**与登录无关、 +不依赖客户端鉴权**(比价透传链路当前不鉴权,user_id 客户端带上时一并记)。 + +与 price_observation 的区别: +- price_observation:平台/门店视角的**价格事实**(某店这单多少钱)。 +- store_mapping:平台/门店视角的**身份映射**(同一家物理店在 淘宝/美团/京东 各自的 + 店铺 id 与店名)。是未来"我见过这家店→跳过重新搜索/匹配"的源头。两表独立。 + +「先存下来、用法后说」:列尽量铺全(各平台 id/名 + 地理 + 溯源 + 淘宝/美团原料 URL), +attrs(JSONB)兜底存灵活明细,免得每多记一个字段就迁移 schema。 + +⚠️ 数据质量:跨平台"同一家店"的连接来自 agent 的 LLM 店铺匹配,匹配错则一行里连错店。 +本表是 append-only 原始记录(每比价一行、trace_id 幂等防重试重复),清洗/归一二期再做。 +已接通**淘宝**(id_taobao=shopId)、**美团**(meituan_poi_id_str,非稳定主键、单列存)、 +**京东**(id_jd=storeId + jd_vender_id,均稳定数字主键;3.cn 短链反查)。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import ( + JSON, + DateTime, + Float, + Integer, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + +# PG 上用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 price_observation / comparison_record)。 +_JSON = JSON().with_variant(JSONB(), "postgresql") + + +class StoreMapping(Base): + __tablename__ = "store_mapping" + __table_args__ = ( + # 一次比价(trace)只记一条:pricebot 重试 / 客户端 replay 重复上报时幂等去重。 + # 一次淘宝比价 = 一个目标淘宝店 → 一行映射(源 + 各平台身份压在同一行)。 + UniqueConstraint("trace_id", name="uq_store_mapping_trace"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + # ===== 跨平台店铺身份(同一家物理店在各平台的 id/名;按比价角色稀疏填充)===== + # id_taobao = 分享短链解析出的 shopId(淘宝当目标、走完取 id 流程才有); + # name_taobao = 店铺页 a11y content_desc "店铺标题:xxx" 剥前缀。 + id_taobao: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + name_taobao: Mapped[str | None] = mapped_column(String(128), nullable=True) + # 美团:无同款 share→稳定id 机制(poi_id_str 每次变,见下方 meituan_poi_id_str),id_meituan + # 留给将来 CPS API 的稳定 poi_id;name-only 时仅 name_meituan(源平台店名来自 intent/agent 匹配名)。 + id_meituan: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + name_meituan: Mapped[str | None] = mapped_column(String(128), nullable=True) + # 京东:已接通 share→id(3.cn 短链反查)。id_jd = storeId(门店稳定数字主键,同 taobao shopId→ + # id_taobao);venderId(deeplink 还需)单列存 jd_vender_id。name_jd = 店铺页店名。 + id_jd: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + name_jd: Mapped[str | None] = mapped_column(String(128), nullable=True) + + # ===== 地理(同名店异地区分 / 地理分桶匹配的主要燃料)===== + city: Mapped[str | None] = mapped_column(String(64), nullable=True) + geohash: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True) + lng: Mapped[float | None] = mapped_column(Float, nullable=True) + lat: Mapped[float | None] = mapped_column(Float, nullable=True) + # 淘宝门店地址(店铺页 a11y 抓到才有;比经纬度更利于人工/LLM 匹配) + taobao_address: Mapped[str | None] = mapped_column(String(256), nullable=True) + + # ===== 溯源 / 用户画像 ===== + # 源平台(发起比价那家:meituan / taobao_flash / jd_waimai ...) + source_platform: Mapped[str | None] = mapped_column(String(32), index=True, nullable=True) + business_type: Mapped[str] = mapped_column(String(16), nullable=False, default="food") + # pricebot 侧 trace_id:回指原始 trace(溯源)+ 幂等去重键(uq_store_mapping_trace) + trace_id: Mapped[str] = mapped_column(String(64), nullable=False) + source_device_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + # user_id 当前比价链路不鉴权拿不到,客户端带上时才有;先可空。 + source_user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True) + + # ===== 淘宝原料(可复跳 / 可重解析 / 调试;URL 可能很长 → Text)===== + taobao_share_url: Mapped[str | None] = mapped_column(String(256), nullable=True) # m.tb.cn 短链 + taobao_resolved_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 解析出的目标 URL(含 shopId) + taobao_deeplink: Mapped[str | None] = mapped_column(Text, nullable=True) # 拼好的 et-store/search deeplink + + # ===== 美团原料(同淘宝;dpurl.cn 短链 → 302 反查 poi_id_str → imeituan:// deeplink)===== + # ⚠️ poi_id_str 每次分享重新加密、非稳定主键(调研文档 §八), 故单列存"可复跳的一次性票据", + # 不进 id_meituan —— 后者留给将来 CPS API 拿到的稳定数字 poi_id。 + meituan_poi_id_str: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + meituan_share_url: Mapped[str | None] = mapped_column(String(256), nullable=True) # dpurl.cn 短链 + meituan_resolved_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 302 落地 menu URL(含 poi_id_str) + meituan_deeplink: Mapped[str | None] = mapped_column(Text, nullable=True) # 拼好的 imeituan:// 店内搜索 deeplink + + # ===== 京东原料(秒送;3.cn 短链 → 跟随重定向反查 venderId+storeId → openapp.jdmobile:// deeplink)===== + # storeId 进 id_jd(稳定店主键);venderId 单列存(deeplink 模板 venderId+storeId 都要,且 venderId + # 是商家维度、可跨门店,与门店 storeId 分开记)。其余三列与 taobao_*/meituan_* 平行。 + jd_vender_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + jd_share_url: Mapped[str | None] = mapped_column(String(256), nullable=True) # 3.cn 短链 + jd_resolved_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 反查出的目标 openapp.jdmobile:// deeplink + jd_deeplink: Mapped[str | None] = mapped_column(Text, nullable=True) # 拼好的 pages/search 店内搜索 deeplink + + # 灵活字段兜底(免得加字段就迁移) + attrs: 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 + ) + + def __repr__(self) -> str: # pragma: no cover + return ( + f"" + ) diff --git a/app/repositories/store_mapping.py b/app/repositories/store_mapping.py new file mode 100644 index 0000000..8ebc9fc --- /dev/null +++ b/app/repositories/store_mapping.py @@ -0,0 +1,172 @@ +"""平台店铺映射落库:一次比价(trace)一行,各目标平台解析出 id 就 upsert 进同一行。 + +合并键 = trace_id(唯一约束)。一次跨平台比价 = 一个 trace = 一个真实店铺:淘宝腿解析出 +shopId 先建行(填淘宝列),京东腿(将来)解析出 id 再 upsert 进**同一行**(填京东列)。 + +合并策略 = 填空(fill-the-blanks):只写该行当前为 NULL 的列,绝不覆盖已有非空值。保证后到 +的平台只填自己那几列、动不了先到平台的数据;共享列(geo / source / 溯源)先到先得。 +不依赖 ON CONFLICT,跨方言(PG / SQLite dev)都安全;并发撞唯一约束则回滚后转走合并路径。 + +⚠️ 一行里 id_taobao 与 id_jd 共存只表示"两条腿搜同一个源店名各自匹配到了某家店",是 +name-match 置信度、非已核实同一实体。作为 append-only 原始资产留存,跨平台精确匹配由下游做。 +""" +from __future__ import annotations + +import logging +import math + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.models.store_mapping import StoreMapping +from app.schemas.store_mapping import StoreMappingIn + +logger = logging.getLogger("shagua.store_mapping") + +# 源平台 → 该平台店名所在列(缓存查询的匹配键)。镜像 pricebot reporter 的同名表。 +_SOURCE_NAME_COLUMN = { + "meituan": "name_meituan", + "meituan_waimai": "name_meituan", + "jd_waimai": "name_jd", + "jd_waimai_standalone": "name_jd", + "taobao_flash": "name_taobao", +} + +# 源平台 → 它自己对应的目标 key(查缓存时排除"源平台自己", 不会复用源平台的店铺 id)。 +_SOURCE_TARGET_KEY = { + "meituan": "meituan", "meituan_waimai": "meituan", + "jd_waimai": "jd", "jd_waimai_standalone": "jd", + "taobao_flash": "taobao", +} + +# upsert 填空时可写的列。不含: trace_id(合并键)/ business_type(非空默认)/ +# id(主键)/ created_at(server_default)。各平台只会带自己那几列非空, 其余为 None 不动。 +_MERGE_COLUMNS = ( + "source_platform", + "id_taobao", "name_taobao", "id_meituan", "name_meituan", "id_jd", "name_jd", + "city", "geohash", "lng", "lat", "taobao_address", + "source_device_id", "source_user_id", + "taobao_share_url", "taobao_resolved_url", "taobao_deeplink", + "meituan_poi_id_str", "meituan_share_url", "meituan_resolved_url", "meituan_deeplink", + "jd_vender_id", "jd_share_url", "jd_resolved_url", "jd_deeplink", + "attrs", +) + + +def _find(db: Session, trace_id: str) -> StoreMapping | None: + return db.execute( + select(StoreMapping).where(StoreMapping.trace_id == trace_id) + ).scalar_one_or_none() + + +def _merge_fill_blanks(existing: StoreMapping, payload: StoreMappingIn) -> list[str]: + """把 payload 里非空、且 existing 当前为 NULL 的列填进去。返回被填的列名(空=无变化)。""" + filled: list[str] = [] + for col in _MERGE_COLUMNS: + new = getattr(payload, col) + if new is not None and getattr(existing, col) is None: + setattr(existing, col, new) + filled.append(col) + return filled + + +def upsert(db: Session, payload: StoreMappingIn) -> tuple[int, int | None]: + """落一行跨平台店铺映射,返回 (created, row_id)。 + created=1 新建该 trace 的行 / 0 合并进已存在行(填空,不覆盖)。""" + existing = _find(db, payload.trace_id) + if existing is None: + # 首写: 把 payload 全部可合并列灌进去(逐列 setattr 而非硬编码构造器, 否则首写的是 + # 美团/京东腿时它们的列会漏 —— 不在硬编码列表里就丢)。trace_id/business_type 是键/默认, 显式给。 + row = StoreMapping( + trace_id=payload.trace_id, + business_type=payload.business_type, + ) + for col in _MERGE_COLUMNS: + setattr(row, col, getattr(payload, col)) + db.add(row) + try: + db.commit() + except IntegrityError: + # 并发: 另一个请求刚插了同 trace → 撞唯一约束。回滚后转合并路径填空。 + db.rollback() + existing = _find(db, payload.trace_id) + if existing is None: + raise + logger.warning("store_mapping 并发冲突 trace=%s, 转填空合并", payload.trace_id) + else: + db.refresh(row) + return 1, row.id + + # 已存在(或并发回退到此): 填空合并, 只写当前 NULL 的列 + filled = _merge_fill_blanks(existing, payload) + if filled: + db.commit() + db.refresh(existing) + logger.info("store_mapping 合并 trace=%s 填列=%s", payload.trace_id, filled) + return 0, existing.id + + +# ============================================================ +# 缓存查询: 比价前按"源平台店名"反查已沉淀的各目标平台店铺 id, 命中就让 pricebot 直接 +# deeplink 跳店内搜索, 省掉"开平台→进店→分享反查"整段。 +# ============================================================ + +def _haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float: + """两点球面距离(km)。仅用于同名候选里挑最近, 精度够用。""" + r = 6371.0 + p1, p2 = math.radians(lat1), math.radians(lat2) + dp = math.radians(lat2 - lat1) + dl = math.radians(lng2 - lng1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * r * math.asin(math.sqrt(a)) + + +def _pick_best(rows: list[StoreMapping], lat: float | None, lng: float | None) -> StoreMapping: + """同名 + 含目标 id 的候选里挑一条:有入参 geo 且有候选带 geo → 取最近;否则取 created_at 最新。""" + if lat is not None and lng is not None: + geod = [r for r in rows if r.lat is not None and r.lng is not None] + if geod: + return min(geod, key=lambda r: _haversine_km(lat, lng, r.lat, r.lng)) + return max(rows, key=lambda r: r.created_at) + + +# 目标 key → (该平台店铺 id 列, 组装返回 payload 的函数)。pricebot 拿 id 现拼 deeplink。 +_TARGETS = { + "taobao": ("id_taobao", lambda r: {"shop_id": r.id_taobao, "deeplink": r.taobao_deeplink}), + "jd": ("id_jd", lambda r: {"store_id": r.id_jd, "vender_id": r.jd_vender_id, "deeplink": r.jd_deeplink}), + "meituan": ("meituan_poi_id_str", lambda r: {"poi_id_str": r.meituan_poi_id_str, "deeplink": r.meituan_deeplink}), +} + + +def lookup_nearest( + db: Session, source_platform: str, store_name: str, + lat: float | None = None, lng: float | None = None, +) -> dict: + """按"源平台店名"反查各目标平台已沉淀的店铺 id。返回 {target_key: {id..., deeplink, row_id, ...}}。 + - 匹配键 = 源平台对应的 name 列 == store_name(精确)。 + - 每个目标**分别**取"含该目标 id 的同名候选里最近一条"(淘宝 id / 京东 id 可能在不同行)。 + - 排除源平台自己(不复用源平台的店铺 id)。命中为空 = 没缓存, pricebot 走现场反查老路。""" + name_col = _SOURCE_NAME_COLUMN.get(source_platform) + if not name_col or not store_name: + return {} + rows = db.execute( + select(StoreMapping).where(getattr(StoreMapping, name_col) == store_name) + ).scalars().all() + if not rows: + return {} + src_key = _SOURCE_TARGET_KEY.get(source_platform) + out: dict = {} + for tgt, (id_attr, make_payload) in _TARGETS.items(): + if tgt == src_key: + continue # 不返回源平台自己 + cands = [r for r in rows if getattr(r, id_attr)] + if not cands: + continue + best = _pick_best(cands, lat, lng) + payload = make_payload(best) + payload["row_id"] = best.id + if best.lat is not None and best.lng is not None and lat is not None and lng is not None: + payload["dist_km"] = round(_haversine_km(lat, lng, best.lat, best.lng), 3) + out[tgt] = payload + return out diff --git a/app/schemas/store_mapping.py b/app/schemas/store_mapping.py new file mode 100644 index 0000000..2d84c75 --- /dev/null +++ b/app/schemas/store_mapping.py @@ -0,0 +1,62 @@ +"""平台店铺映射内部上报的收发模型。 + +pricebot 在淘宝比价拿到 shopId 后 server→server POST 一行映射。所有字段可空(按比价 +角色稀疏填充),server 端只校验共享密钥 + 幂等(trace_id)落库。 +""" +from __future__ import annotations + +from pydantic import BaseModel + + +class StoreMappingIn(BaseModel): + """一次比价的跨平台店铺映射上报体(一行)。""" + + trace_id: str + business_type: str = "food" + source_platform: str | None = None + + # 跨平台身份(按角色稀疏填充) + id_taobao: str | None = None + name_taobao: str | None = None + id_meituan: str | None = None + name_meituan: str | None = None + id_jd: str | None = None + name_jd: str | None = None + + # 地理 + city: str | None = None + geohash: str | None = None + lng: float | None = None + lat: float | None = None + taobao_address: str | None = None + + # 溯源 + source_device_id: str | None = None + source_user_id: int | None = None + + # 淘宝原料(可复跳 / 可重解析 / 调试) + taobao_share_url: str | None = None + taobao_resolved_url: str | None = None + taobao_deeplink: str | None = None + + # 美团原料(poi_id_str 非稳定主键, 单列存; 见 model 注释) + meituan_poi_id_str: str | None = None + meituan_share_url: str | None = None + meituan_resolved_url: str | None = None + meituan_deeplink: str | None = None + + # 京东原料(venderId 单列存, storeId 进 id_jd; 见 model 注释) + jd_vender_id: str | None = None + jd_share_url: str | None = None + jd_resolved_url: str | None = None + jd_deeplink: str | None = None + + attrs: dict | None = None + + +class StoreMappingOut(BaseModel): + """上报结果。inserted=1 为新建该 trace 行,0 为合并进已存在行(填空,不覆盖); + row_id 为该 trace 对应行 id。""" + + inserted: int + row_id: int | None = None From 47812f7fccd0c17f87518bb9e0517149d3a4c214 Mon Sep 17 00:00:00 2001 From: liujiahui Date: Sun, 14 Jun 2026 04:16:52 +0800 Subject: [PATCH 05/36] =?UTF-8?q?=E6=AF=94=E4=BB=B7=E7=BB=93=E7=AE=97?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=20schema=20+=20comparison=20=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 改动 - 支撑客户端比价结算页:补 `compare_record` schema 字段 + `comparison` 模型调整 - docs/database 索引合并(领券三表 + onboarding_completion,表数对齐 28) ## 验证 - 模型/schema parse OK;已 merge origin/main 解 OVERVIEW/README 文档冲突(取并集) 🤖 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/44 Co-authored-by: liujiahui Co-committed-by: liujiahui --- app/models/comparison.py | 2 +- app/schemas/compare_record.py | 11 +++ docs/database/OVERVIEW.md | 20 +++++- docs/database/README.md | 14 ++-- docs/database/coupon_state.md | 123 ++++++++++++++++++++++++++++++++++ 5 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 docs/database/coupon_state.md diff --git a/app/models/comparison.py b/app/models/comparison.py index 0dbde5e..f521bc8 100644 --- a/app/models/comparison.py +++ b/app/models/comparison.py @@ -94,7 +94,7 @@ class ComparisonRecord(Base): # ===== 明细(JSON,越详细越好)===== # 下单菜品 [{name, qty, specs?}] items: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) - # 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名) + # 逐平台对比 [{platform_id, platform_name, package, price, is_source, rank, coupon_saved, coupon_name, applied_coupons}](price/coupon_saved 单位:元,原样存;coupon_name=优惠来源名;applied_coupons=[{name,amount}] 多券明细) comparison_results: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) # 目标平台未找到、跳过的菜名 skipped_dish_names: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py index 2a6ba5f..ce8a4a0 100644 --- a/app/schemas/compare_record.py +++ b/app/schemas/compare_record.py @@ -24,6 +24,13 @@ class ComparisonItemIn(BaseModel): specs: list[str] | None = None +class AppliedCouponIn(BaseModel): + """单笔已用优惠(来自 comparison_results[].applied_coupons)。amount 单位:元、正数。""" + + name: str + amount: float + + class ComparisonResultIn(BaseModel): """逐平台对比项(来自 done.params.comparison_results)。price 单位:元。""" @@ -40,6 +47,10 @@ class ComparisonResultIn(BaseModel): # 优惠**来源名**(展示用, best-effort): 美团"外卖大额神券"/京东"百亿补贴"/淘宝"平台红包"。 # None=没抠到 → 前端走通用"红包"。同样必须显式声明否则上报边界被 pydantic 静默丢弃(pricebot#38 引入)。 coupon_name: str | None = None + # 多券明细 [{name, amount}](全口径: 平台红包+商家券+满减+配送减免, amount 单位元正数)。 + # 跟 coupon_saved 并存, 是更丰富的明细; 空=没抠到 → 前端回退单券路径。 + # 必须显式声明: 落库走 model_dump(), pydantic 默认丢未知字段, 不声明这行会被悄悄吞掉。 + applied_coupons: list[AppliedCouponIn] = Field(default_factory=list) class ComparisonRecordIn(BaseModel): diff --git a/docs/database/OVERVIEW.md b/docs/database/OVERVIEW.md index 5e298d4..04ae153 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、**无关系库**。共 **23 张业务表** + `alembic_version`(框架的迁移版本指针)。 +> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **28 张业务表** + `alembic_version`(框架的迁移版本指针)。领券联动的「今日状态」三张表(`coupon_*`)同理:领券过程在 pricebot 内存态跑、**不落库**,只有结果回到 app-server 才落这三张表。 --- @@ -17,6 +17,14 @@ | profile「累计省了 / 省钱战绩 / 省钱明细」 | [`savings_record`](./savings_record.md) | 真实下单归因(source=compare)+ 无真实数据时 demo 兜底 | | 「上报更低价」提交 / 列表 | [`price_report`](./price_report.md) | 众包纠偏:用户举证某平台更便宜,人工审核发奖 | +### 领券(每日领券联动 · 今日状态) +| App 位置 / 动作 | 表 | 说明 | +|---|---|---| +| 领券**过程**(看屏→领券) | (无) | 在 pricebot-backend 内存态跑,**过程不落库**;结果回 app-server 才落下面三张表 | +| 切外卖 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;当前**不参与**判断 | + ### 钱包 / 福利(看广告赚钱闭环) | App 位置 / 动作 | 表 | 说明 | |---|---|---| @@ -76,6 +84,13 @@ | 首次进 profile 省钱页且无真实记录 | `savings_record`(C `source=demo`) | 懒种子,`ensure_seeded` 按 user 幂等 | | 上报更低价 `POST /report` | `price_report`(C) | 读 `comparison_record.best_price_cents` 校验 | | 提交反馈 `POST /feedback` | `feedback`(C) | | +| 领券首帧 `POST /api/v1/coupon/step`(step=0) | `coupon_prompt_engagement`(C/U `claim_started`) | `(device_id, 北京日)` 幂等;best-effort | +| 领券每帧结果 `POST /api/v1/coupon/step` | `coupon_claim_record`(C/U) | `(device_id, coupon_id, 北京日)` 幂等;best-effort | +| 领券跑完 `POST /api/v1/coupon/step`(action.command=done) | `coupon_daily_completion`(C/U) | `(device_id, 北京日)` 幂等;best-effort | +| 拒绝领券引导窗 `POST /api/v1/coupon/prompt/dismiss` | `coupon_prompt_engagement`(C/U `dismissed`) | 同上;客户端通知(透传链路看不到拒绝) | +| 重置今日弹窗 `POST /api/v1/coupon/prompt/reset`(开发) | `coupon_prompt_engagement`(**D** 今日条) | 删后今天又能弹 | + +> 领券三表写库**全 best-effort**:`/coupon/step` 里写失败只 `logger.warning`、不连累领券返回;**判断只看 `device_id`**,`user_id` 可空旁路(资产留痕)。 ### admin 端(管理员触发,均额外写一条 `admin_audit_log`) | 后台操作 | 写入 | 操作 | @@ -122,6 +137,7 @@ - **`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))。 - **`onboarding_completion.(user_id, device_id)`**:`user_id` 语义关联 `user.id`(无硬 FK,同 `coupon_*` 设备表),`device_id` = 客户端硬件级 `ANDROID_ID`(≠ 领券 per-install `device_id`)。登录读、走完引导写,决定是否再展示新手引导。 ### ER 关系(文字版) @@ -136,6 +152,8 @@ user ─1:N─ onboarding_completion (user_id, 无硬 FK; (user_id,d comparison_record ─1:N─ price_report (comparison_record_id, 可空) 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 仅软关联) ``` --- diff --git a/docs/database/README.md b/docs/database/README.md index c42b59d..24f91ad 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -3,16 +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-10(新增 `onboarding_completion` 新手引导完成表 → 23 张业务表) -======= - +> 最后更新:2026-06-11(合并:新增 3 张领券今日状态表 `coupon_*` + `onboarding_completion` 新手引导完成表;含 [OVERVIEW 总览](./OVERVIEW.md)) > 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。 --- -## 表总览(23 张业务表 + `alembic_version` 框架表) +## 表总览(28 张业务表 + `alembic_version` 框架表) ### 账号 / 反馈 | 表 | 用途 | 模型 | 文档 | @@ -45,6 +42,13 @@ | `savings_record` | 省钱记录(profile 省钱战绩源;真实下单归因 + demo) | `models/savings.py` | [详情](./savings_record.md) | | `price_report` | 上报更低价(众包纠偏,人工审核发奖) | `models/price_report.py` | [详情](./price_report.md) | +### 领券(每日领券联动 · 今日状态) +| 表 | 用途 | 模型 | 文档 | +|---|---|---|---| +| `coupon_prompt_engagement` | 领券引导窗频控源(今日是否已 engage,按 device+日) | `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) | + ### 美团 CPS 券缓存 | 表 | 用途 | 模型 | 文档 | |---|---|---|---| diff --git a/docs/database/coupon_state.md b/docs/database/coupon_state.md new file mode 100644 index 0000000..956f227 --- /dev/null +++ b/docs/database/coupon_state.md @@ -0,0 +1,123 @@ +# 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) + +领券(优惠券自动化)联动产生的三张「今日状态」表,都挂在领券透传端点 `POST /api/v1/coupon/step` 这条链路上(pricebot 跑领券,结果回 app-server 落库;**领券过程本身在 pricebot 内存态跑、不落库**)。三表各管一件事: + +- **`coupon_prompt_engagement`** — 弹窗频控源。按 `(device, 自然日)` 记「今天是否对领券引导窗表达过**意向**」(点「一键领取」=`claim_started` / 点拒绝关闭=`dismissed` 都算)。切到外卖 App 时据此决定弹不弹:今天 engage 过就不再弹。 +- **`coupon_daily_completion`** — 首页置灰源。按 `(device, 自然日)` 记「今天是否已**跑完整轮**领券(到 done 帧)」。首页「去领取」卡据此置灰:今天跑完了就不能再领。 +- **`coupon_claim_record`** — 资产沉淀层。按 `(device, 券, 自然日)` 记每张券的领取结果(success/already_claimed/failed/skipped),**纯沉淀**(资产/画像/排查/CPS 归因),当前**不参与**「要不要领 / 弹不弹」的判断。 + +三表共同口径: +- **判断维度是 `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` 可空**:领券登录态有就记(资产/画像),可空、**不进唯一键、不阻塞判断**。 +- **`trace_id` 可空**:回指 pricebot work_logs,供排查(哪次任务领的)。 +- **engagement vs completion vs claim 的区别**:engagement = 用户**表达过意向**(点了领或拒,不管跑没跑完);completion = 这一轮**真跑到了 done**(整套流程走完);claim = **每张券一条**的结果留痕。 + +> 写库全部 **best-effort**:在 `/coupon/step` 里写库失败只 `logger.warning`、**绝不连累领券主流程/返回**(见 `app/api/v1/coupon.py`)。 + +--- + +## coupon_prompt_engagement — 弹窗频控(今日是否已对引导窗表达意向) + +`(device_id, engage_date)` 唯一,一台设备一天一条;今天 engage 过(领或拒)就不再弹。 + +### 用在哪 / 增删改查 +- **C / U(幂等 upsert)**:`mark_engagement`。两条触发: + - `POST /api/v1/coupon/step` 且 `step==0`(领券首帧=用户已发起领券)→ 记 `claim_started`; + - `POST /api/v1/coupon/prompt/dismiss`(用户点关闭引导窗;server 在透传链路看不到「拒绝」,必须客户端通知)→ 记 `dismissed`。 + - 已有今天那条则覆盖 `engage_type`(并补 `user_id`),否则插入。 +- **D**:`POST /api/v1/coupon/prompt/reset`(`reset_today_engagement`)—— 删这台设备今天那条,开发设置「重置今日领券弹窗状态」按钮调,测频控用;删后今天又能弹。 +- **R**:`GET /api/v1/coupon/prompt/should-show?device_id=…`(`has_engaged_today`)→ `should_show = not 今天已 engage`。客户端切外卖 App 前查,纯后台判据。 + +### 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `device_id` | String(64) | NOT NULL | 判断/聚合维度;客户端 `getOrCreateDeviceId`,重装会变 | +| `user_id` | Integer | index, 可空 | 登录态有就记(资产);不进唯一键、不阻塞判断 | +| `engage_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`) | +| `engage_type` | String(16) | NOT NULL | `claim_started`(点一键领取)/ `dismissed`(点拒绝关闭);仅记录区分,**判断只看「今天有没有这条」,type 不影响弹不弹** | +| `created_at` | DateTime(tz) | server_default now() | | +| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | | + +### 索引与约束 +- PK `id`;index `user_id`;UNIQUE(`device_id`, `engage_date`) = `uq_coupon_engage_device_date`(一台设备一天一条)。 + +### 注意 +- `device_id` 重装会变 → 重装当新设备,今天重新弹一次(产品预期)。 +- 判断只看「今天这台设备有没有这条」,不看 `engage_type`(领或拒都算 engage 过、都不再弹)。 + +--- + +## coupon_daily_completion — 首页置灰(今日是否已跑完整轮领券) + +`(device_id, complete_date)` 唯一,一台设备一天一条;今天跑完整轮(到 done 帧)就把首页「去领取」卡置灰。 + +### 用在哪 / 增删改查 +- **C / U(幂等 upsert)**:`mark_completed_today`,由 `POST /api/v1/coupon/step` 在 pricebot 返回 `action.command == "done"` 那帧调。pricebot 把中途单券 done 改写成 `wait+continue=true`,只有整套全跑完那帧才保留 `command=="done"`,故 **done 已等价「整轮完成」**。已有今天那条则补 `user_id`/`trace_id`,否则插入。 +- **U / D**:无业务删除。 +- **R**:`GET /api/v1/coupon/completed-today?device_id=…`(`has_completed_today`)→ `completed`。客户端据此把首页「去领取」卡置灰、不可点。 + +### 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `device_id` | String(64) | NOT NULL | 判断维度,与 engagement/claim 一致;客户端两端都用 ANDROID_ID | +| `user_id` | Integer | index, 可空 | 登录态有就记(资产) | +| `complete_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`) | +| `trace_id` | String(64) | index, 可空 | 哪次任务跑到 done,回指 pricebot work_logs / 排查 | +| `created_at` | DateTime(tz) | server_default now() | | +| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | | + +### 索引与约束 +- PK `id`;index `user_id`、`trace_id`;UNIQUE(`device_id`, `complete_date`) = `uq_coupon_completion_device_date`(一台设备一天一条)。 + +### 注意 +- 口径(用户决策 2026-06-10 A 方案):**到 done 即算完成,不管单券成败**——失败/跳过常是无障碍/环境问题,重复点也补不回来。 +- 与 engagement 区别:engagement 是「表达过意向」(点了就记,不管跑没跑完);completion 是「真跑到了 done」。 + +--- + +## coupon_claim_record — 领券记录(每张券一天一条,资产沉淀层) + +`(device_id, coupon_id, claim_date)` 唯一,同设备同券同一天只一条;纯沉淀,**当前不参与判断**,留作以后按券去重 / CPS 归因 / 用户画像的数据源。 + +### 用在哪 / 增删改查 +- **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**:**当前无读取端点**(纯写入沉淀,未来做去重/归因/画像时再用)。 + +### 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `device_id` | String(64) | NOT NULL | 聚合维度;客户端 `getOrCreateDeviceId`,重装会变 | +| `user_id` | Integer | index, 可空 | 登录态有就记(资产/画像);不进唯一键 | +| `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 结果) | +| `vendor` | String(48) | 可空 | 券提供方 | +| `coupon_name` | String(128) | 可空 | 取 pricebot `name` | +| `claimed_count` | Integer | 可空 | 这张领到几张(pricebot `display_count`,给不出时 None;兼容 `claimed_count`) | +| `trace_id` | String(64) | index, 可空 | 哪次任务领的,回指 pricebot work_logs / 排查 | +| `reason` | String(255) | 可空 | failed / skipped 原因 | +| `extra` | JSON(PG JSONB) | 可空 | 杂项兜底:券的结构化信息(面额/入口/关键节点摘要等),免得加字段就迁移;当前直接存这帧 pricebot 单券结果 dict | +| `created_at` | DateTime(tz) | server_default now() | | +| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | | + +### 索引与约束 +- PK `id`;index `user_id`、`trace_id`;UNIQUE(`device_id`, `coupon_id`, `claim_date`) = `uq_coupon_claim_device_coupon_date`;Index(`device_id`, `claim_date`) = `ix_coupon_claim_device_date`(按 device+日 取一天所有券)。 + +### 注意 +- **`extra` 别塞原始无障碍树**(几十 KB → 行膨胀);原始大树看 `trace_id` 指过去的 work_logs。 +- 同批去重很关键:`autoflush=False` 下同 `coupon_id` 两次 `add` 会撞唯一约束、`IntegrityError` 回滚整批(done/单券记录全丢),故端点 `_extract_coupon_results` + 仓库 `seen` 集合双重防御。 +- `extra` 是 `JSON().with_variant(JSONB(), "postgresql")`:PG 用 JSONB(可建 GIN 索引),SQLite 退化通用 JSON(同 `price_observation` / `comparison_record`)。 + +--- + +## 三表共性小结 +- 数据流向:客户端 → `POST /api/v1/coupon/step`(透传给 pricebot)→ 结果回写这三张表(best-effort,写库失败不影响领券)。 +- 唯一键都含 `device_id` + 某个北京自然日列;`user_id` 永远是可空旁路(资产留痕,不进唯一键、不阻塞判断)。 +- 无硬外键:`user_id` 软指 `user.id`、`trace_id` 软指 pricebot work_logs(详见 [OVERVIEW → 表间关系 & Join Key](./OVERVIEW.md))。 From 27f76918b2e1dfc3d937f9c73b54ea981acb167e Mon Sep 17 00:00:00 2001 From: ouzhou Date: Sun, 14 Jun 2026 22:54:07 +0800 Subject: [PATCH 06/36] =?UTF-8?q?feat(admin):=20=E8=BF=90=E8=90=A5?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E8=B7=9F=E8=BF=9B=E2=80=94=E2=80=94review=20?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA=20+=20=E5=8F=8D=E9=A6=88=E6=94=B9=E7=89=88?= =?UTF-8?q?=20+=20=E5=88=97=E8=A1=A8=E9=A1=B5=E7=A0=81=E5=88=86=E9=A1=B5?= =?UTF-8?q?=20(#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本 PR 汇合三块运营后台改动(原 #50 仅含其中「加固」一块,已并入本 PR 并关闭)。 ## 1. review 加固 (436b2a3) - 超管防自锁:降级/禁用最后一个 active super_admin 前校验,杜绝零超管死局 - 时间筛选统一 tz-aware(列为 timestamptz),比较绝对时刻、不依赖 DB 会话时区 - 上报审核 / 调余额(set·扣减)加行锁,防并发/连点重复发钱 - ad_audit 复算排序补 id 次级键;health-check 限 finance;调账/拒绝 reason 去空白校验 ## 2. 反馈改版 (5a18dbb) - contact 可选、截图≤6;admin 反馈列表筛选/排序;admin·wallet 接口调整 + docs ## 3. 列表页码分页 (1a7a624) - CursorPage 加 total;新增 offset_paginate(count 与分页同源) - 上报/审计日志从 id 游标改 offset 分页(支持跳页) - 用户 / 提现 / 上报 / 审计日志 四页接入页码分页 测试:admin 套件 47 passed。前端配套改动见 shaguabijia-admin-web。 --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/51 Co-authored-by: ouzhou Co-committed-by: ouzhou --- app/admin/repositories/ad_audit.py | 6 +- app/admin/repositories/audit_log.py | 25 +++--- app/admin/repositories/queries.py | 129 ++++++++++++++++++---------- app/admin/routers/admins.py | 29 +++++-- app/admin/routers/audit.py | 6 +- app/admin/routers/feedback.py | 19 +++- app/admin/routers/price_report.py | 12 ++- app/admin/routers/users.py | 19 ++-- app/admin/routers/withdraw.py | 15 +++- app/admin/schemas/common.py | 7 +- app/admin/schemas/price_report.py | 10 ++- app/admin/schemas/user.py | 13 ++- app/api/v1/feedback.py | 9 +- app/core/config.py | 30 ++++++- app/core/rewards.py | 13 ++- app/models/feedback.py | 5 +- app/repositories/ad_feed_reward.py | 14 ++- app/repositories/wallet.py | 12 ++- docs/api/admin-feedbacks-list.md | 16 ++-- docs/api/feedback.md | 8 +- docs/database/feedback.md | 6 +- tests/test_admin.py | 7 +- 22 files changed, 295 insertions(+), 115 deletions(-) diff --git a/app/admin/repositories/ad_audit.py b/app/admin/repositories/ad_audit.py index 064b036..e08ae68 100644 --- a/app/admin/repositories/ad_audit.py +++ b/app/admin/repositories/ad_audit.py @@ -50,7 +50,7 @@ def _reward_video_rows( AdRewardRecord.reward_date == date, AdRewardRecord.reward_scene == "reward_video", ) - .order_by(AdRewardRecord.user_id, AdRewardRecord.created_at) + .order_by(AdRewardRecord.user_id, AdRewardRecord.created_at, AdRewardRecord.id) ) if user_id is not None: stmt = stmt.where(AdRewardRecord.user_id == user_id) @@ -127,7 +127,7 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: stmt = ( select(AdFeedRewardRecord) .where(AdFeedRewardRecord.reward_date == date) - .order_by(AdFeedRewardRecord.user_id, AdFeedRewardRecord.created_at) + .order_by(AdFeedRewardRecord.user_id, AdFeedRewardRecord.created_at, AdFeedRewardRecord.id) ) if user_id is not None: stmt = stmt.where(AdFeedRewardRecord.user_id == user_id) @@ -205,7 +205,7 @@ def ad_coin_audit( rows.extend(_reward_video_rows(db, date=date, user_id=user_id)) if scene in (None, "feed"): rows.extend(_feed_rows(db, date=date, user_id=user_id)) - rows.sort(key=lambda r: r["created_at"], reverse=True) + rows.sort(key=lambda r: (r["created_at"], r["record_id"]), reverse=True) total = len(rows) mismatch_count = sum(1 for r in rows if not r["matched"]) diff --git a/app/admin/repositories/audit_log.py b/app/admin/repositories/audit_log.py index 8b41bf4..7eec119 100644 --- a/app/admin/repositories/audit_log.py +++ b/app/admin/repositories/audit_log.py @@ -4,7 +4,7 @@ """ from __future__ import annotations -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session from app.models.admin import AdminAuditLog @@ -49,8 +49,9 @@ def list_audit_logs( admin_id: int | None = None, limit: int = 50, cursor: int | None = None, -) -> tuple[list[AdminAuditLog], int | None]: - """游标分页(id 倒序),与现有 list_* 约定一致。返回 (rows, next_cursor)。""" +) -> tuple[list[AdminAuditLog], int | None, int]: + """offset 分页(id 倒序)+ total。cursor 即 offset((page-1)*pageSize),支持页码跳页。 + 返回 (rows, next_cursor, total)。""" stmt = select(AdminAuditLog) if action: stmt = stmt.where(AdminAuditLog.action == action) @@ -58,13 +59,15 @@ def list_audit_logs( stmt = stmt.where(AdminAuditLog.target_type == target_type) if admin_id is not None: stmt = stmt.where(AdminAuditLog.admin_id == admin_id) - if cursor is not None: - stmt = stmt.where(AdminAuditLog.id < cursor) - stmt = stmt.order_by(AdminAuditLog.id.desc()) - rows = list(db.execute(stmt.limit(limit + 1)).scalars().all()) + + total = int(db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one()) + offset = max(cursor or 0, 0) + rows = list( + db.execute( + stmt.order_by(AdminAuditLog.id.desc()).offset(offset).limit(limit + 1) + ).scalars().all() + ) has_more = len(rows) > limit items = rows[:limit] - # next_cursor 必须是"本页返回的最后一条"的 id(下一页查 id < 它),不能用 rows[limit]—— - # rows[limit] 是探测下一页用的第 limit+1 条,它既不在本页也不在下页 → 每页边界丢一条。 - next_cursor = items[-1].id if has_more else None - return items, next_cursor + next_cursor = offset + limit if has_more else None + return items, next_cursor, total diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index d7293f0..a4721e0 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -38,6 +38,28 @@ def cursor_paginate( return items, next_cursor +def offset_paginate( + db: Session, stmt: Select, sort_clause: tuple, *, limit: int, cursor: int | None +) -> tuple[list, int | None, int]: + """offset 分页 + 总数。stmt 只含 where/join,不要预先带 order_by/offset/limit。 + + cursor 即 offset(页码分页:offset=(page-1)*pageSize)。返回 (items, next_cursor, total): + - total:符合筛选条件的总条数(供 antd pagination 渲染页码/共 N 条),count 在 P0 量级开销可忽略; + - next_cursor:下一页 offset(兼容「加载更多」),末页为 None。 + 多取 1 条探测下一页。sort_clause 为 order_by 表达式元组(末位应含 id 保证稳定排序)。""" + total = int( + db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one() + ) + offset = max(cursor or 0, 0) + rows = list( + db.execute(stmt.order_by(*sort_clause).offset(offset).limit(limit + 1)).scalars().all() + ) + has_more = len(rows) > limit + items = rows[:limit] + next_cursor = offset + limit if has_more else None + return items, next_cursor, total + + def list_users( db: Session, *, @@ -53,11 +75,11 @@ def list_users( sort_order: str = "desc", limit: int = 20, cursor: int | None = None, -) -> tuple[list[User], int | None]: +) -> tuple[list[User], int | None, int]: """用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录时间范围筛选, 按 id·注册时间·最近登录排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一, 代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。 - 日期入参统一转 UTC naive 比较(User 时间均为 UTC naive,见 _as_utc_naive)。""" + 日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。""" stmt = select(User) if phone: stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配 @@ -68,13 +90,13 @@ def list_users( if nickname and nickname.strip(): stmt = stmt.where(User.nickname.ilike(f"%{nickname.strip()}%")) if created_from is not None: - stmt = stmt.where(User.created_at >= _as_utc_naive(created_from)) + stmt = stmt.where(User.created_at >= _as_utc(created_from)) if created_to is not None: - stmt = stmt.where(User.created_at <= _as_utc_naive(created_to)) + stmt = stmt.where(User.created_at <= _as_utc(created_to)) if last_login_from is not None: - stmt = stmt.where(User.last_login_at >= _as_utc_naive(last_login_from)) + 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_naive(last_login_to)) + stmt = stmt.where(User.last_login_at <= _as_utc(last_login_to)) sort_cols = { "id": User.id, @@ -84,14 +106,7 @@ def list_users( 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) - stmt = stmt.order_by(order_fn(sort_col), id_order) - - offset = max(cursor or 0, 0) - rows = list(db.execute(stmt.offset(offset).limit(limit + 1)).scalars().all()) - has_more = len(rows) > limit - items = rows[:limit] - next_cursor = offset + limit if has_more else None - return items, next_cursor + return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor) def list_onboarding_devices(db: Session, *, limit: int = 500) -> list[dict]: @@ -160,7 +175,7 @@ def list_all_withdraw_orders( quick_filter: str | None = None, limit: int = 20, cursor: int | None = None, -) -> tuple[list[WithdrawOrder], int | None]: +) -> tuple[list[WithdrawOrder], int | None, int]: stmt = select(WithdrawOrder) needs_user_join = bool(keyword and keyword.strip()) or quick_filter == "high_risk" if needs_user_join: @@ -190,16 +205,16 @@ def list_all_withdraw_orders( date_col = WithdrawOrder.updated_at if date_field == "updated_at" else WithdrawOrder.created_at if date_from is not None: - stmt = stmt.where(date_col >= _as_utc_naive(date_from)) + stmt = stmt.where(date_col >= _as_utc(date_from)) if date_to is not None: - stmt = stmt.where(date_col <= _as_utc_naive(date_to)) + stmt = stmt.where(date_col <= _as_utc(date_to)) - now = datetime.now(timezone.utc).replace(tzinfo=None) + # tz-aware:列为 timestamptz,比较绝对时刻、与 DB 会话时区无关(同 _as_utc / stats.py) + now = datetime.now(timezone.utc) today_start = ( datetime.now(ZoneInfo("Asia/Shanghai")) .replace(hour=0, minute=0, second=0, microsecond=0) .astimezone(timezone.utc) - .replace(tzinfo=None) ) if quick_filter == "abnormal": stmt = stmt.where( @@ -244,6 +259,53 @@ def list_all_withdraw_orders( sort_col = sort_cols.get(sort_by, WithdrawOrder.created_at) order_fn = asc if sort_order == "asc" else desc id_order = asc(WithdrawOrder.id) if sort_order == "asc" else desc(WithdrawOrder.id) + return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor) + + +def _as_utc(value: datetime) -> datetime: + """前端传 ISO 时间 → 统一成 tz-aware UTC 再比较。 + + 所有时间列均为 `DateTime(timezone=True)`(Postgres timestamptz);用 tz-aware 绑定参数 + 比较的是绝对时刻,与 DB 会话时区无关、恒正确。曾用 naive UTC,正确性依赖会话 TimeZone=UTC, + 生产会话非 UTC 时筛选边界会整体偏移——故统一 tz-aware(与 stats.py / withdraw_summary 一致)。 + 无时区入参按 UTC 解释。""" + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def list_feedbacks( + db: Session, + *, + status: str | None = None, + user_id: int | None = None, + content: str | None = None, + created_from: datetime | None = None, + created_to: datetime | None = None, + sort_by: str = "id", + sort_order: str = "desc", + limit: int = 20, + cursor: int | None = None, +) -> tuple[list[Feedback], int | None]: + """反馈工单列表。支持 状态 / 用户ID / 内容模糊 / 提交时间范围 筛选,按 id·提交时间排序。 + **offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),代价是翻页期间 + 数据变动可能错位一条——admin 低频场景可接受。created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。""" + stmt = select(Feedback) + if status: + stmt = stmt.where(Feedback.status == status) + if user_id is not None: + stmt = stmt.where(Feedback.user_id == user_id) + if content and content.strip(): + stmt = stmt.where(Feedback.content.ilike(f"%{content.strip()}%")) + if created_from is not None: + stmt = stmt.where(Feedback.created_at >= _as_utc(created_from)) + if created_to is not None: + stmt = stmt.where(Feedback.created_at <= _as_utc(created_to)) + + sort_cols = {"id": Feedback.id, "created_at": Feedback.created_at} + sort_col = sort_cols.get(sort_by, Feedback.id) + order_fn = asc if sort_order == "asc" else desc + id_order = asc(Feedback.id) if sort_order == "asc" else desc(Feedback.id) stmt = stmt.order_by(order_fn(sort_col), id_order) offset = max(cursor or 0, 0) @@ -254,29 +316,6 @@ def list_all_withdraw_orders( return items, next_cursor -def _as_utc_naive(value: datetime) -> datetime: - """前端传 ISO 时间;DB 当前按 UTC naive 比较最稳(SQLite/本地开发一致)。""" - if value.tzinfo is None: - return value - return value.astimezone(timezone.utc).replace(tzinfo=None) - - -def list_feedbacks( - db: Session, - *, - status: str | None = None, - user_id: int | None = None, - limit: int = 20, - cursor: int | None = None, -) -> tuple[list[Feedback], int | None]: - stmt = select(Feedback) - if status: - stmt = stmt.where(Feedback.status == status) - if user_id is not None: - stmt = stmt.where(Feedback.user_id == user_id) - return cursor_paginate(db, stmt, Feedback.id, limit=limit, cursor=cursor) - - def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder | None: """按商户单号查提现单(admin 重试打款先拿 user_id 用,M3)。""" return db.execute( @@ -463,14 +502,14 @@ def list_price_reports( user_id: int | None = None, limit: int = 20, cursor: int | None = None, -) -> tuple[list[PriceReport], int | None]: - """上报更低价列表(admin 全量,可按状态/用户筛)。游标同 feedback:id 倒序。""" +) -> tuple[list[PriceReport], int | None, int]: + """上报更低价列表(admin 全量,可按状态/用户筛)。offset 分页 + total,id 倒序。""" stmt = select(PriceReport) if status: stmt = stmt.where(PriceReport.status == status) if user_id is not None: stmt = stmt.where(PriceReport.user_id == user_id) - return cursor_paginate(db, stmt, PriceReport.id, limit=limit, cursor=cursor) + return offset_paginate(db, stmt, (PriceReport.id.desc(),), limit=limit, cursor=cursor) def price_report_summary(db: Session) -> dict: diff --git a/app/admin/routers/admins.py b/app/admin/routers/admins.py index c3b8483..9f7ac15 100644 --- a/app/admin/routers/admins.py +++ b/app/admin/routers/admins.py @@ -17,6 +17,12 @@ router = APIRouter( ) +def _active_super_count(db: AdminDb) -> int: + return sum( + 1 for a in admin_repo.list_admins(db) if a.role == "super_admin" and a.status == "active" + ) + + @router.get("", response_model=list[AdminOut], summary="管理员列表") def list_admins(db: AdminDb) -> list[AdminOut]: return [AdminOut.model_validate(a) for a in admin_repo.list_admins(db)] @@ -48,16 +54,29 @@ def update_admin( if admin_id == admin.id and body.status == "disabled": raise HTTPException(status_code=400, detail="不能禁用自己") + # 防自锁:降级 / 禁用某个 super_admin 前,确认操作后仍至少剩 1 个 active super_admin, + # 否则会进入「零可用超管」死局——本路由仅 super 可进,只能改库恢复。 + demotes_super = ( + target.role == "super_admin" + and target.status == "active" + and ( + (body.role is not None and body.role != "super_admin") + or body.status == "disabled" + ) + ) + if demotes_super and _active_super_count(db) <= 1: + raise HTTPException(status_code=400, detail="不能降级/禁用最后一个超级管理员") + changes: dict = {} - if body.role is not None: + if body.role is not None and body.role != target.role: + changes["role"] = {"before": target.role, "after": body.role} target.role = body.role - changes["role"] = body.role - if body.status is not None: + if body.status is not None and body.status != target.status: + changes["status"] = {"before": target.status, "after": body.status} target.status = body.status - changes["status"] = body.status if body.password is not None: - target.password_hash = hash_password(body.password) changes["password"] = "reset" + target.password_hash = hash_password(body.password) if not changes: raise HTTPException(status_code=400, detail="无任何变更字段") db.commit() diff --git a/app/admin/routers/audit.py b/app/admin/routers/audit.py index 580e03c..dd7cace 100644 --- a/app/admin/routers/audit.py +++ b/app/admin/routers/audit.py @@ -26,9 +26,11 @@ def list_audit_logs( limit: Annotated[int, Query(ge=1, le=100)] = 50, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[AdminAuditLogOut]: - items, next_cursor = audit_repo.list_audit_logs( + items, next_cursor, total = audit_repo.list_audit_logs( db, action=action, target_type=target_type, admin_id=admin_id, limit=limit, cursor=cursor, ) return CursorPage( - items=[AdminAuditLogOut.model_validate(x) for x in items], next_cursor=next_cursor, + items=[AdminAuditLogOut.model_validate(x) for x in items], + next_cursor=next_cursor, + total=total, ) diff --git a/app/admin/routers/feedback.py b/app/admin/routers/feedback.py index 23f21de..51a2a29 100644 --- a/app/admin/routers/feedback.py +++ b/app/admin/routers/feedback.py @@ -1,6 +1,7 @@ -"""admin 反馈工单:列表(读)+ 标记已处理(写,带审计)。""" +"""admin 反馈工单:列表(读,支持 状态/用户ID/内容/时间 筛选 + 排序)+ 标记已处理(写,带审计)。""" from __future__ import annotations +from datetime import datetime from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, Request @@ -25,11 +26,25 @@ def list_feedbacks( db: AdminDb, status: Annotated[str | None, Query()] = None, user_id: Annotated[int | None, Query()] = None, + content: Annotated[str | None, Query(max_length=100)] = None, + created_from: Annotated[datetime | None, Query()] = None, + created_to: Annotated[datetime | None, Query()] = None, + sort_by: Annotated[str, Query(pattern="^(id|created_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, ) -> CursorPage[FeedbackOut]: items, next_cursor = queries.list_feedbacks( - db, status=status, user_id=user_id, limit=limit, cursor=cursor, + db, + status=status, + user_id=user_id, + content=content, + created_from=created_from, + created_to=created_to, + sort_by=sort_by, + sort_order=sort_order, + limit=limit, + cursor=cursor, ) return CursorPage( items=[FeedbackOut.model_validate(f) for f in items], next_cursor=next_cursor, diff --git a/app/admin/routers/price_report.py b/app/admin/routers/price_report.py index b29f271..8e04b6d 100644 --- a/app/admin/routers/price_report.py +++ b/app/admin/routers/price_report.py @@ -40,11 +40,13 @@ def list_price_reports( limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[PriceReportOut]: - items, next_cursor = queries.list_price_reports( + items, next_cursor, total = queries.list_price_reports( db, status=status, user_id=user_id, limit=limit, cursor=cursor, ) return CursorPage( - items=[PriceReportOut.model_validate(r) for r in items], next_cursor=next_cursor, + items=[PriceReportOut.model_validate(r) for r in items], + next_cursor=next_cursor, + total=total, ) @@ -60,7 +62,9 @@ def approve_price_report( admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> OkResponse: - rep = db.get(PriceReport, report_id) + # 行锁(SELECT FOR UPDATE):并发/连点双请求会都读到 pending → 各发一次金币双倍发奖, + # 锁住该行串行化,第二个请求拿锁后看到 approved → 走 400。SQLite 下 FOR UPDATE 为 no-op。 + rep = db.get(PriceReport, report_id, with_for_update=True) if rep is None: raise HTTPException(status_code=404, detail="上报记录不存在") if rep.status != "pending": @@ -88,7 +92,7 @@ def reject_price_report( admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> OkResponse: - rep = db.get(PriceReport, report_id) + rep = db.get(PriceReport, report_id, with_for_update=True) # 行锁,同 approve(防并发重复审核) if rep is None: raise HTTPException(status_code=404, detail="上报记录不存在") if rep.status != "pending": diff --git a/app/admin/routers/users.py b/app/admin/routers/users.py index e992e20..c631b5d 100644 --- a/app/admin/routers/users.py +++ b/app/admin/routers/users.py @@ -45,7 +45,7 @@ def list_users( limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[AdminUserListItem]: - items, next_cursor = queries.list_users( + items, next_cursor, total = queries.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, @@ -54,6 +54,7 @@ def list_users( return CursorPage( items=[AdminUserListItem.model_validate(u) for u in items], next_cursor=next_cursor, + total=total, ) @@ -126,7 +127,8 @@ def grant_user_coins( if body.mode == "set": if body.amount < 0: raise HTTPException(status_code=400, detail="目标金币值不能为负") - before = wallet_repo.get_or_create_account(db, user_id, commit=False).coin_balance + # lock=True:锁账户行,防连点/并发各读同一 before 算同一 delta 双写,余额错位 + before = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True).coin_balance delta = body.amount - before if delta == 0: raise HTTPException(status_code=400, detail=f"当前金币已为 {body.amount},无需调整") @@ -134,9 +136,9 @@ def grant_user_coins( if body.amount == 0: raise HTTPException(status_code=400, detail="amount 不能为 0") delta = body.amount - # 负数扣减时不允许扣成负余额(运营误操作保护) + # 负数扣减时不允许扣成负余额(运营误操作保护);lock=True 防并发扣穿 if delta < 0: - acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False) + acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True) if acc_now.coin_balance + delta < 0: raise HTTPException( status_code=400, detail=f"扣减后金币为负(当前余额 {acc_now.coin_balance})" @@ -174,7 +176,10 @@ def grant_user_cash( if body.mode == "set": if body.amount_cents < 0: raise HTTPException(status_code=400, detail="目标现金值不能为负") - before = wallet_repo.get_or_create_account(db, user_id, commit=False).cash_balance_cents + # lock=True:锁账户行,防连点/并发各读同一 before 算同一 delta 双写,余额错位 + before = wallet_repo.get_or_create_account( + db, user_id, commit=False, lock=True + ).cash_balance_cents delta = body.amount_cents - before if delta == 0: raise HTTPException( @@ -184,9 +189,9 @@ def grant_user_cash( if body.amount_cents == 0: raise HTTPException(status_code=400, detail="amount_cents 不能为 0") delta = body.amount_cents - # 负数扣减时不允许扣成负余额(运营误操作保护) + # 负数扣减时不允许扣成负余额(运营误操作保护);lock=True 防并发扣穿 if delta < 0: - acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False) + acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True) if acc_now.cash_balance_cents + delta < 0: raise HTTPException( status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents} 分)" diff --git a/app/admin/routers/withdraw.py b/app/admin/routers/withdraw.py index 7fcfff6..732159f 100644 --- a/app/admin/routers/withdraw.py +++ b/app/admin/routers/withdraw.py @@ -63,7 +63,7 @@ def list_withdraws( limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[WithdrawOrderOut]: - items, next_cursor = queries.list_all_withdraw_orders( + items, next_cursor, total = queries.list_all_withdraw_orders( db, user_id=user_id, status=status, @@ -78,7 +78,9 @@ def list_withdraws( cursor=cursor, ) return CursorPage( - items=[WithdrawOrderOut.model_validate(o) for o in items], next_cursor=next_cursor, + items=[WithdrawOrderOut.model_validate(o) for o in items], + next_cursor=next_cursor, + total=total, ) @@ -87,7 +89,12 @@ def withdraws_summary(db: AdminDb) -> WithdrawSummaryOut: return WithdrawSummaryOut(**queries.withdraw_summary(db)) -@router.get("/health-check", response_model=WxpayHealthCheckOut, summary="提现配置健康检查") +@router.get( + "/health-check", + response_model=WxpayHealthCheckOut, + summary="提现配置健康检查", + dependencies=[Depends(require_role("finance"))], # 暴露密钥路径/配置,限财务+super +) def withdraw_health_check() -> WxpayHealthCheckOut: private_path = wxpay._resolve_config_path(settings.WXPAY_MCH_PRIVATE_KEY_PATH) # noqa: SLF001 public_path = wxpay._resolve_config_path(settings.WXPAY_PUBLIC_KEY_PATH) # noqa: SLF001 @@ -160,7 +167,7 @@ def withdraw_detail(out_bill_no: str, db: AdminDb) -> WithdrawDetailOut: withdraw_success_cents=overview["withdraw_success_cents"], ) - recent_withdraws, _ = queries.list_all_withdraw_orders( + recent_withdraws, _, _ = queries.list_all_withdraw_orders( db, user_id=order.user_id, limit=5, cursor=None, ) recent_cash_transactions, _ = queries.list_all_cash_transactions( diff --git a/app/admin/schemas/common.py b/app/admin/schemas/common.py index 6987eeb..9f283c6 100644 --- a/app/admin/schemas/common.py +++ b/app/admin/schemas/common.py @@ -9,10 +9,15 @@ T = TypeVar("T") class CursorPage(BaseModel, Generic[T]): - """游标分页响应:items + 下一页游标(next_cursor=None 表示末页)。""" + """分页响应:items + 下一页游标(next_cursor=None 表示末页)+ 可选 total。 + + next_cursor:offset 分页时即下一页 offset,「加载更多」用;末页为 None。 + total:符合筛选条件的总条数,页码分页(antd pagination)用;不需要总数的接口可不传(None)。 + """ items: list[T] next_cursor: int | None = None + total: int | None = None class OkResponse(BaseModel): diff --git a/app/admin/schemas/price_report.py b/app/admin/schemas/price_report.py index 30ea8ad..ddf3278 100644 --- a/app/admin/schemas/price_report.py +++ b/app/admin/schemas/price_report.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator class PriceReportOut(BaseModel): @@ -36,6 +36,14 @@ class PriceReportOut(BaseModel): class PriceReportRejectRequest(BaseModel): reason: str = Field(min_length=1, max_length=256, description="拒绝理由,用户端记录页会看到") + @field_validator("reason") + @classmethod + def _reason_not_blank(cls, v: str) -> str: + # min_length=1 放过纯空白(" "),trim 后再校验非空,避免审计/用户端记录到空理由 + if not v.strip(): + raise ValueError("拒绝理由不能为空") + return v.strip() + class PriceReportSummary(BaseModel): """审核台顶部各状态计数。""" diff --git a/app/admin/schemas/user.py b/app/admin/schemas/user.py index f781fe5..6f1c352 100644 --- a/app/admin/schemas/user.py +++ b/app/admin/schemas/user.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import datetime from typing import Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator class AdminUserListItem(BaseModel): @@ -37,6 +37,13 @@ class AdminUserOverview(BaseModel): feedback_total: int +def _strip_reason(v: str) -> str: + # min_length=1 放过纯空白(" "),trim 后再校验非空,避免审计记到空原因 + if not v.strip(): + raise ValueError("操作原因不能为空") + return v.strip() + + class GrantCoinsRequest(BaseModel): mode: Literal["delta", "set"] = Field( "delta", description="delta=增减(amount 为变动量) / set=设为(amount 为目标值,须≥0)" @@ -47,6 +54,8 @@ class GrantCoinsRequest(BaseModel): ) reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)") + _v_reason = field_validator("reason")(_strip_reason) + class GrantCashRequest(BaseModel): mode: Literal["delta", "set"] = Field( @@ -58,6 +67,8 @@ class GrantCashRequest(BaseModel): ) reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)") + _v_reason = field_validator("reason")(_strip_reason) + class SetUserStatusRequest(BaseModel): status: Literal["active", "disabled"] = Field( diff --git a/app/api/v1/feedback.py b/app/api/v1/feedback.py index cc30d86..c085780 100644 --- a/app/api/v1/feedback.py +++ b/app/api/v1/feedback.py @@ -1,7 +1,7 @@ """帮助与反馈 endpoint。 路由前缀 `/api/v1/feedback`,需 Bearer 鉴权(反馈绑到登录用户,便于回访)。 - POST / 提交反馈(multipart:content / contact 必填,images 可选 ≤4 张) + POST / 提交反馈(multipart:content 必填;contact 可选(原型改版后客户端已不再采集);images 可选 ≤6 张) 截图复用 [app.core.media] 落盘到 /media/feedback/。 """ @@ -20,7 +20,7 @@ logger = logging.getLogger("shagua.feedback") router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"]) -_MAX_IMAGES = 4 +_MAX_IMAGES = 6 _CONTENT_MAX = 2000 _CONTACT_MAX = 128 @@ -30,7 +30,8 @@ async def submit_feedback( user: CurrentUser, db: DbSession, content: str = Form(...), - contact: str = Form(...), + # 原型改版后客户端不再采集联系方式;保留字段以兼容旧端 + 后续可能复用,默认空串。 + contact: str = Form(default=""), images: list[UploadFile] = File(default=[]), ) -> FeedbackOut: content = content.strip() @@ -39,8 +40,6 @@ async def submit_feedback( raise HTTPException(status_code=400, detail="反馈内容不能为空") if len(content) > _CONTENT_MAX: raise HTTPException(status_code=400, detail="反馈内容过长") - if not contact: - raise HTTPException(status_code=400, detail="联系方式不能为空") if len(contact) > _CONTACT_MAX: raise HTTPException(status_code=400, detail="联系方式过长") diff --git a/app/core/config.py b/app/core/config.py index a64c3a8..a06ebd0 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -9,11 +9,16 @@ from functools import lru_cache from pathlib import Path from typing import Literal -from pydantic import Field +from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict _PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +# 生产环境 JWT secret 的最小可接受长度(字节)。HS256 推荐高熵随机串;<16 视为弱密钥。 +_MIN_PROD_SECRET_LEN = 16 +# 已知的占位默认值(代码里写死的 default),prod 下绝不能沿用。 +_INSECURE_SECRET_DEFAULTS = frozenset({"change-me", "change-me-admin", ""}) + class Settings(BaseSettings): model_config = SettingsConfigDict( @@ -198,6 +203,29 @@ class Settings(BaseSettings): def is_prod(self) -> bool: return self.APP_ENV == "prod" + @model_validator(mode="after") + def _enforce_prod_secrets(self) -> "Settings": + """prod 下强校验 JWT secret,弱/默认/空即启动报错(fail-fast,挡住 token 被伪造)。 + + 只校验两个签发凭证:App 用户的 JWT_SECRET_KEY、后台的 ADMIN_JWT_SECRET——它们沿用默认值 + 时任何人都能伪造 access/admin token → 账号与后台失陷。INTERNAL_API_SECRET 默认空 = 内部端点 + 关闭(返 503),是安全的默认态,故不在此强制。dev 不触发,便于本地直接起。 + """ + if not self.is_prod: + return self + weak: list[str] = [] + for name in ("JWT_SECRET_KEY", "ADMIN_JWT_SECRET"): + value = getattr(self, name) + if value in _INSECURE_SECRET_DEFAULTS or len(value) < _MIN_PROD_SECRET_LEN: + weak.append(name) + if weak: + raise ValueError( + f"APP_ENV=prod 但检测到弱/默认密钥: {', '.join(weak)} —— 必须改成 " + f"≥{_MIN_PROD_SECRET_LEN} 位高熵随机串(否则 JWT 可被伪造 → 用户/后台账号失陷)。" + f"生成示例: python -c \"import secrets; print(secrets.token_urlsafe(48))\"" + ) + return self + @lru_cache(maxsize=1) def get_settings() -> Settings: diff --git a/app/core/rewards.py b/app/core/rewards.py index 3870af0..e9eac9a 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -143,6 +143,13 @@ AD_LT_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = ( (1.0, 11, None), ) +# 客户端可影响的 eCPM 可信上限(分/千次展示):信息流广告一期由客户端上报 eCPM,伪造天价 eCPM +# 可铸出天量金币(见 calculate_ad_reward_coin)。真实 eCPM 一般 <¥100 CPM(=10000 分),档位表顶档 +# 为 >¥400(=40000 分);取 ¥500 CPM=50000 分,留足真实头部余量又封死伪造值。钳在唯一计算口 +# calculate_ad_reward_coin,故 feed 与 reward_video(回退客户端上报 eCPM 时)一并护住;阈值设在所有 +# 真实值之上,不会少发正规奖励。 +AD_ECPM_MAX_FEN: int = 50_000 + def parse_ecpm_fen(ecpm: str | int | float | None) -> float: """解析 eCPM 原始值(穿山甲 getEcpm 原值,单位=分/千次展示)。非法/缺失→0。""" @@ -187,8 +194,12 @@ def calculate_ad_reward_coin(ecpm: str | int | float | None, count_after_this: i eCPM 是穿山甲 getEcpm 原值,单位【分/千次展示】;先 ÷100 转成元(因子判档 + 收益换算都用元)。 单次收益(元)= eCPM元 ÷ 1000(每千次→单次) × 因子1(eCPM 元档) × 因子2(LT); 再按 1 元=10000 金币取整。count_after_this 为账号累计第 N 次看视频(LT 因子用,不按天重置)。 + + eCPM 在此先钳到 AD_ECPM_MAX_FEN(¥500 CPM):信息流广告一期 eCPM 由客户端上报,伪造天价值 + 会铸天量金币;钳在这唯一入口,feed 与 reward_video 回退客户端 eCPM 的路径都护住,且阈值高于 + 所有真实值,不影响正规发奖。 """ - ecpm_yuan = parse_ecpm_yuan(ecpm) + ecpm_yuan = min(parse_ecpm_yuan(ecpm), AD_ECPM_MAX_FEN / 100.0) yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(count_after_this) return max(0, round(yuan * COIN_PER_YUAN)) diff --git a/app/models/feedback.py b/app/models/feedback.py index 004d9e2..caef033 100644 --- a/app/models/feedback.py +++ b/app/models/feedback.py @@ -1,7 +1,8 @@ """用户反馈表(帮助与反馈)。 -每条 = 用户一次提交。content 必填,contact 必填(微信/QQ/手机,便于回访),images 为可选的 -截图 URL 列表(/media/feedback/...,JSON 存)。status: new(待处理)/ handled(已处理)。 +每条 = 用户一次提交。content 必填;contact 原为必填(微信/QQ/手机),原型改版后客户端不再采集, +新数据存空串(列保持 NOT NULL,免迁移;历史数据仍有值);images 为可选的截图 URL 列表 +(/media/feedback/...,JSON 存)。status: new(待处理)/ handled(已处理)。 """ from __future__ import annotations diff --git a/app/repositories/ad_feed_reward.py b/app/repositories/ad_feed_reward.py index 894c928..c2e367c 100644 --- a/app/repositories/ad_feed_reward.py +++ b/app/repositories/ad_feed_reward.py @@ -16,6 +16,10 @@ from app.repositories import wallet as crud_wallet FEED_REWARD_UNIT_SECONDS = 10 +# 单个 feed 事件的时长上限(秒):一期 duration_seconds 由客户端上报,伪造超长时长会刷份数 +# (每 10 秒 1 份)。真实单条信息流视频远小于此;取 120s=12 份封顶,挡刷量、不影响正规单。 +# 与 rewards.AD_ECPM_MAX_FEN(eCPM 钳顶)合起来,把单事件可铸金币锁进有限区间。 +FEED_MAX_DURATION_SECONDS = 120 def _find_by_event(db: Session, client_event_id: str) -> AdFeedRewardRecord | None: @@ -66,13 +70,19 @@ def grant_feed_reward( adn: str | None = None, slot_id: str | None = None, ) -> AdFeedRewardRecord: - """完成一条信息流广告后结算奖励。client_event_id 幂等,同号重试不重复发。""" + """完成一条信息流广告后结算奖励。client_event_id 幂等,同号重试不重复发。 + + 一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到 FEED_MAX_DURATION_SECONDS + 限单事件份数,eCPM 在 rewards.calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN 限单份金额; + 叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间。 + """ existing = _find_by_event(db, client_event_id) if existing is not None: return existing today = cn_today().isoformat() - safe_duration = max(0, min(duration_seconds, 24 * 60 * 60)) + # 客户端上报时长先钳到 FEED_MAX_DURATION_SECONDS,防伪造超长时长刷份数(见常量注释)。 + safe_duration = max(0, min(duration_seconds, FEED_MAX_DURATION_SECONDS)) unit_count = safe_duration // FEED_REWARD_UNIT_SECONDS if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db): diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index 3438928..615f0eb 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -78,9 +78,15 @@ class WithdrawNotReviewable(Exception): """提现单当前状态不可审核(非 reviewing,可能已被处理过)。""" -def get_or_create_account(db: Session, user_id: int, *, commit: bool = True) -> CoinAccount: - """取用户金币账户,不存在则建一个空账户。""" - acc = db.get(CoinAccount, user_id) +def get_or_create_account( + db: Session, user_id: int, *, commit: bool = True, lock: bool = False +) -> CoinAccount: + """取用户金币账户,不存在则建一个空账户。 + + lock=True 时对已存在的账户行加 SELECT FOR UPDATE(读-算-写余额的调用方串行化,防并发 + 双写余额错位,如 admin set 模式连点);默认 False 不改 C 端发奖行为。SQLite 下为 no-op。 + """ + acc = db.get(CoinAccount, user_id, with_for_update=True) if lock else db.get(CoinAccount, user_id) if acc is None: acc = CoinAccount( user_id=user_id, diff --git a/docs/api/admin-feedbacks-list.md b/docs/api/admin-feedbacks-list.md index 2a3d002..af0460f 100644 --- a/docs/api/admin-feedbacks-list.md +++ b/docs/api/admin-feedbacks-list.md @@ -1,4 +1,4 @@ -# GET /admin/api/feedbacks — 反馈工单列表(游标分页) +# GET /admin/api/feedbacks — 反馈工单列表(offset 分页 + 筛选/排序) > 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 `require_role`,仅 `get_current_admin`) | [← 返回 API 索引](./README.md) @@ -7,8 +7,13 @@ |---|---|---|---|---| | `status` | string | ❌ | null | 反馈状态,精确匹配:`new`(待处理) / `handled`(已处理);传空/不传则不筛 | | `user_id` | int | ❌ | null | 按提交用户 id 精确筛 | +| `content` | string | ❌ | null | 反馈内容模糊匹配(ilike,≤100 字) | +| `created_from` | datetime | ❌ | null | 提交时间 ≥(ISO,统一按 UTC 比较) | +| `created_to` | datetime | ❌ | null | 提交时间 ≤(ISO,统一按 UTC 比较) | +| `sort_by` | string | ❌ | `id` | 排序列:`id` / `created_at` | +| `sort_order` | string | ❌ | `desc` | `asc` / `desc` | | `limit` | int | ❌ | 20 | 1–100 | -| `cursor` | int | ❌ | null | 上一页 next_cursor(按 feedback id 倒序,查 `id < cursor`) | +| `cursor` | int | ❌ | null | 上一页 next_cursor(**offset 分页**,cursor=offset) | ## 出参 响应 `200`:`{ items: FeedbackOut[], next_cursor: int|null }`(`next_cursor=null` 表示末页) @@ -26,9 +31,10 @@ ## 错误码 - `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`) -- `422` `limit` 超出 1–100 范围 / 字段类型不合法 +- `422` `limit` 超出 1–100 范围 / `sort_by`·`sort_order` 不在允许集 / 字段类型不合法 ## 说明 -- 游标分页约定:结果按 feedback `id` 倒序;`cursor` 传上一页返回的 `next_cursor`;`next_cursor=null` 即末页。 -- `status` / `user_id` 均为精确匹配,可叠加。 +- **offset 分页**(同 [admin-users-list](./admin-users-list.md)):`cursor` 即 offset,传上一页返回的 `next_cursor`;`next_cursor=null` 即末页。改用 offset 是为了在任意列排序下游标语义统一,代价是翻页期间数据变动可能错位一条(admin 低频可接受)。 +- 排序:`sort_by`(id/created_at)× `sort_order`(asc/desc),恒以 `id` 同向兜底次序。 +- `status` / `user_id` 精确匹配、`content` 模糊、`created_from`/`created_to` 时间范围,均可叠加。 - 关联表 [feedback](../database/feedback.md);截图为相对路径,经 `GET /media/feedback/` 静态读。 diff --git a/docs/api/feedback.md b/docs/api/feedback.md index 8b65588..259df11 100644 --- a/docs/api/feedback.md +++ b/docs/api/feedback.md @@ -7,8 +7,8 @@ | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| | `content` | string | ✓ | 反馈正文,**1-2000 字**(strip 后) | -| `contact` | string | ✓ | 联系方式(微信/QQ/手机号),**1-128 字**,便于回访 | -| `images` | file[] | ✗ | 截图,**最多 4 张**,每张走头像同款校验(JPEG/PNG/WebP,≤ 5 MB,魔数嗅探) | +| `contact` | string | ✗ | 联系方式(微信/QQ/手机号),**≤128 字**。原型改版后客户端已不再采集、不传该字段(后端默认空串);保留字段兼容旧端 | +| `images` | file[] | ✗ | 截图,**最多 6 张**,每张走头像同款校验(JPEG/PNG/WebP,≤ 5 MB,魔数嗅探) | ## 出参 响应 `200`: @@ -29,9 +29,9 @@ > 不返回上传的 image URL——这是给运营后台看的,客户端通常不需要。 ## 错误码 -- `400` 内容为空 / 内容超 2000 字 / 联系方式为空 / 联系方式超 128 字 / 图片超 4 张 / 单图非法(空/过大/格式不对) +- `400` 内容为空 / 内容超 2000 字 / 联系方式超 128 字 / 图片超 6 张 / 单图非法(空/过大/格式不对) - `401` 未带 token / token 无效或过期 / 用户被禁用 -- `422` 缺 `content` 或 `contact` 字段 +- `422` 缺 `content` 字段 ## 说明 - **反馈绑用户**:`feedback.user_id = current_user.id`,便于回访 diff --git a/docs/database/feedback.md b/docs/database/feedback.md index 2261af2..d916b30 100644 --- a/docs/database/feedback.md +++ b/docs/database/feedback.md @@ -2,10 +2,10 @@ > 模型 `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「帮助与反馈」每次提交写一行。`content` 与 `contact` 必填,`images` 为可选截图。后台人工处理后置 `handled`。与 `price_report`(结构化上报更低价)不同,本表是**自由文本**反馈。 +App「帮助与反馈」每次提交写一行。`content` 必填;`contact` 原必填,**原型改版后客户端不再采集,新数据存空串**(列保持 NOT NULL、免迁移,历史数据仍有值);`images` 为可选截图(≤6 张)。后台人工处理后置 `handled`。与 `price_report`(结构化上报更低价)不同,本表是**自由文本**反馈。 ## 用在哪 / 增删改查 -- **C(插入)**:`POST /api/v1/feedback`(multipart:`content` + `contact` + 可选 `images`;`create_feedback`)。截图先经 `core.media` 落 `/media/feedback/` 拿相对路径,再随反馈写入,`status='new'`。 +- **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`)。 - **D**:无。 - **R**:admin 反馈列表(可按 `status` 筛)。C 端当前无"我的反馈列表"读接口。 @@ -16,7 +16,7 @@ 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/手机,便于回访) | +| `contact` | String(128) | NOT NULL | 联系方式(微信/QQ/手机)。客户端改版后不再采集,新数据为空串;列仍 NOT NULL | | `images` | JSON | nullable | 截图相对 URL 列表 `/media/feedback/...`;无图为 NULL | | `status` | String(16) | NOT NULL, default `new` | 取值:`new`(待处理)/ `handled`(已处理) | | `created_at` | DateTime(tz) | server_default now(), index | 提交时间 | diff --git a/tests/test_admin.py b/tests/test_admin.py index dea292f..7932a7f 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -125,7 +125,7 @@ def test_long_password_does_not_crash(admin_client: TestClient) -> None: def test_audit_log_pagination_no_gap() -> None: - """审计游标分页跨页不丢/不重(回归 next_cursor off-by-one)。""" + """审计分页跨页不丢/不重(offset 分页:cursor 即 offset,翻完覆盖全部)。""" from app.admin.repositories import admin_user as admin_repo from app.admin.repositories import audit_log as audit_repo @@ -142,12 +142,13 @@ def test_audit_log_pagination_no_gap() -> None: ) created_ids.append(log.id) - # limit=2 翻 5 条,收集所有 id,应正好覆盖创建的 5 条(无丢无重) + # limit=2 翻 5 条,收集所有 id,应正好覆盖创建的 5 条(无丢无重);total 恒为符合条件总数 seen: list[int] = [] cursor = None for _ in range(10): # 上限防死循环 - rows, cursor = audit_repo.list_audit_logs(db, action=action, limit=2, cursor=cursor) + rows, cursor, total = audit_repo.list_audit_logs(db, action=action, limit=2, cursor=cursor) seen.extend(r.id for r in rows) + assert total == len(created_ids), f"total 应为 {len(created_ids)},得 {total}" if cursor is None: break assert sorted(seen) == sorted(created_ids), f"分页丢/重: want={created_ids} got={seen}" From 8fa55eec3e53e98a58eb62548b038be7ba408e1c Mon Sep 17 00:00:00 2001 From: liujiahui Date: Sun, 14 Jun 2026 23:41:54 +0800 Subject: [PATCH 07/36] =?UTF-8?q?fix(coupon):=20=E9=A2=86=E5=88=B8?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E9=A2=91=E6=8E=A7=E6=8C=89=20App=20=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=20+=20debug=20=E5=85=A8=E9=87=8D=E7=BD=AE=E8=A1=A5?= =?UTF-8?q?=E5=85=A8=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bug: 弹窗 engagement 表只按 (device, 日) 全局记一条,美团弹过/领过就把整台设备当天 标记 engage,淘宝/京东被压住不弹。需求是美团/淘宝/京东各自独立、每日各弹一次。 改动: - model: CouponPromptEngagement 加 package 列,唯一约束 (device,日) → (device,package,日) - alembic: 新增迁移 coupon_engage_per_package(加列 + 改唯一约束, batch_alter_table) - repository: has_engaged_today / mark_engagement 加 package 维度;新增 reset_today_completion - api: should-show / dismiss 接收 package;coupon_step(step=0) 按 App 记 engagement; 补 /prompt/shown 接口(客户端一直在调但后端缺失, 原 404); 补 /completed-today/reset(开发设置全重置用, 解首页卡置灰) 验证: curl 端到端 —— 美团弹过后 should-show 美团=false 淘宝/京东=true; shown/reset/completion-reset 端点全 ok。 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: no_gen_mu Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/52 Co-authored-by: liujiahui Co-committed-by: liujiahui --- alembic/versions/coupon_engage_per_package.py | 53 ++++++++++++++++++ app/api/v1/coupon.py | 54 +++++++++++++++---- app/models/coupon_state.py | 22 +++++--- app/repositories/coupon_state.py | 37 ++++++++++--- app/schemas/coupon_state.py | 18 ++++++- 5 files changed, 157 insertions(+), 27 deletions(-) create mode 100644 alembic/versions/coupon_engage_per_package.py diff --git a/alembic/versions/coupon_engage_per_package.py b/alembic/versions/coupon_engage_per_package.py new file mode 100644 index 0000000..58d1e70 --- /dev/null +++ b/alembic/versions/coupon_engage_per_package.py @@ -0,0 +1,53 @@ +"""coupon_prompt_engagement 频控加 package 维度(美团/淘宝/京东各自独立弹) + +Revision ID: coupon_engage_per_package +Revises: store_mapping_jd_cols +Create Date: 2026-06-14 11:00:00.000000 + +需求:领券引导窗按 (device, App, 自然日) 频控——在美团弹过/领过,不影响淘宝、京东今天 +仍各弹一次。原表唯一键是 (device_id, engage_date),缺 package → 任一 App 弹过就把整台 +设备当天标记 engage,其余 App 被压住不弹(bug)。 + +本迁移: + 1. 加 package 列(NOT NULL,旧行用 server_default "" 填占位,不影响新逻辑判断)。 + 2. 旧唯一约束 (device_id, engage_date) → 新 (device_id, package, engage_date)。 + +SQLite 不支持直接 drop/add 约束,用 batch_alter_table(建临时表 + 拷数据 + 换名, +与 store_mapping_* 同款)。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'coupon_engage_per_package' +down_revision: Union[str, Sequence[str], None] = 'store_mapping_jd_cols' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op: + # 加 package 列。旧行(改造前的全局记录)填 "" 占位:它们对应的是"老的全局态", + # 新逻辑按 (device, package, 日) 判,占位 "" 不会与真实包名(com.xxx)碰撞。 + batch_op.add_column( + sa.Column('package', sa.String(length=64), nullable=False, server_default='') + ) + # 旧唯一约束 (device_id, engage_date) → 新三元组 (device_id, package, engage_date)。 + batch_op.drop_constraint('uq_coupon_engage_device_date', type_='unique') + batch_op.create_unique_constraint( + 'uq_coupon_engage_device_pkg_date', + ['device_id', 'package', 'engage_date'], + ) + + +def downgrade() -> None: + with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op: + batch_op.drop_constraint('uq_coupon_engage_device_pkg_date', type_='unique') + batch_op.create_unique_constraint( + 'uq_coupon_engage_device_date', + ['device_id', 'engage_date'], + ) + batch_op.drop_column('package') diff --git a/app/api/v1/coupon.py b/app/api/v1/coupon.py index e44adb5..6e07120 100644 --- a/app/api/v1/coupon.py +++ b/app/api/v1/coupon.py @@ -27,6 +27,7 @@ from app.schemas.coupon_state import ( CouponCompletedTodayOut, CouponPromptDismissIn, CouponPromptShouldShowOut, + CouponPromptShownIn, ) logger = logging.getLogger("shagua.coupon") @@ -66,11 +67,11 @@ def _extract_coupon_results(resp_json: dict) -> list[dict]: def _mark_engagement_blocking( - device_id: str, user_id: int | None, engage_type: str + device_id: str, package: str, user_id: int | None, engage_type: str ) -> None: """独立 session 写 engagement(async 端点经 run_in_threadpool 调,不阻塞事件循环)。""" with SessionLocal() as db: - coupon_repo.mark_engagement(db, device_id, user_id, engage_type) + coupon_repo.mark_engagement(db, device_id, package, user_id, engage_type) def _record_claims_blocking( @@ -111,14 +112,17 @@ async def coupon_step( device_id = meta.get("device_id") user_id = _to_int(meta.get("user_id")) # 登录态才带;判断不靠它,资产留痕用 trace_id = meta.get("trace_id") + # 发起领券时前台 App 包名(step body 带 "package")。频控按 App,这条 engagement 要记到 + # 对应 App 上。App 内「去领取」发起时 package 可能缺/为空 → 退化为 "" 占位(全局态)。 + pkg = meta.get("package") or "" # 领券任务首帧(step=0)= 用户已发起领券 → 记一条今日 engagement(claim_started), - # 今天这台设备不再弹引导窗(对齐前台"点一键领取即 markEngaged")。写库失败绝不能 + # 今天**这个 App** 不再弹引导窗(对齐前台"点一键领取即 markEngaged")。写库失败绝不能 # 连累领券主流程,整段吞掉。 if device_id and meta.get("step") == 0: try: await run_in_threadpool( - _mark_engagement_blocking, device_id, user_id, "claim_started" + _mark_engagement_blocking, device_id, pkg, user_id, "claim_started" ) except Exception as e: # noqa: BLE001 logger.warning("coupon engagement write failed: %s", e) @@ -188,14 +192,29 @@ async def coupon_step( return resp_json +@router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)") +def coupon_prompt_shown(payload: CouponPromptShownIn, db: DbSession) -> dict[str, bool]: + """客户端弹出引导窗那刻调 → 记一条今日 engagement(shown),今天**这个 App** 不再自动弹。 + + 频控主判据(管跨重装):弹出即占用今天这个 App 的"一次"。用户领/拒/无视都算用掉。 + 后续点领取/拒绝再由 step/dismiss 把 type 升级。按 (device, package, 日) 记。 + """ + coupon_repo.mark_engagement( + db, payload.device_id, payload.package, payload.user_id, "shown" + ) + return {"ok": True} + + @router.post("/prompt/dismiss", summary="用户拒绝/关闭领券引导窗(记今日已 engage)") def coupon_prompt_dismiss(payload: CouponPromptDismissIn, db: DbSession) -> dict[str, bool]: - """客户端点关闭引导窗时调用 → 记一条今日 engagement(dismissed),今天不再弹。 + """客户端点关闭引导窗时调用 → 记一条今日 engagement(dismissed),今天**这个 App** 不再弹。 server 在透传链路里看不到"用户拒绝"(拒绝不发起领券),故必须客户端通知。 - MVP 不鉴权,按 device_id 记。 + 频控按 (device, package, 日),各 App 独立。MVP 不鉴权,按 device_id 记。 """ - coupon_repo.mark_engagement(db, payload.device_id, payload.user_id, "dismissed") + coupon_repo.mark_engagement( + db, payload.device_id, payload.package, payload.user_id, "dismissed" + ) return {"ok": True} @@ -205,12 +224,12 @@ def coupon_prompt_dismiss(payload: CouponPromptDismissIn, db: DbSession) -> dict summary="切到外卖 App 时是否还应弹领券引导窗", ) def coupon_prompt_should_show( - device_id: str, db: DbSession + device_id: str, db: DbSession, package: str = "" ) -> CouponPromptShouldShowOut: - """今天这台设备已 engage(领或拒)过 → should_show=false。客户端据此决定弹不弹 - (纯后台判据,客户端不再做前台 SP 缓存判断)。""" + """今天这台设备**这个 App** 已 engage(弹/领/拒)过 → should_show=false。各 App 独立: + 美团弹过不压淘宝/京东。客户端切到目标 App 时带 package 查(老客户端不带 → "" 全局态)。""" return CouponPromptShouldShowOut( - should_show=not coupon_repo.has_engaged_today(db, device_id) + should_show=not coupon_repo.has_engaged_today(db, device_id, package) ) @@ -236,3 +255,16 @@ def coupon_completed_today( return CouponCompletedTodayOut( completed=coupon_repo.has_completed_today(db, device_id) ) + + +@router.post( + "/completed-today/reset", + summary="重置今日已完成(开发设置全重置用,恢复首页「去领取」卡可点)", +) +def coupon_completed_today_reset( + payload: CouponPromptDismissIn, db: DbSession +) -> dict[str, bool]: + """删这台设备今天的 completion → has_completed_today 变 false,首页「去领取」卡恢复可点。 + 与 /prompt/reset 配套:开发设置「重置今日领券弹窗状态」一键把今日状态全清。MVP 不鉴权。""" + coupon_repo.reset_today_completion(db, payload.device_id) + return {"ok": True} diff --git a/app/models/coupon_state.py b/app/models/coupon_state.py index bd2aa2d..3f9364d 100644 --- a/app/models/coupon_state.py +++ b/app/models/coupon_state.py @@ -137,25 +137,35 @@ class CouponDailyCompletion(Base): class CouponPromptEngagement(Base): - """按 (device, 自然日) 记"今天是否对领券引导窗表达过意向"——弹窗频控源。""" + """按 (device, **App**, 自然日) 记"今天这个 App 是否对领券引导窗表达过意向"——弹窗频控源。 + + 2026-06-14:频控维度从 (device, 日) 改为 (device, package, 日)。需求是美团/淘宝/京东 + 各自独立——在美团弹过/领过,不影响淘宝、京东今天仍各弹一次。原来缺 package → 任一 App + 弹过就把整台设备当天标记 engage,其余 App 被压住不弹(bug)。 + """ __tablename__ = "coupon_prompt_engagement" __table_args__ = ( - # 一台设备一天一条:今天 engage 过(领或拒)就不再弹。 + # 一台设备、一个 App、一天一条:今天**这个 App** engage 过(领或拒)才不再弹该 App。 UniqueConstraint( - "device_id", "engage_date", - name="uq_coupon_engage_device_date", + "device_id", "package", "engage_date", + name="uq_coupon_engage_device_pkg_date", ), ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) device_id: Mapped[str] = mapped_column(String(64), nullable=False) + # 触发弹窗的目标 App 包名(com.sankuai.meituan / com.taobao.taobao / com.jingdong.app.mall)。 + # 频控维度,各 App 独立。旧行(改造前)无此值 → 迁移用占位 "" 填,不影响新逻辑判断。 + package: Mapped[str] = mapped_column( + String(64), nullable=False, server_default="" + ) user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True) # Asia/Shanghai 自然日。 engage_date: Mapped[date] = mapped_column(Date, nullable=False) - # claim_started(点了一键领取)/ dismissed(点了拒绝/关闭)。仅记录区分, - # 判断只看"今天有没有这条",type 不影响弹不弹。 + # claim_started(点了一键领取)/ dismissed(点了拒绝/关闭)/ shown(自动弹出即记)。 + # 仅记录区分,判断只看"今天这个 App 有没有这条",type 不影响弹不弹。 engage_type: Mapped[str] = mapped_column(String(16), nullable=False) created_at: Mapped[datetime] = mapped_column( diff --git a/app/repositories/coupon_state.py b/app/repositories/coupon_state.py index c5012b2..33f4781 100644 --- a/app/repositories/coupon_state.py +++ b/app/repositories/coupon_state.py @@ -31,11 +31,13 @@ def today_cn() -> date: # ===== 弹窗频控(coupon_prompt_engagement)===== -def has_engaged_today(db: Session, device_id: str) -> bool: - """这台设备今天是否已对领券引导窗表达过意向(领或拒)。有 = 不再弹。""" +def has_engaged_today(db: Session, device_id: str, package: str) -> bool: + """这台设备今天**这个 App** 是否已对领券引导窗表达过意向(领/拒/弹出)。有 = 该 App 不再弹。 + 频控按 (device, package, 日):美团弹过不影响淘宝/京东今天各自仍弹一次。""" row = db.execute( select(CouponPromptEngagement.id).where( CouponPromptEngagement.device_id == device_id, + CouponPromptEngagement.package == package, CouponPromptEngagement.engage_date == today_cn(), ) ).first() @@ -43,13 +45,18 @@ def has_engaged_today(db: Session, device_id: str) -> bool: def mark_engagement( - db: Session, device_id: str, user_id: int | None, engage_type: str + db: Session, device_id: str, package: str, user_id: int | None, engage_type: str ) -> None: - """记今日意向(claim_started / dismissed)。(device, 今天) 唯一,幂等 upsert。""" + """记今日意向(shown / claim_started / dismissed)。(device, package, 今天) 唯一,幂等 upsert。 + + engage_type 升级口径(同一 (device,package,日) 多次调,只覆盖 type,不新增行): + shown(自动弹出)→ claim_started(点一键领取)/ dismissed(点关闭)。判断只看"有没有这条"。 + """ today = today_cn() row = db.execute( select(CouponPromptEngagement).where( CouponPromptEngagement.device_id == device_id, + CouponPromptEngagement.package == package, CouponPromptEngagement.engage_date == today, ) ).scalar_one_or_none() @@ -59,19 +66,20 @@ def mark_engagement( row.user_id = user_id else: db.add(CouponPromptEngagement( - device_id=device_id, user_id=user_id, + device_id=device_id, package=package, user_id=user_id, engage_date=today, engage_type=engage_type, )) try: db.commit() except IntegrityError: - # 并发下另一请求刚插了同 (device, 日) → 唯一约束撞,回滚忽略(本就幂等)。 + # 并发下另一请求刚插了同 (device, package, 日) → 唯一约束撞,回滚忽略(本就幂等)。 db.rollback() def reset_today_engagement(db: Session, device_id: str) -> int: - """删这台设备今天的 engagement(开发设置「重置今日领券弹窗状态」调,测频控用)。 - 删后 has_engaged_today → false,今天又能弹。返回删除行数。""" + """删这台设备今天**所有 App** 的 engagement(开发设置「重置今日领券弹窗状态」调,测频控用)。 + 删后各 App has_engaged_today → false,今天又都能弹。返回删除行数。 + (不按 package 过滤:重置是"把今天清干净从头测",清全部 App 最符合预期。)""" result = db.execute( delete(CouponPromptEngagement).where( CouponPromptEngagement.device_id == device_id, @@ -123,6 +131,19 @@ def mark_completed_today( db.rollback() +def reset_today_completion(db: Session, device_id: str) -> int: + """删这台设备今天的"已完成"记录(开发设置「重置今日领券弹窗状态」全重置时调)。 + 删后 has_completed_today → false,首页「去领取」卡恢复可点。返回删除行数。""" + result = db.execute( + delete(CouponDailyCompletion).where( + CouponDailyCompletion.device_id == device_id, + CouponDailyCompletion.complete_date == today_cn(), + ) + ) + db.commit() + return result.rowcount or 0 + + # ===== 领券记录(coupon_claim_record)===== def record_claims( diff --git a/app/schemas/coupon_state.py b/app/schemas/coupon_state.py index fd66cb8..15e9c16 100644 --- a/app/schemas/coupon_state.py +++ b/app/schemas/coupon_state.py @@ -7,16 +7,30 @@ from pydantic import BaseModel class CouponPromptDismissIn(BaseModel): """客户端拒绝/关闭领券引导窗的通知体。 - server 据此记一条今日 engagement(dismissed)→ 今天这台设备不再弹引导窗。 + server 据此记一条今日 engagement(dismissed)→ 今天**这个 App** 不再弹引导窗。 + 频控按 (device, package, 日):各 App 独立。package 缺省 ""(老客户端兼容,退化为全局态)。 MVP 不鉴权,按 device_id 判断;user_id 登录态带上就一并记(资产),可空。 """ device_id: str + package: str = "" + user_id: int | None = None + + +class CouponPromptShownIn(BaseModel): + """客户端弹出领券引导窗即上报(记 shown)。 + + 弹出那刻就记一条今日 engagement(shown)→ 今天**这个 App** 不再自动弹(频控主判据, + 管跨重装;本地 SP 兜后台抖动)。后续用户点领取/拒绝再把 type 升级成 claim_started/dismissed。 + """ + + device_id: str + package: str user_id: int | None = None class CouponPromptShouldShowOut(BaseModel): - """切到外卖 App 时是否还应弹领券引导窗。今天已 engage(领或拒)过 → false。""" + """切到外卖 App 时是否还应弹领券引导窗。今天**这个 App** 已 engage(弹/领/拒)过 → false。""" should_show: bool From 9ec9d2389dac1d6f3024e6bd98723a64985eaebc Mon Sep 17 00:00:00 2001 From: marco Date: Mon, 15 Jun 2026 18:07:32 +0800 Subject: [PATCH 08/36] =?UTF-8?q?feat(store=5Fmapping):=20=E6=B7=98?= =?UTF-8?q?=E5=AE=9D=E7=BC=93=E5=AD=98=20deeplink=20=E5=A4=B1=E6=95=88?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E2=86=92=E6=A0=87=E8=AE=B0=E2=86=92=E5=9B=9E?= =?UTF-8?q?=E9=80=80(app-server=20=E4=BE=A7)=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 加 taobao_deeplink_invalid_at 列(迁移+model);mark_taobao_deeplink_invalid 按 shopId 标记所有行;lookup_nearest 过滤失效淘宝候选;新增 POST /internal/store-mapping/invalidate。+ 7 单测。 Co-Authored-By: Claude Opus 4.8 Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/53 --- .../versions/store_mapping_tb_dl_invalid.py | 33 +++++ app/api/internal/store.py | 30 ++++- app/models/store_mapping.py | 3 + app/repositories/store_mapping.py | 22 +++- app/schemas/store_mapping.py | 14 +++ tests/test_store_mapping_invalidate.py | 118 ++++++++++++++++++ 6 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/store_mapping_tb_dl_invalid.py create mode 100644 tests/test_store_mapping_invalidate.py diff --git a/alembic/versions/store_mapping_tb_dl_invalid.py b/alembic/versions/store_mapping_tb_dl_invalid.py new file mode 100644 index 0000000..cea52a4 --- /dev/null +++ b/alembic/versions/store_mapping_tb_dl_invalid.py @@ -0,0 +1,33 @@ +"""store_mapping 加淘宝 deeplink 失效标记列 taobao_deeplink_invalid_at + +Revision ID: store_mapping_tb_dl_invalid +Revises: coupon_engage_per_package +Create Date: 2026-06-15 00:00:00.000000 + +缓存的淘宝店内搜索 deeplink 会失效(打开是"页面出错了"降级页)。pricebot 比价撞到错误页时 +回退正常搜店, 并 server→server 通知把该 shopId 的 deeplink 标记失效。本列记失效时刻 +(NULL=有效); lookup 反查时过滤掉已失效的淘宝候选, 不再返回坏 deeplink。 +只加淘宝一列(当前只接淘宝); 用时间戳而非布尔: 留痕可审计、可统计失效率, 且不销毁原 deeplink。 +无需索引: 过滤总叠在 name_taobao== 之后, 候选集已小。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'store_mapping_tb_dl_invalid' +down_revision: Union[str, Sequence[str], None] = 'coupon_engage_per_package' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('store_mapping', schema=None) as batch_op: + batch_op.add_column(sa.Column('taobao_deeplink_invalid_at', sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table('store_mapping', schema=None) as batch_op: + batch_op.drop_column('taobao_deeplink_invalid_at') diff --git a/app/api/internal/store.py b/app/api/internal/store.py index 916398a..b7959e7 100644 --- a/app/api/internal/store.py +++ b/app/api/internal/store.py @@ -17,7 +17,12 @@ from fastapi import APIRouter, Header from app.api.deps import DbSession from app.api.internal.price import _check_secret from app.repositories import store_mapping as repo -from app.schemas.store_mapping import StoreMappingIn, StoreMappingOut +from app.schemas.store_mapping import ( + StoreMappingIn, + StoreMappingInvalidateIn, + StoreMappingInvalidateOut, + StoreMappingOut, +) logger = logging.getLogger("shagua.internal.store") @@ -77,3 +82,26 @@ def report_store_mapping( payload.source_device_id, payload.source_user_id, ) return StoreMappingOut(inserted=created, row_id=row_id) + + +@router.post( + "/store-mapping/invalidate", + response_model=StoreMappingInvalidateOut, + summary="标记某平台 shopId 的缓存 deeplink 失效(pricebot 撞错误页回退时上报,lookup 不再返回)", +) +def invalidate_store_mapping( + payload: StoreMappingInvalidateIn, + db: DbSession, + x_internal_secret: Annotated[str | None, Header()] = None, +) -> StoreMappingInvalidateOut: + _check_secret(x_internal_secret) + if payload.platform != "taobao": + # 当前只接淘宝; 其它平台先 no-op(affected=0), 不报错 — 向后兼容 pricebot 将来扩展。 + logger.info("store_mapping invalidate 跳过: platform=%s 暂不支持", payload.platform) + return StoreMappingInvalidateOut(ok=True, affected=0) + affected = repo.mark_taobao_deeplink_invalid(db, payload.shop_id) + logger.info( + "store_mapping invalidate platform=%s shop_id=%s → 标记失效 %d 行", + payload.platform, payload.shop_id, affected, + ) + return StoreMappingInvalidateOut(ok=True, affected=affected) diff --git a/app/models/store_mapping.py b/app/models/store_mapping.py index a76f8aa..35272d0 100644 --- a/app/models/store_mapping.py +++ b/app/models/store_mapping.py @@ -86,6 +86,9 @@ class StoreMapping(Base): taobao_share_url: Mapped[str | None] = mapped_column(String(256), nullable=True) # m.tb.cn 短链 taobao_resolved_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 解析出的目标 URL(含 shopId) taobao_deeplink: Mapped[str | None] = mapped_column(Text, nullable=True) # 拼好的 et-store/search deeplink + # 淘宝 deeplink 失效标记:比价撞"页面出错了"降级页时被置(pricebot server→server invalidate), + # NULL=有效。lookup 反查过滤掉非 NULL 的淘宝候选,不再返回坏 deeplink(重搜会写新行覆盖)。 + taobao_deeplink_invalid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) # ===== 美团原料(同淘宝;dpurl.cn 短链 → 302 反查 poi_id_str → imeituan:// deeplink)===== # ⚠️ poi_id_str 每次分享重新加密、非稳定主键(调研文档 §八), 故单列存"可复跳的一次性票据", diff --git a/app/repositories/store_mapping.py b/app/repositories/store_mapping.py index 8ebc9fc..358aa7d 100644 --- a/app/repositories/store_mapping.py +++ b/app/repositories/store_mapping.py @@ -15,7 +15,7 @@ from __future__ import annotations import logging import math -from sqlalchemy import select +from sqlalchemy import func, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -107,6 +107,23 @@ def upsert(db: Session, payload: StoreMappingIn) -> tuple[int, int | None]: return 0, existing.id +def mark_taobao_deeplink_invalid(db: Session, shop_id: str) -> int: + """把所有 id_taobao=shop_id 的行标记淘宝 deeplink 失效(置 invalid_at=now)。返回本次新标记的行数。 + + 按 shopId 标记**所有**行 —— 同一个坏 shopId(撞淘宝"页面出错了"降级页)可能散在多次比价的 + 多行里, 全标掉才能让后续 lookup 不再返回它。幂等: 已标记的行(invalid_at 非 NULL)跳过。""" + result = db.execute( + update(StoreMapping) + .where( + StoreMapping.id_taobao == shop_id, + StoreMapping.taobao_deeplink_invalid_at.is_(None), + ) + .values(taobao_deeplink_invalid_at=func.now()) + ) + db.commit() + return result.rowcount or 0 + + # ============================================================ # 缓存查询: 比价前按"源平台店名"反查已沉淀的各目标平台店铺 id, 命中就让 pricebot 直接 # deeplink 跳店内搜索, 省掉"开平台→进店→分享反查"整段。 @@ -161,6 +178,9 @@ def lookup_nearest( if tgt == src_key: continue # 不返回源平台自己 cands = [r for r in rows if getattr(r, id_attr)] + if tgt == "taobao": + # 失效的淘宝 deeplink(撞过错误页被 invalidate)整条排除 = 当没缓存, pricebot 走正常搜店。 + cands = [r for r in cands if r.taobao_deeplink_invalid_at is None] if not cands: continue best = _pick_best(cands, lat, lng) diff --git a/app/schemas/store_mapping.py b/app/schemas/store_mapping.py index 2d84c75..c9a20d9 100644 --- a/app/schemas/store_mapping.py +++ b/app/schemas/store_mapping.py @@ -60,3 +60,17 @@ class StoreMappingOut(BaseModel): inserted: int row_id: int | None = None + + +class StoreMappingInvalidateIn(BaseModel): + """标记某平台某 shopId 的缓存 deeplink 失效(pricebot 撞错误页回退时上报)。""" + + platform: str # 目前只支持 "taobao" + shop_id: str # 失效的店铺 id(淘宝 = shopId = id_taobao) + + +class StoreMappingInvalidateOut(BaseModel): + """标记结果。affected = 本次新标记失效的行数(按 shopId 标记所有匹配行)。""" + + ok: bool + affected: int diff --git a/tests/test_store_mapping_invalidate.py b/tests/test_store_mapping_invalidate.py new file mode 100644 index 0000000..e1930f9 --- /dev/null +++ b/tests/test_store_mapping_invalidate.py @@ -0,0 +1,118 @@ +"""淘宝 deeplink 失效标记 + lookup 过滤 + invalidate 端点测试。 + +覆盖:按 shopId 标记所有行(幂等)、lookup 过滤失效淘宝候选、失效后新行仍可命中、 +invalidate 端点鉴权(503 未配 / 401 头错 / 200)、端到端标记后 lookup MISS、非淘宝 no-op。 +""" +from __future__ import annotations + +import pytest + +from app.core.config import settings +from app.db.session import SessionLocal +from app.models.store_mapping import StoreMapping +from app.repositories import store_mapping as repo + +_SECRET = "test-internal-secret-only-for-pytest" + + +def _mk_row(db, *, trace_id, name_meituan=None, name_taobao=None, + id_taobao=None, deeplink=None, lat=None, lng=None): + row = StoreMapping( + trace_id=trace_id, business_type="food", + name_meituan=name_meituan, name_taobao=name_taobao, + id_taobao=id_taobao, taobao_deeplink=deeplink, lat=lat, lng=lng, + ) + db.add(row) + db.commit() + db.refresh(row) + return row + + +@pytest.fixture() +def db(): + """每个用例前后清空 store_mapping(表由 conftest 的 create_all 建好,session 级共享)。""" + s = SessionLocal() + s.query(StoreMapping).delete() + s.commit() + yield s + s.query(StoreMapping).delete() + s.commit() + s.close() + + +# ---------- repo 层 ---------- + +def test_mark_invalid_marks_all_rows_with_shop_id(db): + # 同一个坏 shopId 散在两行(两次比价),另一行不同 shopId + _mk_row(db, trace_id="t1", name_meituan="绝味鸭脖", name_taobao="绝味鸭脖", id_taobao="BAD1", deeplink="dl_a") + _mk_row(db, trace_id="t2", name_meituan="绝味鸭脖", name_taobao="绝味鸭脖", id_taobao="BAD1", deeplink="dl_b") + _mk_row(db, trace_id="t3", name_meituan="绝味鸭脖", name_taobao="绝味鸭脖", id_taobao="GOOD9", deeplink="dl_g") + + # 按 shopId 标记所有行 + assert repo.mark_taobao_deeplink_invalid(db, "BAD1") == 2 + # 幂等:已标记的不重复 + assert repo.mark_taobao_deeplink_invalid(db, "BAD1") == 0 + + rows = {r.trace_id: r for r in db.query(StoreMapping).all()} + assert rows["t1"].taobao_deeplink_invalid_at is not None + assert rows["t2"].taobao_deeplink_invalid_at is not None + assert rows["t3"].taobao_deeplink_invalid_at is None # 不同 shopId 不动 + + +def test_lookup_filters_invalid_taobao(db): + _mk_row(db, trace_id="t1", name_meituan="绝味鸭脖", name_taobao="绝味鸭脖", id_taobao="BAD1", deeplink="dl") + # 标记前命中 + assert repo.lookup_nearest(db, "meituan", "绝味鸭脖")["taobao"]["shop_id"] == "BAD1" + # 标记失效后淘宝候选被过滤 → MISS(仅此一条淘宝) + repo.mark_taobao_deeplink_invalid(db, "BAD1") + assert "taobao" not in repo.lookup_nearest(db, "meituan", "绝味鸭脖") + + +def test_lookup_picks_new_valid_row_after_invalidate(db): + # 失效旧行 + 重搜写的新行(新 shopId,invalid_at=NULL)共存 → lookup 选到新行 + _mk_row(db, trace_id="t1", name_meituan="店A", name_taobao="店A", id_taobao="BAD1", deeplink="dl_bad") + repo.mark_taobao_deeplink_invalid(db, "BAD1") + _mk_row(db, trace_id="t2", name_meituan="店A", name_taobao="店A", id_taobao="NEW2", deeplink="dl_new") + assert repo.lookup_nearest(db, "meituan", "店A")["taobao"]["shop_id"] == "NEW2" + + +# ---------- invalidate 端点 ---------- + +def test_invalidate_endpoint_secret_unset_503(client, monkeypatch): + monkeypatch.setattr(settings, "INTERNAL_API_SECRET", "") # 未配 = 端点关闭 + r = client.post("/internal/store-mapping/invalidate", + json={"platform": "taobao", "shop_id": "X"}) + assert r.status_code == 503 + + +def test_invalidate_endpoint_auth(client, monkeypatch): + monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET) + body = {"platform": "taobao", "shop_id": "X"} + assert client.post("/internal/store-mapping/invalidate", json=body).status_code == 401 + assert client.post("/internal/store-mapping/invalidate", json=body, + headers={"X-Internal-Secret": "wrong"}).status_code == 401 + r = client.post("/internal/store-mapping/invalidate", json=body, + headers={"X-Internal-Secret": _SECRET}) + assert r.status_code == 200 and r.json()["ok"] is True + + +def test_invalidate_endpoint_marks_and_lookup_miss(client, db, monkeypatch): + monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET) + _mk_row(db, trace_id="t1", name_meituan="店B", name_taobao="店B", id_taobao="BADX", deeplink="dl") + _mk_row(db, trace_id="t2", name_meituan="店B", name_taobao="店B", id_taobao="BADX", deeplink="dl2") + r = client.post("/internal/store-mapping/invalidate", + json={"platform": "taobao", "shop_id": "BADX"}, + headers={"X-Internal-Secret": _SECRET}) + assert r.status_code == 200 and r.json()["affected"] == 2 + lk = client.get("/internal/store-mapping/lookup", + params={"source_platform": "meituan", "name": "店B"}, + headers={"X-Internal-Secret": _SECRET}) + assert "taobao" not in lk.json() + + +def test_invalidate_endpoint_non_taobao_noop(client, monkeypatch): + monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET) + r = client.post("/internal/store-mapping/invalidate", + json={"platform": "jd", "shop_id": "X"}, + headers={"X-Internal-Secret": _SECRET}) + assert r.status_code == 200 and r.json()["affected"] == 0 From f7d86011c1c76114c0ffbd1ae127dbec783f58c6 Mon Sep 17 00:00:00 2001 From: ouzhou Date: Mon, 15 Jun 2026 23:13:14 +0800 Subject: [PATCH 09/36] =?UTF-8?q?feat(ad-revenue):=20admin=20=E5=B9=BF?= =?UTF-8?q?=E5=91=8A=E6=94=B6=E7=9B=8A=E6=8A=A5=E8=A1=A8(=E6=8C=89=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7/=E6=97=A5=E6=9C=9F/=E7=B1=BB=E5=9E=8B/?= =?UTF-8?q?=E5=BA=94=E7=94=A8/=E4=BB=A3=E7=A0=81=E4=BD=8D=20=E8=81=9A?= =?UTF-8?q?=E5=90=88)=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 GET /admin/api/ad-revenue-report:展示条数/收益 + 复用金币审计逐条复算做发奖对账 - ad_ecpm/ad_reward/ad_feed_reward 各加 app_env + our_code_id 两列(alembic 迁移) - ecpm-report / feed-reward 接收并落库 app_env/our_code_id;激励发奖按 ad_session_id 回填 - ad_audit 抽出 audit_rows,报表与逐条审计复用同一复算口径 - 组级 matched 改「组内逐条全一致」,避免应发和==实发和的互相抵消掩盖错误 - list_feedbacks 改 offset 分页并返回 total(配合 admin 页码分页) - 反馈正文上限 _CONTENT_MAX 2000→200 - 文档:新增 admin-ad-revenue-report,更新 ecpm/feed-reward/feedback 及对应 db docs Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/54 Co-authored-by: ouzhou Co-committed-by: ouzhou --- alembic/versions/ad_revenue_report_cols.py | 39 +++ ...merge_ad_revenue_report_cols_and_store_.py | 26 ++ app/admin/main.py | 2 + app/admin/repositories/ad_audit.py | 30 ++- app/admin/repositories/ad_revenue.py | 239 ++++++++++++++++++ app/admin/repositories/queries.py | 14 +- app/admin/routers/ad_revenue.py | 75 ++++++ app/admin/routers/feedback.py | 6 +- app/admin/schemas/ad_revenue.py | 90 +++++++ app/api/v1/ad.py | 36 ++- app/api/v1/feedback.py | 2 +- app/models/ad_ecpm.py | 5 + app/models/ad_feed_reward.py | 3 + app/models/ad_reward.py | 4 + app/repositories/ad_ecpm.py | 10 +- app/repositories/ad_feed_reward.py | 57 ++++- app/repositories/ad_reward.py | 66 ++++- app/schemas/ad.py | 54 +++- docs/api/README.md | 2 + docs/api/ad-ecpm-report.md | 10 +- docs/api/ad-feed-reward.md | 21 +- docs/api/ad-reward-noshow.md | 41 +++ docs/api/admin-ad-revenue-report.md | 108 ++++++++ docs/api/feedback.md | 4 +- docs/database/ad_ecpm_record.md | 10 +- docs/database/ad_feed_reward_record.md | 12 +- docs/database/ad_reward_record.md | 10 +- 27 files changed, 915 insertions(+), 61 deletions(-) create mode 100644 alembic/versions/ad_revenue_report_cols.py create mode 100644 alembic/versions/d4e68464761d_merge_ad_revenue_report_cols_and_store_.py create mode 100644 app/admin/repositories/ad_revenue.py create mode 100644 app/admin/routers/ad_revenue.py create mode 100644 app/admin/schemas/ad_revenue.py create mode 100644 docs/api/ad-reward-noshow.md create mode 100644 docs/api/admin-ad-revenue-report.md diff --git a/alembic/versions/ad_revenue_report_cols.py b/alembic/versions/ad_revenue_report_cols.py new file mode 100644 index 0000000..afbc8b4 --- /dev/null +++ b/alembic/versions/ad_revenue_report_cols.py @@ -0,0 +1,39 @@ +"""ad revenue report columns: app_env + our_code_id + +给 ad_ecpm_record / ad_reward_record / ad_feed_reward_record 各加两列: +- app_env:我们的穿山甲应用环境(prod=傻瓜比价正式 / test=测试应用) +- our_code_id:我们在穿山甲后台配置的代码位 ID(104xxx,非底层 mediation rit) + +供「广告收益报表」按 用户/日期/广告类型/应用/代码位 聚合 展示条数/收益/金币。 +旧数据这两列为 NULL(报表里来源列留空),新数据由客户端上报/发奖时回填。 + +Revision ID: ad_revenue_report_cols +Revises: coupon_engage_per_package +Create Date: 2026-06-15 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "ad_revenue_report_cols" +down_revision = "coupon_engage_per_package" +branch_labels = None +depends_on = None + + +_TABLES = ("ad_ecpm_record", "ad_reward_record", "ad_feed_reward_record") + + +def upgrade() -> None: + for table in _TABLES: + op.add_column(table, sa.Column("app_env", sa.String(length=16), nullable=True)) + op.add_column(table, sa.Column("our_code_id", sa.String(length=64), nullable=True)) + + +def downgrade() -> None: + for table in _TABLES: + op.drop_column(table, "our_code_id") + op.drop_column(table, "app_env") diff --git a/alembic/versions/d4e68464761d_merge_ad_revenue_report_cols_and_store_.py b/alembic/versions/d4e68464761d_merge_ad_revenue_report_cols_and_store_.py new file mode 100644 index 0000000..16fc94c --- /dev/null +++ b/alembic/versions/d4e68464761d_merge_ad_revenue_report_cols_and_store_.py @@ -0,0 +1,26 @@ +"""merge ad_revenue_report_cols and store_mapping_tb_dl_invalid heads + +Revision ID: d4e68464761d +Revises: ad_revenue_report_cols, store_mapping_tb_dl_invalid +Create Date: 2026-06-15 21:55:58.115692 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd4e68464761d' +down_revision: Union[str, Sequence[str], None] = ('ad_revenue_report_cols', 'store_mapping_tb_dl_invalid') +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/main.py b/app/admin/main.py index a422885..c65aa9e 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -14,6 +14,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.admin.routers.ad_audit import router as ad_audit_router +from app.admin.routers.ad_revenue import router as ad_revenue_router from app.admin.routers.admins import router as admins_router from app.admin.routers.audit import router as audit_router from app.admin.routers.auth import router as auth_router @@ -90,3 +91,4 @@ admin_app.include_router(admins_router) admin_app.include_router(audit_router) admin_app.include_router(config_router) admin_app.include_router(ad_audit_router) +admin_app.include_router(ad_revenue_router) diff --git a/app/admin/repositories/ad_audit.py b/app/admin/repositories/ad_audit.py index e08ae68..7cb2941 100644 --- a/app/admin/repositories/ad_audit.py +++ b/app/admin/repositories/ad_audit.py @@ -67,6 +67,8 @@ def _reward_video_rows( "scene": "reward_video", "record_id": rec.id, "user_id": rec.user_id, + "app_env": rec.app_env, + "our_code_id": rec.our_code_id, "created_at": rec.created_at, "status": rec.status, "ecpm": rec.ecpm_raw, @@ -86,6 +88,8 @@ def _reward_video_rows( "scene": "reward_video", "record_id": rec.id, "user_id": rec.user_id, + "app_env": rec.app_env, + "our_code_id": rec.our_code_id, "created_at": rec.created_at, "status": rec.status, "ecpm": rec.ecpm_raw, @@ -150,6 +154,8 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: "scene": "feed", "record_id": rec.id, "user_id": rec.user_id, + "app_env": rec.app_env, + "our_code_id": rec.our_code_id, "created_at": rec.created_at, "status": rec.status, "ecpm": rec.ecpm_raw, @@ -168,6 +174,8 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: "scene": "feed", "record_id": rec.id, "user_id": rec.user_id, + "app_env": rec.app_env, + "our_code_id": rec.our_code_id, "created_at": rec.created_at, "status": rec.status, "ecpm": rec.ecpm_raw, @@ -184,6 +192,22 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: return rows +def audit_rows( + db: Session, *, date: str, user_id: int | None, scene: str | None = None +) -> list[dict]: + """当日逐条发奖复算行(未排序)。scene: None=两类 / "reward_video" / "feed"。 + + 每行含 `app_env`/`our_code_id`/`expected_coin`/`actual_coin` 等,供金币审计逐条对账, + 也供广告收益报表把「应发/实发」按 用户×类型×应用×代码位 聚合(见 ad_revenue,复用同一复算口径)。 + """ + rows: list[dict] = [] + if scene in (None, "reward_video"): + rows.extend(_reward_video_rows(db, date=date, user_id=user_id)) + if scene in (None, "feed"): + rows.extend(_feed_rows(db, date=date, user_id=user_id)) + return rows + + def ad_coin_audit( db: Session, *, @@ -200,11 +224,7 @@ def ad_coin_audit( 影响;`items` 才是展示集(only_mismatch 时只取 ✗ 行)按 created_at 倒序截断到 limit。 份序号在全天数据上已算好,limit 只影响展示条数、不影响 expected 复算正确性。 """ - rows: list[dict] = [] - if scene in (None, "reward_video"): - rows.extend(_reward_video_rows(db, date=date, user_id=user_id)) - if scene in (None, "feed"): - rows.extend(_feed_rows(db, date=date, user_id=user_id)) + rows = audit_rows(db, date=date, user_id=user_id, scene=scene) rows.sort(key=lambda r: (r["created_at"], r["record_id"]), reverse=True) total = len(rows) diff --git a/app/admin/repositories/ad_revenue.py b/app/admin/repositories/ad_revenue.py new file mode 100644 index 0000000..206726b --- /dev/null +++ b/app/admin/repositories/ad_revenue.py @@ -0,0 +1,239 @@ +"""admin 广告收益报表:按 用户 / 日期 / 广告类型 / 应用 / 代码位 聚合(单表含发奖对账)。 + +只读。聚合键 = user_id × ad_type × app_env × our_code_id;每组一行同时给出: +- 展示条数 + 收益:`ad_ecpm_record`(每行 = 客户端一次广告展示;收益 = Σ eCPM元 ÷ 1000)。 + 激励视频每次展示上报一行;信息流轮播每条展示各上报一行(每条独立 id,不复用会话)。 +- 应发金币 / 实发金币:复用金币审计的**逐条复算**(`ad_audit.audit_rows`,与正式发奖同一公式口径, + 不另写公式),把每条发奖记录的 expected/actual 按同维度求和;`matched` = 组内**逐条**全部一致 + (任一条不符该组即不符,不用「应发和==实发和」以免互相抵消掩盖错误)。**不改发奖逻辑**,只读复算。 + +展示与发奖来自不同表,做并集:有展示无发奖(用户中途关 / 未达发奖)、有发奖无展示 +(未上报 eCPM)都各自成行。app_env/our_code_id 旧数据为 NULL → 归到「来源未知」组。 + +⚠️ 局限:① 历史 Draw 发奖混在 ad_feed_reward_record 无类型标记,金币侧统一记 `feed`(迁移后 Draw +不再产生新数据)。② 聚合级只能看出「某组应发≠实发」,定位到具体哪条仍需逐条审计接口(ad-coin-audit)。 +""" +from __future__ import annotations + +from datetime import date as _date, datetime, timedelta, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.admin.repositories import ad_audit +from app.core import rewards +from app.models.ad_ecpm import AdEcpmRecord + + +def _cn_hour(dt: datetime) -> int: + """created_at(UTC 口径)→ 北京时间小时(0–23)。naive 当 UTC 处理(sqlite),tz-aware 直接换算(pg)。""" + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(rewards.CN_TZ).hour + + +def _key( + report_date: str, + user_id: int, + ad_type: str, + app_env: str | None, + our_code_id: str | None, + hour: int | None, +) -> tuple: + return (report_date, user_id, ad_type, app_env or None, our_code_id or None, hour) + + +def _date_range(date_from: str, date_to: str) -> list[str]: + """闭区间内逐日 'YYYY-MM-DD' 串(含首尾)。date_from > date_to 时返回空。""" + d0 = _date.fromisoformat(date_from) + d1 = _date.fromisoformat(date_to) + out: list[str] = [] + d = d0 + while d <= d1: + out.append(d.isoformat()) + d += timedelta(days=1) + return out + + +# 审计行的 scene 与报表 ad_type 一一对应 +_SCENE_TO_AD_TYPE = {"reward_video": "reward_video", "feed": "feed"} + + +def ad_revenue_report( + db: Session, + *, + date_from: str, + date_to: str, + user_id: int | None = None, + ad_type: str | None = None, + granularity: str = "day", + limit: int = 500, +) -> dict: + """日期区间(北京时间,闭区间)广告收益聚合 + 发奖对账。单日时 date_from==date_to。 + + 聚合键含**日期**:report_date × user × ad_type × app_env × our_code_id(× 北京小时,granularity=hour)。 + ad_type: None=全部 / reward_video / feed / draw。 + granularity: "day"=按天 / "hour"=按小时(聚合键再加北京小时 0–23,每组一行)。 + limit 只截断展示明细,total 与 total_* / daily 在全量上统计(不受 limit 影响),数字始终可信。 + + 返回额外含 `daily`(按日期汇总的展示/收益/应发/实发,供前端按天趋势图;不受 limit 影响)。 + + 注:按小时下,展示按 ecpm 记录的小时、金币按发奖记录的小时各自归桶——S2S 回调可能比展示晚 + 一会儿,故同一次广告的展示与金币偶尔落相邻小时(按天则一致)。 + """ + by_hour = granularity == "hour" + groups: dict[tuple, dict] = {} + + def _grp(key: tuple) -> dict: + g = groups.get(key) + if g is None: + rdate, uid, atype, app_env, code_id, hour = key + g = { + "report_date": rdate, + "user_id": uid, + "ad_type": atype, + "app_env": app_env, + "our_code_id": code_id, + "hour": hour, + "impressions": 0, + "revenue_yuan": 0.0, + "expected_coin": 0, + "actual_coin": 0, + "adns": set(), + "impression_records": [], # 该组逐条展示明细(展开下钻用) + "records": [], # 该组逐条发奖复算明细(展开下钻用) + } + groups[key] = g + return g + + # 1) 展示条数 + 收益 ← ad_ecpm_record(report_date 闭区间;字符串 YYYY-MM-DD 字典序即日期序) + stmt = select(AdEcpmRecord).where( + AdEcpmRecord.report_date >= date_from, + AdEcpmRecord.report_date <= date_to, + ) + if user_id is not None: + stmt = stmt.where(AdEcpmRecord.user_id == user_id) + if ad_type is not None: + stmt = stmt.where(AdEcpmRecord.ad_type == ad_type) + for rec in db.execute(stmt).scalars(): + hour = _cn_hour(rec.created_at) if by_hour else None + g = _grp(_key(rec.report_date, rec.user_id, rec.ad_type, rec.app_env, rec.our_code_id, hour)) + g["impressions"] += 1 + # 单次展示收益(元) = eCPM元 ÷ 1000(每千次→单次);用与发奖同源的解析,口径一致。 + rev = rewards.parse_ecpm_yuan(rec.ecpm_raw) / 1000.0 + g["revenue_yuan"] += rev + if rec.adn: + g["adns"].add(rec.adn) + g["impression_records"].append({ + "id": rec.id, + "created_at": rec.created_at, + "ecpm": rec.ecpm_raw, + "revenue_yuan": round(rev, 6), + "adn": rec.adn, + "slot_id": rec.slot_id, + }) + + # 2) 应发 / 实发金币 ← 复用金币审计逐条复算(同一公式口径),按同维度求和。 + # audit_rows 是单日的,区间逐日调用,每天的行归到当天 report_date(语义与单日报表完全一致)。 + # ad_type=draw 时审计无对应记录(scene 只有 reward_video/feed),金币侧自然为空。 + audit_scene = _SCENE_TO_AD_TYPE.get(ad_type) if ad_type is not None else None + if ad_type is None or audit_scene is not None: + for d in _date_range(date_from, date_to): + for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene): + atype = _SCENE_TO_AD_TYPE.get(row["scene"], row["scene"]) + hour = _cn_hour(row["created_at"]) if by_hour else None + g = _grp(_key(d, row["user_id"], atype, row.get("app_env"), row.get("our_code_id"), hour)) + g["expected_coin"] += int(row["expected_coin"]) + g["actual_coin"] += int(row["actual_coin"]) + # 逐条明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致)——前端展开该组时下钻展示。 + g["records"].append({ + "record_id": row["record_id"], + "created_at": row["created_at"], + "status": row["status"], + "ecpm": row["ecpm"], + "ecpm_factor": row["ecpm_factor"], + "units": row["units"], + "lt_index_start": row["lt_index_start"], + "lt_index_end": row["lt_index_end"], + "lt_factor_start": row["lt_factor_start"], + "lt_factor_end": row["lt_factor_end"], + "expected_coin": row["expected_coin"], + "actual_coin": row["actual_coin"], + "matched": row["matched"], + }) + + rows = list(groups.values()) + rows.sort( + key=lambda r: ( + r["report_date"], + r["user_id"], + r["hour"] if r["hour"] is not None else -1, + r["ad_type"] or "", + r["our_code_id"] or "", + ) + ) + + total_impressions = sum(r["impressions"] for r in rows) + total_expected_coin = sum(r["expected_coin"] for r in rows) + total_actual_coin = sum(r["actual_coin"] for r in rows) + total_revenue_yuan = round(sum(r["revenue_yuan"] for r in rows), 6) + + # 按日期汇总(全量,不受 limit):供前端按天趋势图。 + daily_map: dict[str, dict] = {} + for r in rows: + d = daily_map.get(r["report_date"]) + if d is None: + d = { + "date": r["report_date"], + "impressions": 0, + "revenue_yuan": 0.0, + "expected_coin": 0, + "actual_coin": 0, + } + daily_map[r["report_date"]] = d + d["impressions"] += r["impressions"] + d["revenue_yuan"] += r["revenue_yuan"] + d["expected_coin"] += r["expected_coin"] + d["actual_coin"] += r["actual_coin"] + daily = [ + {**d, "revenue_yuan": round(d["revenue_yuan"], 6)} + for d in sorted(daily_map.values(), key=lambda x: x["date"]) + ] + + items = [ + { + "report_date": r["report_date"], + "user_id": r["user_id"], + "ad_type": r["ad_type"], + "app_env": r["app_env"], + "our_code_id": r["our_code_id"], + "hour": r["hour"], + "impressions": r["impressions"], + "revenue_yuan": round(r["revenue_yuan"], 6), + "expected_coin": r["expected_coin"], + "actual_coin": r["actual_coin"], + # 组内**逐条**全部一致才记一致——不能用「应发和==实发和」,否则一条多发+一条少发会互相 + # 抵消、求和相等被误判为 ✓,掩盖真实发奖错误。纯展示无发奖记录的组 all([]) → True。 + "matched": all(rec["matched"] for rec in r["records"]), + "adns": sorted(r["adns"]), + "impression_records": sorted( + r["impression_records"], key=lambda x: (x["created_at"], x["id"]) + ), + "records": sorted(r["records"], key=lambda x: (x["created_at"], x["record_id"])), + } + for r in rows[:limit] + ] + + return { + "total": len(rows), + "truncated": len(rows) > limit, + "total_impressions": total_impressions, + "total_revenue_yuan": total_revenue_yuan, + "total_expected_coin": total_expected_coin, + "total_actual_coin": total_actual_coin, + "mismatch_count": sum( + 1 for r in rows if not all(rec["matched"] for rec in r["records"]) + ), + "daily": daily, + "items": items, + } diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index a4721e0..6e39192 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -286,10 +286,11 @@ def list_feedbacks( sort_order: str = "desc", limit: int = 20, cursor: int | None = None, -) -> tuple[list[Feedback], int | None]: +) -> tuple[list[Feedback], int | None, int]: """反馈工单列表。支持 状态 / 用户ID / 内容模糊 / 提交时间范围 筛选,按 id·提交时间排序。 **offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),代价是翻页期间 - 数据变动可能错位一条——admin 低频场景可接受。created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。""" + 数据变动可能错位一条——admin 低频场景可接受。返回 (items, next_cursor, total),total 供页码分页。 + created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。""" stmt = select(Feedback) if status: stmt = stmt.where(Feedback.status == status) @@ -306,14 +307,7 @@ def list_feedbacks( sort_col = sort_cols.get(sort_by, Feedback.id) order_fn = asc if sort_order == "asc" else desc id_order = asc(Feedback.id) if sort_order == "asc" else desc(Feedback.id) - stmt = stmt.order_by(order_fn(sort_col), id_order) - - offset = max(cursor or 0, 0) - rows = list(db.execute(stmt.offset(offset).limit(limit + 1)).scalars().all()) - has_more = len(rows) > limit - items = rows[:limit] - next_cursor = offset + limit if has_more else None - return items, next_cursor + return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor) def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder | None: diff --git a/app/admin/routers/ad_revenue.py b/app/admin/routers/ad_revenue.py new file mode 100644 index 0000000..1a67270 --- /dev/null +++ b/app/admin/routers/ad_revenue.py @@ -0,0 +1,75 @@ +"""admin 广告收益报表:按 用户/日期/广告类型/应用/代码位 聚合 展示条数 / 收益 / 金币。 + +任意已登录 admin 可看(只读,不涉及资金操作)。聚合逻辑在 app/admin/repositories/ad_revenue.py。 +""" +from __future__ import annotations + +from datetime import date as _date +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.admin.deps import AdminDb, get_current_admin +from app.admin.repositories import ad_revenue +from app.admin.schemas.ad_revenue import AdRevenueDaily, AdRevenueReportOut, AdRevenueRow +from app.core.rewards import cn_today + +router = APIRouter( + prefix="/admin/api/ad-revenue-report", + tags=["admin-ad-revenue-report"], + dependencies=[Depends(get_current_admin)], +) + +# 区间最大跨度(天);超出拒绝,避免审计页一次拉过多天(逐日审计 + 大查询)拖垮接口。 +_MAX_RANGE_DAYS = 92 + + +def _parse_day(value: str | None, *, field: str, default: _date) -> _date: + if value is None: + return default + try: + return _date.fromisoformat(value) + except ValueError as e: + raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e + + +@router.get("", response_model=AdRevenueReportOut, summary="广告收益报表(按 日期区间/用户/类型/应用/代码位 聚合)") +def get_ad_revenue_report( + 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, + user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None, + ad_type: Annotated[ + str | None, + Query(description="reward_video / feed / draw;不传=全部类型"), + ] = None, + granularity: Annotated[ + str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day") + ] = "day", + limit: Annotated[int, Query(ge=1, le=1000)] = 500, +) -> AdRevenueReportOut: + 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} 天") + + result = ad_revenue.ad_revenue_report( + db, date_from=d_from.isoformat(), date_to=d_to.isoformat(), + user_id=user_id, ad_type=ad_type, granularity=granularity, limit=limit, + ) + return AdRevenueReportOut( + date_from=d_from.isoformat(), + date_to=d_to.isoformat(), + daily=[AdRevenueDaily(**d) for d in result["daily"]], + total=result["total"], + truncated=result["truncated"], + total_impressions=result["total_impressions"], + total_revenue_yuan=result["total_revenue_yuan"], + total_expected_coin=result["total_expected_coin"], + total_actual_coin=result["total_actual_coin"], + mismatch_count=result["mismatch_count"], + items=[AdRevenueRow(**r) for r in result["items"]], + ) diff --git a/app/admin/routers/feedback.py b/app/admin/routers/feedback.py index 51a2a29..249378d 100644 --- a/app/admin/routers/feedback.py +++ b/app/admin/routers/feedback.py @@ -34,7 +34,7 @@ def list_feedbacks( limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[FeedbackOut]: - items, next_cursor = queries.list_feedbacks( + items, next_cursor, total = queries.list_feedbacks( db, status=status, user_id=user_id, @@ -47,7 +47,9 @@ def list_feedbacks( cursor=cursor, ) return CursorPage( - items=[FeedbackOut.model_validate(f) for f in items], next_cursor=next_cursor, + items=[FeedbackOut.model_validate(f) for f in items], + next_cursor=next_cursor, + total=total, ) diff --git a/app/admin/schemas/ad_revenue.py b/app/admin/schemas/ad_revenue.py new file mode 100644 index 0000000..beeb132 --- /dev/null +++ b/app/admin/schemas/ad_revenue.py @@ -0,0 +1,90 @@ +"""广告收益报表 schemas。 + +按 用户 / 日期 / 广告类型 / 应用 / 代码位 聚合的只读报表:展示条数、收益(元)、金币、来源。 +字段 snake_case;收益按元(float),金币按整数。 +""" +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class AdRevenueImpression(BaseModel): + """聚合行下钻的单条**展示**明细(每次广告展示一条,展开该组时展示)。""" + + id: int = Field(..., description="ad_ecpm_record 主键") + created_at: datetime + ecpm: str = Field(..., description="本次展示 eCPM 原始值(分/千次展示)") + revenue_yuan: float = Field(..., description="本次展示预估收益(元)= eCPM元 ÷ 1000") + adn: str | None = Field(None, description="实际填充 ADN 子渠道(pangle/gdt…)") + slot_id: str | None = Field(None, description="底层 mediation rit(非我们配置的广告位 ID)") + + +class AdRevenueRecord(BaseModel): + """聚合行下钻的单条发奖复算明细(与金币审计同源,展开该组时展示)。""" + + record_id: int + created_at: datetime + status: str = Field(..., description="granted / capped / ecpm_missing") + ecpm: str | None = Field(None, description="本次采用的 eCPM 原始值(分/千次展示)") + ecpm_factor: float | None = Field(None, description="因子1(eCPM 档);非 granted 为空") + units: int = Field(..., description="折算份数:激励视频恒 1;信息流 = 满 10 秒份数") + lt_index_start: int | None = Field(None, description="本条占用「账号累计第几份」的起") + lt_index_end: int | None = Field(None, description="本条占用「账号累计第几份」的止;激励视频 = 起") + lt_factor_start: float | None = Field(None, description="因子2(LT)起值") + lt_factor_end: float | None = Field(None, description="因子2(LT)止值;激励视频 = 起") + expected_coin: int = Field(..., description="按公式复算应发金币") + actual_coin: int = Field(..., description="实际入账金币") + matched: bool = Field(..., description="复算与实发是否一致") + + +class AdRevenueDaily(BaseModel): + """按日期汇总的一天(供前端按天趋势图;全量,不受 limit 影响)。""" + + date: str = Field(..., description="北京时间 YYYY-MM-DD") + impressions: int = Field(..., description="当天展示条数合计") + revenue_yuan: float = Field(..., description="当天预估收益合计(元)") + expected_coin: int = Field(..., description="当天应发金币合计") + actual_coin: int = Field(..., description="当天实发金币合计") + + +class AdRevenueRow(BaseModel): + """一个聚合组(report_date × user × ad_type × app_env × our_code_id)的汇总。""" + + report_date: str = Field(..., description="该组所属日期(北京时间 YYYY-MM-DD)") + user_id: int + ad_type: str = Field(..., description="reward_video(激励视频) / feed(信息流) / draw(历史 Draw 信息流)") + app_env: str | None = Field(None, description="我们的应用:prod(傻瓜比价正式) / test(测试应用);旧数据为空") + our_code_id: str | None = Field(None, description="我们后台配置的代码位 ID(104xxx);旧数据为空") + hour: int | None = Field(None, description="北京时间小时 0–23(granularity=hour 时有值;按天为 null)") + impressions: int = Field(..., description="展示条数(每条广告展示一条;轮播每条各计一次)") + revenue_yuan: float = Field(..., description="收益(元)= Σ(eCPM元 ÷ 1000);测试应用多为 0") + expected_coin: int = Field(..., description="应发金币(按公式复算,与金币审计同源)") + actual_coin: int = Field(..., description="实发金币(实际入账,按现发奖算法)") + matched: bool = Field(..., description="该组应发==实发(组内任一条不符则 false)") + adns: list[str] = Field(default_factory=list, description="实际填充的底层 ADN 子渠道集合(如 pangle/gdt)") + impression_records: list[AdRevenueImpression] = Field( + default_factory=list, + description="该组逐条展示明细(时间/eCPM/收益/adn);展开下钻用,无发奖也有(只要有展示)", + ) + records: list[AdRevenueRecord] = Field( + default_factory=list, + description="该组逐条发奖复算明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致);展开下钻用,纯展示无发奖记录的组为空", + ) + + +class AdRevenueReportOut(BaseModel): + """报表响应:全量统计 + 按天趋势 + 聚合明细。""" + + date_from: str = Field(..., description="报表起始日期(北京时间 YYYY-MM-DD)") + date_to: str = Field(..., description="报表结束日期(北京时间 YYYY-MM-DD,闭区间;单日时与 date_from 相同)") + daily: list[AdRevenueDaily] = Field(..., description="按日期汇总序列(全量,供按天趋势图)") + total: int = Field(..., description="聚合组总数(全量,不受 limit 影响)") + truncated: bool = Field(..., description="明细是否被 limit 截断") + total_impressions: int = Field(..., description="全量展示条数合计") + total_revenue_yuan: float = Field(..., description="全量收益合计(元)") + total_expected_coin: int = Field(..., description="全量应发金币合计") + total_actual_coin: int = Field(..., description="全量实发金币合计") + mismatch_count: int = Field(..., description="应发≠实发的组数(=0 说明全部按公式发放)") + items: list[AdRevenueRow] = Field(..., description="聚合明细(按 用户→类型→代码位 排序)") diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index 0c7e351..9508061 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -32,6 +32,8 @@ from app.schemas.ad import ( FeedRewardIn, FeedRewardOut, PangleCallbackOut, + RewardNoShowIn, + RewardNoShowOut, TestGrantIn, TestGrantOut, WatchReportIn, @@ -242,10 +244,12 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm ad_type=payload.ad_type, ecpm_raw=payload.ecpm, ad_session_id=payload.ad_session_id, adn=payload.adn, slot_id=payload.slot_id, + app_env=payload.app_env, our_code_id=payload.our_code_id, ) logger.info( - "ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s", + "ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s app=%s code=%s", user.id, payload.ad_type, payload.ad_session_id, payload.ecpm, payload.adn, payload.slot_id, + payload.app_env, payload.our_code_id, ) return EcpmReportOut(ok=True) @@ -359,6 +363,9 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed ad_session_id=payload.ad_session_id, adn=payload.adn, slot_id=payload.slot_id, + app_env=payload.app_env, + our_code_id=payload.our_code_id, + aborted=payload.aborted, ) logger.info( "feed ad reward user_id=%d event=%s status=%s units=%d coin=%d", @@ -371,3 +378,30 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed unit_count=rec.unit_count, daily_limit=rewards.get_ad_daily_limit(db), ) + + +@router.post( + "/reward-noshow", + response_model=RewardNoShowOut, + summary="激励视频提前关闭/未发奖留痕", + dependencies=[Depends(rate_limit(120, 60, "ad-reward-noshow"))], +) +def reward_noshow(payload: RewardNoShowIn, user: CurrentUser, db: DbSession) -> RewardNoShowOut: + """激励视频展示了但用户提前关/跳过、未触发 S2S 发奖时,客户端 best-effort 上报一条留痕, + 让广告收益报表能呈现「有展示、没发金币」的原因。不发金币;同一 session 已发奖则跳过。 + """ + rec = crud_ad.record_reward_noshow( + db, + user.id, + ad_session_id=payload.ad_session_id, + ecpm=payload.ecpm, + adn=payload.adn, + slot_id=payload.slot_id, + app_env=payload.app_env, + our_code_id=payload.our_code_id, + ) + logger.info( + "ad reward noshow user_id=%d session=%s watched=%ds -> status=%s", + user.id, payload.ad_session_id, payload.watched_seconds, rec.status, + ) + return RewardNoShowOut(ok=True, status=rec.status) diff --git a/app/api/v1/feedback.py b/app/api/v1/feedback.py index c085780..e59fb54 100644 --- a/app/api/v1/feedback.py +++ b/app/api/v1/feedback.py @@ -21,7 +21,7 @@ logger = logging.getLogger("shagua.feedback") router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"]) _MAX_IMAGES = 6 -_CONTENT_MAX = 2000 +_CONTENT_MAX = 200 _CONTACT_MAX = 128 diff --git a/app/models/ad_ecpm.py b/app/models/ad_ecpm.py index 0f46b51..c1aa029 100644 --- a/app/models/ad_ecpm.py +++ b/app/models/ad_ecpm.py @@ -35,6 +35,11 @@ class AdEcpmRecord(Base): adn: Mapped[str | None] = mapped_column(String(32), nullable=True) # 实际展示用的代码位(底层 mediation rit,非客户端配置位) slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + # 我们的穿山甲应用环境:prod(傻瓜比价正式应用) / test(测试应用)。客户端按 AdConfig.useProductionApp 上报。 + # 与底层 adn 不同:这是「我们用的是哪个 App」,adn 是「聚合后实际填充的子渠道」。旧数据为 NULL。 + app_env: Mapped[str | None] = mapped_column(String(16), nullable=True) + # 我们在穿山甲后台配置的代码位 ID(AdConfig.feedCodeId/rewardCodeId 返回的 104xxx,**非** slot_id 的底层 rit)。旧数据为 NULL。 + our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True) # 客户端上报的 eCPM 原始字符串(单位:分/千次展示,SDK getEcpm 原值,原样存) ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False) # 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较) diff --git a/app/models/ad_feed_reward.py b/app/models/ad_feed_reward.py index 99f261e..fb1ff82 100644 --- a/app/models/ad_feed_reward.py +++ b/app/models/ad_feed_reward.py @@ -30,6 +30,9 @@ class AdFeedRewardRecord(Base): ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False) adn: Mapped[str | None] = mapped_column(String(32), nullable=True) slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + # 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx,由客户端 feed-reward 上报带上。旧数据为 NULL。 + app_env: Mapped[str | None] = mapped_column(String(16), nullable=True) + our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True) coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0) status: Mapped[str] = mapped_column(String(16), nullable=False, default="granted") diff --git a/app/models/ad_reward.py b/app/models/ad_reward.py index c9e8e9f..d45be76 100644 --- a/app/models/ad_reward.py +++ b/app/models/ad_reward.py @@ -33,6 +33,10 @@ class AdRewardRecord(Base): ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) # 本次发奖采用的 eCPM 原始值(回调自带或按 ad_session_id 匹配的客户端上报) ecpm_raw: Mapped[str | None] = mapped_column(String(32), nullable=True) + # 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx。 + # S2S 回调本身不带这俩,发奖时按 ad_session_id 匹配 ad_ecpm_record 回填(查不到为 NULL)。 + app_env: Mapped[str | None] = mapped_column(String(16), nullable=True) + our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True) # 北京时间日期串 'YYYY-MM-DD',按它等值统计当日发奖次数 reward_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False) # 穿山甲上报的奖励名(参考,不作发奖依据) diff --git a/app/repositories/ad_ecpm.py b/app/repositories/ad_ecpm.py index 9841ae9..adfc7fd 100644 --- a/app/repositories/ad_ecpm.py +++ b/app/repositories/ad_ecpm.py @@ -23,8 +23,14 @@ def create_ecpm_record( ad_session_id: str | None = None, adn: str | None = None, slot_id: str | None = None, + app_env: str | None = None, + our_code_id: str | None = None, ) -> AdEcpmRecord: - """落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。""" + """落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。 + + app_env(prod/test)与 our_code_id(我们后台配置的 104xxx 代码位)供广告收益报表按 + 应用/代码位聚合;与 adn(实际填充子渠道)/slot_id(底层 rit)是两组不同口径。 + """ if ad_session_id: existing = find_by_session(db, user_id=user_id, ad_session_id=ad_session_id) if existing is not None: @@ -35,6 +41,8 @@ def create_ecpm_record( ad_session_id=ad_session_id, adn=adn, slot_id=slot_id, + app_env=app_env, + our_code_id=our_code_id, ecpm_raw=ecpm_raw, report_date=cn_today().isoformat(), ) diff --git a/app/repositories/ad_feed_reward.py b/app/repositories/ad_feed_reward.py index c2e367c..64fe277 100644 --- a/app/repositories/ad_feed_reward.py +++ b/app/repositories/ad_feed_reward.py @@ -69,12 +69,19 @@ def grant_feed_reward( ad_session_id: str | None = None, adn: str | None = None, slot_id: str | None = None, + app_env: str | None = None, + our_code_id: str | None = None, + aborted: bool = False, ) -> AdFeedRewardRecord: - """完成一条信息流广告后结算奖励。client_event_id 幂等,同号重试不重复发。 + """比价/领券一整场信息流(轮播多条)结束后结算奖励。client_event_id 幂等,同号重试不重复发。 - 一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到 FEED_MAX_DURATION_SECONDS - 限单事件份数,eCPM 在 rewards.calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN 限单份金额; - 叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间。 + 发奖规则:**比价全程不关广告才发**,金额按整场**总观看时长**折份(每 10 秒 1 份)。 + - aborted=True(用户中途 ✕ 关闭):整场不发,记 status='closed_early' 留痕(原因可查)。 + - 总时长不足 10 秒(unit_count==0):记 status='too_short' 不发。 + - 命中当日条数上限:记 status='capped' 不发。 + duration_seconds 是整场累计秒数。一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到 + FEED_MAX_DURATION_SECONDS 限单场份数,eCPM 在 rewards.calculate_ad_reward_coin 内钳到 + AD_ECPM_MAX_FEN 限单份金额;叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间。 """ existing = _find_by_event(db, client_event_id) if existing is not None: @@ -85,6 +92,25 @@ def grant_feed_reward( safe_duration = max(0, min(duration_seconds, FEED_MAX_DURATION_SECONDS)) unit_count = safe_duration // FEED_REWARD_UNIT_SECONDS + # 用户中途关闭广告:整场不发(全程不关才发),留一条 closed_early 记录原因。优先级最高。 + if aborted: + rec = AdFeedRewardRecord( + client_event_id=client_event_id, + user_id=user_id, + reward_date=today, + duration_seconds=safe_duration, + unit_count=unit_count, + ad_session_id=ad_session_id, + ecpm_raw=ecpm, + adn=adn, + slot_id=slot_id, + app_env=app_env, + our_code_id=our_code_id, + coin=0, + status="closed_early", + ) + return _commit_record(db, rec, client_event_id) + if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db): rec = AdFeedRewardRecord( client_event_id=client_event_id, @@ -96,11 +122,32 @@ def grant_feed_reward( ecpm_raw=ecpm, adn=adn, slot_id=slot_id, + app_env=app_env, + our_code_id=our_code_id, coin=0, status="capped", ) return _commit_record(db, rec, client_event_id) + # 整场总时长不足 10 秒,凑不满一份 → 不发,记 too_short 留痕。 + if unit_count == 0: + rec = AdFeedRewardRecord( + client_event_id=client_event_id, + user_id=user_id, + reward_date=today, + duration_seconds=safe_duration, + unit_count=0, + ad_session_id=ad_session_id, + ecpm_raw=ecpm, + adn=adn, + slot_id=slot_id, + app_env=app_env, + our_code_id=our_code_id, + coin=0, + status="too_short", + ) + return _commit_record(db, rec, client_event_id) + coin = _unit_reward_total(db, user_id, ecpm, unit_count) if coin > 0: crud_wallet.grant_coins( @@ -118,6 +165,8 @@ def grant_feed_reward( ecpm_raw=ecpm, adn=adn, slot_id=slot_id, + app_env=app_env, + our_code_id=our_code_id, coin=coin, status="granted", ) diff --git a/app/repositories/ad_reward.py b/app/repositories/ad_reward.py index 7c433d5..f9fb16f 100644 --- a/app/repositories/ad_reward.py +++ b/app/repositories/ad_reward.py @@ -90,6 +90,16 @@ def grant_ad_reward( today = cn_today().isoformat() + # 按 ad_session_id 匹配客户端 eCPM 上报:既用于缺 eCPM 时回退取值,也把「来源」 + # (我们的应用 app_env + 我们配置的代码位 our_code_id)回填到发奖记录,供广告收益报表聚合。 + # S2S 回调本身不带这俩;查不到(未上报 eCPM)则留空。 + ecpm_rec = ( + crud_ecpm.find_by_session(db, user_id=user_id, ad_session_id=ad_session_id) + if ad_session_id else None + ) + src_app_env = ecpm_rec.app_env if ecpm_rec is not None else None + src_code_id = ecpm_rec.our_code_id if ecpm_rec is not None else None + # #3 每日上限:当前产品只保留发奖次数上限(默认 500 次)。旧的观看时长闸保留字段, # 但 DAILY_AD_WATCH_SECONDS_LIMIT=0 时视为停用,不能命中 capped。 over_time = ( @@ -102,19 +112,18 @@ def grant_ad_reward( trans_id=trans_id, user_id=user_id, coin=0, status="capped", reward_date=today, reward_name=reward_name, raw=raw, reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm, + app_env=src_app_env, our_code_id=src_code_id, ) return _commit_record(db, rec, trans_id) - ecpm_raw = ecpm - if not ecpm_raw and ad_session_id: - ecpm_rec = crud_ecpm.find_by_session(db, user_id=user_id, ad_session_id=ad_session_id) - ecpm_raw = ecpm_rec.ecpm_raw if ecpm_rec is not None else None + ecpm_raw = ecpm or (ecpm_rec.ecpm_raw if ecpm_rec is not None else None) if not ecpm_raw: rec = AdRewardRecord( trans_id=trans_id, user_id=user_id, coin=0, status="ecpm_missing", reward_date=today, reward_name=reward_name, raw=raw, reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=None, + app_env=src_app_env, our_code_id=src_code_id, ) return _commit_record(db, rec, trans_id) @@ -131,6 +140,55 @@ def grant_ad_reward( trans_id=trans_id, user_id=user_id, coin=coin, status="granted", reward_date=today, reward_name=reward_name, raw=raw, reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm_raw, + app_env=src_app_env, our_code_id=src_code_id, + ) + return _commit_record(db, rec, trans_id) + + +def record_reward_noshow( + db: Session, + user_id: int, + *, + ad_session_id: str, + ecpm: str | None = None, + adn: str | None = None, + slot_id: str | None = None, + app_env: str | None = None, + our_code_id: str | None = None, + reward_scene: str = "reward_video", +) -> AdRewardRecord: + """客户端上报「激励视频展示了但用户提前关/跳过、未触发发奖」,落一条 coin=0 status='closed_early' + 记录,供广告收益报表把「不发金币的原因」也呈现出来(只留痕,不发币)。 + + 幂等键 trans_id = 'noreward:{ad_session_id}'(每次展示唯一)。若同一 ad_session_id 已有 granted + 记录(S2S 已发奖,正常路径),说明用户其实看完了 → 跳过不写、原样返回那条,避免与正常发奖重复。 + app_env/our_code_id 由客户端直接带上(它本就持有);查不到 user 抛 UnknownUserError。 + """ + if db.get(User, user_id) is None: + raise UnknownUserError + # 同一次展示已正常发奖 → 不再记 closed_early(防与 S2S granted 重复) + granted = db.execute( + select(AdRewardRecord) + .where( + AdRewardRecord.user_id == user_id, + AdRewardRecord.ad_session_id == ad_session_id, + AdRewardRecord.status == "granted", + ) + .limit(1) + ).scalar_one_or_none() + if granted is not None: + return granted + + trans_id = f"noreward:{ad_session_id}" + existing = _find_by_trans(db, trans_id) + if existing is not None: + return existing + + rec = AdRewardRecord( + trans_id=trans_id, user_id=user_id, coin=0, status="closed_early", + reward_date=cn_today().isoformat(), reward_name=None, raw=None, + reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm, + app_env=app_env, our_code_id=our_code_id, ) return _commit_record(db, rec, trans_id) diff --git a/app/schemas/ad.py b/app/schemas/ad.py index a1cb7e0..ae56aab 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -57,6 +57,12 @@ class EcpmReportIn(BaseModel): ) adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle") slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)") + app_env: str | None = Field( + None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)" + ) + our_code_id: str | None = Field( + None, max_length=64, description="我们后台配置的代码位 ID(AdConfig 的 104xxx,非底层 rit)" + ) class EcpmReportOut(BaseModel): @@ -115,24 +121,62 @@ class TestGrantOut(BaseModel): class FeedRewardIn(BaseModel): - """信息流广告完成后结算奖励。 + """比价/领券一整场信息流(轮播多条)结束后结算奖励。 - 每展示满 10 秒累计一份奖励,视频完成后一次性入账。client_event_id 用于客户端超时重试幂等。 + 规则:全程不关广告才发,金额按整场**总观看时长**折份(每 10 秒 1 份)。client_event_id 用于 + 客户端超时重试幂等。中途被用户关闭时传 aborted=True,整场不发(只记 closed_early)。 """ client_event_id: str = Field(..., min_length=8, max_length=64, description="客户端生成的幂等事件 id") ad_session_id: str | None = Field( None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id" ) - ecpm: str = Field(..., description="本条信息流广告 eCPM,按分/千次展示处理(SDK getEcpm 原值,非元)") - duration_seconds: int = Field(..., ge=0, description="本条广告实际展示/播放秒数") + ecpm: str = Field(..., description="本场信息流 eCPM(代表值,按分/千次展示处理;SDK getEcpm 原值,非元)") + duration_seconds: int = Field(..., ge=0, description="整场累计观看秒数(轮播各条相加)") adn: str | None = Field(None, description="实际投放 ADN") slot_id: str | None = Field(None, description="实际展示代码位") + app_env: str | None = Field( + None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)" + ) + our_code_id: str | None = Field( + None, max_length=64, description="我们后台配置的代码位 ID(AdConfig 的 104xxx,非底层 rit)" + ) + aborted: bool = Field( + False, description="用户中途 ✕ 关闭广告(未走完比价):整场不发,记 closed_early" + ) class FeedRewardOut(BaseModel): granted: bool = Field(..., description="本次是否入账。达上限时 false") - status: str = Field(..., description="granted / capped") + status: str = Field(..., description="granted / capped / too_short / closed_early") coin: int = Field(..., description="本次发放金币") unit_count: int = Field(..., description="按 10 秒折算出的奖励份数") daily_limit: int = Field(..., description="每日信息流展示次数上限") + + +class RewardNoShowIn(BaseModel): + """激励视频展示了但用户提前关闭/跳过、未触发发奖——上报留痕(不发金币)。 + + 供广告收益报表把「有展示、没发金币」的原因也记录下来。user_id 由 JWT 取,不在 body。 + """ + + ad_session_id: str = Field( + ..., min_length=8, max_length=64, description="本次展示会话 id(与 ecpm 上报、S2S extra 一致)" + ) + watched_seconds: int = Field(0, ge=0, description="关闭前已观看秒数(仅留痕参考,不入库)") + ecpm: str | None = Field(None, description="本次展示 eCPM(分/千次,SDK getEcpm 原值);可空") + adn: str | None = Field(None, description="实际投放 ADN") + slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)") + app_env: str | None = Field( + None, max_length=16, description="我们的穿山甲应用环境:prod / test" + ) + our_code_id: str | None = Field( + None, max_length=64, description="我们后台配置的代码位 ID(104xxx)" + ) + + +class RewardNoShowOut(BaseModel): + ok: bool = Field(..., description="是否处理成功(落库或幂等命中)") + status: str = Field( + ..., description="closed_early(已留痕) / granted(该次其实已发奖,跳过未写)" + ) diff --git a/docs/api/README.md b/docs/api/README.md index 79bea57..25e8ec6 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -63,6 +63,7 @@ | 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) | | 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.md) | | 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad-feed-reward.md) | +| 35b | `POST /api/v1/ad/reward-noshow` | Bearer | [详情](./ad-reward-noshow.md)(激励视频提前关闭/未发奖留痕,只记原因不发币) | | **用户资料**(前缀 `/api/v1/user`) ||| | 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) | | 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.md) | @@ -104,6 +105,7 @@ | A26 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) | | A27 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) | | A28 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) | +| A29 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin-ad-revenue-report.md)(广告收益报表:按用户/日期/类型/应用/代码位 聚合 条数/收益/金币,只读) | | - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) | > ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。 diff --git a/docs/api/ad-ecpm-report.md b/docs/api/ad-ecpm-report.md index b29fefb..6359b00 100644 --- a/docs/api/ad-ecpm-report.md +++ b/docs/api/ad-ecpm-report.md @@ -7,11 +7,13 @@ | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| -| `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `draw`(Draw 信息流) 等 | -| `ad_session_id` | str\|null | 否 | 客户端生成的广告会话 ID;需和穿山甲 `extra.ad_session_id` 一致,用于 S2S 缺 eCPM 时匹配 | +| `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `feed`(信息流) / `draw`(历史 Draw 信息流) 等 | +| `ad_session_id` | str\|null | 否 | 客户端生成的广告会话 ID;激励视频与穿山甲 `extra.ad_session_id` 一致,用于 S2S 缺 eCPM 时匹配。**信息流轮播每条展示用各自独立 id**(不复用比价会话 id,否则 `uq_ad_ecpm_record_session` 去重只留一条) | | `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,单位是**分/千次展示**(非元),后端 ÷100 转元参与金币公式 | -| `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle` | +| `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle`(聚合后实际填充的子渠道) | | `slot_id` | str\|null | 否 | 实际展示代码位(底层 mediation rit,非客户端配置位) | +| `app_env` | str\|null | 否 | **我们的**穿山甲应用环境:`prod`(傻瓜比价正式) / `test`(测试应用) | +| `our_code_id` | str\|null | 否 | **我们后台配置的**代码位 ID(`AdConfig` 的 104xxx,**非** `slot_id` 的底层 rit) | `user_id` 不在 body 里——由 JWT 取(Bearer),防伪造。 @@ -23,7 +25,7 @@ | `ok` | bool | 落库即 `true` | ## 说明 -客户端在广告**展示后**(`onAdShow` 读 `getShowEcpm()`)调用,把本次展示的 eCPM 落库做**内部收益统计/对账**。 +客户端在广告**展示后**(`onAdShow` 读 `getShowEcpm()`)调用,把本次展示的 eCPM 落库做**内部收益统计/对账**。激励视频每次展示上报一条;**信息流轮播每条展示各上报一条**(每条独立 `ad_session_id`),作为「广告收益报表」展示条数/收益的数据源(见 [admin-ad-revenue-report](./admin-ad-revenue-report.md))。`app_env` + `our_code_id` 供报表按「我们的应用 / 我们配置的代码位」聚合,与 `adn`/`slot_id`(底层填充渠道/rit)是两组不同口径。 - 普通激励视频发奖会先用 S2S 回调自带 `ecpm`;若缺失,再按 `ad_session_id` 读取本接口上报的 eCPM;两边都没有则不发并记录异常。 - **best-effort**:客户端 fire-and-forget,但普通激励视频若 S2S 缺 eCPM,这条上报会成为发奖依据。 diff --git a/docs/api/ad-feed-reward.md b/docs/api/ad-feed-reward.md index 0510a0f..23516a7 100644 --- a/docs/api/ad-feed-reward.md +++ b/docs/api/ad-feed-reward.md @@ -1,6 +1,6 @@ # POST /api/v1/ad/feed-reward — 信息流广告完成后结算金币 -点位 2:比价等待 / 领券信息流广告。每展示满 10 秒累计一份奖励,视频完成后一次性入账。 +点位 2:比价等待 / 领券信息流广告(轮播多条)。**整场比价全程不关广告才发**,金额按整场**总观看时长**折份(每 10 秒 1 份),结束时一次性入账。用户中途 ✕ 关闭则整场不发。 ## 鉴权 @@ -12,24 +12,27 @@ |---|---|---:|---| | `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 | | `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 | -| `ecpm` | string | 是 | 本条信息流广告 eCPM(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) | -| `duration_seconds` | int | 是 | 实际展示/播放秒数 | -| `adn` | string\|null | 否 | 实际投放 ADN | -| `slot_id` | string\|null | 否 | 实际展示代码位 | +| `ecpm` | string | 是 | 本场信息流 eCPM 代表值(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) | +| `duration_seconds` | int | 是 | **整场累计观看秒数**(轮播各条相加) | +| `adn` | string\|null | 否 | 实际投放 ADN(聚合后实际填充的子渠道) | +| `slot_id` | string\|null | 否 | 实际展示代码位(底层 mediation rit) | +| `app_env` | string\|null | 否 | **我们的**应用环境:`prod`(傻瓜比价正式) / `test`(测试应用) | +| `our_code_id` | string\|null | 否 | **我们后台配置的**代码位 ID(104xxx,非底层 rit);供广告收益报表按代码位聚合金币 | +| `aborted` | bool | 否 | 用户中途 ✕ 关闭广告(未走完比价):整场不发,仅记 `closed_early`。默认 `false` | ## 响应 | 字段 | 类型 | 说明 | |---|---|---| -| `granted` | bool | 本次是否入账;达上限时为 `false` | -| `status` | string | `granted` / `capped` | -| `coin` | int | 本次发放金币 | +| `granted` | bool | 本次是否入账;未发(任一非 granted 状态)时为 `false` | +| `status` | string | `granted`(已发) / `capped`(当日次数超限) / `too_short`(整场总时长<10s 凑不满一份) / `closed_early`(用户中途关闭) | +| `coin` | int | 本次发放金币;非 granted 为 0 | | `unit_count` | int | 按 10 秒折算出的奖励份数 | | `daily_limit` | int | 每日信息流展示次数上限,默认 500 | ## 计算口径 -- 奖励份数:`duration_seconds // 10`。 +- 奖励份数:`整场总时长 // 10`。 - 单份奖励:`eCPM / 1000 × 因子1(eCPM 档) × 因子2(当天累计份序号) × 10000`,四舍五入为整数金币。 - eCPM 档:`0-100=0.1`,`101-200=0.3`,`201-400=0.4`,`>400=0.6`。 - LT 档:第 1 份 `2.0`,第 2 份 `1.5`,第 3 份 `1.3`,第 4-10 份 `1.1`,第 11 份及以后 `1.0`。 diff --git a/docs/api/ad-reward-noshow.md b/docs/api/ad-reward-noshow.md new file mode 100644 index 0000000..a36b69b --- /dev/null +++ b/docs/api/ad-reward-noshow.md @@ -0,0 +1,41 @@ +# POST /api/v1/ad/reward-noshow — 激励视频提前关闭/未发奖留痕 + +激励视频**展示了但用户提前关闭/跳过、未触发 S2S 发奖**时,客户端 best-effort 上报一条留痕记录, +让运营后台「广告数据」能呈现「有展示、没发金币」的原因。**不发金币**。 + +## 鉴权 + +需要 Bearer token。`user_id` 由 JWT 取,不在 body。 + +## 请求体 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---:|---| +| `ad_session_id` | string | 是 | 本次展示会话 id(与 eCPM 上报、S2S `extra.ad_session_id` 一致),8-64 字符 | +| `watched_seconds` | int | 否 | 关闭前已观看秒数(仅留痕参考,**不入库**)。默认 0 | +| `ecpm` | string\|null | 否 | 本次展示 eCPM(穿山甲 getEcpm 原值,分/千次展示) | +| `adn` | string\|null | 否 | 实际投放 ADN(聚合后实际填充子渠道) | +| `slot_id` | string\|null | 否 | 实际展示代码位(底层 mediation rit) | +| `app_env` | string\|null | 否 | **我们的**应用环境:`prod`(傻瓜比价正式) / `test`(测试应用) | +| `our_code_id` | string\|null | 否 | **我们后台配置的**代码位 ID(104xxx,非底层 rit) | + +## 响应 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `ok` | bool | 是否处理成功(落库或幂等命中) | +| `status` | string | `closed_early`(已留痕) / `granted`(该次其实已发奖,跳过未写) | + +## 说明 + +- 在 `onAdClose` 时若本次**未触发发奖**(无 `onRewardVerify`/`onRewardArrived`)调用。 +- **幂等**:写入 `ad_reward_record`,`trans_id = noreward:{ad_session_id}`(每次展示唯一),重试不重复。 +- **防与正常发奖重复**:若同一 `ad_session_id` 已有 `status='granted'` 记录(S2S 已发,说明用户其实看完了), + 则跳过不写,返回 `status='granted'`。 +- best-effort:客户端 fire-and-forget,失败只 log,不影响主流程。 +- 信息流的「用户中途关闭」走 [ad-feed-reward](./ad-feed-reward.md) 的 `aborted=true`(记 `closed_early`),不走本接口。 + +## 数据写入 + +- `ad_reward_record` 新增一行(`coin=0`、`status='closed_early'`、`reward_scene='reward_video'`)。 +- **不**写 `coin_account` / `coin_transaction`(不发币)。 diff --git a/docs/api/admin-ad-revenue-report.md b/docs/api/admin-ad-revenue-report.md new file mode 100644 index 0000000..bccfd8f --- /dev/null +++ b/docs/api/admin-ad-revenue-report.md @@ -0,0 +1,108 @@ +# Admin 广告收益报表 + +> 所属:Admin 组(前缀 `/admin/api/ad-revenue-report`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](./README.md) + +按 **用户 × 日期 × 广告类型 × 我们的应用 × 我们的代码位** 聚合,回答「每个用户某天、每类广告(激励视频 / 信息流 / 历史 Draw)分别**看了多少条**、**收益多少**、按现算法**发了多少金币**、广告来自**哪个应用的哪个代码位**」。**纯只读**,不发币、不改数据,也**不改发奖逻辑**。 + +相关表:[ad_ecpm_record](../database/ad_ecpm_record.md)、[ad_reward_record](../database/ad_reward_record.md)、[ad_feed_reward_record](../database/ad_feed_reward_record.md)。 + +## 数据来源(三流合并,聚合键 = user × ad_type × app_env × our_code_id) + +| 指标 | 来源表 | 口径 | +|---|---|---| +| 展示条数 `impressions` | `ad_ecpm_record` | 每行 = 客户端一次广告展示。激励视频每次展示上报一条;**信息流轮播每条展示各上报一条**(每条独立 `ad_session_id`) | +| 收益 `revenue_yuan` | `ad_ecpm_record` | `Σ(eCPM元 ÷ 1000)`,即每条展示预估收益累加(eCPM 原值是分,÷100 转元;÷1000 是每千次→单次)。**预估口径,非结算;测试应用多为 0** | +| 应发/实发金币 `expected_coin`/`actual_coin` | `ad_reward_record`(reward_video)+ `ad_feed_reward_record`(feed) | **复用金币审计逐条复算**(`ad_audit.audit_rows`,与正式发奖同一公式口径,不另写公式),按同维度求和;`matched = 应发==实发`。**只读复算,不改发奖** | +| 来源应用/代码位 `app_env`/`our_code_id` | 上述各表回填 | `prod`(傻瓜比价)/`test`(测试);代码位是**我们后台配的 104xxx**,非底层 rit | +| 底层渠道 `adns` | `ad_ecpm_record` | 实际填充的 ADN 子渠道集合(pangle/gdt/...),附加参考 | + +展示与金币来自不同表,做**并集**:有展示无金币(用户中途关、未达发奖)、有金币无展示(未上报 eCPM)各自成行。 + +## GET /admin/api/ad-revenue-report — 聚合报表 + +- 入参(均 query,可选): + | 参数 | 类型 | 默认 | 说明 | + |---|---|---|---| + | `date_from` | string | 今天 | 起始日 北京时间 `YYYY-MM-DD` | + | `date_to` | string | =`date_from` | 结束日 北京时间 `YYYY-MM-DD`,**闭区间**;单日时与 `date_from` 相同 | + | `user_id` | int | 全部 | 只看某用户;不传=所有用户 | + | `ad_type` | string | 全部 | `reward_video` / `feed` / `draw`;不传=全部类型 | + | `granularity` | string | `day` | `day`=按天 / `hour`=按小时(聚合键再加北京时间小时 0–23);**区间>1 天建议用 day** | + | `limit` | int(1~1000) | 500 | **展示**明细组数(截断;`total`/`total_*`/`daily` 按全量统计不受影响) | + + 约束:`date_to` 不早于 `date_from`、区间最长 **92 天**、日期须 `YYYY-MM-DD`,否则 `422`。 + +- 出参 `200`:`AdRevenueReportOut` + | 字段 | 类型 | 说明 | + |---|---|---| + | `date_from` / `date_to` | string | 报表起止日期(闭区间) | + | `daily` | `AdRevenueDaily[]` | 按日期汇总序列(全量,供按天趋势图;不受 `limit` 影响) | + | `total` | int | 聚合组**总数**(全量,不受 `limit` 影响) | + | `truncated` | bool | 明细是否被 `limit` 截断 | + | `total_impressions` | int | 全量展示条数合计 | + | `total_revenue_yuan` | float | 全量收益合计(元) | + | `total_expected_coin` | int | 全量应发金币合计 | + | `total_actual_coin` | int | 全量实发金币合计 | + | `mismatch_count` | int | 应发≠实发的组数(=0 说明全部按公式发放) | + | `items` | `AdRevenueRow[]` | 聚合明细(按 日期→用户→类型→代码位 排序) | + +### AdRevenueDaily(`daily[]` — 按天趋势) +| 字段 | 类型 | 说明 | +|---|---|---| +| `date` | string | 北京时间 `YYYY-MM-DD` | +| `impressions` | int | 当天展示条数合计 | +| `revenue_yuan` | float | 当天预估收益合计(元) | +| `expected_coin` | int | 当天应发金币合计 | +| `actual_coin` | int | 当天实发金币合计 | + +### AdRevenueRow(`items[]`) +| 字段 | 类型 | 说明 | +|---|---|---| +| `report_date` | string | 该组所属日期 北京时间 `YYYY-MM-DD` | +| `user_id` | int | | +| `ad_type` | string | `reward_video` / `feed` / `draw` | +| `app_env` | string \| null | 我们的应用:`prod`(傻瓜比价)/`test`(测试);旧数据为空 | +| `our_code_id` | string \| null | 我们配置的代码位 104xxx;旧数据为空 | +| `hour` | int \| null | 北京时间小时 0–23(`granularity=hour` 时有值;按天为 null) | +| `impressions` | int | 展示条数 | +| `revenue_yuan` | float | 收益(元),预估口径 | +| `expected_coin` | int | 应发金币(公式复算,与金币审计同源) | +| `actual_coin` | int | 实发金币(实际入账) | +| `matched` | bool | 该组应发==实发(组内任一条不符则 false) | +| `adns` | string[] | 底层填充 ADN 子渠道集合 | +| `impression_records` | `AdRevenueImpression[]` | 该组**逐条展示明细**(前端展开下钻);只要有展示就非空 | +| `records` | `AdRevenueRecord[]` | 该组**逐条发奖复算明细**(前端展开下钻);纯展示无发奖的组为空 | + +### AdRevenueImpression(`items[].impression_records[]` — 展开「展示明细」) +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | int | ad_ecpm_record 主键 | +| `created_at` | datetime | | +| `ecpm` | string | 本次展示 eCPM 原始值(分/千次展示) | +| `revenue_yuan` | float | 本次展示预估收益(元)= eCPM元 ÷ 1000 | +| `adn` | string \| null | 实际填充 ADN 子渠道 | +| `slot_id` | string \| null | 底层 mediation rit(非我们配置的广告位 ID) | + +### AdRevenueRecord(`items[].records[]` — 展开「发奖明细」) +还原金币审计的逐条列,与发奖同一复算口径。 +| 字段 | 类型 | 说明 | +|---|---|---| +| `record_id` | int | 发奖记录主键 | +| `created_at` | datetime | | +| `status` | string | `granted` / `capped` / `ecpm_missing` | +| `ecpm` | string \| null | 本次采用的 eCPM 原始值 | +| `ecpm_factor` | float \| null | 因子1(eCPM 档);非 granted 为空 | +| `units` | int | 折算份数:激励视频恒 1;信息流 = 满 10 秒份数 | +| `lt_index_start` / `lt_index_end` | int \| null | 占用「账号累计第几份」的起止 | +| `lt_factor_start` / `lt_factor_end` | float \| null | 因子2(LT)起止值 | +| `expected_coin` | int | 应发金币 | +| `actual_coin` | int | 实发金币 | +| `matched` | bool | 该条复算与实发是否一致 | + +## 说明与局限 + +- **展示 vs 发奖分离**:信息流轮播一会话可展示多条(都计入 `impressions`),但发奖仍按现规则(一会话发一次),`coin` 不因展示条数变化——这是有意设计(用户中途关只记展示不发奖)。 +- **历史 Draw 不可拆**:迁移(Draw→普通信息流)前,Draw 发奖混在 `ad_feed_reward_record` 且无类型标记,金币侧统一记 `feed`;迁移后 Draw 不再产生新数据。展示侧 `ad_type` 由客户端上报区分,故 `draw` 桶基本为空。 +- **来源字段从上线起齐全**:`app_env`/`our_code_id` 是本期新增列,历史记录为 NULL(报表来源列留空)。 +- **收益是预估**:基于客户端上报的 eCPM,非穿山甲后台结算值;以后台报表为结算权威。 +- **对账聚合级 + 逐条下钻**:行级 `matched` 给出该组(用户×类型×应用×代码位)应发是否==实发;**展开 `records` 即可看该组逐条明细**(eCPM/因子1/份数/LT/因子2/应发/实发/一致)定位到具体记录。独立逐条审计接口 [admin-ad-coin-audit](./admin-ad-coin-audit.md) 仍保留(同一复算口径,可全局按场景/只看不符筛选)。 diff --git a/docs/api/feedback.md b/docs/api/feedback.md index 259df11..e59e157 100644 --- a/docs/api/feedback.md +++ b/docs/api/feedback.md @@ -6,7 +6,7 @@ **multipart/form-data**: | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| -| `content` | string | ✓ | 反馈正文,**1-2000 字**(strip 后) | +| `content` | string | ✓ | 反馈正文,**1-200 字**(strip 后) | | `contact` | string | ✗ | 联系方式(微信/QQ/手机号),**≤128 字**。原型改版后客户端已不再采集、不传该字段(后端默认空串);保留字段兼容旧端 | | `images` | file[] | ✗ | 截图,**最多 6 张**,每张走头像同款校验(JPEG/PNG/WebP,≤ 5 MB,魔数嗅探) | @@ -29,7 +29,7 @@ > 不返回上传的 image URL——这是给运营后台看的,客户端通常不需要。 ## 错误码 -- `400` 内容为空 / 内容超 2000 字 / 联系方式超 128 字 / 图片超 6 张 / 单图非法(空/过大/格式不对) +- `400` 内容为空 / 内容超 200 字 / 联系方式超 128 字 / 图片超 6 张 / 单图非法(空/过大/格式不对) - `401` 未带 token / token 无效或过期 / 用户被禁用 - `422` 缺 `content` 字段 diff --git a/docs/database/ad_ecpm_record.md b/docs/database/ad_ecpm_record.md index 127636f..61def90 100644 --- a/docs/database/ad_ecpm_record.md +++ b/docs/database/ad_ecpm_record.md @@ -7,17 +7,19 @@ ## 用在哪 / 增删改查 - **C(插入)**:`POST /ad/ecpm-report`(`create_ecpm_record`)。每次广告展示上报一条;best-effort,丢一两条不影响业务。鉴权接口已确保 user 存在。 - **U / D**:无。 -- **R**:内部收益统计/对账(按 `(user_id, report_date)` 聚合);`count_today` 排查辅助。当前无面向 C 端用户的读接口。 +- **R**:内部收益统计/对账(按 `(user_id, report_date)` 聚合);`count_today` 排查辅助;广告收益报表 [admin-ad-revenue-report](../api/admin-ad-revenue-report.md) 的展示条数/收益数据源(按 `app_env`/`our_code_id` 聚合)。当前无面向 C 端用户的读接口。 ## 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | -| `ad_type` | String(32) | NOT NULL | 广告类型,取值如 `reward_video`(激励视频)/ `draw`(Draw 信息流);各类型各自上报,不强行统一代码位 | -| `ad_session_id` | String(64) | UNIQUE, index, nullable | 客户端广告会话 ID;用于普通激励视频在 S2S 缺 `ecpm` 时匹配发奖 | -| `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`) | +| `ad_type` | String(32) | NOT NULL | 广告类型,取值如 `reward_video`(激励视频)/ `feed`(信息流)/ `draw`(历史 Draw 信息流);各类型各自上报,不强行统一代码位 | +| `ad_session_id` | String(64) | UNIQUE, index, nullable | 客户端广告会话 ID;激励视频用于 S2S 缺 `ecpm` 时匹配发奖。**信息流轮播每条展示用各自独立 id**(不复用比价会话 id,否则 UNIQUE 去重只留一条) | +| `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`;聚合后实际填充的子渠道) | | `slot_id` | String(64) | nullable | 实际展示用代码位(底层 mediation rit,非客户端配置位) | +| `app_env` | String(16) | nullable | **我们的**穿山甲应用环境:`prod`(傻瓜比价正式)/`test`(测试应用);旧数据 NULL。广告收益报表按它聚合「来源应用」 | +| `our_code_id` | String(64) | nullable | **我们后台配置的**代码位 ID(`AdConfig` 的 104xxx,**非** `slot_id` 底层 rit);旧数据 NULL | | `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM **原始串**(穿山甲 getEcpm 原值,单位**分/千次展示**);后端 ÷100 转元参与金币公式 | | `report_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它做按天聚合 | | `created_at` | DateTime(tz) | server_default now(), index | 时间 | diff --git a/docs/database/ad_feed_reward_record.md b/docs/database/ad_feed_reward_record.md index 5ab9e94..99cccb4 100644 --- a/docs/database/ad_feed_reward_record.md +++ b/docs/database/ad_feed_reward_record.md @@ -11,13 +11,15 @@ | `ad_session_id` | String(64) | index, nullable | 客户端生成的一次信息流广告会话 id | | `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 | | `reward_date` | String(10) | index, NOT NULL | 北京时间日期 `YYYY-MM-DD` | -| `duration_seconds` | Integer | NOT NULL | 实际展示/播放秒数 | +| `duration_seconds` | Integer | NOT NULL | 整场比价累计观看秒数(轮播各条相加) | | `unit_count` | Integer | NOT NULL | `duration_seconds // 10` 得到的奖励份数 | | `ecpm_raw` | String(32) | NOT NULL | 客户端上报 eCPM 原始值 | -| `adn` | String(32) | nullable | 实际投放 ADN | -| `slot_id` | String(64) | nullable | 实际展示代码位 | -| `coin` | Integer | NOT NULL | 实发金币,`capped` 时为 0 | -| `status` | String(16) | NOT NULL | `granted` / `capped` | +| `adn` | String(32) | nullable | 实际投放 ADN(聚合后实际填充子渠道) | +| `slot_id` | String(64) | nullable | 实际展示代码位(底层 mediation rit) | +| `app_env` | String(16) | nullable | 来源应用 `prod`(傻瓜比价)/`test`(测试),客户端 feed-reward 上报;旧数据 NULL。广告收益报表金币侧按它聚合 | +| `our_code_id` | String(64) | nullable | 我们配置的代码位 104xxx,客户端上报;旧数据 NULL | +| `coin` | Integer | NOT NULL | 实发金币,非 `granted` 时为 0 | +| `status` | String(16) | NOT NULL | `granted`(已发) / `capped`(当日次数超限) / `too_short`(整场总时长<10s 凑不满一份) / `closed_early`(用户中途 ✕ 关闭,全程未看完) | | `created_at` | DateTime | index, NOT NULL | 创建时间 | ## 约束 diff --git a/docs/database/ad_reward_record.md b/docs/database/ad_reward_record.md index 380967c..07c08be 100644 --- a/docs/database/ad_reward_record.md +++ b/docs/database/ad_reward_record.md @@ -5,7 +5,7 @@ 每条 = 穿山甲一次**服务端激励回调**。`trans_id` 唯一做幂等键(穿山甲会重试,同号只处理一次)。`reward_scene` 区分普通激励视频、签到膨胀等场景;`reward_date`(北京时间日期串)给普通激励视频"每日上限"计数用。 ## 用在哪 / 增删改查 -- **C(插入)**:`POST /ad/pangle-callback`(穿山甲 S2S,经 SHA256 验签;`grant_ad_reward` 或场景业务处理)或 `POST /ad/test-grant`(本地联调)。普通激励视频三道闸:① 验签不过 → API 层 403,不进库;② `trans_id` 已存在 → 原样返回不重复发;③ **当日发奖次数(`DAILY_AD_REWARD_LIMIT`,默认 500)到顶** → 记一行 `status='capped'`、`coin=0`、不发币。否则按 eCPM 公式发币。 +- **C(插入)**:`POST /ad/pangle-callback`(穿山甲 S2S,经 SHA256 验签;`grant_ad_reward` 或场景业务处理)或 `POST /ad/test-grant`(本地联调)。普通激励视频三道闸:① 验签不过 → API 层 403,不进库;② `trans_id` 已存在 → 原样返回不重复发;③ **当日发奖次数(`DAILY_AD_REWARD_LIMIT`,默认 500)到顶** → 记一行 `status='capped'`、`coin=0`、不发币。否则按 eCPM 公式发币。另:`POST /ad/reward-noshow`(`record_reward_noshow`,Bearer)在用户提前关/未发奖时记一行 `status='closed_early'`、`coin=0` 留痕(同 session 已 granted 则跳过)。 - **U / D**:无。 - **R**:`GET /ad/reward-status`(看广告页:今日已发次数/上限、单次金币、本轮已看/冷却结束、今日已看时长/上限);审计/对账整表回溯。 @@ -13,13 +13,15 @@ | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | | -| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=reward_video/signin_boost 等) | +| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=reward_video/signin_boost 等)。`closed_early` 留痕记录无 S2S 交易号,用合成键 `noreward:{ad_session_id}` | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(回调 media_extra 带回;不存在抛 UnknownUserError) | | `reward_scene` | String(32) | NOT NULL, default `reward_video` | 奖励场景:`reward_video` 普通激励视频;`signin_boost` 签到膨胀 | | `ad_session_id` | String(64) | index, nullable | 客户端广告会话 ID,来自 `extra.ad_session_id`;用于匹配 `ad_ecpm_record` | | `ecpm_raw` | String(32) | nullable | 本次发奖采用的 eCPM 原始值;可来自 S2S `ecpm` 或客户端上报 | -| `coin` | Integer | NOT NULL, default 0 | 实发金币;`capped`/`ecpm_missing`/业务不满足时为 0 | -| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `not_signed`/`already_boosted`/`last_day` | +| `app_env` | String(16) | nullable | 来源应用 `prod`(傻瓜比价)/`test`(测试);S2S 不带,发奖时按 `ad_session_id` 匹配 `ad_ecpm_record` 回填,查不到 NULL。广告收益报表金币侧按它聚合 | +| `our_code_id` | String(64) | nullable | 我们配置的代码位 104xxx(同上回填) | +| `coin` | Integer | NOT NULL, default 0 | 实发金币;`capped`/`ecpm_missing`/`closed_early`/业务不满足时为 0 | +| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `closed_early`(展示了但用户提前关/跳过,未发奖,客户端 reward-noshow 留痕)/ `not_signed`/`already_boosted`/`last_day` | | `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值统计当日发奖次数 | | `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) | | `raw` | String(1024) | nullable | 回调原始参数(审计排查) | From f15ca74a220aa78afe5b2e375edd2c1dac02f4e5 Mon Sep 17 00:00:00 2001 From: marco Date: Tue, 16 Jun 2026 01:25:57 +0800 Subject: [PATCH 10/36] =?UTF-8?q?feat(store-mapping):=20JD=20=E7=BC=93?= =?UTF-8?q?=E5=AD=98=20deeplink=20=E5=A4=B1=E6=95=88=E6=A0=87=E8=AE=B0(?= =?UTF-8?q?=E5=AF=B9=E6=A0=87=E6=B7=98=E5=AE=9D)=20(#56)?= 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/56 --- .../versions/store_mapping_jd_dl_invalid.py | 32 ++++++++++++++ app/api/internal/store.py | 9 ++-- app/api/v1/coupon.py | 15 ++++++- app/models/store_mapping.py | 2 + app/repositories/coupon_state.py | 28 +++++++++++- app/repositories/store_mapping.py | 20 +++++++++ app/schemas/coupon_state.py | 10 +++++ tests/test_store_mapping_invalidate.py | 44 +++++++++++++++++-- 8 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 alembic/versions/store_mapping_jd_dl_invalid.py diff --git a/alembic/versions/store_mapping_jd_dl_invalid.py b/alembic/versions/store_mapping_jd_dl_invalid.py new file mode 100644 index 0000000..09a1af2 --- /dev/null +++ b/alembic/versions/store_mapping_jd_dl_invalid.py @@ -0,0 +1,32 @@ +"""store_mapping 加京东 deeplink 失效标记列 jd_deeplink_invalid_at + +Revision ID: store_mapping_jd_dl_invalid +Revises: store_mapping_tb_dl_invalid +Create Date: 2026-06-15 00:00:00.000000 + +京东同淘宝: 缓存的店内搜索 deeplink 会失效(打开是"当前门店超出配送范围"页)。pricebot 比价撞到 +失效页时回退正常搜店, 并 server→server 通知把该 storeId 的 deeplink 标记失效。本列记失效时刻 +(NULL=有效); lookup 反查时过滤掉已失效的京东候选, 不再返回坏 deeplink。 +与淘宝 taobao_deeplink_invalid_at 对称; 用时间戳而非布尔: 留痕可审计、可统计失效率, 不销毁原 deeplink。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'store_mapping_jd_dl_invalid' +down_revision: Union[str, Sequence[str], None] = 'store_mapping_tb_dl_invalid' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('store_mapping', schema=None) as batch_op: + batch_op.add_column(sa.Column('jd_deeplink_invalid_at', sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table('store_mapping', schema=None) as batch_op: + batch_op.drop_column('jd_deeplink_invalid_at') diff --git a/app/api/internal/store.py b/app/api/internal/store.py index b7959e7..104d379 100644 --- a/app/api/internal/store.py +++ b/app/api/internal/store.py @@ -95,11 +95,14 @@ def invalidate_store_mapping( x_internal_secret: Annotated[str | None, Header()] = None, ) -> StoreMappingInvalidateOut: _check_secret(x_internal_secret) - if payload.platform != "taobao": - # 当前只接淘宝; 其它平台先 no-op(affected=0), 不报错 — 向后兼容 pricebot 将来扩展。 + if payload.platform == "taobao": + affected = repo.mark_taobao_deeplink_invalid(db, payload.shop_id) + elif payload.platform == "jd": + affected = repo.mark_jd_deeplink_invalid(db, payload.shop_id) + else: + # 当前只接淘宝/京东; 其它平台先 no-op(affected=0), 不报错 — 向后兼容 pricebot 将来扩展。 logger.info("store_mapping invalidate 跳过: platform=%s 暂不支持", payload.platform) return StoreMappingInvalidateOut(ok=True, affected=0) - affected = repo.mark_taobao_deeplink_invalid(db, payload.shop_id) logger.info( "store_mapping invalidate platform=%s shop_id=%s → 标记失效 %d 行", payload.platform, payload.shop_id, affected, diff --git a/app/api/v1/coupon.py b/app/api/v1/coupon.py index 6e07120..07c19af 100644 --- a/app/api/v1/coupon.py +++ b/app/api/v1/coupon.py @@ -18,7 +18,7 @@ import httpx from fastapi import APIRouter, HTTPException, Request, status from fastapi.concurrency import run_in_threadpool -from app.api.deps import DbSession +from app.api.deps import CurrentUser, DbSession from app.core.config import settings from app.core.pricebot_router import pick_pricebot from app.db.session import SessionLocal @@ -28,6 +28,7 @@ from app.schemas.coupon_state import ( CouponPromptDismissIn, CouponPromptShouldShowOut, CouponPromptShownIn, + CouponStatsOut, ) logger = logging.getLogger("shagua.coupon") @@ -268,3 +269,15 @@ def coupon_completed_today_reset( 与 /prompt/reset 配套:开发设置「重置今日领券弹窗状态」一键把今日状态全清。MVP 不鉴权。""" coupon_repo.reset_today_completion(db, payload.device_id) return {"ok": True} + + +@router.get( + "/stats", + response_model=CouponStatsOut, + summary="累计领券数(「我的」页战绩卡「领取优惠券 X 张」)", +) +def coupon_stats(user: CurrentUser, db: DbSession) -> CouponStatsOut: + """该登录用户累计领到的券数(SUM(claimed_count),口径见 coupon_repo.sum_claimed_count)。 + **鉴权(CurrentUser)**——区别于同文件不鉴权的 /step 透传与 /prompt 频控(那些按 device_id): + 个人战绩按 user_id 聚合,必须有登录态。""" + return CouponStatsOut(coupon_count=coupon_repo.sum_claimed_count(db, user.id)) diff --git a/app/models/store_mapping.py b/app/models/store_mapping.py index 35272d0..78850a2 100644 --- a/app/models/store_mapping.py +++ b/app/models/store_mapping.py @@ -105,6 +105,8 @@ class StoreMapping(Base): jd_share_url: Mapped[str | None] = mapped_column(String(256), nullable=True) # 3.cn 短链 jd_resolved_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 反查出的目标 openapp.jdmobile:// deeplink jd_deeplink: Mapped[str | None] = mapped_column(Text, nullable=True) # 拼好的 pages/search 店内搜索 deeplink + # 京东 deeplink 失效标记(同 taobao_deeplink_invalid_at):撞"当前门店超出配送范围"页时被置,NULL=有效。 + jd_deeplink_invalid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) # 灵活字段兜底(免得加字段就迁移) attrs: Mapped[dict | None] = mapped_column(_JSON, nullable=True) diff --git a/app/repositories/coupon_state.py b/app/repositories/coupon_state.py index 33f4781..e3e2765 100644 --- a/app/repositories/coupon_state.py +++ b/app/repositories/coupon_state.py @@ -9,7 +9,7 @@ import logging from datetime import date, datetime from zoneinfo import ZoneInfo -from sqlalchemy import delete, select +from sqlalchemy import delete, func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -206,3 +206,29 @@ def record_claims( ) return 0 return written + + +# ===== 累计领券数(「我的」页战绩卡「领取优惠券 X 张」)===== + +def sum_claimed_count(db: Session, user_id: int) -> int: + """该用户累计领到的优惠券张数。口径(2026-06-15 用户定):SUM(claimed_count) —— + 各成功领券记录的 pricebot 展示张数(claimed_count 列,存的是 display_count)之和, + 与领券完成时给用户看的「本次领了 N 张」同源。 + + - 只算 status ∈ {success, already_claimed}:already_claimed=今日已领过,协议里算「已领到」; + failed / skipped 不计。 + - claimed_count 为 0 的保持 0:那是同 count_group 合并去重项(pricebot 只让一条出数),不重复计; + 为 NULL 的兜底成 1(成功领到至少 1 张;实际 pricebot to_dict 恒下发 display_count,NULL 基本不出现)。 + - 维度 user_id:登录态领的券才归入。登录前匿名领的(user_id 为空)不算(产品可接受)。 + """ + total = db.execute( + select( + func.coalesce( + func.sum(func.coalesce(CouponClaimRecord.claimed_count, 1)), 0 + ) + ).where( + CouponClaimRecord.user_id == user_id, + CouponClaimRecord.status.in_(("success", "already_claimed")), + ) + ).scalar_one() + return int(total or 0) diff --git a/app/repositories/store_mapping.py b/app/repositories/store_mapping.py index 358aa7d..ed59cfc 100644 --- a/app/repositories/store_mapping.py +++ b/app/repositories/store_mapping.py @@ -124,6 +124,23 @@ def mark_taobao_deeplink_invalid(db: Session, shop_id: str) -> int: return result.rowcount or 0 +def mark_jd_deeplink_invalid(db: Session, store_id: str) -> int: + """把所有 id_jd=store_id 的行标记京东 deeplink 失效(置 invalid_at=now)。返回本次新标记的行数。 + + 同 mark_taobao_deeplink_invalid:按 storeId 标记**所有**行(撞京东"当前门店超出配送范围"页), + 全标掉才能让后续 lookup 不再返回它。幂等:已标记的行(invalid_at 非 NULL)跳过。""" + result = db.execute( + update(StoreMapping) + .where( + StoreMapping.id_jd == store_id, + StoreMapping.jd_deeplink_invalid_at.is_(None), + ) + .values(jd_deeplink_invalid_at=func.now()) + ) + db.commit() + return result.rowcount or 0 + + # ============================================================ # 缓存查询: 比价前按"源平台店名"反查已沉淀的各目标平台店铺 id, 命中就让 pricebot 直接 # deeplink 跳店内搜索, 省掉"开平台→进店→分享反查"整段。 @@ -181,6 +198,9 @@ def lookup_nearest( if tgt == "taobao": # 失效的淘宝 deeplink(撞过错误页被 invalidate)整条排除 = 当没缓存, pricebot 走正常搜店。 cands = [r for r in cands if r.taobao_deeplink_invalid_at is None] + elif tgt == "jd": + # 同上:失效的京东 deeplink(撞"当前门店超出配送范围"被 invalidate)整条排除。 + cands = [r for r in cands if r.jd_deeplink_invalid_at is None] if not cands: continue best = _pick_best(cands, lat, lng) diff --git a/app/schemas/coupon_state.py b/app/schemas/coupon_state.py index 15e9c16..236d311 100644 --- a/app/schemas/coupon_state.py +++ b/app/schemas/coupon_state.py @@ -39,3 +39,13 @@ class CouponCompletedTodayOut(BaseModel): """这台设备今天是否已跑完整轮领券(到 done 帧)。完成 → 首页「去领取」卡置灰。""" completed: bool + + +class CouponStatsOut(BaseModel): + """「我的」页战绩卡「领取优惠券 X 张」数据源:该登录用户累计领到的券数。 + + 口径(2026-06-15 用户定):SUM(claimed_count) —— 各成功领券记录的 pricebot 展示张数之和, + 与领券完成时给用户看的「本次领了 N 张」同源。详见 repositories.coupon_state.sum_claimed_count。 + """ + + coupon_count: int diff --git a/tests/test_store_mapping_invalidate.py b/tests/test_store_mapping_invalidate.py index e1930f9..2d3188f 100644 --- a/tests/test_store_mapping_invalidate.py +++ b/tests/test_store_mapping_invalidate.py @@ -16,11 +16,13 @@ _SECRET = "test-internal-secret-only-for-pytest" def _mk_row(db, *, trace_id, name_meituan=None, name_taobao=None, - id_taobao=None, deeplink=None, lat=None, lng=None): + id_taobao=None, deeplink=None, lat=None, lng=None, + name_jd=None, id_jd=None, jd_deeplink=None): row = StoreMapping( trace_id=trace_id, business_type="food", name_meituan=name_meituan, name_taobao=name_taobao, id_taobao=id_taobao, taobao_deeplink=deeplink, lat=lat, lng=lng, + name_jd=name_jd, id_jd=id_jd, jd_deeplink=jd_deeplink, ) db.add(row) db.commit() @@ -110,9 +112,45 @@ def test_invalidate_endpoint_marks_and_lookup_miss(client, db, monkeypatch): assert "taobao" not in lk.json() -def test_invalidate_endpoint_non_taobao_noop(client, monkeypatch): +def test_invalidate_endpoint_unsupported_platform_noop(client, monkeypatch): + # 当前支持 taobao/jd;其它平台(如 meituan)no-op 返 affected=0 monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET) r = client.post("/internal/store-mapping/invalidate", - json={"platform": "jd", "shop_id": "X"}, + json={"platform": "meituan", "shop_id": "X"}, headers={"X-Internal-Secret": _SECRET}) assert r.status_code == 200 and r.json()["affected"] == 0 + + +# ---------- 京东(对称淘宝)---------- + +def test_mark_jd_invalid_marks_all_rows_with_store_id(db): + _mk_row(db, trace_id="j1", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JBAD", jd_deeplink="dl_a") + _mk_row(db, trace_id="j2", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JBAD", jd_deeplink="dl_b") + _mk_row(db, trace_id="j3", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JGOOD", jd_deeplink="dl_g") + assert repo.mark_jd_deeplink_invalid(db, "JBAD") == 2 + assert repo.mark_jd_deeplink_invalid(db, "JBAD") == 0 # 幂等 + rows = {r.trace_id: r for r in db.query(StoreMapping).all()} + assert rows["j1"].jd_deeplink_invalid_at is not None + assert rows["j2"].jd_deeplink_invalid_at is not None + assert rows["j3"].jd_deeplink_invalid_at is None # 不同 storeId 不动 + + +def test_lookup_filters_invalid_jd(db): + _mk_row(db, trace_id="j1", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JBAD", jd_deeplink="dl") + assert repo.lookup_nearest(db, "meituan", "兰州拉面")["jd"]["store_id"] == "JBAD" + repo.mark_jd_deeplink_invalid(db, "JBAD") + assert "jd" not in repo.lookup_nearest(db, "meituan", "兰州拉面") + + +def test_invalidate_endpoint_jd_marks_and_lookup_miss(client, db, monkeypatch): + monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET) + _mk_row(db, trace_id="j1", name_meituan="店J", name_jd="店J", id_jd="JBADX", jd_deeplink="dl") + _mk_row(db, trace_id="j2", name_meituan="店J", name_jd="店J", id_jd="JBADX", jd_deeplink="dl2") + r = client.post("/internal/store-mapping/invalidate", + json={"platform": "jd", "shop_id": "JBADX"}, + headers={"X-Internal-Secret": _SECRET}) + assert r.status_code == 200 and r.json()["affected"] == 2 + lk = client.get("/internal/store-mapping/lookup", + params={"source_platform": "meituan", "name": "店J"}, + headers={"X-Internal-Secret": _SECRET}) + assert "jd" not in lk.json() From f97048ff56f353cd4dc95adbd1183e7fe665b17e Mon Sep 17 00:00:00 2001 From: marco Date: Tue, 16 Jun 2026 01:39:05 +0800 Subject: [PATCH 11/36] =?UTF-8?q?fix(alembic):=20=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E5=A4=9A=20head=20(store=5Fmapping=5Fjd=5Fdl=5Finvalid=20+=20a?= =?UTF-8?q?d=5Frevenue)=20=E4=BF=AE=E5=A4=8D=200.1.2=20=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #56 的 jd_deeplink_invalid 迁移与 ad_revenue 报表迁移从同一 branchpoint 分叉成双 head, 部署时 alembic upgrade head 报 "Multiple head revisions"。 加一个 no-op merge 节点统一 DAG, 两支迁移列不冲突。 Co-Authored-By: Claude Opus 4.8 --- ..._merge_store_mapping_jd_dl_invalid_and_.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 alembic/versions/45047b5a884c_merge_store_mapping_jd_dl_invalid_and_.py diff --git a/alembic/versions/45047b5a884c_merge_store_mapping_jd_dl_invalid_and_.py b/alembic/versions/45047b5a884c_merge_store_mapping_jd_dl_invalid_and_.py new file mode 100644 index 0000000..58c3271 --- /dev/null +++ b/alembic/versions/45047b5a884c_merge_store_mapping_jd_dl_invalid_and_.py @@ -0,0 +1,26 @@ +"""merge store_mapping_jd_dl_invalid and ad_revenue heads (0.1.2) + +Revision ID: 45047b5a884c +Revises: d4e68464761d, store_mapping_jd_dl_invalid +Create Date: 2026-06-16 01:38:26.273226 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '45047b5a884c' +down_revision: Union[str, Sequence[str], None] = ('d4e68464761d', 'store_mapping_jd_dl_invalid') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass From 783dfd059d26964c6ad686c22fddda7119e7b885 Mon Sep 17 00:00:00 2001 From: chenshuobo Date: Tue, 16 Jun 2026 15:40:46 +0800 Subject: [PATCH 12/36] =?UTF-8?q?feat(meituan-etl):=20=E7=A6=BB=E7=BA=BF?= =?UTF-8?q?=E5=BA=93=E6=89=A9=E5=88=B0=E5=85=A8=E5=9B=BD=20359=20=E5=9F=8E?= =?UTF-8?q?(=E5=A4=9A=E5=9F=8E=E5=B9=B6=E5=8F=91=E6=8A=93=E5=8F=96?= =?UTF-8?q?=E5=85=A5=E5=BA=93)=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 智能推荐 / 销量最高两 tab 的离线库此前只有北京(ETL 写死 cityId),改为遍历 美团官方城市字典 359 个地级市全量抓取。实测一个地级市 cityId 已覆盖其下辖县级市 (徐州→邳州/新沂/睢宁等),按地级市抓即可,无需区县层级。 - 城市字典:tools/gen_meituan_cities.py 从美团 Excel 生成随仓库 JSON (app/integrations/data/meituan_cities.json,359 城),app/integrations/cities.py 读取; - ETL:city_id 参数化 + 城市级并发(默认 12)+ worker 启动错峰削平瞬时峰值 + 主线程逐城串行入库(Session 不跨线程); - 配速实测:单城全量 ~110s/~2300 条;15 并发抓完一轮 ~50-60min,402 占 3% 退避全消化; 每 3h 一轮全量(--interval 10800),窗口充裕; - prune 双护栏:本轮 0 入库 或 失败城占比 >5% 时跳过,防上游故障/大面积限流误删全表; - 仅写入侧;读取侧(rec/top-sales 按城过滤)待后续(依赖用户定位→cityId 映射,字典无经纬度)。 --------- Co-authored-by: chenshuobo <1119780489@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/57 Co-authored-by: chenshuobo Co-committed-by: chenshuobo --- app/integrations/cities.py | 34 + app/integrations/data/meituan_cities.json | 1797 +++++++++++++++++++++ scripts/pull_meituan_coupons.py | 215 ++- tools/gen_meituan_cities.py | 57 + 4 files changed, 2053 insertions(+), 50 deletions(-) create mode 100644 app/integrations/cities.py create mode 100644 app/integrations/data/meituan_cities.json create mode 100644 tools/gen_meituan_cities.py diff --git a/app/integrations/cities.py b/app/integrations/cities.py new file mode 100644 index 0000000..a91fa55 --- /dev/null +++ b/app/integrations/cities.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +"""美团城市字典(地级市)读取。 + +数据源:app/integrations/data/meituan_cities.json,由 tools/gen_meituan_cities.py 从 +美团官方「城市字典」Excel 生成(359 个地级市:city_id / 城市名 / 省份)。 +实测一个地级市 cityId 已覆盖其下辖县级市供给(徐州 → 邳州 / 新沂 / 睢宁 …),故无区县层级。 + +- ETL(scripts/pull_meituan_coupons.py)遍历 all_cities() 全量抓取入库。 +- 读取侧将来「按城市过滤」时,也从这里取 city_id ↔ 城市名映射。 +""" +from __future__ import annotations + +import functools +import json +from pathlib import Path + +_JSON = Path(__file__).resolve().parent / "data" / "meituan_cities.json" + + +@functools.lru_cache(maxsize=1) +def all_cities() -> list[dict]: + """全部城市 [{city_id, name, province}, ...](359 个地级市)。""" + return json.loads(_JSON.read_text(encoding="utf-8")) + + +@functools.lru_cache(maxsize=1) +def _by_id() -> dict[str, dict]: + return {c["city_id"]: c for c in all_cities()} + + +def city_name(city_id: str) -> str | None: + """city_id → 城市名(查不到返回 None)。""" + c = _by_id().get(city_id) + return c["name"] if c else None diff --git a/app/integrations/data/meituan_cities.json b/app/integrations/data/meituan_cities.json new file mode 100644 index 0000000..8431142 --- /dev/null +++ b/app/integrations/data/meituan_cities.json @@ -0,0 +1,1797 @@ +[ + { + "city_id": "3NUYJKKJXPHVNZUHFK3HWUDHNM", + "name": "宣城市", + "province": "安徽省" + }, + { + "city_id": "LXXSHOY7LNK74ZK2SKVUXFY72Q", + "name": "阜阳市", + "province": "安徽省" + }, + { + "city_id": "ZEBF2LBJOEHGM4XGFPNW4IHBIA", + "name": "合肥市", + "province": "安徽省" + }, + { + "city_id": "WADWEY3GR6IARJLSNGQWG2KI4E", + "name": "滁州市", + "province": "安徽省" + }, + { + "city_id": "Z62QL3X66AOT6MSY7LB6LVO4CI", + "name": "芜湖市", + "province": "安徽省" + }, + { + "city_id": "M6QCWRCECRT6ZEJ6RERR6IWHFI", + "name": "淮南市", + "province": "安徽省" + }, + { + "city_id": "UP2NBJACSAO7FUH4XQQDMBOUE4", + "name": "马鞍山市", + "province": "安徽省" + }, + { + "city_id": "WLL7BSHUBPTLTMITFITJX2GKWY", + "name": "蚌埠市", + "province": "安徽省" + }, + { + "city_id": "ECTBNJ4KNNHPGXEVI4TBJNU7BY", + "name": "亳州市", + "province": "安徽省" + }, + { + "city_id": "KNEGDNOSRGNQPAMOMH54ATLIUU", + "name": "六安市", + "province": "安徽省" + }, + { + "city_id": "YH53FRR55H6VA4KXW36OJ7RNSE", + "name": "宿州市", + "province": "安徽省" + }, + { + "city_id": "LH6LX5DUVFPOFCAQ6NEGPWCLBI", + "name": "淮北市", + "province": "安徽省" + }, + { + "city_id": "RIFXWJ46SEJXE7EP6HZXEUIALU", + "name": "铜陵市", + "province": "安徽省" + }, + { + "city_id": "FK6F7KMO4WNARZKUXMMSBWGATM", + "name": "安庆市", + "province": "安徽省" + }, + { + "city_id": "M3CHGNNUSFSPS5HRV3W7MCBWFI", + "name": "黄山市", + "province": "安徽省" + }, + { + "city_id": "XFI6PM7SRO6NHBGYQUZEEA2WT4", + "name": "池州市", + "province": "安徽省" + }, + { + "city_id": "D2ZILSASTTGEFQWZIDTHA7PWU4", + "name": "澳门", + "province": "澳门特别行政区" + }, + { + "city_id": "WKV2HMXUEK634WP64CUCUQGM64", + "name": "北京市", + "province": "北京市" + }, + { + "city_id": "HPMKHLM3QR6EZGMY7GEI4H3QYQ", + "name": "泉州市", + "province": "福建省" + }, + { + "city_id": "6V523YRU54Y3PEAM2XPADNJM2U", + "name": "福州市", + "province": "福建省" + }, + { + "city_id": "HH3ZZCERPVQIYUZPW4A2U4JKZI", + "name": "莆田市", + "province": "福建省" + }, + { + "city_id": "BQ5RWEJS4O7W27SQMLPMRIRJDU", + "name": "宁德市", + "province": "福建省" + }, + { + "city_id": "TDN7XDQMZEP6ZCK6UO3VVMCSMM", + "name": "三明市", + "province": "福建省" + }, + { + "city_id": "ZKNTORN6YTZL2BXRYUSRGV3CHU", + "name": "厦门市", + "province": "福建省" + }, + { + "city_id": "Y2DI2QLZN5NNACMD3KI2DBR4IA", + "name": "龙岩市", + "province": "福建省" + }, + { + "city_id": "C4RS32I6QAHWLP55UQWI3N5LLY", + "name": "南平市", + "province": "福建省" + }, + { + "city_id": "5T23WDEOAP7RYNU4JNL2ZG7PDQ", + "name": "漳州市", + "province": "福建省" + }, + { + "city_id": "UHZBROATFB2KWNMNLS23DPRJBY", + "name": "定西市", + "province": "甘肃省" + }, + { + "city_id": "WLPAHIUOIVKS644QSN4V5ZY5XQ", + "name": "金昌市", + "province": "甘肃省" + }, + { + "city_id": "J7TO3UHZ57ABNUKIQUBCIJZQFA", + "name": "白银市", + "province": "甘肃省" + }, + { + "city_id": "GX277SS75375VEFYVCEHBL6ISA", + "name": "临夏回族自治州", + "province": "甘肃省" + }, + { + "city_id": "ORA3R7F2LSJHUOCIDTZCP54G7Q", + "name": "张掖市", + "province": "甘肃省" + }, + { + "city_id": "L6VQYYOJNTHSLW5JCR5SXBITDM", + "name": "武威市", + "province": "甘肃省" + }, + { + "city_id": "65OXQPQVFXNOYHXTMCE2RNFRL4", + "name": "兰州市", + "province": "甘肃省" + }, + { + "city_id": "IUIZYQ7E2SPMEAIGOUUNZWBQOA", + "name": "天水市", + "province": "甘肃省" + }, + { + "city_id": "BYBRGRDRV4NKAWCU6GWBIVLX3Q", + "name": "酒泉市", + "province": "甘肃省" + }, + { + "city_id": "KDST2VRETG6WK5SMJO2G2FN2RU", + "name": "嘉峪关市", + "province": "甘肃省" + }, + { + "city_id": "R3Q2XWVFVF4T2ADZZZMPZJRWBA", + "name": "庆阳市", + "province": "甘肃省" + }, + { + "city_id": "7FWNT2TP66SU4QEP6IBBMZWFNE", + "name": "陇南市", + "province": "甘肃省" + }, + { + "city_id": "T33S2GYGPVHAL5SR2FLSPTJSBE", + "name": "平凉市", + "province": "甘肃省" + }, + { + "city_id": "RR6KAWBLOKD4H2UINKYPFCPXO4", + "name": "甘南藏族自治州", + "province": "甘肃省" + }, + { + "city_id": "QKX4DS3CTJJG7SFW5SBHPLD42I", + "name": "茂名市", + "province": "广东省" + }, + { + "city_id": "JJZ75A32XCQNZU4IN2ZCEUGN3M", + "name": "梅州市", + "province": "广东省" + }, + { + "city_id": "SJSOXOSJASLUT6LBH4E32SUKKQ", + "name": "清远市", + "province": "广东省" + }, + { + "city_id": "SQQWAN5BQOVSX55S7EPF7QHMAU", + "name": "珠海市", + "province": "广东省" + }, + { + "city_id": "NGRJMW6JMRS6U2KUJEONSORAEY", + "name": "韶关市", + "province": "广东省" + }, + { + "city_id": "FAUMIGOSOET4E5WR5BL6P3OZHA", + "name": "佛山市", + "province": "广东省" + }, + { + "city_id": "JSBIH55ICFZQ2D3LEV47YMZ2NI", + "name": "河源市", + "province": "广东省" + }, + { + "city_id": "KOYAYPD2DBLDCF2ZI5GCW4LF6Y", + "name": "中山市", + "province": "广东省" + }, + { + "city_id": "647JGFPUYM4VWVLZCPSHT63XKQ", + "name": "汕头市", + "province": "广东省" + }, + { + "city_id": "AMOIPZW3Q2NMTDSEREFVM4SV74", + "name": "深圳市", + "province": "广东省" + }, + { + "city_id": "XIHEJY4H2CDZJCLIXDIN36BKXQ", + "name": "广州市", + "province": "广东省" + }, + { + "city_id": "UZ6OT4CYUR42KCTTED2KJW6EGA", + "name": "东莞市", + "province": "广东省" + }, + { + "city_id": "RM3HLOIUEYKTQ5O2JSVEKDMFRY", + "name": "阳江市", + "province": "广东省" + }, + { + "city_id": "C6XNJAZA6N3NNUDBUU3JIZPTDI", + "name": "潮州市", + "province": "广东省" + }, + { + "city_id": "AUPF3G2ULSV4TDT4L3NMHRTY6Y", + "name": "揭阳市", + "province": "广东省" + }, + { + "city_id": "7HIITKBPRXTVBA2FBBN443XITQ", + "name": "云浮市", + "province": "广东省" + }, + { + "city_id": "TO6ILZ7MPJMJN3S7W2SXMIFQQY", + "name": "江门市", + "province": "广东省" + }, + { + "city_id": "DTTBMGCIOMPCZY5NETUEKWJ6PY", + "name": "汕尾市", + "province": "广东省" + }, + { + "city_id": "HJY7JYWBA6FQY42RYSKX3RZTRI", + "name": "湛江市", + "province": "广东省" + }, + { + "city_id": "SLICHB4FBDVDLI53MR74WVUUNI", + "name": "肇庆市", + "province": "广东省" + }, + { + "city_id": "ZPX4JXJVBBYSSD2KTWHAPXO6NE", + "name": "惠州市", + "province": "广东省" + }, + { + "city_id": "JH4Q44RQA4EZ3Q6MHQVEVE7KZQ", + "name": "百色市", + "province": "广西壮族自治区" + }, + { + "city_id": "H2JXFEJFIL4PPFMYOS4MHZ5IBM", + "name": "崇左市", + "province": "广西壮族自治区" + }, + { + "city_id": "SXIRRISUOEGBU335AWT2ZFL6A4", + "name": "贵港市", + "province": "广西壮族自治区" + }, + { + "city_id": "HEQHKC4KP7YGGYVBZM5JEUI5AQ", + "name": "北海市", + "province": "广西壮族自治区" + }, + { + "city_id": "SKGG7KMFKVDIDKRVQEPTS7SIE4", + "name": "贺州市", + "province": "广西壮族自治区" + }, + { + "city_id": "N4WR7CWCULNA5Z35OTDJSZYDCU", + "name": "钦州市", + "province": "广西壮族自治区" + }, + { + "city_id": "T4RXX2WY6WPQYZEUXVJNZBXQZU", + "name": "梧州市", + "province": "广西壮族自治区" + }, + { + "city_id": "3R23AS3EIY7EYE2D5MWWORZODI", + "name": "河池市", + "province": "广西壮族自治区" + }, + { + "city_id": "57SMWWCV7X44E256P4I23OQ3AA", + "name": "防城港市", + "province": "广西壮族自治区" + }, + { + "city_id": "YHGHVIQ37UCTNQ4JKPQEAUWIQA", + "name": "桂林市", + "province": "广西壮族自治区" + }, + { + "city_id": "MQJZTM455OKZLAN5WQYUTA5TDE", + "name": "柳州市", + "province": "广西壮族自治区" + }, + { + "city_id": "C6FZPLB4NJQ6VUPSKDJH3EWQDM", + "name": "玉林市", + "province": "广西壮族自治区" + }, + { + "city_id": "BFSU5W6E5XBIDFPQLVPGRDSATY", + "name": "南宁市", + "province": "广西壮族自治区" + }, + { + "city_id": "CKXOQUZDNOVNME3PEBOY2CULQQ", + "name": "来宾市", + "province": "广西壮族自治区" + }, + { + "city_id": "LV32FV6IQTKFR7JIBEQMHRUVCA", + "name": "贵阳市", + "province": "贵州省" + }, + { + "city_id": "F26RCNKMFTZONJCSJ5C6FHVY74", + "name": "毕节市", + "province": "贵州省" + }, + { + "city_id": "G2LMYRWVRCK7BTD2WM4NWX4SYM", + "name": "黔南布依族苗族自治州", + "province": "贵州省" + }, + { + "city_id": "MFSOO3NBMB2PVLIVSI5EJK7MWY", + "name": "黔西南布依族苗族自治州", + "province": "贵州省" + }, + { + "city_id": "KWUL44L7SEMJGIMXCWSSEB3OOA", + "name": "遵义市", + "province": "贵州省" + }, + { + "city_id": "T2P3OFGQZUGR7D6TGRCMDF22GI", + "name": "铜仁市", + "province": "贵州省" + }, + { + "city_id": "AGUFUANSZNGC4TMOPZO65IRSPI", + "name": "六盘水市", + "province": "贵州省" + }, + { + "city_id": "2XOCOSNUAK3J5QDGTKIBWKK7KU", + "name": "安顺市", + "province": "贵州省" + }, + { + "city_id": "6XRTSAEYJTA2UBKXO4XEPQE5ZY", + "name": "黔东南苗族侗族自治州", + "province": "贵州省" + }, + { + "city_id": "YRMKRP2GOE2VMRS73N4YRIZUHY", + "name": "三亚市", + "province": "海南省" + }, + { + "city_id": "CJRGVLBNLJAVBJ4ZKKIZ3FZ2LY", + "name": "白沙黎族自治县", + "province": "海南省" + }, + { + "city_id": "5XOUAJ5Z4J4K7SVIQGXL2OM2JQ", + "name": "保亭黎族苗族自治县", + "province": "海南省" + }, + { + "city_id": "2UFQ6A2QRJPXPH3VOYEAQHMVSQ", + "name": "海口市", + "province": "海南省" + }, + { + "city_id": "TGCVXVS4M7NDQM4ROUDCVI6I3A", + "name": "承德市", + "province": "河北省" + }, + { + "city_id": "JEUP6QWCOXPSM3SQTINQCJKIGM", + "name": "衡水市", + "province": "河北省" + }, + { + "city_id": "Z442MNCW6BO2BBHIRUPRBACXPI", + "name": "唐山市", + "province": "河北省" + }, + { + "city_id": "RFE6R34GD4FY3LUKC2ICSF6AFY", + "name": "张家口市", + "province": "河北省" + }, + { + "city_id": "CWJN55M73VZDCYJEQ7AHDBWGGY", + "name": "沧州市", + "province": "河北省" + }, + { + "city_id": "DFL4ES776ECRGBYNOPLWKB247I", + "name": "雄安新区", + "province": "河北省" + }, + { + "city_id": "ZLSXYY34IHBHIC2NOVPQQBFTBE", + "name": "保定市", + "province": "河北省" + }, + { + "city_id": "3DO6Z2QRJQFMPLLDS55PG7DSBU", + "name": "石家庄市", + "province": "河北省" + }, + { + "city_id": "PR57XT25LI3246VGASEPSHP63E", + "name": "邢台市", + "province": "河北省" + }, + { + "city_id": "SKYLNH737BS56TD452FOKYL36U", + "name": "邯郸市", + "province": "河北省" + }, + { + "city_id": "5PWPERL7GQKJD6QPLR2TWUUM7E", + "name": "秦皇岛市", + "province": "河北省" + }, + { + "city_id": "5T2TGV6SJFVL3MO7HIMN2KTQTA", + "name": "廊坊市", + "province": "河北省" + }, + { + "city_id": "ECSTLZ7GP7IX3MB5EVNKS47MLE", + "name": "焦作市", + "province": "河南省" + }, + { + "city_id": "VTWW34QB2F5Q4LW7ISNUMWX7GY", + "name": "开封市", + "province": "河南省" + }, + { + "city_id": "D2NUN47NY4Q55X3UED4JMSI6CM", + "name": "周口市", + "province": "河南省" + }, + { + "city_id": "TR3XJFQR4EFYRRIX7TUQF3B26Y", + "name": "郑州市", + "province": "河南省" + }, + { + "city_id": "CKJGF5S6XMHW5ZJEBU7MJC47QA", + "name": "新乡市", + "province": "河南省" + }, + { + "city_id": "IFASZ625MCFJQKPLJ7EA2SMJUU", + "name": "商丘市", + "province": "河南省" + }, + { + "city_id": "SKXPYKTTRG4YAUHE2HZXWRWXGM", + "name": "鹤壁市", + "province": "河南省" + }, + { + "city_id": "G5LXE74CUHO2K6BBRN7Q5DSRJY", + "name": "漯河市", + "province": "河南省" + }, + { + "city_id": "SLNOAFJV2LTBSH7SJCRXJA36K4", + "name": "驻马店市", + "province": "河南省" + }, + { + "city_id": "65WO7LH7CFDUGKYMXQLRAYKKWM", + "name": "安阳市", + "province": "河南省" + }, + { + "city_id": "RIX2X7FAVTZCAQ5RT2C2CWK22Q", + "name": "南阳市", + "province": "河南省" + }, + { + "city_id": "SZOW5OY3U54SSY4WRC65VJUNTI", + "name": "平顶山市", + "province": "河南省" + }, + { + "city_id": "7VPIDDUS4P2LSZ6Q5S57MAQDEM", + "name": "信阳市", + "province": "河南省" + }, + { + "city_id": "4VYCRORUOZ4DC2U6S3CT6H6KWE", + "name": "洛阳市", + "province": "河南省" + }, + { + "city_id": "M5WNO2BQ3UGLLHBCG4NCEFPP5U", + "name": "濮阳市", + "province": "河南省" + }, + { + "city_id": "LY3O6PBPIWETMA3ZOL6ETY5UB4", + "name": "三门峡市", + "province": "河南省" + }, + { + "city_id": "FXXJLIRE72LS2W4OWWQVJMRJHA", + "name": "许昌市", + "province": "河南省" + }, + { + "city_id": "5XH353QTY3VWF2KYOCZCL3TOXY", + "name": "牡丹江市", + "province": "黑龙江省" + }, + { + "city_id": "2KGRZKF6IECV2W7K5J64Y2LY4M", + "name": "齐齐哈尔市", + "province": "黑龙江省" + }, + { + "city_id": "FASGWS5ADVSTFGJG6TGBZPZP6Q", + "name": "鹤岗市", + "province": "黑龙江省" + }, + { + "city_id": "2O6CDIXSWIKBXILZEEPKCS7MVI", + "name": "双鸭山市", + "province": "黑龙江省" + }, + { + "city_id": "OZ2PTOBYTBG57XJZMIC23QFJKM", + "name": "佳木斯市", + "province": "黑龙江省" + }, + { + "city_id": "FO24MQMULT3J5JW64APNXSQEPU", + "name": "伊春市", + "province": "黑龙江省" + }, + { + "city_id": "ETZ2HYWVU6U7SKU6G4JAO64RUQ", + "name": "黑河市", + "province": "黑龙江省" + }, + { + "city_id": "PR7EJNBY2VZBEUT36JAWE3TM7I", + "name": "七台河市", + "province": "黑龙江省" + }, + { + "city_id": "HADAAVLERKIW4SQGCTQYGX4AL4", + "name": "哈尔滨市", + "province": "黑龙江省" + }, + { + "city_id": "CGTU45YC5C3JYLHMA47USDPA7Y", + "name": "大庆市", + "province": "黑龙江省" + }, + { + "city_id": "T4W7SQIPOM4EYMEFFRAB5BSTII", + "name": "鸡西市", + "province": "黑龙江省" + }, + { + "city_id": "TYGZHNQL6YT7CX6EEG5DJQQHMA", + "name": "绥化市", + "province": "黑龙江省" + }, + { + "city_id": "OOSJTSN2CVUUCKD6XAB7EYYIPY", + "name": "大兴安岭地区", + "province": "黑龙江省" + }, + { + "city_id": "I3YF3EKZHIZTN6TZOTYTGZ2UXQ", + "name": "随州市", + "province": "湖北省" + }, + { + "city_id": "ESGVBOSTHW7JWEVCGYJUTEHEBQ", + "name": "宜昌市", + "province": "湖北省" + }, + { + "city_id": "MTJRWJ53XBW5SBTWHKNNZDLM7U", + "name": "十堰市", + "province": "湖北省" + }, + { + "city_id": "PXZLF2ISKQL5ACM67ZCBNOGDT4", + "name": "黄石市", + "province": "湖北省" + }, + { + "city_id": "44RMTOEHPUFXBHZXX4IQ4IRZVQ", + "name": "荆州市", + "province": "湖北省" + }, + { + "city_id": "OTKZGG743NFC474ADMMRX4ZOOA", + "name": "鄂州市", + "province": "湖北省" + }, + { + "city_id": "EXOUAZAQ73OEFAK72CHQ32GQHQ", + "name": "恩施土家族苗族自治州", + "province": "湖北省" + }, + { + "city_id": "SUCY7I72QJDZD7EBFXREIQ67SI", + "name": "咸宁市", + "province": "湖北省" + }, + { + "city_id": "QENSGB5R7HGYDXCG2LQZQTO3TU", + "name": "荆门市", + "province": "湖北省" + }, + { + "city_id": "OHIWL6SAE2PR4EJR4BOMLAE6FU", + "name": "武汉市", + "province": "湖北省" + }, + { + "city_id": "ZXCE4WV2CDVPQTA4HAOVELQMNE", + "name": "襄阳市", + "province": "湖北省" + }, + { + "city_id": "KEFN5OPSS4ZZF6NU2TTL72S6HE", + "name": "孝感市", + "province": "湖北省" + }, + { + "city_id": "ROAHLMQ67H6M5NDFVXROJG723E", + "name": "黄冈市", + "province": "湖北省" + }, + { + "city_id": "YBEBX2YYN4WPBNH6Z6C73DNE7I", + "name": "张家界市", + "province": "湖南省" + }, + { + "city_id": "45XGRKYGSCPE5VNRYF4FVJGFMM", + "name": "株洲市", + "province": "湖南省" + }, + { + "city_id": "LK3SEIBRU7GTDT4J2EPTLIO33U", + "name": "永州市", + "province": "湖南省" + }, + { + "city_id": "R4YXFIK53W5E556BSGSBJWS4DM", + "name": "郴州市", + "province": "湖南省" + }, + { + "city_id": "PQDO3RNADWXX75OWZW2GSXJ4SE", + "name": "怀化市", + "province": "湖南省" + }, + { + "city_id": "RRRT6QOJYEJ432L3F76ZN5NHCA", + "name": "长沙市", + "province": "湖南省" + }, + { + "city_id": "B6WPNMCZ3ENQSV4NFY5MSTPDAM", + "name": "岳阳市", + "province": "湖南省" + }, + { + "city_id": "KNDZW5EHDPKP2DX7HBLKP4DYLM", + "name": "益阳市", + "province": "湖南省" + }, + { + "city_id": "PA2GHG3XZ7I47HTKZ4YAFH3OYY", + "name": "湘西土家族苗族自治州", + "province": "湖南省" + }, + { + "city_id": "I7CNIUA5PYV2EHDEW3RGYT2R4U", + "name": "邵阳市", + "province": "湖南省" + }, + { + "city_id": "SRI2SU4FN66FMJJCKQOCZD72ZY", + "name": "常德市", + "province": "湖南省" + }, + { + "city_id": "H7UHHJAMQUL7UA5QEUTGKNSL3A", + "name": "湘潭市", + "province": "湖南省" + }, + { + "city_id": "EFB255OBTB2BUDZENR5UVIC7ZQ", + "name": "衡阳市", + "province": "湖南省" + }, + { + "city_id": "RDMANB4KCM3OJSNVGZWVYVME6E", + "name": "娄底市", + "province": "湖南省" + }, + { + "city_id": "LD37PDU5OB4UAV5QDOBMKG5YTY", + "name": "吉林市", + "province": "吉林省" + }, + { + "city_id": "EO3GF4XNF5RXWRPUVAT3KTQO4U", + "name": "四平市", + "province": "吉林省" + }, + { + "city_id": "4GD7OS4CAQABH5YIWVK5SKGHMY", + "name": "通化市", + "province": "吉林省" + }, + { + "city_id": "6DRI2R5VAWMYJHJPCJKUNMDYEQ", + "name": "延边朝鲜族自治州", + "province": "吉林省" + }, + { + "city_id": "TVBCNVGUND4MOUOOUXFGX7DIUA", + "name": "松原市", + "province": "吉林省" + }, + { + "city_id": "JYY62HSKBUVK5OU7KGJDKQ4RTA", + "name": "白城市", + "province": "吉林省" + }, + { + "city_id": "QEDUHKMZ36CHJTKRD6O2ZPLNBU", + "name": "长春市", + "province": "吉林省" + }, + { + "city_id": "4EADVCBJMZ5UBH2FVRT6QCLS2U", + "name": "辽源市", + "province": "吉林省" + }, + { + "city_id": "EUQD5EGS2LR5KJSFNG6PPSIHHI", + "name": "白山市", + "province": "吉林省" + }, + { + "city_id": "YLTIISPCLBEGTZZX3WUWAD7WDE", + "name": "淮安市", + "province": "江苏省" + }, + { + "city_id": "L6U5DZP6MESXPMHOHCDMJS55O4", + "name": "宿迁市", + "province": "江苏省" + }, + { + "city_id": "HQMLYA7TDGMYQAXFCDUXBZPYHI", + "name": "镇江市", + "province": "江苏省" + }, + { + "city_id": "K6XJ4UN65ZD6XQKYEG5YN7HCRI", + "name": "盐城市", + "province": "江苏省" + }, + { + "city_id": "36I4X3EZZU4EHOSCLQI5OAKKBE", + "name": "南通市", + "province": "江苏省" + }, + { + "city_id": "S3GWFQU6QAVRDKLJT77LD6OFLE", + "name": "泰州市", + "province": "江苏省" + }, + { + "city_id": "IO6F4AFGAVIFRGYTZEC4TXM7W4", + "name": "无锡市", + "province": "江苏省" + }, + { + "city_id": "NUXNK2VOFSD2JFTEO2AMWX6NSU", + "name": "扬州市", + "province": "江苏省" + }, + { + "city_id": "TEVZU6CU6SK57HFW7DFNGMQ44A", + "name": "南京市", + "province": "江苏省" + }, + { + "city_id": "UTYSRBQ4FSB7XLWCF3Z2HTKNUA", + "name": "常州市", + "province": "江苏省" + }, + { + "city_id": "OCZOBCJDEXKE7KBN3BD7AYQG2Q", + "name": "徐州市", + "province": "江苏省" + }, + { + "city_id": "6LIBPJGZROLXE3CLZGJRYMYBOU", + "name": "连云港市", + "province": "江苏省" + }, + { + "city_id": "FS4PIU74F7QKYARDWR5ZMOLICI", + "name": "苏州市", + "province": "江苏省" + }, + { + "city_id": "R2F4OWUO65HYZW2IQIKINORZ7Y", + "name": "赣州市", + "province": "江西省" + }, + { + "city_id": "YW346BTN3VFYNRC3744UR5MZXY", + "name": "抚州市", + "province": "江西省" + }, + { + "city_id": "QR3FDR26U2EJIXOMBHL7IJLQSA", + "name": "南昌市", + "province": "江西省" + }, + { + "city_id": "OAJHJL7L7VNW2Q5UXRE7F4CUJQ", + "name": "九江市", + "province": "江西省" + }, + { + "city_id": "OMH7D45R4DX2KNHLV3G2UP56OY", + "name": "景德镇市", + "province": "江西省" + }, + { + "city_id": "YSB2PAEROB2IZSJZFVFH7KJPEI", + "name": "鹰潭市", + "province": "江西省" + }, + { + "city_id": "5OYAMNORCXKYA6UF7DW6KFFBIU", + "name": "上饶市", + "province": "江西省" + }, + { + "city_id": "SMHZOYKE7BXQJ2NT6Q24TFMLEQ", + "name": "吉安市", + "province": "江西省" + }, + { + "city_id": "2RZV26OUPKUHUJ5ZPB673VDGZU", + "name": "萍乡市", + "province": "江西省" + }, + { + "city_id": "232VHZEEZ6SXACE4AC5HQ4ZTFQ", + "name": "新余市", + "province": "江西省" + }, + { + "city_id": "QRLM74YXNDW2QDBWLTFGEMXK2I", + "name": "宜春市", + "province": "江西省" + }, + { + "city_id": "S6OHUVUKIIWPVMQD44RREUMNT4", + "name": "葫芦岛市", + "province": "辽宁省" + }, + { + "city_id": "DQQ4OIFUGFYJY3XZRK5VDWMLCA", + "name": "辽阳市", + "province": "辽宁省" + }, + { + "city_id": "S4YXGFEYXEUG6ISZ6O337OPVSI", + "name": "阜新市", + "province": "辽宁省" + }, + { + "city_id": "D3JHM7A4CG6RJMBD7YRDS5JOYU", + "name": "盘锦市", + "province": "辽宁省" + }, + { + "city_id": "VTRWMOSS6PCUYUAIPG6VPBKUUQ", + "name": "营口市", + "province": "辽宁省" + }, + { + "city_id": "ZPHFGWBIEVLKP5CVZNZUB3CRT4", + "name": "朝阳市", + "province": "辽宁省" + }, + { + "city_id": "NGYYULZ4UAGD3Q2PG726FFXSHU", + "name": "抚顺市", + "province": "辽宁省" + }, + { + "city_id": "Q5BRTSW752VSHIAKLLL7KL5TNA", + "name": "锦州市", + "province": "辽宁省" + }, + { + "city_id": "ZGV3WNPOSS7J4ZWBP6ZQG46BNM", + "name": "沈阳市", + "province": "辽宁省" + }, + { + "city_id": "XEX676YYMTYIV5QPIUZB4TA7IY", + "name": "本溪市", + "province": "辽宁省" + }, + { + "city_id": "PRTEQZMLNLQNZXJHRCYYLWZB4E", + "name": "丹东市", + "province": "辽宁省" + }, + { + "city_id": "4GN4WF6UQRFU64T4FVZPRDXRWQ", + "name": "鞍山市", + "province": "辽宁省" + }, + { + "city_id": "3QTZDFLJFSLLOOVCZ65PSDAVOU", + "name": "铁岭市", + "province": "辽宁省" + }, + { + "city_id": "CC4ZTMKKXI73ZEVT5QQTJN5SMM", + "name": "大连市", + "province": "辽宁省" + }, + { + "city_id": "3MBJEFDLAVOMQZ7L7CM5MNSYKA", + "name": "鄂尔多斯市", + "province": "内蒙古自治区" + }, + { + "city_id": "5NOS4YC5WO2IZCQPVB6MCBYDJ4", + "name": "呼和浩特市", + "province": "内蒙古自治区" + }, + { + "city_id": "OQNIP675H7L5R64652BH7KHUOQ", + "name": "通辽市", + "province": "内蒙古自治区" + }, + { + "city_id": "4WA6I63MGVINV5DNLNWRRHCDDM", + "name": "阿拉善盟", + "province": "内蒙古自治区" + }, + { + "city_id": "ELI6BDJBAN6RCYTETMK2EX2UKU", + "name": "乌兰察布市", + "province": "内蒙古自治区" + }, + { + "city_id": "PV5ZAAXFW2DZCVZKCF4I4KK7BQ", + "name": "巴彦淖尔市", + "province": "内蒙古自治区" + }, + { + "city_id": "YU6UUT6G6T6AMWTJFECDIUQFEQ", + "name": "乌海市", + "province": "内蒙古自治区" + }, + { + "city_id": "V4MYANW5QFZCXG3FIDPXA3HOTE", + "name": "呼伦贝尔市", + "province": "内蒙古自治区" + }, + { + "city_id": "S5DCFJWJ7J3MJSLY2PHKWLNPOQ", + "name": "包头市", + "province": "内蒙古自治区" + }, + { + "city_id": "LY7SAZRFSJJMRU3JEO5SKNKIVM", + "name": "兴安盟", + "province": "内蒙古自治区" + }, + { + "city_id": "NM2XP54CNQCFOILKACYEQWUSGM", + "name": "锡林郭勒盟", + "province": "内蒙古自治区" + }, + { + "city_id": "S5G3IO75IDEJPZQA6VFM3OYPDI", + "name": "赤峰市", + "province": "内蒙古自治区" + }, + { + "city_id": "UUFUUPM5RT6ZU5UKILQC5YQV54", + "name": "吴忠市", + "province": "宁夏回族自治区" + }, + { + "city_id": "VMSRLIATK44WQXQEWAL63AXJ3M", + "name": "固原市", + "province": "宁夏回族自治区" + }, + { + "city_id": "4GWWCAAKGNJV2SMQPSWWZNCGYY", + "name": "中卫市", + "province": "宁夏回族自治区" + }, + { + "city_id": "6IE7GEETBQEF7GUSGU2FLIUEEM", + "name": "石嘴山市", + "province": "宁夏回族自治区" + }, + { + "city_id": "VI4YIH3URSON4Q4MWOEESXJ56Q", + "name": "银川市", + "province": "宁夏回族自治区" + }, + { + "city_id": "SIE4ED6QWVRT727GEHWBFH3DAA", + "name": "海东市", + "province": "青海省" + }, + { + "city_id": "JNJH6OJZIOQKXDXWW5ZGEHG5MA", + "name": "海西蒙古族藏族自治州", + "province": "青海省" + }, + { + "city_id": "MJADYNCKQNDJU2TXACTDP5I52M", + "name": "海北藏族自治州", + "province": "青海省" + }, + { + "city_id": "LRGFXIVB6RJWQWYAFH7EIUHCPE", + "name": "黄南藏族自治州", + "province": "青海省" + }, + { + "city_id": "J4TG3PCK2ZEMNEUMIPZF32UNQY", + "name": "果洛藏族自治州", + "province": "青海省" + }, + { + "city_id": "2YS5POGG53LKZGFBIUMDWP57SM", + "name": "玉树藏族自治州", + "province": "青海省" + }, + { + "city_id": "GRZMJEZCA2DNCZK3O6ZSUHMRPM", + "name": "西宁市", + "province": "青海省" + }, + { + "city_id": "NBFQIACRBBCAH5AZWJ5LVT7AU4", + "name": "海南藏族自治州", + "province": "青海省" + }, + { + "city_id": "MQUKCLQ76P4FRRECDBA3HBKT7Q", + "name": "滨州市", + "province": "山东省" + }, + { + "city_id": "633FVSBDDBM5WSMXSKOCX6QC5I", + "name": "潍坊市", + "province": "山东省" + }, + { + "city_id": "4434FVT3PXLMV6UAWLEW6O3M5A", + "name": "菏泽市", + "province": "山东省" + }, + { + "city_id": "P7PK4UBVCOHW3PI6IPEIA54DLY", + "name": "济南市", + "province": "山东省" + }, + { + "city_id": "I5M6JGTGSQEWX6HL7E5I6GRBAY", + "name": "德州市", + "province": "山东省" + }, + { + "city_id": "V562AOMBVU5NG5GB3EPK6U42XY", + "name": "烟台市", + "province": "山东省" + }, + { + "city_id": "4OSPHTE5TD24J6DYGR6DXMEDKY", + "name": "淄博市", + "province": "山东省" + }, + { + "city_id": "227TLAVTUJABWPJD4S4ZECJ3FY", + "name": "临沂市", + "province": "山东省" + }, + { + "city_id": "DAEZKZU32ZAPJGUTA6LLGO3WTY", + "name": "聊城市", + "province": "山东省" + }, + { + "city_id": "LBRRK2EOYJN5MLYJWT4R3QBSXM", + "name": "东营市", + "province": "山东省" + }, + { + "city_id": "AB6PBGCDBTNTG4KUQROY2FJ4GY", + "name": "枣庄市", + "province": "山东省" + }, + { + "city_id": "EVANGU7WZCVRAAM6NWTDJVP7SU", + "name": "济宁市", + "province": "山东省" + }, + { + "city_id": "GNUEGWZ3OKRWAKKVJ5THHHX6YY", + "name": "泰安市", + "province": "山东省" + }, + { + "city_id": "F3VWSF4ART2FYYBBOZYWKRXTUI", + "name": "青岛市", + "province": "山东省" + }, + { + "city_id": "KD6MNWWLVKB4E655XMV6MMA3KE", + "name": "日照市", + "province": "山东省" + }, + { + "city_id": "LHYVF4LBCOZ34G3WNZYUVEIQGA", + "name": "威海市", + "province": "山东省" + }, + { + "city_id": "ENVYDMYGDO3BMDXSAVQYZXLX74", + "name": "阳泉市", + "province": "山西省" + }, + { + "city_id": "UXOUG4UIF7ZRJYCNMQJ3LDN5FY", + "name": "临汾市", + "province": "山西省" + }, + { + "city_id": "4NZPT6Z35BMYACJ2HZGHUWRJ6E", + "name": "吕梁市", + "province": "山西省" + }, + { + "city_id": "KSNXQME2A3VFHCE3DM3SFZKIJQ", + "name": "晋城市", + "province": "山西省" + }, + { + "city_id": "HDOX7WKYSHJKEHET6TUYMVCTMQ", + "name": "太原市", + "province": "山西省" + }, + { + "city_id": "DFJIZVXJGBGBIABPSL3DGMIDIE", + "name": "长治市", + "province": "山西省" + }, + { + "city_id": "5KQYYTJR2EMP653QIALMA6LXXI", + "name": "忻州市", + "province": "山西省" + }, + { + "city_id": "T76EOJA332RIHML7B6LYS5LF4U", + "name": "朔州市", + "province": "山西省" + }, + { + "city_id": "HVX67CKT5TS6GPRDFDYOOLK4PE", + "name": "大同市", + "province": "山西省" + }, + { + "city_id": "S4NGXQJDOH7E4IHDWOH3EK6IIE", + "name": "晋中市", + "province": "山西省" + }, + { + "city_id": "GWDLZXLAWU54FKQ6G3HRQRR7E4", + "name": "运城市", + "province": "山西省" + }, + { + "city_id": "3FFTTN5PPV7MBCE5AGY2NGYOOI", + "name": "安康市", + "province": "陕西省" + }, + { + "city_id": "GACVPL3SWO3ZKH73JMJV6YI4NY", + "name": "延安市", + "province": "陕西省" + }, + { + "city_id": "6KPS7VRMW57P2DAC6OPR4ISHQQ", + "name": "商洛市", + "province": "陕西省" + }, + { + "city_id": "OMMF6XLNDNYWG5TBSNTWO2ZJZ4", + "name": "渭南市", + "province": "陕西省" + }, + { + "city_id": "EALFXGMWYRS6E6TWXQ2K3YHV4M", + "name": "咸阳市", + "province": "陕西省" + }, + { + "city_id": "WFG7U6JNUWDIS5ZZYM4FSM5C64", + "name": "榆林市", + "province": "陕西省" + }, + { + "city_id": "R3VBMYTCF5LVHO35X3MYJQFOOE", + "name": "宝鸡市", + "province": "陕西省" + }, + { + "city_id": "RQOWP7C234IS4RKSHB26IYZ5IU", + "name": "西安市", + "province": "陕西省" + }, + { + "city_id": "K4YU6B4T5GZLRWVHGVCR3576HI", + "name": "铜川市", + "province": "陕西省" + }, + { + "city_id": "VVFVAPLKSCN5KN4Q6RK2GPGUUA", + "name": "汉中市", + "province": "陕西省" + }, + { + "city_id": "2QSF6IG3KMDXWO5VP7FXHMMKXA", + "name": "上海市", + "province": "上海市" + }, + { + "city_id": "X3JCRNIPTCUU6DGOFFJ4MUK37M", + "name": "眉山市", + "province": "四川省" + }, + { + "city_id": "GQ24IZNTZJ3PUB5FDAMDA4W7UI", + "name": "攀枝花市", + "province": "四川省" + }, + { + "city_id": "6B6WT62WHBZRHPQUT7BAD2N6ZI", + "name": "泸州市", + "province": "四川省" + }, + { + "city_id": "HJ35P4KXL442MLIII7PFFWUNAE", + "name": "雅安市", + "province": "四川省" + }, + { + "city_id": "K4A6VSJH2AJYT46LMUSVQZPCCU", + "name": "资阳市", + "province": "四川省" + }, + { + "city_id": "646ZNPATOOM3MHI3LDU6HI4KFI", + "name": "阿坝藏族羌族自治州", + "province": "四川省" + }, + { + "city_id": "MU735ZDBFPXRQDUZ3I35JK3XEU", + "name": "内江市", + "province": "四川省" + }, + { + "city_id": "4WPGGJ63USY77GSRN2PPFCYKPQ", + "name": "广安市", + "province": "四川省" + }, + { + "city_id": "O4FFS4DALDAAKIFAUH4F5V5VS4", + "name": "宜宾市", + "province": "四川省" + }, + { + "city_id": "IRFJVK2KXBE6BZ7CSN4UFCI624", + "name": "绵阳市", + "province": "四川省" + }, + { + "city_id": "NELFD7FEKKUNDJ46VLD55SMDCE", + "name": "甘孜藏族自治州", + "province": "四川省" + }, + { + "city_id": "TKMVEUPZSQCXNRZPBEIK3F45AI", + "name": "遂宁市", + "province": "四川省" + }, + { + "city_id": "VQW7DPB4KTUI65COJBO3NODU24", + "name": "巴中市", + "province": "四川省" + }, + { + "city_id": "STP4ELXTVGQSB572LFRFJRIUUY", + "name": "南充市", + "province": "四川省" + }, + { + "city_id": "6ST5EX2JVXUCLR5GP5VEFSKN5M", + "name": "成都市", + "province": "四川省" + }, + { + "city_id": "UWNFCMW3HYJRALQI2MJH6EM2O4", + "name": "德阳市", + "province": "四川省" + }, + { + "city_id": "J5ZYU7XRV6CHSJAGOPQKS5YXNA", + "name": "达州市", + "province": "四川省" + }, + { + "city_id": "KYJTF5S746T35RFBMR65BLGM6U", + "name": "凉山彝族自治州", + "province": "四川省" + }, + { + "city_id": "RDUXR23XROLB4NGRVDDLXFXDSE", + "name": "乐山市", + "province": "四川省" + }, + { + "city_id": "4TUBIBHMVESJUCGMTUSLJPHXWI", + "name": "广元市", + "province": "四川省" + }, + { + "city_id": "AXQL57AO27NCHYMEOLRHAAKMTA", + "name": "自贡市", + "province": "四川省" + }, + { + "city_id": "4RXX566RZORCXS6HLEX3DIICSM", + "name": "花莲县", + "province": "台湾" + }, + { + "city_id": "BD5Y7SISWSSQGP3HTVPU6TXAH4", + "name": "台东县", + "province": "台湾" + }, + { + "city_id": "I4DNWLECRYOJAZLGYQB7PBBJXQ", + "name": "台中市", + "province": "台湾" + }, + { + "city_id": "GIZQIESFOMAEQSDQKOEQ5RTTPA", + "name": "南投县", + "province": "台湾" + }, + { + "city_id": "MPM6M2C634FAW7KYG3KIHERDTU", + "name": "彰化县", + "province": "台湾" + }, + { + "city_id": "NH2NK6JVOBBYYTK2G53ADWLX4Y", + "name": "苗栗县", + "province": "台湾" + }, + { + "city_id": "DW2Q2R2UEEDNQA7IHBGYV423K4", + "name": "新竹市", + "province": "台湾" + }, + { + "city_id": "FX5AOPFRPHGHHYZB4XNIPXLNNM", + "name": "新北市", + "province": "台湾" + }, + { + "city_id": "UVNZNB6G4M35RV3IGRUN6OXMXE", + "name": "屏东县", + "province": "台湾" + }, + { + "city_id": "MPB3M2YK24ZORO3EDCJ6UDWIGQ", + "name": "基隆市", + "province": "台湾" + }, + { + "city_id": "EBHVITJPDHJEEMMZTM4TD4UVRU", + "name": "台北市", + "province": "台湾" + }, + { + "city_id": "FQS4PZNCTQEX6I5F34Z2AGJUZM", + "name": "高雄市", + "province": "台湾" + }, + { + "city_id": "WVOJ636Q7MGT6RMN6QYQ4SZWIE", + "name": "嘉义市", + "province": "台湾" + }, + { + "city_id": "QRLER4EEMMKYGQLER2RWEQA74E", + "name": "台南市", + "province": "台湾" + }, + { + "city_id": "K2LHF64R2P4OJ7MDHJ6J2NSTPE", + "name": "桃园市", + "province": "台湾" + }, + { + "city_id": "BILG6LJIWUCTXZ6CDPXYAVM6XI", + "name": "澎湖县", + "province": "台湾" + }, + { + "city_id": "3FYRA3O2HUMLIQAAJQCPX2TETE", + "name": "宜兰县", + "province": "台湾" + }, + { + "city_id": "4MW6X22PAPVMHB6SBGF3RYS324", + "name": "天津市", + "province": "天津市" + }, + { + "city_id": "YEYPP4SQOBXU5UCNDN7ORSR6DI", + "name": "拉萨市", + "province": "西藏自治区" + }, + { + "city_id": "UNE6UPENGWQDOWGABDJEAQ2FEY", + "name": "山南市", + "province": "西藏自治区" + }, + { + "city_id": "VDXKN2YCIUPOHBZKKQHPN6HJWE", + "name": "林芝市", + "province": "西藏自治区" + }, + { + "city_id": "EAWIMNI77H72EYSOAYV3M76CB4", + "name": "阿里地区", + "province": "西藏自治区" + }, + { + "city_id": "HCF6UHTXOOKIOA43AIJH3ARKXA", + "name": "昌都市", + "province": "西藏自治区" + }, + { + "city_id": "Y47QI3KJY352QV3VOPXHM2IDWU", + "name": "日喀则市", + "province": "西藏自治区" + }, + { + "city_id": "R4UWJX44GVAA54NFKHT4Y4ZC5A", + "name": "那曲市", + "province": "西藏自治区" + }, + { + "city_id": "2D37GB5XUALJDXONWJIGXV3QXU", + "name": "香港", + "province": "香港特别行政区" + }, + { + "city_id": "PA5W7Z255K3EGYA4LTA7BGLHRA", + "name": "巴音郭楞蒙古自治州", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "RJZOCU5ECQCOLCOCHJH2UNLQJM", + "name": "哈密市", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "RYK6AR3VDQJFXZLX3MHYFV5VAI", + "name": "塔城地区", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "MXUVGU5NPVTNINAKKPNLWUQ54Q", + "name": "博尔塔拉蒙古自治州", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "T5RKMSH5VIGRTHDZOKV2EIKPIM", + "name": "伊犁哈萨克自治州", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "NABMKFZPOUCZMS4TUVJSZ24DNA", + "name": "克孜勒苏柯尔克孜自治州", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "2YQBXWNFYVJX4NU6WXB5II6X34", + "name": "昌吉回族自治州", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "DBKCQCCQU2URQG2EP5ZNPKBETY", + "name": "乌鲁木齐市", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "DON6KYBCR2XJQOJQGOZTYV4RMM", + "name": "阿克苏地区", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "WKG47NEVYII5JISZJN7QSI2BDQ", + "name": "克拉玛依市", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "DWLK6D3OUOXOVQHSEJVTRIDRAI", + "name": "喀什地区", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "SAPKF2PQGJD4UMVJZTC3IZKI64", + "name": "阿勒泰地区", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "ARWSLGG54LGUGN3XMIWW76NW34", + "name": "吐鲁番市", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "VQCWAKL6ADHYFTSVPBGPDFSB2I", + "name": "北屯市", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "ES5A6MOROAG6F2XAQ25TYYPWUE", + "name": "铁门关市", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "36IUY52AESIPF4QEQAR2RTNQYA", + "name": "和田地区", + "province": "新疆维吾尔自治区" + }, + { + "city_id": "Z26KSUL6ULS65ITZNUCRRWBYJM", + "name": "文山壮族苗族自治州", + "province": "云南省" + }, + { + "city_id": "XBBUUATPBD2IUJR47FCKVUXB2I", + "name": "昭通市", + "province": "云南省" + }, + { + "city_id": "TK2LP3JCYYMPTV4OA3WJCH7UQ4", + "name": "怒江傈僳族自治州", + "province": "云南省" + }, + { + "city_id": "ISJ4FESOYKCQ5LXOQO6NL7TQHA", + "name": "曲靖市", + "province": "云南省" + }, + { + "city_id": "ELBUBVI5UMUIEQGIOAHPMESXFA", + "name": "西双版纳傣族自治州", + "province": "云南省" + }, + { + "city_id": "QHOYHEGZM4WSJZPFEU6FCBYIWY", + "name": "玉溪市", + "province": "云南省" + }, + { + "city_id": "4G4SPJ7MVHMYZAWMQ4642PMLVI", + "name": "保山市", + "province": "云南省" + }, + { + "city_id": "HCHRV2LGJ2TJ4X6BWNWI2IMID4", + "name": "普洱市", + "province": "云南省" + }, + { + "city_id": "IS4Q6NASBWHO3RFO7UCIWOXVI4", + "name": "昆明市", + "province": "云南省" + }, + { + "city_id": "TDJZOAZFQUQPYRR5BHOJTG6RWM", + "name": "红河哈尼族彝族自治州", + "province": "云南省" + }, + { + "city_id": "EIYC62RNU4SHQW3RLAMDPTQYTI", + "name": "大理白族自治州", + "province": "云南省" + }, + { + "city_id": "BA3XFPITAYKBUWDKU3QONRHBC4", + "name": "德宏傣族景颇族自治州", + "province": "云南省" + }, + { + "city_id": "6P6FFFO6C5MLNCVRJAJUICSVQI", + "name": "临沧市", + "province": "云南省" + }, + { + "city_id": "OXHMWH2TSIDI7BQ43EHAMXJ6N4", + "name": "丽江市", + "province": "云南省" + }, + { + "city_id": "QQPDT4LBI2K2KMBNXZ6YH7X2FI", + "name": "楚雄彝族自治州", + "province": "云南省" + }, + { + "city_id": "XBG4EJAWCRJL2TDPNJ23PTFYHQ", + "name": "迪庆藏族自治州", + "province": "云南省" + }, + { + "city_id": "DINNCH54AP74TJ62MICEYAZP74", + "name": "宁波市", + "province": "浙江省" + }, + { + "city_id": "XYTSLYGB2ETU6HG7GIXA7X5SOE", + "name": "嘉兴市", + "province": "浙江省" + }, + { + "city_id": "NNAALJZXGAWALR3LGE2V4UZT6U", + "name": "丽水市", + "province": "浙江省" + }, + { + "city_id": "H5UOJ5MQYJ737GS3TXYN2OJHUU", + "name": "杭州市", + "province": "浙江省" + }, + { + "city_id": "HG5VQGOMSCEGNXJXKO6XCNCHMY", + "name": "湖州市", + "province": "浙江省" + }, + { + "city_id": "LJ2SWEPRINTYDH5A2QHRMI5US4", + "name": "衢州市", + "province": "浙江省" + }, + { + "city_id": "TW4RRM62TDA7WWU77FDSLSAXGY", + "name": "台州市", + "province": "浙江省" + }, + { + "city_id": "HBBN247QZ6YUW5ZYSS6D7RVJCA", + "name": "绍兴市", + "province": "浙江省" + }, + { + "city_id": "GVFB23SJZGRPRXXTIKDCAOCDPI", + "name": "金华市", + "province": "浙江省" + }, + { + "city_id": "UEW4ENX7N7IFGFM7FD5SZ5GI7Y", + "name": "舟山市", + "province": "浙江省" + }, + { + "city_id": "HCCXS5DGRJQMYZMRLGVAMUIQEA", + "name": "温州市", + "province": "浙江省" + }, + { + "city_id": "FDGY55I6IHKY76E3MWDBOT2R6Y", + "name": "重庆市", + "province": "重庆市" + } +] \ No newline at end of file diff --git a/scripts/pull_meituan_coupons.py b/scripts/pull_meituan_coupons.py index aaefdc2..3bc2977 100644 --- a/scripts/pull_meituan_coupons.py +++ b/scripts/pull_meituan_coupons.py @@ -1,45 +1,74 @@ -"""美团 CPS 券定时抓取入库(北京试点)。 +"""美团 CPS 券定时抓取入库(全国 359 个地级市)。 -把 3 路券抓进 meituan_coupon 表,供「销量/佣金排序」从库里捞、本地排序,不再实时打美团: +把每个城市的 3 路券抓进 meituan_coupon 表,供「智能推荐 / 销量最高」从库里捞、本地排序, +不再实时打美团: 1. search_waimai : 到家/外卖, 搜「外卖」 翻到尽头 2. search_meishi : 到家/外卖, 搜「美食」 翻到尽头 3. store_supply : 到店, 多业务线供给(到餐+到综+酒店+门票) 翻到尽头 +城市来自 app/integrations/data/meituan_cities.json(美团官方城市字典,359 地级市)。 +实测一个地级市 cityId 已覆盖其下辖县级市(徐州 → 邳州/新沂…),故按地级市抓即可。 + +并发:城市级并发(每城内部仍顺序跑 3 路),主线程串行入库(Session 不跨线程)。 +实测单城全量 ~110s/~2300 条;15 并发抓完全国一轮 ~50–60min,402 占比 ~3% 且退避全消化。 +mentor 要求每 3h 全量一轮,窗口充裕,故默认 12 并发 + 启动错峰,把瞬时峰值与 402 压更低。 + 按 (source, product_view_sign) upsert 存最新态;last_seen 每轮刷新。带文件锁,防止 -上一轮没跑完下一轮又起(本地 5~10min、跨进程 cron 都安全)。 +上一轮没跑完下一轮又起。 用法: - # 单轮(打通验证 / 给 cron 用,线上每 1h 一次) + # 单轮全量(给 cron 用,线上每 3h 一次) python -m scripts.pull_meituan_coupons --once - # 本地循环(默认每 10min 一轮) - python -m scripts.pull_meituan_coupons --loop --interval 600 + # 常驻循环(每 3h 一轮) + python -m scripts.pull_meituan_coupons --loop --interval 10800 + + # 本地测试:只抓前 N 个城市 / 指定城市 + python -m scripts.pull_meituan_coupons --once --limit 3 + python -m scripts.pull_meituan_coupons --once --city-ids OCZOBCJDEXKE7KBN3BD7AYQG2Q + +部署(服务器):推荐 cron 跑 --once(每 3h),避免长驻进程孤儿: + 0 */3 * * * cd /path/to/app-server && .venv/bin/python -m scripts.pull_meituan_coupons --once >> data/etl.log 2>&1 """ from __future__ import annotations import argparse import hashlib +import logging import os import re import sys import tempfile +import threading import time +from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timedelta, timezone -# Windows 控制台按 UTF-8 输出中文/¥ +# Windows 控制台按 UTF-8 输出中文/¥;line_buffering=True 让 print 每行即时 flush—— +# 否则 stdout 重定向到 cron/后台日志文件时是块缓冲,要攒到 ~4KB 或进程退出才落盘, +# 常驻(--loop)时几乎看不到每轮进度。 try: - sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined] + sys.stdout.reconfigure(encoding="utf-8", line_buffering=True) # type: ignore[attr-defined] except Exception: # noqa: BLE001 pass -from sqlalchemy import delete, func, select -from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy import delete, func, select # noqa: E402 +from sqlalchemy.dialects.postgresql import insert as pg_insert # noqa: E402 -from app.db.session import SessionLocal -from app.integrations.meituan import MeituanCpsError, _call -from app.models.meituan_coupon import MeituanCoupon +from app.db.session import SessionLocal, engine # noqa: E402 +from app.integrations.cities import all_cities # noqa: E402 +from app.integrations.meituan import MeituanCpsError, _call # noqa: E402 +from app.models.meituan_coupon import MeituanCoupon # noqa: E402 + +# dev 默认 create_engine(echo=True) 会逐条打 SQL,本脚本逐城 upsert 会把日志刷爆; +# ETL 不需要 SQL 日志,显式关掉(入库不受影响;线上 prod 本就 echo=False)。 +engine.echo = False + +# 美团调用偶发错误(本机走代理高并发时的 SSL EOF / 上游 code=5 等)会被 meituan._call 的 +# logger.exception 打完整 traceback,并发抓取下单轮可刷数十 KB 日志。ETL 自己用 _STATS 统计 +# 「放弃页数」,无需逐条 traceback,故把美团 logger 压到 CRITICAL(线上直连少见此类错误)。 +logging.getLogger("shagua.meituan").setLevel(logging.CRITICAL) -CITY_BEIJING = "WKV2HMXUEK634WP64CUCUQGM64" QUERY_PATH = "/cps_open/common/api/v1/query_coupon" PAGE_SIZE = 20 MAX_PAGES = 80 # 单路安全上限(搜索 ~52 页、供给 ~70 页) @@ -49,7 +78,12 @@ RETRY = 7 # 无 sudo 部署时 data/ 常属 root,cps 写不了会导致每轮 PermissionError、cron 抓不进数据。 # 需要指定位置时用环境变量 MEITUAN_ETL_LOCK 覆盖。 LOCK_FILE = os.environ.get("MEITUAN_ETL_LOCK") or os.path.join(tempfile.gettempdir(), "meituan_etl.lock") -LOCK_STALE_SEC = 30 * 60 # 锁超过 30min 视为陈旧(进程异常退出残留),自动接管 +# 多城全量一轮 ~50–60min,锁陈旧阈值放宽到 90min,避免把「正在跑的轮次」误判为残留而接管。 +LOCK_STALE_SEC = 90 * 60 + +DEFAULT_CONCURRENCY = 12 # 并发城市数(实测 15 并发 402 占 3% 可退避消化;12 更稳,3h 窗口充裕) +STARTUP_STAGGER = 0.3 # 首批城市启动错峰间隔秒(削平瞬时峰值,实测能压低 402) +PRUNE_FAIL_RATIO_MAX = 0.05 # 失败城占比超此值则本轮跳过 prune(避免大面积抓取失败误删库) SOURCES = [ {"code": "search_waimai", "label": "外卖·搜外卖", "kind": "search", "platform": 1, "keyword": "外卖"}, @@ -58,33 +92,44 @@ SOURCES = [ "platform": 2, "biz_lines": [1, 2, 3, 4]}, ] +# 跨线程统计(并发抓取下汇总请求量 / 402 / 放弃页数,供观测限流) +_STATS_LOCK = threading.Lock() +_STATS = {"req": 0, "r402": 0, "err": 0} + + +def _bump(key: str) -> None: + with _STATS_LOCK: + _STATS[key] += 1 + # ───────────────────────── 美团调用 ───────────────────────── def _call_retry(body: dict) -> dict | None: - """打美团,402/频繁退避重试;其它错误打印并放弃本页。""" + """打美团,402/频繁退避重试;其它错误放弃本页。并发下不逐条 print(避免刷屏),计入 _STATS。""" for a in range(RETRY): + _bump("req") try: return _call(QUERY_PATH, body) except MeituanCpsError as e: msg = str(e) if "402" in msg or "频繁" in msg: + _bump("r402") time.sleep(2.5 * (a + 1)) continue - print(f" [warn] meituan: {msg[:80]}") + _bump("err") return None - except Exception as e: # noqa: BLE001 - print(f" [warn] {type(e).__name__}: {str(e)[:60]}") + except Exception: # noqa: BLE001 time.sleep(2.0 * (a + 1)) + _bump("err") return None -def _pull_search(platform: int, keyword: str) -> list[dict]: +def _pull_search(city_id: str, platform: int, keyword: str) -> list[dict]: rows: list[dict] = [] sid = None pg = 1 while pg <= MAX_PAGES: - body = {"platform": platform, "searchText": keyword, "cityId": CITY_BEIJING, "pageSize": PAGE_SIZE} + body = {"platform": platform, "searchText": keyword, "cityId": city_id, "pageSize": PAGE_SIZE} if sid: body["searchId"] = sid else: @@ -102,14 +147,14 @@ def _pull_search(platform: int, keyword: str) -> list[dict]: return rows -def _pull_supply(platform: int, biz_lines: list[int]) -> list[dict]: +def _pull_supply(city_id: str, platform: int, biz_lines: list[int]) -> list[dict]: rows: list[dict] = [] sid = None biz_param = [{"bizLine": b} for b in biz_lines] for _ in range(MAX_PAGES): body = { "multipleSupplyList": [{"platform": platform, "bizLineParamList": biz_param}], - "cityId": CITY_BEIJING, + "cityId": city_id, "sortField": 2, # 供给查询 sortField 必填;我们入库后本地再排,这里给个默认 "pageSize": PAGE_SIZE, } @@ -157,7 +202,7 @@ def _to_cents(yuan) -> int | None: return None -def _parse_item(item: dict, source: dict) -> dict | None: +def _parse_item(item: dict, source: dict, city_id: str) -> dict | None: cpd = item.get("couponPackDetail") or {} br = item.get("brandInfo") or {} ci = item.get("commissionInfo") or {} @@ -190,7 +235,7 @@ def _parse_item(item: dict, source: dict) -> dict | None: "source": source["code"], "platform": source["platform"], "biz_line": item.get("bizLine") or cpd.get("bizLine"), - "city_id": CITY_BEIJING, + "city_id": city_id, "product_view_sign": str(sign)[:128], "sku_view_id": cpd.get("skuViewId"), "name": (name[:256] or None), @@ -210,6 +255,27 @@ def _parse_item(item: dict, source: dict) -> dict | None: } +def _pull_one_city(city: dict, index: int, concurrency: int, stagger: float) -> list[dict]: + """抓单个城市的 3 路券并解析。worker 线程内执行(只抓取+解析,不碰 DB)。 + + 启动错峰:首批并发的 `concurrency` 个城市按 index 错开首请求,削平瞬时峰值(压低 402)。 + """ + if stagger: + time.sleep((index % concurrency) * stagger) + cid = city["city_id"] + parsed: list[dict] = [] + for src in SOURCES: + if src["kind"] == "search": + items = _pull_search(cid, src["platform"], src["keyword"]) + else: + items = _pull_supply(cid, src["platform"], src["biz_lines"]) + for it in items: + p = _parse_item(it, src, cid) + if p: + parsed.append(p) + return parsed + + # ───────────────────────── 入库(upsert) ───────────────────────── def _upsert(db, rows: list[dict], now: datetime) -> tuple[int, int]: @@ -272,30 +338,53 @@ def _release_lock() -> None: # ───────────────────────── 主流程 ───────────────────────── -def run_once(prune_hours: int = 24) -> None: +def run_once( + prune_hours: int = 24, + concurrency: int = DEFAULT_CONCURRENCY, + stagger: float = STARTUP_STAGGER, + cities: list[dict] | None = None, +) -> None: if not _acquire_lock(): print(f"[{datetime.now():%H:%M:%S}] 上一轮还在跑(锁占用),跳过本轮") return t0 = time.time() now = datetime.now(timezone.utc) + with _STATS_LOCK: + _STATS.update(req=0, r402=0, err=0) + cities = cities if cities is not None else all_cities() + n_city = len(cities) db = SessionLocal() try: - total = 0 - for src in SOURCES: - ts = time.time() - if src["kind"] == "search": - items = _pull_search(src["platform"], src["keyword"]) - else: - items = _pull_supply(src["platform"], src["biz_lines"]) - parsed = [p for p in (_parse_item(it, src) for it in items) if p] - up, dup = _upsert(db, parsed, now) - total += up - print(f" {src['label']:18} 抓{len(items):5} 解析{len(parsed):5} " - f"入库{up:5} (本源去重{dup:3}) {time.time() - ts:4.0f}s") - # 清理长期未再出现的陈旧券(美团 sign 轮换 / 券下架后的残留),默认 24h 宽限。 - # 护栏:仅在本轮确有入库(total>0)时才清理。美团整体故障时本轮可能 0 入库 - # (脚本不抛异常、只是抓回空),若仍照常 prune,连续故障会按 last_seen 把全表删空。 - if prune_hours and prune_hours > 0 and total > 0: + total_up = 0 + done = 0 + fails: list[str] = [] + # 城市级并发抓取(worker 只抓取+解析),主线程逐城串行入库(Session 不跨线程)。 + with ThreadPoolExecutor(max_workers=concurrency) as pool: + futs = { + pool.submit(_pull_one_city, city, i, concurrency, stagger): city + for i, city in enumerate(cities) + } + for fut in as_completed(futs): + city = futs[fut] + try: + parsed = fut.result() + except Exception as e: # noqa: BLE001 + fails.append(city["name"]) + print(f" [fail] {city['name']}: {type(e).__name__}: {str(e)[:60]}") + continue + up, _dup = _upsert(db, parsed, now) + total_up += up + done += 1 + if done % 20 == 0 or done == n_city: + print(f" [{done}/{n_city}] {city['name']:10} 抓{len(parsed):5} 入库{up:5} " + f"(累计入库 {total_up}, 用时 {time.time() - t0:.0f}s)") + + # 清理陈旧券(美团 sign 轮换 / 券下架残留),默认 24h 宽限。两道护栏防误删: + # ① total_up>0:本轮 0 入库(疑似上游整体故障,脚本不抛异常只抓回空)时跳过,否则 + # 会按 last_seen 把全表删空; + # ② 失败城占比 ≤5%:大面积城市抓取失败(限流/网络)时跳过,避免误删还在架上的券。 + fail_ratio = len(fails) / max(1, n_city) + if prune_hours and prune_hours > 0 and total_up > 0 and fail_ratio <= PRUNE_FAIL_RATIO_MAX: cutoff = now - timedelta(hours=prune_hours) pruned = db.execute( delete(MeituanCoupon).where(MeituanCoupon.last_seen < cutoff) @@ -303,36 +392,62 @@ def run_once(prune_hours: int = 24) -> None: db.commit() if pruned: print(f" 清理陈旧券(>{prune_hours}h 未再出现): {pruned} 条") - elif prune_hours and prune_hours > 0 and total == 0: - print(" 本轮 0 入库(疑似上游故障),跳过清理以防误删全表") + elif prune_hours and prune_hours > 0: + reason = "本轮 0 入库" if total_up == 0 else f"失败城占比 {fail_ratio:.0%}>{PRUNE_FAIL_RATIO_MAX:.0%}" + print(f" 跳过 prune({reason},防误删)") + cnt = db.execute(select(func.count()).select_from(MeituanCoupon)).scalar() - print(f"[{datetime.now():%H:%M:%S}] 本轮完成: 入库 {total} 条, 表总计 {cnt} 行, " - f"用时 {time.time() - t0:.0f}s") + with _STATS_LOCK: + req, r402, err = _STATS["req"], _STATS["r402"], _STATS["err"] + fail_tail = (": " + ",".join(fails[:10])) if fails else "" + print(f"[{datetime.now():%H:%M:%S}] 本轮完成: {done}/{n_city} 城, 入库 {total_up} 条, " + f"表总计 {cnt} 行, 请求 {req}(402 {r402} / 放弃 {err}), " + f"失败城 {len(fails)}{fail_tail}, 用时 {time.time() - t0:.0f}s") finally: db.close() _release_lock() +def _select_cities(limit: int, city_ids: str) -> list[dict]: + cities = all_cities() + if city_ids.strip(): + want = {c.strip() for c in city_ids.split(",") if c.strip()} + return [c for c in cities if c["city_id"] in want] + if limit and limit > 0: + return cities[:limit] + return cities + + def main() -> None: - ap = argparse.ArgumentParser(description="美团 CPS 券定时抓取入库") + ap = argparse.ArgumentParser(description="美团 CPS 券定时抓取入库(全国 359 地级市)") ap.add_argument("--once", action="store_true", help="只跑一轮(默认)") ap.add_argument("--loop", action="store_true", help="循环跑") - ap.add_argument("--interval", type=int, default=600, help="循环间隔秒(默认 600=10min)") + ap.add_argument("--interval", type=int, default=10800, + help="循环间隔秒(默认 10800=3h,对齐每 3h 全量一轮)") ap.add_argument("--prune-hours", type=int, default=24, help="清理超过 N 小时未再出现的陈旧券(默认 24;0=不清理)") + ap.add_argument("--concurrency", type=int, default=DEFAULT_CONCURRENCY, + help=f"并发城市数(默认 {DEFAULT_CONCURRENCY};实测 15 并发 402 占 3%% 可退避消化)") + ap.add_argument("--stagger", type=float, default=STARTUP_STAGGER, + help=f"首批城市启动错峰间隔秒(默认 {STARTUP_STAGGER};削平瞬时峰值压低 402)") + ap.add_argument("--limit", type=int, default=0, help="只抓前 N 个城市(测试用,0=全部)") + ap.add_argument("--city-ids", default="", help="只抓这些 cityId(逗号分隔,测试用,优先于 --limit)") args = ap.parse_args() + cities = _select_cities(args.limit, args.city_ids) + print(f"城市数: {len(cities)} / 并发: {args.concurrency} / 错峰: {args.stagger}s / 间隔: {args.interval}s") + if args.loop: print(f"循环模式: 每 {args.interval}s 一轮 (Ctrl-C 退出)") while True: try: - run_once(args.prune_hours) + run_once(args.prune_hours, args.concurrency, args.stagger, cities) except Exception as e: # noqa: BLE001 print(f"[{datetime.now():%H:%M:%S}] 本轮异常: {type(e).__name__}: {e}") _release_lock() time.sleep(args.interval) else: - run_once(args.prune_hours) + run_once(args.prune_hours, args.concurrency, args.stagger, cities) if __name__ == "__main__": diff --git a/tools/gen_meituan_cities.py b/tools/gen_meituan_cities.py new file mode 100644 index 0000000..231fb7e --- /dev/null +++ b/tools/gen_meituan_cities.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +"""从美团「城市字典」Excel 生成随仓库的 JSON(app/integrations/data/meituan_cities.json)。 + +本地一次性工具:美团每年更新城市字典时,把新 Excel 放进来重跑即可。 +Excel 不入库、不进仓库(二进制、需 openpyxl);生成的 JSON 随仓库提交,部署到服务器后 +ETL(scripts/pull_meituan_coupons.py)直接读它遍历全部城市。 + +字典口径:359 个地级市,经实测一个地级市 cityId 已覆盖其下辖县级市(如徐州→邳州/新沂), +故无需区县层级。 + +用法: + python tools/gen_meituan_cities.py ["城市字典xxx.xlsx" 路径] + (不传则用默认 e:\\codes\\城市字典2025 (1).xlsx) +""" +from __future__ import annotations + +import json +import os +import sys + +import openpyxl + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +DEFAULT_EXCEL = r"e:\codes\城市字典2025 (1).xlsx" +OUT = os.path.join(ROOT, "app", "integrations", "data", "meituan_cities.json") + + +def main() -> None: + excel = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_EXCEL + wb = openpyxl.load_workbook(excel, data_only=True) + ws = wb.worksheets[0] + rows = list(ws.iter_rows(values_only=True)) + header = rows[0] + + cities = [] + seen = set() + for r in rows[1:]: + cid = str(r[0]).strip() if r[0] else "" + name = str(r[1]).strip() if len(r) > 1 and r[1] else "" + prov = str(r[2]).strip() if len(r) > 2 and r[2] else "" + if not cid or cid in seen: + continue + seen.add(cid) + cities.append({"city_id": cid, "name": name, "province": prov}) + + os.makedirs(os.path.dirname(OUT), exist_ok=True) + with open(OUT, "w", encoding="utf-8") as f: + json.dump(cities, f, ensure_ascii=False, indent=1) + + print(f"表头: {header}") + print(f"写出 {len(cities)} 城 → {OUT}") + print("样例:", cities[:3]) + + +if __name__ == "__main__": + main() From f853938095bf6263d480ab21b1c0017f7437a76c Mon Sep 17 00:00:00 2001 From: chenshuobo Date: Tue, 16 Jun 2026 21:19:47 +0800 Subject: [PATCH 13/36] =?UTF-8?q?fix(meituan-etl):=20=E6=B8=85=E6=B4=97?= =?UTF-8?q?=E6=96=87=E6=9C=AC=20NUL=20=E5=AD=97=E8=8A=82=20+=20=E9=80=90?= =?UTF-8?q?=E5=9F=8E=E5=85=A5=E5=BA=93=E5=AE=B9=E9=94=99(=E4=BF=AE?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E9=A6=96=E7=81=8C=E5=B4=A9=E5=9C=A8=E8=84=8F?= =?UTF-8?q?=E6=95=B0=E6=8D=AE)=20(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全国 359 城全量首灌崩在厦门:美团某券文本字段含 NUL(0x00),PostgreSQL text/jsonb 拒绝该字节,整批 upsert 抛 DataError → --once 进程崩、后续 300+ 城 全不跑(本地 20 城没撞上、跑全国才暴露)。两处修复: - _strip_nul 递归清洗入库 dict 所有字符串(含 raw JSON)的 NUL; - 逐城 _upsert 包 try/except + rollback,单城入库失败记 fails 跳过,不再让 一条脏数据 / 一次抖动拖垮整轮 359 城。 --------- Co-authored-by: chenshuobo <1119780489@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/58 Co-authored-by: chenshuobo Co-committed-by: chenshuobo --- scripts/pull_meituan_coupons.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/scripts/pull_meituan_coupons.py b/scripts/pull_meituan_coupons.py index 3bc2977..3431cc8 100644 --- a/scripts/pull_meituan_coupons.py +++ b/scripts/pull_meituan_coupons.py @@ -202,6 +202,18 @@ def _to_cents(yuan) -> int | None: return None +def _strip_nul(v): + """递归去掉字符串里的 NUL(0x00):PostgreSQL 的 text / jsonb 字段都不接受该字节, + 美团偶有脏数据(某券文本含 NUL)会让整批 upsert 抛 DataError。入库前统一清洗。""" + if isinstance(v, str): + return v.replace(chr(0), "") + if isinstance(v, dict): + return {k: _strip_nul(x) for k, x in v.items()} + if isinstance(v, list): + return [_strip_nul(x) for x in v] + return v + + def _parse_item(item: dict, source: dict, city_id: str) -> dict | None: cpd = item.get("couponPackDetail") or {} br = item.get("brandInfo") or {} @@ -231,7 +243,8 @@ def _parse_item(item: dict, source: dict, city_id: str) -> dict | None: dist = None dedup_raw = f"{brand}|{name}|{price_cents}" - return { + # 入库前递归清洗 NUL(美团脏数据偶含 0x00,PostgreSQL text/jsonb 拒绝整批 → DataError) + return _strip_nul({ "source": source["code"], "platform": source["platform"], "biz_line": item.get("bizLine") or cpd.get("bizLine"), @@ -252,7 +265,7 @@ def _parse_item(item: dict, source: dict, city_id: str) -> dict | None: "delivery_distance_m": dist, "dedup_key": hashlib.md5(dedup_raw.encode("utf-8")).hexdigest(), "raw": item, - } + }) def _pull_one_city(city: dict, index: int, concurrency: int, stagger: float) -> list[dict]: @@ -372,7 +385,13 @@ def run_once( fails.append(city["name"]) print(f" [fail] {city['name']}: {type(e).__name__}: {str(e)[:60]}") continue - up, _dup = _upsert(db, parsed, now) + try: + up, _dup = _upsert(db, parsed, now) + except Exception as e: # noqa: BLE001 + db.rollback() # 事务已 abort,必须 rollback 才能继续给下一城复用 Session + fails.append(city["name"]) + print(f" [入库失败] {city['name']}: {type(e).__name__}: {str(e)[:80]}") + continue total_up += up done += 1 if done % 20 == 0 or done == n_city: From 9d93b70b9ba23d3823e2eb0335442e5e642f0bb1 Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 17 Jun 2026 10:00:29 +0800 Subject: [PATCH 14/36] =?UTF-8?q?feat(user):=20username=20=E5=AF=B9?= =?UTF-8?q?=E5=A4=96=E5=B1=95=E7=A4=BA=20+=20=E9=BB=98=E8=AE=A4=E6=98=B5?= =?UTF-8?q?=E7=A7=B0=20+=20=E5=AD=98=E9=87=8F=E5=9B=9E=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 代提工作区既有的他人在制品(非本次 CPS);CPS 迁移链 cps_tables 依赖其 b3f1a2c4d5e6 迁移,需一并提交。 Co-Authored-By: Claude Opus 4.8 --- ...d5e6_user_username_and_default_nickname.py | 79 +++++++++++++++++++ app/models/user.py | 4 + app/repositories/user.py | 48 +++++++++++ app/schemas/auth.py | 2 + tests/test_auth.py | 37 +++++++++ tests/test_invite.py | 18 +++-- 6 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 alembic/versions/b3f1a2c4d5e6_user_username_and_default_nickname.py diff --git a/alembic/versions/b3f1a2c4d5e6_user_username_and_default_nickname.py b/alembic/versions/b3f1a2c4d5e6_user_username_and_default_nickname.py new file mode 100644 index 0000000..ba76a60 --- /dev/null +++ b/alembic/versions/b3f1a2c4d5e6_user_username_and_default_nickname.py @@ -0,0 +1,79 @@ +"""user.username(对外展示账号 ID)+ 默认昵称,并回填存量用户 + +Revision ID: b3f1a2c4d5e6 +Revises: 45047b5a884c +Create Date: 2026-06-16 12:00:00.000000 + +加 user.username(11 位纯数字、首位非 1 与手机号天然区分、全局唯一)。存量用户: +生成唯一 username,昵称为空的补 9 位字母数字随机昵称。 +迁移自包含(不 import app 业务代码,逻辑冻结为当时快照)。 +""" +import secrets +import string +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b3f1a2c4d5e6' +down_revision: Union[str, Sequence[str], None] = '45047b5a884c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +_USERNAME_FIRST = "23456789" # 首位避前导 0、避 1(手机号都以 1 开头) +_USERNAME_DIGITS = "0123456789" +_NICKNAME_ALPHABET = string.ascii_letters + string.digits + + +def _gen_username() -> str: + return secrets.choice(_USERNAME_FIRST) + "".join( + secrets.choice(_USERNAME_DIGITS) for _ in range(10) + ) + + +def _gen_nickname() -> str: + return "".join(secrets.choice(_NICKNAME_ALPHABET) for _ in range(9)) + + +def upgrade() -> None: + # 1. 先加可空列(存量行此刻还没值) + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('username', sa.String(length=11), nullable=True)) + + # 2. 回填存量:每人一个唯一 username;昵称为空的补随机昵称。 + # 表里 username 原本全空,本批用 used 防互撞即可(无既有非空值需规避)。 + # "user" 是 PostgreSQL 保留字,必须加双引号(SQLite 也接受双引号标识符)。 + bind = op.get_bind() + rows = bind.execute(sa.text('SELECT id, nickname FROM "user"')).fetchall() + used = set() + for row in rows: + uid, nick = row[0], row[1] + while True: + uname = _gen_username() + if uname not in used: + used.add(uname) + break + if nick is None or not str(nick).strip(): + bind.execute( + sa.text('UPDATE "user" SET username = :u, nickname = :n WHERE id = :i'), + {"u": uname, "n": _gen_nickname(), "i": uid}, + ) + else: + bind.execute( + sa.text('UPDATE "user" SET username = :u WHERE id = :i'), + {"u": uname, "i": uid}, + ) + + # 3. 收紧:NOT NULL + 唯一索引(对齐模型 unique=True, index=True) + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.alter_column('username', existing_type=sa.String(length=11), nullable=False) + batch_op.create_index('ix_user_username', ['username'], unique=True) + + +def downgrade() -> None: + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_index('ix_user_username') + batch_op.drop_column('username') diff --git a/app/models/user.py b/app/models/user.py index 67d6600..1161ba1 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -27,6 +27,10 @@ class User(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) phone: Mapped[str] = mapped_column(String(20), unique=True, index=True, nullable=False) + # 对外展示的账号 ID:11 位纯数字、首位非 1(与手机号天然区分——手机号都以 1 开头)、全局唯一、 + # 创建时随机生成、不可变、不参与登录(登录仍走 phone)。生成见 repositories/user._gen_username。 + username: Mapped[str] = mapped_column(String(11), unique=True, index=True, nullable=False) + # 注册渠道:jverify / sms。后续加 wechat / apple 时扩 register_channel: Mapped[str] = mapped_column(String(20), nullable=False, default="jverify") diff --git a/app/repositories/user.py b/app/repositories/user.py index 697d7e8..33c53ae 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -4,6 +4,8 @@ """ from __future__ import annotations +import secrets +import string from datetime import datetime, timezone from sqlalchemy import select @@ -12,6 +14,50 @@ from sqlalchemy.orm import Session from app.models.user import User +# ===== 创建时分配的标识:用户名(对外展示账号 ID)+ 默认昵称 ===== + +# 用户名 = 11 位纯数字,首位 2-9(避前导 0、避 1 与手机号区分),后 10 位 0-9。 +# 空间 8×10^10,创建时随机生成 + 查重去重,全局唯一、不可变、不参与登录。 +_USERNAME_FIRST = "23456789" +_USERNAME_DIGITS = "0123456789" +_USERNAME_LEN = 11 + +# 默认昵称 = 9 位大小写字母 + 数字随机串(创建时给,用户可后续改;不要求唯一)。 +_NICKNAME_ALPHABET = string.ascii_letters + string.digits +_NICKNAME_LEN = 9 + + +def _gen_username() -> str: + """随机一个 11 位纯数字、首位非 0/1 的用户名(不保证唯一,唯一性由 _gen_unique_username 兜)。""" + return secrets.choice(_USERNAME_FIRST) + "".join( + secrets.choice(_USERNAME_DIGITS) for _ in range(_USERNAME_LEN - 1) + ) + + +def _gen_nickname() -> str: + """随机一个 9 位字母+数字的默认昵称。""" + return "".join(secrets.choice(_NICKNAME_ALPHABET) for _ in range(_NICKNAME_LEN)) + + +def get_user_by_username(db: Session, username: str) -> User | None: + return db.execute( + select(User).where(User.username == username) + ).scalar_one_or_none() + + +def _gen_unique_username(db: Session) -> str: + """生成一个库里尚不存在的用户名(仿 invite.ensure_code 的碰撞重试)。 + + 8×10^10 空间下碰撞极罕见,查重 + 重试足够;并发下的残留碰撞由 username 唯一约束 + 兜底(commit 抛 IntegrityError,与现有 phone 并发同号同级别,概率 ~ 用户数/8e10)。 + """ + for _ in range(8): + uname = _gen_username() + if get_user_by_username(db, uname) is None: + return uname + raise RuntimeError("生成用户名连续碰撞,请重试") + + def get_user_by_id(db: Session, user_id: int) -> User | None: return db.get(User, user_id) @@ -36,6 +82,8 @@ def upsert_user_for_login( if user is None: user = User( phone=phone, + username=_gen_unique_username(db), + nickname=_gen_nickname(), register_channel=register_channel, last_login_at=now, ) diff --git a/app/schemas/auth.py b/app/schemas/auth.py index a5eb077..600baaf 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -18,6 +18,8 @@ class UserOut(BaseModel): model_config = ConfigDict(from_attributes=True) # 允许直接 UserOut.model_validate(orm_user) id: int + # 对外展示的账号 ID:11 位纯数字、首位非 1、全局唯一、创建时分配、不可变。区别于登录用的 phone。 + username: str phone: str nickname: str | None = None avatar_url: str | None = None diff --git a/tests/test_auth.py b/tests/test_auth.py index 3aca97e..f9a6be1 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -206,3 +206,40 @@ def test_sms_gc_purges_stale_only(monkeypatch) -> None: assert "stale" not in sms._codes and "fresh" in sms._codes assert "old" not in sms._last_sent and "recent" in sms._last_sent assert "yesterday" not in sms._daily_count and "today" in sms._daily_count + + +# ============================ 用户名 / 默认昵称 ============================ + +def test_login_assigns_username_and_nickname(client) -> None: + """新用户创建即分配:11 位纯数字 username(首位非 0/1,与手机号天然区分)+ 9 位字母数字默认昵称。""" + phone = "13600136000" + 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 + user = r.json()["user"] + + uname = user["username"] + assert uname.isdigit() and len(uname) == 11 # 11 位纯数字 + assert uname[0] not in ("0", "1") # 无前导 0、与手机号(均以 1 开头)区分 + + nick = user["nickname"] + assert nick and len(nick) == 9 and nick.isalnum() # 9 位字母+数字 + + +def test_username_stable_and_unique_on_relogin() -> None: + """同号重登 username 不变(不可变标识);不同号 username 不同(唯一)。 + 直连 repository 绕开 HTTP 短信冷却。""" + from app.db.session import SessionLocal + from app.repositories import user as user_repo + + db = SessionLocal() + try: + a1 = user_repo.upsert_user_for_login(db, phone="13522220001", register_channel="sms") + uname_a, id_a = a1.username, a1.id + a2 = user_repo.upsert_user_for_login(db, phone="13522220001", register_channel="sms") + assert a2.id == id_a and a2.username == uname_a # 重登:同一行、username 稳定不变 + + b = user_repo.upsert_user_for_login(db, phone="13522220002", register_channel="sms") + assert b.username != uname_a # 不同用户、username 唯一 + finally: + db.close() diff --git a/tests/test_invite.py b/tests/test_invite.py index 574adad..a578c6a 100644 --- a/tests/test_invite.py +++ b/tests/test_invite.py @@ -300,7 +300,7 @@ def test_bind_no_code_no_fingerprint(client) -> None: # ===================================================================== def test_invitees_basic(client) -> None: - """A 邀 B、C → 列表返 2 条、total=2、没设资料的名字=脱敏手机号、头像 null、金币对。""" + """A 邀 B、C → 列表返 2 条、total=2、名字=被邀请人默认昵称(创建即有)、头像 null、金币对。""" a = _login(client, "13800002050") a_code = _my_code(client, a) for phone in ("13800002051", "13800002052"): @@ -313,9 +313,14 @@ def test_invitees_basic(client) -> None: assert body["total"] == 2 assert body["has_more"] is False assert len(body["items"]) == 2 - # 没设昵称头像 → 名字脱敏手机号、头像 null(不依赖顺序用 set) + # 创建即分配默认昵称 → 名字=被邀请人昵称(取实际值对比)、头像 null(不依赖顺序用 set) + with SessionLocal() as db: + expected_names = { + get_user_by_phone(db, "13800002051").nickname, + get_user_by_phone(db, "13800002052").nickname, + } names = {it["display_name"] for it in body["items"]} - assert names == {"138****2051", "138****2052"} + assert names == expected_names assert all(it["avatar_url"] is None for it in body["items"]) assert all(it["coins"] == INVITE_INVITER_COINS for it in body["items"]) @@ -336,9 +341,12 @@ def test_invitees_order_desc(client) -> None: ).scalar_one() rel_b.created_at = datetime.now(timezone.utc) - timedelta(hours=2) db.commit() + nick_b = ub.nickname + nick_c = get_user_by_phone(db, "13800002062").nickname items = client.get("/api/v1/invite/invitees", headers=_auth(a)).json()["items"] - assert items[0]["display_name"] == "138****2062" # C 刚邀,在前 - assert items[1]["display_name"] == "138****2061" # B 2 小时前,在后 + # 创建即有默认昵称 → display_name=被邀请人昵称;本用例核心验证倒序(C 刚邀在前、B 2h 前在后) + assert items[0]["display_name"] == nick_c + assert items[1]["display_name"] == nick_b def test_invitees_nickname_priority(client) -> None: From 277f9b16a2298c92092250972ca35664eb8453ad Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 17 Jun 2026 10:00:29 +0800 Subject: [PATCH 15/36] =?UTF-8?q?feat(cps):=20=E7=BE=A4=E5=8F=91=E5=88=B8?= =?UTF-8?q?=E5=88=86=E5=8F=91=E4=B8=8E=E5=AF=B9=E8=B4=A6=20+=20=E7=9F=AD?= =?UTF-8?q?=E9=93=BE=E7=82=B9=E5=87=BB=E8=BF=BD=E8=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 群(sid)/活动池/转链(带sid)/美团 query_order 按 sid 对账/按群统计; 短链 /c/{code} 记点击(PV/UV)→302 跳美团,点击→下单漏斗。 Co-Authored-By: Claude Opus 4.8 --- alembic/versions/cps_link_tables.py | 61 ++++++ alembic/versions/cps_tables.py | 92 +++++++++ app/admin/main.py | 2 + app/admin/repositories/cps.py | 287 ++++++++++++++++++++++++++++ app/admin/routers/cps.py | 268 ++++++++++++++++++++++++++ app/admin/schemas/cps.py | 122 ++++++++++++ app/api/v1/cps_redirect.py | 39 ++++ app/core/config.py | 7 + app/integrations/meituan.py | 41 +++- app/main.py | 3 + app/models/__init__.py | 4 + app/models/cps_activity.py | 34 ++++ app/models/cps_group.py | 32 ++++ app/models/cps_link.py | 52 +++++ app/models/cps_order.py | 63 ++++++ app/repositories/cps_link.py | 82 ++++++++ 16 files changed, 1187 insertions(+), 2 deletions(-) create mode 100644 alembic/versions/cps_link_tables.py create mode 100644 alembic/versions/cps_tables.py create mode 100644 app/admin/repositories/cps.py create mode 100644 app/admin/routers/cps.py create mode 100644 app/admin/schemas/cps.py create mode 100644 app/api/v1/cps_redirect.py create mode 100644 app/models/cps_activity.py create mode 100644 app/models/cps_group.py create mode 100644 app/models/cps_link.py create mode 100644 app/models/cps_order.py create mode 100644 app/repositories/cps_link.py diff --git a/alembic/versions/cps_link_tables.py b/alembic/versions/cps_link_tables.py new file mode 100644 index 0000000..7c42d4f --- /dev/null +++ b/alembic/versions/cps_link_tables.py @@ -0,0 +1,61 @@ +"""cps_link / cps_click tables (群发短链 + 点击统计) + +Revision ID: cps_link_tables +Revises: cps_tables +Create Date: 2026-06-16 13:20:00.000000 + +群发券链接套一层 /c/{code} 做点击统计:cps_link(短码→美团目标) + cps_click(点击事件)。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "cps_link_tables" +down_revision: Union[str, Sequence[str], None] = "cps_tables" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_NOW = sa.text("CURRENT_TIMESTAMP") + + +def upgrade() -> None: + op.create_table( + "cps_link", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("code", sa.String(length=16), nullable=False), + sa.Column("group_id", sa.Integer(), nullable=False), + sa.Column("activity_id", sa.Integer(), nullable=False), + sa.Column("sid", sa.String(length=64), nullable=False), + sa.Column("platform", sa.String(length=20), nullable=False, server_default="meituan"), + sa.Column("target_url", sa.String(length=1024), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("code", name="uq_cps_link_code"), + ) + op.create_index("ix_cps_link_code", "cps_link", ["code"]) + op.create_index("ix_cps_link_group_id", "cps_link", ["group_id"]) + op.create_index("ix_cps_link_activity_id", "cps_link", ["activity_id"]) + op.create_index("ix_cps_link_sid", "cps_link", ["sid"]) + + op.create_table( + "cps_click", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("link_id", sa.Integer(), nullable=False), + sa.Column("group_id", sa.Integer(), nullable=False), + sa.Column("sid", sa.String(length=64), nullable=False), + sa.Column("ip", sa.String(length=64), nullable=True), + sa.Column("ua", sa.String(length=512), nullable=True), + sa.Column("clicked_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_cps_click_link_id", "cps_click", ["link_id"]) + op.create_index("ix_cps_click_group_id", "cps_click", ["group_id"]) + op.create_index("ix_cps_click_sid", "cps_click", ["sid"]) + op.create_index("ix_cps_click_clicked_at", "cps_click", ["clicked_at"]) + + +def downgrade() -> None: + op.drop_table("cps_click") + op.drop_table("cps_link") diff --git a/alembic/versions/cps_tables.py b/alembic/versions/cps_tables.py new file mode 100644 index 0000000..8180e35 --- /dev/null +++ b/alembic/versions/cps_tables.py @@ -0,0 +1,92 @@ +"""cps_group / cps_activity / cps_order tables (CPS 分发与对账) + +Revision ID: cps_tables +Revises: b3f1a2c4d5e6 +Create Date: 2026-06-16 12:40:00.000000 + +群发 CPS 优惠券的分发与对账:cps_group(群=sid) + cps_activity(可推广活动池) + +cps_order(美团 query_order 按 sid 拉回的订单明细)。金额统一存「分」。 +order_id 全局唯一(reconcile 按它 upsert,订单状态会变则更新)。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision: str = "cps_tables" +down_revision: Union[str, Sequence[str], None] = "b3f1a2c4d5e6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_NOW = sa.text("CURRENT_TIMESTAMP") + + +def upgrade() -> None: + op.create_table( + "cps_group", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("sid", sa.String(length=64), nullable=False), + sa.Column("name", sa.String(length=128), nullable=False), + sa.Column("member_count", sa.Integer(), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default="active"), + sa.Column("remark", sa.String(length=256), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("sid", name="uq_cps_group_sid"), + ) + op.create_index("ix_cps_group_sid", "cps_group", ["sid"]) + + op.create_table( + "cps_activity", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("platform", sa.String(length=20), nullable=False, server_default="meituan"), + sa.Column("name", sa.String(length=128), nullable=False), + sa.Column("act_id", sa.String(length=64), nullable=True), + sa.Column("product_view_sign", sa.String(length=128), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default="active"), + sa.Column("remark", sa.String(length=256), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_table( + "cps_order", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("order_id", sa.String(length=64), nullable=False), + sa.Column("sid", sa.String(length=64), nullable=True), + sa.Column("act_id", sa.String(length=64), nullable=True), + sa.Column("biz_line", sa.Integer(), nullable=True), + sa.Column("trade_type", sa.Integer(), nullable=True), + sa.Column("pay_price_cents", sa.Integer(), nullable=True), + sa.Column("commission_cents", sa.Integer(), nullable=True), + sa.Column("commission_rate", sa.String(length=16), nullable=True), + sa.Column("refund_price_cents", sa.Integer(), nullable=True), + sa.Column("refund_profit_cents", sa.Integer(), nullable=True), + sa.Column("mt_status", sa.String(length=8), nullable=True), + sa.Column("invalid_reason", sa.String(length=128), nullable=True), + sa.Column("product_name", sa.String(length=512), nullable=True), + sa.Column("pay_time", sa.DateTime(timezone=True), nullable=True), + sa.Column("mt_update_time", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "raw", + sa.JSON().with_variant(postgresql.JSONB(), "postgresql"), + nullable=False, + ), + sa.Column("first_seen", sa.DateTime(timezone=True), server_default=_NOW, nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("order_id", name="uq_cps_order_order_id"), + ) + op.create_index("ix_cps_order_order_id", "cps_order", ["order_id"]) + op.create_index("ix_cps_order_sid", "cps_order", ["sid"]) + op.create_index("ix_cps_order_act_id", "cps_order", ["act_id"]) + op.create_index("ix_cps_order_mt_status", "cps_order", ["mt_status"]) + op.create_index("ix_cps_order_pay_time", "cps_order", ["pay_time"]) + + +def downgrade() -> None: + op.drop_table("cps_order") + op.drop_table("cps_activity") + op.drop_table("cps_group") diff --git a/app/admin/main.py b/app/admin/main.py index c65aa9e..a67b403 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -19,6 +19,7 @@ from app.admin.routers.admins import router as admins_router from app.admin.routers.audit import router as audit_router from app.admin.routers.auth import router as auth_router from app.admin.routers.config import router as config_router +from app.admin.routers.cps import router as cps_router from app.admin.routers.dashboard import router as dashboard_router from app.admin.routers.ops_stat_config import router as ops_stat_config_router from app.admin.routers.feedback import router as feedback_router @@ -90,5 +91,6 @@ admin_app.include_router(feedback_router) admin_app.include_router(admins_router) admin_app.include_router(audit_router) admin_app.include_router(config_router) +admin_app.include_router(cps_router) admin_app.include_router(ad_audit_router) admin_app.include_router(ad_revenue_router) diff --git a/app/admin/repositories/cps.py b/app/admin/repositories/cps.py new file mode 100644 index 0000000..17516e2 --- /dev/null +++ b/app/admin/repositories/cps.py @@ -0,0 +1,287 @@ +"""admin CPS 数据访问:群/活动 CRUD + 美团订单拉单入库(对账) + 按群统计聚合。 + +美团 query_order 返回的金额是「元」字符串、时间是秒级时间戳,这里统一转「分」+ tz-aware。 +统计在 Python 侧聚合(订单量级小、admin 低频),逻辑清晰、跨 PG/SQLite 无 SQL 方言坑。 +""" +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal, InvalidOperation +from uuid import uuid4 + +from sqlalchemy import desc, select +from sqlalchemy.orm import Session + +from app.admin.repositories.queries import _as_utc, offset_paginate +from app.integrations import meituan +from app.repositories import cps_link as cps_link_repo +from app.models.cps_activity import CpsActivity +from app.models.cps_group import CpsGroup +from app.models.cps_order import CpsOrder + +# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账 +_INVALID_STATUS = {"4", "5"} +_SETTLED_STATUS = "6" + + +# ───────────── 单位换算 ───────────── +def _yuan_to_cents(v: object) -> int | None: + """「元」字符串/数 → 分。None / 空 / 字面 "null" → None。""" + if v is None: + return None + s = str(v).strip() + if not s or s.lower() == "null": + return None + try: + return int((Decimal(s) * 100).to_integral_value()) + except (InvalidOperation, ValueError): + return None + + +def _ts_to_dt(ts: object) -> datetime | None: + """秒级时间戳 → tz-aware UTC datetime(绝对时刻,前端按北京展示)。""" + if not ts: + return None + try: + return datetime.fromtimestamp(int(ts), tz=timezone.utc) + except (ValueError, OSError, TypeError): + return None + + +# ───────────── 群 ───────────── +def get_group(db: Session, group_id: int) -> CpsGroup | None: + return db.get(CpsGroup, group_id) + + +def get_group_by_sid(db: Session, sid: str) -> CpsGroup | None: + return db.execute(select(CpsGroup).where(CpsGroup.sid == sid)).scalar_one_or_none() + + +def list_groups( + db: Session, *, keyword: str | None = None, status: str | None = None, + limit: int = 20, cursor: int | None = None, +) -> tuple[list[CpsGroup], int | None, int]: + stmt = select(CpsGroup) + if keyword and keyword.strip(): + kw = f"%{keyword.strip()}%" + stmt = stmt.where(CpsGroup.name.ilike(kw) | CpsGroup.sid.ilike(kw)) + if status: + stmt = stmt.where(CpsGroup.status == status) + return offset_paginate(db, stmt, (desc(CpsGroup.id),), limit=limit, cursor=cursor) + + +def create_group( + db: Session, *, name: str, sid: str | None = None, + member_count: int | None = None, remark: str | None = None, commit: bool = True, +) -> CpsGroup: + """建群。sid 留空 → 自动 g(先 flush 拿 id 再回填)。""" + group = CpsGroup( + name=name, + sid=sid or f"tmp{uuid4().hex[:20]}", # 临时唯一占位,留空时下面回填 g + member_count=member_count, + remark=remark, + ) + db.add(group) + db.flush() + if not sid: + group.sid = f"g{group.id}" + db.flush() + if commit: + db.commit() + db.refresh(group) + return group + + +def update_group( + db: Session, group: CpsGroup, *, name: str | None = None, + member_count: int | None = None, status: str | None = None, + remark: str | None = None, commit: bool = True, +) -> CpsGroup: + if name is not None: + group.name = name + if member_count is not None: + group.member_count = member_count + if status is not None: + group.status = status + if remark is not None: + group.remark = remark + if commit: + db.commit() + db.refresh(group) + else: + db.flush() + return group + + +# ───────────── 活动 ───────────── +def get_activity(db: Session, activity_id: int) -> CpsActivity | None: + return db.get(CpsActivity, activity_id) + + +def list_activities( + db: Session, *, platform: str | None = None, status: str | None = None, + limit: int = 20, cursor: int | None = None, +) -> tuple[list[CpsActivity], int | None, int]: + stmt = select(CpsActivity) + if platform: + stmt = stmt.where(CpsActivity.platform == platform) + if status: + stmt = stmt.where(CpsActivity.status == status) + return offset_paginate(db, stmt, (desc(CpsActivity.id),), limit=limit, cursor=cursor) + + +def create_activity( + db: Session, *, name: str, platform: str = "meituan", act_id: str | None = None, + product_view_sign: str | None = None, remark: str | None = None, commit: bool = True, +) -> CpsActivity: + activity = CpsActivity( + name=name, platform=platform, act_id=act_id, + product_view_sign=product_view_sign, remark=remark, + ) + db.add(activity) + if commit: + db.commit() + db.refresh(activity) + else: + db.flush() + return activity + + +# ───────────── 订单(对账) ───────────── +def _map_order_fields(r: dict) -> dict: + """美团 query_order 单条 dataList → CpsOrder 字段(金额转分、时间转 datetime)。""" + pn = r.get("productName") + if pn and len(pn) > 500: + pn = pn[:500] + return { + "sid": (r.get("sid") or None), + "act_id": str(r["actId"]) if r.get("actId") is not None else None, + "biz_line": r.get("businessLine"), + "trade_type": r.get("tradeType"), + "pay_price_cents": _yuan_to_cents(r.get("payPrice")), + "commission_cents": _yuan_to_cents(r.get("profit")), + "commission_rate": str(r["commissionRate"]) if r.get("commissionRate") is not None else None, + "refund_price_cents": _yuan_to_cents(r.get("refundPrice")), + "refund_profit_cents": _yuan_to_cents(r.get("refundProfit")), + "mt_status": str(r["status"]) if r.get("status") is not None else None, + "invalid_reason": (r.get("invalidReason") or None), + "product_name": pn or None, + "pay_time": _ts_to_dt(r.get("payTime")), + "mt_update_time": _ts_to_dt(r.get("updateTime")), + "raw": r, + } + + +def reconcile_orders( + db: Session, *, start_time: int, end_time: int, + query_time_type: int = 1, sid: str | None = None, max_pages: int = 200, +) -> dict: + """调美团 query_order 分页拉单 → 按 order_id upsert。返回 {fetched, inserted, updated, pages}。 + + 订单状态会随时间变(付款→完成→结算/退款),重复拉同一单则更新。max_pages 防异常死循环。 + """ + fetched = inserted = updated = pages = 0 + page = 1 + while page <= max_pages: + resp = meituan.query_order( + sid=sid, start_time=start_time, end_time=end_time, + query_time_type=query_time_type, page=page, limit=100, + ) + rows = ((resp.get("data") or {}).get("dataList")) or [] + if not rows: + break + pages += 1 + for r in rows: + order_id = str(r.get("orderId") or "").strip() + if not order_id: + continue + fetched += 1 + fields = _map_order_fields(r) + existing = db.execute( + select(CpsOrder).where(CpsOrder.order_id == order_id) + ).scalar_one_or_none() + if existing is None: + db.add(CpsOrder(order_id=order_id, **fields)) + inserted += 1 + else: + for k, v in fields.items(): + setattr(existing, k, v) + updated += 1 + if len(rows) < 100: + break + page += 1 + db.commit() + return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages} + + +def list_orders( + db: Session, *, sid: str | None = None, mt_status: str | None = None, + limit: int = 20, cursor: int | None = None, +) -> tuple[list[CpsOrder], int | None, int]: + stmt = select(CpsOrder) + if sid: + stmt = stmt.where(CpsOrder.sid == sid) + if mt_status: + stmt = stmt.where(CpsOrder.mt_status == mt_status) + return offset_paginate(db, stmt, (desc(CpsOrder.id),), limit=limit, cursor=cursor) + + +# ───────────── 统计(按群聚合) ───────────── +def group_stats( + db: Session, *, date_from: datetime | None = None, date_to: datetime | None = None, +) -> list[dict]: + """按 sid 聚合订单 + join 群信息。已建但本期无单的活跃群也列出(全 0)。 + 未归群的 sid(历史/其它来源)单独成行 group_id=None。按预估佣金降序。 + """ + stmt = select(CpsOrder) + if date_from is not None: + stmt = stmt.where(CpsOrder.pay_time >= _as_utc(date_from)) + if date_to is not None: + stmt = stmt.where(CpsOrder.pay_time <= _as_utc(date_to)) + orders = list(db.execute(stmt).scalars().all()) + + groups = {g.sid: g for g in db.execute(select(CpsGroup)).scalars().all()} + + buckets: dict[str | None, list[CpsOrder]] = {} + for o in orders: + buckets.setdefault(o.sid, []).append(o) + + rows: list[dict] = [] + for sid, items in buckets.items(): + g = groups.get(sid) if sid else None + valid = [o for o in items if o.mt_status not in _INVALID_STATUS] + settled = [o for o in items if o.mt_status == _SETTLED_STATUS] + canceled = [o for o in items if o.mt_status in _INVALID_STATUS] + rows.append({ + "group_id": g.id if g else None, + "sid": sid, + "name": g.name if g else (sid or "(无 sid 归属)"), + "member_count": g.member_count if g else None, + "order_count": len(valid), + "settled_count": len(settled), + "canceled_count": len(canceled), + "gmv_cents": sum(o.pay_price_cents or 0 for o in valid), + "est_commission_cents": sum(o.commission_cents or 0 for o in valid), + "settled_commission_cents": sum(o.commission_cents or 0 for o in settled), + }) + + # 已建的活跃群但本期无单 → 补 0 行,让运营看到全部群 + seen = set(buckets.keys()) + for sid, g in groups.items(): + if sid not in seen and g.status == "active": + rows.append({ + "group_id": g.id, "sid": sid, "name": g.name, + "member_count": g.member_count, "order_count": 0, + "settled_count": 0, "canceled_count": 0, "gmv_cents": 0, + "est_commission_cents": 0, "settled_commission_cents": 0, + }) + + # 合并点击数据(按 group_id):点击只对生成过我们短链的群有,未归群行(group_id=None)恒 0 + clicks = cps_link_repo.click_stats_by_group(db, date_from=date_from, date_to=date_to) + for r in rows: + c = clicks.get(r["group_id"]) if r["group_id"] is not None else None + r["click_pv"] = c["pv"] if c else 0 + r["click_uv"] = c["uv"] if c else 0 + + rows.sort(key=lambda x: x["est_commission_cents"], reverse=True) + return rows diff --git a/app/admin/routers/cps.py b/app/admin/routers/cps.py new file mode 100644 index 0000000..592f363 --- /dev/null +++ b/app/admin/routers/cps.py @@ -0,0 +1,268 @@ +"""admin CPS 分发与对账:群(sid)管理 + 活动池 + 生成带 sid 券链接 + 美团订单对账 + 统计。 + +当前仅接美团(get_referral_link / query_order)。淘宝/京东待各自联盟凭证。 +群/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可。 +""" +from __future__ import annotations + +import time +from datetime import datetime, timedelta, timezone +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, Request + +from app.admin.audit import write_audit +from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role +from app.admin.repositories import cps as cps_repo +from app.admin.schemas.common import CursorPage +from app.core.config import settings +from app.repositories import cps_link as cps_link_repo +from app.admin.schemas.cps import ( + CpsActivityCreate, + CpsActivityOut, + CpsGroupCreate, + CpsGroupOut, + CpsGroupStat, + CpsGroupUpdate, + CpsOrderOut, + CpsReconcileResult, + CpsReferralLinkOut, + CpsReferralLinkRequest, + CpsStatsOut, +) +from app.integrations import meituan +from app.integrations.meituan import MeituanCpsError +from app.models.admin import AdminUser + +router = APIRouter( + prefix="/admin/api/cps", + tags=["admin-cps"], + dependencies=[Depends(get_current_admin)], +) + + +# ───────────── 群(sid) ───────────── +@router.get("/groups", response_model=CursorPage[CpsGroupOut], summary="群(sid)列表") +def list_groups( + db: AdminDb, + keyword: Annotated[str | None, Query(max_length=100)] = None, + status: Annotated[str | None, Query(pattern="^(active|archived)$")] = None, + limit: Annotated[int, Query(ge=1, le=100)] = 20, + cursor: Annotated[int | None, Query()] = None, +) -> CursorPage[CpsGroupOut]: + items, next_cursor, total = cps_repo.list_groups( + db, keyword=keyword, status=status, limit=limit, cursor=cursor + ) + return CursorPage( + items=[CpsGroupOut.model_validate(g) for g in items], + next_cursor=next_cursor, + total=total, + ) + + +@router.post("/groups", response_model=CpsGroupOut, summary="新建群(分配 sid)") +def create_group( + body: CpsGroupCreate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> CpsGroupOut: + if body.sid and cps_repo.get_group_by_sid(db, body.sid) is not None: + raise HTTPException(status_code=409, detail="sid 已存在") + group = cps_repo.create_group( + db, name=body.name, sid=body.sid, member_count=body.member_count, + remark=body.remark, commit=False, + ) + write_audit( + db, admin, action="cps.group.create", target_type="cps_group", target_id=group.id, + detail={"sid": group.sid, "name": group.name}, ip=get_client_ip(request), commit=False, + ) + db.commit() + db.refresh(group) + return CpsGroupOut.model_validate(group) + + +@router.patch("/groups/{group_id}", response_model=CpsGroupOut, summary="编辑群") +def update_group( + group_id: int, + body: CpsGroupUpdate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> CpsGroupOut: + group = cps_repo.get_group(db, group_id) + if group is None: + raise HTTPException(status_code=404, detail="群不存在") + cps_repo.update_group( + db, group, name=body.name, member_count=body.member_count, + status=body.status, remark=body.remark, commit=False, + ) + write_audit( + db, admin, action="cps.group.update", target_type="cps_group", target_id=group_id, + detail=body.model_dump(exclude_none=True), ip=get_client_ip(request), commit=False, + ) + db.commit() + db.refresh(group) + return CpsGroupOut.model_validate(group) + + +# ───────────── 活动 ───────────── +@router.get("/activities", response_model=CursorPage[CpsActivityOut], summary="活动列表") +def list_activities( + db: AdminDb, + platform: Annotated[str | None, Query(max_length=20)] = None, + status: Annotated[str | None, Query(pattern="^(active|archived)$")] = None, + limit: Annotated[int, Query(ge=1, le=100)] = 20, + cursor: Annotated[int | None, Query()] = None, +) -> CursorPage[CpsActivityOut]: + items, next_cursor, total = cps_repo.list_activities( + db, platform=platform, status=status, limit=limit, cursor=cursor + ) + return CursorPage( + items=[CpsActivityOut.model_validate(a) for a in items], + next_cursor=next_cursor, + total=total, + ) + + +@router.post("/activities", response_model=CpsActivityOut, summary="新建活动") +def create_activity( + body: CpsActivityCreate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> CpsActivityOut: + if not body.act_id and not body.product_view_sign: + raise HTTPException(status_code=400, detail="act_id 或 product_view_sign 必填其一") + activity = cps_repo.create_activity( + db, name=body.name, platform=body.platform, act_id=body.act_id, + product_view_sign=body.product_view_sign, remark=body.remark, commit=False, + ) + write_audit( + db, admin, action="cps.activity.create", target_type="cps_activity", target_id=activity.id, + detail={"name": activity.name, "act_id": activity.act_id, "platform": activity.platform}, + ip=get_client_ip(request), commit=False, + ) + db.commit() + db.refresh(activity) + return CpsActivityOut.model_validate(activity) + + +# ───────────── 转链 ───────────── +@router.post("/referral-link", response_model=CpsReferralLinkOut, summary="生成群发短链(带 sid + 点击统计)") +def generate_referral_link( + body: CpsReferralLinkRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> CpsReferralLinkOut: + group = cps_repo.get_group(db, body.group_id) + if group is None: + raise HTTPException(status_code=404, detail="群不存在") + activity = cps_repo.get_activity(db, body.activity_id) + if activity is None: + raise HTTPException(status_code=404, detail="活动不存在") + if activity.platform != "meituan": + raise HTTPException(status_code=400, detail=f"当前仅支持美团转链(活动平台={activity.platform})") + try: + # 全要:短链作跳转目标(微信可打开),长链/deeplink 作参考/备用 + resp = meituan.get_referral_link( + act_id=activity.act_id, + product_view_sign=activity.product_view_sign, + sid=group.sid, + link_type_list=[1, 2, 3], + ) + except MeituanCpsError as e: + raise HTTPException(status_code=502, detail=f"美团转链失败: {e}") from e + + link_map = {str(k): v for k, v in (resp.get("referralLinkMap") or {}).items()} + if not link_map and resp.get("data"): + link_map = {"1": resp["data"]} + # 跳转目标优先短链(2,微信可打开)→ 长链(1)→ deeplink(3) + target_url = link_map.get("2") or link_map.get("1") or link_map.get("3") + if not target_url: + raise HTTPException(status_code=502, detail="美团未返回有效链接(检查 actId 是否在推广有效期)") + + # 存我们的短链:发群用 /c/{code},用户点 → 我们记点击 → 302 跳 target_url + link = cps_link_repo.create_link( + db, group_id=group.id, activity_id=activity.id, sid=group.sid, + target_url=target_url, platform=activity.platform, commit=False, + ) + write_audit( + db, admin, action="cps.referral_link.generate", target_type="cps_link", target_id=link.id, + detail={"sid": group.sid, "activity_id": activity.id, "code": link.code}, + ip=get_client_ip(request), commit=False, + ) + db.commit() + db.refresh(link) + + base = settings.CPS_REDIRECT_BASE.rstrip("/") + redirect_url = f"{base}/c/{link.code}" if base else f"/c/{link.code}" + return CpsReferralLinkOut( + redirect_url=redirect_url, + code=link.code, + sid=group.sid, + group_name=group.name, + activity_name=activity.name, + target_url=target_url, + link_map=link_map, + ) + + +# ───────────── 订单对账 ───────────── +@router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取美团订单对账") +def reconcile_orders( + request: Request, + admin: Annotated[AdminUser, Depends(require_role("finance"))], + db: AdminDb, + days: Annotated[int, Query(ge=1, le=90)] = 7, + sid: Annotated[str | None, Query(max_length=64)] = None, +) -> CpsReconcileResult: + now = int(time.time()) + try: + result = cps_repo.reconcile_orders( + db, start_time=now - days * 86400, end_time=now, sid=sid, + ) + except MeituanCpsError as e: + raise HTTPException(status_code=502, detail=f"美团拉单失败: {e}") from e + write_audit( + db, admin, action="cps.order.reconcile", target_type="cps_order", target_id=None, + detail={"days": days, "sid": sid, **result}, ip=get_client_ip(request), commit=True, + ) + return CpsReconcileResult(**result) + + +@router.get("/orders", response_model=CursorPage[CpsOrderOut], summary="订单明细") +def list_orders( + db: AdminDb, + sid: Annotated[str | None, Query(max_length=64)] = None, + mt_status: Annotated[str | None, Query(max_length=8)] = None, + limit: Annotated[int, Query(ge=1, le=100)] = 20, + cursor: Annotated[int | None, Query()] = None, +) -> CursorPage[CpsOrderOut]: + items, next_cursor, total = cps_repo.list_orders( + db, sid=sid, mt_status=mt_status, limit=limit, cursor=cursor + ) + return CursorPage( + items=[CpsOrderOut.model_validate(o) for o in items], + next_cursor=next_cursor, + total=total, + ) + + +# ───────────── 统计 ───────────── +@router.get("/stats", response_model=CpsStatsOut, summary="按群对账统计") +def get_stats( + db: AdminDb, + days: Annotated[int, Query(ge=1, le=90)] = 30, +) -> CpsStatsOut: + date_to = datetime.now(timezone.utc) + date_from = date_to - timedelta(days=days) + rows = cps_repo.group_stats(db, date_from=date_from, date_to=date_to) + stats = [CpsGroupStat(**r) for r in rows] + return CpsStatsOut( + groups=stats, + total_order_count=sum(s.order_count for s in stats), + total_est_commission_cents=sum(s.est_commission_cents for s in stats), + total_settled_commission_cents=sum(s.settled_commission_cents for s in stats), + ) diff --git a/app/admin/schemas/cps.py b/app/admin/schemas/cps.py new file mode 100644 index 0000000..9028fe1 --- /dev/null +++ b/app/admin/schemas/cps.py @@ -0,0 +1,122 @@ +"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。""" +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +# ───────────── 群(sid) ───────────── +class CpsGroupOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + sid: str + name: str + member_count: int | None = None + status: str + remark: str | None = None + created_at: datetime + + +class CpsGroupCreate(BaseModel): + name: str = Field(min_length=1, max_length=128) + # 留空 → 后端自动生成 g;填写则必须字母+数字(美团 sid 限制)。 + sid: str | None = Field(default=None, max_length=64, pattern=r"^[A-Za-z0-9]+$") + member_count: int | None = Field(default=None, ge=0) + remark: str | None = Field(default=None, max_length=256) + + +class CpsGroupUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=128) + member_count: int | None = Field(default=None, ge=0) + status: str | None = Field(default=None, pattern=r"^(active|archived)$") + remark: str | None = Field(default=None, max_length=256) + + +# ───────────── 活动(actId) ───────────── +class CpsActivityOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + platform: str + name: str + act_id: str | None = None + product_view_sign: str | None = None + status: str + remark: str | None = None + created_at: datetime + + +class CpsActivityCreate(BaseModel): + name: str = Field(min_length=1, max_length=128) + platform: str = Field(default="meituan", pattern=r"^(meituan|taobao|jd)$") + act_id: str | None = Field(default=None, max_length=64) + product_view_sign: str | None = Field(default=None, max_length=128) + remark: str | None = Field(default=None, max_length=256) + + +# ───────────── 转链 ───────────── +class CpsReferralLinkRequest(BaseModel): + group_id: int + activity_id: int + # 链接类型:1长链 2短链 3deeplink。默认短链(微信可打开)+deeplink。 + link_types: list[int] = Field(default_factory=lambda: [2, 3]) + + +class CpsReferralLinkOut(BaseModel): + redirect_url: str # ★ 我们的群发短链(/c/{code}),发群就用这个,点击经我们统计 + code: str + sid: str + group_name: str + activity_name: str + target_url: str # 实际 302 跳转的美团短链 + link_map: dict[str, str] # 美团原始 1长链/2短链/3deeplink(参考/备用) + + +# ───────────── 对账拉单 ───────────── +class CpsReconcileResult(BaseModel): + fetched: int # 本次美团返回的订单条数 + inserted: int # 新入库 + updated: int # 已存在→更新状态/佣金 + pages: int # 翻了几页 + + +# ───────────── 订单明细 ───────────── +class CpsOrderOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + order_id: str + sid: str | None = None + act_id: str | None = None + pay_price_cents: int | None = None + commission_cents: int | None = None + commission_rate: str | None = None + mt_status: str | None = None + invalid_reason: str | None = None + product_name: str | None = None + pay_time: datetime | None = None + + +# ───────────── 统计 ───────────── +class CpsGroupStat(BaseModel): + group_id: int | None = None # None = 未归群的 sid(历史/其它来源) + sid: str | None = None + name: str + member_count: int | None = None + click_pv: int = 0 # 点击总次数 + click_uv: int = 0 # 独立点击(按 ip+ua 近似去重) + order_count: int # 有效订单数(不含取消/风控) + settled_count: int # 已结算单数 + canceled_count: int # 取消+风控单数 + gmv_cents: int # 成交额(有效单 payPrice 合计) + est_commission_cents: int # 预估佣金(有效单 profit 合计) + settled_commission_cents: int # 已结算佣金 + + +class CpsStatsOut(BaseModel): + groups: list[CpsGroupStat] + total_order_count: int + total_est_commission_cents: int + total_settled_commission_cents: int diff --git a/app/api/v1/cps_redirect.py b/app/api/v1/cps_redirect.py new file mode 100644 index 0000000..3b4120c --- /dev/null +++ b/app/api/v1/cps_redirect.py @@ -0,0 +1,39 @@ +"""CPS 群发短链跳转:用户点 /c/{code} → 记一条点击 → 302 跳美团。 + +公网、无鉴权(群里任何人点都要能跳)。记点击失败绝不影响跳转(用户体验优先)。 +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.repositories import cps_link as cps_link_repo + +router = APIRouter(tags=["cps-redirect"]) + +# code 不存在/失效时的兜底落地(避免用户看到报错) +_FALLBACK_URL = "https://www.meituan.com/" + + +def _client_ip(request: Request) -> str | None: + xff = request.headers.get("x-forwarded-for") + if xff: + return xff.split(",")[0].strip() + return request.client.host if request.client else None + + +@router.get("/c/{code}", summary="群发短链跳转(记点击 + 302)") +def cps_redirect(code: str, request: Request, db: Session = Depends(get_db)): + link = cps_link_repo.get_by_code(db, code) + if link is None: + return RedirectResponse(_FALLBACK_URL, status_code=302) + try: + cps_link_repo.record_click( + db, link=link, ip=_client_ip(request), + ua=request.headers.get("user-agent"), + ) + except Exception: + pass # 记点击失败不阻断跳转 + return RedirectResponse(link.target_url, status_code=302) diff --git a/app/core/config.py b/app/core/config.py index a06ebd0..5c3a787 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -190,6 +190,13 @@ class Settings(BaseSettings): # MVP 落地页就放 app-server 的 /media 静态目录下(dl.html)。 INVITE_LANDING_URL: str = "https://app-api.shaguabijia.com/media/dl.html" + # ===== CPS 群发短链跳转 ===== + # 群发券链接套一层我们的短链 /c/{code} 做点击统计后 302 跳美团。这里是写进 admin + # 生成链接里的对外跳转域名前缀。本地填 http://localhost:8770;生产**强烈建议用独立 + # 域名**(大量群发会被微信封,别用主 API 域名连累整个 App)。留空 → 跳转端点用请求 + # 的 Host 兜底拼绝对地址。 + CPS_REDIRECT_BASE: str = "" + # ===== CORS ===== CORS_ALLOW_ORIGINS: str = "" diff --git a/app/integrations/meituan.py b/app/integrations/meituan.py index 6d1b10a..b628b90 100644 --- a/app/integrations/meituan.py +++ b/app/integrations/meituan.py @@ -126,13 +126,23 @@ def query_coupon( def get_referral_link( *, - product_view_sign: str, + product_view_sign: str | None = None, + act_id: str | None = None, platform: int = 1, biz_line: int | None = None, sid: str | None = None, link_type_list: list[int] | None = None, ) -> dict[str, Any]: - body: dict[str, Any] = {"productViewSign": product_view_sign} + """换推广链接。actId(活动物料)与 productViewSign(商品券)二选一。 + 用 actId 入参时,出参 data 为 null,链接全在 referralLinkMap(实测)。 + """ + if not act_id and not product_view_sign: + raise MeituanCpsError("act_id 或 product_view_sign 必填其一") + body: dict[str, Any] = {} + if act_id: + body["actId"] = act_id + else: + body["productViewSign"] = product_view_sign if platform == 2: body["platform"] = 2 if biz_line is not None: @@ -142,3 +152,30 @@ def get_referral_link( body["linkTypeList"] = link_type_list or [1, 3] return _call("/cps_open/common/api/v1/get_referral_link", body) + + +def query_order( + *, + sid: str | None = None, + start_time: int, + end_time: int, + query_time_type: int = 1, + page: int = 1, + limit: int = 100, +) -> dict[str, Any]: + """按时间窗(+可选 sid)拉 CPS 订单做对账。实测要点: + - 分页是 page / limit(不是 pageNo/pageSize);startTime/endTime 是 10 位秒级时间戳 + - query_time_type: 1 按支付时间 2 按更新时间 + - 响应 data.dataList[],每条含 orderId / sid / payPrice(元) / profit(元) / + commissionRate / status(2付款 3完成 4取消 5风控 6结算) / payTime(秒) / actId 等 + """ + body: dict[str, Any] = { + "queryTimeType": query_time_type, + "startTime": start_time, + "endTime": end_time, + "page": page, + "limit": limit, + } + if sid: + body["sid"] = sid + return _call("/cps_open/common/api/v1/query_order", body) diff --git a/app/main.py b/app/main.py index cea4568..140a7a5 100644 --- a/app/main.py +++ b/app/main.py @@ -20,6 +20,7 @@ from app.api.v1.compare import router as compare_router from app.api.v1.compare_milestone import router as compare_milestone_router from app.api.v1.compare_record import router as compare_record_router from app.api.v1.coupon import router as coupon_router +from app.api.v1.cps_redirect import router as cps_redirect_router from app.api.internal.price import router as internal_price_router from app.api.internal.store import router as internal_store_router from app.api.v1.feedback import router as feedback_router @@ -107,6 +108,8 @@ app.include_router(report_router) app.include_router(internal_price_router) app.include_router(internal_store_router) app.include_router(platform_router) +# CPS 群发短链跳转 /c/{code}(公网无鉴权:记点击 → 302 跳美团) +app.include_router(cps_redirect_router) # 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。 _media_root = Path(settings.MEDIA_ROOT) diff --git a/app/models/__init__.py b/app/models/__init__.py index a089a8a..1955e31 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -6,6 +6,10 @@ from app.models.ad_watch_log import AdWatchLog # noqa: F401 from app.models.admin import AdminAuditLog, AdminUser # noqa: F401 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 +from app.models.cps_group import CpsGroup # noqa: F401 +from app.models.cps_link import CpsClick, CpsLink # noqa: F401 +from app.models.cps_order import CpsOrder # noqa: F401 from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401 from app.models.coupon_state import ( # noqa: F401 CouponClaimRecord, diff --git a/app/models/cps_activity.py b/app/models/cps_activity.py new file mode 100644 index 0000000..d52823a --- /dev/null +++ b/app/models/cps_activity.py @@ -0,0 +1,34 @@ +"""CPS 可推广活动池(cps_activity)。 + +运营预存常推的活动(券),生成链接时选它,省得每次记 actId。当前只接美团: +`act_id` = 美团联盟「我要推广-活动推广」第一列的物料 ID。`platform` 预留,接 +淘宝/京东时复用本表(各自的活动标识)。 +""" +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 CpsActivity(Base): + __tablename__ = "cps_activity" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + platform: Mapped[str] = mapped_column(String(20), nullable=False, default="meituan") # meituan|taobao|jd + name: Mapped[str] = mapped_column(String(128), nullable=False) + # 美团活动物料 ID(actId);淘宝/京东接入后存各自活动标识。 + act_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + # 美团商品券 productViewSign(与 act_id 二选一转链;多数活动用 act_id)。 + product_view_sign: Mapped[str | None] = mapped_column(String(128), nullable=True) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") # active | archived + remark: Mapped[str | None] = mapped_column(String(256), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/models/cps_group.py b/app/models/cps_group.py new file mode 100644 index 0000000..fcd40fd --- /dev/null +++ b/app/models/cps_group.py @@ -0,0 +1,32 @@ +"""CPS 推广群(cps_group)。 + +每个微信群一条记录,`sid` 是美团联盟二级渠道追踪位(get_referral_link / query_order +共用):发券时塞进推广链接,订单回来按 sid 归群对账。美团限制 sid 仅字母+数字 ≤64。 +""" +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 CpsGroup(Base): + __tablename__ = "cps_group" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # 每群唯一的渠道标识,建群时分配(留空则自动生成 g)。字母+数字,≤64。 + sid: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + name: Mapped[str] = mapped_column(String(128), nullable=False) + # 群人数,运营填,作转化率分母(可空)。 + member_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") # active | archived + remark: Mapped[str | None] = mapped_column(String(256), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/models/cps_link.py b/app/models/cps_link.py new file mode 100644 index 0000000..ef53859 --- /dev/null +++ b/app/models/cps_link.py @@ -0,0 +1,52 @@ +"""CPS 群发短链(cps_link)+ 点击事件(cps_click)。 + +群发券链接套一层我们自己的短链 `/c/{code}`:用户点 → 记一条 cps_click → 302 跳 +target_url(美团短链,微信可打开)。据此统计点击 PV/UV(按群/活动/时段),配合 cps_order +的下单/佣金做"点击→下单→佣金"漏斗。group_id/sid 在 cps_click 里冗余一份,统计直接按 +群聚合点击、不必 join cps_link。 +""" +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 CpsLink(Base): + __tablename__ = "cps_link" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # 短码:发群链接 /c/{code} 的 code,全局唯一。 + code: Mapped[str] = mapped_column(String(16), unique=True, index=True, nullable=False) + group_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) + activity_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) + sid: Mapped[str] = mapped_column(String(64), index=True, nullable=False) + platform: Mapped[str] = mapped_column(String(20), nullable=False, default="meituan") + # 302 跳转目标(美团短链,微信内可打开并唤起 App)。 + target_url: Mapped[str] = mapped_column(String(1024), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" + + +class CpsClick(Base): + __tablename__ = "cps_click" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + link_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) + group_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) # 冗余,按群聚合快 + sid: Mapped[str] = mapped_column(String(64), index=True, nullable=False) # 冗余 + ip: Mapped[str | None] = mapped_column(String(64), nullable=True) + ua: Mapped[str | None] = mapped_column(String(512), nullable=True) + clicked_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/models/cps_order.py b/app/models/cps_order.py new file mode 100644 index 0000000..306b30d --- /dev/null +++ b/app/models/cps_order.py @@ -0,0 +1,63 @@ +"""CPS 对账订单(cps_order)。 + +从美团联盟 query_order 按时间窗拉回、按 sid 归群的订单明细。字段对齐 query_order +实测返回: + - payPrice / profit 是「元」字符串 → 入库统一转「分」(与全站口径一致) + - payTime / updateTime 是秒级时间戳 → 入库转 tz-aware datetime + - status: 2付款 3完成 4取消 5风控 6结算(取消/风控不计佣金) +order_id 全局唯一,reconcile 按它 upsert(订单状态会变,重复拉则更新)。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import JSON, DateTime, Integer, String, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class CpsOrder(Base): + __tablename__ = "cps_order" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # 美团订单号(加密串),全局唯一,upsert 幂等键。 + order_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + # 渠道追踪位 = 群 sid(历史无 sid 订单为空)。按它归群聚合。 + sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + act_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + biz_line: Mapped[int | None] = mapped_column(Integer, nullable=True) # 1=外卖 + trade_type: Mapped[int | None] = mapped_column(Integer, nullable=True) # 1=cps 2=cpa + + pay_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) + commission_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) # 预估佣金(profit) + commission_rate: Mapped[str | None] = mapped_column(String(16), nullable=True) # "300"=3% "10"=0.1% + refund_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) + refund_profit_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) + + # 美团订单状态: 2付款 3完成 4取消 5风控 6结算 + mt_status: Mapped[str | None] = mapped_column(String(8), index=True, nullable=True) + invalid_reason: Mapped[str | None] = mapped_column(String(128), nullable=True) + product_name: Mapped[str | None] = mapped_column(String(512), nullable=True) + + pay_time: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), index=True, nullable=True + ) + mt_update_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + raw: Mapped[dict] = mapped_column( + JSON().with_variant(JSONB(), "postgresql"), nullable=False, default=dict + ) + first_seen: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return ( + f"" + ) diff --git a/app/repositories/cps_link.py b/app/repositories/cps_link.py new file mode 100644 index 0000000..9991ff8 --- /dev/null +++ b/app/repositories/cps_link.py @@ -0,0 +1,82 @@ +"""CPS 群发短链 + 点击 数据访问(用户侧跳转端点 + admin 生成/统计 共用)。 + +短码生成去掉易混字符;点击按 (ip, ua) 近似去重 UV。点击量级小、admin 低频,统计在 +Python 侧聚合,跨 PG/SQLite 无方言坑(与 admin cps repo 同风格)。 +""" +from __future__ import annotations + +import secrets +from datetime import datetime + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.cps_link import CpsClick, CpsLink + +# 去掉易混字符 0/O/1/l/I,运营/用户肉眼复制不易错 +_CODE_ALPHABET = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" + + +def _gen_code(n: int = 8) -> str: + return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(n)) + + +def get_by_code(db: Session, code: str) -> CpsLink | None: + return db.execute(select(CpsLink).where(CpsLink.code == code)).scalar_one_or_none() + + +def create_link( + db: Session, *, group_id: int, activity_id: int, sid: str, target_url: str, + platform: str = "meituan", commit: bool = True, +) -> CpsLink: + """生成唯一短码并存库。冲突重试 5 次,仍冲突用 12 位兜底(概率近 0)。""" + code = _gen_code() + for _ in range(5): + if get_by_code(db, code) is None: + break + code = _gen_code() + else: + code = _gen_code(12) + link = CpsLink( + code=code, group_id=group_id, activity_id=activity_id, sid=sid, + target_url=target_url, platform=platform, + ) + db.add(link) + if commit: + db.commit() + db.refresh(link) + else: + db.flush() + return link + + +def record_click(db: Session, *, link: CpsLink, ip: str | None = None, ua: str | None = None) -> None: + db.add(CpsClick( + link_id=link.id, group_id=link.group_id, sid=link.sid, + ip=ip, ua=(ua[:500] if ua else None), + )) + db.commit() + + +def click_stats_by_group( + db: Session, *, date_from: datetime | None = None, date_to: datetime | None = None, +) -> dict[int, dict]: + """按 group_id 聚合点击 → {group_id: {"pv": N, "uv": M}}。uv 按 (ip, ua) 近似去重。""" + stmt = select(CpsClick) + if date_from is not None: + stmt = stmt.where(CpsClick.clicked_at >= date_from) + if date_to is not None: + stmt = stmt.where(CpsClick.clicked_at <= date_to) + clicks = list(db.execute(stmt).scalars().all()) + + agg: dict[int, dict] = {} + seen: dict[int, set] = {} + for c in clicks: + a = agg.setdefault(c.group_id, {"pv": 0, "uv": 0}) + a["pv"] += 1 + s = seen.setdefault(c.group_id, set()) + key = (c.ip, c.ua) + if key not in s: + s.add(key) + a["uv"] += 1 + return agg From 04edb50accce6989c64b64f3a327ce6d44f29da1 Mon Sep 17 00:00:00 2001 From: ouzhou Date: Wed, 17 Jun 2026 10:37:54 +0800 Subject: [PATCH 16/36] =?UTF-8?q?feat(admin/ad):=20=E6=8F=90=E7=8E=B0?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=AF=B9=E8=B4=A6=E5=90=8E=E5=8F=B0=E5=BC=80?= =?UTF-8?q?=E5=85=B3=20+=20=E5=AE=A2=E6=88=B7=E7=AB=AF=E5=B9=BF=E5=91=8A?= =?UTF-8?q?=20flags=20=E7=AB=AF=E7=82=B9=20+=20=E7=A9=BF=E5=B1=B1=E7=94=B2?= =?UTF-8?q?=E5=A4=9A=20m-key=20=E9=AA=8C=E7=AD=BE=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 自动对账接入 app_config 运行时配置(新增 bool 配置类型):env 部署总闸 + DB 运营日常开关 双层;worker 每轮读 DB 即时生效,健康检查改报「env AND DB」实际生效态 - 新增 GET /api/v1/platform/flags(不鉴权)下发 comparing_ad_enabled 远程 kill-switch, 客户端拉取后缓存;空库回退默认 True - 穿山甲发奖回调支持多激励位 m-key(三命名项 PANGLE_REWARD_SECRET_TEST/_DEDICATED/_PROD + 旧逗号分隔合并去重),verify_callback_sign_any 逐个验签任一通过即受理;向后兼容旧单 key - 补 test_platform / bool config 用例;app_config 文档同步 Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/59 Co-authored-by: ouzhou Co-committed-by: ouzhou --- .env.example | 12 ++++++++-- app/admin/routers/config.py | 3 +++ app/admin/routers/withdraw.py | 16 +++++++++---- app/admin/schemas/config.py | 2 +- app/api/v1/ad.py | 2 +- app/api/v1/platform.py | 18 +++++++++++++- app/core/config.py | 26 +++++++++++++++++--- app/core/config_schema.py | 21 ++++++++++++++++- app/core/withdraw_reconcile_worker.py | 21 +++++++++++++---- app/integrations/pangle.py | 10 ++++++++ app/schemas/platform.py | 6 +++++ docs/api/ad-pangle-callback.md | 2 +- docs/database/app_config.md | 4 ++-- docs/integrations/pangle.md | 12 +++++++--- docs/看广告赚金币上线清单.md | 17 ++++++++++++-- tests/test_admin_config.py | 34 +++++++++++++++++++++++++++ tests/test_platform.py | 13 ++++++++++ 17 files changed, 194 insertions(+), 25 deletions(-) create mode 100644 tests/test_platform.py diff --git a/.env.example b/.env.example index 7178490..1f53db8 100644 --- a/.env.example +++ b/.env.example @@ -88,9 +88,17 @@ AUTO_EXCHANGE_ENABLED=true # ===== 穿山甲激励视频(服务端发奖回调)===== # 看完激励视频后穿山甲服务器 S2S 回调本服务发金币(客户端不参与发奖)。 -# PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",用于验签,从后台取到后填这里; -# 配齐并把 ENABLED=true 后,/api/v1/ad/pangle-callback 才受理回调(否则 503)。 +# 穿山甲"奖励校验密钥"(m-key),验签用,从 GroMore 后台各广告位取到后填这里。 +# 配齐(任一非空)并把 ENABLED=true 后,/api/v1/ad/pangle-callback 才受理回调(否则 503)。 +# 每个激励位 m-key 不同但共用同一回调 URL → 各位分开一行配(留空的忽略);验签逐个试、任一通过即接受。 PANGLE_CALLBACK_ENABLED=false +# 测试应用 激励位 104099649 +PANGLE_REWARD_SECRET_TEST= +# 测试应用 专属激励位 104127529 +PANGLE_REWARD_SECRET_TEST_DEDICATED= +# 正式应用 激励位 104099389 +PANGLE_REWARD_SECRET_PROD= +# (旧用法,仍兼容:单个或逗号分隔的多个 m-key,会与上面三个合并去重) PANGLE_REWARD_SECRET= # ⚠️ 仅本地联调:true 时开放 POST /api/v1/ad/test-grant,让 debug 客户端看完广告直接发奖, # 验证"看广告→金币到账"全链路(未部署公网、穿山甲 S2S 打不到本地时用)。生产必须 false(绕过反作弊)。 diff --git a/app/admin/routers/config.py b/app/admin/routers/config.py index e581d24..8a65c61 100644 --- a/app/admin/routers/config.py +++ b/app/admin/routers/config.py @@ -43,6 +43,9 @@ def _validate(key: str, value: Any) -> None: for k, v in value.items() ): raise ValueError("需为 {字符串: 整数} 映射") + elif t == "bool": + if not isinstance(value, bool): + raise ValueError("需为布尔值") def _item(db, key: str) -> ConfigItemOut: diff --git a/app/admin/routers/withdraw.py b/app/admin/routers/withdraw.py index 732159f..c6f7ecc 100644 --- a/app/admin/routers/withdraw.py +++ b/app/admin/routers/withdraw.py @@ -34,6 +34,7 @@ from app.admin.schemas.wallet import ( from app.core.config import settings from app.integrations import wxpay from app.models.admin import AdminUser +from app.repositories import app_config from app.repositories import wallet as wallet_repo router = APIRouter( @@ -95,7 +96,7 @@ def withdraws_summary(db: AdminDb) -> WithdrawSummaryOut: summary="提现配置健康检查", dependencies=[Depends(require_role("finance"))], # 暴露密钥路径/配置,限财务+super ) -def withdraw_health_check() -> WxpayHealthCheckOut: +def withdraw_health_check(db: AdminDb) -> WxpayHealthCheckOut: private_path = wxpay._resolve_config_path(settings.WXPAY_MCH_PRIVATE_KEY_PATH) # noqa: SLF001 public_path = wxpay._resolve_config_path(settings.WXPAY_PUBLIC_KEY_PATH) # noqa: SLF001 issues: list[str] = [] @@ -117,8 +118,15 @@ def withdraw_health_check() -> WxpayHealthCheckOut: issues.append("微信支付基础配置不完整") if not settings.WXPAY_AUTH_NOTIFY_URL: issues.append("免确认授权回调地址未配置") - if not settings.WITHDRAW_AUTO_RECONCILE_ENABLED: - issues.append("自动对账未开启") + + # 实际是否自动对账 = env 部署总闸(worker 起没起)AND 运营后台 DB 开关(本轮跑不跑)。 + worker_running = settings.WITHDRAW_AUTO_RECONCILE_ENABLED + daily_on = bool(app_config.get_value(db, "withdraw_auto_reconcile_enabled")) + auto_reconcile_enabled = worker_running and daily_on + if not worker_running: + issues.append("自动对账 worker 未启动(部署侧 env WITHDRAW_AUTO_RECONCILE_ENABLED=false)") + elif not daily_on: + issues.append("自动对账运营开关已关闭(系统配置页可开)") return WxpayHealthCheckOut( ok=not issues, @@ -131,7 +139,7 @@ def withdraw_health_check() -> WxpayHealthCheckOut: public_key_exists=public_path.exists(), public_key_loadable=public_loadable, auth_notify_url_configured=bool(settings.WXPAY_AUTH_NOTIFY_URL), - auto_reconcile_enabled=settings.WITHDRAW_AUTO_RECONCILE_ENABLED, + auto_reconcile_enabled=auto_reconcile_enabled, auto_reconcile_interval_sec=settings.WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC, auto_reconcile_older_than_minutes=settings.WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES, issues=issues, diff --git a/app/admin/schemas/config.py b/app/admin/schemas/config.py index 01b56db..10b694b 100644 --- a/app/admin/schemas/config.py +++ b/app/admin/schemas/config.py @@ -10,7 +10,7 @@ class ConfigItemOut(BaseModel): key: str label: str group: str - type: str # int / int_list / dict_str_int + type: str # int / int_list / dict_str_int / bool help: str | None = None default: Any value: Any diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index 9508061..bb05891 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -87,7 +87,7 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut: params = dict(request.query_params) - if not pangle.verify_callback_sign(params, settings.PANGLE_REWARD_SECRET): + if not pangle.verify_callback_sign_any(params, settings.pangle_reward_secrets): logger.warning("pangle callback bad sign trans_id=%s", params.get("trans_id")) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad sign") diff --git a/app/api/v1/platform.py b/app/api/v1/platform.py index 5c3c9d1..063a40b 100644 --- a/app/api/v1/platform.py +++ b/app/api/v1/platform.py @@ -2,6 +2,7 @@ 路由前缀 `/api/v1/platform`: GET /stats 首页三统计(帮助用户 / 完成比价 / 累计节省),按运营后台配的模式算。 + GET /flags 客户端运营 feature flag(比价/领券期广告开关等),客户端拉取后缓存。 展示模式(real/manual/random,每指标独立)与计算逻辑见 app/repositories/ops_stat.py。 """ @@ -12,9 +13,15 @@ import logging from fastapi import APIRouter, Query from app.api.deps import DbSession +from app.repositories import app_config from app.repositories import ops_marquee as marquee_crud from app.repositories import ops_stat as crud -from app.schemas.platform import PlatformStatsOut, SavingsFeedItem, SavingsFeedOut +from app.schemas.platform import ( + AppFlagsOut, + PlatformStatsOut, + SavingsFeedItem, + SavingsFeedOut, +) logger = logging.getLogger("shagua.platform") @@ -35,3 +42,12 @@ def stats(db: DbSession) -> PlatformStatsOut: def savings_feed(db: DbSession, limit: int = Query(8, ge=1, le=30)) -> SavingsFeedOut: items = marquee_crud.get_feed(db, limit=limit) return SavingsFeedOut(items=[SavingsFeedItem(**it) for it in items]) + + +@router.get("/flags", response_model=AppFlagsOut, summary="客户端运营 feature flag(不鉴权)") +def flags(db: DbSession) -> AppFlagsOut: + """客户端拉取运营开关并缓存(app 启动 / 每场比价开始时刷新)。不鉴权:开关非敏感, + 且比价无障碍服务取值时未必有登录态。值来自 app_config(admin 可改),空库回退默认。""" + return AppFlagsOut( + comparing_ad_enabled=bool(app_config.get_value(db, "comparing_ad_enabled")), + ) diff --git a/app/core/config.py b/app/core/config.py index 5c3a787..bfc4139 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -136,8 +136,15 @@ class Settings(BaseSettings): # ===== 穿山甲激励视频(服务端发奖回调)===== # 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。 - # PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",验签用,从后台取到后填 .env。 + # 穿山甲后台配置的"奖励校验密钥"(m-key),验签用。每个 GroMore 广告位 m-key 不同(后台各自 + # 生成),但共用同一回调 URL → 多个位的 m-key 都要配上。验签时所有非空 m-key 逐个试、任一通过 + # 即接受(见 pangle.verify_callback_sign_any / 下面的 pangle_reward_secrets)。 PANGLE_CALLBACK_ENABLED: bool = False + # 推荐:每个激励位的 m-key 分开一行配,清晰不混淆(留空的忽略)。 + PANGLE_REWARD_SECRET_TEST: str = "" # 测试应用 激励位 104099649 + PANGLE_REWARD_SECRET_TEST_DEDICATED: str = "" # 测试应用 专属激励位 104127529 + PANGLE_REWARD_SECRET_PROD: str = "" # 正式应用 激励位 104099389 + # 旧用法:单个或逗号分隔的多个 m-key,仍兼容(会与上面三个命名项合并去重)。 PANGLE_REWARD_SECRET: str = "" # ⚠️ 仅本地联调:打开后开放 POST /api/v1/ad/test-grant,让(已登录的)客户端在没部署公网、 @@ -145,10 +152,23 @@ class Settings(BaseSettings): # 它让客户端能自助发奖 = 绕过反作弊,**生产必须保持 False**(默认 False;只在本地 .env 设 true)。 AD_REWARD_TEST_GRANT_ENABLED: bool = False + @property + def pangle_reward_secrets(self) -> list[str]: + """汇总所有 m-key 成列表(去空白、去空项、去重保序)。验签时逐个试、任一通过即接受 + (见 pangle.verify_callback_sign_any)。来源可混用:三个命名项 + 旧的逗号分隔 PANGLE_REWARD_SECRET。""" + raw = [ + *self.PANGLE_REWARD_SECRET.split(","), + self.PANGLE_REWARD_SECRET_TEST, + self.PANGLE_REWARD_SECRET_TEST_DEDICATED, + self.PANGLE_REWARD_SECRET_PROD, + ] + cleaned = [s.strip() for s in raw if s and s.strip()] + return list(dict.fromkeys(cleaned)) # 去重保序 + @property def pangle_callback_configured(self) -> bool: - """回调开关打开且验签密钥已配,才接受发奖回调。""" - return bool(self.PANGLE_CALLBACK_ENABLED and self.PANGLE_REWARD_SECRET) + """回调开关打开且至少配了一个验签密钥,才接受发奖回调。""" + return bool(self.PANGLE_CALLBACK_ENABLED and self.pangle_reward_secrets) # ===== Pricebot 上游 (领券/比价业务透传目标) ===== # pricebot-backend 默认跑在 8000。/api/v1/coupon/step 会透传到这里的 /api/coupon/step diff --git a/app/core/config_schema.py b/app/core/config_schema.py index 65d2a9a..5a56c3a 100644 --- a/app/core/config_schema.py +++ b/app/core/config_schema.py @@ -11,7 +11,7 @@ from typing import Any from app.core import rewards as r -# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int +# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool CONFIG_DEFS: dict[str, dict[str, Any]] = { "signin_rewards": { "default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位", @@ -65,4 +65,23 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { "group": "签到", "type": "int", "help": "Day1-Day6 签到后看完激励视频额外发放的固定金币;Day7 不展示也不允许膨胀。", }, + "comparing_ad_enabled": { + "default": True, "label": "比价/领券期信息流广告", + "group": "看广告", "type": "bool", + "help": ( + "开启后,比价进行中 + 领券等候期会在悬浮窗展示穿山甲信息流广告(变现行为);" + "关闭则全程不出广告。客户端按 app 启动 / 每场比价开始时拉取并缓存,故为「最终一致」的" + "远程开关(下一场比价生效),用于出问题时无需发版即可快速止血。debug 包可用本地开关覆盖。" + ), + }, + "withdraw_auto_reconcile_enabled": { + "default": True, "label": "提现自动对账", + "group": "钱包", "type": "bool", + "help": ( + "开启后后台 worker 每隔一段时间自动扫描超时仍「打款中」的提现单并归一化" + "(查微信/退款/撤单)。需部署侧 env WITHDRAW_AUTO_RECONCILE_ENABLED=true 启动 worker " + "进程后此开关才起效;扫描间隔/超时阈值仍由 env 控制。关掉只停自动扫描," + "提现页「批量对账」手动按钮不受影响。" + ), + }, } diff --git a/app/core/withdraw_reconcile_worker.py b/app/core/withdraw_reconcile_worker.py index eac6dd6..6d667f7 100644 --- a/app/core/withdraw_reconcile_worker.py +++ b/app/core/withdraw_reconcile_worker.py @@ -14,8 +14,11 @@ from sqlalchemy.exc import SQLAlchemyError from app.core.config import settings from app.db.session import SessionLocal from app.integrations.wxpay import WxPayNotConfiguredError +from app.repositories import app_config from app.repositories import wallet as wallet_repo +_AUTO_RECONCILE_KEY = "withdraw_auto_reconcile_enabled" + logger = logging.getLogger("shagua.withdraw_reconcile") _LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "withdraw_reconcile.lock" @@ -59,8 +62,15 @@ def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]: _LOCK_PATH.unlink() -def _reconcile_once(older_than_minutes: int) -> dict: +def _reconcile_once(older_than_minutes: int) -> dict | None: + """读运营开关(app_config):关着返回 None(本轮跳过),开着才真扫单。 + + env WITHDRAW_AUTO_RECONCILE_ENABLED 是部署级总闸(决定 worker 起不起); + 这里的 DB 开关是运营级日常开关,后台一改下一轮即生效、跨进程一致、无需重启。 + """ with SessionLocal() as db: + if not app_config.get_value(db, _AUTO_RECONCILE_KEY): + return None return wallet_repo.reconcile_pending_withdraws(db, older_than_minutes=older_than_minutes) @@ -78,16 +88,18 @@ async def _run_loop() -> None: async def _run_locked_loop(interval: int, older_than: int) -> None: logger.info( - "withdraw auto reconcile started interval=%ss older_than=%sm", + "withdraw auto reconcile worker started interval=%ss older_than=%sm " + "(runtime on/off via app_config '%s')", interval, older_than, + _AUTO_RECONCILE_KEY, ) try: while True: try: _touch_lock() result = await asyncio.to_thread(_reconcile_once, older_than) - if result["checked"] or result["resolved"]: + if result is not None and (result["checked"] or result["resolved"]): logger.info("withdraw auto reconcile result=%s", result) except WxPayNotConfiguredError: logger.warning("withdraw auto reconcile skipped: wxpay not configured") @@ -103,7 +115,8 @@ async def _run_locked_loop(interval: int, older_than: int) -> None: def start_withdraw_reconcile_worker() -> asyncio.Task | None: if not settings.WITHDRAW_AUTO_RECONCILE_ENABLED: - logger.info("withdraw auto reconcile disabled") + # 部署级总闸关:worker 进程不启动(运营后台开关此时无效,需先在 env 打开)。 + logger.info("withdraw auto reconcile worker not started (env master switch off)") return None if not settings.wxpay_configured: logger.warning("withdraw auto reconcile enabled but wxpay not configured") diff --git a/app/integrations/pangle.py b/app/integrations/pangle.py index aacae85..1badabb 100644 --- a/app/integrations/pangle.py +++ b/app/integrations/pangle.py @@ -42,3 +42,13 @@ def verify_callback_sign(params: dict[str, str], secret: str) -> bool: got = params.get("sign") or "" expect = build_sign(trans_id, secret) return hmac.compare_digest(got, expect) + + +def verify_callback_sign_any(params: dict[str, str], secrets: list[str]) -> bool: + """对**多个** m-key 逐个验签,任一通过即接受。 + + 多广告位场景:每个 GroMore 广告位的 m-key 由后台各自生成、互不相同,但本服务用 + 同一个回调 URL 接所有位的回调。配置里放多个密钥(逗号分隔),回调到达时挨个试。 + 仍然安全:伪造者必须知道其中某个 m-key 才能算出合法 sign;空列表 → 一律失败。 + """ + return any(verify_callback_sign(params, s) for s in secrets if s) diff --git a/app/schemas/platform.py b/app/schemas/platform.py index ec8619c..74f11ad 100644 --- a/app/schemas/platform.py +++ b/app/schemas/platform.py @@ -22,3 +22,9 @@ class SavingsFeedItem(BaseModel): class SavingsFeedOut(BaseModel): items: list[SavingsFeedItem] + + +class AppFlagsOut(BaseModel): + """客户端拉取的运营 feature flag(不鉴权,登录前也能拉)。客户端缓存后按需读。""" + + comparing_ad_enabled: bool # 比价/领券期是否展示信息流广告(远程 kill-switch) diff --git a/docs/api/ad-pangle-callback.md b/docs/api/ad-pangle-callback.md index d32e90f..3d8e13c 100644 --- a/docs/api/ad-pangle-callback.md +++ b/docs/api/ad-pangle-callback.md @@ -22,7 +22,7 @@ GroMore 以 GET 回调,关键参数: | `ecpm` | string | 本次广告 eCPM(同上,可用于收益分析) | | `sign` | string | 签名,见下 | -**验签**:`sign = SHA256("{m-key}:{trans_id}")` 十六进制(只签 `trans_id`,其余参数不参与)。算法细节、m-key 来源、为什么这样设计 → 见集成文档 [integrations/pangle](../integrations/pangle.md)。 +**验签**:`sign = SHA256("{m-key}:{trans_id}")` 十六进制(只签 `trans_id`,其余参数不参与)。多激励位共用同一回调 URL → 服务端把各位的 m-key 都配上,`verify_callback_sign_any` 逐个试、任一过即接受。算法细节、m-key 配置项(`PANGLE_REWARD_SECRET_TEST/_TEST_DEDICATED/_PROD`)、为什么这样设计 → 见集成文档 [integrations/pangle](../integrations/pangle.md)。 ## 出参 响应 `200`,**响应体必须是 `{"is_verify": bool, "reason": int}`**(GroMore 规范)。 diff --git a/docs/database/app_config.md b/docs/database/app_config.md index 947831c..e5b787d 100644 --- a/docs/database/app_config.md +++ b/docs/database/app_config.md @@ -14,8 +14,8 @@ ## 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| -| `key` | String(64) | **PK** | 配置标识,取值见 `config_schema.CONFIG_DEFS`:`signin_rewards` / `min_exchange_coin` / `withdraw_min_cents` / `withdraw_max_cents` / `task_rewards` / `record_milestones` / `ad_reward_coin` / `ad_daily_limit` / `ad_max_coin` / `ad_round_count` / `ad_cooldown_sec` | -| `value` | JSON(PG: JSONB) | NOT NULL | 配置值,类型随 key(`int` / `int_list` 如签到 14 档 / `dict_str_int` 如 task_rewards) | +| `key` | String(64) | **PK** | 配置标识,取值见 `config_schema.CONFIG_DEFS`:`signin_rewards` / `min_exchange_coin` / `withdraw_min_cents` / `withdraw_max_cents` / `task_rewards` / `record_milestones` / `ad_reward_coin` / `ad_daily_limit` / `ad_max_coin` / `ad_round_count` / `ad_cooldown_sec` / `signin_boost_coin` / `withdraw_auto_reconcile_enabled` | +| `value` | JSON(PG: JSONB) | NOT NULL | 配置值,类型随 key(`int` / `int_list` 如签到 14 档 / `dict_str_int` 如 task_rewards / `bool` 如 withdraw_auto_reconcile_enabled) | | `updated_by_admin_id` | Integer | nullable | 最后修改的管理员 id(= `admin_user.id`,软引用,无 FK) | | `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最后修改时间 | diff --git a/docs/integrations/pangle.md b/docs/integrations/pangle.md index 72837fd..7c6d825 100644 --- a/docs/integrations/pangle.md +++ b/docs/integrations/pangle.md @@ -16,12 +16,18 @@ | 函数 | 说明 | |---|---| | `build_sign(trans_id, secret) -> str` | 计算 `SHA256("{secret}:{trans_id}")` hex。自验签测试 / 模拟回调脚本共用,保证两端一致 | -| `verify_callback_sign(params, secret) -> bool` | 校验回调签名。密钥空 / 缺 `trans_id` 一律失败;比较用 `hmac.compare_digest` 定长比较防时序侧信道 | +| `verify_callback_sign(params, secret) -> bool` | 用**单个** m-key 校验回调签名。密钥空 / 缺 `trans_id` 一律失败;比较用 `hmac.compare_digest` 定长比较防时序侧信道 | +| `verify_callback_sign_any(params, secrets) -> bool` | 用**一组** m-key 逐个验签、任一通过即接受(多激励位共用同一回调 URL 时用)。空列表 → 失败 | + +## 配置(多激励位 = 多 m-key) +每个 GroMore 激励位的 m-key 由后台各自生成、互不相同,但本服务用**同一个回调 URL** 接所有位的回调,因此把用到的位的 m-key 都配上;验签时 `verify_callback_sign_any` 逐个试、任一过即接受(仍安全:伪造者须知道其中某个 m-key 才能造出合法 sign)。m-key 在后台「GroMore 聚合管理 → 搜广告位 ID → 编辑」处获取。`settings.pangle_reward_secrets` 把下列来源汇总去重: -## 配置 | 配置项 | 说明 | |---|---| -| `PANGLE_REWARD_SECRET` | m-key(安全密钥)。后台「GroMore 聚合管理 → 搜广告位 ID → 编辑」处获取 | +| `PANGLE_REWARD_SECRET_TEST` | 测试应用 激励位 104099649 的 m-key | +| `PANGLE_REWARD_SECRET_TEST_DEDICATED` | 测试应用 专属激励位 104127529 的 m-key | +| `PANGLE_REWARD_SECRET_PROD` | 正式应用 激励位 104099389 的 m-key | +| `PANGLE_REWARD_SECRET` | 旧用法:单个或逗号分隔多个 m-key,仍兼容(与上面三个命名项合并去重) | ## 踩坑 - **别用联盟代码位那套**:联盟代码位层级用的是另一套 Security Key + `isValid` 响应体,与 GroMore 广告位层级不通用。我们走 GroMore,别接错。 diff --git a/docs/看广告赚金币上线清单.md b/docs/看广告赚金币上线清单.md index 3f0ccd7..42d9448 100644 --- a/docs/看广告赚金币上线清单.md +++ b/docs/看广告赚金币上线清单.md @@ -41,18 +41,31 @@ > 已确认走 **GroMore 广告位层级**回调(客户端 useMediation(true);规范 supportcenter/26240), > **不是**联盟代码位层级(5416)。验签算法 / 响应格式 / reward_amount 解析**代码已按 GroMore 规范实现** > (2026-05-27),剩下的是后台配置 + 填密钥。 +> +> 🔴 **2026-06-16 实测的头号阻塞:穿山甲打线上后端会超时**。真机看广告时 SDK 日志 +> `onRewardArrived valid=false errorCode=50002`,errorMsg = +> `Get "https://app-api.shaguabijia.com/api/v1/ad/pangle-callback...": context deadline exceeded`。 +> 而我们自己 curl 线上是秒回。说明**穿山甲机房到线上后端这条网络路径有问题**(疑似 nginx/云防火墙对 +> 机房/境外 IP 限流或拦截)。**m-key 配对了也救不了——穿山甲根本没把请求送达。** 上线前必须单独验 +> 「线上 `app-api.shaguabijia.com` 对穿山甲机房可达且响应够快」,否则用户看完发不了奖。 +> +> ✅ **多激励位已支持**(2026-06-16):内部测试期 测试位/专属位/正式位 并存,m-key 各配一行 +> (`PANGLE_REWARD_SECRET_TEST/_TEST_DEDICATED/_PROD`),`verify_callback_sign_any` 逐个试。本地已验通。 - [ ] **GroMore 后台配回调**:GroMore 聚合管理 → 搜广告位ID → 编辑 → 勾选「服务端激励回调」→ 回调 URL 填 `https://app-api.shaguabijia.com/api/v1/ad/pangle-callback`(用域名,别用 IP)。 ⚠️ 广告位层级配了就**别再在代码位层级重复配**(会导致发奖出问题)。 -- [ ] **拿 m-key(安全密钥)**:就在上面"编辑广告位"页获取,填到生产 `.env` 的 `PANGLE_REWARD_SECRET`。 +- [ ] **拿 m-key(安全密钥)**:就在上面"编辑广告位"页获取(每个激励位各一把),填到生产 `.env` 的命名变量 + (正式位填 `PANGLE_REWARD_SECRET_PROD`;内部测试期 测试位/专属位 也并存就填 `_TEST`/`_TEST_DEDICATED`)。 (注:这是 GroMore 广告位的 m-key,**不是**联盟代码位那个 Security Key。) - [x] ~~换验签~~:`app/integrations/pangle.py` 已实现 `sign = SHA256("{m-key}:{trans_id}")`(GroMore 真实算法)。 - [x] ~~响应格式~~:已返回 GroMore 要求的 `{"is_verify": bool, "reason": int}`。 - [x] ~~reward_amount~~:回调按 `reward_amount` 发金币(`rewards.resolve_ad_reward_coin`,带回退/夹紧); **后台广告位"奖励数量"须配成与 `AD_REWARD_COIN`(=100)一致**,保证"广告内展示/进度预告/到账"三者一致。 - [x] ~~透传 user_id~~:客户端已 `setUserID(userId)` + `setMediaExtra("uid:...")`。 -- [ ] 生产 `.env`:`PANGLE_CALLBACK_ENABLED=true` + `PANGLE_REWARD_SECRET=`(配齐才不返 503) +- [ ] 生产 `.env`:`PANGLE_CALLBACK_ENABLED=true` + 至少一个 m-key 命名变量(配齐才不返 503)+ 确认 + `AD_REWARD_TEST_GRANT_ENABLED=false`(绕过反作弊,生产红线)。改 `.env` 后**重启 uvicorn** + (watchfiles 不监听 .env)。验证:外部 curl 回调 URL 从 `503` 变 `403 bad sign` 即生效。 ## C. 部署 + 包名 diff --git a/tests/test_admin_config.py b/tests/test_admin_config.py index 4f51fbe..13c0e29 100644 --- a/tests/test_admin_config.py +++ b/tests/test_admin_config.py @@ -117,6 +117,40 @@ def test_update_ad_limit_takes_effect(admin_client: TestClient, token: str) -> N db.close() +def test_update_bool_config(admin_client: TestClient, token: str) -> None: + # 提现自动对账开关默认 True + items = { + i["key"]: i + for i in admin_client.get("/admin/api/config", headers=_auth(token)).json() + } + assert items["withdraw_auto_reconcile_enabled"]["type"] == "bool" + assert items["withdraw_auto_reconcile_enabled"]["value"] is True + + # 关掉 → DB 落 False、业务读到 False + r = admin_client.patch( + "/admin/api/config/withdraw_auto_reconcile_enabled", + json={"value": False}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + assert r.json()["value"] is False and r.json()["overridden"] is True + + db = SessionLocal() + try: + from app.repositories import app_config + + assert app_config.get_value(db, "withdraw_auto_reconcile_enabled") is False + finally: + db.close() + + # bool 项不接受非布尔值 + assert admin_client.patch( + "/admin/api/config/withdraw_auto_reconcile_enabled", + json={"value": 1}, + headers=_auth(token), + ).status_code == 400 + + def test_config_validation(admin_client: TestClient, token: str) -> None: # 签到档位长度≠7 assert admin_client.patch( diff --git a/tests/test_platform.py b/tests/test_platform.py new file mode 100644 index 0000000..883a75f --- /dev/null +++ b/tests/test_platform.py @@ -0,0 +1,13 @@ +"""平台公开端点测试(不鉴权):/api/v1/platform/flags 等。""" +from __future__ import annotations + +from fastapi.testclient import TestClient + + +def test_flags_default_comparing_ad_enabled(client: TestClient) -> None: + """空配置库下,/flags 返回 comparing_ad_enabled 的默认值 True;不需要鉴权。""" + r = client.get("/api/v1/platform/flags") + assert r.status_code == 200, r.text + body = r.json() + assert "comparing_ad_enabled" in body + assert body["comparing_ad_enabled"] is True From 003fd9d9869bcd57b01e5309d350a672adf148cf Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 17 Jun 2026 15:56:15 +0800 Subject: [PATCH 17/36] =?UTF-8?q?feat(compare):=20=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=AF=94=E4=BB=B7=E8=AE=B0=E5=BD=95=E6=94=AF=E6=8C=81=20trace?= =?UTF-8?q?=20=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compare.py 加 /trace/finalize 透传(按 trace_id 一致性 hash 落同一 pricebot 进程);compare_record.py list_records 加 include_trace,对本人记录在调试态(客户端 agent 调试模式)放行 trace_url。 Co-Authored-By: Claude Opus 4.8 --- app/api/v1/compare.py | 8 ++++++++ app/api/v1/compare_record.py | 11 +++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/api/v1/compare.py b/app/api/v1/compare.py index 93279b3..c05e7ca 100644 --- a/app/api/v1/compare.py +++ b/app/api/v1/compare.py @@ -116,3 +116,11 @@ async def intent_precoupon_step(request: Request) -> dict[str, Any]: @router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传到 pricebot)") async def price_step(request: Request) -> dict[str, Any]: return await _passthrough(request, "/api/price/step") + + +@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传到 pricebot, 终止/未识别拿 trace_url)") +async def trace_finalize(request: Request) -> dict[str, Any]: + # 用户终止 / Phase1 未识别没走到 done 帧, pricebot 没上云也没回传 trace_url。客户端收尾时 + # 打这个, _passthrough 按 trace_id 一致性 hash 落到处理这条 trace 的同一 pricebot 进程 + # (dir_cache 在那, 才能算对 trace 目录), 由后者打包上云返回 {trace_url}。 + return await _passthrough(request, "/api/trace/finalize") diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index 8e527dc..d53dd15 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -73,13 +73,20 @@ def list_records( db: DbSession, limit: int = Query(20, ge=1, le=100), cursor: int | None = Query(None, description="上一页末条 id"), + include_trace: bool = Query( + False, + description="客户端开了本机 agent 调试模式时带 true,放行本人记录的 trace_url", + ), ) -> ComparisonRecordPage: items, next_cursor = crud_compare.list_records( db, user.id, limit=limit, cursor=cursor ) outs = [ComparisonRecordOut.model_validate(it) for it in items] - # 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它) - if not user.debug_trace_enabled: + # 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它)。 + # include_trace=true 例外:客户端开了本机 agent 调试模式时带上,放行**本人记录**的 trace_url + # ——list_records 只查 user.id 自己的记录,给本人看自己的调试链接无越权,与实时结果页 + # CompResultScreen「debug 权限 OR 本机 agent 调试」同口径(领导 2026-06-12 拍板)。 + if not (user.debug_trace_enabled or include_trace): for o in outs: o.trace_url = None return ComparisonRecordPage(items=outs, next_cursor=next_cursor) From 3a40f617bdc5d49eba020ef05fb0b73e27feef75 Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 17 Jun 2026 19:21:20 +0800 Subject: [PATCH 18/36] =?UTF-8?q?feat(cps):=20=E5=90=8E=E7=AB=AF=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E6=B7=98=E5=AE=9D/=E4=BA=AC=E4=B8=9C=E5=A4=9A?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0(payload=20+=20=E7=BE=A4=E5=A4=9A=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=20+=20=E5=A4=8D=E5=88=B6=E7=BB=9F=E8=AE=A1=20+=20?= =?UTF-8?q?=E8=BF=81=E7=A7=BB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - model: cps_group.platforms 多选 / cps_activity.payload(淘口令/京东链接) / cps_link.sid 可空 + 复制统计 - admin API: 群与活动平台多选、批量生成落地页短链 referral-links、对账字段可空 - 落地页 cps_redirect: 淘宝展示淘口令(记 copy 事件) / 京东 302 / 美团原逻辑 - 迁移 cps_v2_platforms: 加 platforms/payload/event_type 列, sid 放宽可空(含 downgrade) Co-Authored-By: Claude Opus 4.8 --- alembic/versions/cps_v2_platforms.py | 61 ++++++++++ app/admin/repositories/cps.py | 103 ++++++++++------- app/admin/routers/cps.py | 165 ++++++++++++++++----------- app/admin/schemas/cps.py | 81 +++++++------ app/api/v1/cps_redirect.py | 121 ++++++++++++++++++-- app/models/cps_activity.py | 4 +- app/models/cps_group.py | 11 +- app/models/cps_link.py | 13 ++- app/repositories/cps_link.py | 35 ++++-- 9 files changed, 422 insertions(+), 172 deletions(-) create mode 100644 alembic/versions/cps_v2_platforms.py diff --git a/alembic/versions/cps_v2_platforms.py b/alembic/versions/cps_v2_platforms.py new file mode 100644 index 0000000..e80c1a6 --- /dev/null +++ b/alembic/versions/cps_v2_platforms.py @@ -0,0 +1,61 @@ +"""cps v2: 活动 payload(淘口令/京东链接) + 群多平台/sid 可空 + 点击 event_type + +Revision ID: cps_v2_platforms +Revises: cps_link_tables +Create Date: 2026-06-17 11:00:00.000000 + +接入淘宝/京东: +- cps_activity.payload 淘宝整段淘口令文本 / 京东推广链接(美团仍用 act_id) +- cps_group.platforms 该群发哪些平台(多选);sid 改可空(纯淘宝/京东无 sid) +- cps_link.sid 可空 + target_url 加长(淘口令较长) +- cps_click.event_type visit / copy(淘宝落地页"复制口令") +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision: str = "cps_v2_platforms" +down_revision: Union[str, Sequence[str], None] = "cps_link_tables" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 活动:淘宝淘口令 / 京东链接 + op.add_column("cps_activity", sa.Column("payload", sa.Text(), nullable=True)) + + # 群:多平台(现有群都是美团,server_default 回填)+ sid 可空 + op.add_column( + "cps_group", + sa.Column( + "platforms", + sa.JSON().with_variant(postgresql.JSONB(), "postgresql"), + nullable=False, + server_default=sa.text("'[\"meituan\"]'::jsonb"), + ), + ) + op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=True) + + # link:sid 可空 + target 加长 + op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=True) + op.alter_column("cps_link", "target_url", existing_type=sa.String(1024), type_=sa.String(2048)) + + # click:sid 可空 + 事件类型 + op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=True) + op.add_column( + "cps_click", + sa.Column("event_type", sa.String(16), nullable=False, server_default="visit"), + ) + + +def downgrade() -> None: + op.drop_column("cps_click", "event_type") + op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=False) + op.alter_column("cps_link", "target_url", existing_type=sa.String(2048), type_=sa.String(1024)) + op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=False) + op.drop_column("cps_group", "platforms") + op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=False) + op.drop_column("cps_activity", "payload") diff --git a/app/admin/repositories/cps.py b/app/admin/repositories/cps.py index 17516e2..6733de9 100644 --- a/app/admin/repositories/cps.py +++ b/app/admin/repositories/cps.py @@ -71,19 +71,23 @@ def list_groups( def create_group( - db: Session, *, name: str, sid: str | None = None, + db: Session, *, name: str, platforms: list[str], sid: str | None = None, member_count: int | None = None, remark: str | None = None, commit: bool = True, ) -> CpsGroup: - """建群。sid 留空 → 自动 g(先 flush 拿 id 再回填)。""" + """建群。含 meituan 才有 sid(留空自动 g);纯淘宝/京东 sid=None(那俩不支持 sid)。""" + has_meituan = "meituan" in (platforms or []) + # 含美团:用填的 sid,或临时占位待回填 g;不含美团:sid 恒 None + seed_sid = (sid or f"tmp{uuid4().hex[:20]}") if has_meituan else None group = CpsGroup( name=name, - sid=sid or f"tmp{uuid4().hex[:20]}", # 临时唯一占位,留空时下面回填 g + platforms=list(platforms or []), + sid=seed_sid, member_count=member_count, remark=remark, ) db.add(group) db.flush() - if not sid: + if has_meituan and not sid: group.sid = f"g{group.id}" db.flush() if commit: @@ -94,11 +98,14 @@ def create_group( def update_group( db: Session, group: CpsGroup, *, name: str | None = None, + platforms: list[str] | None = None, member_count: int | None = None, status: str | None = None, remark: str | None = None, commit: bool = True, ) -> CpsGroup: if name is not None: group.name = name + if platforms is not None: + group.platforms = list(platforms) if member_count is not None: group.member_count = member_count if status is not None: @@ -132,11 +139,12 @@ def list_activities( def create_activity( db: Session, *, name: str, platform: str = "meituan", act_id: str | None = None, - product_view_sign: str | None = None, remark: str | None = None, commit: bool = True, + product_view_sign: str | None = None, payload: str | None = None, + remark: str | None = None, commit: bool = True, ) -> CpsActivity: activity = CpsActivity( name=name, platform=platform, act_id=act_id, - product_view_sign=product_view_sign, remark=remark, + product_view_sign=product_view_sign, payload=payload, remark=remark, ) db.add(activity) if commit: @@ -233,55 +241,68 @@ def group_stats( """按 sid 聚合订单 + join 群信息。已建但本期无单的活跃群也列出(全 0)。 未归群的 sid(历史/其它来源)单独成行 group_id=None。按预估佣金降序。 """ - stmt = select(CpsOrder) + ostmt = select(CpsOrder) if date_from is not None: - stmt = stmt.where(CpsOrder.pay_time >= _as_utc(date_from)) + ostmt = ostmt.where(CpsOrder.pay_time >= _as_utc(date_from)) if date_to is not None: - stmt = stmt.where(CpsOrder.pay_time <= _as_utc(date_to)) - orders = list(db.execute(stmt).scalars().all()) + ostmt = ostmt.where(CpsOrder.pay_time <= _as_utc(date_to)) + orders_by_sid: dict[str | None, list[CpsOrder]] = {} + for o in db.execute(ostmt).scalars().all(): + orders_by_sid.setdefault(o.sid, []).append(o) - groups = {g.sid: g for g in db.execute(select(CpsGroup)).scalars().all()} + clicks = cps_link_repo.click_stats_by_group(db, date_from=date_from, date_to=date_to) + groups = list(db.execute(select(CpsGroup)).scalars().all()) - buckets: dict[str | None, list[CpsOrder]] = {} - for o in orders: - buckets.setdefault(o.sid, []).append(o) - - rows: list[dict] = [] - for sid, items in buckets.items(): - g = groups.get(sid) if sid else None + def _order_agg(items: list[CpsOrder]) -> dict: valid = [o for o in items if o.mt_status not in _INVALID_STATUS] settled = [o for o in items if o.mt_status == _SETTLED_STATUS] canceled = [o for o in items if o.mt_status in _INVALID_STATUS] - rows.append({ - "group_id": g.id if g else None, - "sid": sid, - "name": g.name if g else (sid or "(无 sid 归属)"), - "member_count": g.member_count if g else None, + return { "order_count": len(valid), "settled_count": len(settled), "canceled_count": len(canceled), "gmv_cents": sum(o.pay_price_cents or 0 for o in valid), "est_commission_cents": sum(o.commission_cents or 0 for o in valid), "settled_commission_cents": sum(o.commission_cents or 0 for o in settled), - }) + } - # 已建的活跃群但本期无单 → 补 0 行,让运营看到全部群 - seen = set(buckets.keys()) - for sid, g in groups.items(): - if sid not in seen and g.status == "active": - rows.append({ - "group_id": g.id, "sid": sid, "name": g.name, - "member_count": g.member_count, "order_count": 0, - "settled_count": 0, "canceled_count": 0, "gmv_cents": 0, - "est_commission_cents": 0, "settled_commission_cents": 0, - }) + # 淘宝/京东无法对账 → 对账字段全 None(前端显示 "-") + _no_recon = { + "order_count": None, "settled_count": None, "canceled_count": None, + "gmv_cents": None, "est_commission_cents": None, "settled_commission_cents": None, + } - # 合并点击数据(按 group_id):点击只对生成过我们短链的群有,未归群行(group_id=None)恒 0 - clicks = cps_link_repo.click_stats_by_group(db, date_from=date_from, date_to=date_to) - for r in rows: - c = clicks.get(r["group_id"]) if r["group_id"] is not None else None - r["click_pv"] = c["pv"] if c else 0 - r["click_uv"] = c["uv"] if c else 0 + rows: list[dict] = [] + seen_sids: set[str] = set() + for g in groups: + if g.status != "active": + continue + c = clicks.get(g.id) or {} + row = { + "group_id": g.id, "sid": g.sid, "name": g.name, + "platforms": list(g.platforms or []), "member_count": g.member_count, + "click_pv": c.get("pv", 0), "click_uv": c.get("uv", 0), + "copy_pv": c.get("copy_pv", 0), "copy_uv": c.get("copy_uv", 0), + } + # 对账只对美团群(有 sid);淘宝/京东 → None + if "meituan" in (g.platforms or []) and g.sid: + row.update(_order_agg(orders_by_sid.get(g.sid, []))) + seen_sids.add(g.sid) + else: + row.update(_no_recon) + rows.append(row) - rows.sort(key=lambda x: x["est_commission_cents"], reverse=True) + # 未归群的历史美团 sid(如 wonderableai):单列,有对账无点击 + for sid, items in orders_by_sid.items(): + if sid is None or sid in seen_sids: + continue + row = { + "group_id": None, "sid": sid, "name": sid, "platforms": ["meituan"], + "member_count": None, "click_pv": 0, "click_uv": 0, "copy_pv": 0, "copy_uv": 0, + } + row.update(_order_agg(items)) + rows.append(row) + + # 佣金降序(None 当 0),次按点击 + rows.sort(key=lambda x: ((x["est_commission_cents"] or 0), x["click_pv"]), reverse=True) return rows diff --git a/app/admin/routers/cps.py b/app/admin/routers/cps.py index 592f363..5eae8fe 100644 --- a/app/admin/routers/cps.py +++ b/app/admin/routers/cps.py @@ -1,6 +1,7 @@ -"""admin CPS 分发与对账:群(sid)管理 + 活动池 + 生成带 sid 券链接 + 美团订单对账 + 统计。 +"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 美团订单对账 + 统计。 -当前仅接美团(get_referral_link / query_order)。淘宝/京东待各自联盟凭证。 +平台:meituan(actId+sid 转链 + query_order 对账) / taobao(整段淘口令) / jd(链接)。 +淘宝/京东无 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"。 群/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可。 """ from __future__ import annotations @@ -15,8 +16,6 @@ from app.admin.audit import write_audit from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role from app.admin.repositories import cps as cps_repo from app.admin.schemas.common import CursorPage -from app.core.config import settings -from app.repositories import cps_link as cps_link_repo from app.admin.schemas.cps import ( CpsActivityCreate, CpsActivityOut, @@ -26,13 +25,18 @@ from app.admin.schemas.cps import ( CpsGroupUpdate, CpsOrderOut, CpsReconcileResult, - CpsReferralLinkOut, - CpsReferralLinkRequest, + CpsReferralLinkItem, + CpsReferralLinksOut, + CpsReferralLinksRequest, CpsStatsOut, ) +from app.core.config import settings from app.integrations import meituan from app.integrations.meituan import MeituanCpsError from app.models.admin import AdminUser +from app.models.cps_activity import CpsActivity +from app.models.cps_group import CpsGroup +from app.repositories import cps_link as cps_link_repo router = APIRouter( prefix="/admin/api/cps", @@ -40,9 +44,11 @@ router = APIRouter( dependencies=[Depends(get_current_admin)], ) +_VALID_PLATFORMS = {"meituan", "taobao", "jd"} -# ───────────── 群(sid) ───────────── -@router.get("/groups", response_model=CursorPage[CpsGroupOut], summary="群(sid)列表") + +# ───────────── 群 ───────────── +@router.get("/groups", response_model=CursorPage[CpsGroupOut], summary="群列表") def list_groups( db: AdminDb, keyword: Annotated[str | None, Query(max_length=100)] = None, @@ -60,22 +66,27 @@ def list_groups( ) -@router.post("/groups", response_model=CpsGroupOut, summary="新建群(分配 sid)") +@router.post("/groups", response_model=CpsGroupOut, summary="新建群") def create_group( body: CpsGroupCreate, request: Request, admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> CpsGroupOut: - if body.sid and cps_repo.get_group_by_sid(db, body.sid) is not None: + bad = [p for p in body.platforms if p not in _VALID_PLATFORMS] + if bad: + raise HTTPException(status_code=400, detail=f"非法平台: {bad}") + has_meituan = "meituan" in body.platforms + if has_meituan and body.sid and cps_repo.get_group_by_sid(db, body.sid) is not None: raise HTTPException(status_code=409, detail="sid 已存在") group = cps_repo.create_group( - db, name=body.name, sid=body.sid, member_count=body.member_count, - remark=body.remark, commit=False, + db, name=body.name, platforms=body.platforms, sid=body.sid, + member_count=body.member_count, remark=body.remark, commit=False, ) write_audit( db, admin, action="cps.group.create", target_type="cps_group", target_id=group.id, - detail={"sid": group.sid, "name": group.name}, ip=get_client_ip(request), commit=False, + detail={"name": group.name, "platforms": group.platforms, "sid": group.sid}, + ip=get_client_ip(request), commit=False, ) db.commit() db.refresh(group) @@ -93,9 +104,13 @@ def update_group( group = cps_repo.get_group(db, group_id) if group is None: raise HTTPException(status_code=404, detail="群不存在") + if body.platforms is not None: + bad = [p for p in body.platforms if p not in _VALID_PLATFORMS] + if bad: + raise HTTPException(status_code=400, detail=f"非法平台: {bad}") cps_repo.update_group( - db, group, name=body.name, member_count=body.member_count, - status=body.status, remark=body.remark, commit=False, + db, group, name=body.name, platforms=body.platforms, + member_count=body.member_count, status=body.status, remark=body.remark, commit=False, ) write_audit( db, admin, action="cps.group.update", target_type="cps_group", target_id=group_id, @@ -132,15 +147,21 @@ def create_activity( admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> CpsActivityOut: - if not body.act_id and not body.product_view_sign: - raise HTTPException(status_code=400, detail="act_id 或 product_view_sign 必填其一") + if body.platform == "meituan": + if not body.act_id and not body.product_view_sign: + raise HTTPException(status_code=400, detail="美团活动需填 actId 或 productViewSign") + else: # taobao / jd + if not body.payload: + label = "淘口令" if body.platform == "taobao" else "推广链接" + raise HTTPException(status_code=400, detail=f"{body.platform} 活动需填{label}") activity = cps_repo.create_activity( db, name=body.name, platform=body.platform, act_id=body.act_id, - product_view_sign=body.product_view_sign, remark=body.remark, commit=False, + product_view_sign=body.product_view_sign, payload=body.payload, + remark=body.remark, commit=False, ) write_audit( db, admin, action="cps.activity.create", target_type="cps_activity", target_id=activity.id, - detail={"name": activity.name, "act_id": activity.act_id, "platform": activity.platform}, + detail={"name": activity.name, "platform": activity.platform}, ip=get_client_ip(request), commit=False, ) db.commit() @@ -148,65 +169,71 @@ def create_activity( return CpsActivityOut.model_validate(activity) -# ───────────── 转链 ───────────── -@router.post("/referral-link", response_model=CpsReferralLinkOut, summary="生成群发短链(带 sid + 点击统计)") -def generate_referral_link( - body: CpsReferralLinkRequest, +# ───────────── 生成链接(批量,按平台分支) ───────────── +def _gen_one_link(db, group: CpsGroup, activity: CpsActivity): + """给一个活动生成一条落地页 link。美团:转链拿短链(需群 sid);淘宝/京东:用 payload。""" + if activity.platform == "meituan": + if not group.sid: + raise HTTPException(status_code=400, detail=f"群「{group.name}」无 sid,无法生成美团链接") + try: + resp = meituan.get_referral_link( + act_id=activity.act_id, product_view_sign=activity.product_view_sign, + sid=group.sid, link_type_list=[1, 2, 3], + ) + except MeituanCpsError as e: + raise HTTPException(status_code=502, detail=f"美团转链失败: {e}") from e + link_map = {str(k): v for k, v in (resp.get("referralLinkMap") or {}).items()} + if not link_map and resp.get("data"): + link_map = {"1": resp["data"]} + target = link_map.get("2") or link_map.get("1") or link_map.get("3") + if not target: + raise HTTPException(status_code=502, detail=f"美团未返回有效链接(活动「{activity.name}」)") + else: # taobao(淘口令) / jd(链接) + if not activity.payload: + raise HTTPException(status_code=400, detail=f"活动「{activity.name}」缺少内容") + target = activity.payload + return cps_link_repo.create_link( + db, group_id=group.id, activity_id=activity.id, sid=group.sid, + target_url=target, platform=activity.platform, commit=False, + ) + + +@router.post("/referral-links", response_model=CpsReferralLinksOut, summary="批量生成落地页短链") +def generate_referral_links( + body: CpsReferralLinksRequest, request: Request, admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, -) -> CpsReferralLinkOut: +) -> CpsReferralLinksOut: group = cps_repo.get_group(db, body.group_id) if group is None: raise HTTPException(status_code=404, detail="群不存在") - activity = cps_repo.get_activity(db, body.activity_id) - if activity is None: - raise HTTPException(status_code=404, detail="活动不存在") - if activity.platform != "meituan": - raise HTTPException(status_code=400, detail=f"当前仅支持美团转链(活动平台={activity.platform})") - try: - # 全要:短链作跳转目标(微信可打开),长链/deeplink 作参考/备用 - resp = meituan.get_referral_link( - act_id=activity.act_id, - product_view_sign=activity.product_view_sign, - sid=group.sid, - link_type_list=[1, 2, 3], - ) - except MeituanCpsError as e: - raise HTTPException(status_code=502, detail=f"美团转链失败: {e}") from e + group_platforms = set(group.platforms or []) + base = settings.CPS_REDIRECT_BASE.rstrip("/") - link_map = {str(k): v for k, v in (resp.get("referralLinkMap") or {}).items()} - if not link_map and resp.get("data"): - link_map = {"1": resp["data"]} - # 跳转目标优先短链(2,微信可打开)→ 长链(1)→ deeplink(3) - target_url = link_map.get("2") or link_map.get("1") or link_map.get("3") - if not target_url: - raise HTTPException(status_code=502, detail="美团未返回有效链接(检查 actId 是否在推广有效期)") + items: list[CpsReferralLinkItem] = [] + for aid in body.activity_ids: + activity = cps_repo.get_activity(db, aid) + if activity is None: + raise HTTPException(status_code=404, detail=f"活动 {aid} 不存在") + if activity.platform not in group_platforms: + raise HTTPException( + status_code=400, + detail=f"活动「{activity.name}」平台({activity.platform})不在群「{group.name}」范围内", + ) + link = _gen_one_link(db, group, activity) + items.append(CpsReferralLinkItem( + activity_id=activity.id, activity_name=activity.name, platform=activity.platform, + redirect_url=f"{base}/c/{link.code}" if base else f"/c/{link.code}", code=link.code, + )) - # 存我们的短链:发群用 /c/{code},用户点 → 我们记点击 → 302 跳 target_url - link = cps_link_repo.create_link( - db, group_id=group.id, activity_id=activity.id, sid=group.sid, - target_url=target_url, platform=activity.platform, commit=False, - ) write_audit( - db, admin, action="cps.referral_link.generate", target_type="cps_link", target_id=link.id, - detail={"sid": group.sid, "activity_id": activity.id, "code": link.code}, + db, admin, action="cps.referral_link.generate", target_type="cps_group", target_id=group.id, + detail={"group": group.name, "activity_ids": body.activity_ids, "count": len(items)}, ip=get_client_ip(request), commit=False, ) db.commit() - db.refresh(link) - - base = settings.CPS_REDIRECT_BASE.rstrip("/") - redirect_url = f"{base}/c/{link.code}" if base else f"/c/{link.code}" - return CpsReferralLinkOut( - redirect_url=redirect_url, - code=link.code, - sid=group.sid, - group_name=group.name, - activity_name=activity.name, - target_url=target_url, - link_map=link_map, - ) + return CpsReferralLinksOut(group_name=group.name, results=items) # ───────────── 订单对账 ───────────── @@ -262,7 +289,7 @@ def get_stats( stats = [CpsGroupStat(**r) for r in rows] return CpsStatsOut( groups=stats, - total_order_count=sum(s.order_count for s in stats), - total_est_commission_cents=sum(s.est_commission_cents for s in stats), - total_settled_commission_cents=sum(s.settled_commission_cents for s in stats), + total_order_count=sum(s.order_count or 0 for s in stats), + total_est_commission_cents=sum(s.est_commission_cents or 0 for s in stats), + total_settled_commission_cents=sum(s.settled_commission_cents or 0 for s in stats), ) diff --git a/app/admin/schemas/cps.py b/app/admin/schemas/cps.py index 9028fe1..5ca7dbb 100644 --- a/app/admin/schemas/cps.py +++ b/app/admin/schemas/cps.py @@ -1,4 +1,8 @@ -"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。""" +"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。 + +平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接,只统计点击)。 +对账类字段对淘宝/京东为 None → 前端显示 "-"(无法对账)。 +""" from __future__ import annotations from datetime import datetime @@ -6,13 +10,14 @@ from datetime import datetime from pydantic import BaseModel, ConfigDict, Field -# ───────────── 群(sid) ───────────── +# ───────────── 群 ───────────── class CpsGroupOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int - sid: str + sid: str | None = None # 仅美团群有 name: str + platforms: list[str] = Field(default_factory=list) member_count: int | None = None status: str remark: str | None = None @@ -21,7 +26,9 @@ class CpsGroupOut(BaseModel): class CpsGroupCreate(BaseModel): name: str = Field(min_length=1, max_length=128) - # 留空 → 后端自动生成 g;填写则必须字母+数字(美团 sid 限制)。 + # 该群发哪些平台,至少选一个 + platforms: list[str] = Field(min_length=1) + # 仅当含 meituan 时需要(留空自动生成);纯淘宝/京东不填,后端置 None。 sid: str | None = Field(default=None, max_length=64, pattern=r"^[A-Za-z0-9]+$") member_count: int | None = Field(default=None, ge=0) remark: str | None = Field(default=None, max_length=256) @@ -29,12 +36,13 @@ class CpsGroupCreate(BaseModel): class CpsGroupUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=128) + platforms: list[str] | None = None member_count: int | None = Field(default=None, ge=0) status: str | None = Field(default=None, pattern=r"^(active|archived)$") remark: str | None = Field(default=None, max_length=256) -# ───────────── 活动(actId) ───────────── +# ───────────── 活动 ───────────── class CpsActivityOut(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -43,6 +51,7 @@ class CpsActivityOut(BaseModel): name: str act_id: str | None = None product_view_sign: str | None = None + payload: str | None = None # 淘宝淘口令 / 京东链接 status: str remark: str | None = None created_at: datetime @@ -50,36 +59,40 @@ class CpsActivityOut(BaseModel): class CpsActivityCreate(BaseModel): name: str = Field(min_length=1, max_length=128) - platform: str = Field(default="meituan", pattern=r"^(meituan|taobao|jd)$") + platform: str = Field(pattern=r"^(meituan|taobao|jd)$") + # 美团:act_id 或 product_view_sign 二选一 act_id: str | None = Field(default=None, max_length=64) product_view_sign: str | None = Field(default=None, max_length=128) + # 淘宝:整段淘口令文本 / 京东:推广链接 + payload: str | None = Field(default=None, max_length=4096) remark: str | None = Field(default=None, max_length=256) -# ───────────── 转链 ───────────── -class CpsReferralLinkRequest(BaseModel): +# ───────────── 生成链接(批量) ───────────── +class CpsReferralLinksRequest(BaseModel): group_id: int + activity_ids: list[int] = Field(min_length=1) + + +class CpsReferralLinkItem(BaseModel): activity_id: int - # 链接类型:1长链 2短链 3deeplink。默认短链(微信可打开)+deeplink。 - link_types: list[int] = Field(default_factory=lambda: [2, 3]) - - -class CpsReferralLinkOut(BaseModel): - redirect_url: str # ★ 我们的群发短链(/c/{code}),发群就用这个,点击经我们统计 - code: str - sid: str - group_name: str activity_name: str - target_url: str # 实际 302 跳转的美团短链 - link_map: dict[str, str] # 美团原始 1长链/2短链/3deeplink(参考/备用) + platform: str + redirect_url: str # 我们的落地页 /c/{code}(发群用) + code: str + + +class CpsReferralLinksOut(BaseModel): + group_name: str + results: list[CpsReferralLinkItem] # ───────────── 对账拉单 ───────────── class CpsReconcileResult(BaseModel): - fetched: int # 本次美团返回的订单条数 - inserted: int # 新入库 - updated: int # 已存在→更新状态/佣金 - pages: int # 翻了几页 + fetched: int + inserted: int + updated: int + pages: int # ───────────── 订单明细 ───────────── @@ -101,18 +114,22 @@ class CpsOrderOut(BaseModel): # ───────────── 统计 ───────────── class CpsGroupStat(BaseModel): - group_id: int | None = None # None = 未归群的 sid(历史/其它来源) + group_id: int | None = None sid: str | None = None name: str + platforms: list[str] = Field(default_factory=list) member_count: int | None = None - click_pv: int = 0 # 点击总次数 - click_uv: int = 0 # 独立点击(按 ip+ua 近似去重) - order_count: int # 有效订单数(不含取消/风控) - settled_count: int # 已结算单数 - canceled_count: int # 取消+风控单数 - gmv_cents: int # 成交额(有效单 payPrice 合计) - est_commission_cents: int # 预估佣金(有效单 profit 合计) - settled_commission_cents: int # 已结算佣金 + click_pv: int = 0 + click_uv: int = 0 + copy_pv: int = 0 # 淘宝"复制口令"次数 + copy_uv: int = 0 + # 对账类:淘宝/京东无法对账 → None(前端显示 "-");美团群为数值 + order_count: int | None = None + settled_count: int | None = None + canceled_count: int | None = None + gmv_cents: int | None = None + est_commission_cents: int | None = None + settled_commission_cents: int | None = None class CpsStatsOut(BaseModel): diff --git a/app/api/v1/cps_redirect.py b/app/api/v1/cps_redirect.py index 3b4120c..f69fab3 100644 --- a/app/api/v1/cps_redirect.py +++ b/app/api/v1/cps_redirect.py @@ -1,19 +1,24 @@ -"""CPS 群发短链跳转:用户点 /c/{code} → 记一条点击 → 302 跳美团。 +"""CPS 群发短链落地:用户点 /c/{code} → 记点击 → 按平台 302 跳 或 返回淘宝落地页。 -公网、无鉴权(群里任何人点都要能跳)。记点击失败绝不影响跳转(用户体验优先)。 +公网无鉴权(群里任何人点都要能跳/能领)。记点击失败绝不影响用户。 +- 美团/京东:记 visit + 302 跳 target(美团短链 / 京东链接) +- 淘宝:记 visit + 返回 H5 落地页(整段淘口令复制按钮);点"复制口令"→ POST /c/{code}/copy 记 copy """ from __future__ import annotations +import json + from fastapi import APIRouter, Depends, Request -from fastapi.responses import RedirectResponse +from fastapi.responses import HTMLResponse, RedirectResponse from sqlalchemy.orm import Session from app.db.session import get_db +from app.models.cps_link import CpsLink from app.repositories import cps_link as cps_link_repo router = APIRouter(tags=["cps-redirect"]) -# code 不存在/失效时的兜底落地(避免用户看到报错) +# code 不存在/失效时的兜底落地 _FALLBACK_URL = "https://www.meituan.com/" @@ -24,16 +29,110 @@ def _client_ip(request: Request) -> str | None: return request.client.host if request.client else None -@router.get("/c/{code}", summary="群发短链跳转(记点击 + 302)") -def cps_redirect(code: str, request: Request, db: Session = Depends(get_db)): - link = cps_link_repo.get_by_code(db, code) - if link is None: - return RedirectResponse(_FALLBACK_URL, status_code=302) +def _record(db: Session, link: CpsLink, request: Request, event_type: str) -> None: try: cps_link_repo.record_click( db, link=link, ip=_client_ip(request), - ua=request.headers.get("user-agent"), + ua=request.headers.get("user-agent"), event_type=event_type, ) except Exception: - pass # 记点击失败不阻断跳转 + pass # 记点击失败不阻断 + + +@router.get("/c/{code}", summary="群发短链落地(记点击 + 跳转/淘宝落地页)") +def cps_landing(code: str, request: Request, db: Session = Depends(get_db)): + link = cps_link_repo.get_by_code(db, code) + if link is None: + return RedirectResponse(_FALLBACK_URL, status_code=302) + _record(db, link, request, "visit") + if link.platform == "taobao": + return HTMLResponse(_taobao_landing_html(link.target_url)) + # 美团短链 / 京东链接:直接 302 跳 return RedirectResponse(link.target_url, status_code=302) + + +@router.post("/c/{code}/copy", summary="淘宝落地页点了「复制口令」(记 copy 事件)") +def cps_copy(code: str, request: Request, db: Session = Depends(get_db)) -> dict: + link = cps_link_repo.get_by_code(db, code) + if link is not None: + _record(db, link, request, "copy") + return {"ok": True} + + +def _taobao_landing_html(token: str) -> str: + """淘宝落地页 H5(红色喜庆,两步引导,底部复制淘口令按钮)。token 安全嵌入 JS。""" + return _TAOBAO_HTML.replace("__TOKEN_JS__", json.dumps(token)) + + +_TAOBAO_HTML = """ + + + + +淘宝闪购 · 天天领红包 + + + + +
+
最高 15-15 红包 天天可领!
+
1
第一步:点击下方按钮 复制淘口令
+
2
第二步:打开 淘宝 App,自动弹出领取红包
+
+
外卖红包 · 今日已到账
+
明天继续来领哦
+
¥15满15可用
外卖红包
仅限于美食外卖
去使用
+
¥7满9可用
外卖红包
仅限于美食外卖
去使用
+
+
+
+
+ + +""" diff --git a/app/models/cps_activity.py b/app/models/cps_activity.py index d52823a..383f854 100644 --- a/app/models/cps_activity.py +++ b/app/models/cps_activity.py @@ -8,7 +8,7 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import DateTime, Integer, String, func +from sqlalchemy import DateTime, Integer, String, Text, func from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -24,6 +24,8 @@ class CpsActivity(Base): act_id: Mapped[str | None] = mapped_column(String(64), nullable=True) # 美团商品券 productViewSign(与 act_id 二选一转链;多数活动用 act_id)。 product_view_sign: Mapped[str | None] = mapped_column(String(128), nullable=True) + # 淘宝:整段淘口令文本(落地页原样复制);京东:推广链接(落地页 302 跳)。美团不用这个(用 act_id)。 + payload: Mapped[str | None] = mapped_column(Text, nullable=True) status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") # active | archived remark: Mapped[str | None] = mapped_column(String(256), nullable=True) created_at: Mapped[datetime] = mapped_column( diff --git a/app/models/cps_group.py b/app/models/cps_group.py index fcd40fd..8a9e516 100644 --- a/app/models/cps_group.py +++ b/app/models/cps_group.py @@ -7,7 +7,8 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import DateTime, Integer, String, func +from sqlalchemy import JSON, DateTime, Integer, String, func +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -17,9 +18,13 @@ class CpsGroup(Base): __tablename__ = "cps_group" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - # 每群唯一的渠道标识,建群时分配(留空则自动生成 g)。字母+数字,≤64。 - sid: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + # 渠道标识。仅美团需要(sid 追踪);纯淘宝/京东群无 sid(那俩不支持)。含美团时建群分配。 + sid: Mapped[str | None] = mapped_column(String(64), unique=True, index=True, nullable=True) name: Mapped[str] = mapped_column(String(128), nullable=False) + # 该群发哪些平台的券(多选): ["meituan","taobao","jd"]。含 meituan 才需要 sid。 + platforms: Mapped[list] = mapped_column( + JSON().with_variant(JSONB(), "postgresql"), nullable=False, default=list + ) # 群人数,运营填,作转化率分母(可空)。 member_count: Mapped[int | None] = mapped_column(Integer, nullable=True) status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") # active | archived diff --git a/app/models/cps_link.py b/app/models/cps_link.py index ef53859..2702c8a 100644 --- a/app/models/cps_link.py +++ b/app/models/cps_link.py @@ -23,10 +23,11 @@ class CpsLink(Base): code: Mapped[str] = mapped_column(String(16), unique=True, index=True, nullable=False) group_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) activity_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) - sid: Mapped[str] = mapped_column(String(64), index=True, nullable=False) + # 仅美团 link 有 sid(渠道追踪);淘宝/京东无 sid。 + sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) platform: Mapped[str] = mapped_column(String(20), nullable=False, default="meituan") - # 302 跳转目标(美团短链,微信内可打开并唤起 App)。 - target_url: Mapped[str] = mapped_column(String(1024), nullable=False) + # 跳转/落地目标: 美团短链 / 京东链接(302 跳) / 淘宝整段淘口令文本(H5 落地页复制)。 + target_url: Mapped[str] = mapped_column(String(2048), nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) @@ -41,7 +42,11 @@ class CpsClick(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) link_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) group_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) # 冗余,按群聚合快 - sid: Mapped[str] = mapped_column(String(64), index=True, nullable=False) # 冗余 + sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) # 冗余(淘宝/京东无) + # 事件类型: visit(进落地页/被跳转) | copy(淘宝落地页点了"复制口令") + event_type: Mapped[str] = mapped_column( + String(16), nullable=False, default="visit", server_default="visit" + ) ip: Mapped[str | None] = mapped_column(String(64), nullable=True) ua: Mapped[str | None] = mapped_column(String(512), nullable=True) clicked_at: Mapped[datetime] = mapped_column( diff --git a/app/repositories/cps_link.py b/app/repositories/cps_link.py index 9991ff8..2e34970 100644 --- a/app/repositories/cps_link.py +++ b/app/repositories/cps_link.py @@ -26,7 +26,7 @@ def get_by_code(db: Session, code: str) -> CpsLink | None: def create_link( - db: Session, *, group_id: int, activity_id: int, sid: str, target_url: str, + db: Session, *, group_id: int, activity_id: int, sid: str | None, target_url: str, platform: str = "meituan", commit: bool = True, ) -> CpsLink: """生成唯一短码并存库。冲突重试 5 次,仍冲突用 12 位兜底(概率近 0)。""" @@ -50,9 +50,13 @@ def create_link( return link -def record_click(db: Session, *, link: CpsLink, ip: str | None = None, ua: str | None = None) -> None: +def record_click( + db: Session, *, link: CpsLink, ip: str | None = None, ua: str | None = None, + event_type: str = "visit", +) -> None: + """记一条点击。event_type: visit(进落地页/被跳转) | copy(淘宝点了"复制口令")。""" db.add(CpsClick( - link_id=link.id, group_id=link.group_id, sid=link.sid, + link_id=link.id, group_id=link.group_id, sid=link.sid, event_type=event_type, ip=ip, ua=(ua[:500] if ua else None), )) db.commit() @@ -61,7 +65,8 @@ def record_click(db: Session, *, link: CpsLink, ip: str | None = None, ua: str | def click_stats_by_group( db: Session, *, date_from: datetime | None = None, date_to: datetime | None = None, ) -> dict[int, dict]: - """按 group_id 聚合点击 → {group_id: {"pv": N, "uv": M}}。uv 按 (ip, ua) 近似去重。""" + """按 group_id 聚合点击 → {group_id: {pv, uv, copy_pv, copy_uv}}。 + visit 计入 pv/uv,copy(淘宝复制口令) 计入 copy_pv/copy_uv;uv 按 (ip, ua) 近似去重。""" stmt = select(CpsClick) if date_from is not None: stmt = stmt.where(CpsClick.clicked_at >= date_from) @@ -70,13 +75,21 @@ def click_stats_by_group( clicks = list(db.execute(stmt).scalars().all()) agg: dict[int, dict] = {} - seen: dict[int, set] = {} + seen_visit: dict[int, set] = {} + seen_copy: dict[int, set] = {} for c in clicks: - a = agg.setdefault(c.group_id, {"pv": 0, "uv": 0}) - a["pv"] += 1 - s = seen.setdefault(c.group_id, set()) + a = agg.setdefault(c.group_id, {"pv": 0, "uv": 0, "copy_pv": 0, "copy_uv": 0}) key = (c.ip, c.ua) - if key not in s: - s.add(key) - a["uv"] += 1 + if c.event_type == "copy": + a["copy_pv"] += 1 + s = seen_copy.setdefault(c.group_id, set()) + if key not in s: + s.add(key) + a["copy_uv"] += 1 + else: + a["pv"] += 1 + s = seen_visit.setdefault(c.group_id, set()) + if key not in s: + s.add(key) + a["uv"] += 1 return agg From 7368a1ca8acca8626615b550191a315bdaea9114 Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 17 Jun 2026 20:53:34 +0800 Subject: [PATCH 19/36] =?UTF-8?q?feat(cps):=20=E6=B7=98=E5=AE=9D=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=E9=A1=B5=E6=94=B9=E7=94=A8=E8=AE=BE=E8=AE=A1=E5=9B=BE?= =?UTF-8?q?=E4=B8=BB=E8=A7=86=E8=A7=89=20+=20=E5=A4=8D=E5=88=B6=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E4=B8=8A=E6=8F=90=E8=87=B3=2075%=20=E5=B1=8F=E9=AB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - banner/card 手画区域整体换成设计图 /media/taobao_landing.jpg(~240KB jpg) - 复制淘口令按钮从底部固定上提到 top:75vh, 上方留白 - 图入库(data/media 白名单, 同 dl.html 先例), 同域 /media serve - 淘口令注入 / 复制 / toast 逻辑不变 Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + app/api/v1/cps_redirect.py | 43 ++++------------------------------ data/media/taobao_landing.jpg | Bin 0 -> 245022 bytes 3 files changed, 6 insertions(+), 38 deletions(-) create mode 100644 data/media/taobao_landing.jpg diff --git a/.gitignore b/.gitignore index bd48023..d473d6b 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ data/* !data/media/ data/media/* !data/media/dl.html +!data/media/taobao_landing.jpg secrets/* !secrets/.gitkeep diff --git a/app/api/v1/cps_redirect.py b/app/api/v1/cps_redirect.py index f69fab3..0c604dd 100644 --- a/app/api/v1/cps_redirect.py +++ b/app/api/v1/cps_redirect.py @@ -60,7 +60,7 @@ def cps_copy(code: str, request: Request, db: Session = Depends(get_db)) -> dict def _taobao_landing_html(token: str) -> str: - """淘宝落地页 H5(红色喜庆,两步引导,底部复制淘口令按钮)。token 安全嵌入 JS。""" + """淘宝落地页 H5(主视觉为设计图 /media/taobao_landing.jpg,复制淘口令按钮在 75% 屏高)。token 安全嵌入 JS。""" return _TAOBAO_HTML.replace("__TOKEN_JS__", json.dumps(token)) @@ -72,27 +72,9 @@ _TAOBAO_HTML = """ 淘宝闪购 · 天天领红包 - -
-
最高 15-15 红包 天天可领!
-
1
第一步:点击下方按钮 复制淘口令
-
2
第二步:打开 淘宝 App,自动弹出领取红包
-
-
外卖红包 · 今日已到账
-
明天继续来领哦
-
¥15满15可用
外卖红包
仅限于美食外卖
去使用
-
¥7满9可用
外卖红包
仅限于美食外卖
去使用
-
-
+淘宝闪购 天天领红包
""" diff --git a/app/core/config.py b/app/core/config.py index bfc4139..8862476 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -95,6 +95,17 @@ class Settings(BaseSettings): """美团 CPS 凭证齐全(缺则接口返空,而非 502)。""" return bool(self.MT_CPS_APP_KEY and self.MT_CPS_APP_SECRET) + # ===== 微信服务号(网页授权) ===== + # CPS 落地页在微信内拿用户 openid(base 静默)/昵称头像(userinfo),做用户级群统计。 + # ⚠️ 区别于 WECHAT_APP_ID(那是 App 移动应用,用于微信支付);这是【已认证服务号】。 + WX_MP_APPID: str = "" + WX_MP_SECRET: str = "" + + @property + def wx_mp_configured(self) -> bool: + """服务号网页授权凭证齐全(缺则落地页不发起授权,降级为无 openid)。""" + return bool(self.WX_MP_APPID and self.WX_MP_SECRET) + # ===== 微信支付(商家转账到零钱 / 提现)===== # 真实凭证放 .env(已 gitignore),证书 .pem 放 secrets/。WECHAT_APP_ID 同时用于 # 微信登录(code 换 openid)与转账,必须与 App 端开放平台 appid 一致。 diff --git a/app/integrations/wx_oauth.py b/app/integrations/wx_oauth.py new file mode 100644 index 0000000..0af50d6 --- /dev/null +++ b/app/integrations/wx_oauth.py @@ -0,0 +1,75 @@ +"""微信服务号网页授权:构造授权链接 + code 换 openid + 拉 userinfo。 + +用于 CPS 落地页(coupon.shaguabijia.com)在微信内拿用户身份。流程(见微信文档): + 授权链接(snsapi_base|snsapi_userinfo) → 回调带 code → sns/oauth2/access_token 换 openid + → (userinfo 时)sns/userinfo 拿昵称头像。 + +注意: + - 这里的 access_token 是【网页授权 token】,与基础 access_token(cgi-bin/token)不同, + 走 sns/* 接口,不需要 IP 白名单。 + - secret 只在服务器,不下发客户端。 + - 用 WX_MP_APPID/SECRET(已认证服务号),区别于 WECHAT_APP_ID(App 移动应用)。 +""" +from __future__ import annotations + +from urllib.parse import quote + +import httpx + +from app.core.config import settings + +_API = "https://api.weixin.qq.com" +_AUTHORIZE = "https://open.weixin.qq.com/connect/oauth2/authorize" +_TIMEOUT = 10 + + +class WxOauthError(Exception): + """网页授权调用失败(凭证/网络/code 失效等)。调用方兜底为无 openid。""" + + +def build_authorize_url(redirect_uri: str, scope: str, state: str) -> str: + """构造网页授权跳转链接。scope: 'snsapi_base'(静默,仅 openid) | 'snsapi_userinfo'(昵称头像)。 + + 微信要求 redirect_uri urlEncode、参数顺序固定、结尾 #wechat_redirect。 + """ + return ( + f"{_AUTHORIZE}?appid={settings.WX_MP_APPID}" + f"&redirect_uri={quote(redirect_uri, safe='')}" + f"&response_type=code&scope={scope}&state={quote(state, safe='')}" + f"#wechat_redirect" + ) + + +def exchange_code(code: str) -> dict: + """code 换网页授权 access_token + openid。返回含 openid/access_token/scope(/unionid)。 + + code 5 分钟内一次性有效。失败(errcode)抛 WxOauthError。 + """ + resp = httpx.get( + f"{_API}/sns/oauth2/access_token", + params={ + "appid": settings.WX_MP_APPID, + "secret": settings.WX_MP_SECRET, + "code": code, + "grant_type": "authorization_code", + }, + timeout=_TIMEOUT, + ) + data = resp.json() + if "openid" not in data: + raise WxOauthError(f"exchange_code failed: {data}") + return data + + +def get_userinfo(access_token: str, openid: str) -> dict: + """拉用户信息(nickname/headimgurl/unionid)。需 scope=snsapi_userinfo 的 access_token。""" + resp = httpx.get( + f"{_API}/sns/userinfo", + params={"access_token": access_token, "openid": openid, "lang": "zh_CN"}, + timeout=_TIMEOUT, + ) + resp.encoding = "utf-8" # 昵称含中文/emoji,强制 utf-8 避免乱码 + data = resp.json() + if "openid" not in data: + raise WxOauthError(f"get_userinfo failed: {data}") + return data diff --git a/app/models/__init__.py b/app/models/__init__.py index 1955e31..768d192 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -10,6 +10,7 @@ from app.models.cps_activity import CpsActivity # noqa: F401 from app.models.cps_group import CpsGroup # noqa: F401 from app.models.cps_link import CpsClick, CpsLink # noqa: F401 from app.models.cps_order import CpsOrder # noqa: F401 +from app.models.cps_wx_user import CpsWxUser # noqa: F401 from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401 from app.models.coupon_state import ( # noqa: F401 CouponClaimRecord, diff --git a/app/models/cps_link.py b/app/models/cps_link.py index 2702c8a..bb88e6b 100644 --- a/app/models/cps_link.py +++ b/app/models/cps_link.py @@ -49,6 +49,8 @@ class CpsClick(Base): ) ip: Mapped[str | None] = mapped_column(String(64), nullable=True) ua: Mapped[str | None] = mapped_column(String(512), nullable=True) + # 微信网页授权拿到的用户标识(非微信/未授权为空),做用户级群统计 + openid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) clicked_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True, nullable=False ) diff --git a/app/models/cps_wx_user.py b/app/models/cps_wx_user.py new file mode 100644 index 0000000..669b26c --- /dev/null +++ b/app/models/cps_wx_user.py @@ -0,0 +1,42 @@ +"""CPS 落地页微信用户(cps_wx_user)。 + +用户在微信内打开群发短链落地页 /c/{code},经服务号网页授权: + - base 静默:拿 openid(唯一标识,统计主力) + - userinfo(点领券触发):补 nickname/headimgurl/unionid + +按 openid 唯一,记录首次来源群(first_group_id),用于群内用户级统计(谁领了券)。 +下单归因到人需 user-level sid(另一期),本表只承载身份 + 领券/点击侧。 +""" +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 CpsWxUser(Base): + __tablename__ = "cps_wx_user" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # 服务号下用户唯一标识(网页授权拿到) + openid: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + # 开放平台 unionid(服务号绑开放平台 + scope=userinfo 才有);跨 App/服务号统一用户 + unionid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + # 昵称/头像:userinfo 授权后才有(base 阶段为空) + nickname: Mapped[str | None] = mapped_column(String(128), nullable=True) + headimgurl: Mapped[str | None] = mapped_column(String(512), nullable=True) + # 首次进入来源(从哪个群的链接授权进来) + first_code: Mapped[str | None] = mapped_column(String(16), nullable=True) + first_group_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True) + first_seen: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + last_seen: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/repositories/cps_link.py b/app/repositories/cps_link.py index 2e34970..00b9c1f 100644 --- a/app/repositories/cps_link.py +++ b/app/repositories/cps_link.py @@ -52,12 +52,13 @@ def create_link( def record_click( db: Session, *, link: CpsLink, ip: str | None = None, ua: str | None = None, - event_type: str = "visit", + event_type: str = "visit", openid: str | None = None, ) -> None: - """记一条点击。event_type: visit(进落地页/被跳转) | copy(淘宝点了"复制口令")。""" + """记一条点击。event_type: visit(进落地页/被跳转) | copy(淘宝点了"复制口令")。 + openid: 微信网页授权拿到的用户标识(非微信/未授权为空),做用户级群统计。""" db.add(CpsClick( link_id=link.id, group_id=link.group_id, sid=link.sid, event_type=event_type, - ip=ip, ua=(ua[:500] if ua else None), + ip=ip, ua=(ua[:500] if ua else None), openid=openid, )) db.commit() diff --git a/app/repositories/cps_wx_user.py b/app/repositories/cps_wx_user.py new file mode 100644 index 0000000..9fde31e --- /dev/null +++ b/app/repositories/cps_wx_user.py @@ -0,0 +1,43 @@ +"""cps_wx_user 数据访问:按 openid upsert。""" +from __future__ import annotations + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models.cps_wx_user import CpsWxUser + + +def get_by_openid(db: Session, openid: str) -> CpsWxUser | None: + return db.execute( + select(CpsWxUser).where(CpsWxUser.openid == openid) + ).scalar_one_or_none() + + +def upsert( + db: Session, *, openid: str, code: str | None = None, group_id: int | None = None, + nickname: str | None = None, headimgurl: str | None = None, unionid: str | None = None, +) -> CpsWxUser: + """按 openid upsert。 + + - 首次:建行,记来源 code/group。 + - 已存在:仅在传入非 None 时更新昵称/头像/unionid(base 阶段为 None,不覆盖已有画像); + 并刷新 last_seen(显式 set,因 onupdate 仅在字段有变更时触发)。 + """ + u = get_by_openid(db, openid) + if u is None: + u = CpsWxUser( + openid=openid, first_code=code, first_group_id=group_id, + nickname=nickname, headimgurl=headimgurl, unionid=unionid, + ) + db.add(u) + else: + if nickname is not None: + u.nickname = nickname + if headimgurl is not None: + u.headimgurl = headimgurl + if unionid is not None: + u.unionid = unionid + u.last_seen = func.now() + db.commit() + db.refresh(u) + return u From 733a51260f79bb58be1df281a17d11311017b35a Mon Sep 17 00:00:00 2001 From: marco Date: Fri, 19 Jun 2026 12:03:58 +0800 Subject: [PATCH 33/36] =?UTF-8?q?feat(cps):=20=E5=BE=AE=E4=BF=A1=E7=BD=91?= =?UTF-8?q?=E9=A1=B5=E6=8E=88=E6=9D=83=E5=8A=A0=E6=80=BB=E5=BC=80=E5=85=B3?= =?UTF-8?q?=20WX=5FMP=5FOAUTH=5FENABLED(=E9=BB=98=E8=AE=A4=E5=85=B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务号认证审核中,先关掉落地页授权(走原逻辑不跳授权/不拿 openid),整套微信代码保留。 审核通过后 .env 置 WX_MP_OAUTH_ENABLED=true + 重启即启用,无需改代码。 Co-Authored-By: Claude Opus 4.8 --- app/api/v1/cps_redirect.py | 4 ++-- app/core/config.py | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/api/v1/cps_redirect.py b/app/api/v1/cps_redirect.py index 5270586..ee7aa0b 100644 --- a/app/api/v1/cps_redirect.py +++ b/app/api/v1/cps_redirect.py @@ -79,7 +79,7 @@ def cps_landing(code: str, request: Request, db: Session = Depends(get_db)): openid = request.cookies.get(_WX_OPENID_COOKIE) # 微信内 + 还没拿到 openid + 配了服务号 → 先 base 静默授权拿 openid,再 302 回本页。 # 非微信 / 已有 cookie / 未配服务号 → 直接走原逻辑(openid 可能为 None,绝不阻断领券)。 - if openid is None and settings.wx_mp_configured and _is_wechat(request): + if openid is None and settings.wx_oauth_active and _is_wechat(request): redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb" auth_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_base", f"base:{code}") return RedirectResponse(auth_url, status_code=302) @@ -147,7 +147,7 @@ def _taobao_landing_html( """ safe_img = html.escape(media.to_abs_media_url(image_url) or image_url, quote=True) uinfo_url = "" - if openid and not has_uinfo and settings.wx_mp_configured: + if openid and not has_uinfo and settings.wx_oauth_active: redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb" uinfo_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_userinfo", f"uinfo:{code}") return ( diff --git a/app/core/config.py b/app/core/config.py index 8862476..76621e1 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -100,12 +100,20 @@ class Settings(BaseSettings): # ⚠️ 区别于 WECHAT_APP_ID(那是 App 移动应用,用于微信支付);这是【已认证服务号】。 WX_MP_APPID: str = "" WX_MP_SECRET: str = "" + # 落地页微信网页授权【总开关】。默认关:服务号认证审核中/未就绪时,落地页走原逻辑 + # (不跳授权、不拿 openid),整套微信代码保留。审核通过后 .env 置 true + 重启即启用,无需改代码。 + WX_MP_OAUTH_ENABLED: bool = False @property def wx_mp_configured(self) -> bool: """服务号网页授权凭证齐全(缺则落地页不发起授权,降级为无 openid)。""" return bool(self.WX_MP_APPID and self.WX_MP_SECRET) + @property + def wx_oauth_active(self) -> bool: + """落地页是否真正发起微信授权 = 凭证齐全 且 总开关开。""" + return self.wx_mp_configured and self.WX_MP_OAUTH_ENABLED + # ===== 微信支付(商家转账到零钱 / 提现)===== # 真实凭证放 .env(已 gitignore),证书 .pem 放 secrets/。WECHAT_APP_ID 同时用于 # 微信登录(code 换 openid)与转账,必须与 App 端开放平台 appid 一致。 From 95a07f75b75d0ce632526f669435f6621e12e7cc Mon Sep 17 00:00:00 2001 From: marco Date: Sat, 20 Jun 2026 01:35:17 +0800 Subject: [PATCH 34/36] =?UTF-8?q?feat(ad):=20=E7=A9=BF=E5=B1=B1=E7=94=B2?= =?UTF-8?q?=E5=B9=BF=E5=91=8A=E9=85=8D=E7=BD=AE=E5=90=8E=E5=8F=B0=E5=8C=96?= =?UTF-8?q?(app=5Fid/=E5=90=84=E4=BD=8DID/=E9=AA=8C=E7=AD=BE=E5=AF=86?= =?UTF-8?q?=E9=92=A5/=E5=90=84=E5=9C=BA=E6=99=AF=E5=BC=80=E5=85=B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app_config 存 ad_config(默认=客户端当前内置 ID,空库维持现状) - GET /platform/ad-config 下发客户端(不含验签密钥 reward_mkey) - admin GET/PATCH /admin/api/ad-config 读写(operator/finance + 审计,mkey 不记明文) - pangle 回调验签 m-key 优先读后台配置、.env 兜底兼容 - 比价/领券信息流位拆分(compare/coupon_feed_code_id) - 客户端需发版接入 /platform/ad-config 才生效;本次后端就绪、不影响现状 Co-Authored-By: Claude Opus 4.8 --- app/admin/main.py | 2 ++ app/admin/routers/ad_config.py | 45 +++++++++++++++++++++++++++++++ app/admin/schemas/ad_config.py | 30 +++++++++++++++++++++ app/api/v1/ad.py | 13 +++++++-- app/api/v1/platform.py | 17 ++++++++++++ app/repositories/app_config.py | 48 ++++++++++++++++++++++++++++++++++ app/schemas/platform.py | 15 +++++++++++ 7 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 app/admin/routers/ad_config.py create mode 100644 app/admin/schemas/ad_config.py diff --git a/app/admin/main.py b/app/admin/main.py index b718441..eb46a7c 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -14,6 +14,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.admin.routers.ad_audit import router as ad_audit_router +from app.admin.routers.ad_config import router as ad_config_router from app.admin.routers.ad_revenue import router as ad_revenue_router from app.admin.routers.admins import router as admins_router from app.admin.routers.audit import router as audit_router @@ -95,4 +96,5 @@ admin_app.include_router(config_router) admin_app.include_router(comparison_router) admin_app.include_router(cps_router) admin_app.include_router(ad_audit_router) +admin_app.include_router(ad_config_router) admin_app.include_router(ad_revenue_router) diff --git a/app/admin/routers/ad_config.py b/app/admin/routers/ad_config.py new file mode 100644 index 0000000..c46068c --- /dev/null +++ b/app/admin/routers/ad_config.py @@ -0,0 +1,45 @@ +"""admin 广告配置:读/写穿山甲 app_id / 各场景广告位ID / 验签密钥 / 各场景开关。 + +存在 app_config 表的 ad_config dict(见 repositories/app_config.get_ad_config/set_ad_config)。 +客户端经 /api/v1/platform/ad-config 拉取(不含 reward_mkey)。权限 operator/finance + 审计。 +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, Request + +from app.admin.audit import write_audit +from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role +from app.admin.schemas.ad_config import AdConfigOut, AdConfigUpdate +from app.models.admin import AdminUser +from app.repositories import app_config + +router = APIRouter( + prefix="/admin/api/ad-config", + tags=["admin-ad-config"], + dependencies=[Depends(get_current_admin)], +) + + +@router.get("", response_model=AdConfigOut, summary="广告配置(穿山甲ID/验签密钥/各场景开关)") +def get_ad_config(db: AdminDb) -> AdConfigOut: + return AdConfigOut(**app_config.get_ad_config(db)) + + +@router.patch("", response_model=AdConfigOut, summary="改广告配置(部分更新,带审计)") +def update_ad_config( + body: AdConfigUpdate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator", "finance"))], + db: AdminDb, +) -> AdConfigOut: + data = body.model_dump(exclude_none=True) + app_config.set_ad_config(db, data, admin_id=admin.id, commit=False) + # 审计只记改了哪些字段,不记 mkey 明文(机密) + write_audit( + db, admin, action="ad_config.set", target_type="ad_config", target_id="ad_config", + detail={"changed": sorted(data.keys())}, ip=get_client_ip(request), commit=False, + ) + db.commit() + return AdConfigOut(**app_config.get_ad_config(db)) diff --git a/app/admin/schemas/ad_config.py b/app/admin/schemas/ad_config.py new file mode 100644 index 0000000..3f5c033 --- /dev/null +++ b/app/admin/schemas/ad_config.py @@ -0,0 +1,30 @@ +"""admin 广告配置 schemas(穿山甲 app_id/各位ID/验签密钥/各场景开关)。""" +from __future__ import annotations + +from pydantic import BaseModel + + +class AdConfigOut(BaseModel): + """admin 读到的完整广告配置(含 reward_mkey;admin 有权见,但绝不经 /platform 下发客户端)。""" + + app_id: str + reward_code_id: str + compare_feed_code_id: str + coupon_feed_code_id: str + reward_mkey: str + reward_enabled: bool + compare_ad_enabled: bool + coupon_ad_enabled: bool + + +class AdConfigUpdate(BaseModel): + """部分更新:只改传入(非 None)字段。空串是合法值(如清空 mkey 回退 .env)。""" + + app_id: str | None = None + reward_code_id: str | None = None + compare_feed_code_id: str | None = None + coupon_feed_code_id: str | None = None + reward_mkey: str | None = None + reward_enabled: bool | None = None + compare_ad_enabled: bool | None = None + coupon_ad_enabled: bool | None = None diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index bb05891..cf866b2 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -24,6 +24,7 @@ from app.repositories import ad_ecpm as crud_ecpm from app.repositories import ad_feed_reward as crud_feed from app.repositories import ad_reward as crud_ad from app.repositories import ad_watch as crud_watch +from app.repositories import app_config from app.repositories import signin as crud_signin from app.schemas.ad import ( AdRewardStatusOut, @@ -80,14 +81,22 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut: 验签失败 403(留给真请求重试);参数缺/坏或 user 不存在 → is_verify=false + reason(不发,不重试)。 granted / capped → is_verify=true + reason=0。 """ - if not settings.pangle_callback_configured: + if not settings.PANGLE_CALLBACK_ENABLED: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured" ) params = dict(request.query_params) - if not pangle.verify_callback_sign_any(params, settings.pangle_reward_secrets): + # 验签密钥:admin 后台配的 reward_mkey 优先,.env 的 PANGLE_REWARD_SECRET* 兜底(兼容/过渡)。 + # 换激励位时后台同步换 mkey 即可,无需改 .env。两者都空才视为未配置。 + ad_mkey = app_config.get_ad_config(db).get("reward_mkey") or "" + secrets = ([ad_mkey] if ad_mkey else []) + settings.pangle_reward_secrets + if not secrets: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured" + ) + if not pangle.verify_callback_sign_any(params, secrets): logger.warning("pangle callback bad sign trans_id=%s", params.get("trans_id")) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad sign") diff --git a/app/api/v1/platform.py b/app/api/v1/platform.py index f839a58..31908bd 100644 --- a/app/api/v1/platform.py +++ b/app/api/v1/platform.py @@ -17,6 +17,7 @@ from app.repositories import app_config from app.repositories import ops_marquee as marquee_crud from app.repositories import ops_stat as crud from app.schemas.platform import ( + AdConfigPublicOut, AppFlagsOut, AppVersionOut, PlatformStatsOut, @@ -54,6 +55,22 @@ def flags(db: DbSession) -> AppFlagsOut: ) +@router.get("/ad-config", response_model=AdConfigPublicOut, summary="客户端拉广告配置(穿山甲ID+场景开关,不鉴权)") +def ad_config(db: DbSession) -> AdConfigPublicOut: + """客户端启动/每场广告前拉,缓存后用:app_id + 各位ID + 各场景开关。 + 不含验签密钥;空库回退默认(=客户端内置值,维持现状)。""" + c = app_config.get_ad_config(db) + return AdConfigPublicOut( + app_id=c["app_id"], + reward_code_id=c["reward_code_id"], + compare_feed_code_id=c["compare_feed_code_id"], + coupon_feed_code_id=c["coupon_feed_code_id"], + reward_enabled=c["reward_enabled"], + compare_ad_enabled=c["compare_ad_enabled"], + coupon_ad_enabled=c["coupon_ad_enabled"], + ) + + @router.get("/app-version", response_model=AppVersionOut, summary="最新 App 版本(OTA 检查更新,不鉴权)") def app_version(db: DbSession) -> AppVersionOut: """客户端启动 / 手动检查更新时拉取。不鉴权:版本信息非敏感,且检查更新可能在登录前。 diff --git a/app/repositories/app_config.py b/app/repositories/app_config.py index b6f67a4..40bfc80 100644 --- a/app/repositories/app_config.py +++ b/app/repositories/app_config.py @@ -91,3 +91,51 @@ def set_app_version(db: Session, data: dict, *, commit: bool = True) -> AppConfi else: db.flush() return row + + +# ── 穿山甲广告配置(admin 可配,客户端动态拉)────────────────────────────────── +# 同 app_version:结构化 dict,复用 AppConfig 表不进 CONFIG_DEFS(它是一组关联配置,不是散项)。 +# 默认值 = 客户端当前内置的硬编码 ID(AdConfig.kt),空库时下发默认 = 维持现状。 +# reward_mkey 是 GroMore S2S 验签密钥(机密),只后端验签用、绝不下发客户端(见 platform/ad-config)。 +AD_CONFIG_KEY = "ad_config" +_AD_CONFIG_DEFAULTS: dict[str, Any] = { + "app_id": "5830519", # 穿山甲应用ID(正式) + "reward_code_id": "104099389", # 福利页激励视频位 + "compare_feed_code_id": "104090333", # 比价信息流位 + "coupon_feed_code_id": "104090333", # 领券信息流位(初始同比价,运营可拆) + "reward_mkey": "", # 激励位 GroMore 验签密钥(空则回退 .env PANGLE_REWARD_SECRET*) + "reward_enabled": True, # 福利激励视频开关 + "compare_ad_enabled": True, # 比价广告开关 + "coupon_ad_enabled": True, # 领券广告开关 +} + + +def get_ad_config(db: Session) -> dict: + """读广告配置。DB 无则返回默认(=客户端内置值,维持现状);DB 有则与默认 merge + (新增字段向后兼容,老记录缺的字段用默认补)。""" + row = db.get(AppConfig, AD_CONFIG_KEY) + merged = dict(_AD_CONFIG_DEFAULTS) + if row is not None and isinstance(row.value, dict): + merged.update(row.value) + return merged + + +def set_ad_config(db: Session, data: dict, *, admin_id: int, commit: bool = True) -> AppConfig: + """admin 写广告配置。与现有值 merge(部分更新),只接受已知字段(防脏键)。""" + row = db.get(AppConfig, AD_CONFIG_KEY) + merged = dict(_AD_CONFIG_DEFAULTS) + if row is not None and isinstance(row.value, dict): + merged.update(row.value) + merged.update({k: v for k, v in data.items() if k in _AD_CONFIG_DEFAULTS}) + if row is None: + row = AppConfig(key=AD_CONFIG_KEY, value=merged, updated_by_admin_id=admin_id) + db.add(row) + else: + row.value = merged + row.updated_by_admin_id = admin_id + if commit: + db.commit() + db.refresh(row) + else: + db.flush() + return row diff --git a/app/schemas/platform.py b/app/schemas/platform.py index 6968271..4c97bd6 100644 --- a/app/schemas/platform.py +++ b/app/schemas/platform.py @@ -30,6 +30,21 @@ class AppFlagsOut(BaseModel): comparing_ad_enabled: bool # 比价/领券期是否展示信息流广告(远程 kill-switch) +class AdConfigPublicOut(BaseModel): + """下发给客户端的穿山甲广告配置(不鉴权,客户端缓存后用)。 + + ⚠️ 不含 reward_mkey(GroMore 验签密钥,机密,只后端验签用,绝不下发)。 + """ + + app_id: str # 穿山甲应用ID(改了客户端需冷启才生效,SDK init 一次性读) + reward_code_id: str # 福利页激励视频位 + compare_feed_code_id: str # 比价信息流位 + coupon_feed_code_id: str # 领券信息流位 + reward_enabled: bool # 福利激励视频开关 + compare_ad_enabled: bool # 比价广告开关 + coupon_ad_enabled: bool # 领券广告开关 + + class AppVersionOut(BaseModel): """最新 App 版本信息(OTA 检查更新,不鉴权)。 From d8358a6816fc6a359f13c0aaabb0cbb341e54280 Mon Sep 17 00:00:00 2001 From: marco Date: Sat, 20 Jun 2026 02:03:33 +0800 Subject: [PATCH 35/36] =?UTF-8?q?fix(user):=20=E6=B3=A8=E9=94=80=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E5=89=8D=E7=BD=AE=E6=A0=A1=E9=AA=8C=E8=B5=84=E9=87=91?= =?UTF-8?q?=E4=BD=99=E9=A2=9D=20+=20=E9=87=8A=E6=94=BE=E5=BE=AE=E4=BF=A1/?= =?UTF-8?q?=E9=82=80=E8=AF=B7=E7=A0=81=E5=94=AF=E4=B8=80=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - delete_account 端点: 有可提现现金余额 / 在审提现单(reviewing)时拒绝注销(409), 防余额锁死在 deleted 行无法退出; pending 提现已在途不拦(对账 worker 不依赖 user.status) - soft_delete_account: 注销连带清 wechat_openid/nickname/avatar(释放微信绑定槽, 否则该微信永久无法再被任何账号绑定→提现通道锁死) + invite_code(释放邀请码唯一槽) - wallet 新增 get_cash_balance_cents(只读, 不建账户) 供注销前置校验; 复用 has_reviewing_withdraw - 新增 tests/test_delete_account.py Co-Authored-By: Claude Opus 4.8 --- app/api/v1/user.py | 12 +++ app/repositories/user.py | 17 ++++- app/repositories/wallet.py | 9 +++ tests/test_delete_account.py | 141 +++++++++++++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 tests/test_delete_account.py diff --git a/app/api/v1/user.py b/app/api/v1/user.py index 6be689f..dc23388 100644 --- a/app/api/v1/user.py +++ b/app/api/v1/user.py @@ -18,6 +18,7 @@ from app.api.deps import CurrentUser, DbSession from app.core import media from app.repositories import onboarding as onboarding_repo from app.repositories import user as user_repo +from app.repositories import wallet as wallet_repo from app.schemas.auth import UserOut from app.schemas.user import ( OkResponse, @@ -87,6 +88,17 @@ def onboarding_status( @router.delete("", response_model=OkResponse, summary="注销账号(软删除)") def delete_account(user: CurrentUser, db: DbSession) -> OkResponse: + # 资金前置校验:注销是软删 + 匿名化,余额一旦留在 deleted 行就锁死(既退不出、新号也 + # 拿不回)。有可提现现金 / 在审提现单 → 拒绝注销,引导用户先把钱处理掉。 + # (pending 提现转账已在途、对账 worker 不依赖 user.status 照常到账,故不拦 pending。) + if wallet_repo.get_cash_balance_cents(db, user.id) > 0: + raise HTTPException( + status_code=409, detail="账户还有未提现的现金余额,请先提现后再注销" + ) + if wallet_repo.has_reviewing_withdraw(db, user.id): + raise HTTPException( + status_code=409, detail="有提现正在审核中,请等审核完成后再注销" + ) media.delete_avatar(user.avatar_url) user_repo.soft_delete_account(db, user) logger.info("delete account user_id=%d", user.id) diff --git a/app/repositories/user.py b/app/repositories/user.py index 33c53ae..7753e33 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -111,14 +111,27 @@ def set_avatar_url(db: Session, user: User, *, avatar_url: str) -> User: def soft_delete_account(db: Session, user: User) -> None: - """注销账号:软删除 + 匿名化。 + """注销账号:软删除 + 匿名化 + 释放唯一约束/外部绑定。 - 把 phone 改成 `deleted_` 释放唯一约束,允许同号码重新注册成全新账号; + 把 phone 改成 `deleted_` 释放手机号唯一约束,允许同号码重新注册成全新账号; 清空 PII(昵称/头像),保留行用于审计。status=deleted 后将无法再登录该行 (登录接口校验 status==active)。 + + ⚠️ 注销必须连同释放 user 上其余唯一约束/外部身份,否则 deleted 行永久占着槽位, + 对应资源再也无法被复用: + - wechat_openid: 不清 → 这个微信再也无法被任何账号绑定(提现通道彻底锁死); + - invite_code: 不清 → 邀请码槽位被占 + 裸查仍命中死号(发奖已被 bind 的 + status==active 校验兜住,清掉更干净)。 + 资金安全(未提现现金 / 在审提现单)由调用方 delete_account 端点前置校验,这里只匿名化。 """ user.status = "deleted" user.phone = f"deleted_{user.id}" user.nickname = None user.avatar_url = None + # 等价"解绑微信":释放 openid 唯一槽 + 清微信昵称头像 + user.wechat_openid = None + user.wechat_nickname = None + user.wechat_avatar_url = None + # 释放邀请码唯一槽 + user.invite_code = None db.commit() diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index 615f0eb..a38ac88 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -340,6 +340,15 @@ def bind_wechat_openid(db: Session, user_id: int, code: str) -> dict: return info +def get_cash_balance_cents(db: Session, user_id: int) -> int: + """只读查现金余额(分)。账户不存在返回 0,**不创建账户**——注销前置校验用, + 不该给一个即将被删除的用户凭空建一行空账户(get_or_create_account 会建)。""" + val = db.execute( + select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id) + ).scalar_one_or_none() + return val or 0 + + def has_reviewing_withdraw(db: Session, user_id: int) -> bool: """用户是否有「待审核(reviewing)」的提现单。 diff --git a/tests/test_delete_account.py b/tests/test_delete_account.py new file mode 100644 index 0000000..464b02c --- /dev/null +++ b/tests/test_delete_account.py @@ -0,0 +1,141 @@ +"""注销账号(DELETE /api/v1/user):软删 + 释放唯一约束 + 资金前置校验。 + +覆盖: + - 干净注销:status=deleted + phone 匿名化 + wechat_openid/invite_code 等唯一槽全部释放 + - 回归原始 bug:注销释放 openid 后,别的账号能绑同一个微信 + - 资金前置闸:有可提现现金 / 在审提现单 → 409 拒绝注销,账号不被删 + - 注销后旧 token 即时失效(status==active 闸) +""" +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 + + +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": "微信昵称", "avatar_url": "https://x/a.png", "raw": {}}, + ) + + +def _seed_cash(client, token: str, phone: str, cents: int) -> None: + """先访问 /account 触发建账户,再用 DB 直接灌现金余额。""" + 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 = cents + db.commit() + finally: + db.close() + + +def _get_user(phone: str) -> User | None: + db = SessionLocal() + try: + return db.execute(select(User).where(User.phone == phone)).scalar_one_or_none() + finally: + db.close() + + +def test_delete_clean_account_releases_all_unique_slots(client) -> None: + phone = "13900000001" + token = _login(client, phone) + # 预置:给该用户造昵称 + 邀请码(占 invite_code 唯一槽),验证注销时一并释放 + db = SessionLocal() + try: + u = db.execute(select(User).where(User.phone == phone)).scalar_one() + u.nickname = "张三" + u.invite_code = "INVCODE0001" + db.commit() + uid = u.id + finally: + db.close() + + r = client.delete("/api/v1/user", headers=_auth(token)) + assert r.status_code == 200, r.text + + u = _get_user(f"deleted_{uid}") + assert u is not None + assert u.status == "deleted" + assert u.phone == f"deleted_{uid}" + assert u.nickname is None + assert u.avatar_url is None + assert u.wechat_openid is None + assert u.wechat_nickname is None + assert u.wechat_avatar_url is None + assert u.invite_code is None + + +def test_delete_releases_wechat_so_another_account_can_bind(client, monkeypatch) -> None: + """原始 bug 回归:A 绑微信 → 注销,B 必须能绑同一个 openid(修复前会 409)。""" + openid = "openid_delreuse_a1" # 独特值,避免跨测试共享库污染(如 test_withdraw 也用 openid_shared_xyz) + # A 绑微信 + _patch_userinfo(monkeypatch, openid) + token_a = _login(client, "13900000002") + r = client.post("/api/v1/wallet/bind-wechat", json={"code": "ca"}, headers=_auth(token_a)) + assert r.status_code == 200, r.text + # A 注销(无现金、无提现单 → 允许) + r = client.delete("/api/v1/user", headers=_auth(token_a)) + assert r.status_code == 200, r.text + # B 绑同一个 openid → 必须成功(修复前会 409 "该微信已绑定其他账号") + token_b = _login(client, "13900000003") + r = client.post("/api/v1/wallet/bind-wechat", json={"code": "cb"}, headers=_auth(token_b)) + assert r.status_code == 200, r.text + assert r.json()["bound"] is True + + +def test_delete_blocked_when_cash_balance_positive(client) -> None: + phone = "13900000004" + token = _login(client, phone) + _seed_cash(client, token, phone, 100) # 1 元未提现 + r = client.delete("/api/v1/user", headers=_auth(token)) + assert r.status_code == 409, r.text + assert "现金" in r.json()["detail"] + u = _get_user(phone) # 账号未被删 + assert u is not None and u.status == "active" + + +def test_delete_blocked_when_reviewing_withdraw_exists(client, monkeypatch) -> None: + """cash 提光(=0)但有在审提现单 → 仍拒绝(命中 reviewing 闸,非 cash 闸)。""" + phone = "13900000005" + _patch_userinfo(monkeypatch, "openid_delrev_b2") + token = _login(client, phone) + _seed_cash(client, token, phone, 50) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + # 提光 50 → cash 归 0,单进 reviewing + r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token)) + assert r.status_code == 200 and r.json()["status"] == "reviewing", r.text + assert r.json()["cash_balance_cents"] == 0 + + r = client.delete("/api/v1/user", headers=_auth(token)) + assert r.status_code == 409, r.text + assert "审核" in r.json()["detail"] + u = _get_user(phone) + assert u is not None and u.status == "active" + + +def test_deleted_user_old_token_is_rejected(client) -> None: + phone = "13900000006" + token = _login(client, phone) + r = client.delete("/api/v1/user", headers=_auth(token)) + assert r.status_code == 200, r.text + # 注销后旧 token 立即失效(get_current_user 校验 status==active) + r = client.get("/api/v1/user/onboarding/status", params={"device_id": "d"}, headers=_auth(token)) + assert r.status_code == 401, r.text From f7a3ef2e0bd2f8bc80377732761d4a6eca78d032 Mon Sep 17 00:00:00 2001 From: marco Date: Sat, 20 Jun 2026 13:39:45 +0800 Subject: [PATCH 36/36] =?UTF-8?q?feat(internal):=20=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E7=AA=97=20agent=20=E5=85=9C=E5=BA=95?= =?UTF-8?q?=E6=A0=B7=E6=9C=AC=E8=90=BD=E7=9B=98(pricebot=20=E5=9B=9E?= =?UTF-8?q?=E5=86=99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增内部端点 POST /internal/launch-confirm-sample(X-Internal-Secret 鉴权): pricebot 对没见过文案的启动确认窗用 LLM 兜底放行后,把样本(host 包 / 弹窗树 / LLM plan / 设备 locale 机型)回写落 launch_confirm_sample 表,供研发人工沉淀回 pricebot 规则 yaml - 配套: model LaunchConfirmSample + schema + repo(都上报不去重) + alembic 迁移 launch_confirm_sample_table(down_revision=cps_wx_user) + main 注册 router + models/__init__ 登记 - docs: 后端技术实现.md 加 §6.5 pricebot 回写内部端点说明 Co-Authored-By: Claude Opus 4.8 --- .../versions/launch_confirm_sample_table.py | 53 ++++++++++++++++ app/api/internal/launch_confirm.py | 63 +++++++++++++++++++ app/main.py | 2 + app/models/__init__.py | 1 + app/models/launch_confirm_sample.py | 61 ++++++++++++++++++ app/repositories/launch_confirm_sample.py | 35 +++++++++++ app/schemas/launch_confirm_sample.py | 26 ++++++++ docs/后端技术实现.md | 10 +++ 8 files changed, 251 insertions(+) create mode 100644 alembic/versions/launch_confirm_sample_table.py create mode 100644 app/api/internal/launch_confirm.py create mode 100644 app/models/launch_confirm_sample.py create mode 100644 app/repositories/launch_confirm_sample.py create mode 100644 app/schemas/launch_confirm_sample.py diff --git a/alembic/versions/launch_confirm_sample_table.py b/alembic/versions/launch_confirm_sample_table.py new file mode 100644 index 0000000..d9ff6fe --- /dev/null +++ b/alembic/versions/launch_confirm_sample_table.py @@ -0,0 +1,53 @@ +"""launch_confirm_sample table (启动确认窗 agent 兜底样本: LLM 放行的弹窗样本沉淀) + +Revision ID: launch_confirm_sample_table +Revises: cps_wx_user +Create Date: 2026-06-20 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "launch_confirm_sample_table" +down_revision: Union[str, Sequence[str], None] = "cps_wx_user" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "launch_confirm_sample", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), + sa.Column("trace_id", sa.String(length=64), nullable=True), + sa.Column("device_id", sa.String(length=64), nullable=True), + sa.Column("host_package", sa.String(length=128), nullable=True), + sa.Column("target_app", sa.String(length=128), nullable=True), + sa.Column("system_locale", sa.String(length=32), nullable=True), + sa.Column("exec_success", sa.Boolean(), nullable=False), + sa.Column("dialog_title", sa.String(length=256), nullable=True), + # PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐 + sa.Column("payload", sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), "postgresql"), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("launch_confirm_sample", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_launch_confirm_sample_created_at"), ["created_at"], unique=False) + batch_op.create_index(batch_op.f("ix_launch_confirm_sample_trace_id"), ["trace_id"], unique=False) + batch_op.create_index(batch_op.f("ix_launch_confirm_sample_device_id"), ["device_id"], unique=False) + batch_op.create_index(batch_op.f("ix_launch_confirm_sample_host_package"), ["host_package"], unique=False) + batch_op.create_index(batch_op.f("ix_launch_confirm_sample_system_locale"), ["system_locale"], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table("launch_confirm_sample", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_system_locale")) + batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_host_package")) + batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_device_id")) + batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_trace_id")) + batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_created_at")) + + op.drop_table("launch_confirm_sample") diff --git a/app/api/internal/launch_confirm.py b/app/api/internal/launch_confirm.py new file mode 100644 index 0000000..5f24d3f --- /dev/null +++ b/app/api/internal/launch_confirm.py @@ -0,0 +1,63 @@ +"""启动确认窗兜底样本内部上报端点(pricebot → app-server)。 + +pricebot 的 launch_confirm_agent 每次靠 LLM 兜底放行一个"静态 PROFILES 没认出"的跨 App +启动确认窗时,把完整样本 POST 到这里落库。**不是给客户端的接口**:不走用户 JWT,靠 +server 间共享密钥头 `X-Internal-Secret` 校验(== settings.INTERNAL_API_SECRET)。密钥未配置 +(默认空)时直接 503,避免裸奔写端点。 + +研发定期人工把 exec_success=true 的样本沉淀进 pricebot 的 launch_confirm.PROFILES。 +""" +from __future__ import annotations + +import hmac +import logging +from typing import Annotated + +from fastapi import APIRouter, Header, HTTPException, status + +from app.api.deps import DbSession +from app.core.config import settings +from app.repositories import launch_confirm_sample as repo +from app.schemas.launch_confirm_sample import ( + LaunchConfirmSampleIn, + LaunchConfirmSampleOut, +) + +logger = logging.getLogger("shagua.internal.launch_confirm") + +router = APIRouter(prefix="/internal", tags=["internal"]) + + +def _check_secret(x_internal_secret: str | None) -> None: + """共享密钥校验。未配置 → 503(挡裸奔写端点);不匹配 → 401(常量时间比较)。""" + configured = settings.INTERNAL_API_SECRET + if not configured: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="internal api not configured", + ) + if not x_internal_secret or not hmac.compare_digest(x_internal_secret, configured): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid internal secret", + ) + + +@router.post( + "/launch-confirm-sample", + response_model=LaunchConfirmSampleOut, + summary="启动确认窗兜底样本上报(pricebot→app-server,落 launch_confirm_sample)", +) +def report_launch_confirm_sample( + payload: LaunchConfirmSampleIn, + db: DbSession, + x_internal_secret: Annotated[str | None, Header()] = None, +) -> LaunchConfirmSampleOut: + _check_secret(x_internal_secret) + sid = repo.insert_sample(db, payload) + logger.info( + "launch_confirm_sample id=%d host=%s locale=%s success=%s trace=%s", + sid, payload.host_package, payload.system_locale, + payload.exec_success, payload.trace_id, + ) + return LaunchConfirmSampleOut(id=sid) diff --git a/app/main.py b/app/main.py index 702f19b..17ff332 100644 --- a/app/main.py +++ b/app/main.py @@ -22,6 +22,7 @@ from app.api.v1.compare_record import router as compare_record_router from app.api.v1.coupon import router as coupon_router from app.api.v1.cps_redirect import router as cps_redirect_router from app.api.internal.app_version import router as internal_app_version_router +from app.api.internal.launch_confirm import router as internal_launch_confirm_router from app.api.internal.price import router as internal_price_router from app.api.internal.store import router as internal_store_router from app.api.v1.feedback import router as feedback_router @@ -109,6 +110,7 @@ app.include_router(report_router) app.include_router(internal_price_router) app.include_router(internal_store_router) app.include_router(internal_app_version_router) +app.include_router(internal_launch_confirm_router) app.include_router(platform_router) # CPS 群发短链跳转 /c/{code}(公网无鉴权:记点击 → 302 跳美团) app.include_router(cps_redirect_router) diff --git a/app/models/__init__.py b/app/models/__init__.py index 768d192..d88882f 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -20,6 +20,7 @@ from app.models.coupon_state import ( # noqa: F401 from app.models.feedback import Feedback # noqa: F401 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 from app.models.meituan_coupon import MeituanCoupon # noqa: F401 from app.models.onboarding import OnboardingCompletion # noqa: F401 from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401 diff --git a/app/models/launch_confirm_sample.py b/app/models/launch_confirm_sample.py new file mode 100644 index 0000000..de225b0 --- /dev/null +++ b/app/models/launch_confirm_sample.py @@ -0,0 +1,61 @@ +"""启动确认窗 agent 兜底样本表(launch_confirm_sample)。 + +pricebot 的 launch_confirm_agent 每次"静态 PROFILES 没认出、靠 LLM 兜底放行"一个跨 App +启动确认窗(繁体 / 英文 / 小语种 / ROM 改版)时,把完整样本 server→server 落这里。研发定期 +**人工**把 exec_success=true 的样本沉淀进 pricebot 的 launch_confirm.PROFILES(静态快路径), +让以后同款窗不必再调 LLM。 + +设计: +- 与登录无关、不鉴权(server→server 共享密钥头 X-Internal-Secret)。 +- **都上报、不去重**(by design):同一种窗多台机器上报多条,研发按 host_package / locale + 聚合后人工筛。 +- 大字段(弹窗树快照 + LLM plan + 设备信息)塞 payload(JSONB)兜底,免得加字段就迁移 schema。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import JSON, Boolean, DateTime, Integer, String, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + +# PG 上用 JSONB(可建 GIN 索引),SQLite(本地/测试)退化为通用 JSON(同 price_observation)。 +_JSON = JSON().with_variant(JSONB(), "postgresql") + + +class LaunchConfirmSample(Base): + __tablename__ = "launch_confirm_sample" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) + + # pricebot 侧 trace_id:回指原始 trace,能去 price.shaguabijia.com 查完整上下文。 + trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + device_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + + # 弹窗宿主包(= 触发判据, 也是研发沉淀进 PROFILES.host_packages 的键)。按它聚合人工筛。 + host_package: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True) + # 被打开的目标 App 包名(标题里"想要打开 X"的 X 是变量, 据此把它从 sign 里挖空)。 + target_app: Mapped[str | None] = mapped_column(String(128), nullable=True) + # 系统语言地区(zh-TW / en-US…)—— 多语言问题的核心维度。 + # ⚠️ 前端 AgentStepRequest 暂未上报设备信息 → 现恒 None,待前端加 device_info 字段后回填。 + system_locale: Mapped[str | None] = mapped_column(String(32), index=True, nullable=True) + + # 这次兜底是否成功放行(弹窗消失=True;放弃 / LLM 没认出=False)。研发只沉淀 True 的。 + exec_success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + # 弹窗标题全文(研发据此提 launch_sign),如"「傻瓜比价」想要開啟「京東」"。 + dialog_title: Mapped[str | None] = mapped_column(String(256), nullable=True) + + # 大 JSON:完整样本。{is_launch_confirm, llm_model, llm_reason, plan, dialog_tree, + # device_info: {screen, locale, brand, model, android_version, rom_version}} + payload: Mapped[dict | None] = mapped_column(_JSON, nullable=True) + + def __repr__(self) -> str: # pragma: no cover + return ( + f"" + ) diff --git a/app/repositories/launch_confirm_sample.py b/app/repositories/launch_confirm_sample.py new file mode 100644 index 0000000..4d0b226 --- /dev/null +++ b/app/repositories/launch_confirm_sample.py @@ -0,0 +1,35 @@ +"""启动确认窗兜底样本落库:单条 insert(都上报、不去重,by design)。""" +from __future__ import annotations + +import logging +from typing import Optional + +from sqlalchemy.orm import Session + +from app.models.launch_confirm_sample import LaunchConfirmSample +from app.schemas.launch_confirm_sample import LaunchConfirmSampleIn + +logger = logging.getLogger("shagua.launch_confirm") + + +def _trunc(s: Optional[str], n: int) -> Optional[str]: + """截断到列长上限(PG varchar 超长会报错;繁体长标题等需要)。空 → None。""" + return s[:n] if s else None + + +def insert_sample(db: Session, payload: LaunchConfirmSampleIn) -> int: + """写入一条样本,返回新行 id。""" + row = LaunchConfirmSample( + trace_id=_trunc(payload.trace_id, 64), + device_id=_trunc(payload.device_id, 64), + host_package=_trunc(payload.host_package, 128), + target_app=_trunc(payload.target_app, 128), + system_locale=_trunc(payload.system_locale, 32), + exec_success=bool(payload.exec_success), + dialog_title=_trunc(payload.dialog_title, 256), + payload=payload.payload, + ) + db.add(row) + db.commit() + db.refresh(row) + return row.id diff --git a/app/schemas/launch_confirm_sample.py b/app/schemas/launch_confirm_sample.py new file mode 100644 index 0000000..e2ed223 --- /dev/null +++ b/app/schemas/launch_confirm_sample.py @@ -0,0 +1,26 @@ +"""启动确认窗兜底样本的内部上报模型(pricebot → app-server)。""" +from __future__ import annotations + +from pydantic import BaseModel + + +class LaunchConfirmSampleIn(BaseModel): + """一次 agent 兜底的完整样本。 + + 字段一律宽松可空:落盘是辅助数据,绝不能因个别字段缺失就拒收(否则连累 pricebot 兜底)。 + """ + + trace_id: str | None = None + device_id: str | None = None + host_package: str | None = None + target_app: str | None = None + system_locale: str | None = None + exec_success: bool = False + dialog_title: str | None = None + payload: dict | None = None + + +class LaunchConfirmSampleOut(BaseModel): + """落库结果。""" + + id: int diff --git a/docs/后端技术实现.md b/docs/后端技术实现.md index 8828ed7..e106985 100644 --- a/docs/后端技术实现.md +++ b/docs/后端技术实现.md @@ -222,6 +222,16 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert --- +## 6.5 pricebot 回写:内部端点(server→server) + +§6 是 app-server 透传给 pricebot;反过来 pricebot 也会把少量数据回写 app-server 落库,走 `app/api/internal/*` —— **不走用户 JWT**,靠共享密钥头 `X-Internal-Secret`(== `INTERNAL_API_SECRET`,未配则 503,防裸奔写端点)校验。 + +**启动确认窗兜底样本**(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 侧跳过上报、不影响比价/领券)。 + +> 同类内部回写端点还有 `/internal/price-observation`(价格观测)、`/internal/store-mapping`(跨平台店铺映射)等 pricebot 比价资产沉淀。 + +--- + ## 7. 数据模型 业务表(下表)+ `alembic_version` 框架表。生产 PG / 开发可回退 SQLite。