Compare commits

...

23 Commits

Author SHA1 Message Date
marco 733a51260f feat(cps): 微信网页授权加总开关 WX_MP_OAUTH_ENABLED(默认关)
服务号认证审核中,先关掉落地页授权(走原逻辑不跳授权/不拿 openid),整套微信代码保留。
审核通过后 .env 置 WX_MP_OAUTH_ENABLED=true + 重启即启用,无需改代码。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:03:58 +08:00
marco 7fd70a264d feat(cps): 微信网页授权接入——落地页拿 openid/昵称头像,用户级群统计
- 服务号网页授权(WX_MP_APPID/SECRET,区别于 App 的 WECHAT_APP_ID):
  落地页 base 静默拿 openid;点「领券」补 userinfo 拿昵称头像(交互触发避快照页)
- 新表 cps_wx_user + cps_click 加 openid + 迁移;integrations/wx_oauth
- /c/{code} 授权链路 + 回调 /wx/oauth/cb + cookie 免重复授权;click 带 openid
- admin 群详情加「群内微信用户」(领券画像)端点
- 下单归因到人需 user-level sid,本期只做身份+领券侧

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:44:11 +08:00
marco 4b98d405d7 feat: OTA 版本下发端点(检查更新)
- app_config 加 get/set_app_version(独立 key,复用表不进 CONFIG_DEFS)
- GET /api/v1/platform/app-version 公开读 + POST /internal/app-version 内部写(X-Internal-Secret)
- tests/test_app_version.py 5 端到端测试

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 08:31:31 +08:00
marco 63400c29cc docs(api): 补录比价透传端点到 API 总览
intent/precoupon/step、intent/step、trace/finalize、meituan/top-sales 补进总览表,
标注 Phase 流程与不鉴权; 更新最后更新日期到 2026-06-18。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 07:04:48 +08:00
marco 42be0f828f feat(cps): 加微信网页授权域名校验文件端点(MP_verify)
coupon.shaguabijia.com 全反代 app-server,微信请求该域名根目录 MP_verify_xxx.txt
验证域名归属(放根目录)。为微信网页授权(落地页拿 openid)接入做准备。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 05:53:45 +08:00
marco 5bafe5b71a feat(cps): 群每天明细端点(点击+订单按天合并,含退款/净佣金)
- repo group_order_daily:订单按 pay_time 北京分天聚合(口径同 _order_agg)+ 退款佣金合计
- GET /groups/{id}/daily?days=N:点击侧(补零连续天)与订单侧(仅美团群有 sid)按天合并
- 用生产 g46 真实数据验证按天口径无误

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:32:18 +08:00
marco 498da05995 docs(database): 补充数据库表文档(cps/invite/price_observation/store_mapping 等)
(工作区既有改动, 非本次会话产出)
2026-06-17 23:32:36 +08:00
marco 3cab3b9055 fix(compare-record): admin 放行 cancelled 筛选 + llm_calls 拉取改后台任务
- admin 列表 status pattern 加 cancelled(客户端会上报用户终止比价)
- 上报端点拉 pricebot llm_calls 改 BackgroundTasks 异步回填, 不阻塞上报响应(独立 session)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 23:32:36 +08:00
marco f790519765 fix(cps): 时序分桶对 naive datetime 按 UTC 兜底(本地 SQLite 错桶)
clicked_at.astimezone 对 naive(SQLite 读回无 tz)会按系统时区解释→分桶偏移;
naive 先按 UTC 兜底再转北京,与 queries._as_utc 约定一致。生产 PG(tz-aware)本就正确。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 23:06:05 +08:00
marco a2fd666203 feat(admin): 比价记录 debug 页后端——补全链路 + DB 迁移
model/schema 的 debug 字段已在 4ee6de2 起头,本次补完整条链路:
- repo upsert 存客户端环境/性能字段;端点同机拉 pricebot llm_calls 落库 + 算 llm次数/重试
- schema 加 platform_results 透传(admin「卡在哪一步」从 raw_payload 读)
- admin 比价记录查询接口(按 user_id/phone 列表分页 + 详情含 llm_calls)
- 迁移 comparison_debug_fields: 给 comparison_record 加环境/性能/llm_calls 列(全 nullable)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 22:33:33 +08:00
marco aa898fc09a feat(cps): 群点击时序聚合端点(天/小时级 PV/UV/复制,补零连续桶)
- repo group_click_timeseries:北京时区分桶,visit/copy 的 PV/UV,无数据补 0
- GET /cps/groups/{id}/timeseries?granularity=day&days=N | granularity=hour

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 22:31:34 +08:00
marco 4ee6de2548 feat(cps): 活动支持编辑(PATCH /activities/{id})
- CpsActivityUpdate schema + repo update_activity(部分更新,非 None 覆盖)
- router 按「合并后最终值」校验平台必填项(同新建口径),写审计 cps.activity.update
- 可改名/平台/对应字段/落地页图/备注/状态

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 22:13:35 +08:00
marco c78cfc838f feat(cps): 群/活动支持删除(operator,二次确认,不级联删已发链接)
- repo delete_group/delete_activity + DELETE /cps/groups/{id} 与 /cps/activities/{id}
- 硬删记录但保留 cps_link:已发短链继续可用(落地页不依赖 group/activity 存活)
- 写审计 cps.group.delete / cps.activity.delete

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:43:34 +08:00
marco d55f47fc59 feat(cps): 淘宝活动落地页图做活——每活动关联图,运营上传/选已有
- model: cps_activity.image_url + 迁移 cps_activity_image 回填存量淘宝活动为现有图
- admin: 新建淘宝活动必传图(上传 /upload-image 或选已有 /activity-images),存绝对 URL
- 落地页 cps_redirect: 按 link→activity 取图展示,缺图兜底默认图,img src 经 html.escape
- media: save_cps_image + to_abs_media_url(基于 CPS_REDIRECT_BASE,跨域 admin-web 可显示)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:32:10 +08:00
marco 7368a1ca8a feat(cps): 淘宝落地页改用设计图主视觉 + 复制按钮上提至 75% 屏高
- banner/card 手画区域整体换成设计图 /media/taobao_landing.jpg(~240KB jpg)
- 复制淘口令按钮从底部固定上提到 top:75vh, 上方留白
- 图入库(data/media 白名单, 同 dl.html 先例), 同域 /media serve
- 淘口令注入 / 复制 / toast 逻辑不变

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 20:53:34 +08:00
marco 3a40f617bd feat(cps): 后端接入淘宝/京东多平台(payload + 群多平台 + 复制统计 + 迁移)
- 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 <noreply@anthropic.com>
2026-06-17 19:21:20 +08:00
marco 003fd9d986 feat(compare): 失败比价记录支持 trace 链接
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 <noreply@anthropic.com>
2026-06-17 15:56:15 +08:00
ouzhou 04edb50acc feat(admin/ad): 提现自动对账后台开关 + 客户端广告 flags 端点 + 穿山甲多 m-key 验签 (#59)
- 自动对账接入 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) <noreply@anthropic.com>

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #59
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-17 10:37:54 +08:00
marco 3483c1bba9 Merge origin/main (meituan-etl #57/#58) into CPS 分发
整合实习生 meituan-etl 全国扩城 + NUL 清洗,与本次 CPS 分发对账并线。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:03:29 +08:00
marco 277f9b16a2 feat(cps): 群发券分发与对账 + 短链点击追踪
群(sid)/活动池/转链(带sid)/美团 query_order 按 sid 对账/按群统计;
短链 /c/{code} 记点击(PV/UV)→302 跳美团,点击→下单漏斗。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:00:29 +08:00
marco 9d93b70b9b feat(user): username 对外展示 + 默认昵称 + 存量回填
代提工作区既有的他人在制品(非本次 CPS);CPS 迁移链 cps_tables 依赖其
b3f1a2c4d5e6 迁移,需一并提交。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:00:29 +08:00
chenshuobo f853938095 fix(meituan-etl): 清洗文本 NUL 字节 + 逐城入库容错(修全量首灌崩在脏数据) (#58)
全国 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: #58
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
2026-06-16 21:19:47 +08:00
chenshuobo 783dfd059d feat(meituan-etl): 离线库扩到全国 359 城(多城并发抓取入库) (#57)
智能推荐 / 销量最高两 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: #57
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
2026-06-16 15:40:46 +08:00
101 changed files with 5902 additions and 169 deletions
+10 -2
View File
@@ -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(绕过反作弊)。
+1
View File
@@ -30,6 +30,7 @@ data/*
!data/media/
data/media/*
!data/media/dl.html
!data/media/taobao_landing.jpg
secrets/*
!secrets/.gitkeep
@@ -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')
@@ -0,0 +1,54 @@
"""comparison_record: 客户端环境 / 性能统计 / LLM 明细 debug 字段
Revision ID: comparison_debug_fields
Revises: cps_activity_image
Create Date: 2026-06-17 22:00:00.000000
给 admin 比价记录 debug 页新增采集列,全部 nullable(旧记录 / 未发版客户端为 NULL,向后兼容):
- 客户端环境: device_*/rom_*/android_*/app_version*/source_app_version/经纬度
- 过程统计: total_ms/step_count/llm_call_count/retry_count
- LLM 明细: llm_calls(JSONBserver 按 trace_id 同机拉 pricebot 落库)
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "comparison_debug_fields"
down_revision: Union[str, Sequence[str], None] = "cps_activity_image"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_JSONB = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
def upgrade() -> None:
op.add_column("comparison_record", sa.Column("device_model", sa.String(64), nullable=True))
op.add_column("comparison_record", sa.Column("device_manufacturer", sa.String(64), nullable=True))
op.add_column("comparison_record", sa.Column("rom_vendor", sa.String(32), nullable=True))
op.add_column("comparison_record", sa.Column("rom_name", sa.String(32), nullable=True))
op.add_column("comparison_record", sa.Column("rom_version", sa.Integer(), nullable=True))
op.add_column("comparison_record", sa.Column("android_version", sa.String(16), nullable=True))
op.add_column("comparison_record", sa.Column("android_sdk", sa.Integer(), nullable=True))
op.add_column("comparison_record", sa.Column("app_version", sa.String(32), nullable=True))
op.add_column("comparison_record", sa.Column("app_version_code", sa.Integer(), nullable=True))
op.add_column("comparison_record", sa.Column("source_app_version", sa.String(32), nullable=True))
op.add_column("comparison_record", sa.Column("longitude", sa.Float(), nullable=True))
op.add_column("comparison_record", sa.Column("latitude", sa.Float(), nullable=True))
op.add_column("comparison_record", sa.Column("total_ms", sa.Integer(), nullable=True))
op.add_column("comparison_record", sa.Column("step_count", sa.Integer(), nullable=True))
op.add_column("comparison_record", sa.Column("llm_call_count", sa.Integer(), nullable=True))
op.add_column("comparison_record", sa.Column("retry_count", sa.Integer(), nullable=True))
op.add_column("comparison_record", sa.Column("llm_calls", _JSONB, nullable=True))
def downgrade() -> None:
for col in (
"llm_calls", "retry_count", "llm_call_count", "step_count", "total_ms",
"latitude", "longitude", "source_app_version", "app_version_code",
"app_version", "android_sdk", "android_version", "rom_version",
"rom_name", "rom_vendor", "device_manufacturer", "device_model",
):
op.drop_column("comparison_record", col)
+38
View File
@@ -0,0 +1,38 @@
"""cps activity 落地页图: cps_activity.image_url + 回填存量淘宝活动
新建淘宝活动时运营提供落地页主视觉图(上传或选已有),落地页按活动展示对应图。
存量淘宝活动回填为此前写死的那张图,无缝衔接。
Revision ID: cps_activity_image
Revises: cps_v2_platforms
Create Date: 2026-06-17 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from app.core.config import settings
revision: str = "cps_activity_image"
down_revision: Union[str, Sequence[str], None] = "cps_v2_platforms"
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("image_url", sa.String(512), nullable=True))
# 回填存量淘宝活动 = 此前写死的那张图(绝对 URL,基于落地页域名 CPS_REDIRECT_BASE;
# dev 未配则相对路径)。新活动由运营自传/选,不回填。
base = settings.CPS_REDIRECT_BASE.rstrip("/")
default_img = f"{base}/media/taobao_landing.jpg" if base else "/media/taobao_landing.jpg"
op.execute(
sa.text(
"UPDATE cps_activity SET image_url = :img WHERE platform = 'taobao'"
).bindparams(img=default_img)
)
def downgrade() -> None:
op.drop_column("cps_activity", "image_url")
+61
View File
@@ -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")
+92
View File
@@ -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")
+61
View File
@@ -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")
+48
View File
@@ -0,0 +1,48 @@
"""cps 微信用户表 cps_wx_user + cps_click 加 openid
CPS 落地页微信网页授权拿 openid/昵称头像,做用户级群统计。
Revision ID: cps_wx_user
Revises: comparison_debug_fields
Create Date: 2026-06-19 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "cps_wx_user"
down_revision: Union[str, Sequence[str], None] = "comparison_debug_fields"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"cps_wx_user",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("openid", sa.String(64), nullable=False),
sa.Column("unionid", sa.String(64), nullable=True),
sa.Column("nickname", sa.String(128), nullable=True),
sa.Column("headimgurl", sa.String(512), nullable=True),
sa.Column("first_code", sa.String(16), nullable=True),
sa.Column("first_group_id", sa.Integer(), nullable=True),
sa.Column("first_seen", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("last_seen", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_cps_wx_user_openid", "cps_wx_user", ["openid"], unique=True)
op.create_index("ix_cps_wx_user_unionid", "cps_wx_user", ["unionid"])
op.create_index("ix_cps_wx_user_first_group_id", "cps_wx_user", ["first_group_id"])
op.add_column("cps_click", sa.Column("openid", sa.String(64), nullable=True))
op.create_index("ix_cps_click_openid", "cps_click", ["openid"])
def downgrade() -> None:
op.drop_index("ix_cps_click_openid", table_name="cps_click")
op.drop_column("cps_click", "openid")
op.drop_index("ix_cps_wx_user_first_group_id", table_name="cps_wx_user")
op.drop_index("ix_cps_wx_user_unionid", table_name="cps_wx_user")
op.drop_index("ix_cps_wx_user_openid", table_name="cps_wx_user")
op.drop_table("cps_wx_user")
+4
View File
@@ -18,7 +18,9 @@ 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
from app.admin.routers.comparison import router as comparison_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 +92,7 @@ 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(comparison_router)
admin_app.include_router(cps_router)
admin_app.include_router(ad_audit_router)
admin_app.include_router(ad_revenue_router)
+517
View File
@@ -0,0 +1,517 @@
"""admin CPS 数据访问:群/活动 CRUD + 美团订单拉单入库(对账) + 按群统计聚合。
美团 query_order 返回的金额是「元」字符串、时间是秒级时间戳,这里统一转「分」+ tz-aware。
统计在 Python 侧聚合(订单量级小、admin 低频),逻辑清晰、跨 PG/SQLite 无 SQL 方言坑。
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
from uuid import uuid4
from sqlalchemy import desc, func, 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_link import CpsClick
from app.models.cps_order import CpsOrder
from app.models.cps_wx_user import CpsWxUser
# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账
_INVALID_STATUS = {"4", "5"}
_SETTLED_STATUS = "6"
# CPS 点击时序按北京时区分桶(运营看的是北京时间)
_BJ_TZ = timezone(timedelta(hours=8))
# ───────────── 单位换算 ─────────────
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, platforms: list[str], sid: str | None = None,
member_count: int | None = None, remark: str | None = None, commit: bool = True,
) -> CpsGroup:
"""建群。含 meituan 才有 sid(留空自动 g<id>);纯淘宝/京东 sid=None(那俩不支持 sid)。"""
has_meituan = "meituan" in (platforms or [])
# 含美团:用填的 sid,或临时占位待回填 g<id>;不含美团:sid 恒 None
seed_sid = (sid or f"tmp{uuid4().hex[:20]}") if has_meituan else None
group = CpsGroup(
name=name,
platforms=list(platforms or []),
sid=seed_sid,
member_count=member_count,
remark=remark,
)
db.add(group)
db.flush()
if has_meituan and 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,
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:
group.status = status
if remark is not None:
group.remark = remark
if commit:
db.commit()
db.refresh(group)
else:
db.flush()
return group
def delete_group(db: Session, group: CpsGroup, *, commit: bool = True) -> None:
"""硬删群。不级联删已生成的 cps_link(已发链接继续可用);该群历史点击/订单按 sid 仍在库。"""
db.delete(group)
if commit:
db.commit()
else:
db.flush()
# ───────────── 活动 ─────────────
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 list_activity_images(db: Session) -> list[str]:
"""已用过的活动落地页图(distinct 非空 image_url),供新建活动复用选择。"""
rows = (
db.execute(
select(CpsActivity.image_url)
.where(CpsActivity.image_url.is_not(None))
.distinct()
)
.scalars()
.all()
)
return [r for r in rows if r]
def create_activity(
db: Session, *, name: str, platform: str = "meituan", act_id: str | None = None,
product_view_sign: str | None = None, payload: str | None = None,
image_url: 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, payload=payload,
image_url=image_url, remark=remark,
)
db.add(activity)
if commit:
db.commit()
db.refresh(activity)
else:
db.flush()
return activity
def update_activity(
db: Session, activity: CpsActivity, *, name: str | None = None,
platform: str | None = None, act_id: str | None = None,
product_view_sign: str | None = None, payload: str | None = None,
image_url: str | None = None, remark: str | None = None,
status: str | None = None, commit: bool = True,
) -> CpsActivity:
if name is not None:
activity.name = name
if platform is not None:
activity.platform = platform
if act_id is not None:
activity.act_id = act_id
if product_view_sign is not None:
activity.product_view_sign = product_view_sign
if payload is not None:
activity.payload = payload
if image_url is not None:
activity.image_url = image_url
if remark is not None:
activity.remark = remark
if status is not None:
activity.status = status
if commit:
db.commit()
db.refresh(activity)
else:
db.flush()
return activity
def delete_activity(db: Session, activity: CpsActivity, *, commit: bool = True) -> None:
"""硬删活动。不级联删 cps_link(已发链接继续可用;淘宝落地页缺活动时取图走兜底默认图)。"""
db.delete(activity)
if commit:
db.commit()
else:
db.flush()
# ───────────── 订单(对账) ─────────────
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。按预估佣金降序。
"""
ostmt = select(CpsOrder)
if date_from is not None:
ostmt = ostmt.where(CpsOrder.pay_time >= _as_utc(date_from))
if date_to is not None:
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)
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())
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]
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),
}
# 淘宝/京东无法对账 → 对账字段全 None(前端显示 "-")
_no_recon = {
"order_count": None, "settled_count": None, "canceled_count": None,
"gmv_cents": None, "est_commission_cents": None, "settled_commission_cents": None,
}
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)
# 未归群的历史美团 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
def group_click_timeseries(
db: Session, *, group_id: int, granularity: str,
start: datetime, end: datetime,
) -> list[dict]:
"""该群点击时序(北京时区分桶,granularity=day|hour)。
每桶:click_pv/uv(visit)+copy_pv/uv;UV=distinct(ip,ua)。**生成连续桶(无数据补 0)**,
让折线图 x 轴按所选范围铺满、不断裂(CPS 数据稀疏,补零是关键)。
"""
rows = db.execute(
select(CpsClick.clicked_at, CpsClick.event_type, CpsClick.ip, CpsClick.ua)
.where(CpsClick.group_id == group_id)
.where(CpsClick.clicked_at >= _as_utc(start))
.where(CpsClick.clicked_at <= _as_utc(end))
).all()
agg: dict = {} # bucket_key -> {vp, vu(set), cp, cu(set)}
for clicked_at, event_type, ip, ua in rows:
# naive(本地 SQLite 读回无 tz)按 UTC 兜底,再转北京——与 queries._as_utc 约定一致,
# 否则 astimezone 会把 UTC 瞬刻当系统本地时区解释,分桶整体偏移。
ca = clicked_at if clicked_at.tzinfo else clicked_at.replace(tzinfo=timezone.utc)
bj = ca.astimezone(_BJ_TZ)
key = bj.strftime("%Y-%m-%d") if granularity == "day" else bj.hour
b = agg.setdefault(key, {"vp": 0, "vu": set(), "cp": 0, "cu": set()})
uid = f"{ip or ''}|{ua or ''}"
if event_type == "copy":
b["cp"] += 1
b["cu"].add(uid)
else:
b["vp"] += 1
b["vu"].add(uid)
def _point(label: str, b: dict | None) -> dict:
return {
"label": label,
"click_pv": b["vp"] if b else 0,
"click_uv": len(b["vu"]) if b else 0,
"copy_pv": b["cp"] if b else 0,
"copy_uv": len(b["cu"]) if b else 0,
}
points: list[dict] = []
if granularity == "day":
cur = start.astimezone(_BJ_TZ).date()
last = end.astimezone(_BJ_TZ).date()
while cur <= last:
points.append(_point(cur.strftime("%m-%d"), agg.get(cur.strftime("%Y-%m-%d"))))
cur += timedelta(days=1)
else:
for h in range(24):
points.append(_point(f"{h:02d}:00", agg.get(h)))
return points
def group_order_daily(
db: Session, *, sid: str, start: datetime, end: datetime,
) -> dict[str, dict]:
"""美团群订单按天(北京时区,key=YYYY-MM-DD)聚合。口径与 group_stats._order_agg 一致:
取消(4)/风控(5)不计有效;结算只数 status=6。退款佣金取当天全部单的 refund_profit_cents 之和。
返回 {date: {order_count, canceled_count, gmv_cents, est_commission_cents,
refund_profit_cents, settled_commission_cents}};无单的天不在 dict 里(端点侧补零)。
"""
rows = (
db.execute(
select(CpsOrder)
.where(CpsOrder.sid == sid)
.where(CpsOrder.pay_time >= _as_utc(start))
.where(CpsOrder.pay_time <= _as_utc(end))
)
.scalars()
.all()
)
by_day: dict[str, list] = {}
for o in rows:
if not o.pay_time:
continue
pt = o.pay_time if o.pay_time.tzinfo else o.pay_time.replace(tzinfo=timezone.utc)
d = pt.astimezone(_BJ_TZ).strftime("%Y-%m-%d")
by_day.setdefault(d, []).append(o)
out: dict[str, dict] = {}
for d, items in by_day.items():
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]
out[d] = {
"order_count": len(valid),
"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),
"refund_profit_cents": sum(o.refund_profit_cents or 0 for o in items),
"settled_commission_cents": sum(o.commission_cents or 0 for o in settled),
}
return out
def group_wx_users(db: Session, *, group_id: int, limit: int = 200) -> list[dict]:
"""群内微信用户(首次来源 first_group_id=group_id)+ 每人在该群的 visit/copy 次数(领券画像)。
按首次进入倒序。copy 次数 = 该用户在该群点了几次「复制口令」(领券意向),visit = 进落地页次数。
"""
users = (
db.execute(
select(CpsWxUser)
.where(CpsWxUser.first_group_id == group_id)
.order_by(desc(CpsWxUser.first_seen))
.limit(limit)
)
.scalars()
.all()
)
if not users:
return []
openids = [u.openid for u in users]
stat: dict[str, dict] = {}
rows = db.execute(
select(CpsClick.openid, CpsClick.event_type, func.count())
.where(CpsClick.group_id == group_id)
.where(CpsClick.openid.in_(openids))
.group_by(CpsClick.openid, CpsClick.event_type)
).all()
for openid, event_type, cnt in rows:
s = stat.setdefault(openid, {"visit": 0, "copy": 0})
if event_type == "copy":
s["copy"] = cnt
else:
s["visit"] = cnt
return [
{
"openid": u.openid,
"nickname": u.nickname,
"headimgurl": u.headimgurl,
"first_seen": u.first_seen,
"visit_count": stat.get(u.openid, {}).get("visit", 0),
"copy_count": stat.get(u.openid, {}).get("copy", 0),
}
for u in users
]
+57
View File
@@ -109,6 +109,63 @@ def list_users(
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
def _attach_user_info(db: Session, records: list[ComparisonRecord]) -> None:
"""给每条比价记录瞬态挂 phone/nickname(非 DB 列,供 admin schema from_attributes 读)。"""
uids = {r.user_id for r in records}
if not uids:
return
rows = db.execute(
select(User.id, User.phone, User.nickname).where(User.id.in_(uids))
).all()
umap = {uid: (phone, nick) for uid, phone, nick in rows}
for r in records:
phone, nick = umap.get(r.user_id, (None, None))
r.phone = phone
r.nickname = nick
def list_comparison_records(
db: Session,
*,
user_id: int | None = None,
phone: str | None = None,
status: str | None = None,
business_type: str | None = None,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[ComparisonRecord], int | None, int]:
"""admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛,
offset 分页(创建时间倒序、id 兜底)。join User 取 phone/nickname 瞬态挂记录上。"""
stmt = select(ComparisonRecord)
if user_id is not None:
stmt = stmt.where(ComparisonRecord.user_id == user_id)
if phone:
stmt = stmt.where(
ComparisonRecord.user_id.in_(
select(User.id).where(User.phone.like(f"{phone}%"))
)
)
if status:
stmt = stmt.where(ComparisonRecord.status == status)
if business_type:
stmt = stmt.where(ComparisonRecord.business_type == business_type)
items, next_cursor, total = offset_paginate(
db, stmt,
(desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)),
limit=limit, cursor=cursor,
)
_attach_user_info(db, items)
return items, next_cursor, total
def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None:
"""admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。"""
rec = db.get(ComparisonRecord, record_id)
if rec is not None:
_attach_user_info(db, [rec])
return rec
def list_onboarding_devices(db: Session, *, limit: int = 500) -> list[dict]:
"""按设备(device_id, ANDROID_ID)聚合 onboarding_completion:每台设备走过引导的账号数 +
最近完成时间,按最近完成倒序。设备维度新手引导管理用。没走过引导的设备不在表里(本就会
+58
View File
@@ -0,0 +1,58 @@
"""admin 比价记录 debug:按 user_id / phone 查列表 + 单条详情(含 LLM 每次调用明细 / 原始 payload)。
只读。读权限默认即可(get_current_admin),不额外 require_role(查记录是基础 debug 能力)。
trace_url 无条件下发——admin 是内部 debug 工具,不走 C 端 user.debug_trace_enabled 权限闸。
"""
from __future__ import annotations
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 queries
from app.admin.schemas.common import CursorPage
from app.admin.schemas.comparison import AdminComparisonDetail, AdminComparisonListItem
router = APIRouter(
prefix="/admin/api/comparison-records",
tags=["admin-comparison"],
dependencies=[Depends(get_current_admin)],
)
@router.get(
"",
response_model=CursorPage[AdminComparisonListItem],
summary="比价记录列表(按 user_id/phone 筛 + 分页)",
)
def list_comparison_records(
db: AdminDb,
user_id: Annotated[int | None, Query()] = None,
phone: Annotated[str | None, Query(description="手机号前缀")] = None,
status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None,
business_type: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[AdminComparisonListItem]:
items, next_cursor, total = queries.list_comparison_records(
db, user_id=user_id, phone=phone, status=status,
business_type=business_type, limit=limit, cursor=cursor,
)
return CursorPage(
items=[AdminComparisonListItem.model_validate(r) for r in items],
next_cursor=next_cursor,
total=total,
)
@router.get(
"/{record_id}",
response_model=AdminComparisonDetail,
summary="比价记录详情(含 LLM 每次调用明细 + 原始 payload)",
)
def get_comparison_record(record_id: int, db: AdminDb) -> AdminComparisonDetail:
rec = queries.get_comparison_record(db, record_id)
if rec is None:
raise HTTPException(status_code=404, detail="记录不存在")
return AdminComparisonDetail.model_validate(rec)
+3
View File
@@ -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:
+498
View File
@@ -0,0 +1,498 @@
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 美团订单对账 + 统计。
平台:meituan(actId+sid 转链 + query_order 对账) / taobao(整段淘口令) / jd(链接)。
淘宝/京东无 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"
群/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可。
"""
from __future__ import annotations
import time
from datetime import datetime, timedelta, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
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.admin.schemas.cps import (
CpsActivityCreate,
CpsActivityOut,
CpsActivityUpdate,
CpsGroupCreate,
CpsGroupOut,
CpsGroupStat,
CpsGroupUpdate,
CpsOrderOut,
CpsReconcileResult,
CpsReferralLinkItem,
CpsReferralLinksOut,
CpsReferralLinksRequest,
CpsStatsOut,
)
from app.core import media
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",
tags=["admin-cps"],
dependencies=[Depends(get_current_admin)],
)
_VALID_PLATFORMS = {"meituan", "taobao", "jd"}
# ───────────── 群 ─────────────
@router.get("/groups", response_model=CursorPage[CpsGroupOut], summary="群列表")
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="新建群")
def create_group(
body: CpsGroupCreate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> CpsGroupOut:
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, 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={"name": group.name, "platforms": group.platforms, "sid": group.sid},
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="群不存在")
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, 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,
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.delete("/groups/{group_id}", summary="删除群")
def delete_group(
group_id: int,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> dict:
group = cps_repo.get_group(db, group_id)
if group is None:
raise HTTPException(status_code=404, detail="群不存在")
write_audit(
db, admin, action="cps.group.delete", target_type="cps_group", target_id=group_id,
detail={"name": group.name, "sid": group.sid, "platforms": list(group.platforms or [])},
ip=get_client_ip(request), commit=False,
)
cps_repo.delete_group(db, group, commit=False)
db.commit()
return {"ok": True}
# ───────────── 活动 ─────────────
@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 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}")
if body.platform == "taobao" and not body.image_url:
raise HTTPException(status_code=400, detail="淘宝活动需提供落地页图(上传或选已有)")
activity = cps_repo.create_activity(
db, name=body.name, platform=body.platform, act_id=body.act_id,
product_view_sign=body.product_view_sign, payload=body.payload,
image_url=media.to_abs_media_url(body.image_url), 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, "platform": activity.platform},
ip=get_client_ip(request), commit=False,
)
db.commit()
db.refresh(activity)
return CpsActivityOut.model_validate(activity)
@router.patch("/activities/{activity_id}", response_model=CpsActivityOut, summary="编辑活动")
def update_activity(
activity_id: int,
body: CpsActivityUpdate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> CpsActivityOut:
activity = cps_repo.get_activity(db, activity_id)
if activity is None:
raise HTTPException(status_code=404, detail="活动不存在")
# 用「合并后最终值」校验平台必填项(同新建口径),允许只改部分字段
platform = body.platform or activity.platform
act_id = body.act_id if body.act_id is not None else activity.act_id
pvs = body.product_view_sign if body.product_view_sign is not None else activity.product_view_sign
payload = body.payload if body.payload is not None else activity.payload
image_url = body.image_url if body.image_url is not None else activity.image_url
if platform == "meituan":
if not act_id and not pvs:
raise HTTPException(status_code=400, detail="美团活动需填 actId 或 productViewSign")
else: # taobao / jd
if not payload:
label = "淘口令" if platform == "taobao" else "推广链接"
raise HTTPException(status_code=400, detail=f"{platform} 活动需填{label}")
if platform == "taobao" and not image_url:
raise HTTPException(status_code=400, detail="淘宝活动需提供落地页图(上传或选已有)")
cps_repo.update_activity(
db, activity, name=body.name, platform=body.platform, act_id=body.act_id,
product_view_sign=body.product_view_sign, payload=body.payload,
image_url=media.to_abs_media_url(body.image_url) if body.image_url else None,
remark=body.remark, status=body.status, commit=False,
)
write_audit(
db, admin, action="cps.activity.update", target_type="cps_activity", target_id=activity_id,
detail=body.model_dump(exclude_none=True), ip=get_client_ip(request), commit=False,
)
db.commit()
db.refresh(activity)
return CpsActivityOut.model_validate(activity)
@router.post("/upload-image", summary="上传活动落地页图(返回绝对 URL)")
async def upload_activity_image(
admin: Annotated[AdminUser, Depends(require_role("operator"))],
file: UploadFile = File(..., description="落地页图(jpeg/png/webp,≤5MB)"),
) -> dict:
data = await file.read()
try:
url = media.save_cps_image(admin.id, data)
except media.MediaError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"url": media.to_abs_media_url(url)}
@router.get("/activity-images", summary="已有活动落地页图(供新建复用选择)")
def activity_images(db: AdminDb) -> dict:
return {"images": cps_repo.list_activity_images(db)}
@router.delete("/activities/{activity_id}", summary="删除活动")
def delete_activity(
activity_id: int,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> dict:
activity = cps_repo.get_activity(db, activity_id)
if activity is None:
raise HTTPException(status_code=404, detail="活动不存在")
write_audit(
db, admin, action="cps.activity.delete", target_type="cps_activity", target_id=activity_id,
detail={"name": activity.name, "platform": activity.platform},
ip=get_client_ip(request), commit=False,
)
cps_repo.delete_activity(db, activity, commit=False)
db.commit()
return {"ok": True}
# ───────────── 生成链接(批量,按平台分支) ─────────────
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,
) -> CpsReferralLinksOut:
group = cps_repo.get_group(db, body.group_id)
if group is None:
raise HTTPException(status_code=404, detail="群不存在")
group_platforms = set(group.platforms or [])
base = settings.CPS_REDIRECT_BASE.rstrip("/")
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,
))
write_audit(
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()
return CpsReferralLinksOut(group_name=group.name, results=items)
# ───────────── 订单对账 ─────────────
@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 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),
)
@router.get("/groups/{group_id}/timeseries", summary="群点击时序(折线图:天/小时级 PV/UV/复制)")
def group_timeseries(
group_id: int,
db: AdminDb,
granularity: Annotated[str, Query(pattern="^(day|hour)$")] = "day",
days: Annotated[int, Query(ge=1, le=30)] = 7,
date: Annotated[str | None, Query()] = None, # hour 粒度看哪天(YYYY-MM-DD 北京),默认今天
) -> dict:
group = cps_repo.get_group(db, group_id)
if group is None:
raise HTTPException(status_code=404, detail="群不存在")
bj = timezone(timedelta(hours=8))
now_bj = datetime.now(bj)
if granularity == "hour":
if date:
try:
day0 = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=bj)
except ValueError as e:
raise HTTPException(status_code=400, detail="date 格式应为 YYYY-MM-DD") from e
else:
day0 = now_bj
start = day0.replace(hour=0, minute=0, second=0, microsecond=0)
end = start + timedelta(days=1) - timedelta(microseconds=1)
else:
start = (now_bj - timedelta(days=days - 1)).replace(hour=0, minute=0, second=0, microsecond=0)
end = now_bj
points = cps_repo.group_click_timeseries(
db, group_id=group_id, granularity=granularity, start=start, end=end
)
return {
"group_id": group.id,
"group_name": group.name,
"granularity": granularity,
"points": points,
}
@router.get("/groups/{group_id}/daily", summary="群每天明细大表格(点击+订单按天合并)")
def group_daily(
group_id: int,
db: AdminDb,
days: Annotated[int, Query(ge=1, le=90)] = 7,
) -> dict:
group = cps_repo.get_group(db, group_id)
if group is None:
raise HTTPException(status_code=404, detail="群不存在")
bj = timezone(timedelta(hours=8))
now_bj = datetime.now(bj)
start = (now_bj - timedelta(days=days - 1)).replace(hour=0, minute=0, second=0, microsecond=0)
end = now_bj
# 点击侧:连续 N 天补零,顺序 = start..end(与下面 while 同序,按 index 对齐避免跨年 MM-DD 碰撞)
click_points = cps_repo.group_click_timeseries(
db, group_id=group_id, granularity="day", start=start, end=end
)
# 订单侧:仅美团群(有 sid)有对账;淘宝/京东订单列返回 None(前端显示 "-")
is_meituan = "meituan" in (group.platforms or []) and bool(group.sid)
order_daily = (
cps_repo.group_order_daily(db, sid=group.sid, start=start, end=end) if is_meituan else {}
)
_ORDER_KEYS = (
"order_count", "canceled_count", "gmv_cents",
"est_commission_cents", "refund_profit_cents", "settled_commission_cents",
)
rows = []
cur = start.astimezone(bj).date()
last = end.astimezone(bj).date()
idx = 0
while cur <= last:
cp = click_points[idx] if idx < len(click_points) else None
row = {
"date": cur.strftime("%m-%d"),
"click_pv": cp["click_pv"] if cp else 0,
"click_uv": cp["click_uv"] if cp else 0,
"copy_pv": cp["copy_pv"] if cp else 0,
}
if is_meituan:
od = order_daily.get(cur.strftime("%Y-%m-%d"))
row.update({k: (od[k] if od else 0) for k in _ORDER_KEYS})
else:
row.update({k: None for k in _ORDER_KEYS})
rows.append(row)
idx += 1
cur += timedelta(days=1)
return {
"group_id": group.id,
"group_name": group.name,
"is_meituan": is_meituan,
"days": days,
"rows": rows,
}
@router.get("/groups/{group_id}/wx-users", summary="群内微信用户(领券画像:头像/昵称/领券次数)")
def group_wx_users(group_id: int, db: AdminDb) -> dict:
group = cps_repo.get_group(db, group_id)
if group is None:
raise HTTPException(status_code=404, detail="群不存在")
return {"users": cps_repo.group_wx_users(db, group_id=group_id)}
+12 -4
View File
@@ -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,
+70
View File
@@ -0,0 +1,70 @@
"""admin 比价记录 debug 页 schema(独立于 C 端 app/schemas/compare_record:admin 看任意用户、
无 debug_trace 权限闸、展示全量 debug 字段)。phone/nickname 由 queries 瞬态挂在 ORM 实例上。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class AdminComparisonListItem(BaseModel):
"""列表项:概要 + debug 维度概览(不含大字段 raw_payload/llm_calls/明细数组)。"""
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int
phone: str | None = None # join User 瞬态(非 DB 列)
nickname: str | None = None # join User 瞬态
business_type: str
trace_id: str
# admin 是 debug 工具,无条件下发 trace_url(不看 user.debug_trace_enabled)
trace_url: str | None = None
status: str
information: str | None = None
store_name: str | None = None
source_platform_name: str | None = None
best_platform_name: str | None = None
source_price_cents: int | None = None
best_price_cents: int | None = None
saved_amount_cents: int | None = None
# debug 概览
total_ms: int | None = None
step_count: int | None = None
llm_call_count: int | None = None
retry_count: int | None = None
device_model: str | None = None
rom_vendor: str | None = None
rom_name: str | None = None
android_version: str | None = None
app_version: str | None = None
created_at: datetime
class AdminComparisonDetail(AdminComparisonListItem):
"""详情:概要 + 全量明细(逐平台对比 / LLM 每次调用 / 原始 payload)。"""
source_platform_id: str | None = None
source_package: str | None = None
best_platform_id: str | None = None
best_deeplink: str | None = None
is_source_best: bool | None = None
total_dish_count: int | None = None
skipped_dish_count: int | None = None
device_id: str | None = None
items: list = []
comparison_results: list = [] # 逐平台对比(价格/rank/coupon/打烊...)
skipped_dish_names: list = []
# 全量环境
device_manufacturer: str | None = None
rom_version: int | None = None
android_sdk: int | None = None
app_version_code: int | None = None
source_app_version: str | None = None
longitude: float | None = None
latitude: float | None = None
# 每次 LLM 调用明细 [{scene,model,input_messages,output,usage,latency_ms,error}]
llm_calls: list | None = None
# 原始上报全量;「卡在哪一步」从 raw_payload.platform_results[*].status 读
# (store_not_found/items_not_found/below_minimum/unsupported = 卡在 找店/加菜/起送/读价)。
raw_payload: dict | None = None
+1 -1
View File
@@ -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
+155
View File
@@ -0,0 +1,155 @@
"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。
平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接,只统计点击)。
对账类字段对淘宝/京东为 None → 前端显示 "-"(无法对账)。
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
# ───────────── 群 ─────────────
class CpsGroupOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
sid: str | None = None # 仅美团群有
name: str
platforms: list[str] = Field(default_factory=list)
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)
# 该群发哪些平台,至少选一个
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)
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)
# ───────────── 活动 ─────────────
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
payload: str | None = None # 淘宝淘口令 / 京东链接
image_url: str | None = None # 淘宝落地页图(绝对 URL)
status: str
remark: str | None = None
created_at: datetime
class CpsActivityCreate(BaseModel):
name: str = Field(min_length=1, max_length=128)
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)
# 淘宝落地页图(绝对/相对 URL;淘宝必填,由 router 校验)
image_url: str | None = Field(default=None, max_length=512)
remark: str | None = Field(default=None, max_length=256)
class CpsActivityUpdate(BaseModel):
"""编辑活动(PATCH):全字段可选,只更新传入的非 None 项;最终平台对应字段由 router 校验。"""
name: str | None = Field(default=None, min_length=1, max_length=128)
platform: str | None = Field(default=None, 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)
payload: str | None = Field(default=None, max_length=4096)
image_url: str | None = Field(default=None, max_length=512)
remark: str | None = Field(default=None, max_length=256)
status: str | None = Field(default=None, pattern=r"^(active|archived)$")
# ───────────── 生成链接(批量) ─────────────
class CpsReferralLinksRequest(BaseModel):
group_id: int
activity_ids: list[int] = Field(min_length=1)
class CpsReferralLinkItem(BaseModel):
activity_id: int
activity_name: str
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
# ───────────── 订单明细 ─────────────
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
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
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):
groups: list[CpsGroupStat]
total_order_count: int
total_est_commission_cents: int
total_settled_commission_cents: int
+58
View File
@@ -0,0 +1,58 @@
"""最新 App 版本写入端点(发布流程 → app-server)。
发车流程出 APK 后,把版本号 / 下载链接 / sha256 等 POST 到这里,落 app_config(key=latest_app_version)。
客户端再 GET /api/v1/platform/app-version 读取做 OTA 检查更新。**不是给客户端的接口**:
不走用户 JWT,靠 server 间共享密钥头 `X-Internal-Secret` 校验(== settings.INTERNAL_API_SECRET)。
密钥未配置(默认空)时直接 503,避免裸奔的写端点。也是应急改版本信息(紧急下线/改 apk_url)的入口。
"""
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 app_config
from app.schemas.platform import AppVersionOut, AppVersionWriteIn
logger = logging.getLogger("shagua.internal.app_version")
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(
"/app-version",
response_model=AppVersionOut,
summary="写最新 App 版本(发布流程→app-server,落 app_config)",
)
def write_app_version(
payload: AppVersionWriteIn,
db: DbSession,
x_internal_secret: Annotated[str | None, Header()] = None,
) -> AppVersionOut:
_check_secret(x_internal_secret)
data = payload.model_dump()
app_config.set_app_version(db, data)
logger.info(
"app_version set code=%d name=%s url=%s",
payload.latest_version_code, payload.latest_version_name, payload.apk_url,
)
return AppVersionOut(**data)
+1 -1
View File
@@ -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")
+8
View File
@@ -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")
+44 -5
View File
@@ -15,10 +15,13 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Query, status
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status
from app.api.deps import CurrentUser, DbSession
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
from app.repositories import comparison as crud_compare
from app.services.pricebot_llm_calls import fetch_llm_calls
from app.schemas.compare_record import (
CompareStatsOut,
ComparisonRecordCreatedOut,
@@ -39,11 +42,18 @@ router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"])
summary="上报一次比价结果(幂等)",
)
def report_record(
payload: ComparisonRecordIn, user: CurrentUser, db: DbSession
payload: ComparisonRecordIn,
user: CurrentUser,
db: DbSession,
background_tasks: BackgroundTasks,
) -> ComparisonRecordCreatedOut:
rec = crud_compare.upsert_record(db, user_id=user.id, payload=payload)
# LLM 调用明细异步回填:同机拉 pricebot llm_calls 最长 5s 且是 best-effort,放后台
# 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用
# 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。
background_tasks.add_task(_backfill_llm_calls, rec.id, rec.trace_id)
logger.info(
"compare record user=%s trace=%s biz=%s status=%s saved=%s",
"compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)",
user.id,
rec.trace_id,
rec.business_type,
@@ -53,6 +63,28 @@ def report_record(
return ComparisonRecordCreatedOut(id=rec.id)
def _backfill_llm_calls(record_id: int, trace_id: str) -> None:
"""后台回填本次比价的 LLM 调用明细 + 派生 llm_call_count/retry_count。
独立 DB session(请求 session 此时已关);拉取/写库失败只 log,绝不影响已落库的上报。"""
calls = fetch_llm_calls(trace_id)
if not calls:
return
db = SessionLocal()
try:
rec = db.get(ComparisonRecord, record_id)
if rec is None:
return
rec.llm_calls = calls
rec.llm_call_count = len(calls)
rec.retry_count = sum(1 for c in calls if c.get("error"))
db.commit()
logger.info("backfill llm_calls trace=%s n=%d", trace_id, len(calls))
except Exception as e: # noqa: BLE001 best-effort
logger.warning("backfill llm_calls failed trace=%s: %s", trace_id, e)
finally:
db.close()
@router.get(
"/stats",
response_model=CompareStatsOut,
@@ -73,13 +105,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)
+208
View File
@@ -0,0 +1,208 @@
"""CPS 群发短链落地:用户点 /c/{code} → 记点击 → 按平台 302 跳 或 返回淘宝落地页。
公网无鉴权(群里任何人点都要能跳/能领)。记点击失败绝不影响用户。
- 美团/京东:记 visit + 302 跳 target(美团短链 / 京东链接)
- 淘宝:记 visit + 返回 H5 落地页(整段淘口令复制按钮);点"复制口令"→ POST /c/{code}/copy 记 copy
"""
from __future__ import annotations
import html
import json
import logging
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.core import media
from app.core.config import settings
from app.db.session import get_db
from app.integrations import wx_oauth
from app.models.cps_activity import CpsActivity
from app.models.cps_link import CpsLink
from app.repositories import cps_link as cps_link_repo
from app.repositories import cps_wx_user as cps_wx_user_repo
logger = logging.getLogger("shagua.cps_redirect")
router = APIRouter(tags=["cps-redirect"])
# code 不存在/失效时的兜底落地
_FALLBACK_URL = "https://www.meituan.com/"
# 淘宝活动未设图时的兜底主视觉(存量已回填,基本只在老 link/异常时触发)
_DEFAULT_TAOBAO_IMAGE = "/media/taobao_landing.jpg"
# 微信网页授权拿到的用户标识 cookie(种在 coupon 域,30 天免重复授权)
_WX_OPENID_COOKIE = "wx_openid"
_WX_UINFO_COOKIE = "wx_uinfo" # "1" = 已拿过昵称头像(userinfo),点领券不再跳授权
_COOKIE_MAX_AGE = 30 * 86400
def _is_wechat(request: Request) -> bool:
"""是否微信内置浏览器(网页授权只在微信内有意义,外部浏览器不跳授权)。"""
return "micromessenger" in (request.headers.get("user-agent", "").lower())
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
def _record(
db: Session, link: CpsLink, request: Request, event_type: str, openid: str | None = None
) -> None:
try:
cps_link_repo.record_click(
db, link=link, ip=_client_ip(request),
ua=request.headers.get("user-agent"), event_type=event_type, openid=openid,
)
except Exception:
pass # 记点击失败不阻断
@router.get("/MP_verify_F7wnRQ7xPbhVOWC8.txt", include_in_schema=False)
def wx_mp_domain_verify() -> PlainTextResponse:
"""微信「网页授权域名」归属校验文件。coupon.shaguabijia.com 全反代 app-server,
微信请求 coupon.shaguabijia.com/MP_verify_xxx.txt 验证域名归属;内容=文件名核心串。
放域名根目录(本端点即根路径),为微信网页授权(拿 openid)接入做准备。"""
return PlainTextResponse("F7wnRQ7xPbhVOWC8")
@router.get("/c/{code}", summary="群发短链落地(微信授权拿 openid + 记点击 + 跳转/淘宝落地页)")
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)
openid = request.cookies.get(_WX_OPENID_COOKIE)
# 微信内 + 还没拿到 openid + 配了服务号 → 先 base 静默授权拿 openid,再 302 回本页。
# 非微信 / 已有 cookie / 未配服务号 → 直接走原逻辑(openid 可能为 None,绝不阻断领券)。
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)
_record(db, link, request, "visit", openid=openid)
if link.platform == "taobao":
activity = db.get(CpsActivity, link.activity_id)
image_url = (activity.image_url if activity else None) or _DEFAULT_TAOBAO_IMAGE
has_uinfo = request.cookies.get(_WX_UINFO_COOKIE) == "1"
return HTMLResponse(
_taobao_landing_html(link.target_url, image_url, code, openid, has_uinfo)
)
# 美团短链 / 京东链接:直接 302 跳
return RedirectResponse(link.target_url, status_code=302)
@router.get("/wx/oauth/cb", include_in_schema=False)
def wx_oauth_cb(code: str, state: str, db: Session = Depends(get_db)):
"""微信网页授权回调。state='base:{原code}'(静默拿 openid) 或 'uinfo:{原code}'(补昵称头像)。
换 openid(+userinfo)→ upsert 用户 → 种 cookie → 302 回落地页。任何失败兜底回落地页,
绝不阻断用户领券。"""
kind, _, orig_code = state.partition(":")
try:
token = wx_oauth.exchange_code(code) # {openid, access_token, scope, ...}
openid = token["openid"]
link = cps_link_repo.get_by_code(db, orig_code)
group_id = link.group_id if link else None
nickname = headimgurl = unionid = None
if kind == "uinfo" and "userinfo" in (token.get("scope") or ""):
info = wx_oauth.get_userinfo(token["access_token"], openid)
nickname, headimgurl, unionid = (
info.get("nickname"), info.get("headimgurl"), info.get("unionid"),
)
cps_wx_user_repo.upsert(
db, openid=openid, code=orig_code, group_id=group_id,
nickname=nickname, headimgurl=headimgurl, unionid=unionid,
)
except Exception:
logger.exception("[wx_oauth] callback failed state=%s", state)
return RedirectResponse(f"/c/{orig_code}" if orig_code else _FALLBACK_URL, status_code=302)
# userinfo 授权回来带 ?authed=1,落地页据此自动触发复制(把"点领券→授权→复制"衔接为一步)
target = f"/c/{orig_code}" + ("?authed=1" if kind == "uinfo" else "")
resp = RedirectResponse(target, status_code=302)
resp.set_cookie(_WX_OPENID_COOKIE, openid, max_age=_COOKIE_MAX_AGE, httponly=True, samesite="lax")
if nickname:
resp.set_cookie(_WX_UINFO_COOKIE, "1", max_age=_COOKIE_MAX_AGE, samesite="lax")
return resp
@router.post("/c/{code}/copy", summary="淘宝落地页点了「复制口令」(记 copy 事件,带 openid)")
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", openid=request.cookies.get(_WX_OPENID_COOKIE))
return {"ok": True}
def _taobao_landing_html(
token: str, image_url: str, code: str, openid: str | None, has_uinfo: bool
) -> str:
"""淘宝落地页 H5(主视觉图按活动传入,复制淘口令按钮在 75% 屏高)。
image_url 经 html.escape 嵌入 <img src>(防注入);token 经 json.dumps 安全嵌入 JS。
uinfo_url:已有 openid 但还没拿过昵称头像时,生成 userinfo 授权链接 —— 用户点「领券」
时先跳它(交互触发,避免微信快照页),授权回来自动复制。已拿过 / 无 openid 则为空,直接复制。
"""
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_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 (
_TAOBAO_HTML
.replace("__IMAGE_URL__", safe_img)
.replace("__UINFO_URL__", json.dumps(uinfo_url))
.replace("__TOKEN_JS__", json.dumps(token))
)
_TAOBAO_HTML = """<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>淘宝闪购 · 天天领红包</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
body{font-family:-apple-system,"PingFang SC",sans-serif;background:#fff0ef;color:#333;min-height:100vh}
.hero{display:block;width:100%}
.btn-wrap{position:fixed;left:0;right:0;top:75vh;padding:0 20px}
.btn{display:block;width:100%;background:linear-gradient(90deg,#ff5b5b,#ff3b3b);color:#fff;font-size:19px;font-weight:800;text-align:center;padding:16px;border:none;border-radius:30px;box-shadow:0 6px 16px rgba(255,59,59,.4)}
.btn:active{transform:scale(.98)}
.toast{position:fixed;left:50%;top:42%;transform:translate(-50%,-50%);background:rgba(0,0,0,.82);color:#fff;padding:12px 22px;border-radius:10px;font-size:15px;opacity:0;transition:opacity .25s;pointer-events:none;z-index:99;white-space:nowrap}
.toast.show{opacity:1}
</style>
</head>
<body>
<img class="hero" src="__IMAGE_URL__" alt="淘宝闪购 天天领红包">
<div class="btn-wrap"><button class="btn" onclick="copyToken()">复制口令去淘宝领红包</button></div>
<div class="toast" id="toast"></div>
<script>
var TOKEN = __TOKEN_JS__;
var UINFO_URL = __UINFO_URL__; // 非空=还没拿昵称头像,点领券先跳它补(授权回来自动复制)
function showToast(m){var t=document.getElementById('toast');t.textContent=m;t.classList.add('show');setTimeout(function(){t.classList.remove('show')},2200)}
function reportCopy(){try{fetch(location.pathname+'/copy',{method:'POST',keepalive:true})}catch(e){}}
function done(){showToast('复制成功!打开淘宝即可领取');reportCopy()}
function fallback(){
var ta=document.createElement('textarea');ta.value=TOKEN;ta.style.position='fixed';ta.style.opacity='0';
document.body.appendChild(ta);ta.focus();ta.select();
try{document.execCommand('copy');done()}catch(e){showToast('复制失败,请长按手动复制')}
document.body.removeChild(ta);
}
function doCopy(){
if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(TOKEN).then(done).catch(fallback)}
else{fallback()}
}
function copyToken(){
// 第一次领券且还没授权过昵称头像:先跳 userinfo 授权(用户点击=交互触发,避免快照页),
// 授权回来 ?authed=1 自动复制;已授权过/无 openid 则直接复制。
if(UINFO_URL){location.href=UINFO_URL;return}
doCopy();
}
// userinfo 授权回流(?authed=1):自动复制,把"点领券→授权→复制"衔接成一步无感
if(location.search.indexOf('authed=1')>=0){doCopy()}
</script>
</body>
</html>"""
+28 -1
View File
@@ -2,6 +2,7 @@
路由前缀 `/api/v1/platform`:
GET /stats 首页三统计(帮助用户 / 完成比价 / 累计节省),按运营后台配的模式算。
GET /flags 客户端运营 feature flag(比价/领券期广告开关等),客户端拉取后缓存。
展示模式(real/manual/random,每指标独立)与计算逻辑见 app/repositories/ops_stat.py。
"""
@@ -12,9 +13,16 @@ 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,
AppVersionOut,
PlatformStatsOut,
SavingsFeedItem,
SavingsFeedOut,
)
logger = logging.getLogger("shagua.platform")
@@ -35,3 +43,22 @@ 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")),
)
@router.get("/app-version", response_model=AppVersionOut, summary="最新 App 版本(OTA 检查更新,不鉴权)")
def app_version(db: DbSession) -> AppVersionOut:
"""客户端启动 / 手动检查更新时拉取。不鉴权:版本信息非敏感,且检查更新可能在登录前。
用 latest_version_code 与本机 versionCode 比;未配置(返回默认 0)时客户端视为已是最新。"""
data = app_config.get_app_version(db)
if not data:
return AppVersionOut()
return AppVersionOut(**data)
+49 -3
View File
@@ -95,6 +95,25 @@ 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 = ""
# 落地页微信网页授权【总开关】。默认关:服务号认证审核中/未就绪时,落地页走原逻辑
# (不跳授权、不拿 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 一致。
@@ -136,8 +155,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 +171,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
@@ -190,6 +229,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 = ""
+20 -1
View File
@@ -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 控制。关掉只停自动扫描,"
"提现页「批量对账」手动按钮不受影响。"
),
},
}
+17
View File
@@ -67,6 +67,23 @@ def save_report_image(user_id: int, data: bytes) -> str:
return _save_image("price_report", user_id, data)
def save_cps_image(admin_id: int, data: bytes) -> str:
"""保存 CPS 活动落地页图,返回相对 URL(`/media/cps/<file>`)。admin_id 入文件名便于追溯。"""
return _save_image("cps", admin_id, data)
def to_abs_media_url(rel_url: str | None) -> str | None:
"""相对 `/media/...` → 基于 CPS_REDIRECT_BASE 的绝对 URL(CPS 落地页域名,跨域可访问)。
已是绝对 URL / / base 未配(dev) 时原样返回CPS 活动图入库前转绝对,使落地页
(coupon ) admin-web(另一域, serve /media)都能直接 <img src> 加载
"""
if not rel_url or rel_url.startswith(("http://", "https://")):
return rel_url
base = settings.CPS_REDIRECT_BASE.rstrip("/")
return f"{base}{rel_url}" if base else rel_url
def delete_avatar(url: str | None) -> None:
"""删除本服务托管的旧头像文件;外部 URL(如微信头像)或空值不处理。"""
prefix = f"{settings.MEDIA_URL_PREFIX}/avatars/"
+17 -4
View File
@@ -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")
+34
View File
@@ -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
File diff suppressed because it is too large Load Diff
+39 -2
View File
@@ -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)
+10
View File
@@ -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)
+75
View File
@@ -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
+5
View File
@@ -20,6 +20,8 @@ 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.app_version import router as internal_app_version_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
@@ -106,7 +108,10 @@ app.include_router(report_router)
# 内部(server→server)端点:pricebot 上报价格观测 / 店铺映射,靠共享密钥头校验,不对客户端开放。
app.include_router(internal_price_router)
app.include_router(internal_store_router)
app.include_router(internal_app_version_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)
+5
View File
@@ -6,6 +6,11 @@ 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.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,
+2 -2
View File
@@ -4,8 +4,8 @@
由客户端生成,并通过激励视频 extra 透传给 S2S 回调,用于把"展示 eCPM"
"奖励完成"绑定没有 session id 的旧上报仍可作为按天对账补充
`ecpm_raw` 原样存客户端上报的字符串eCPM 单位( / )截至 2026-05-31 尚未最终确认,
确认后再加一列解析好的数值;在此之前对账按"待定单位"处理
`ecpm_raw` 原样存客户端上报的字符串单位是穿山甲 getEcpm 官方口径/千次展示
(非元!),对账由 `core/rewards.parse_ecpm_fen` 解析`parse_ecpm_yuan` ÷100 转元;本列只存原值
"""
from __future__ import annotations
+27
View File
@@ -18,6 +18,7 @@ from sqlalchemy import (
JSON,
Boolean,
DateTime,
Float,
ForeignKey,
Index,
Integer,
@@ -101,6 +102,32 @@ class ComparisonRecord(Base):
# 客户端上报的原始 payload(calibration + done.params 全量),未来取数兜底
raw_payload: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
# ===== debug 维度(客户端上报;旧记录 / 未发版客户端为 None)=====
# 设备环境:无障碍比价高度依赖机型/ROM,这是排"某机型跑不通"类问题的头号线索
device_model: Mapped[str | None] = mapped_column(String(64), nullable=True)
device_manufacturer: Mapped[str | None] = mapped_column(String(64), nullable=True)
rom_vendor: Mapped[str | None] = mapped_column(String(32), nullable=True) # vivo/OPPO/Xiaomi/HUAWEI/HONOR/samsung
rom_name: Mapped[str | None] = mapped_column(String(32), nullable=True) # OriginOS/ColorOS/HyperOS/MIUI/EMUI/HarmonyOS...
rom_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
android_version: Mapped[str | None] = mapped_column(String(16), nullable=True) # Build.VERSION.RELEASE
android_sdk: Mapped[int | None] = mapped_column(Integer, nullable=True) # Build.VERSION.SDK_INT
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) # 我们 app versionName
app_version_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 被操控的源平台 App 版本(美团/淘宝/京东),排"平台 App 改版导致 UI 适配失效"用
source_app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 比价时设备定位(影响淘宝 deeplink / 距离匹配)
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
# ===== 性能 / 过程统计(debug 用)=====
total_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) # 客户端整场比价墙钟耗时
step_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 客户端 agent loop 总步数
llm_call_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 本次 LLM 调用次数(server 从 llm_calls 算)
retry_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # LLM 失败重试次数(server 从 llm_calls error 算)
# 每次 LLM 调用明细 [{scene,model,input_messages,output,usage,latency_ms,error}];
# server 收上报后按 trace_id 同机拉 pricebot 落库(见 compare_record 端点)。旧记录/未采集为 None。
llm_calls: Mapped[list | None] = mapped_column(_JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
+38
View File
@@ -0,0 +1,38 @@
"""CPS 可推广活动池(cps_activity)。
运营预存常推的活动(),生成链接时选它,省得每次记 actId当前只接美团:
`act_id` = 美团联盟我要推广-活动推广第一列的物料 ID`platform` 预留,
淘宝/京东时复用本表(各自的活动标识)
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text, 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)
# 淘宝:整段淘口令文本(落地页原样复制);京东:推广链接(落地页 302 跳)。美团不用这个(用 act_id)。
payload: Mapped[str | None] = mapped_column(Text, nullable=True)
# 淘宝落地页主视觉图(绝对 URL,基于 CPS_REDIRECT_BASE)。运营建活动时上传/选已有,落地页按活动展示。
image_url: Mapped[str | None] = mapped_column(String(512), 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"<CpsActivity id={self.id} platform={self.platform} name={self.name!r}>"
+37
View File
@@ -0,0 +1,37 @@
"""CPS 推广群(cps_group)。
每个微信群一条记录,`sid` 是美团联盟二级渠道追踪位(get_referral_link / query_order
共用):发券时塞进推广链接,订单回来按 sid 归群对账美团限制 sid 仅字母+数字 64
"""
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 CpsGroup(Base):
__tablename__ = "cps_group"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 渠道标识。仅美团需要(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
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"<CpsGroup id={self.id} sid={self.sid!r} name={self.name!r}>"
+59
View File
@@ -0,0 +1,59 @@
"""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)
# 仅美团 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 跳) / 淘宝整段淘口令文本(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
)
def __repr__(self) -> str: # pragma: no cover
return f"<CpsLink id={self.id} code={self.code!r} sid={self.sid!r}>"
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 | 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)
# 微信网页授权拿到的用户标识(非微信/未授权为空),做用户级群统计
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
)
def __repr__(self) -> str: # pragma: no cover
return f"<CpsClick id={self.id} link_id={self.link_id} group_id={self.group_id}>"
+63
View File
@@ -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"<CpsOrder id={self.id} order_id={self.order_id!r} "
f"sid={self.sid!r} status={self.mt_status} profit_cents={self.commission_cents}>"
)
+42
View File
@@ -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"<CpsWxUser id={self.id} openid={self.openid!r} nickname={self.nickname!r}>"
+2 -1
View File
@@ -1,7 +1,8 @@
"""签到记录表。
每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次
- cycle_day: 1..14,14 天循环里今天落在第几档,决定发多少金币;断签后重置回 1
- cycle_day: 1..7,7 天循环里今天落在第几档,决定发多少金币;断签后重置回 1
(周期长度 = rewards.SIGNIN_CYCLE_LEN,2026-06 14 天改 7 天一轮)
- streak: 连续签到天数(不封顶),用于"已连续签到 N 天"展示;断签后重置回 1
"""
from __future__ import annotations
+4
View File
@@ -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")
+28
View File
@@ -63,3 +63,31 @@ def list_all(db: Session) -> list[dict]:
}
)
return out
# ── 最新 App 版本(OTA 检查更新)──────────────────────────────────────────────
# 版本信息不是"运营可配置项",不进 CONFIG_DEFS:它由发布流程(发车)/应急整组写入、客户端读取做
# OTA,语义是"发布系统状态"而非"运营调参"。故走下面专用读写,直接操作 AppConfig 表(复用表不复用 repo)。
APP_VERSION_KEY = "latest_app_version"
def get_app_version(db: Session) -> dict | None:
"""读最新 App 版本信息(OTA)。无记录返回 None(客户端视为无更新)。"""
row = db.get(AppConfig, APP_VERSION_KEY)
return row.value if row is not None else None
def set_app_version(db: Session, data: dict, *, commit: bool = True) -> AppConfig:
"""整组原子覆盖最新版本信息(发布流程/应急写,非 admin,故 updated_by_admin_id 留空)。"""
row = db.get(AppConfig, APP_VERSION_KEY)
if row is None:
row = AppConfig(key=APP_VERSION_KEY, value=data, updated_by_admin_id=None)
db.add(row)
else:
row.value = data
if commit:
db.commit()
db.refresh(row)
else:
db.flush()
return row
+15
View File
@@ -90,6 +90,21 @@ def upsert_record(
items=[it.model_dump(exclude_none=True) for it in payload.items],
comparison_results=[r.model_dump() for r in payload.comparison_results],
skipped_dish_names=list(payload.skipped_dish_names),
# 客户端环境 / 性能(debug,客户端上报;旧客户端为 None)
device_model=payload.device_model,
device_manufacturer=payload.device_manufacturer,
rom_vendor=payload.rom_vendor,
rom_name=payload.rom_name,
rom_version=payload.rom_version,
android_version=payload.android_version,
android_sdk=payload.android_sdk,
app_version=payload.app_version,
app_version_code=payload.app_version_code,
source_app_version=payload.source_app_version,
longitude=payload.longitude,
latitude=payload.latitude,
total_ms=payload.total_ms,
step_count=payload.step_count,
raw_payload=payload.model_dump(),
**derived,
)
+96
View File
@@ -0,0 +1,96 @@
"""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 | None, 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,
event_type: str = "visit", openid: str | None = None,
) -> None:
"""记一条点击。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), openid=openid,
))
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, 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)
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_visit: dict[int, set] = {}
seen_copy: dict[int, set] = {}
for c in clicks:
a = agg.setdefault(c.group_id, {"pv": 0, "uv": 0, "copy_pv": 0, "copy_uv": 0})
key = (c.ip, c.ua)
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
+43
View File
@@ -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
+48
View File
@@ -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,
)
+2
View File
@@ -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
+21
View File
@@ -69,6 +69,10 @@ class ComparisonRecordIn(BaseModel):
# 明细
items: list[ComparisonItemIn] = Field(default_factory=list)
comparison_results: list[ComparisonResultIn] = Field(default_factory=list)
# 逐平台结局摘要(含失败平台的细分原因 status: store_not_found/items_not_found/below_minimum/
# unsupported/...)。来自 done.params.platform_results,客户端透传;落 raw_payload(不单列),
# admin「卡在哪一步」从这里读。list[dict] 宽松存(结构由 pricebot 定,只作 debug 展示)。
platform_results: list = Field(default_factory=list)
skipped_dish_count: int | None = None
skipped_dish_names: list[str] = Field(default_factory=list)
total_dish_count: int | None = None
@@ -82,6 +86,23 @@ class ComparisonRecordIn(BaseModel):
# 时分秒前端拼不出,必须由后端透传)。
trace_url: str | None = Field(None, description="本次比价公网调试链接")
# ===== debug 维度(客户端采集上报;旧客户端不带 → None。仅 admin 比价记录页用)=====
# 必须显式声明,否则 model_dump() 落 raw_payload 时被 pydantic 静默丢弃(同上面 coupon_saved 的坑)。
device_model: str | None = None
device_manufacturer: str | None = None
rom_vendor: str | None = Field(None, description="vivo/OPPO/Xiaomi/HUAWEI/HONOR/samsung")
rom_name: str | None = Field(None, description="OriginOS/ColorOS/HyperOS/MIUI/HarmonyOS...")
rom_version: int | None = None
android_version: str | None = None
android_sdk: int | None = None
app_version: str | None = None
app_version_code: int | None = None
source_app_version: str | None = Field(None, description="被操控的源平台 App 版本")
longitude: float | None = None
latitude: float | None = None
total_ms: int | None = Field(None, description="整场比价墙钟耗时(ms)")
step_count: int | None = Field(None, description="agent loop 总步数")
# ===== 读取出参 =====
+35 -1
View File
@@ -1,7 +1,7 @@
"""首页平台级展示数据 schemas(客户端首页门面数字)。"""
from __future__ import annotations
from pydantic import BaseModel
from pydantic import BaseModel, Field
class PlatformStatsOut(BaseModel):
@@ -22,3 +22,37 @@ class SavingsFeedItem(BaseModel):
class SavingsFeedOut(BaseModel):
items: list[SavingsFeedItem]
class AppFlagsOut(BaseModel):
"""客户端拉取的运营 feature flag(不鉴权,登录前也能拉)。客户端缓存后按需读。"""
comparing_ad_enabled: bool # 比价/领券期是否展示信息流广告(远程 kill-switch)
class AppVersionOut(BaseModel):
"""最新 App 版本信息(OTA 检查更新,不鉴权)。
客户端用 latest_version_code(整数,单调递增)与本机 versionCode 比较,**不要** version_name 字符串比
latest_version_code=0 表示后台尚未配置任何版本 客户端一律视为"已是最新"
"""
latest_version_code: int = 0 # 最新版 versionCode;0=未配置(无更新)
latest_version_name: str = "" # 展示用,如 "0.1.4"
apk_url: str = "" # 下载链接(发车产出的永久版本化链接)
update_note: str = "" # 更新说明,弹窗展示
min_supported_version_code: int = 0 # 本机低于此版本=强制更新;0=不强更(全可选)
apk_size_bytes: int = 0 # 包大小(字节),展示"约 xMB"
apk_sha256: str = "" # APK 完整性校验(客户端下载后比对,防损坏/劫持)
class AppVersionWriteIn(BaseModel):
"""内部写入最新版本(发布流程/应急,X-Internal-Secret 校验)。整组原子覆盖。"""
latest_version_code: int = Field(gt=0)
latest_version_name: str = Field(min_length=1)
apk_url: str = Field(min_length=1)
update_note: str = ""
min_supported_version_code: int = 0
apk_size_bytes: int = 0
apk_sha256: str = ""
+38
View File
@@ -0,0 +1,38 @@
"""按 trace_id 同机拉 pricebot 的本次比价 LLM 调用明细(供 admin 比价记录 debug)。
pricebot chat() 收口处把每次 LLM input/output 落盘,暴露内部接口
GET /api/internal/llm_calls/{trace_id}(X-Internal-Secret 校验,与本服务 INTERNAL_API_SECRET 同值)
app-server 收到比价记录上报后同机拉一次, comparison_record.llm_calls
best-effort:拉不到(pricebot 未部署该版本 / trace 无明细 / 网络)一律返 [],绝不阻断
比价记录上报多实例下用 pick_pricebot(trace_id) 选对实例( trace 落同进程,明细在
该进程磁盘),与比价透传同一致性 hash 口径
"""
from __future__ import annotations
import logging
import httpx
from app.core.config import settings
from app.core.pricebot_router import pick_pricebot
logger = logging.getLogger("shagua.pricebot_llm")
def fetch_llm_calls(trace_id: str) -> list[dict]:
"""返回该次比价的 LLM 调用明细列表(每条 {scene,model,input_messages,output,usage,latency_ms,error});
未配密钥 / trace_id / 拉取失败 []"""
secret = settings.INTERNAL_API_SECRET
if not secret or not trace_id:
return []
base = pick_pricebot(trace_id).rstrip("/")
url = f"{base}/api/internal/llm_calls/{trace_id}"
try:
resp = httpx.get(url, headers={"X-Internal-Secret": secret}, timeout=5.0)
if resp.status_code == 200:
return resp.json().get("calls", []) or []
logger.warning("fetch_llm_calls trace=%s status=%s", trace_id, resp.status_code)
except Exception as e: # noqa: BLE001 — best-effort,任何异常都不该影响上报
logger.warning("fetch_llm_calls trace=%s failed: %s", trace_id, e)
return []
Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

+15 -9
View File
@@ -1,16 +1,22 @@
# 后端文档库(docs/)
`shaguabijia-app-server` 的文档都在这里。各文档作用:
`shaguabijia-app-server` 的文档都在这里。结构:**根目录放总览,其余按领域/用途分目录**。
| 文档 / 目录 | 作用 |
## 根目录
| 文档 | 作用 |
|---|---|
| [后端技术实现.md](./后端技术实现.md) | **后端技术方案**:业务概览、分层架构与目录、登录链路、美团 CPS、领券透传、数据模型、配置与部署、已知问题。想了解"整个后端怎么回事"看这份。 |
| [api/](./api/) | **API 接口文档**(目录),采用"索引 + 一接口一文件",结构见下。想查"某个接口的协议"看这里。 |
| [integrations/](./integrations/) | **集成层实现文档**(目录):穿山甲验签 / 微信支付 / 极光 / 短信 / 美团 CPS 等 SDK 集成的签名、加解密、协议细节与踩坑。想知道"接外部服务那块到底怎么实现"看这里。 |
| [数据库迁移.md](./数据库迁移.md) | **Alembic 迁移指南**:clone 后如何建表、日常升级、新增迁移、迁移文件命名约定。想"把数据库跑起来 / 改表结构"看这份。 |
| [待办与技术债.md](./待办与技术债.md) | **待办与技术债账本**:记"现在先简化、以后要补"的事 + 跨前后端技术债(P1 鉴权/用户绑定、引擎移植待办等),免遗忘。想知道"还欠什么、以后要补什么"看这份。 |
| [看广告赚金币上线清单.md](./看广告赚金币上线清单.md) | **看广告发奖上线 checklist**:跨前后端,记上线前必做(GroMore 回调配置、清理调试脚手架、端到端验收)。上线"看广告赚金币"前对照这份。 |
| [邀请功能-实现原理与本地测试.md](./邀请功能-实现原理与本地测试.md) | **好友邀请(invite-mvp)实现原理 + 跨前后端具体实现 + 本地内网测试方法**:剪贴板 deferred-deeplink 归因、双方各发 1 万金币、落地页 dl.html、起本地后端 + 编 debug 包 + 两台真机走全链路 + 上线前还差什么。要本地测 / 接手邀请功能看这份。 |
| [后端技术实现.md](./后端技术实现.md) | **后端技术方案总览**:业务概览、分层架构与目录、登录链路、美团 CPS、领券/比价透传、数据模型、配置与部署、已知问题。想了解"整个后端怎么回事"看这份。 |
| README.md | 本文件:文档库索引/传送门。 |
## 子目录
| 目录 | 作用 |
|---|---|
| [api/](./api/) | **API 接口文档**:"索引 + 一接口一文件"(组织方式见下)。想查"某个接口的协议"看这里。 |
| [database/](./database/) | **数据库文档**:每张表一个文件(表结构/字段/索引/约定) + 两份迁移指南——[数据库迁移.md](./database/数据库迁移.md)(Alembic 操作:clone 后建表/日常升级/新增迁移/多 head 排查) + [postgres-migration.md](./database/postgres-migration.md)(SQLite → PostgreSQL 切换步骤,配套 `scripts/init_postgres.py`)。想"把库跑起来 / 改表结构 / 查某张表"看这里。 |
| [integrations/](./integrations/) | **集成层实现文档**:穿山甲验签 / 微信支付 / 极光 / 短信 / 美团 CPS 等 SDK 集成的签名、加解密、协议细节与踩坑。想知道"接外部服务那块到底怎么实现"看这里。 |
| [guides/](./guides/) | **开发 / 上线 / 功能 指南**:[待办与技术债.md](./guides/待办与技术债.md)(跨前后端 backlog,P1 鉴权/用户绑定、引擎移植待办等,"还欠什么、以后要补什么"看这份) + [看广告赚金币上线清单.md](./guides/看广告赚金币上线清单.md)(看广告发奖上线 checklist) + [邀请功能-实现原理与本地测试.md](./guides/邀请功能-实现原理与本地测试.md)(invite-mvp 实现原理 + 本地内网全链路测试)。 |
## api/ 目录是怎么组织的(传送门式)
+9 -5
View File
@@ -3,7 +3,7 @@
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
> 最后更新:2026-06-07金币数值体系一期:签到膨胀 + 信息流广告结算
> 最后更新:2026-06-18(补录比价透传端点 `intent/precoupon/step``intent/step``trace/finalize``meituan/top-sales` 到总览;上一次功能更新 2026-06-07 金币数值体系一期)
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
---
@@ -23,9 +23,13 @@
| 9 | `POST /api/v1/meituan/coupons` | 无 | [详情](./meituan-coupons.md) |
| 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan-feed.md) |
| 11 | `POST /api/v1/meituan/referral-link` | 无 | [详情](./meituan-referral-link.md) |
| **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend |||
| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./compare-intent-recognize.md) |
| 13 | `POST /api/v1/price/step` | 无 | [详情](./compare-price-step.md) |
| 11a | `POST /api/v1/meituan/top-sales` | 无 | [详情](./meituan-top-sales.md)(销量榜:离线库 `meituan_coupon` 按销量降序 + 跨源去重,不实时打美团 |
| **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend;下按 Phase 流程列,均不鉴权) |||
| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./compare-intent-recognize.md)(Phase 1 意图识别,单次,多数源) |
| 12a | `POST /api/v1/intent/precoupon/step` | 无 | Phase 0 意图识别前先用券,仅美团源(透传,无单独文档) |
| 12b | `POST /api/v1/intent/step` | 无 | Phase 1 多帧意图识别,仅淘宝源,循环到 done(透传,无单独文档) |
| 13 | `POST /api/v1/price/step` | 无 | [详情](./compare-price-step.md)Phase 2 步进) |
| 13a | `POST /api/v1/trace/finalize` | 无 | 比价 trace 收尾上云,终止/未识别拿 trace_url(透传,无单独文档) |
| **比价记录**(前缀 `/api/v1/compare`;按用户落库,**鉴权**,区别于上面不鉴权的透传) |||
| 12a | `POST /api/v1/compare/record` | Bearer | [详情](./compare-record-report.md) |
| 12b | `GET /api/v1/compare/records` | Bearer | [详情](./compare-records.md) |
@@ -109,7 +113,7 @@
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link``sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
> `coupon/step` 及外卖比价的 `intent/recognize` / `price/step` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见各接口详情)。
> `coupon/step` 及外卖比价的 `intent/recognize``intent/precoupon/step``intent/step``price/step``trace/finalize` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见 `app/api/v1/compare.py`)。
> 福利相关业务接口(wallet/signin/tasks/savings、`ad/reward-status``ad/feed-reward`)均需 **Bearer**;`wallet/exchange-info` 是静态规则无鉴权;`ad/pangle-callback` 不走 JWT、靠穿山甲**验签**;`ad/test-grant` **仅本地联调**(开关控制,生产 404)。
> 金额字段一律以**分**为单位(`*_cents`)。
+1 -1
View File
@@ -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 规范)。
+1 -1
View File
@@ -18,7 +18,7 @@ pricebot-backend 的响应**原样返回**JSON object)。典型含 `result`
外卖比价由客户端无障碍引擎在源平台(淘宝闪购 / 美团 / 京东外卖)购物车页点悬浮球触发 → 调本接口拿 `query` + `calibration` → 进入 `/price/step` 循环。
⚠️ **MVP 阶段不鉴权**(同 `coupon/step`:`device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 行为暂绑不到登录用户。待补 JWT,见 [待办与技术债.md](../待办与技术债.md) P1。
⚠️ **MVP 阶段不鉴权**(同 `coupon/step`:`device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 行为暂绑不到登录用户。待补 JWT,见 [待办与技术债.md](../guides/待办与技术债.md) P1。
**相关配置**:
- `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`
+1 -1
View File
@@ -5,7 +5,7 @@
比价 `done` 帧后,客户端用**带 JWT 的通道**上报一条比价结果,落 `comparison_record` 表,作为「我的比价记录」数据源 + 用户级行为画像。
> ⚠️ 与不鉴权的透传端点 [`/api/v1/price/step`](./compare-price-step.md) 不同:那是转发壳,本接口按用户维度落库,**必须鉴权**。
> 本轮只做 server 端;客户端在 done 帧后调本接口的改动另起一轮(见 [待办与技术债.md](../待办与技术债.md) P1)。
> 本轮只做 server 端;客户端在 done 帧后调本接口的改动另起一轮(见 [待办与技术债.md](../guides/待办与技术债.md) P1)。
## 入参(JSON body
+1 -1
View File
@@ -17,7 +17,7 @@ pricebot-backend 的响应**原样返回**(JSON object)。
是产品"一键领券"的接入点,**前端已接通**:首页「去领取」→ `CouponPromptDialog` 确认 → 权限检查 → 无障碍引擎 `PriceBotService.startCouponClaim()` → 循环调本接口,后端逐张券下发 launch/wait/done。
⚠️ **客户端契约 vs Server 实现**:Android 客户端通过 `AuthInterceptor` 已自动带 `Authorization: Bearer <access_token>` 头(契约层 OK);Server MVP 阶段**暂未启用强校验**——[coupon.py](../../app/api/v1/coupon.py) 没接 `Depends(get_current_user)`,只读 `device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 领券行为暂时绑不到登录用户(采集不到用户级画像)。待补强校验 + `device_id↔user_id` 绑定,详见 [待办与技术债.md](../待办与技术债.md) P1。
⚠️ **客户端契约 vs Server 实现**:Android 客户端通过 `AuthInterceptor` 已自动带 `Authorization: Bearer <access_token>` 头(契约层 OK);Server MVP 阶段**暂未启用强校验**——[coupon.py](../../app/api/v1/coupon.py) 没接 `Depends(get_current_user)`,只读 `device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 领券行为暂时绑不到登录用户(采集不到用户级画像)。待补强校验 + `device_id↔user_id` 绑定,详见 [待办与技术债.md](../guides/待办与技术债.md) P1。
**相关配置**:
- `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`
+44 -4
View File
@@ -2,7 +2,7 @@
> 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。
> 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **28 张业务表** + `alembic_version`(框架的迁移版本指针)。领券联动的「今日状态」三张表(`coupon_*`)同理:领券过程在 pricebot 内存态跑、**不落库**,只有结果回到 app-server 才落这三张表
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **37 张业务表** + `alembic_version`(框架的迁移版本指针)。注意「比价/领券**过程**」始终在 pricebot 内存态跑、**不落库**——只有**结果**回 app-server 才落库:领券结果落 `coupon_*` 三张今日状态表;比价结果分两路——客户端带 JWT 上报「我的记录」落 `comparison_record`,pricebot 另经 `app/api/internal/` server→server 把客观价格/门店事实落 `price_observation`/`store_mapping`(不鉴权、匿名也记)。此外好友邀请(`invite_*` 2 张)与美团 CPS 群发联盟(`cps_*` 5 张)是两个独立子系统
---
@@ -16,6 +16,8 @@
| 比价战绩里程碑(逐档领金币) | [`comparison_milestone_claim`](./comparison_milestone_claim.md) | 累计成功比价 N 次解锁;进度读 `comparison_record` 计数 |
| profile「累计省了 / 省钱战绩 / 省钱明细」 | [`savings_record`](./savings_record.md) | 真实下单归因(source=compare)+ 无真实数据时 demo 兜底 |
| 「上报更低价」提交 / 列表 | [`price_report`](./price_report.md) | 众包纠偏:用户举证某平台更便宜,人工审核发奖 |
| (无 App UI)比价 done 后客观价格沉淀 | [`price_observation`](./price_observation.md) | **pricebot server→server 内部上报**;平台/门店视角的到手价事实,匿名也记,与 `comparison_record`(用户视角)互补 |
| (无 App UI)跨平台门店身份映射 | [`store_mapping`](./store_mapping.md) | **pricebot 内部上报**;同店在淘宝/美团/京东的 id/名/deeplink,供下次比价 `lookup` 反查省掉现场搜店 |
### 领券(每日领券联动 · 今日状态)
| App 位置 / 动作 | 表 | 说明 |
@@ -31,7 +33,7 @@
| 资产卡 / 钱包余额 | [`coin_account`](./coin_account.md) | 一用户一行的金币+现金余额快照 |
| 金币明细 | [`coin_transaction`](./coin_transaction.md) | 每次金币变动一笔流水 |
| 现金明细 | [`cash_transaction`](./cash_transaction.md) | 每次现金变动一笔流水(分) |
| 每日签到 | [`signin_record`](./signin_record.md) + [`signin_boost_record`](./signin_boost_record.md) | 14 天循环发币;签到后看广告可膨胀一次 |
| 每日签到 | [`signin_record`](./signin_record.md) + [`signin_boost_record`](./signin_boost_record.md) | 7 天循环发币;签到后看广告可膨胀一次 |
| 一次性任务(开消息提醒等) | [`user_task`](./user_task.md) | 领一次发币 |
| 看激励视频赚金币 | [`ad_reward_record`](./ad_reward_record.md) + [`ad_watch_log`](./ad_watch_log.md) + [`ad_ecpm_record`](./ad_ecpm_record.md) | 独立数据流:发奖 / 旧版观看时长 / 收益对账 |
| 信息流广告结算 | [`ad_feed_reward_record`](./ad_feed_reward_record.md) | 每展示满 10 秒累计一份奖励,完成后一次性入账 |
@@ -46,6 +48,21 @@
| 新手引导是否再展示 | [`onboarding_completion`](./onboarding_completion.md) | 按 设备+账号 去重;登录响应回 `onboarding_completed`,走完引导时标记,跨卸载重装 |
| 帮助与反馈 | [`feedback`](./feedback.md) | 含截图,后台人工处理 |
### 好友邀请(注册增长)
| App 位置 / 动作 | 表 | 说明 |
|---|---|---|
| 输入/剪贴板邀请码绑定 | [`invite_relation`](./invite_relation.md) | 注册即生效,邀请人+被邀请人各发 1 万金币;`invitee_user_id` 唯一=幂等防重复发奖 |
| 落地页访问指纹(剪贴板归因兜底) | [`invite_fingerprint`](./invite_fingerprint.md) | 剪贴板没拿到码时,用 (ip+机型+屏幕) 7 天内反查邀请人 |
### 美团 CPS 群发联盟(私域社群比价,运营后台驱动 · 群发选品→点击→对账漏斗)
| 后台/用户动作 | 表 | 说明 |
|---|---|---|
| 运营建可推广活动 | [`cps_activity`](./cps_activity.md) | 预存券/物料(美团 actId / 淘宝淘口令 / 京东链接) |
| 运营建微信推广群 | [`cps_group`](./cps_group.md) | 一群一行,`sid`=美团二级渠道追踪位 |
| 后台批量生成群发短链 | [`cps_link`](./cps_link.md) | 群×活动 → `/c/{code}`,运营复制到微信群 |
| 用户点短链(落地/复制口令) | [`cps_click`](./cps_click.md) | 记 visit/copy 事件,统计 PV/UV |
| 定时拉美团联盟订单对账 | [`cps_order`](./cps_order.md) | `query_order` 按 sid 归群,串成点击→下单→佣金漏斗 |
### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权)
| 后台模块 | 表 | 说明 |
|---|---|---|
@@ -84,7 +101,9 @@
| 首次进 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 /invite/bind` | `invite_relation`(C `effective`) + `coin_account`(U×2) + `coin_transaction`(C `invite_inviter` + `invite_invitee`) | 同事务;`invitee_user_id` 唯一幂等,双方各发 1 万金币 |
| 落地页归因 `POST /invite/landing-track` | `invite_fingerprint`(C) | 剪贴板归因兜底线索,登录后用 (ip+机型+屏幕) 反查 |
| 领券首帧 `POST /api/v1/coupon/step`(step=0) | `coupon_prompt_engagement`(C/U `claim_started`) | `(device_id, package, 北京日)` 幂等;best-effort |
| 领券每帧结果 `POST /api/v1/coupon/step` | `coupon_claim_record`(C/U) | `(device_id, coupon_id, 北京日)` 幂等;best-effort |
| 领券跑完 `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`) | 同上;客户端通知(透传链路看不到拒绝) |
@@ -103,7 +122,19 @@
| 改运营配置 | `app_config`(C/U)+`admin_audit_log`(C) | 同事务 |
| 任意写操作 | `admin_audit_log`(C,**永不 U/D**) | |
> 没有任何表会被业务流程物理 DELETE。注销是软删(改 user 行),其余只 C/U。
> 没有任何表会被业务流程物理 DELETE。注销是软删(改 user 行),其余只 C/U(领券 `/prompt/reset``/completed-today/reset` 是开发用删除,非业务流程)
### CPS 群发联盟 & pricebot 内部上报(非 C 端用户、非 admin 常规管理触发)
| 触发 | 写入 | 操作 |
|---|---|---|
| 运营后台建活动 / 建群 | `cps_activity`(C/U) / `cps_group`(C/U) | `app/admin/routers/cps.py`;群含美团平台才分配 `sid` |
| 后台批量生成短链 `POST /admin/api/cps/referral-links` | `cps_link`(C) | 每 群×活动 一条;美团经 `sid` 转链拿 `target_url`(同群同活动重复生成产生多条) |
| 用户点群发短链 `GET /c/{code}` / `POST /c/{code}/copy` | `cps_click`(C `visit`/`copy`) | 公开端点不鉴权;`group_id`/`sid` 从 link 冗余进来免 join |
| 定时拉美团联盟订单对账 | `cps_order`(C/U upsert) | `query_order``sid` 归群;`order_id` 幂等(状态会变,重复拉则更新) |
| pricebot 比价 done 内部上报 `POST /internal/price-observation` | `price_observation`(C 批量) | `(trace_id,platform,scope)` 幂等;**不走 JWT、靠 `X-Internal-Secret`**(未配→503) |
| pricebot 比价 done 内部上报 `POST /internal/store-mapping` | `store_mapping`(C/U 填空合并) | `trace_id` 幂等;另有 `lookup` 反查 + `invalidate` deeplink 失效标记(淘宝/京东) |
> CPS 5 表与本 App `user` **无关**(被推广群的用户在美团下单,不是本 App 注册用户);`price_observation`/`store_mapping``source_user_id` 是可空旁路(链路不鉴权,匿名也记)。
---
@@ -113,6 +144,8 @@
- **17 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction``cash_transaction``withdraw_order``wechat_transfer_authorization`(同时是 PK)、`signin_record``signin_boost_record``user_task``comparison_record``comparison_milestone_claim``savings_record``ad_reward_record``ad_watch_log``ad_ecpm_record``ad_feed_reward_record``price_report``feedback`
- `admin_audit_log.admin_id``admin_user.id`
- `price_report.comparison_record_id``comparison_record.id`(可空:关联记录被删后仍留上报历史)。
- **邀请两表**`user.id`:`invite_relation.inviter_user_id``invite_relation.invitee_user_id`(唯一)、`invite_fingerprint.inviter_user_id`——注意 FK 列名是 `inviter`/`invitee_user_id`,不是 `user_id`
- **CPS 5 表 + 比价沉淀 2 表均无硬 FK**(全靠 `sid`/`trace_id` 语义对齐,见下)。
### 语义 join key(无 FK 约束,靠业务字段对齐 —— 排障/对账必看)
- **`coin_transaction.ref_id` 指向随 `biz_type` 变**:
@@ -139,6 +172,8 @@
- **里程碑解锁进度不存库**:`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`)。登录读、走完引导写,决定是否再展示新手引导。
- **CPS 群发 5 表全靠 `sid` / id 语义串联(无硬 FK)**:`cps_link.group_id``cps_group.id``cps_link.activity_id``cps_activity.id``cps_click.link_id``cps_link.id`;**点击与订单无法对到单笔**,只在群维度(`sid`)汇合——`cps_order.sid``cps_group.sid``cps_link.sid``cps_click.sid`(仅美团有 sid,淘宝/京东无)。统计按群聚合,故 `group_id`/`sid` 冗余进 `cps_click` 免 join。
- **比价沉淀两表(`price_observation` / `store_mapping`)**:`trace_id` 软指 pricebot work_logs(与 `comparison_record.trace_id` 同源但不互 join,各存各视角);`source_user_id` / `source_device_id` 软指用户/设备(可空,匿名也记)。`store_mapping.lookup` 靠**店名字符串精确相等** + geo 取最近,非 id 级 join。
### ER 关系(文字版)
```
@@ -154,6 +189,11 @@ admin_user ─1:N─ admin_audit_log
app_config (独立, 无外键, key 为主键)
coupon_prompt_engagement / coupon_daily_completion / coupon_claim_record
(独立, 无硬 FK; 维度=device_id+北京日, user_id/trace_id 仅软关联)
user ─1:N─ invite_relation (inviter_user_id 硬 FK); invitee_user_id ─1:1─ user (唯一硬 FK)
user ─1:N─ invite_fingerprint (inviter_user_id 硬 FK)
cps_activity / cps_group ──语义(无FK)──▶ cps_link ─1:N─ cps_click
(CPS 5 表自成子系统; cps_order 经 sid 归群对账, 与本 App user 无关)
price_observation / store_mapping (独立, 无硬 FK; 维度=trace_id, source_user/device 仅软关联; pricebot 内部上报)
```
---
+25 -4
View File
@@ -3,13 +3,13 @@
> 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。
> ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。
> 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`
> 最后更新:2026-06-11(合并:新增 3 张领券今日状态表 `coupon_*` + `onboarding_completion` 新手引导完成表;含 [OVERVIEW 总览](./OVERVIEW.md))
> 最后更新:2026-06-17(补全 9 张此前缺文档的表:CPS 群发 5 张 `cps_*` + 好友邀请 2 张 `invite_*` + 比价沉淀 `price_observation`/`store_mapping`;并全表 review 对齐 model——`signin_record` 改 7 天循环、`coupon_prompt_engagement``package` 列按 App 频控、`user``username`/`invite_code`/`debug_trace_enabled`;含 [OVERVIEW 总览](./OVERVIEW.md))
> 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。
---
## 表总览(28 张业务表 + `alembic_version` 框架表)
## 表总览(37 张业务表 + `alembic_version` 框架表)
### 账号 / 反馈
| 表 | 用途 | 模型 | 文档 |
@@ -18,6 +18,12 @@
| `onboarding_completion` | 新手引导完成标记(按 设备+账号 去重,登录时据此跳过引导) | `models/onboarding.py` | [详情](./onboarding_completion.md) |
| `feedback` | 用户帮助与反馈(含截图) | `models/feedback.py` | [详情](./feedback.md) |
### 好友邀请(注册增长)
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
| `invite_relation` | 邀请绑定关系(注册即生效,双方各发1万金币;`invitee_user_id` 唯一=幂等防重复发奖) | `models/invite.py` | [详情](./invite_relation.md) |
| `invite_fingerprint` | 剪贴板归因失败时的指纹兜底(落地页记 ip+屏幕+机型,登录后反查邀请人) | `models/invite_fingerprint.py` | [详情](./invite_fingerprint.md) |
### 钱包 / 福利(看广告赚钱闭环)
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
@@ -26,7 +32,7 @@
| `cash_transaction` | 现金流水账本(分) | `models/wallet.py` | [详情](./cash_transaction.md) |
| `withdraw_order` | 提现单(现金→微信零钱,含人工审核态) | `models/wallet.py` | [详情](./withdraw_order.md) |
| `wechat_transfer_authorization` | 微信免确认转账授权(一用户一行) | `models/wallet.py` | [详情](./wechat_transfer_authorization.md) |
| `signin_record` | 签到记录(14 天循环) | `models/signin.py` | [详情](./signin_record.md) |
| `signin_record` | 签到记录(7 天循环) | `models/signin.py` | [详情](./signin_record.md) |
| `signin_boost_record` | 签到后看广告膨胀记录 | `models/signin.py` | [详情](./signin_boost_record.md) |
| `user_task` | 一次性任务领取去重 | `models/task.py` | [详情](./user_task.md) |
| `ad_reward_record` | 看激励视频发奖记录(S2S 回调,trans_id 幂等) | `models/ad_reward.py` | [详情](./ad_reward_record.md) |
@@ -42,10 +48,16 @@
| `savings_record` | 省钱记录(profile 省钱战绩源;真实下单归因 + demo) | `models/savings.py` | [详情](./savings_record.md) |
| `price_report` | 上报更低价(众包纠偏,人工审核发奖) | `models/price_report.py` | [详情](./price_report.md) |
### 比价数据沉淀(pricebot server→server 内部上报,不走用户鉴权)
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
| `price_observation` | 价格观测(平台/门店视角的到手价事实,匿名也记;`comparison_record` 的客观对照) | `models/price_observation.py` | [详情](./price_observation.md) |
| `store_mapping` | 跨平台「同一家店」id/名/deeplink 身份映射(淘宝/美团/京东,供 lookup 反查省现场搜店) | `models/store_mapping.py` | [详情](./store_mapping.md) |
### 领券(每日领券联动 · 今日状态)
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
| `coupon_prompt_engagement` | 领券引导窗频控源(今日是否已 engage,按 device+日) | `models/coupon_state.py` | [详情](./coupon_state.md) |
| `coupon_prompt_engagement` | 领券引导窗频控源(今日是否已 engage,按 device+package+日) | `models/coupon_state.py` | [详情](./coupon_state.md) |
| `coupon_daily_completion` | 首页「去领取」置灰源(今日是否已跑完整轮) | `models/coupon_state.py` | [详情](./coupon_state.md) |
| `coupon_claim_record` | 每张券领取结果沉淀(资产/画像/排查,不参与判断) | `models/coupon_state.py` | [详情](./coupon_state.md) |
@@ -54,6 +66,15 @@
|---|---|---|---|
| `meituan_coupon` | 美团 CPS 券本地缓存(智能推荐/销量榜从库出,定时 ETL 灌入) | `models/meituan_coupon.py` | [详情](./meituan_coupon.md) |
### 美团 CPS 群发联盟(私域社群比价 · 群发选品→点击→对账漏斗)
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
| `cps_activity` | 可推广活动池(运营预存的券/物料:美团 actId / 淘宝淘口令 / 京东链接) | `models/cps_activity.py` | [详情](./cps_activity.md) |
| `cps_group` | 推广微信群(`sid`=美团二级渠道追踪位,订单按 sid 归群对账) | `models/cps_group.py` | [详情](./cps_group.md) |
| `cps_link` | 群发短链 `/c/{code}`(群×活动生成,302 跳/淘宝落地页) | `models/cps_link.py` | [详情](./cps_link.md) |
| `cps_click` | 短链点击事件流(visit/copy,PV/UV 漏斗源) | `models/cps_link.py` | [详情](./cps_click.md) |
| `cps_order` | 美团联盟 query_order 拉回的对账订单(按 sid 归群算佣金) | `models/cps_order.py` | [详情](./cps_order.md) |
### 首页门面数据
| 表 | 用途 | 模型 | 文档 |
|---|---|---|---|
+2
View File
@@ -1,5 +1,7 @@
# ad_feed_reward_record — 信息流广告奖励记录
> 模型 `app/models/ad_feed_reward.py` · 仓库 `app/repositories/ad_feed_reward.py` · 接口 `POST /api/v1/ad/feed-reward`(信息流广告结算) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
点位 2 的奖励记录。每条记录对应客户端完成的一次信息流广告展示/播放事件。
## 字段
+1 -1
View File
@@ -21,7 +21,7 @@
| `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` |
| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `closed_early`(展示了但用户提前关/跳过,未发奖,客户端 reward-noshow 留痕)/ `not_signed`/`already_boosted`/`last_day`/ `unknown_scene`(回调 `reward_scene` 不在支持集合,只留痕不发) |
| `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值统计当日发奖次数 |
| `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) |
| `raw` | String(1024) | nullable | 回调原始参数(审计排查) |
+2 -2
View File
@@ -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` / `comparing_ad_enabled` |
| `value` | JSON(PG: JSONB) | NOT NULL | 配置值,类型随 key(`int` / `int_list` 如签到 14 档 / `dict_str_int` 如 task_rewards / `bool` 如 withdraw_auto_reconcile_enabled / comparing_ad_enabled) |
| `updated_by_admin_id` | Integer | nullable | 最后修改的管理员 id(= `admin_user.id`,软引用,无 FK) |
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最后修改时间 |
+1
View File
@@ -34,6 +34,7 @@
## 索引与约束
- PK `id`;index `user_id``created_at`
- 部分唯一索引 `ux_cash_transaction_withdraw_refund_ref`(`ref_id`),条件 `biz_type = 'withdraw_refund' AND ref_id IS NOT NULL`:一笔提现单只能退款一次(按 `out_bill_no` 去重),挡重复退款。
## 注意
- 退款流水 `remark` 是用户可见文案(区分"未成功自动退"vs"审核未通过退");技术原因记在 `withdraw_order.fail_reason`,不外露。
+4 -3
View File
@@ -10,8 +10,8 @@
| 动作 / endpoint | `biz_type` | `amount` | `ref_id` 指向 |
|---|---|---|---|
| 签到 `POST /signin/do` | `signin` | + | 当天日期串(= `signin_record.signin_date` ISO) |
| 签到后看广告膨胀 `POST /signin/boost` | `signin_boost` | + | 当天日期串(= `signin_boost_record.signin_date` ISO) |
| 领任务 `POST /tasks/claim` | `task_<key>`(如 `task_enable_notification`) | + | `user_task.task_key` |
| 签到后看广告膨胀 `POST /signin/boost` | `signin_boost` | + | 广告 `trans_id`(= `signin_boost_record.ad_ref_id`);无 ad_ref_id 时回退当天日期 ISO |
| 领任务 `POST /tasks/claim` | `task_<key>`(如 `task_enable_notification`) | + | 一次性任务=`user_task.task_key`;可重复任务(`enable_notification`)=带序号 `task_key:N` |
| 普通激励视频 S2S 回调 `POST /ad/pangle-callback` | `reward_video`(历史兼容:`ad_reward`) | + | `ad_reward_record.trans_id` |
| 信息流广告结算 `POST /ad/feed-reward` | `feed_ad_reward` | + | `ad_feed_reward_record.client_event_id` |
| 金币兑现金 `POST /wallet/exchange` | `exchange_out` | | null(配套 `cash_transaction.exchange_in`) |
@@ -36,10 +36,11 @@
## 关系 / Join Key
- `user_id``user.id`(多对一)。
- `ref_id` 是**软关联**(无 FK),目标`biz_type`:`signin`→签到日 / `signin_boost``ad_reward_record.trans_id` / `task_<key>``user_task.task_key` / `reward_video`/`ad_reward``ad_reward_record.trans_id` / `feed_ad_reward``ad_feed_reward_record.client_event_id` / 其余 null。
- `ref_id` 是**软关联**(无 FK),目标随 `biz_type`:`signin`→签到日(`signin_record.signin_date` ISO) / `signin_boost``signin_boost_record.ad_ref_id`(无则当天日期) / `task_<key>`一次性任务=`user_task.task_key`、可重复任务=`task_key:N` / `reward_video`/`ad_reward``ad_reward_record.trans_id` / `feed_ad_reward``ad_feed_reward_record.client_event_id` / 其余 null。
## 索引与约束
- PK `id`;index `user_id``created_at`
- 部分唯一索引 `ux_coin_transaction_task_ref`(`user_id`, `biz_type`, `ref_id`),条件 `biz_type LIKE 'task%' AND ref_id IS NOT NULL`:可重复任务(如打开消息提醒)按 `ref_id` 序号(`task_key:N`)去重,挡并发/连点重复发放;仅 `task_*``ref_id` 非空生效,不影响 signin/exchange/withdraw 等其它 `biz_type`
## 注意
- 流水与余额快照(`coin_account`)、与对应业务记录(signin/task/ad_reward/feed_ad_reward 等)在**同一事务**写,不会只发币不留痕。
+1 -1
View File
@@ -5,7 +5,7 @@
App「记录比价战绩」每档(第 1~6 次)只能领一次,领取后写一行去重。解锁进度**不存本表**——由 `comparison_record``status='success'` 的条数实时算(第 N 档在"成功比价 ≥ N 次"时解锁)。档位金额来自 `rewards.RECORD_MILESTONES`(默认 `(120,180,300,500,800,1200)`,运营后台 `app_config.record_milestones` 可改)。仿 `user_task` 的一次性领取模型。
## 用在哪 / 增删改查
- **C(插入)**:`POST /compare/milestone/claim`(`claim`)。档位合法 + 已解锁(`count_success >= milestone`)+ 未领过 → 写一行(`coin_awarded=0`)。越界 `UnknownMilestoneError`(404)、未解锁 `MilestoneLockedError`(409)、已领 `AlreadyClaimedError`(409)。
- **C(插入)**:`POST /compare/milestones/{milestone}/claim`(`claim`)。档位合法 + 已解锁(`count_success >= milestone`)+ 未领过 → 写一行(`coin_awarded=0`)。越界 `UnknownMilestoneError`(404)、未解锁 `MilestoneLockedError`(409)、已领 `AlreadyClaimedError`(409)。
- **U / D**:无。
- **R**:`GET /compare/milestones`(战绩页:各档 claimed/active/locked + 成功次数),`get_status` 读本表已领集合 + `comparison_record` 成功计数对照。
+4 -2
View File
@@ -5,6 +5,7 @@
每完成一次比价(外卖/电商/领券),客户端在 done 帧后用**带 JWT** 的通道上报一条。App「我的比价记录」列表/详情的数据源,也是比价战绩里程碑解锁进度的计数源(`status='success'` 条数),还被「上报更低价」反查原最低价。
> 与 `savings_record` 的区别:本表是「每一次**比价行为**的完整明细」(不省钱、甚至失败也记);`savings_record` 是「真正**下单成交**省了多少」。两表独立、互不喂数据。
> 与 [`price_observation`](./price_observation.md) / `store_mapping` 的区别:本表是**用户视角**(登录后按 `user_id` 存「我的比价记录」);后两张是 server 侧无条件沉淀的**平台/门店视角客观事实**(价格事实 / 跨平台店铺身份映射),与本表 `trace_id` 同源但不互相 join,各存各的视角。
## 用在哪 / 增删改查
- **C / U(upsert,幂等)**:`POST /compare/record`(`upsert_record`)。按 `(user_id, trace_id)` 查:不存在→新建;已存在→整行覆盖(客户端重试/重复上报时,**更完整的那次胜出**)。`best_*`/`saved_amount_cents`/`is_source_best`/`status``_derive``comparison_results` 算出(协议已按 price 升序、rank=1 最便宜),不信客户端自算。
@@ -19,6 +20,7 @@
| `device_id` | String(64) | nullable | 设备号(多设备区分 / 与不鉴权期对账) |
| `business_type` | String(16) | NOT NULL, default `food`, index | 取值:`food`(当前唯一接通)/ `ecom` / `coupon` |
| `trace_id` | String(64) | NOT NULL | pricebot 侧 trace_id(关联调试落盘 + 幂等键) |
| `trace_url` | String(512) | nullable | 本次比价公网调试链接(`price.shaguabijia.com/traces/{dir}/`);dir 名含 pricebot 落盘时分秒,前端/server 拼不出必须存。查看接口按 `user.debug_trace_enabled`(或本机 agent 调试 `include_trace`)决定返不返回。旧记录 / 未开上云为 null |
| `source_platform_id` / `_name` | String(32) | nullable | 源平台代号 / 中文名 |
| `source_package` | String(128) | nullable | 源平台 Android 包名 |
| `source_price_cents` | Integer | nullable | 源平台到手价(分) |
@@ -32,7 +34,7 @@
| `status` | String(16) | NOT NULL, default `success` | 取值:`success`(有非源且有价的目标结果)/ `failed`(出错/没采到目标价)。**里程碑只数 success** |
| `information` | String(256) | nullable | done 帧文案;成功=摘要,失败=具体原因(前端失败时当原因展示) |
| `items` | JSON(PG: JSONB) | NOT NULL, default [] | 下单菜品 `[{name, qty, specs?}]` |
| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved(元),coupon_name}]`;`coupon_saved`=该平台主优惠额(美团红包/淘宝平台红包/京东百亿补贴,只取一笔),`coupon_name`=优惠来源名(展示用) |
| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved(元),coupon_name,applied_coupons}]`;`coupon_saved`=该平台主优惠额(美团红包/淘宝平台红包/京东百亿补贴,只取一笔),`coupon_name`=优惠来源名(展示用),`applied_coupons`=`[{name,amount}]` 多券明细 |
| `skipped_dish_names` | JSON(PG: JSONB) | NOT NULL, default [] | 被跳过的菜名 |
| `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报全量(calibration + done.params),取数兜底 |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
@@ -46,7 +48,7 @@
- 被 `comparison_milestone_claim` 间接依赖:解锁进度 = 本表 `status='success'` 计数。
## 索引与约束
- PK `id`;index `user_id``business_type``created_at`;UNIQUE(`user_id`, `trace_id`) = `uq_comparison_user_trace`(幂等覆盖)。
- PK `id`;index `user_id``business_type``created_at`;复合 index `ix_comparison_status_created`(`status`, `created_at`)(按 `status='success'` 过滤 + 近期排序的聚合/轮播,避免随数据量退化为全表扫);UNIQUE(`user_id`, `trace_id`) = `uq_comparison_user_trace`(幂等覆盖)。
## 注意
- 4 个 JSON 列用 `JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。结构化金额列存「分」,`comparison_results.price`/`coupon_saved` 原样存「元」。
+15 -12
View File
@@ -4,7 +4,7 @@
领券(优惠券自动化)联动产生的三张「今日状态」表,都挂在领券透传端点 `POST /api/v1/coupon/step` 这条链路上(pricebot 跑领券,结果回 app-server 落库;**领券过程本身在 pricebot 内存态跑、不落库**)。三表各管一件事:
- **`coupon_prompt_engagement`** — 弹窗频控源。按 `(device, 自然日)` 记「今天是否对领券引导窗表达过**意向**」(点「一键领取」=`claim_started` / 点拒绝关闭=`dismissed` 都算)。切到外卖 App 时据此决定弹不弹:今天 engage 过就不再弹。
- **`coupon_prompt_engagement`** — 弹窗频控源。按 `(device, App 包名, 自然日)` 记「今天**这个 App** 是否对领券引导窗表达过**意向**」(弹出即记 `shown` / 点「一键领取」=`claim_started` / 点拒绝关闭=`dismissed` 都算)。切到外卖 App 时据此决定弹不弹:今天**该 App** engage 过就不再弹该 App。频控维度自 2026-06-14 起含 `package`,美团/淘宝/京东各自独立、互不压制
- **`coupon_daily_completion`** — 首页置灰源。按 `(device, 自然日)` 记「今天是否已**跑完整轮**领券(到 done 帧)」。首页「去领取」卡据此置灰:今天跑完了就不能再领。
- **`coupon_claim_record`** — 资产沉淀层。按 `(device, 券, 自然日)` 记每张券的领取结果(success/already_claimed/failed/skipped),**纯沉淀**(资产/画像/排查/CPS 归因),当前**不参与**「要不要领 / 弹不弹」的判断。
@@ -19,35 +19,38 @@
---
## coupon_prompt_engagement — 弹窗频控(今日是否已对引导窗表达意向)
## coupon_prompt_engagement — 弹窗频控(今日这个 App 是否已对引导窗表达意向)
`(device_id, engage_date)` 唯一,一台设备一天一条;今天 engage 过(领或拒)就不再弹。
`(device_id, package, engage_date)` 唯一,一台设备、一个 App、一天一条;今天**这个 App** engage 过(弹/领/拒)就不再弹该 App。各 App 独立:美团弹过不压淘宝/京东
### 用在哪 / 增删改查
- **C / U(幂等 upsert**`mark_engagement`条触发:
- **C / U(幂等 upsert**`mark_engagement`条触发:
- `POST /api/v1/coupon/prompt/shown`(引导窗弹出那刻上报)→ 记 `shown`(频控主判据:弹出即占用今天这个 App 的「一次」,领/拒/无视都算用掉);
- `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 前查,纯后台判据
- 已有今天那条则覆盖 `engage_type`(并补 `user_id`),否则插入。`step` 帧的 `package` 取 step body 的 `package`,缺/空则退化为 `""` 占位(全局态)。
- **D**`POST /api/v1/coupon/prompt/reset``reset_today_engagement`)—— 删这台设备今天**所有 App** 的 engagement(不按 package 过滤:重置=从头测,清全部 App 最符预期),开发设置「重置今日领券弹窗状态」按钮调;删后今天又能弹。
- **R**`GET /api/v1/coupon/prompt/should-show?device_id=…&package=…``has_engaged_today`)→ `should_show = not 今天该 App 已 engage`。客户端切外卖 App 前`package` 查(老客户端不带 → `""` 全局态)
### 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `device_id` | String(64) | NOT NULL | 判断/聚合维度;客户端 `getOrCreateDeviceId`,重装会变 |
| `package` | String(64) | NOT NULL, server_default `""` | 触发弹窗的目标 App 包名(`com.sankuai.meituan` / `com.taobao.taobao` / `com.jingdong.app.mall`)。频控维度,各 App 独立。旧行(2026-06-14 改造前)无此值 → 迁移用占位 `""` 填 |
| `user_id` | Integer | index, 可空 | 登录态有就记(资产);不进唯一键、不阻塞判断 |
| `engage_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()` |
| `engage_type` | String(16) | NOT NULL | `claim_started`(点一键领取)/ `dismissed`(点拒绝关闭);仅记录区分,**判断只看「今天有没有这条」,type 不影响弹不弹** |
| `engage_type` | String(16) | NOT NULL | `shown`(自动弹出即记)/ `claim_started`(点一键领取)/ `dismissed`(点拒绝关闭);仅记录区分,**判断只看「今天这个 App 有没有这条」,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`(一台设备一天一条)。
- PK `id`index `user_id`UNIQUE(`device_id`, `package`, `engage_date`) = `uq_coupon_engage_device_pkg_date`(一台设备、一个 App、一天一条)。
- 历史:原为 UNIQUE(`device_id`, `engage_date`) = `uq_coupon_engage_device_date`2026-06-14 迁移 `coupon_engage_per_package` 加入 `package` 维度改成现状(修「任一 App 弹过就把整台设备当天标记、压住其余 App」的 bug)。
### 注意
- `device_id` 重装会变 → 重装当新设备,今天重新弹一次(产品预期)。
- 判断只看「今天这台设备有没有这条」,不看 `engage_type`领或拒都算 engage 过、都不再弹)。
- 判断只看「今天这台设备这个 App 有没有这条」,不看 `engage_type`弹/领/拒都算 engage 过、都不再弹该 App)。
---
@@ -57,7 +60,7 @@
### 用在哪 / 增删改查
- **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**无业务删除
- **D**`POST /api/v1/coupon/completed-today/reset``reset_today_completion`)—— 删这台设备今天那条,首页「去领取」卡恢复可点。与 `/prompt/reset` 配套,开发设置「重置今日领券弹窗状态」一键把今日状态全清;MVP 不鉴权
- **R**`GET /api/v1/coupon/completed-today?device_id=…``has_completed_today`)→ `completed`。客户端据此把首页「去领取」卡置灰、不可点。
### 字段
@@ -119,5 +122,5 @@
## 三表共性小结
- 数据流向:客户端 → `POST /api/v1/coupon/step`(透传给 pricebot)→ 结果回写这三张表(best-effort,写库失败不影响领券)。
- 唯一键都含 `device_id` + 某个北京自然日列;`user_id` 永远是可空旁路(资产留痕,不进唯一键、不阻塞判断)。
- 唯一键都含 `device_id` + 某个北京自然日列engagement 还含 `package`,按 App 频控)`user_id` 永远是可空旁路(资产留痕,不进唯一键、不阻塞判断)。
- 无硬外键:`user_id` 软指 `user.id``trace_id` 软指 pricebot work_logs(详见 [OVERVIEW → 表间关系 & Join Key](./OVERVIEW.md))。
+40
View File
@@ -0,0 +1,40 @@
# cps_activity — CPS 可推广活动池(运营预存的券/物料)
> 模型 `app/models/cps_activity.py` · 仓库 `app/admin/repositories/cps.py`(`create_activity` / `list_activities` / `get_activity` / `delete_activity` / `list_activity_images`) · 接口 admin `POST /admin/api/cps/activities``GET /admin/api/cps/activities``DELETE /admin/api/cps/activities/{id}``POST /admin/api/cps/upload-image``GET /admin/api/cps/activity-images` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
运营把常推的活动(券)预存成池,生成群发链接时从池里挑,省得每次记物料 ID。数据**全由运营在后台手填/上传**,无外部同步。在 CPS 群发漏斗里它是**最上游的"卖什么"**:活动(本表)× 群([`cps_group`](./cps_group.md))经"批量生成短链"组合出一条条 [`cps_link`](./cps_link.md)。一条活动可被多个群、多次复用生成 link(一对多,无外键)。
## 用在哪 / 增删改查
- **C(插入)**:`POST /admin/api/cps/activities`(`create_activity`,需 `operator` 角色)。按 `platform` 分支校验:`meituan` 必须填 `act_id``product_view_sign` 之一;`taobao`/`jd` 必须填 `payload`,且 `taobao` 还必须有 `image_url`(落地页图)。淘宝图经 `POST /admin/api/cps/upload-image`(`media.save_cps_image`)上传得绝对 URL,或经 `GET /admin/api/cps/activity-images` 选已有。无幂等键,重复提交建多条。
- **U(更新)**:**无更新端点**。改活动 = 删了重建。`status`(active/archived)列存在但目前没有切换它的路由(待确认是否前端规划)。
- **R(读)**:`GET /admin/api/cps/activities`(`list_activities`,游标分页,可按 `platform`/`status` 过滤,登录即可);生成 link 时 `get_activity` 按 id 取;淘宝落地页渲染时 `cps_redirect``db.get(CpsActivity, link.activity_id)``image_url`;`list_activity_images` 取所有 distinct 非空 `image_url` 供新建复用。
- **D(删除)**:`DELETE /admin/api/cps/activities/{id}`(`delete_activity`,`operator`)。**硬删,不级联** `cps_link`——已发出去的短链继续可用;淘宝落地页若取不到活动(被删),`cps_redirect` 用兜底默认图 `/media/taobao_landing.jpg`
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | 被 `cps_link.activity_id` 语义引用(非 FK) |
| `platform` | String(20) | NOT NULL, default `meituan` | `meituan` / `taobao` / `jd`。决定转链/落地方式,也决定下面哪些字段必填 |
| `name` | String(128) | NOT NULL | 活动名,运营自定,后台列表/审计展示 |
| `act_id` | String(64) | nullable | **美团**联盟「我要推广-活动推广」第一列的物料 ID(actId)。转链 `get_referral_link` 入参 |
| `product_view_sign` | String(128) | nullable | **美团**商品券 productViewSign,与 `act_id` 二选一转链(多数活动用 `act_id`) |
| `payload` | Text | nullable | **淘宝**=整段淘口令文本(落地页原样供复制);**京东**=推广链接(落地页 302 跳)。美团不用此列(用 `act_id`)。生成 link 时直接作 `target_url` |
| `image_url` | String(512) | nullable | **淘宝**落地页主视觉图,存绝对 URL(基于 `CPS_REDIRECT_BASE`,跨域 admin-web 可显示)。落地页按活动展示对应图 |
| `status` | String(20) | NOT NULL, default `active` | `active` / `archived`(目前无切换入口,主要靠删) |
| `remark` | String(256) | nullable | 运营备注 |
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间。**无 `updated_at`**(本表无更新路径) |
## 关系 / Join Key
- `id``cps_link.activity_id`(一活动多 link,**语义关联,无 ForeignKey**;删活动不级联删 link)。
- `id``cps_redirect` 渲染淘宝落地页时按 `link.activity_id` 反查本表取 `image_url`
- 与 [`cps_group`](./cps_group.md) 无直接列关系:两者在"批量生成短链"(`generate_referral_links`)时按 **platform 是否在群范围内**组合,产物是 [`cps_link`](./cps_link.md)。
## 索引与约束
- PK `id`
- **无唯一约束、无额外索引、无外键**(运营低频、量级小,按 id 主键访问即可)。
## 注意
- **`platform` 是多平台演进的核心**:初版(commit `277f9b1`)只有美团,字段就是 `act_id` + `product_view_sign`;`3a40f61` 接淘宝/京东时**复用本表**,只加了 `payload`(异构标识塞一列);`d55f47f` 再加 `image_url` 把淘宝落地页图"做活"(此前是写死一张图)。所以三平台的"卖点"挤在同一张表,靠 `platform` 分流——这是省表的刻意取舍。
- **美团二选一**:`act_id``product_view_sign` 对应 `get_referral_link` 的两种转链入参,router 层强制至少填一个。
- **淘宝必须有图**:`taobao` 落地页是我们自渲染的 H5(`cps_redirect._taobao_landing_html`),没图页面就空,故建活动时强制。`d55f47f` 的迁移把存量淘宝活动的 `image_url` 回填成此前写死的 `/media/taobao_landing.jpg`,无缝衔接。
- **`image_url` 存绝对 URL 的原因**:落地页域(`CPS_REDIRECT_BASE`)与 admin-web 跨域,存绝对值两边都能取(`media.to_abs_media_url`)。dev 未配 `CPS_REDIRECT_BASE` 时退化为相对路径。
+37
View File
@@ -0,0 +1,37 @@
# cps_click — CPS 短链点击事件(/c/{code} 每次访问/复制记一条)
> 模型 `app/models/cps_link.py`(`CpsClick`,与 `CpsLink` 同文件) · 仓库 `app/repositories/cps_link.py`(`record_click` / `click_stats_by_group`) · 接口 用户 `GET /c/{code}`(记 `visit`)、`POST /c/{code}/copy`(记 `copy`,`app/api/v1/cps_redirect.py`);统计 admin `GET /admin/api/cps/stats` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
群发短链 [`cps_link`](./cps_link.md) 每被点一次就在此记一条。在 CPS 群发漏斗里它是**"用户点了"这一步**:用户点 `/c/{code}` 进落地/被跳转记 `visit`,淘宝落地页点"复制口令"记 `copy`。据此统计点击 PV/UV(按群/活动/时段),配合 [`cps_order`](./cps_order.md) 的下单/佣金构成"点击→下单→佣金"漏斗。`group_id`/`sid` 在本表**冗余一份**(来自 link),统计直接按群聚合、不必 join `cps_link`
## 用在哪 / 增删改查
- **C(插入)**:`record_click`(`app/repositories/cps_link.py`),由 `cps_redirect` 两处触发——`GET /c/{code}`(`cps_landing`)记 `event_type=visit`;`POST /c/{code}/copy`(`cps_copy`,淘宝落地页点复制按钮 fetch 回调)记 `event_type=copy`。写入时把 link 的 `group_id`/`sid` 冗余进来,`ua` 截断到 500。**记点击失败被 `_record` 吞掉绝不阻断用户跳转/落地**(公网无鉴权,任何人点都要能跳)。无幂等——每次点都写新行(PV 即行数)。
- **U / D**:**无**。点击是只追加的事件流。
- **R(读)**:`click_stats_by_group`(`GET /admin/api/cps/stats``group_stats` 调用)按 `group_id` 聚合 → `{pv, uv, copy_pv, copy_uv}`;UV 按 `(ip, ua)` 近似去重,`visit` 计 pv/uv、`copy` 计 copy_pv/copy_uv。聚合在 Python 侧做(量级小、跨 PG/SQLite 无方言坑)。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `link_id` | Integer | index, NOT NULL | 来源短链 = `cps_link.id`(语义关联,非 FK) |
| `group_id` | Integer | index, NOT NULL | **冗余**自 link.group_id;按群聚合点击快,免 join cps_link |
| `sid` | String(64) | index, **nullable** | **冗余**自 link.sid;美团有、淘宝/京东无 |
| `event_type` | String(16) | NOT NULL, default/server_default `visit` | `visit`(进落地页/被 302 跳) / `copy`(淘宝落地页点了"复制口令") |
| `ip` | String(64) | nullable | 客户端 IP(取 `X-Forwarded-For` 首段,否则 `request.client.host`),UV 去重键之一 |
| `ua` | String(512) | nullable | User-Agent,入库截断到 500;UV 去重键之一 |
| `clicked_at` | DateTime(tz) | server_default now(), index, NOT NULL | 点击时间,统计按时间窗过滤 |
## 关系 / Join Key
- `link_id``cps_link.id`(多对一,**语义关联,无 ForeignKey**)。
- `group_id``cps_group.id``sid``cps_group.sid`——均**冗余字段**,直接按群聚合用,非 FK。
- 与 [`cps_order`](./cps_order.md) 无直接关联:点击与订单在 `group_stats` 里**各按 `group_id`/`sid` 聚合后并到同一群行**,共同组成漏斗,但两表之间无 join key(无法把某次点击对到某笔订单——CPS 联盟不回传点击 id)。
## 索引与约束
- PK `id`;index `link_id``group_id``sid``clicked_at`
- **无唯一约束、无外键**(纯事件流,PV 即行数,允许重复)。
## 注意
- **`event_type` 是接淘宝时新增的**:初版(`277f9b1`)只有 visit(点了就 302 跳美团,记一条即可);`3a40f61` 接淘宝后,淘宝落地页是 H5、用户要点按钮"复制口令"才算转化,故迁移 `cps_v2_platforms``event_type`(server_default `visit` 回填存量),`copy` 单独统计成 `copy_pv/copy_uv`。同迁移把 `sid` 改 nullable(淘宝/京东点击无 sid)。
- **`group_id`/`sid` 冗余的理由**:统计是高频读路径,直接 `WHERE group_id=?` 聚合,省掉 `cps_click → cps_link` 的 join;代价是写入时多带两列(`record_click` 从 link 拷)。这与 model docstring 明说的"统计直接按群聚合、不必 join cps_link"一致。
- **UV 是近似值**:按 `(ip, ua)` 去重,无 Cookie/设备指纹;同一人换网络/浏览器会重复计 UV。够运营看趋势,不作精确口径。
- **记点击绝不阻断**:`_record``try/except: pass` 包住,落地/跳转优先级高于统计——丢几条点击可接受,挡住用户领券不可接受。
+38
View File
@@ -0,0 +1,38 @@
# cps_group — CPS 推广群(一个微信群一条,sid 是美团渠道追踪位)
> 模型 `app/models/cps_group.py` · 仓库 `app/admin/repositories/cps.py`(`create_group` / `update_group` / `delete_group` / `get_group` / `get_group_by_sid` / `list_groups`、统计 `group_stats`) · 接口 admin `POST/GET/PATCH/DELETE /admin/api/cps/groups`、统计 `GET /admin/api/cps/stats` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
每个发券的微信群一条记录。核心字段是 `sid`——**美团联盟二级渠道追踪位**:发链接时塞进推广链接(`get_referral_link`),订单回来按 `sid` 归到这个群对账(`query_order` → [`cps_order`](./cps_order.md))。在 CPS 群发漏斗里它是**"发给谁/在哪个渠道"**:群(本表)× 活动([`cps_activity`](./cps_activity.md))组合生成 [`cps_link`](./cps_link.md);点击([`cps_click`](./cps_click.md))与订单都靠 `sid`/`group_id` 归到本群,统计接口 `group_stats` 即以群为行做"点击→下单→佣金"漏斗。
## 用在哪 / 增删改查
- **C(插入)**:`POST /admin/api/cps/groups`(`create_group`,`operator`)。`platforms` 至少选一个,非法平台 400。**`sid` 仅当 `platforms``meituan` 时才有**:运营填了就用(校验全局唯一,重复 409),没填则先占位 `tmp<uuid>`、flush 拿到 id 后回填成 `g<id>`;纯淘宝/京东群 `sid``None`(那俩平台不支持 sid)。无业务幂等键(同名可重复建),唯一性只在 `sid` 上。
- **U(更新)**:`PATCH /admin/api/cps/groups/{id}`(`update_group`,`operator`)。可改 `name`/`platforms`/`member_count`/`status`/`remark`。**`sid` 不可改**(改了会断开历史订单的归群)。
- **R(读)**:`GET /admin/api/cps/groups`(`list_groups`,游标分页,`keyword` 模糊匹配 name 或 sid、`status` 过滤);生成 link 时 `get_group` 取群拿 `sid`;统计 `group_stats` 遍历所有群、按 `sid` 关联订单、按 `group_id` 关联点击。
- **D(删除)**:`DELETE /admin/api/cps/groups/{id}`(`delete_group`,`operator`)。**硬删,不级联**已生成的 [`cps_link`](./cps_link.md)(已发链接继续可用);该群历史 [`cps_click`](./cps_click.md)/[`cps_order`](./cps_order.md) 按 `sid`/`group_id` 仍在库,删群后这些 sid 在统计里降级为"未归群"行(`group_id=None`)。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | 被 `cps_link.group_id``cps_click.group_id` 语义引用(非 FK) |
| `sid` | String(64) | **UNIQUE, index, nullable** | 美团渠道追踪位,仅字母+数字 ≤64(美团限制,schema 正则 `^[A-Za-z0-9]+$`)。**仅含 meituan 的群有**;留空自动 `g<id>`;纯淘宝/京东群为 `None`。订单/点击按它归群 |
| `name` | String(128) | NOT NULL | 群名,运营自定 |
| `platforms` | JSON / JSONB | NOT NULL, default `[]` | 该群发哪些平台(多选):`["meituan","taobao","jd"]`。含 `meituan` 才需要 `sid`。生成 link 时校验"活动平台必须在群范围内" |
| `member_count` | Integer | nullable | 群人数,运营填,作转化率分母(可空) |
| `status` | String(20) | NOT NULL, default `active` | `active` / `archived`。统计 `group_stats` 只列 `active` 群 |
| `remark` | String(256) | nullable | 运营备注 |
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间(无 `updated_at`) |
## 关系 / Join Key
- `id``cps_link.group_id``cps_click.group_id`(一群多 link/多点击,**语义关联,无 ForeignKey**)。
- `sid``cps_order.sid`(订单按 sid 归群对账,**语义关联,非 FK**);`sid``cps_link.sid` / `cps_click.sid`(生成 link 时把群 sid 冗余进 link/click)。
- 与 [`cps_activity`](./cps_activity.md) 无直接列关系,经"批量生成短链"按平台组合。
## 索引与约束
- PK `id`;**UNIQUE+index `sid`**(`uq_cps_group_sid` + `ix_cps_group_sid`)。
- **无外键**
## 注意
- **`sid` 从"必填"放宽到"可空"是多平台演进的关键改动**:初版(`277f9b1`)`sid` NOT NULL + UNIQUE(每个美团群必有渠道位);`3a40f61` 接淘宝/京东后,这俩平台**没有 sid 概念**,迁移 `cps_v2_platforms``sid` 改 nullable,并加 `platforms` 列(存量群 server_default 回填 `["meituan"]`)。理解本表必须记住:**sid 只对美团有意义**,对账/归群全链路只走美团群。
- **`g<id>` 自动 sid**:运营不填 sid 时服务端生成 `g<群id>`,既保证唯一又人类可读。先写 `tmp<uuid>` 占位是因为建群事务里 id 还没生成(flush 前拿不到),拿到 id 后二次 flush 回填。
- **删群不级联**是刻意的:已发到微信群里的短链不能因为后台删群就失效;历史订单/点击也要保留用于对账。代价是删群后其 `sid` 的订单在 `group_stats` 里变成"未归群"独立行(如历史的 `wonderableai`,见 [`cps_order`](./cps_order.md) 注意)。
- **`platforms` 用 JSON 列**:遵循全库约定 `JSON().with_variant(JSONB(), "postgresql")`——PG 上 JSONB,SQLite 退化通用 JSON。
+38
View File
@@ -0,0 +1,38 @@
# cps_link — CPS 群发短链(发群用的 /c/{code},外套一层我们的短链)
> 模型 `app/models/cps_link.py`(`CpsLink`) · 仓库 `app/repositories/cps_link.py`(`create_link` / `get_by_code`) · 接口 admin 生成 `POST /admin/api/cps/referral-links`、用户落地 `GET /c/{code}`(`app/api/v1/cps_redirect.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
群发券链接外套一层我们自己的短链 `/c/{code}`,目的是**统计点击 + 控制落地**。在 CPS 群发漏斗里它是**"发出去的那条链接"**:由 群([`cps_group`](./cps_group.md))× 活动([`cps_activity`](./cps_activity.md))在后台"批量生成短链"产出,运营把 `/c/{code}` 复制到微信群。用户一点 → 记一条 [`cps_click`](./cps_click.md) → 按平台 302 跳 `target_url`(美团短链/京东链接)或返回淘宝落地页。`cps_click``cps_link` 同文件(`models/cps_link.py`),详见 [`cps_click.md`](./cps_click.md)。
## 用在哪 / 增删改查
- **C(插入)**:`POST /admin/api/cps/referral-links`(`generate_referral_links`,`operator`)批量生成——按 `group_id` + 一组 `activity_ids`,对每个活动调 `_gen_one_link`:美团走 `meituan.get_referral_link(act_id/product_view_sign, sid=群.sid)` 拿短链作 `target_url`(无群 sid 则 400);淘宝/京东直接用活动 `payload``target_url`。落库走 `create_link`,短码 `code``_gen_code` 生成(去易混字符,冲突重试 5 次仍冲突用 12 位兜底)。`code` 全局唯一即天然幂等键,但**每次调用都新建 link**(同群同活动重复生成会产生多条不同 code)。
- **U(更新)**:**无**。link 一旦生成不改(改了等于换链接)。
- **R(读)**:`GET /c/{code}`(`cps_landing`)落地时 `get_by_code` 取 link → 决定跳转/落地;`POST /c/{code}/copy` 同样按 code 取。后台无 link 列表端点(运营只看生成结果与按群统计)。
- **D(删除)**:**无删除路径**。删群/删活动都不级联删 link(已发链接保持可用)。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | 被 `cps_click.link_id` 语义引用(非 FK) |
| `code` | String(16) | **UNIQUE, index, NOT NULL** | 短码,发群链接 `/c/{code}` 的 code。字母表去掉 `0/O/1/l/I`(肉眼复制不易错),默认 8 位 |
| `group_id` | Integer | index, NOT NULL | 归属群 = `cps_group.id`(语义关联,非 FK) |
| `activity_id` | Integer | index, NOT NULL | 对应活动 = `cps_activity.id`(语义关联,非 FK);淘宝落地页反查活动取图 |
| `sid` | String(64) | index, **nullable** | 渠道追踪位,**仅美团 link 有**(= 群 sid);淘宝/京东无 sid。生成时从群冗余进来 |
| `platform` | String(20) | NOT NULL, default `meituan` | `meituan` / `taobao` / `jd`。决定 `cps_landing` 是 302 跳还是渲染淘宝落地页 |
| `target_url` | String(2048) | NOT NULL | 跳转/落地目标:美团短链 / 京东链接(302 跳) / 淘宝整段淘口令文本(H5 落地页复制) |
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 生成时间(无 `updated_at`) |
## 关系 / Join Key
- `group_id``cps_group.id`;`activity_id``cps_activity.id`;`sid``cps_group.sid`——**全是语义关联,无 ForeignKey**(删群/删活动都不报错、不级联)。
- `id``cps_click.link_id`(一 link 多点击);记点击时 `record_click` 把 link 的 `group_id`/`sid` 冗余进 [`cps_click`](./cps_click.md)。
- `target_url` 间接关联 [`cps_order`](./cps_order.md):美团 `target_url` 携带群 sid,用户经它下单后订单回来的 `sid` 即此群 sid——但 link 与 order **没有直接列关联**,只经 `sid` 在群维度汇合。
## 索引与约束
- PK `id`;**UNIQUE+index `code`**(`uq_cps_link_code` + `ix_cps_link_code`);index `group_id``activity_id``sid`
- **无外键**
## 注意
- **`target_url` 从 1024 加长到 2048**:初版(`277f9b1`)只装美团短链(够短),`3a40f61` 接淘宝后要装**整段淘口令文本**(较长),迁移 `cps_v2_platforms``target_url` 放宽到 2048,同时 `sid` 改 nullable(淘宝/京东 link 无 sid)。
- **`sid` 冗余进 link 又冗余进 click**:统计时按群聚合点击不必 `join cps_link`(见 `click_stats_by_group` 直接读 `cps_click.group_id`);`sid` 留在 link 上则是为了生成时一次性带齐,跳转无需再查群。
- **短码去易混字符**:`_CODE_ALPHABET` 排除 `0/O/1/l/I`,因为短链会被运营/用户肉眼复制粘贴,降低抄错率;碰撞重试 5 次后降级 12 位,概率近 0。
- **三平台落地分叉**(`cps_redirect.cps_landing`):美团/京东 → 302 跳 `target_url`;淘宝 → 不能 302(淘口令不是 URL),改返回自渲染 H5 落地页(图来自活动 `image_url`,按钮复制 `target_url` 淘口令)。code 不存在时兜底跳 `https://www.meituan.com/`
+51
View File
@@ -0,0 +1,51 @@
# cps_order — CPS 对账订单(美团联盟 query_order 拉回的订单明细)
> 模型 `app/models/cps_order.py` · 仓库 `app/admin/repositories/cps.py`(`reconcile_orders` / `_map_order_fields` / `list_orders`、统计 `group_stats`) · 接口 admin `POST /admin/api/cps/orders/reconcile``GET /admin/api/cps/orders``GET /admin/api/cps/stats` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
从美团联盟 `query_order` 按时间窗拉回、按 `sid` 归群的订单明细,是 CPS 群发漏斗的**最下游"赚了多少佣金"**:用户点 [`cps_link`](./cps_link.md)(携群 [`cps_group`](./cps_group.md) 的 `sid`)下单后,美团把订单连同 `sid` 回传,这里按 `sid` 归群做对账,与 [`cps_click`](./cps_click.md) 的点击量在 `group_stats` 汇成"点击→下单→佣金"漏斗。**仅美团有此表**——淘宝/京东无对账 API,统计里对账字段显示 `-`
## 用在哪 / 增删改查
- **C/U(upsert)**:`POST /admin/api/cps/orders/reconcile`(`reconcile_orders`,需 `finance` 角色)。调 `meituan.query_order(sid?, start_time, end_time, page, limit=100)` 分页拉单(`max_pages=200` 防死循环),每条经 `_map_order_fields` 转字段,按 `order_id` **upsert**:不存在则 insert,存在则逐字段覆盖(订单状态会随时间变 付款→完成→结算/退款,重复拉则更新)。返回 `{fetched, inserted, updated, pages}``order_id` 全局唯一即幂等键。
- **R(读)**:`GET /admin/api/cps/orders`(`list_orders`,游标分页,可按 `sid`/`mt_status` 过滤);统计 `group_stats` 拉全部订单按 `sid` 分桶,算 `order_count`/`gmv`/`est_commission`/`settled_commission` 等。
- **D**:**无**。
## 字段
> 字段对齐 `query_order` 实测返回:金额(payPrice/profit)是「元」字符串 → 入库 `_yuan_to_cents` 转「分」(全站口径);时间(payTime/updateTime)是秒级时间戳 → `_ts_to_dt` 转 tz-aware UTC。
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join / 源字段) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `order_id` | String(64) | **UNIQUE, index, NOT NULL** | 美团订单号(加密串),`orderId`。upsert 幂等键 |
| `sid` | String(64) | index, **nullable** | 渠道追踪位 = 群 `sid`,源 `sid`。**按它归群聚合**;历史无 sid 订单为空 |
| `act_id` | String(64) | index, nullable | 活动物料 ID,源 `actId`(转字符串) |
| `biz_line` | Integer | nullable | 业务线,源 `businessLine``1=外卖` |
| `trade_type` | Integer | nullable | 源 `tradeType``1=cps 2=cpa` |
| `pay_price_cents` | Integer | nullable | 付款金额(分),源 `payPrice`(元)。统计算 GMV |
| `commission_cents` | Integer | nullable | **预估佣金**(分),源 `profit`(元)。统计算预估/已结算佣金 |
| `commission_rate` | String(16) | nullable | 佣金率,源 `commissionRate``"300"=3%``"10"=0.1%`(原样字符串,前端解释) |
| `refund_price_cents` | Integer | nullable | 退款金额(分),源 `refundPrice`(元) |
| `refund_profit_cents` | Integer | nullable | 退款佣金(分),源 `refundProfit`(元) |
| `mt_status` | String(8) | index, nullable | 美团订单状态,源 `status`:`2`付款 `3`完成 `4`取消 `5`风控 `6`结算 |
| `invalid_reason` | String(128) | nullable | 失效原因,源 `invalidReason` |
| `product_name` | String(512) | nullable | 商品名,源 `productName`(超 500 截断) |
| `pay_time` | DateTime(tz) | index, nullable | 付款时间,源 `payTime`(秒级 ts)。统计按时间窗过滤的就是它 |
| `mt_update_time` | DateTime(tz) | nullable | 美团侧更新时间,源 `updateTime`(秒级 ts) |
| `raw` | JSON / JSONB | NOT NULL, default `{}` | `query_order` 单条原始 dataList,留底排查/补字段 |
| `first_seen` | DateTime(tz) | server_default now(), NOT NULL | 首次拉到入库时间(本地) |
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最近 upsert 时间(本地) |
## 关系 / Join Key
- `sid``cps_group.sid`(订单按 sid 归群,**语义关联,无 ForeignKey**)。`group_stats` 即据此把订单挂到群行。
- 与 [`cps_link`](./cps_link.md)/[`cps_click`](./cps_click.md) **无直接列关联**:下单链路是"美团短链(带 sid)→ 美团 App 下单",订单只回传 `sid`、不回传我们的 `link.code` 或某次点击 id。所以点击与订单只能在**群(sid)维度**汇合,**无法把单笔订单对到单次点击**。
- **本表无 `user_id`**:CPS 联盟订单是别人(被推广的微信群用户)在美团下的单,与本 App 的 `user` 表无关,纯渠道佣金对账,不归属本 App 用户。
## 索引与约束
- PK `id`;**UNIQUE+index `order_id`**(`uq_cps_order_order_id` + `ix_cps_order_order_id`);index `sid``act_id``mt_status``pay_time`
- **无外键**
## 注意
- **状态决定佣金口径**(`group_stats._order_agg`):`4`取消/`5`风控**不计佣金**(`_INVALID_STATUS`);`6`结算才是佣金**真正到账**(`_SETTLED_STATUS`)。统计区分 `order_count`(有效=非取消非风控)、`settled_count`(已结算)、`canceled_count`,佣金分 `est_commission`(有效单预估)与 `settled_commission`(结算单)。
- **"未归群"独立行**:`group_stats` 遍历完所有群后,对剩下的、有订单但 `sid` 不属任何现存群的 sid(历史遗留如 `wonderableai`、或别处来源、或群被删),单列一行 `group_id=None`、有对账无点击。所以本表 `sid` 可空/可孤立是设计内的,不是脏数据。
- **金额/时间统一转换的原因**:`query_order` 返回金额是「元」字符串、时间是秒级 ts;`_yuan_to_cents`(Decimal 防浮点,`"null"`/空 → None)与 `_ts_to_dt`(转 tz-aware UTC,前端按北京展示)在入库时归一,与全站"金额存分、时间存 tz-aware"口径对齐。`raw` 整条留底,字段不够时不用重拉。
- **upsert 而非 append**:同一订单会被多次拉到(状态变化),按 `order_id` 覆盖即可拿到最新状态;`reconcile` 可重复跑(幂等)。
- **本表自始至终只服务美团**:初版(`277f9b1`)就定型,`3a40f61` 接淘宝/京东时**没动本表**——那俩平台无对账 API,统计里对账列直接给 `None`(前端显示 `-`)。这是"对账 = 美团专属"的边界。
+39
View File
@@ -0,0 +1,39 @@
# invite_fingerprint — 邀请指纹归因(剪贴板兜底)
> 模型 `app/models/invite_fingerprint.py`(`InviteFingerprint`) · 仓库 `app/repositories/invite.py` · 接口 `app/api/v1/invite.py`:`POST /api/v1/invite/landing-track`(写,无鉴权)、`POST /api/v1/invite/bind`(读,指纹反查分支) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
一行 = 一次落地页访问的设备指纹快照。**剪贴板归因的兜底**:剪贴板 deferred-deeplink 是邀请归因主路径,但从"点下载"到"App 首启"之间剪贴板可能被覆盖、归因丢失。此表在落地页访问时先把 `(inviter, ip, screen, device_model, ua)` 记下来,被邀请人登录后剪贴板没拿到邀请码时,用 `(ip, device_model)` 反查 7 天内最近一条匹配 → 拿出邀请人 → 走 `bind(channel='fingerprint')`。任务来源见 #31 好友邀请任务 3。
## 用在哪 / 增删改查
- **C(插入)**:`POST /api/v1/invite/landing-track`(`landing_track`,**无需鉴权**,浏览器没 token)→ `invite_repo.record_fingerprint()`。B 浏览器打开 `dl.html?ref=<码>` 时 JS POST,服务端从 HTTP 头拿 `ip`(`_client_ip`,优先 `X-Forwarded-For` 第一段)、解析 UA 拿 `device_model`(`_parse_device_model`Android 正则抓 `;...Build/` 前 token = `Build.MODEL`),连同 JS 上报的 `screen` 一起入库。`record_fingerprint` 自行 commit(调用方此前无其它写)。无效码 / 拿不到 IP 走 silent 返回(`invalid_code`/`no_ip`,不抛 5xx 影响下载流程)。
- **R(反查)**:`POST /api/v1/invite/bind`(`bind_invite`)的指纹分支 → `resolve_inviter_by_fingerprint()`。仅在请求**无邀请码、有 `fingerprint`** 时触发:`WHERE ip=? AND device_model=? AND created_at > now - INVITE_FP_WINDOW_DAYS(7d)` `ORDER BY created_at DESC LIMIT 1` → 命中则取 `inviter_user_id` 对应用户 → 用其 `invite_code` 走原 `bind()`(同样过幂等/自邀/新人闸/发币逻辑);未命中返回 `fp_not_found`
- **U / D**:无。只增不改不删。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `inviter_user_id` | Integer | FK→user.id, index, NOT NULL | 落地页对应的邀请人(由 `?ref=<码>` 解析得到) |
| `ip` | String(64) | NOT NULL | 访问者 IP(服务端从 HTTP 头拿;nginx 反代时走 `X-Forwarded-For`)。入库 `[:64]`。**反查参与匹配** |
| `device_model` | String(64) | NOT NULL, default `""` | 设备型号(如 `PJF110``23046PNC9C`)。浏览器侧由 UA 正则解析,客户端侧 = `Build.MODEL`,跨端对齐。入库 `[:64]`。**反查参与匹配** |
| `screen` | String(32) | NOT NULL, default `""` | 屏幕分辨率 `"1080x2400"`(浏览器 `screen.w×h`,客户端 `DisplayMetrics`)。入库 `[:32]`。**只入库不反查**(见注意) |
| `user_agent` | Text | NOT NULL, default `""` | 完整 UA 串(debug / 排查"匹配不上"用,不参与匹配)。入库 `[:2000]` 防异常长 UA |
| `created_at` | DateTime(tz) | server_default now(), index | 落地页访问时间(= 7 天窗口判据 + 反查倒序键) |
## 关系 / Join Key
- `inviter_user_id``user.id`(硬 FK,多对一):一个邀请人的落地页可被多次访问。
- **反查组合键 = `(ip, device_model)` + `created_at > cutoff`**:等值匹配 IP 与型号、再取窗口内最近一条。注意 `screen` 虽进了组合索引最左前缀,但 SQL 不拿它做 WHERE 条件。
- 反查结果不直接 join 本表→`user`,而是 `db.get(User, fp.inviter_user_id)` 拿邀请人,再回 `invite_relation``bind()`
## 索引与约束
- PK `id`
- `ix_invite_fp_match``ip, screen, device_model, created_at`):反查主索引。`/bind` 兜底分支按 `ip + device_model + created_at` 范围扫 + 倒序 LIMIT 1SQLite/PG 优化器都能用上该复合索引。
- `ix_invite_fp_inviter``inviter_user_id`):统计/排查用("这个邀请人的落地页被谁访问过")。
## 注意
- **`screen` 只入库不反查(关键设计,`resolve_inviter_by_fingerprint` docstring)**:浏览器算物理像素走 `CSS × devicePixelRatio`、Android 走 `DisplayMetrics.widthPixels` 真实硬件值,两端浮点 round 必然 ±1 像素漂移(DPR 不严格是整数)→ 严格匹配注定撞不上。而**同 `device_model` 必同 `screen`**(同型号同硬件),故 `screen` 是冗余字段,删它不损精度。所以匹配只用 `(ip, device_model)`
- **组合索引为何含 `screen`**:索引列序是 `(ip, screen, device_model, created_at)`,但 WHERE 只用 `ip`/`device_model`/`created_at``screen` 夹在中间会打断"`ip` 之后直接接 `device_model`"的最左连续前缀,理论上略损此查询的索引效率(待确认:实测扫描量是否受影响;当前数据量下无感)。若后续优化,建议把索引调成 `(ip, device_model, created_at)`
- **7 天窗口(`rewards.INVITE_FP_WINDOW_DAYS`)的取舍**:太短(24h)覆盖不到"晚上看链接、第二天装";太宽(30d)IP/设备会漂、匹配错率上升;7 天兼顾"看广告→使用"周期与匹配精度。
- **撞错概率**:只有"同一 IP 下、同型号手机、7 天内同时被不同人邀请"才会归因到错的邀请人,中国家庭/公司 WiFi 场景概率极低;属可接受的工程取舍而非 bug。
- **与 invite_relation 的关系**:本表只是**线索库**,不发奖、不是结果。真正落地是反查出邀请人后回到 [invite_relation](./invite_relation.md) 的 `bind()`,写一行 `channel='fingerprint'` 并发币;本表本身不参与发奖与幂等。
- **迁移**`invite_fingerprint_table``down_revision=0cf18d590b1d`),晚于 `invite_code_and_relation`#24 的表先建)。
+46
View File
@@ -0,0 +1,46 @@
# invite_relation — 好友邀请绑定关系(注册即生效)
> 模型 `app/models/invite.py`(`InviteRelation`) · 仓库 `app/repositories/invite.py` · 接口 `app/api/v1/invite.py`:`POST /api/v1/invite/bind`(写)、`GET /api/v1/invite/me`(战绩)、`GET /api/v1/invite/invitees`(列表) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
一行 = 一次**成功的**邀请绑定。被邀请人(B)用邀请人(A)的邀请码完成绑定后写入,**注册即生效**:同一事务里给 A、B 各发 1 万金币(= 1 元,可提现)。数据来自 `bind()`,触发它的归因来源有三种(`channel`)。这是邀请功能的**结果表 / 账本**;邀请码本身存在 `user.invite_code`,不在这张表。
## 用在哪 / 增删改查
- **C(插入)**:`POST /api/v1/invite/bind`(`bind_invite`)→ `invite_repo.bind()`。过四道防线后建一行 `status='effective'`,**同事务**复用 `wallet.grant_coins` 给双方发金币(`grant_coins` 只 flush,由 `bind()` 统一 commit)→ "建关系 + 双方加金币"原子。
- **幂等键 = `invitee_user_id` 唯一**:一个 B 只能被绑一次,重复请求返回 `already_bound`,不重复发奖(仿 `ad_reward_record.trans_id` 思路)。并发下两请求同时插同一 invitee → 后者撞唯一约束 `IntegrityError`,`bind()` rollback 后改判 `already_bound` 兜底。
- 四道防线(`bind()` 内):① invitee 已绑 → `already_bound`;② 无效码 / 邀请人非 `active``invalid_code`;③ 自邀(inviter==invitee)→ `self_invite`;④ 新人闸 `_is_new_user`(B 的 `created_at``rewards.INVITE_NEW_USER_WINDOW_HOURS`=72h 内)→ 否则 `not_eligible`。只有全过才插行 + 发奖。
- **U / D**:无。这张表只增不改不删(纯账本)。
- **R**:
- `GET /api/v1/invite/me`(`my_invite`)→ `get_stats(inviter_id)`:`count(*)` 得已邀人数、`sum(inviter_coin)` 得累计金币。
- `GET /api/v1/invite/invitees`(`my_invitees`)→ `get_invitees(inviter_id, limit, offset)`:join `user` 出被邀请人列表(倒序分页),名字降级兜底 `nickname → wechat_nickname → 脱敏手机号`,`coins``inviter_coin`
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `inviter_user_id` | Integer | FK→user.id, index, NOT NULL | 邀请人(A) |
| `invitee_user_id` | Integer | FK→user.id, **UNIQUE**, index, NOT NULL | 被邀请人(B)。**唯一 = 幂等键**,一个 B 只能被归因一次 |
| `channel` | String(16) | NOT NULL, default `clipboard` | 归因来源:`clipboard`(剪贴板自动)/ `manual`(手动填码)/ `fingerprint`(指纹反查兜底)。入库前 `[:16]` 截断 |
| `status` | String(16) | NOT NULL, default `effective` | 当前只有 `effective`(注册即生效)。**预留** `pending`/`effective`:将来若改"完成首单才生效"时启用 |
| `inviter_coin` | Integer | NOT NULL, default 0 | 本次给 A 发的金币(记账留痕,=`rewards.INVITE_INVITER_COINS`=10000) |
| `invitee_coin` | Integer | NOT NULL, default 0 | 本次给 B 发的金币(=`rewards.INVITE_INVITEE_COINS`=10000) |
| `created_at` | DateTime(tz) | server_default now(), index | 绑定时间(= 列表倒序键) |
## 关系 / Join Key
- `inviter_user_id``user.id`(硬 FK,多对一):一个 A 可邀多个 B。
- `invitee_user_id``user.id`(硬 FK,**一对一**,唯一约束):一个 B 至多一行。
- 发金币时落 `coin_transaction`:`biz_type='invite_inviter'`(给 A,`ref_id=invitee.id`)/ `biz_type='invite_invitee'`(给 B,`ref_id=inviter.id`),双方 `ref_id` 互指对方便于对账。
- `get_invitees``InviteRelation JOIN user ON user.id = invitee_user_id` 取被邀请人资料;`total` 单独 `count``has_more`
## 索引与约束
- PK `id`
- `ix_invite_relation_inviter_user_id`(`inviter_user_id`):`get_stats` / `get_invitees` 按邀请人查。
- `ix_invite_relation_invitee_user_id`(`invitee_user_id`**UNIQUE**):幂等键 + 加速 `_relation_of_invitee` 反查。
- `ix_invite_relation_created_at`(`created_at`):列表倒序。
## 注意
- **防重复发奖三道**(`repositories/invite.py` docstring):① `invitee_user_id` 唯一(应用层 `_relation_of_invitee` 先查 + DB 唯一约束并发兜底);② 自邀屏蔽;③ 手机号天然唯一(每个 B = 一个真实手机号账号)= 限制刷量规模。
- **`status` 当前恒为 `effective`**:产品取"注册即生效"而非"完成首单才生效",`pending` 取值是为后者预留、目前不写入。
- **`inviter_coin`/`invitee_coin` 是留痕字段**:写死当时发的金币值,即便日后改奖励常量,历史行仍保留发奖时的额度,便于对账。
- **`channel` 三种取值**:`clipboard`(deferred-deeplink 主路径,落地页写剪贴板、首启读回)/ `manual`(用户在邀请页手输)/ `fingerprint`(剪贴板被覆盖时走指纹兜底,见 [invite_fingerprint](./invite_fingerprint.md));三者都汇入同一个 `bind()`,只 `channel` 不同。
- **风控缺口(#24 设计文档「上线前还差什么」标注)**:1 万金币可提现且**邀请人无总数上限**,接码平台批量注册新号绑同码即可刷;上线前需加 inviter 上限 + 基础风控。当前 MVP 仅靠"手机号唯一 + 72h 新人闸"挡。
- **alembic 多 head**:本表迁移 `invite_code_and_relation`(`down_revision=11a1d08c6f55`)与 `coupon_state_tables` 是同一父的兄弟迁移,合 main 前需建 merge 迁移,否则 prod `alembic upgrade head``Multiple head revisions`(#24 设计文档已警示)。
@@ -5,7 +5,7 @@
> **相关文档**:
> - 这份文档讲 **切引擎的完整步骤**(本机装 PG / 建库 / `scripts/init_postgres.py` 用法 / 生产切换)
> - [数据库迁移.md](./数据库迁移.md) 讲 **alembic 操作**(如何建表/升级/新增迁移/多 head 排查/迁移链 12 条)
> - [后端技术实现.md §10](./后端技术实现.md) 列待办与已知问题(含 `init_postgres.py` 已知小 bug)
> - [后端技术实现.md §10](../后端技术实现.md) 列待办与已知问题(含 `init_postgres.py` 已知小 bug)
>
> **背景假设**:当前生产**无真实用户数据**(MVP 阶段),所以整个迁移本质是"切引擎",不涉及数据搬迁。如果将来已有真实数据,本文档不适用,需补充 `pgloader` 演练 + 钱表金额逐行核对 + 停服窗口 + 回滚预案。
>
@@ -248,7 +248,7 @@ sudo systemctl restart shaguabijia-app-server
## 6. 顺便建议:同时装上 Redis
PG 上线后,Redis 是下一个明确要补的基础设施(已在 [待办与技术债.md](./待办与技术债.md) 里列了用途)。**建议本次维护窗口顺手把 Redis 也装上**,不开始用、只装服务,免得下次还要单独申请部署:
PG 上线后,Redis 是下一个明确要补的基础设施(已在 [待办与技术债.md](../guides/待办与技术债.md) 里列了用途)。**建议本次维护窗口顺手把 Redis 也装上**,不开始用、只装服务,免得下次还要单独申请部署:
```bash
sudo apt install redis-server
+71
View File
@@ -0,0 +1,71 @@
# price_observation — 比价沉淀的价格事实(平台/门店视角的价格资产层)
> 模型 `app/models/price_observation.py` · 仓库 `app/repositories/price_observation.py` · 上报端点 `app/api/internal/price.py`(`POST /internal/price-observation`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
每完成一次比价,pricebot 在 done 帧把「在某平台某店、这一篮菜/这单到手价多少钱」按平台逐条算出来,**server→server 内部上报**落这里(一次外卖比价 = 源平台 1 条 + N 个目标平台各 1 条,`scope='order'`)。**与登录无关、不依赖客户端鉴权**——比价透传链路(`compare.py`)当前不鉴权,只要比价跑到 done 就记,**匿名用户也记**(来源记 `source_device_id``source_user_id` 客户端带上时才有)。
与 [`comparison_record`](./comparison_record.md) 的区别(model docstring 明示,两表独立、互不影响):
- `comparison_record` = **用户视角**的「我的比价记录」,客户端登录后用带 JWT 的通道(`POST /compare/record`)主动上报,按 `(user_id, trace_id)` 存。
- `price_observation` = **平台/门店视角的客观价格事实**server 侧**无条件沉淀**,按 (平台, 门店, 菜, 时间) 组织。是未来「别人查过同店 → 直接秒回价格」价格大数据的源头(#21,「先存数据、用法后续」)。
> raw trace(逐 step 全过程)另存 pricebot 本地 `jsonl.gz`,本表只存**提炼后的价格事实**(本表可从 raw 重算);`trace_id` 软指 pricebot work_logs 供溯源/重算。
## 用在哪 / 增删改查
- **C(批量插入,幂等)**`POST /internal/price-observation``report_price_observation``repo.insert_batch`)。pricebot 在 done 帧把整批观测 POST 过来落库。幂等键 = `(trace_id, platform, scope)`:仓库**先查该 trace 已有的 `(platform, scope)`,只插新的**(不依赖 `ON CONFLICT`,跨方言 PG/SQLite 都安全),并发撞唯一约束则 `db.rollback()` 后整批跳过(本批本就幂等,**不重试**,见 P0 约定)。同批内还会去重一次(防 pricebot 同帧给重复平台)。返回 `{inserted, skipped}`
- **U / D**:无。append-only,无更新无删除。
- **R**:**当前无读取端点**(纯写入沉淀层,未来做「秒回价格」/聚合时再用)。
> 上报端点是 **server→server 专用**:不走用户 JWT,靠共享密钥头 `X-Internal-Secret`== `settings.INTERNAL_API_SECRET`,常量时间比较 `hmac.compare_digest`)。**密钥未配置(默认空)→ 直接 503**(挡住裸奔的写端点);不匹配 → 401。该密钥须与 pricebot 侧同值。
## 字段
批级字段(门店/菜篮/地理/来源,一次比价共享,由 `insert_batch` 灌进每条)vs 逐平台字段(`observations[]` 各自带)见下表「来源」列。
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `observed_at` | DateTime(tz) | server_default now(), index, NOT NULL | 观测时刻(≈比价 done 时;服务端落库时取 now)。按时间查「最近有效价」用 |
| `trace_id` | String(64) | index, NOT NULL | pricebot 侧 trace_id**幂等去重键**(含于唯一约束)+ 回指 A 层原始 trace(溯源/重算)。软指 pricebot work_logs,无硬 FK |
| `business_type` | String(16) | NOT NULL, default `food`, index | `food`(外卖,当前唯一接通)/ `ecom`(电商,二期)。**批级共享** |
| **门店维度** | | | denormalize 成文本,不建维度表;实体归一二期再做 |
| `platform` | String(32) | index, NOT NULL | 平台标识。**逐平台**(`observations[].platform`),含于唯一约束 |
| `platform_store_id` | String(64) | 可空 | 平台内门店 ID(抓得到才存,将来归一最稳的 key;**现在多为空**)。**逐平台** |
| `store_name` | String(128) | index, 可空 | 门店名。**批级共享**(源/目标同名场景下整批一份) |
| `city` | String(64) | 可空 | **批级共享** |
| `geohash` | String(16) | index, 可空 | 地理(**现在拿不到→留空**;二期把 device 经纬度透传进比价请求后回填)。**批级共享** |
| `lng` / `lat` | Float | 可空 | 同上,**批级共享** |
| **价格事实** | | | |
| `is_source` | Boolean | NOT NULL, default false | 是否源平台(发起比价那家)。源平台价也是真实观测,照记。**逐平台** |
| `scope` | String(16) | NOT NULL, default `order` | 口径:`order`(整单到手价,外卖当前唯一)/ `dish`(单菜单价,引擎暂不逐菜读,二期)。含于唯一约束。**逐平台** |
| `price_cents` | Integer | 可空 | 该口径到手价(分);**None = 该平台采集失败 / 门店打烊,无有效价**。**逐平台** |
| `coupon_saved_cents` | Integer | 可空 | 红包/券省了多少(分)。**逐平台** |
| `coupon_name` | String(64) | 可空 | 优惠来源名。**逐平台** |
| `store_closed` | String(32) | 可空 | 目标店打烊原因(**非空时 `price_cents` 为 None**)。**逐平台** |
| `rank` | Integer | 可空 | 本次比价全局名次(1=最便宜;按到手价升序,源/目标统一排)。**逐平台** |
| **明细(JSON** | | | |
| `dishes` | JSONPG JSONB | 可空 | 菜篮 `[{name, qty, subtotal?}]`(外卖)。dish 级单价二期从这里 + 引擎增强推。**批级共享** |
| `attrs` | JSONPG JSONB | 可空 | 灵活字段兜底(配送费/起送/活动名/跳过菜数 等),免得加字段就迁移。**逐平台**(`observations[].attrs` |
| **来源(溯源/去重/置信)** | | | |
| `source_device_id` | String(64) | index, 可空 | 来源设备;匿名比价也有。**批级共享** |
| `source_user_id` | Integer | index, 可空 | **比价链路不鉴权拿不到,客户端带上时才有**,先可空、不进唯一键。软指 `user.id`。**批级共享** |
| `confidence` | Float | NOT NULL, default 1.0 | 置信度,预留 |
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 落库时间 |
## 关系 / Join Key
- **无硬外键**
- `trace_id` **软指** pricebot work_logs(溯源/重算/排查),并作幂等去重键;与 `comparison_record.trace_id` 同源(同一次比价的 pricebot trace),但两表不互相 join、各存各的视角。
- `source_user_id` **软指** `user.id`(可空,不进唯一键、不阻塞落库)。
- `source_device_id` 与不鉴权期 `comparison_record.device_id` / 领券三表 `device_id` 同源,可用于对账(无 SQL 级约束)。
## 索引与约束
- PK `id`
- **UNIQUE(`trace_id`, `platform`, `scope`) = `uq_price_obs_trace_platform_scope`**:一次比价(trace)里同一平台、同一口径只一条 → pricebot 重试 / 客户端 replay 重复上报幂等去重。
- index`observed_at``trace_id``business_type``platform``store_name``geohash``source_device_id``source_user_id`(为将来按 平台/门店/菜/时间/地理 查询聚合预埋)。
## 注意
- **资产层定位「先存后用」**:当前**只写不读**(无 R 端点、不参与任何业务判断),是为「别人查过同店→秒回价格」大数据预先沉淀的原始事实。结构化列预埋查询/聚合维度,用法二期再定。
- **`attrs` / `dishes` 是 JSONB 兜底,别塞原始无障碍树**(几十 KB → 行膨胀);原始大树/逐 step 看 `trace_id` 指过去的 pricebot 落盘。`_JSON = JSON().with_variant(JSONB(), "postgresql")`PG 用 JSONB(可建 GIN),SQLite dev 退化通用 JSON(同 `comparison_record` / `coupon_claim_record`)。
- **`price_cents=None` 是有意义的观测**:表示该平台采集失败 / 门店打烊(看 `store_closed`),不是「没记」。统计「最低价」时要先过滤 None。
- **当前数据多为半空**`platform_store_id` 多为空(门店 ID 抓不全)、`geohash/lng/lat` 留空(device 经纬度二期才透传进比价请求)、`source_user_id` 多为空(链路不鉴权)。这些是**二期回填**计划,不是 bug。
- **幂等/并发**:不重试、失败就失败(P0 约定);同批去重 + 唯一键 + `IntegrityError` 回滚三重防御,但回滚是**整批**回滚(撞约束则本批全跳过)。
- 上报端点未配 `INTERNAL_API_SECRET` 时返 **503**(不是 401),这是「内部端点默认关闭」的安全默认态——本地 dev / 未启用时该表不会有新数据。
+5 -5
View File
@@ -1,6 +1,6 @@
# signin_boost_record — 签到膨胀记录
App 用户 Day1-Day13 当天签到后,看完激励视频可固定膨胀 2000 金币一次。Day14 不展示也不允许膨胀。本表记录膨胀动作,并用唯一约束防重复补发。
App 用户当天签到后,看完激励视频可固定膨胀一次(默认 3000 金币,`rewards.SIGNIN_BOOST_COIN`,运营后台 `app_config.signin_boost_coin` 可改)。循环最后一天(`cycle_day == SIGNIN_CYCLE_LEN`,即 7 天循环的第 7 天)不展示也不允许膨胀。本表记录膨胀动作,并用唯一约束防重复补发。
## 字段
@@ -9,14 +9,14 @@ App 用户 Day1-Day13 当天签到后,看完激励视频可固定膨胀 2000
| `id` | Integer | PK | 自增主键 |
| `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 |
| `signin_date` | Date | NOT NULL | 被膨胀的签到日期,北京时间 |
| `coin_awarded` | Integer | NOT NULL | 本次补发金币,默认固定 2000 |
| `coin_awarded` | Integer | NOT NULL | 本次补发金币,默认固定 3000(`rewards.get_signin_boost_coin`) |
| `ad_ref_id` | String(64) | nullable | 穿山甲 S2S 回调 `trans_id` |
| `created_at` | DateTime | NOT NULL | 创建时间 |
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间 |
## 约束
- `UNIQUE(user_id, signin_date)`:同一用户同一天只能膨胀一次。
- `UNIQUE(user_id, signin_date)` = `uq_signin_boost_user_date`:同一用户同一天只能膨胀一次。
## 关联
- 膨胀成功时写 `coin_transaction.biz_type=signin_boost``ref_id=ad_ref_id`
- 膨胀成功时写 `coin_transaction.biz_type=signin_boost``ref_id = ad_ref_id`(无 ad_ref_id 时回退当天日期 ISO 串)
+4 -4
View File
@@ -1,13 +1,13 @@
# signin_record — 签到记录(14 天循环)
# signin_record — 签到记录(7 天循环)
> 模型 `app/models/signin.py` · 仓库 `app/repositories/signin.py` · 接口 [signin-status](../api/signin-status.md) / [signin-do](../api/signin-do.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天然防一天签两次。`cycle_day`(1..14)决定发多少金币(档位 `rewards.SIGNIN_REWARDS`,默认 `(200,120,150,180,200,250,500,200,250,300,350,400,500,3000)`,运营后台 `app_config.signin_rewards` 可改),断签重置回 1;第 15 天回到第 1 天;`streak` 是连续天数(不封顶),给"已连续签到 N 天"展示。
App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天然防一天签两次。`cycle_day`(1..7)决定发多少金币(档位 `rewards.SIGNIN_REWARDS`,默认 `(200,200,300,200,400,400,800)`,一轮 7 天累计 2500 金币,运营后台 `app_config.signin_rewards` 可改),断签重置回 1;第 8 天回到第 1 天;`streak` 是连续天数(不封顶),给"已连续签到 N 天"展示。
## 用在哪 / 增删改查
- **C(插入)**:`POST /signin/do`(`do_signin`)。今天未签且校验通过 → 算出 `cycle_day`/`streak`(昨天签过则在上次基础上 +1、否则重置 1)→ 写一行 + `grant_coins(biz_type='signin', ref_id=今天日期)` 加币,**同事务 commit**。今天已签再调 → 抛 `AlreadySignedError`(409),不重复写。
- **U / D**:无。每天一行,历史不改。
- **R**:`GET /signin/status`(签到页:今天签没签、连续天数、14 档状态/金币、能否签),内部 `get_status` 读最近一条 + 推算今天档位。
- **R**:`GET /signin/status`(签到页:今天签没签、连续天数、7 档状态/金币、能否签),内部 `get_status` 读最近一条 + 推算今天档位。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
@@ -15,7 +15,7 @@ App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `signin_date` | **Date** | NOT NULL | 签到日期(**北京时间** `cn_today()` 的 date)。其 ISO 串被 `coin_transaction.ref_id` 引用(biz_type=signin) |
| `cycle_day` | Integer | NOT NULL | 取值 `1..14`:14 天循环里今天第几档,决定发币额;断签重置回 1 |
| `cycle_day` | Integer | NOT NULL | 取值 `1..7`:7 天循环里今天第几档,决定发币额;断签重置回 1 |
| `streak` | Integer | NOT NULL | 连续签到天数(≥1,不封顶);断签重置回 1 |
| `coin_awarded` | Integer | NOT NULL | 本次实发金币(= 当时档位金额) |
| `created_at` | DateTime(tz) | server_default now() | 时间 |
+85
View File
@@ -0,0 +1,85 @@
# store_mapping — 跨平台「同一家店」的 id/名/deeplink 身份映射(门店资产层)
> 模型 `app/models/store_mapping.py` · 仓库 `app/repositories/store_mapping.py` · 上报/反查端点 `app/api/internal/store.py``POST /internal/store-mapping``GET /internal/store-mapping/lookup``POST /internal/store-mapping/invalidate` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
每完成一次比价,pricebot 把同一家物理店在 **淘宝/美团/京东** 各自的店铺 id、店名、可复跳 deeplink **server→server 内部上报**落这里一行(一次比价 = 一个 trace = 一行,各平台「腿」解析出 id 后填空合并进同一行)。**与登录无关、不依赖客户端鉴权**(同 [`price_observation`](./price_observation.md)`source_user_id` 客户端带上时才有,匿名也记)。
与 [`price_observation`](./price_observation.md) 的区别(两表平行、独立):
- `price_observation` = 平台/门店视角的**价格事实**(某店这单多少钱)。
- `store_mapping` = 平台/门店视角的**身份映射**(同一家店在各平台的店铺 id/名/deeplink)。是未来「我见过这家店 → 跳过重新搜索/匹配,直接 deeplink 进店内搜索」的源头(#48)。
与 [`comparison_record`](./comparison_record.md)(用户视角、带 JWT、按 `user_id` 存)也无关:本表是 server 无条件沉淀的客观映射、匿名也记。
> **⚠️ 数据质量**:跨平台「同一家店」的连接来自 agent 的 **LLM 店铺匹配**,匹配错则一行里 `id_taobao``id_jd`/`name_meituan` **连到不同店**。本表是 **append-only 原始记录**(每比价一行、`trace_id` 幂等防重试重复),「一行里多平台 id 共存」只表示「两条腿搜同一个源店名各自匹配到了某家店」= name-match 置信度、**非已核实同一实体**;清洗/归一/精确匹配二期由下游做。
## 用在哪 / 增删改查
- **C / Uupserttrace_id 维度幂等 + 填空合并)**`POST /internal/store-mapping``report_store_mapping``repo.upsert`)。合并键 = `trace_id`(唯一约束)。一次跨平台比价 = 一个 trace = 一个真实店铺:某条腿解析出 id 先建行(填自己那几列),后到的腿(将来)再 upsert 进**同一行**。**合并策略 = 填空(fill-the-blanks**`_merge_fill_blanks` 只写该行当前为 NULL 的列、**绝不覆盖已有非空值**;共享列(geo/source/溯源)先到先得。首写用逐列 `setattr`(而非硬编码构造器,否则首写是美团/京东腿时其列会漏)。不依赖 `ON CONFLICT`,跨方言安全;并发撞唯一约束则 `rollback()` 后转填空合并路径。返回 `{inserted, row_id}`inserted=1 新建 / 0 合并)。
- **R(lookup 反查,命中即省一整段现场反查)**`GET /internal/store-mapping/lookup?source_platform=&name=&lat=&lng=``lookup_nearest`)。**比价前**按「源平台店名」反查各目标平台已沉淀的店铺 id,命中就让 pricebot **直接 deeplink 跳店内搜索**,省掉「开平台→进店→分享→反查」整段。机制:匹配键 = 源平台对应的 name 列精确等于 `name`;每个目标**分别**取「含该目标 id 的同名候选里最近一条」(有入参 geo 且候选带 geo → haversine 取最近,否则取 `created_at` 最新);**排除源平台自己**(不复用源平台 id);**过滤掉 deeplink 已失效的候选**(见下)。返回 `{target_key: {id..., deeplink, row_id, dist_km?}}`,空 = 没缓存、pricebot 走现场反查老路。
- **Udeeplink 失效标记)**`POST /internal/store-mapping/invalidate``{platform, shop_id}`)。pricebot 撞错误页回退时上报,按 `shop_id` 把**所有**匹配行的对应 `*_deeplink_invalid_at``now()`(幂等:已标记的跳过)。`taobao``mark_taobao_deeplink_invalid``jd``mark_jd_deeplink_invalid`;**其它平台(含 meituan)当前 no-op 返回 `affected=0`、不报错**(向后兼容 pricebot 将来扩展)。返回 `{ok, affected}`
- **D**:无业务删除。
> 上报/反查/失效端点全是 **server→server 专用**,复用 `price.py``_check_secret`(同一 `X-Internal-Secret` 密钥,未配置→503,不匹配→401)。
## 字段
各平台列**按比价角色稀疏填充**(淘宝当目标才有 `*_taobao`,依此类推)。
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| **跨平台身份** | | | 同一物理店在各平台的 id/名 |
| `id_taobao` | String(64) | index, 可空 | 淘宝 = 分享短链解析出的 **shopId**(淘宝当目标、走完取 id 流程才有)。门店稳定数字主键 |
| `name_taobao` | String(128) | 可空 | 店铺页 a11y `content_desc`「店铺标题:xxx」剥前缀 |
| `id_meituan` | String(64) | index, 可空 | **留给将来 CPS API 的稳定数字 poi_id**;当前无 share→稳定 id 机制 → 多为空(可复跳票据见 `meituan_poi_id_str` |
| `name_meituan` | String(128) | 可空 | 源平台店名(来自 intent/agent 匹配名) |
| `id_jd` | String(64) | index, 可空 | 京东 = **storeId**(门店稳定数字主键,同 taobao shopId3.cn 短链反查)。`venderId` 另存 `jd_vender_id` |
| `name_jd` | String(128) | 可空 | 京东店铺页店名 |
| **地理** | | | 同名店异地区分 / 地理分桶匹配主要燃料 |
| `city` | String(64) | 可空 | |
| `geohash` | String(16) | index, 可空 | |
| `lng` / `lat` | Float | 可空 | lookup 时按入参 geo 取最近候选用 |
| `taobao_address` | String(256) | 可空 | 淘宝门店地址(店铺页 a11y 抓到才有;比经纬度更利于人工/LLM 匹配) |
| **溯源 / 画像** | | | |
| `source_platform` | String(32) | index, 可空 | 发起比价那家:`meituan` / `taobao_flash` / `jd_waimai` … lookup 据此选 name 列、排除源平台自己 |
| `business_type` | String(16) | NOT NULL, default `food` | |
| `trace_id` | String(64) | NOT NULL | **幂等合并键**(唯一约束)+ 回指原始 trace(溯源)。软指 pricebot work_logs,无硬 FK |
| `source_device_id` | String(64) | index, 可空 | |
| `source_user_id` | Integer | index, 可空 | **比价链路不鉴权拿不到,客户端带上时才有**,软指 `user.id` |
| **淘宝原料**(可复跳/重解析/调试) | | | |
| `taobao_share_url` | String(256) | 可空 | `m.tb.cn` 短链 |
| `taobao_resolved_url` | Text | 可空 | 解析出的目标 URL(含 shopId)。Text 因 URL 可能很长 |
| `taobao_deeplink` | Text | 可空 | 拼好的 et-store/search deeplink |
| `taobao_deeplink_invalid_at` | DateTime(tz) | 可空 | **淘宝 deeplink 失效标记**#53)。撞「页面出错了」降级页时被置;**NULL=有效**。lookup 反查过滤掉非 NULL 的淘宝候选 |
| **美团原料**dpurl.cn 短链 → 302 反查 → imeituan:// | | | |
| `meituan_poi_id_str` | String(64) | index, 可空 | **⚠️ 每次分享重新加密、非稳定主键**(调研 §八)→ 单列存「可复跳的一次性票据」,**不进 `id_meituan`** |
| `meituan_share_url` | String(256) | 可空 | `dpurl.cn` 短链 |
| `meituan_resolved_url` | Text | 可空 | 302 落地 menu URL(含 poi_id_str |
| `meituan_deeplink` | Text | 可空 | 拼好的 `imeituan://` 店内搜索 deeplink |
| **京东原料**(3.cn 短链 → 重定向反查 → openapp.jdmobile:// | | | |
| `jd_vender_id` | String(64) | index, 可空 | venderId 单列存(deeplink 模板 venderId+storeId 都要;venderId 是商家维度、可跨门店,与门店 storeId 分开记) |
| `jd_share_url` | String(256) | 可空 | `3.cn` 短链 |
| `jd_resolved_url` | Text | 可空 | 反查出的目标 `openapp.jdmobile://` URL |
| `jd_deeplink` | Text | 可空 | 拼好的 pages/search 店内搜索 deeplink |
| `jd_deeplink_invalid_at` | DateTime(tz) | 可空 | **京东 deeplink 失效标记**#56,对标淘宝)。撞「当前门店超出配送范围」页时被置;**NULL=有效**。lookup 过滤非 NULL 的京东候选 |
| `attrs` | JSONPG JSONB) | 可空 | 灵活字段兜底,免得加字段就迁移 |
| `created_at` | DateTime(tz) | server_default now(), index, NOT NULL | lookup 同名候选无 geo 时按它取最新 |
## 关系 / Join Key
- **无硬外键**
- `trace_id` **软指** pricebot work_logs(溯源/排查),并作 upsert 幂等合并键。
- `source_user_id` **软指** `user.id`(可空,不进唯一键、不阻塞落库)。
- lookup 的匹配靠**店名字符串精确相等**(`_SOURCE_NAME_COLUMN` 把源平台映到对应 name 列)+ 可选 geo 取最近,**非 id 级 join**。
## 索引与约束
- PK `id`
- **UNIQUE(`trace_id`) = `uq_store_mapping_trace`**:一次比价(trace)只一行 → pricebot 重试 / 客户端 replay 重复上报幂等去重;后到的平台腿填空合并进同一行。
- index`id_taobao``id_meituan``id_jd``meituan_poi_id_str``jd_vender_id``geohash``source_platform``source_device_id``source_user_id``created_at`(lookup 反查 / 失效标记按 id 列查需要)。
## 注意
- **deeplink 失效→标记→回退闭环**#53 淘宝 / #56 京东,本表最关键机制):缓存的 deeplink 会过期(淘宝撞「页面出错了」、京东撞「当前门店超出配送范围」)。pricebot 撞到错误降级页 → 调 `/internal/store-mapping/invalidate` 按 shopId/storeId 把**所有**相关行打上 `*_deeplink_invalid_at` → 后续 `lookup_nearest` **整条排除**失效候选(当没缓存,pricebot 走正常搜店)→ 重搜成功会写**新行**自然覆盖。按 shopId 标记所有行(不是单行)是因为同一坏 id 可能散在多次比价的多行里,全标掉才能让 lookup 不再返回它。
- **美团没有失效标记**`meituan_poi_id_str` 是「每次分享重新加密」的一次性票据、本就不稳定不复用为稳定 id,故无 invalidate 路径(`invalidate` 端点对 meituan no-op)。三平台 share→id 反查机制不对称:淘宝 `m.tb.cn`→shopId(稳定)、京东 `3.cn`→venderId+storeId(稳定)、美团 `dpurl.cn`→poi_id_str**非稳定**)。
- **资产层「先存后用」+ append-only**:lookup 是已接通的读用途(省现场反查),但表本身是原始记录、不做清洗归一。「一行多平台 id 共存」**不代表已核实同一实体**——下游做精确匹配前别当真值用。
- **数据质量依赖 LLM 匹配**:跨平台连接来自 agent 店铺匹配,**可能连错店**;排查某行可疑映射时回 `trace_id` 指的 pricebot trace 看当时的匹配上下文。
- **`attrs` 是 JSONB 兜底,别塞大树**(同 `price_observation`);`_JSON = JSON().with_variant(JSONB(), "postgresql")`PG JSONB / SQLite dev 通用 JSON。
- **填空合并不覆盖**:共享列(geo/source/device)先到先得,后到的腿只填自己那几列的空位;想「纠正」已写错的列**当前没有路径**(append-only,靠新 trace 写新行)。
- 上报端点未配 `INTERNAL_API_SECRET` 时返 **503**(默认关闭态),本地 dev / 未启用时该表不会有新数据。
+6 -3
View File
@@ -13,24 +13,27 @@
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | 用户主键;被 15 张表的 `user_id` 外键引用 |
| `id` | Integer | PK, autoincrement | 用户主键;被多张业务表的 `user_id` 外键引用 |
| `phone` | String(20) | UNIQUE, index, NOT NULL | 手机号(登录主键)。注销后置 `deleted_<id>` 占位释放唯一约束 |
| `username` | String(11) | UNIQUE, index, NOT NULL | 对外展示账号 ID:11 位纯数字、首位非 1(与手机号天然区分)、全局唯一、创建时随机生成、不可变、**不参与登录**(登录仍走 phone)。生成见 `repositories/user._gen_username` |
| `register_channel` | String(20) | NOT NULL, default `jverify` | 取值:`jverify`(极光一键)/ `sms`(短信);`savings_record` 的对比人群另有种子用户用 `seed` |
| `nickname` | String(64) | nullable | 通用昵称(`PATCH /user/profile` 写) |
| `avatar_url` | String(512) | nullable | 通用头像相对 URL `/media/avatars/...`(`POST /user/avatar` 写) |
| `invite_code` | String(16) | UNIQUE, index, nullable | 邀请码:每用户一个稳定短码(懒生成,见 `repositories/invite.ensure_code`),分享链接/二维码里带它。UNIQUE 允许多个 NULL(未生成的用户) |
| `wechat_openid` | String(64) | UNIQUE, index, nullable | 微信 openid(提现转账用);**一微信一账号**,多个 NULL 允许(多个未绑用户) |
| `wechat_nickname` | String(64) | nullable | 微信昵称(绑定时拉,展示在提现绑定卡;与通用 nickname 分开存) |
| `wechat_avatar_url` | String(512) | nullable | 微信头像 URL |
| `status` | String(20) | NOT NULL, default `active` | 取值:`active`(可登录/鉴权)/ `disabled`(admin 禁用)/ `deleted`(已注销);仅 active 能通过鉴权 |
| `debug_trace_enabled` | Boolean | NOT NULL, default false, server_default false | 调试链接权限:开了的用户在比价完成弹窗 + 比价记录页能看到「复制调试链接」按钮(运营后台按用户配置);`/me` 与登录响应带出给前端做条件渲染 |
| `created_at` | DateTime(tz) | server_default now() | 注册时间 |
| `last_login_at` | DateTime(tz) | 应用层 default utcnow | 最近登录时间(每次登录更新) |
## 关系 / Join Key
- **被引用方(本表是 1,对方是 N/1)**:`coin_account``coin_transaction``cash_transaction``withdraw_order``wechat_transfer_authorization``signin_record``signin_boost_record``user_task``comparison_record``comparison_milestone_claim``savings_record``ad_reward_record``ad_watch_log``ad_ecpm_record``ad_feed_reward_record``price_report``feedback``user_id` 均 → `user.id`
- **被引用方(本表是 1,对方是 N/1)**:`coin_account``coin_transaction``cash_transaction``withdraw_order``wechat_transfer_authorization``signin_record``signin_boost_record``user_task``comparison_record``comparison_milestone_claim``savings_record``ad_reward_record``ad_watch_log``ad_ecpm_record``ad_feed_reward_record``price_report``feedback``user_id` 均 → `user.id`;`invite_relation``inviter_user_id` / `invitee_user_id` 均 → `user.id`
- 与 `admin_user` **无任何关联**(C 端用户 vs 后台管理员,两套体系)。
## 索引与约束
- PK `id`;UNIQUE+index `phone`;UNIQUE+index `wechat_openid`(允许多 NULL)。
- PK `id`;UNIQUE+index `phone``username``invite_code`(允许多 NULL)、`wechat_openid`(允许多 NULL)。
## 注意
- `nickname/avatar_url`(通用)与 `wechat_nickname/wechat_avatar_url`(微信)**分开存,不互相覆盖**。
+4 -3
View File
@@ -2,10 +2,11 @@
> 模型 `app/models/task.py` · 仓库 `app/repositories/task.py` · 接口 [tasks-list](../api/tasks-list.md) / [tasks-claim](../api/tasks-claim.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
像"打开消息提醒"这类**只能领一次**的任务,领奖后写一行,`(user_id, task_key)` 唯一防重复领。可循环领取的任务(签到)不走这张表,有专表 `signin_record`。任务字典与奖励来自 `rewards.TASK_REWARDS`(默认 `{enable_notification: 1000}`,运营后台 `app_config.task_rewards` 可改)。
**只能领一次**的任务,领奖后写一行,`(user_id, task_key)` 唯一防重复领。可循环领取的任务(签到)不走这张表,有专表 `signin_record`。任务字典与奖励来自 `rewards.TASK_REWARDS`(默认 `{enable_notification: 750}`,运营后台 `app_config.task_rewards` 可改)。
> ⚠️ 当前 `TASK_REWARDS` 里唯一的 `enable_notification`(打开消息提醒)已改为**可重复领取**任务(每次减半,见 `rewards.notification_reward`),它**不写本表**——领取次数靠数 `coin_transaction` 的正向流水条数(`biz_type='task_enable_notification'`),`ref_id` 带序号 `task_key:N`。因此本表目前实际**没有被任何已定义任务写入**;待新增真正的一次性任务后才会落行。
## 用在哪 / 增删改查
- **C(插入)**:`POST /tasks/claim`(`claim_task`)`task_key` 合法 + 未领过 → 写一行(`status='completed'`)+ `grant_coins(biz_type='task_<key>', ref_id=task_key)` 加币,**同事务**。未知 key 抛 `UnknownTaskError`(404);已领抛 `AlreadyClaimedError`(409)。
- **C(插入)**:`POST /tasks/claim`(`claim_task`),**仅一次性任务**走这条路:`task_key` 合法 + 未领过 → 写一行(`status='completed'`)+ `grant_coins(biz_type='task_<key>', ref_id=task_key)` 加币,**同事务**。未知 key 抛 `UnknownTaskError`(404);已领抛 `AlreadyClaimedError`(409)。可重复任务(`enable_notification`)在 `claim_task` 里走另一分支:**不写本表**,只发币(`ref_id=task_key:N`),减半到底(`notification_max_claims`)后再领抛 `AlreadyClaimedError`
- **U / D**:无。领过即终态。
- **R**:`GET /tasks/list`(任务页:列出所有已知任务 + 是否已领),`list_tasks` 把本表已领的 `task_key` 集合与任务字典对照。
@@ -14,7 +15,7 @@
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `task_key` | String(48) | NOT NULL | 任务标识,取值见 `rewards.TASK_REWARDS`(当前仅 `enable_notification`)。**被 `coin_transaction.ref_id` 引用**(biz_type=`task_<key>`) |
| `task_key` | String(48) | NOT NULL | 任务标识,取值见 `rewards.TASK_REWARDS`(`enable_notification` 是可重复任务,不落本表)。一次性任务领取时其 `task_key` = 配套 `coin_transaction.ref_id`(biz_type=`task_<key>`) |
| `status` | String(16) | NOT NULL, default `completed` | 当前恒为 `completed`(领即完成) |
| `coin_awarded` | Integer | NOT NULL, default 0 | 该任务发放金币(= `TASK_REWARDS[task_key]`) |
| `completed_at` | DateTime(tz) | server_default now() | 领取时间 |
+1
View File
@@ -44,6 +44,7 @@ reviewing ──admin 审核拒绝──▶ rejected(已退款)
## 索引与约束
- PK `id`;UNIQUE+index `out_bill_no`;index `user_id``created_at`
- 部分唯一索引 `ux_withdraw_order_user_active`(`user_id`),条件 `status IN ('reviewing', 'pending')`:每个用户同时只能有一笔在途(待审核 / 打款中)提现单,DB 层挡并发重复提现。
## 注意
- **资金安全**:原子扣款(`WHERE cash_balance_cents >= amount`)+ `out_bill_no` 幂等 + 结果不明时**先查单再决定,绝不盲目退款**(防退款后又到账)+ 孤儿 pending 单 `reconcile_pending_withdraws` 对账兜底。
@@ -6,7 +6,7 @@
> **相关文档**:
> - 这份文档讲 **alembic 操作**(如何建表/升级/新增迁移/多 head 排查)
> - [postgres-migration.md](./postgres-migration.md) 讲 **从 SQLite 切到 PostgreSQL** 的完整步骤(本机安装/建库/`scripts/init_postgres.py` 用法)
> - 配合 [后端技术实现.md §10](./后端技术实现.md) 看待办与已知问题
> - 配合 [后端技术实现.md §10](../后端技术实现.md) 看待办与已知问题
---
@@ -40,7 +40,7 @@
## 技术债(暂不处理,知情即可)
- **agent 接口公网裸奔**:不鉴权 = 任何人可调,烧 pricebot 的 LLM 算力。与美团 3 接口现状同级,记账后随 P1 一起补。
- **既有债(已记录在别处)**:美团 `referral-link``sid` 客户端可覆盖刷分佣;`logout` 占位不吊销 token`SMS_MOCK=true` 全站免密登录;`JWT_SECRET_KEY` 默认值可伪造 token。详见 [后端技术实现.md](./后端技术实现.md) 与 [api/](./api/)。
- **既有债(已记录在别处)**:美团 `referral-link``sid` 客户端可覆盖刷分佣;`logout` 占位不吊销 token`SMS_MOCK=true` 全站免密登录;`JWT_SECRET_KEY` 默认值可伪造 token。详见 [后端技术实现.md](../后端技术实现.md) 与 [api/](./api/)。
---
@@ -87,7 +87,7 @@
## 已解决
- ✅ **短信验证码接入极光(real 模式,2026-06-03)**:`/sms/send`+`/sms/login` 从 mock 升级为真实可用。`integrations/sms.py` 走**自定义验证码模式**(本服务 `secrets` 生成 6 位码 + 进程内存存储 + 极光 `/v1/messages` 只负责发 + 本地校验一次性/防爆破);鉴权**复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`**(同一极光应用,**上线只需 `SMS_MOCK=false`**,无需额外凭证;`sign_id=31729`/`temp_id=1` 已审核就绪)。防刷四层(单号冷却 + 单号每日上限 + 单 IP `rate_limit` + 单码失败次数上限);`SmsError.status_code` 让发送失败按类型返回 429/503/400。**mock 保留"任意6位通过"**(不动其他 11 个测试文件的 `_login` helper);`tests/test_auth.py` 加 real 模式单测(极光协议 / 一次性 / 防爆破 / 余额回滚)。文档 [integrations/sms.md](./integrations/sms.md)。**遗留**:验证码进程内存,多 worker 需迁 DB/Redis(下方技术债)。
- ✅ **短信验证码接入极光(real 模式,2026-06-03)**:`/sms/send`+`/sms/login` 从 mock 升级为真实可用。`integrations/sms.py` 走**自定义验证码模式**(本服务 `secrets` 生成 6 位码 + 进程内存存储 + 极光 `/v1/messages` 只负责发 + 本地校验一次性/防爆破);鉴权**复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`**(同一极光应用,**上线只需 `SMS_MOCK=false`**,无需额外凭证;`sign_id=31729`/`temp_id=1` 已审核就绪)。防刷四层(单号冷却 + 单号每日上限 + 单 IP `rate_limit` + 单码失败次数上限);`SmsError.status_code` 让发送失败按类型返回 429/503/400。**mock 保留"任意6位通过"**(不动其他 11 个测试文件的 `_login` helper);`tests/test_auth.py` 加 real 模式单测(极光协议 / 一次性 / 防爆破 / 余额回滚)。文档 [integrations/sms.md](../integrations/sms.md)。**遗留**:验证码进程内存,多 worker 需迁 DB/Redis(下方技术债)。
- ✅ **比价记录落库(server + client,2026-05-31)**:每次比价 done 后客户端上报、按 user 落库,作「我的比价记录」数据源 + 用户级画像沉淀。
- **server**(本仓):新表 `comparison_record`(独立于 `savings_record`;结构化列 + `items`/`comparison_results`/`skipped_dish_names`/`raw_payload` 四个 JSON(B) 列)+ 3 个**鉴权**端点 `POST /api/v1/compare/record`(`(user_id,trace_id)` 幂等 upsert,best/saved/is_source_best/status 服务端从 comparison_results 派生)/ `GET /api/v1/compare/records`(游标分页)/ `GET /records/{id}`(含 raw_payload);`models/comparison.py` + `repositories/comparison.py` + `schemas/compare_record.py` + `api/v1/compare_record.py` + 迁移 `comparison_record_table`(head `b2c3d4e5f6a7`)+ `tests/test_compare_record.py`(8 例全过)+ `docs/api/compare-record-*.md`
- **client**(android 仓):`PriceBotService.runTask()` 比价 done 后(`lastDoneParams!=null`,成功/引擎失败都报)用独立 IO 协程尽力上报;`Protocol.CompareRecordRequest.fromComparison()` 从 calibration+done.params 零翻译组装;`ApiClient.reportCompareRecord()` 走新建的 `authedClient`(复用 `AuthInterceptor`+`RefreshAuthenticator`,自动 Bearer+401 刷新)。领券不报(非价格对比,本期范围只外卖)。
@@ -99,7 +99,7 @@
- ✅ **无障碍身份统一傻瓜比价**:服务名/图标继承 app;`accessibility_service_description`/前台通知/`GuideCopy`/引导 Activity 里的 "PriceBot" 全改「傻瓜比价」;app 图标换成完整 `logo1`(adaptive 居中+黄边,不裁切)。
- ✅ **GuideOverlayService 组件名 bug**:批量 sed 改包名误把无障碍服务 ComponentName 的 packageName 改成 `…shaguabijia.agent`(应是 applicationId `…shaguabijia`)→ 无障碍开启检测永远 false;改用 `PermissionHelper.isAccessibilityEnabled` 动态构造。
- ✅ **BuildConfig 包名**:`com.pricebot.app.BuildConfig` import 改本 app 包名。
- ✅ **SQLite → PostgreSQL 切换**(2026-05-29,PR #6 + alembic 合并迁移):新增 `scripts/init_postgres.py` 一键脚本、`psycopg[binary]>=3.1` 依赖、`savings_record.dishes` 列改 JSONB、连接池参数(`pool_size=10/overflow=20/recycle=3600`)。SQLite 仍可作开发兜底(只改 `DATABASE_URL`)。详见 [postgres-migration.md](./postgres-migration.md)。
- ✅ **SQLite → PostgreSQL 切换**(2026-05-29,PR #6 + alembic 合并迁移):新增 `scripts/init_postgres.py` 一键脚本、`psycopg[binary]>=3.1` 依赖、`savings_record.dishes` 列改 JSONB、连接池参数(`pool_size=10/overflow=20/recycle=3600`)。SQLite 仍可作开发兜底(只改 `DATABASE_URL`)。详见 [postgres-migration.md](../database/postgres-migration.md)。
- ✅ **外卖比价透传 2 端点**(2026-05-27,`feat(compare)`):`/api/v1/intent/recognize` + `/api/v1/price/step``compare.py`,纯 body 透传到 pricebot-backend,`PRICEBOT_COMPARE_TIMEOUT_SEC=60` 独立超时。电商 2 个待接(`compare.py` 加两行即可)。
- ✅ **用户资料 + 帮助反馈接口**(2026-05-28,PR #4):`PATCH /api/v1/user/profile` 改昵称、`POST /api/v1/user/avatar` 上传头像(魔数嗅探)、`DELETE /api/v1/user` 注销账号(软删+匿名化)、`POST /api/v1/feedback` 提交反馈(含截图);新建 `feedback` 表 + `core/media.py` + `/media` 静态服务。
- ✅ **美团 3 端点未配凭证降级**(2026-05-28,`feat(meituan)`):未配 `MT_CPS_APP_KEY``coupons`/`feed`/`referral-link` 直接返空(不再 502),解决新开发机首屏炸的问题。**坏副作用**:`/feed` 跟"已配但调用失败"路径在响应上无法区分(都是 200 + 空 items),靠 `/health``mt_cps_configured` 字段区分。
@@ -9,7 +9,7 @@
客户端**不发奖**,`onRewardArrived` 只触发"去后端刷余额"。客户端被破解也刷不到钱。
- 后端发奖**幂等**(按 `trans_id` 去重)+ **每日上限**(`DAILY_AD_REWARD_LIMIT`,按北京时间)。
- 关键常量:`app/core/rewards.py``AD_REWARD_COIN=100``DAILY_AD_REWARD_LIMIT=500``VIDEO_ROUND_REQUIRED_COUNT=1``VIDEO_ROUND_COOLDOWN_SECONDS=3`(每次广告关闭后 3 秒短冷却)。旧的观看时长闸 `DAILY_AD_WATCH_SECONDS_LIMIT=0` 表示停用。
- **4 态 CTA**(2026-05-29 PR #5):客户端任务行按钮在 Normal / Loading / Capped / CoolingDown 之间切,**全由后端 `reward-status``round_count` + `cooldown_until` 派生**(权威源),跨设备/重装/杀进程一致。详见 [api/ad-reward-status](./api/ad-reward-status.md)。
- **4 态 CTA**(2026-05-29 PR #5):客户端任务行按钮在 Normal / Loading / Capped / CoolingDown 之间切,**全由后端 `reward-status``round_count` + `cooldown_until` 派生**(权威源),跨设备/重装/杀进程一致。详见 [api/ad-reward-status](../api/ad-reward-status.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=<m-key>`(配齐才不返 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. 部署 + 包名
+1 -1
View File
@@ -47,4 +47,4 @@ api 层 3 个端点都在入口处 `if not settings.mt_cps_configured:` 早返
## 备注
- 美团接口当前**无鉴权**,且换链的 `sid` 允许客户端传值覆盖默认渠道——见对应 api 文档「备注」
- 未配凭证降级、feed 静默吞错都在 [待办与技术债.md](../待办与技术债.md) 已记账
- 未配凭证降级、feed 静默吞错都在 [待办与技术债.md](../guides/待办与技术债.md) 已记账
+9 -3
View File
@@ -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,别接错。
+1 -1
View File
@@ -56,4 +56,4 @@
3. 真机发一条验证:收到短信 + 能登录
## 已知局限
**验证码存进程内存**:单 worker uvicorn 够用;重启丢码(用户重发即可);**多 worker / 多机不共享 → 冷却 / 每日上限 / 校验失效**,扩 worker 前迁移到 DB/Redis。见 [待办与技术债](../待办与技术债.md)。
**验证码存进程内存**:单 worker uvicorn 够用;重启丢码(用户重发即可);**多 worker / 多机不共享 → 冷却 / 每日上限 / 校验失效**,扩 worker 前迁移到 DB/Redis。见 [待办与技术债](../guides/待办与技术债.md)。
+8 -8
View File
@@ -36,7 +36,7 @@
| ASGI | uvicorn[standard] | 生产 `--host 127.0.0.1 --port 8770` |
| ORM | SQLAlchemy 2.0(Mapped 风格) | |
| 迁移 | Alembic | |
| DB | **PostgreSQL 16(生产)/ SQLite(开发兜底)** | 一键脚本 `scripts/init_postgres.py` 建库 + 写 `.env` + 跑迁移;开发环境改 `DATABASE_URL=sqlite:///./data/app.db` 即回退。无 Redis。详见 [docs/postgres-migration.md](./postgres-migration.md) |
| DB | **PostgreSQL 16(生产)/ SQLite(开发兜底)** | 一键脚本 `scripts/init_postgres.py` 建库 + 写 `.env` + 跑迁移;开发环境改 `DATABASE_URL=sqlite:///./data/app.db` 即回退。无 Redis。详见 [docs/database/postgres-migration.md](./database/postgres-migration.md) |
| DB 驱动 | psycopg3(`psycopg[binary]>=3.1`) | `DATABASE_URL=postgresql+psycopg://...`,**不要装 psycopg2**——SQLAlchemy 2.0 时代默认 psycopg3 |
| Auth | PyJWT(HS256) | access + refresh |
| 极光 | httpx + cryptography | REST 验 token + RSA 解密手机号 |
@@ -125,8 +125,8 @@ tests/ # pytest(auth / health / welfare / withdraw / ad_re
run.sh # 本地启动脚本(自动先跑迁移再起服务)
docs/api/ # API 接口文档(索引 README + 一接口一文件)
docs/integrations/ # 集成层实现文档(SDK 签名/加解密/协议细节)
docs/数据库迁移.md # Alembic 迁移指南(如何建表/升级/新增迁移)
docs/postgres-migration.md # SQLite → PostgreSQL 切换指南(配套 scripts/init_postgres.py)
docs/database/数据库迁移.md # Alembic 迁移指南(如何建表/升级/新增迁移)
docs/database/postgres-migration.md # SQLite → PostgreSQL 切换指南(配套 scripts/init_postgres.py)
```
> **命名说明**:`api/v1/``v1` 用于 URL 版本化(移动端无法强制即时升级,需新旧版本并存能力);`integrations` 装外部 SDK 集成、`repositories` 装数据访问、`core` 装基础设施,三者分离。**数据访问层统一在 `repositories/`**(早期叫 `crud/`,2026-05 已整体并入,`crud/` 不再存在)。`coupon.py` 是领券透传,勿与 `meituan.py` 里的 `coupons`(券列表)混淆。
@@ -255,7 +255,7 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
`upsert_user_for_login`:phone 存在则更新 `last_login_at`,不存在则注册(注册即登录)。
**Alembic 迁移**:`alembic/versions/` 当前 11 个迁移 + 1 个合并迁移(`f01db5d77dac_merge_pg_jsonb_and_feedback_heads.py`,2026-05-29 创建——实习生 PG 分支和 user-profile-feedback 分支并行开发时 down_revision 都挂在 `c8d9e0f1a2b3` 上,导致多 head,合并迁移把两条链合并)。详见 [数据库迁移.md](./数据库迁移.md)。
**Alembic 迁移**:`alembic/versions/` 当前 11 个迁移 + 1 个合并迁移(`f01db5d77dac_merge_pg_jsonb_and_feedback_heads.py`,2026-05-29 创建——实习生 PG 分支和 user-profile-feedback 分支并行开发时 down_revision 都挂在 `c8d9e0f1a2b3` 上,导致多 head,合并迁移把两条链合并)。详见 [数据库迁移.md](./database/数据库迁移.md)。
---
@@ -277,7 +277,7 @@ scp secrets/jverify_rsa_private.pem server:/opt/shaguabijia-app-server/secrets/
ssh server "cd /opt/shaguabijia-app-server && .venv/bin/alembic upgrade head && systemctl restart shaguabijia-app-server"
```
完整 PG 切换流程见 [docs/postgres-migration.md](./postgres-migration.md)。
完整 PG 切换流程见 [docs/database/postgres-migration.md](./database/postgres-migration.md)。
**生产 checklist(均为上线必查)**:
@@ -316,11 +316,11 @@ conda activate price # 首次:pip install -e .
| 美团接口无鉴权 + sid 可覆盖 | 评估加鉴权/锁定 sid(注意首页要求未登录可见) |
| 美团接口未配凭证降级 | 未配 `MT_CPS_APP_KEY` 时 3 端点返空(不报 502),`/feed` 跟"已配但调用失败"路径无法区分——见 [integrations/meituan](./integrations/meituan.md) |
| 领券/比价依赖 pricebot | `coupon/step` / `intent/recognize` / `price/step` 仅透传,真正逻辑在 pricebot-backend;前端已接通领券链路,比价 food MVP 也已接通 |
| agent 系列接口 MVP 不鉴权 | 拿不到 user_id → 无法采集"哪个用户领了/买了什么"用户级画像(商业模式核心资产)。见 [待办与技术债.md](./待办与技术债.md) P1 |
| agent 系列接口 MVP 不鉴权 | 拿不到 user_id → 无法采集"哪个用户领了/买了什么"用户级画像(商业模式核心资产)。见 [待办与技术债.md](./guides/待办与技术债.md) P1 |
| SMS 已接极光(2026-06-03) | real 模式自定义验证码,上线只需 `SMS_MOCK=false`(复用极光凭证)。详见 [integrations/sms](./integrations/sms.md) |
| 短信冷却存内存 | 扩 worker 前需迁移到 Redis |
| `MEDIA_ROOT` 进程内 serve | 头像/反馈截图当前用 FastAPI StaticFiles,生产建议 nginx 直 serve 该目录 |
| `init_postgres.py` 已知小 bug | 5 条小坑,见 [待办与技术债.md](./待办与技术债.md) |
| `init_postgres.py` 已知小 bug | 5 条小坑,见 [待办与技术债.md](./guides/待办与技术债.md) |
| Alembic 多 head 风险 | 跨分支并行开发要在 PR 合并前 `alembic heads` 检查只有一个;漏检会出现 "Multiple head revisions",需要 `alembic merge` 合并 |
> 完整接口协议见 [`docs/api/`](./api/)(38 个端点 + `/health` + `/media` 静态服务),以代码为准。
> 完整接口协议见 [`docs/api/`](./api/)(70+ 接口,一接口一文件 + `/health` + `/media` 静态服务),以 `docs/api/README.md` 索引和代码为准。
+186 -52
View File
@@ -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 并发抓完全国一轮 ~5060min,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,19 @@ def _to_cents(yuan) -> int | None:
return None
def _parse_item(item: dict, source: dict) -> dict | 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 {}
ci = item.get("commissionInfo") or {}
@@ -186,11 +243,12 @@ def _parse_item(item: dict, source: dict) -> 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"),
"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),
@@ -207,7 +265,28 @@ def _parse_item(item: dict, source: dict) -> 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]:
"""抓单个城市的 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) ─────────────────────────
@@ -272,30 +351,59 @@ 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
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:
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 +411,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__":
+34
View File
@@ -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(
+95
View File
@@ -0,0 +1,95 @@
"""OTA 检查更新端点测试。
覆盖:公开读端点未配置时返回默认(latest_version_code=0=无更新)内部写端点鉴权
(503 未配 / 401 头错 / 200)写后能读到同一组值写入参数校验(422)
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.core.config import settings
from app.db.session import SessionLocal
from app.models.app_config import AppConfig
from app.repositories.app_config import APP_VERSION_KEY
_SECRET = "test-internal-secret-only-for-pytest"
# 合法 body(够过 pydantic 校验,用于鉴权用例)
_VALID = {
"latest_version_code": 31,
"latest_version_name": "0.1.6",
"apk_url": "https://app-api.shaguabijia.com/media/releases/shaguabijia-v0.1.6-build31.apk",
}
@pytest.fixture()
def clean_version():
"""每个用例前后清掉 latest_app_version 行(表由 conftest 建好,session 级共享)。"""
def _clear():
s = SessionLocal()
s.query(AppConfig).filter(AppConfig.key == APP_VERSION_KEY).delete()
s.commit()
s.close()
_clear()
yield
_clear()
def test_read_default_when_unset(client: TestClient, clean_version) -> None:
"""未配置任何版本时,公开读端点返回 latest_version_code=0(客户端视为已是最新);不鉴权。"""
r = client.get("/api/v1/platform/app-version")
assert r.status_code == 200, r.text
assert r.json()["latest_version_code"] == 0
def test_write_secret_unset_503(client: TestClient, clean_version, monkeypatch) -> None:
monkeypatch.setattr(settings, "INTERNAL_API_SECRET", "") # 未配 = 写端点关闭
r = client.post("/internal/app-version", json=_VALID)
assert r.status_code == 503
def test_write_auth(client: TestClient, clean_version, monkeypatch) -> None:
monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET)
assert client.post("/internal/app-version", json=_VALID).status_code == 401
assert client.post(
"/internal/app-version", json=_VALID, headers={"X-Internal-Secret": "wrong"}
).status_code == 401
r = client.post("/internal/app-version", json=_VALID, headers={"X-Internal-Secret": _SECRET})
assert r.status_code == 200, r.text
def test_write_then_read(client: TestClient, clean_version, monkeypatch) -> None:
monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET)
payload = {
"latest_version_code": 99,
"latest_version_name": "9.9.9",
"apk_url": "https://app-api.shaguabijia.com/media/releases/shaguabijia-v9.9.9-build99.apk",
"update_note": "测试更新说明",
"min_supported_version_code": 50,
"apk_size_bytes": 12345678,
"apk_sha256": "abc123",
}
w = client.post("/internal/app-version", json=payload, headers={"X-Internal-Secret": _SECRET})
assert w.status_code == 200, w.text
assert w.json()["latest_version_code"] == 99
body = client.get("/api/v1/platform/app-version").json()
assert body["latest_version_code"] == 99
assert body["latest_version_name"] == "9.9.9"
assert body["apk_url"] == payload["apk_url"]
assert body["update_note"] == "测试更新说明"
assert body["min_supported_version_code"] == 50
assert body["apk_size_bytes"] == 12345678
assert body["apk_sha256"] == "abc123"
def test_write_rejects_invalid(client: TestClient, clean_version, monkeypatch) -> None:
monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET)
h = {"X-Internal-Secret": _SECRET}
# latest_version_code 必须 > 0
bad_code = {"latest_version_code": 0, "latest_version_name": "x", "apk_url": "u"}
assert client.post("/internal/app-version", json=bad_code, headers=h).status_code == 422
# apk_url 不能空
bad_url = {"latest_version_code": 5, "latest_version_name": "x", "apk_url": ""}
assert client.post("/internal/app-version", json=bad_url, headers=h).status_code == 422
+37
View File
@@ -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()
+13 -5
View File
@@ -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:
+13
View File
@@ -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

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