Compare commits

...

12 Commits

Author SHA1 Message Date
marco f7a3ef2e0b feat(internal): 启动确认窗 agent 兜底样本落盘(pricebot 回写)
- 新增内部端点 POST /internal/launch-confirm-sample(X-Internal-Secret 鉴权):
  pricebot 对没见过文案的启动确认窗用 LLM 兜底放行后,把样本(host 包 / 弹窗树 /
  LLM plan / 设备 locale 机型)回写落 launch_confirm_sample 表,供研发人工沉淀回
  pricebot 规则 yaml
- 配套: model LaunchConfirmSample + schema + repo(都上报不去重) + alembic 迁移
  launch_confirm_sample_table(down_revision=cps_wx_user) + main 注册 router +
  models/__init__ 登记
- docs: 后端技术实现.md 加 §6.5 pricebot 回写内部端点说明

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:39:45 +08:00
marco d8358a6816 fix(user): 注销账号前置校验资金余额 + 释放微信/邀请码唯一约束
- delete_account 端点: 有可提现现金余额 / 在审提现单(reviewing)时拒绝注销(409), 防余额锁死在 deleted 行无法退出; pending 提现已在途不拦(对账 worker 不依赖 user.status)
- soft_delete_account: 注销连带清 wechat_openid/nickname/avatar(释放微信绑定槽, 否则该微信永久无法再被任何账号绑定→提现通道锁死) + invite_code(释放邀请码唯一槽)
- wallet 新增 get_cash_balance_cents(只读, 不建账户) 供注销前置校验; 复用 has_reviewing_withdraw
- 新增 tests/test_delete_account.py

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 02:03:33 +08:00
marco 95a07f75b7 feat(ad): 穿山甲广告配置后台化(app_id/各位ID/验签密钥/各场景开关)
- app_config 存 ad_config(默认=客户端当前内置 ID,空库维持现状)
- GET /platform/ad-config 下发客户端(不含验签密钥 reward_mkey)
- admin GET/PATCH /admin/api/ad-config 读写(operator/finance + 审计,mkey 不记明文)
- pangle 回调验签 m-key 优先读后台配置、.env 兜底兼容
- 比价/领券信息流位拆分(compare/coupon_feed_code_id)
- 客户端需发版接入 /platform/ad-config 才生效;本次后端就绪、不影响现状

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 01:35:17 +08:00
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
43 changed files with 1795 additions and 40 deletions
+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")
@@ -0,0 +1,53 @@
"""launch_confirm_sample table (启动确认窗 agent 兜底样本: LLM 放行的弹窗样本沉淀)
Revision ID: launch_confirm_sample_table
Revises: cps_wx_user
Create Date: 2026-06-20 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "launch_confirm_sample_table"
down_revision: Union[str, Sequence[str], None] = "cps_wx_user"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"launch_confirm_sample",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
sa.Column("trace_id", sa.String(length=64), nullable=True),
sa.Column("device_id", sa.String(length=64), nullable=True),
sa.Column("host_package", sa.String(length=128), nullable=True),
sa.Column("target_app", sa.String(length=128), nullable=True),
sa.Column("system_locale", sa.String(length=32), nullable=True),
sa.Column("exec_success", sa.Boolean(), nullable=False),
sa.Column("dialog_title", sa.String(length=256), nullable=True),
# PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐
sa.Column("payload", sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), "postgresql"), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("launch_confirm_sample", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_created_at"), ["created_at"], unique=False)
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_trace_id"), ["trace_id"], unique=False)
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_device_id"), ["device_id"], unique=False)
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_host_package"), ["host_package"], unique=False)
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_system_locale"), ["system_locale"], unique=False)
def downgrade() -> None:
with op.batch_alter_table("launch_confirm_sample", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_system_locale"))
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_host_package"))
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_device_id"))
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_trace_id"))
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_created_at"))
op.drop_table("launch_confirm_sample")
+2
View File
@@ -14,6 +14,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.admin.routers.ad_audit import router as ad_audit_router
from app.admin.routers.ad_config import router as ad_config_router
from app.admin.routers.ad_revenue import router as ad_revenue_router
from app.admin.routers.admins import router as admins_router
from app.admin.routers.audit import router as audit_router
@@ -95,4 +96,5 @@ admin_app.include_router(config_router)
admin_app.include_router(comparison_router)
admin_app.include_router(cps_router)
admin_app.include_router(ad_audit_router)
admin_app.include_router(ad_config_router)
admin_app.include_router(ad_revenue_router)
+92 -2
View File
@@ -9,7 +9,7 @@ from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
from uuid import uuid4
from sqlalchemy import desc, select
from sqlalchemy import desc, func, select
from sqlalchemy.orm import Session
from app.admin.repositories.queries import _as_utc, offset_paginate
@@ -19,6 +19,7 @@ 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"}
@@ -394,7 +395,10 @@ def group_click_timeseries(
agg: dict = {} # bucket_key -> {vp, vu(set), cp, cu(set)}
for clicked_at, event_type, ip, ua in rows:
bj = clicked_at.astimezone(_BJ_TZ)
# 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 ''}"
@@ -425,3 +429,89 @@ def group_click_timeseries(
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
]
+45
View File
@@ -0,0 +1,45 @@
"""admin 广告配置:读/写穿山甲 app_id / 各场景广告位ID / 验签密钥 / 各场景开关。
存在 app_config 表的 ad_config dict(见 repositories/app_config.get_ad_config/set_ad_config)。
客户端经 /api/v1/platform/ad-config 拉取(不含 reward_mkey)。权限 operator/finance + 审计。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.schemas.ad_config import AdConfigOut, AdConfigUpdate
from app.models.admin import AdminUser
from app.repositories import app_config
router = APIRouter(
prefix="/admin/api/ad-config",
tags=["admin-ad-config"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=AdConfigOut, summary="广告配置(穿山甲ID/验签密钥/各场景开关)")
def get_ad_config(db: AdminDb) -> AdConfigOut:
return AdConfigOut(**app_config.get_ad_config(db))
@router.patch("", response_model=AdConfigOut, summary="改广告配置(部分更新,带审计)")
def update_ad_config(
body: AdConfigUpdate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator", "finance"))],
db: AdminDb,
) -> AdConfigOut:
data = body.model_dump(exclude_none=True)
app_config.set_ad_config(db, data, admin_id=admin.id, commit=False)
# 审计只记改了哪些字段,不记 mkey 明文(机密)
write_audit(
db, admin, action="ad_config.set", target_type="ad_config", target_id="ad_config",
detail={"changed": sorted(data.keys())}, ip=get_client_ip(request), commit=False,
)
db.commit()
return AdConfigOut(**app_config.get_ad_config(db))
+1 -1
View File
@@ -30,7 +30,7 @@ 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)$")] = 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,
+63
View File
@@ -433,3 +433,66 @@ def group_timeseries(
"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)}
+30
View File
@@ -0,0 +1,30 @@
"""admin 广告配置 schemas(穿山甲 app_id/各位ID/验签密钥/各场景开关)。"""
from __future__ import annotations
from pydantic import BaseModel
class AdConfigOut(BaseModel):
"""admin 读到的完整广告配置(含 reward_mkey;admin 有权见,但绝不经 /platform 下发客户端)。"""
app_id: str
reward_code_id: str
compare_feed_code_id: str
coupon_feed_code_id: str
reward_mkey: str
reward_enabled: bool
compare_ad_enabled: bool
coupon_ad_enabled: bool
class AdConfigUpdate(BaseModel):
"""部分更新:只改传入(非 None)字段。空串是合法值(如清空 mkey 回退 .env)。"""
app_id: str | None = None
reward_code_id: str | None = None
compare_feed_code_id: str | None = None
coupon_feed_code_id: str | None = None
reward_mkey: str | None = None
reward_enabled: bool | None = None
compare_ad_enabled: bool | None = None
coupon_ad_enabled: bool | None = None
+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)
+63
View File
@@ -0,0 +1,63 @@
"""启动确认窗兜底样本内部上报端点(pricebot → app-server)。
pricebot launch_confirm_agent 每次靠 LLM 兜底放行一个"静态 PROFILES 没认出"的跨 App
启动确认窗时,把完整样本 POST 到这里落库**不是给客户端的接口**:不走用户 JWT,
server 间共享密钥头 `X-Internal-Secret` 校验(== settings.INTERNAL_API_SECRET)密钥未配置
(默认空)时直接 503,避免裸奔写端点
研发定期人工把 exec_success=true 的样本沉淀进 pricebot launch_confirm.PROFILES
"""
from __future__ import annotations
import hmac
import logging
from typing import Annotated
from fastapi import APIRouter, Header, HTTPException, status
from app.api.deps import DbSession
from app.core.config import settings
from app.repositories import launch_confirm_sample as repo
from app.schemas.launch_confirm_sample import (
LaunchConfirmSampleIn,
LaunchConfirmSampleOut,
)
logger = logging.getLogger("shagua.internal.launch_confirm")
router = APIRouter(prefix="/internal", tags=["internal"])
def _check_secret(x_internal_secret: str | None) -> None:
"""共享密钥校验。未配置 → 503(挡裸奔写端点);不匹配 → 401(常量时间比较)。"""
configured = settings.INTERNAL_API_SECRET
if not configured:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="internal api not configured",
)
if not x_internal_secret or not hmac.compare_digest(x_internal_secret, configured):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid internal secret",
)
@router.post(
"/launch-confirm-sample",
response_model=LaunchConfirmSampleOut,
summary="启动确认窗兜底样本上报(pricebot→app-server,落 launch_confirm_sample)",
)
def report_launch_confirm_sample(
payload: LaunchConfirmSampleIn,
db: DbSession,
x_internal_secret: Annotated[str | None, Header()] = None,
) -> LaunchConfirmSampleOut:
_check_secret(x_internal_secret)
sid = repo.insert_sample(db, payload)
logger.info(
"launch_confirm_sample id=%d host=%s locale=%s success=%s trace=%s",
sid, payload.host_package, payload.system_locale,
payload.exec_success, payload.trace_id,
)
return LaunchConfirmSampleOut(id=sid)
+11 -2
View File
@@ -24,6 +24,7 @@ from app.repositories import ad_ecpm as crud_ecpm
from app.repositories import ad_feed_reward as crud_feed
from app.repositories import ad_reward as crud_ad
from app.repositories import ad_watch as crud_watch
from app.repositories import app_config
from app.repositories import signin as crud_signin
from app.schemas.ad import (
AdRewardStatusOut,
@@ -80,14 +81,22 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
验签失败 403(留给真请求重试);参数缺/坏或 user 不存在 is_verify=false + reason(不发,不重试)
granted / capped is_verify=true + reason=0
"""
if not settings.pangle_callback_configured:
if not settings.PANGLE_CALLBACK_ENABLED:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
)
params = dict(request.query_params)
if not pangle.verify_callback_sign_any(params, settings.pangle_reward_secrets):
# 验签密钥:admin 后台配的 reward_mkey 优先,.env 的 PANGLE_REWARD_SECRET* 兜底(兼容/过渡)。
# 换激励位时后台同步换 mkey 即可,无需改 .env。两者都空才视为未配置。
ad_mkey = app_config.get_ad_config(db).get("reward_mkey") or ""
secrets = ([ad_mkey] if ad_mkey else []) + settings.pangle_reward_secrets
if not secrets:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
)
if not pangle.verify_callback_sign_any(params, secrets):
logger.warning("pangle callback bad sign trans_id=%s", params.get("trans_id"))
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad sign")
+34 -12
View File
@@ -15,9 +15,11 @@ 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 (
@@ -40,29 +42,49 @@ 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)
# 同机拉 pricebot 本次比价的 LLM 调用明细落库(best-effort,失败不阻断上报)。
# llm_call_count/retry_count 直接由明细派生:次数=条数,重试=带 error 的条数。
calls = fetch_llm_calls(rec.trace_id)
if calls:
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()
# 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 llm_calls=%d",
"compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)",
user.id,
rec.trace_id,
rec.business_type,
rec.status,
rec.saved_amount_cents,
len(calls),
)
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,
+98 -12
View File
@@ -8,16 +8,22 @@ from __future__ import annotations
import html
import json
import logging
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
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"])
@@ -27,6 +33,16 @@ _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")
@@ -35,48 +51,109 @@ def _client_ip(request: Request) -> str | None:
return request.client.host if request.client else None
def _record(db: Session, link: CpsLink, request: Request, event_type: str) -> 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,
ua=request.headers.get("user-agent"), event_type=event_type, openid=openid,
)
except Exception:
pass # 记点击失败不阻断
@router.get("/c/{code}", summary="群发短链落地(记点击 + 跳转/淘宝落地页)")
@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)
_record(db, link, request, "visit")
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
return HTMLResponse(_taobao_landing_html(link.target_url, image_url))
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.post("/c/{code}/copy", summary="淘宝落地页点了「复制口令」(记 copy 事件)")
@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")
_record(db, link, request, "copy", openid=request.cookies.get(_WX_OPENID_COOKIE))
return {"ok": True}
def _taobao_landing_html(token: str, image_url: str) -> str:
def _taobao_landing_html(
token: str, image_url: str, code: str, openid: str | None, has_uinfo: bool
) -> str:
"""淘宝落地页 H5(主视觉图按活动传入,复制淘口令按钮在 75% 屏高)。
image_url 转绝对(落地页 coupon ,相对也可达,绝对更稳) html.escape 嵌入
<img src>(防属性注入);token json.dumps 安全嵌入 JS
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))
)
@@ -104,6 +181,7 @@ body{font-family:-apple-system,"PingFang SC",sans-serif;background:#fff0ef;color
<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()}
@@ -113,10 +191,18 @@ function fallback(){
try{document.execCommand('copy');done()}catch(e){showToast('复制失败,请长按手动复制')}
document.body.removeChild(ta);
}
function copyToken(){
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
View File
@@ -17,7 +17,9 @@ from app.repositories import app_config
from app.repositories import ops_marquee as marquee_crud
from app.repositories import ops_stat as crud
from app.schemas.platform import (
AdConfigPublicOut,
AppFlagsOut,
AppVersionOut,
PlatformStatsOut,
SavingsFeedItem,
SavingsFeedOut,
@@ -51,3 +53,29 @@ def flags(db: DbSession) -> AppFlagsOut:
return AppFlagsOut(
comparing_ad_enabled=bool(app_config.get_value(db, "comparing_ad_enabled")),
)
@router.get("/ad-config", response_model=AdConfigPublicOut, summary="客户端拉广告配置(穿山甲ID+场景开关,不鉴权)")
def ad_config(db: DbSession) -> AdConfigPublicOut:
"""客户端启动/每场广告前拉,缓存后用:app_id + 各位ID + 各场景开关。
不含验签密钥;空库回退默认(=客户端内置值,维持现状)"""
c = app_config.get_ad_config(db)
return AdConfigPublicOut(
app_id=c["app_id"],
reward_code_id=c["reward_code_id"],
compare_feed_code_id=c["compare_feed_code_id"],
coupon_feed_code_id=c["coupon_feed_code_id"],
reward_enabled=c["reward_enabled"],
compare_ad_enabled=c["compare_ad_enabled"],
coupon_ad_enabled=c["coupon_ad_enabled"],
)
@router.get("/app-version", response_model=AppVersionOut, summary="最新 App 版本(OTA 检查更新,不鉴权)")
def app_version(db: DbSession) -> AppVersionOut:
"""客户端启动 / 手动检查更新时拉取。不鉴权:版本信息非敏感,且检查更新可能在登录前。
latest_version_code 与本机 versionCode ;未配置(返回默认 0)时客户端视为已是最新"""
data = app_config.get_app_version(db)
if not data:
return AppVersionOut()
return AppVersionOut(**data)
+12
View File
@@ -18,6 +18,7 @@ from app.api.deps import CurrentUser, DbSession
from app.core import media
from app.repositories import onboarding as onboarding_repo
from app.repositories import user as user_repo
from app.repositories import wallet as wallet_repo
from app.schemas.auth import UserOut
from app.schemas.user import (
OkResponse,
@@ -87,6 +88,17 @@ def onboarding_status(
@router.delete("", response_model=OkResponse, summary="注销账号(软删除)")
def delete_account(user: CurrentUser, db: DbSession) -> OkResponse:
# 资金前置校验:注销是软删 + 匿名化,余额一旦留在 deleted 行就锁死(既退不出、新号也
# 拿不回)。有可提现现金 / 在审提现单 → 拒绝注销,引导用户先把钱处理掉。
# (pending 提现转账已在途、对账 worker 不依赖 user.status 照常到账,故不拦 pending。)
if wallet_repo.get_cash_balance_cents(db, user.id) > 0:
raise HTTPException(
status_code=409, detail="账户还有未提现的现金余额,请先提现后再注销"
)
if wallet_repo.has_reviewing_withdraw(db, user.id):
raise HTTPException(
status_code=409, detail="有提现正在审核中,请等审核完成后再注销"
)
media.delete_avatar(user.avatar_url)
user_repo.soft_delete_account(db, user)
logger.info("delete account user_id=%d", user.id)
+19
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 一致。
+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
+4
View File
@@ -21,6 +21,8 @@ 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.launch_confirm import router as internal_launch_confirm_router
from app.api.internal.price import router as internal_price_router
from app.api.internal.store import router as internal_store_router
from app.api.v1.feedback import router as feedback_router
@@ -107,6 +109,8 @@ 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(internal_launch_confirm_router)
app.include_router(platform_router)
# CPS 群发短链跳转 /c/{code}(公网无鉴权:记点击 → 302 跳美团)
app.include_router(cps_redirect_router)
+2
View File
@@ -10,6 +10,7 @@ from app.models.cps_activity import CpsActivity # noqa: F401
from app.models.cps_group import CpsGroup # noqa: F401
from app.models.cps_link import CpsClick, CpsLink # noqa: F401
from app.models.cps_order import CpsOrder # noqa: F401
from app.models.cps_wx_user import CpsWxUser # noqa: F401
from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
from app.models.coupon_state import ( # noqa: F401
CouponClaimRecord,
@@ -19,6 +20,7 @@ from app.models.coupon_state import ( # noqa: F401
from app.models.feedback import Feedback # noqa: F401
from app.models.invite import InviteRelation # noqa: F401
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401
from app.models.meituan_coupon import MeituanCoupon # noqa: F401
from app.models.onboarding import OnboardingCompletion # noqa: F401
from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401
+2
View File
@@ -49,6 +49,8 @@ class CpsClick(Base):
)
ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
ua: Mapped[str | None] = mapped_column(String(512), nullable=True)
# 微信网页授权拿到的用户标识(非微信/未授权为空),做用户级群统计
openid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
clicked_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
+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}>"
+61
View File
@@ -0,0 +1,61 @@
"""启动确认窗 agent 兜底样本表(launch_confirm_sample)。
pricebot launch_confirm_agent 每次"静态 PROFILES 没认出、靠 LLM 兜底放行"一个跨 App
启动确认窗(繁体 / 英文 / 小语种 / ROM 改版),把完整样本 serverserver 落这里研发定期
**人工** exec_success=true 的样本沉淀进 pricebot launch_confirm.PROFILES(静态快路径),
让以后同款窗不必再调 LLM
设计:
- 与登录无关不鉴权(serverserver 共享密钥头 X-Internal-Secret)
- **都上报不去重**(by design):同一种窗多台机器上报多条,研发按 host_package / locale
聚合后人工筛
- 大字段(弹窗树快照 + LLM plan + 设备信息) payload(JSONB)兜底,免得加字段就迁移 schema
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
# PG 上用 JSONB(可建 GIN 索引),SQLite(本地/测试)退化为通用 JSON(同 price_observation)。
_JSON = JSON().with_variant(JSONB(), "postgresql")
class LaunchConfirmSample(Base):
__tablename__ = "launch_confirm_sample"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
# pricebot 侧 trace_id:回指原始 trace,能去 price.shaguabijia.com 查完整上下文。
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
device_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# 弹窗宿主包(= 触发判据, 也是研发沉淀进 PROFILES.host_packages 的键)。按它聚合人工筛。
host_package: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
# 被打开的目标 App 包名(标题里"想要打开 X"的 X 是变量, 据此把它从 sign 里挖空)。
target_app: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 系统语言地区(zh-TW / en-US…)—— 多语言问题的核心维度。
# ⚠️ 前端 AgentStepRequest 暂未上报设备信息 → 现恒 None,待前端加 device_info 字段后回填。
system_locale: Mapped[str | None] = mapped_column(String(32), index=True, nullable=True)
# 这次兜底是否成功放行(弹窗消失=True;放弃 / LLM 没认出=False)。研发只沉淀 True 的。
exec_success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# 弹窗标题全文(研发据此提 launch_sign),如"「傻瓜比价」想要開啟「京東」"。
dialog_title: Mapped[str | None] = mapped_column(String(256), nullable=True)
# 大 JSON:完整样本。{is_launch_confirm, llm_model, llm_reason, plan, dialog_tree,
# device_info: {screen, locale, brand, model, android_version, rom_version}}
payload: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
def __repr__(self) -> str: # pragma: no cover
return (
f"<LaunchConfirmSample id={self.id} host={self.host_package!r} "
f"locale={self.system_locale!r} success={self.exec_success}>"
)
+76
View File
@@ -63,3 +63,79 @@ 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
# ── 穿山甲广告配置(admin 可配,客户端动态拉)──────────────────────────────────
# 同 app_version:结构化 dict,复用 AppConfig 表不进 CONFIG_DEFS(它是一组关联配置,不是散项)。
# 默认值 = 客户端当前内置的硬编码 ID(AdConfig.kt),空库时下发默认 = 维持现状。
# reward_mkey 是 GroMore S2S 验签密钥(机密),只后端验签用、绝不下发客户端(见 platform/ad-config)。
AD_CONFIG_KEY = "ad_config"
_AD_CONFIG_DEFAULTS: dict[str, Any] = {
"app_id": "5830519", # 穿山甲应用ID(正式)
"reward_code_id": "104099389", # 福利页激励视频位
"compare_feed_code_id": "104090333", # 比价信息流位
"coupon_feed_code_id": "104090333", # 领券信息流位(初始同比价,运营可拆)
"reward_mkey": "", # 激励位 GroMore 验签密钥(空则回退 .env PANGLE_REWARD_SECRET*)
"reward_enabled": True, # 福利激励视频开关
"compare_ad_enabled": True, # 比价广告开关
"coupon_ad_enabled": True, # 领券广告开关
}
def get_ad_config(db: Session) -> dict:
"""读广告配置。DB 无则返回默认(=客户端内置值,维持现状);DB 有则与默认 merge
(新增字段向后兼容,老记录缺的字段用默认补)"""
row = db.get(AppConfig, AD_CONFIG_KEY)
merged = dict(_AD_CONFIG_DEFAULTS)
if row is not None and isinstance(row.value, dict):
merged.update(row.value)
return merged
def set_ad_config(db: Session, data: dict, *, admin_id: int, commit: bool = True) -> AppConfig:
"""admin 写广告配置。与现有值 merge(部分更新),只接受已知字段(防脏键)。"""
row = db.get(AppConfig, AD_CONFIG_KEY)
merged = dict(_AD_CONFIG_DEFAULTS)
if row is not None and isinstance(row.value, dict):
merged.update(row.value)
merged.update({k: v for k, v in data.items() if k in _AD_CONFIG_DEFAULTS})
if row is None:
row = AppConfig(key=AD_CONFIG_KEY, value=merged, updated_by_admin_id=admin_id)
db.add(row)
else:
row.value = merged
row.updated_by_admin_id = admin_id
if commit:
db.commit()
db.refresh(row)
else:
db.flush()
return row
+4 -3
View File
@@ -52,12 +52,13 @@ def create_link(
def record_click(
db: Session, *, link: CpsLink, ip: str | None = None, ua: str | None = None,
event_type: str = "visit",
event_type: str = "visit", openid: str | None = None,
) -> None:
"""记一条点击。event_type: visit(进落地页/被跳转) | copy(淘宝点了"复制口令")。"""
"""记一条点击。event_type: visit(进落地页/被跳转) | copy(淘宝点了"复制口令")。
openid: 微信网页授权拿到的用户标识(非微信/未授权为空),做用户级群统计"""
db.add(CpsClick(
link_id=link.id, group_id=link.group_id, sid=link.sid, event_type=event_type,
ip=ip, ua=(ua[:500] if ua else None),
ip=ip, ua=(ua[:500] if ua else None), openid=openid,
))
db.commit()
+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
+35
View File
@@ -0,0 +1,35 @@
"""启动确认窗兜底样本落库:单条 insert(都上报、不去重,by design)。"""
from __future__ import annotations
import logging
from typing import Optional
from sqlalchemy.orm import Session
from app.models.launch_confirm_sample import LaunchConfirmSample
from app.schemas.launch_confirm_sample import LaunchConfirmSampleIn
logger = logging.getLogger("shagua.launch_confirm")
def _trunc(s: Optional[str], n: int) -> Optional[str]:
"""截断到列长上限(PG varchar 超长会报错;繁体长标题等需要)。空 → None。"""
return s[:n] if s else None
def insert_sample(db: Session, payload: LaunchConfirmSampleIn) -> int:
"""写入一条样本,返回新行 id。"""
row = LaunchConfirmSample(
trace_id=_trunc(payload.trace_id, 64),
device_id=_trunc(payload.device_id, 64),
host_package=_trunc(payload.host_package, 128),
target_app=_trunc(payload.target_app, 128),
system_locale=_trunc(payload.system_locale, 32),
exec_success=bool(payload.exec_success),
dialog_title=_trunc(payload.dialog_title, 256),
payload=payload.payload,
)
db.add(row)
db.commit()
db.refresh(row)
return row.id
+15 -2
View File
@@ -111,14 +111,27 @@ def set_avatar_url(db: Session, user: User, *, avatar_url: str) -> User:
def soft_delete_account(db: Session, user: User) -> None:
"""注销账号:软删除 + 匿名化。
"""注销账号:软删除 + 匿名化 + 释放唯一约束/外部绑定
phone 改成 `deleted_<id>` 释放唯一约束,允许同号码重新注册成全新账号;
phone 改成 `deleted_<id>` 释放手机号唯一约束,允许同号码重新注册成全新账号;
清空 PII(昵称/头像),保留行用于审计status=deleted 后将无法再登录该行
(登录接口校验 status==active)
注销必须连同释放 user 上其余唯一约束/外部身份,否则 deleted 行永久占着槽位,
对应资源再也无法被复用:
- wechat_openid: 不清 这个微信再也无法被任何账号绑定(提现通道彻底锁死);
- invite_code: 不清 邀请码槽位被占 + 裸查仍命中死号(发奖已被 bind
status==active 校验兜住,清掉更干净)
资金安全(未提现现金 / 在审提现单)由调用方 delete_account 端点前置校验,这里只匿名化
"""
user.status = "deleted"
user.phone = f"deleted_{user.id}"
user.nickname = None
user.avatar_url = None
# 等价"解绑微信":释放 openid 唯一槽 + 清微信昵称头像
user.wechat_openid = None
user.wechat_nickname = None
user.wechat_avatar_url = None
# 释放邀请码唯一槽
user.invite_code = None
db.commit()
+9
View File
@@ -340,6 +340,15 @@ def bind_wechat_openid(db: Session, user_id: int, code: str) -> dict:
return info
def get_cash_balance_cents(db: Session, user_id: int) -> int:
"""只读查现金余额(分)。账户不存在返回 0,**不创建账户**——注销前置校验用,
不该给一个即将被删除的用户凭空建一行空账户(get_or_create_account 会建)"""
val = db.execute(
select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id)
).scalar_one_or_none()
return val or 0
def has_reviewing_withdraw(db: Session, user_id: int) -> bool:
"""用户是否有「待审核(reviewing)」的提现单。
+26
View File
@@ -0,0 +1,26 @@
"""启动确认窗兜底样本的内部上报模型(pricebot → app-server)。"""
from __future__ import annotations
from pydantic import BaseModel
class LaunchConfirmSampleIn(BaseModel):
"""一次 agent 兜底的完整样本。
字段一律宽松可空:落盘是辅助数据,绝不能因个别字段缺失就拒收(否则连累 pricebot 兜底)
"""
trace_id: str | None = None
device_id: str | None = None
host_package: str | None = None
target_app: str | None = None
system_locale: str | None = None
exec_success: bool = False
dialog_title: str | None = None
payload: dict | None = None
class LaunchConfirmSampleOut(BaseModel):
"""落库结果。"""
id: int
+44 -1
View File
@@ -1,7 +1,7 @@
"""首页平台级展示数据 schemas(客户端首页门面数字)。"""
from __future__ import annotations
from pydantic import BaseModel
from pydantic import BaseModel, Field
class PlatformStatsOut(BaseModel):
@@ -28,3 +28,46 @@ class AppFlagsOut(BaseModel):
"""客户端拉取的运营 feature flag(不鉴权,登录前也能拉)。客户端缓存后按需读。"""
comparing_ad_enabled: bool # 比价/领券期是否展示信息流广告(远程 kill-switch)
class AdConfigPublicOut(BaseModel):
"""下发给客户端的穿山甲广告配置(不鉴权,客户端缓存后用)。
不含 reward_mkey(GroMore 验签密钥,机密,只后端验签用,绝不下发)
"""
app_id: str # 穿山甲应用ID(改了客户端需冷启才生效,SDK init 一次性读)
reward_code_id: str # 福利页激励视频位
compare_feed_code_id: str # 比价信息流位
coupon_feed_code_id: str # 领券信息流位
reward_enabled: bool # 福利激励视频开关
compare_ad_enabled: bool # 比价广告开关
coupon_ad_enabled: bool # 领券广告开关
class AppVersionOut(BaseModel):
"""最新 App 版本信息(OTA 检查更新,不鉴权)。
客户端用 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 = ""
+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`)。
+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 设计文档已警示)。
+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 / 未启用时该表不会有新数据。
+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 / 未启用时该表不会有新数据。
+10
View File
@@ -222,6 +222,16 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
---
## 6.5 pricebot 回写:内部端点(server→server)
§6 是 app-server 透传给 pricebot;反过来 pricebot 也会把少量数据回写 app-server 落库,走 `app/api/internal/*` —— **不走用户 JWT**,靠共享密钥头 `X-Internal-Secret`(== `INTERNAL_API_SECRET`,未配则 503,防裸奔写端点)校验。
**启动确认窗兜底样本**(2026-06):国产 ROM 打开别的 App 时弹"想要打开 XX"确认窗,pricebot 对没见过文案(繁体/英文/ROM 改版)的窗用 LLM 兜底放行后,把样本 POST 到 [`/internal/launch-confirm-sample`](../app/api/internal/launch_confirm.py) 落 `launch_confirm_sample` 表(host 包 + 弹窗树 + LLM plan + 设备 locale/机型),供研发定期人工沉淀回 pricebot 的规则 yaml(回到快路径)。**需 pricebot 与 app-server 两边 `.env` 配同一 `INTERNAL_API_SECRET` 才生效**(未配则 pricebot 侧跳过上报、不影响比价/领券)。
> 同类内部回写端点还有 `/internal/price-observation`(价格观测)、`/internal/store-mapping`(跨平台店铺映射)等 pricebot 比价资产沉淀。
---
## 7. 数据模型
业务表(下表)+ `alembic_version` 框架表。生产 PG / 开发可回退 SQLite。
+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
+141
View File
@@ -0,0 +1,141 @@
"""注销账号(DELETE /api/v1/user):软删 + 释放唯一约束 + 资金前置校验。
覆盖:
- 干净注销:status=deleted + phone 匿名化 + wechat_openid/invite_code 等唯一槽全部释放
- 回归原始 bug:注销释放 openid ,别的账号能绑同一个微信
- 资金前置闸:有可提现现金 / 在审提现单 409 拒绝注销,账号不被删
- 注销后旧 token 即时失效(status==active )
"""
from __future__ import annotations
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models.user import User
from app.models.wallet import CoinAccount
def _login(client, phone: str) -> str:
client.post("/api/v1/auth/sms/send", json={"phone": phone})
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 200, r.text
return r.json()["access_token"]
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _patch_userinfo(monkeypatch, openid: str) -> None:
monkeypatch.setattr(
"app.integrations.wxpay.code_to_userinfo",
lambda code: {"openid": openid, "nickname": "微信昵称", "avatar_url": "https://x/a.png", "raw": {}},
)
def _seed_cash(client, token: str, phone: str, cents: int) -> None:
"""先访问 /account 触发建账户,再用 DB 直接灌现金余额。"""
client.get("/api/v1/wallet/account", headers=_auth(token))
db = SessionLocal()
try:
user = db.execute(select(User).where(User.phone == phone)).scalar_one()
acc = db.get(CoinAccount, user.id)
acc.cash_balance_cents = cents
db.commit()
finally:
db.close()
def _get_user(phone: str) -> User | None:
db = SessionLocal()
try:
return db.execute(select(User).where(User.phone == phone)).scalar_one_or_none()
finally:
db.close()
def test_delete_clean_account_releases_all_unique_slots(client) -> None:
phone = "13900000001"
token = _login(client, phone)
# 预置:给该用户造昵称 + 邀请码(占 invite_code 唯一槽),验证注销时一并释放
db = SessionLocal()
try:
u = db.execute(select(User).where(User.phone == phone)).scalar_one()
u.nickname = "张三"
u.invite_code = "INVCODE0001"
db.commit()
uid = u.id
finally:
db.close()
r = client.delete("/api/v1/user", headers=_auth(token))
assert r.status_code == 200, r.text
u = _get_user(f"deleted_{uid}")
assert u is not None
assert u.status == "deleted"
assert u.phone == f"deleted_{uid}"
assert u.nickname is None
assert u.avatar_url is None
assert u.wechat_openid is None
assert u.wechat_nickname is None
assert u.wechat_avatar_url is None
assert u.invite_code is None
def test_delete_releases_wechat_so_another_account_can_bind(client, monkeypatch) -> None:
"""原始 bug 回归:A 绑微信 → 注销,B 必须能绑同一个 openid(修复前会 409)。"""
openid = "openid_delreuse_a1" # 独特值,避免跨测试共享库污染(如 test_withdraw 也用 openid_shared_xyz)
# A 绑微信
_patch_userinfo(monkeypatch, openid)
token_a = _login(client, "13900000002")
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "ca"}, headers=_auth(token_a))
assert r.status_code == 200, r.text
# A 注销(无现金、无提现单 → 允许)
r = client.delete("/api/v1/user", headers=_auth(token_a))
assert r.status_code == 200, r.text
# B 绑同一个 openid → 必须成功(修复前会 409 "该微信已绑定其他账号")
token_b = _login(client, "13900000003")
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "cb"}, headers=_auth(token_b))
assert r.status_code == 200, r.text
assert r.json()["bound"] is True
def test_delete_blocked_when_cash_balance_positive(client) -> None:
phone = "13900000004"
token = _login(client, phone)
_seed_cash(client, token, phone, 100) # 1 元未提现
r = client.delete("/api/v1/user", headers=_auth(token))
assert r.status_code == 409, r.text
assert "现金" in r.json()["detail"]
u = _get_user(phone) # 账号未被删
assert u is not None and u.status == "active"
def test_delete_blocked_when_reviewing_withdraw_exists(client, monkeypatch) -> None:
"""cash 提光(=0)但有在审提现单 → 仍拒绝(命中 reviewing 闸,非 cash 闸)。"""
phone = "13900000005"
_patch_userinfo(monkeypatch, "openid_delrev_b2")
token = _login(client, phone)
_seed_cash(client, token, phone, 50)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
# 提光 50 → cash 归 0,单进 reviewing
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
assert r.status_code == 200 and r.json()["status"] == "reviewing", r.text
assert r.json()["cash_balance_cents"] == 0
r = client.delete("/api/v1/user", headers=_auth(token))
assert r.status_code == 409, r.text
assert "审核" in r.json()["detail"]
u = _get_user(phone)
assert u is not None and u.status == "active"
def test_deleted_user_old_token_is_rejected(client) -> None:
phone = "13900000006"
token = _login(client, phone)
r = client.delete("/api/v1/user", headers=_auth(token))
assert r.status_code == 200, r.text
# 注销后旧 token 立即失效(get_current_user 校验 status==active)
r = client.get("/api/v1/user/onboarding/status", params={"device_id": "d"}, headers=_auth(token))
assert r.status_code == 401, r.text