diff --git a/.env.example b/.env.example index ff8642f..367226a 100644 --- a/.env.example +++ b/.env.example @@ -100,9 +100,17 @@ AUTO_EXCHANGE_ENABLED=true # ===== 穿山甲激励视频(服务端发奖回调)===== # 看完激励视频后穿山甲服务器 S2S 回调本服务发金币(客户端不参与发奖)。 -# PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",用于验签,从后台取到后填这里; -# 配齐并把 ENABLED=true 后,/api/v1/ad/pangle-callback 才受理回调(否则 503)。 +# 穿山甲"奖励校验密钥"(m-key),验签用,从 GroMore 后台各广告位取到后填这里。 +# 配齐(任一非空)并把 ENABLED=true 后,/api/v1/ad/pangle-callback 才受理回调(否则 503)。 +# 每个激励位 m-key 不同但共用同一回调 URL → 各位分开一行配(留空的忽略);验签逐个试、任一通过即接受。 PANGLE_CALLBACK_ENABLED=false +# 测试应用 激励位 104099649 +PANGLE_REWARD_SECRET_TEST= +# 测试应用 专属激励位 104127529 +PANGLE_REWARD_SECRET_TEST_DEDICATED= +# 正式应用 激励位 104099389 +PANGLE_REWARD_SECRET_PROD= +# (旧用法,仍兼容:单个或逗号分隔的多个 m-key,会与上面三个合并去重) PANGLE_REWARD_SECRET= # ⚠️ 仅本地联调:true 时开放 POST /api/v1/ad/test-grant,让 debug 客户端看完广告直接发奖, # 验证"看广告→金币到账"全链路(未部署公网、穿山甲 S2S 打不到本地时用)。生产必须 false(绕过反作弊)。 diff --git a/.gitignore b/.gitignore index bd48023..d473d6b 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ data/* !data/media/ data/media/* !data/media/dl.html +!data/media/taobao_landing.jpg secrets/* !secrets/.gitkeep diff --git a/alembic/versions/3a8fa058789c_merge_cps_wx_user_与_device_kill_alert_.py b/alembic/versions/3a8fa058789c_merge_cps_wx_user_与_device_kill_alert_.py new file mode 100644 index 0000000..7d25e70 --- /dev/null +++ b/alembic/versions/3a8fa058789c_merge_cps_wx_user_与_device_kill_alert_.py @@ -0,0 +1,26 @@ +"""merge cps_wx_user 与 device_kill_alert_pending 双 head + +Revision ID: 3a8fa058789c +Revises: cps_wx_user, device_kill_alert_pending +Create Date: 2026-06-20 10:27:01.193201 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '3a8fa058789c' +down_revision: Union[str, Sequence[str], None] = ('cps_wx_user', 'device_kill_alert_pending') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/comparison_debug_fields.py b/alembic/versions/comparison_debug_fields.py new file mode 100644 index 0000000..2d472d9 --- /dev/null +++ b/alembic/versions/comparison_debug_fields.py @@ -0,0 +1,54 @@ +"""comparison_record: 客户端环境 / 性能统计 / LLM 明细 debug 字段 + +Revision ID: comparison_debug_fields +Revises: cps_activity_image +Create Date: 2026-06-17 22:00:00.000000 + +给 admin 比价记录 debug 页新增采集列,全部 nullable(旧记录 / 未发版客户端为 NULL,向后兼容): +- 客户端环境: device_*/rom_*/android_*/app_version*/source_app_version/经纬度 +- 过程统计: total_ms/step_count/llm_call_count/retry_count +- LLM 明细: llm_calls(JSONB,server 按 trace_id 同机拉 pricebot 落库) +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision: str = "comparison_debug_fields" +down_revision: Union[str, Sequence[str], None] = "cps_activity_image" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_JSONB = sa.JSON().with_variant(postgresql.JSONB(), "postgresql") + + +def upgrade() -> None: + op.add_column("comparison_record", sa.Column("device_model", sa.String(64), nullable=True)) + op.add_column("comparison_record", sa.Column("device_manufacturer", sa.String(64), nullable=True)) + op.add_column("comparison_record", sa.Column("rom_vendor", sa.String(32), nullable=True)) + op.add_column("comparison_record", sa.Column("rom_name", sa.String(32), nullable=True)) + op.add_column("comparison_record", sa.Column("rom_version", sa.Integer(), nullable=True)) + op.add_column("comparison_record", sa.Column("android_version", sa.String(16), nullable=True)) + op.add_column("comparison_record", sa.Column("android_sdk", sa.Integer(), nullable=True)) + op.add_column("comparison_record", sa.Column("app_version", sa.String(32), nullable=True)) + op.add_column("comparison_record", sa.Column("app_version_code", sa.Integer(), nullable=True)) + op.add_column("comparison_record", sa.Column("source_app_version", sa.String(32), nullable=True)) + op.add_column("comparison_record", sa.Column("longitude", sa.Float(), nullable=True)) + op.add_column("comparison_record", sa.Column("latitude", sa.Float(), nullable=True)) + op.add_column("comparison_record", sa.Column("total_ms", sa.Integer(), nullable=True)) + op.add_column("comparison_record", sa.Column("step_count", sa.Integer(), nullable=True)) + op.add_column("comparison_record", sa.Column("llm_call_count", sa.Integer(), nullable=True)) + op.add_column("comparison_record", sa.Column("retry_count", sa.Integer(), nullable=True)) + op.add_column("comparison_record", sa.Column("llm_calls", _JSONB, nullable=True)) + + +def downgrade() -> None: + for col in ( + "llm_calls", "retry_count", "llm_call_count", "step_count", "total_ms", + "latitude", "longitude", "source_app_version", "app_version_code", + "app_version", "android_sdk", "android_version", "rom_version", + "rom_name", "rom_vendor", "device_manufacturer", "device_model", + ): + op.drop_column("comparison_record", col) diff --git a/alembic/versions/cps_activity_image.py b/alembic/versions/cps_activity_image.py new file mode 100644 index 0000000..e677950 --- /dev/null +++ b/alembic/versions/cps_activity_image.py @@ -0,0 +1,38 @@ +"""cps activity 落地页图: cps_activity.image_url + 回填存量淘宝活动 + +新建淘宝活动时运营提供落地页主视觉图(上传或选已有),落地页按活动展示对应图。 +存量淘宝活动回填为此前写死的那张图,无缝衔接。 + +Revision ID: cps_activity_image +Revises: cps_v2_platforms +Create Date: 2026-06-17 12:00:00.000000 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +from app.core.config import settings + + +revision: str = "cps_activity_image" +down_revision: Union[str, Sequence[str], None] = "cps_v2_platforms" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("cps_activity", sa.Column("image_url", sa.String(512), nullable=True)) + # 回填存量淘宝活动 = 此前写死的那张图(绝对 URL,基于落地页域名 CPS_REDIRECT_BASE; + # dev 未配则相对路径)。新活动由运营自传/选,不回填。 + base = settings.CPS_REDIRECT_BASE.rstrip("/") + default_img = f"{base}/media/taobao_landing.jpg" if base else "/media/taobao_landing.jpg" + op.execute( + sa.text( + "UPDATE cps_activity SET image_url = :img WHERE platform = 'taobao'" + ).bindparams(img=default_img) + ) + + +def downgrade() -> None: + op.drop_column("cps_activity", "image_url") diff --git a/alembic/versions/cps_v2_platforms.py b/alembic/versions/cps_v2_platforms.py new file mode 100644 index 0000000..e80c1a6 --- /dev/null +++ b/alembic/versions/cps_v2_platforms.py @@ -0,0 +1,61 @@ +"""cps v2: 活动 payload(淘口令/京东链接) + 群多平台/sid 可空 + 点击 event_type + +Revision ID: cps_v2_platforms +Revises: cps_link_tables +Create Date: 2026-06-17 11:00:00.000000 + +接入淘宝/京东: +- cps_activity.payload 淘宝整段淘口令文本 / 京东推广链接(美团仍用 act_id) +- cps_group.platforms 该群发哪些平台(多选);sid 改可空(纯淘宝/京东无 sid) +- cps_link.sid 可空 + target_url 加长(淘口令较长) +- cps_click.event_type visit / copy(淘宝落地页"复制口令") +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision: str = "cps_v2_platforms" +down_revision: Union[str, Sequence[str], None] = "cps_link_tables" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 活动:淘宝淘口令 / 京东链接 + op.add_column("cps_activity", sa.Column("payload", sa.Text(), nullable=True)) + + # 群:多平台(现有群都是美团,server_default 回填)+ sid 可空 + op.add_column( + "cps_group", + sa.Column( + "platforms", + sa.JSON().with_variant(postgresql.JSONB(), "postgresql"), + nullable=False, + server_default=sa.text("'[\"meituan\"]'::jsonb"), + ), + ) + op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=True) + + # link:sid 可空 + target 加长 + op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=True) + op.alter_column("cps_link", "target_url", existing_type=sa.String(1024), type_=sa.String(2048)) + + # click:sid 可空 + 事件类型 + op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=True) + op.add_column( + "cps_click", + sa.Column("event_type", sa.String(16), nullable=False, server_default="visit"), + ) + + +def downgrade() -> None: + op.drop_column("cps_click", "event_type") + op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=False) + op.alter_column("cps_link", "target_url", existing_type=sa.String(2048), type_=sa.String(1024)) + op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=False) + op.drop_column("cps_group", "platforms") + op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=False) + op.drop_column("cps_activity", "payload") diff --git a/alembic/versions/cps_wx_user.py b/alembic/versions/cps_wx_user.py new file mode 100644 index 0000000..0e0db37 --- /dev/null +++ b/alembic/versions/cps_wx_user.py @@ -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") diff --git a/app/admin/main.py b/app/admin/main.py index a67b403..eb46a7c 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -14,10 +14,12 @@ 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 from app.admin.routers.auth import router as auth_router +from app.admin.routers.comparison import router as comparison_router from app.admin.routers.config import router as config_router from app.admin.routers.cps import router as cps_router from app.admin.routers.dashboard import router as dashboard_router @@ -91,6 +93,8 @@ admin_app.include_router(feedback_router) admin_app.include_router(admins_router) admin_app.include_router(audit_router) admin_app.include_router(config_router) +admin_app.include_router(comparison_router) admin_app.include_router(cps_router) admin_app.include_router(ad_audit_router) +admin_app.include_router(ad_config_router) admin_app.include_router(ad_revenue_router) diff --git a/app/admin/repositories/cps.py b/app/admin/repositories/cps.py index 17516e2..4b1c7c3 100644 --- a/app/admin/repositories/cps.py +++ b/app/admin/repositories/cps.py @@ -5,11 +5,11 @@ """ from __future__ import annotations -from datetime import datetime, timezone +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 @@ -17,12 +17,17 @@ from app.integrations import meituan from app.repositories import cps_link as cps_link_repo from app.models.cps_activity import CpsActivity from app.models.cps_group import CpsGroup +from app.models.cps_link import CpsClick from app.models.cps_order import CpsOrder +from app.models.cps_wx_user import CpsWxUser # 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账 _INVALID_STATUS = {"4", "5"} _SETTLED_STATUS = "6" +# CPS 点击时序按北京时区分桶(运营看的是北京时间) +_BJ_TZ = timezone(timedelta(hours=8)) + # ───────────── 单位换算 ───────────── def _yuan_to_cents(v: object) -> int | None: @@ -71,19 +76,23 @@ def list_groups( def create_group( - db: Session, *, name: str, sid: str | None = None, + db: Session, *, name: str, platforms: list[str], sid: str | None = None, member_count: int | None = None, remark: str | None = None, commit: bool = True, ) -> CpsGroup: - """建群。sid 留空 → 自动 g(先 flush 拿 id 再回填)。""" + """建群。含 meituan 才有 sid(留空自动 g);纯淘宝/京东 sid=None(那俩不支持 sid)。""" + has_meituan = "meituan" in (platforms or []) + # 含美团:用填的 sid,或临时占位待回填 g;不含美团:sid 恒 None + seed_sid = (sid or f"tmp{uuid4().hex[:20]}") if has_meituan else None group = CpsGroup( name=name, - sid=sid or f"tmp{uuid4().hex[:20]}", # 临时唯一占位,留空时下面回填 g + platforms=list(platforms or []), + sid=seed_sid, member_count=member_count, remark=remark, ) db.add(group) db.flush() - if not sid: + if has_meituan and not sid: group.sid = f"g{group.id}" db.flush() if commit: @@ -94,11 +103,14 @@ def create_group( def update_group( db: Session, group: CpsGroup, *, name: str | None = None, + platforms: list[str] | None = None, member_count: int | None = None, status: str | None = None, remark: str | None = None, commit: bool = True, ) -> CpsGroup: if name is not None: group.name = name + if platforms is not None: + group.platforms = list(platforms) if member_count is not None: group.member_count = member_count if status is not None: @@ -113,6 +125,15 @@ def update_group( return group +def delete_group(db: Session, group: CpsGroup, *, commit: bool = True) -> None: + """硬删群。不级联删已生成的 cps_link(已发链接继续可用);该群历史点击/订单按 sid 仍在库。""" + db.delete(group) + if commit: + db.commit() + else: + db.flush() + + # ───────────── 活动 ───────────── def get_activity(db: Session, activity_id: int) -> CpsActivity | None: return db.get(CpsActivity, activity_id) @@ -130,13 +151,29 @@ def list_activities( return offset_paginate(db, stmt, (desc(CpsActivity.id),), limit=limit, cursor=cursor) +def list_activity_images(db: Session) -> list[str]: + """已用过的活动落地页图(distinct 非空 image_url),供新建活动复用选择。""" + rows = ( + db.execute( + select(CpsActivity.image_url) + .where(CpsActivity.image_url.is_not(None)) + .distinct() + ) + .scalars() + .all() + ) + return [r for r in rows if r] + + def create_activity( db: Session, *, name: str, platform: str = "meituan", act_id: str | None = None, - product_view_sign: str | None = None, remark: str | None = None, commit: bool = True, + product_view_sign: str | None = None, payload: str | None = None, + image_url: str | None = None, remark: str | None = None, commit: bool = True, ) -> CpsActivity: activity = CpsActivity( name=name, platform=platform, act_id=act_id, - product_view_sign=product_view_sign, remark=remark, + product_view_sign=product_view_sign, payload=payload, + image_url=image_url, remark=remark, ) db.add(activity) if commit: @@ -147,6 +184,46 @@ def create_activity( return activity +def update_activity( + db: Session, activity: CpsActivity, *, name: str | None = None, + platform: str | None = None, act_id: str | None = None, + product_view_sign: str | None = None, payload: str | None = None, + image_url: str | None = None, remark: str | None = None, + status: str | None = None, commit: bool = True, +) -> CpsActivity: + if name is not None: + activity.name = name + if platform is not None: + activity.platform = platform + if act_id is not None: + activity.act_id = act_id + if product_view_sign is not None: + activity.product_view_sign = product_view_sign + if payload is not None: + activity.payload = payload + if image_url is not None: + activity.image_url = image_url + if remark is not None: + activity.remark = remark + if status is not None: + activity.status = status + if commit: + db.commit() + db.refresh(activity) + else: + db.flush() + return activity + + +def delete_activity(db: Session, activity: CpsActivity, *, commit: bool = True) -> None: + """硬删活动。不级联删 cps_link(已发链接继续可用;淘宝落地页缺活动时取图走兜底默认图)。""" + db.delete(activity) + if commit: + db.commit() + else: + db.flush() + + # ───────────── 订单(对账) ───────────── def _map_order_fields(r: dict) -> dict: """美团 query_order 单条 dataList → CpsOrder 字段(金额转分、时间转 datetime)。""" @@ -233,55 +310,208 @@ def group_stats( """按 sid 聚合订单 + join 群信息。已建但本期无单的活跃群也列出(全 0)。 未归群的 sid(历史/其它来源)单独成行 group_id=None。按预估佣金降序。 """ - stmt = select(CpsOrder) + ostmt = select(CpsOrder) if date_from is not None: - stmt = stmt.where(CpsOrder.pay_time >= _as_utc(date_from)) + ostmt = ostmt.where(CpsOrder.pay_time >= _as_utc(date_from)) if date_to is not None: - stmt = stmt.where(CpsOrder.pay_time <= _as_utc(date_to)) - orders = list(db.execute(stmt).scalars().all()) + ostmt = ostmt.where(CpsOrder.pay_time <= _as_utc(date_to)) + orders_by_sid: dict[str | None, list[CpsOrder]] = {} + for o in db.execute(ostmt).scalars().all(): + orders_by_sid.setdefault(o.sid, []).append(o) - groups = {g.sid: g for g in db.execute(select(CpsGroup)).scalars().all()} + clicks = cps_link_repo.click_stats_by_group(db, date_from=date_from, date_to=date_to) + groups = list(db.execute(select(CpsGroup)).scalars().all()) - buckets: dict[str | None, list[CpsOrder]] = {} - for o in orders: - buckets.setdefault(o.sid, []).append(o) - - rows: list[dict] = [] - for sid, items in buckets.items(): - g = groups.get(sid) if sid else None + def _order_agg(items: list[CpsOrder]) -> dict: valid = [o for o in items if o.mt_status not in _INVALID_STATUS] settled = [o for o in items if o.mt_status == _SETTLED_STATUS] canceled = [o for o in items if o.mt_status in _INVALID_STATUS] - rows.append({ - "group_id": g.id if g else None, - "sid": sid, - "name": g.name if g else (sid or "(无 sid 归属)"), - "member_count": g.member_count if g else None, + return { "order_count": len(valid), "settled_count": len(settled), "canceled_count": len(canceled), "gmv_cents": sum(o.pay_price_cents or 0 for o in valid), "est_commission_cents": sum(o.commission_cents or 0 for o in valid), "settled_commission_cents": sum(o.commission_cents or 0 for o in settled), - }) + } - # 已建的活跃群但本期无单 → 补 0 行,让运营看到全部群 - seen = set(buckets.keys()) - for sid, g in groups.items(): - if sid not in seen and g.status == "active": - rows.append({ - "group_id": g.id, "sid": sid, "name": g.name, - "member_count": g.member_count, "order_count": 0, - "settled_count": 0, "canceled_count": 0, "gmv_cents": 0, - "est_commission_cents": 0, "settled_commission_cents": 0, - }) + # 淘宝/京东无法对账 → 对账字段全 None(前端显示 "-") + _no_recon = { + "order_count": None, "settled_count": None, "canceled_count": None, + "gmv_cents": None, "est_commission_cents": None, "settled_commission_cents": None, + } - # 合并点击数据(按 group_id):点击只对生成过我们短链的群有,未归群行(group_id=None)恒 0 - clicks = cps_link_repo.click_stats_by_group(db, date_from=date_from, date_to=date_to) - for r in rows: - c = clicks.get(r["group_id"]) if r["group_id"] is not None else None - r["click_pv"] = c["pv"] if c else 0 - r["click_uv"] = c["uv"] if c else 0 + rows: list[dict] = [] + seen_sids: set[str] = set() + for g in groups: + if g.status != "active": + continue + c = clicks.get(g.id) or {} + row = { + "group_id": g.id, "sid": g.sid, "name": g.name, + "platforms": list(g.platforms or []), "member_count": g.member_count, + "click_pv": c.get("pv", 0), "click_uv": c.get("uv", 0), + "copy_pv": c.get("copy_pv", 0), "copy_uv": c.get("copy_uv", 0), + } + # 对账只对美团群(有 sid);淘宝/京东 → None + if "meituan" in (g.platforms or []) and g.sid: + row.update(_order_agg(orders_by_sid.get(g.sid, []))) + seen_sids.add(g.sid) + else: + row.update(_no_recon) + rows.append(row) - rows.sort(key=lambda x: x["est_commission_cents"], reverse=True) + # 未归群的历史美团 sid(如 wonderableai):单列,有对账无点击 + for sid, items in orders_by_sid.items(): + if sid is None or sid in seen_sids: + continue + row = { + "group_id": None, "sid": sid, "name": sid, "platforms": ["meituan"], + "member_count": None, "click_pv": 0, "click_uv": 0, "copy_pv": 0, "copy_uv": 0, + } + row.update(_order_agg(items)) + rows.append(row) + + # 佣金降序(None 当 0),次按点击 + rows.sort(key=lambda x: ((x["est_commission_cents"] or 0), x["click_pv"]), reverse=True) return rows + + +def group_click_timeseries( + db: Session, *, group_id: int, granularity: str, + start: datetime, end: datetime, +) -> list[dict]: + """该群点击时序(北京时区分桶,granularity=day|hour)。 + + 每桶:click_pv/uv(visit)+copy_pv/uv;UV=distinct(ip,ua)。**生成连续桶(无数据补 0)**, + 让折线图 x 轴按所选范围铺满、不断裂(CPS 数据稀疏,补零是关键)。 + """ + rows = db.execute( + select(CpsClick.clicked_at, CpsClick.event_type, CpsClick.ip, CpsClick.ua) + .where(CpsClick.group_id == group_id) + .where(CpsClick.clicked_at >= _as_utc(start)) + .where(CpsClick.clicked_at <= _as_utc(end)) + ).all() + + agg: dict = {} # bucket_key -> {vp, vu(set), cp, cu(set)} + for clicked_at, event_type, ip, ua in rows: + # naive(本地 SQLite 读回无 tz)按 UTC 兜底,再转北京——与 queries._as_utc 约定一致, + # 否则 astimezone 会把 UTC 瞬刻当系统本地时区解释,分桶整体偏移。 + ca = clicked_at if clicked_at.tzinfo else clicked_at.replace(tzinfo=timezone.utc) + bj = ca.astimezone(_BJ_TZ) + key = bj.strftime("%Y-%m-%d") if granularity == "day" else bj.hour + b = agg.setdefault(key, {"vp": 0, "vu": set(), "cp": 0, "cu": set()}) + uid = f"{ip or ''}|{ua or ''}" + if event_type == "copy": + b["cp"] += 1 + b["cu"].add(uid) + else: + b["vp"] += 1 + b["vu"].add(uid) + + def _point(label: str, b: dict | None) -> dict: + return { + "label": label, + "click_pv": b["vp"] if b else 0, + "click_uv": len(b["vu"]) if b else 0, + "copy_pv": b["cp"] if b else 0, + "copy_uv": len(b["cu"]) if b else 0, + } + + points: list[dict] = [] + if granularity == "day": + cur = start.astimezone(_BJ_TZ).date() + last = end.astimezone(_BJ_TZ).date() + while cur <= last: + points.append(_point(cur.strftime("%m-%d"), agg.get(cur.strftime("%Y-%m-%d")))) + cur += timedelta(days=1) + else: + for h in range(24): + points.append(_point(f"{h:02d}:00", agg.get(h))) + return points + + +def group_order_daily( + db: Session, *, sid: str, start: datetime, end: datetime, +) -> dict[str, dict]: + """美团群订单按天(北京时区,key=YYYY-MM-DD)聚合。口径与 group_stats._order_agg 一致: + 取消(4)/风控(5)不计有效;结算只数 status=6。退款佣金取当天全部单的 refund_profit_cents 之和。 + 返回 {date: {order_count, canceled_count, gmv_cents, est_commission_cents, + refund_profit_cents, settled_commission_cents}};无单的天不在 dict 里(端点侧补零)。 + """ + rows = ( + db.execute( + select(CpsOrder) + .where(CpsOrder.sid == sid) + .where(CpsOrder.pay_time >= _as_utc(start)) + .where(CpsOrder.pay_time <= _as_utc(end)) + ) + .scalars() + .all() + ) + by_day: dict[str, list] = {} + for o in rows: + if not o.pay_time: + continue + pt = o.pay_time if o.pay_time.tzinfo else o.pay_time.replace(tzinfo=timezone.utc) + d = pt.astimezone(_BJ_TZ).strftime("%Y-%m-%d") + by_day.setdefault(d, []).append(o) + + out: dict[str, dict] = {} + for d, items in by_day.items(): + valid = [o for o in items if o.mt_status not in _INVALID_STATUS] + settled = [o for o in items if o.mt_status == _SETTLED_STATUS] + canceled = [o for o in items if o.mt_status in _INVALID_STATUS] + out[d] = { + "order_count": len(valid), + "canceled_count": len(canceled), + "gmv_cents": sum(o.pay_price_cents or 0 for o in valid), + "est_commission_cents": sum(o.commission_cents or 0 for o in valid), + "refund_profit_cents": sum(o.refund_profit_cents or 0 for o in items), + "settled_commission_cents": sum(o.commission_cents or 0 for o in settled), + } + return out + + +def group_wx_users(db: Session, *, group_id: int, limit: int = 200) -> list[dict]: + """群内微信用户(首次来源 first_group_id=group_id)+ 每人在该群的 visit/copy 次数(领券画像)。 + + 按首次进入倒序。copy 次数 = 该用户在该群点了几次「复制口令」(领券意向),visit = 进落地页次数。 + """ + users = ( + db.execute( + select(CpsWxUser) + .where(CpsWxUser.first_group_id == group_id) + .order_by(desc(CpsWxUser.first_seen)) + .limit(limit) + ) + .scalars() + .all() + ) + if not users: + return [] + openids = [u.openid for u in users] + stat: dict[str, dict] = {} + rows = db.execute( + select(CpsClick.openid, CpsClick.event_type, func.count()) + .where(CpsClick.group_id == group_id) + .where(CpsClick.openid.in_(openids)) + .group_by(CpsClick.openid, CpsClick.event_type) + ).all() + for openid, event_type, cnt in rows: + s = stat.setdefault(openid, {"visit": 0, "copy": 0}) + if event_type == "copy": + s["copy"] = cnt + else: + s["visit"] = cnt + return [ + { + "openid": u.openid, + "nickname": u.nickname, + "headimgurl": u.headimgurl, + "first_seen": u.first_seen, + "visit_count": stat.get(u.openid, {}).get("visit", 0), + "copy_count": stat.get(u.openid, {}).get("copy", 0), + } + for u in users + ] diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index 6e39192..7294ab7 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -109,6 +109,63 @@ def list_users( return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor) +def _attach_user_info(db: Session, records: list[ComparisonRecord]) -> None: + """给每条比价记录瞬态挂 phone/nickname(非 DB 列,供 admin schema from_attributes 读)。""" + uids = {r.user_id for r in records} + if not uids: + return + rows = db.execute( + select(User.id, User.phone, User.nickname).where(User.id.in_(uids)) + ).all() + umap = {uid: (phone, nick) for uid, phone, nick in rows} + for r in records: + phone, nick = umap.get(r.user_id, (None, None)) + r.phone = phone + r.nickname = nick + + +def list_comparison_records( + db: Session, + *, + user_id: int | None = None, + phone: str | None = None, + status: str | None = None, + business_type: str | None = None, + limit: int = 20, + cursor: int | None = None, +) -> tuple[list[ComparisonRecord], int | None, int]: + """admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛, + offset 分页(创建时间倒序、id 兜底)。join User 取 phone/nickname 瞬态挂记录上。""" + stmt = select(ComparisonRecord) + if user_id is not None: + stmt = stmt.where(ComparisonRecord.user_id == user_id) + if phone: + stmt = stmt.where( + ComparisonRecord.user_id.in_( + select(User.id).where(User.phone.like(f"{phone}%")) + ) + ) + if status: + stmt = stmt.where(ComparisonRecord.status == status) + if business_type: + stmt = stmt.where(ComparisonRecord.business_type == business_type) + items, next_cursor, total = offset_paginate( + db, stmt, + (desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)), + limit=limit, cursor=cursor, + ) + _attach_user_info(db, items) + return items, next_cursor, total + + +def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None: + """admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。""" + rec = db.get(ComparisonRecord, record_id) + if rec is not None: + _attach_user_info(db, [rec]) + return rec + + def list_onboarding_devices(db: Session, *, limit: int = 500) -> list[dict]: """按设备(device_id, ANDROID_ID)聚合 onboarding_completion:每台设备走过引导的账号数 + 最近完成时间,按最近完成倒序。设备维度新手引导管理用。没走过引导的设备不在表里(本就会 diff --git a/app/admin/routers/ad_config.py b/app/admin/routers/ad_config.py new file mode 100644 index 0000000..c46068c --- /dev/null +++ b/app/admin/routers/ad_config.py @@ -0,0 +1,45 @@ +"""admin 广告配置:读/写穿山甲 app_id / 各场景广告位ID / 验签密钥 / 各场景开关。 + +存在 app_config 表的 ad_config dict(见 repositories/app_config.get_ad_config/set_ad_config)。 +客户端经 /api/v1/platform/ad-config 拉取(不含 reward_mkey)。权限 operator/finance + 审计。 +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, Request + +from app.admin.audit import write_audit +from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role +from app.admin.schemas.ad_config import AdConfigOut, AdConfigUpdate +from app.models.admin import AdminUser +from app.repositories import app_config + +router = APIRouter( + prefix="/admin/api/ad-config", + tags=["admin-ad-config"], + dependencies=[Depends(get_current_admin)], +) + + +@router.get("", response_model=AdConfigOut, summary="广告配置(穿山甲ID/验签密钥/各场景开关)") +def get_ad_config(db: AdminDb) -> AdConfigOut: + return AdConfigOut(**app_config.get_ad_config(db)) + + +@router.patch("", response_model=AdConfigOut, summary="改广告配置(部分更新,带审计)") +def update_ad_config( + body: AdConfigUpdate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator", "finance"))], + db: AdminDb, +) -> AdConfigOut: + data = body.model_dump(exclude_none=True) + app_config.set_ad_config(db, data, admin_id=admin.id, commit=False) + # 审计只记改了哪些字段,不记 mkey 明文(机密) + write_audit( + db, admin, action="ad_config.set", target_type="ad_config", target_id="ad_config", + detail={"changed": sorted(data.keys())}, ip=get_client_ip(request), commit=False, + ) + db.commit() + return AdConfigOut(**app_config.get_ad_config(db)) diff --git a/app/admin/routers/comparison.py b/app/admin/routers/comparison.py new file mode 100644 index 0000000..9aeb859 --- /dev/null +++ b/app/admin/routers/comparison.py @@ -0,0 +1,58 @@ +"""admin 比价记录 debug:按 user_id / phone 查列表 + 单条详情(含 LLM 每次调用明细 / 原始 payload)。 + +只读。读权限默认即可(get_current_admin),不额外 require_role(查记录是基础 debug 能力)。 +trace_url 无条件下发——admin 是内部 debug 工具,不走 C 端 user.debug_trace_enabled 权限闸。 +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.admin.deps import AdminDb, get_current_admin +from app.admin.repositories import queries +from app.admin.schemas.common import CursorPage +from app.admin.schemas.comparison import AdminComparisonDetail, AdminComparisonListItem + +router = APIRouter( + prefix="/admin/api/comparison-records", + tags=["admin-comparison"], + dependencies=[Depends(get_current_admin)], +) + + +@router.get( + "", + response_model=CursorPage[AdminComparisonListItem], + summary="比价记录列表(按 user_id/phone 筛 + 分页)", +) +def list_comparison_records( + db: AdminDb, + user_id: Annotated[int | None, Query()] = None, + phone: Annotated[str | None, Query(description="手机号前缀")] = None, + status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None, + business_type: Annotated[str | None, Query()] = None, + limit: Annotated[int, Query(ge=1, le=100)] = 20, + cursor: Annotated[int | None, Query()] = None, +) -> CursorPage[AdminComparisonListItem]: + items, next_cursor, total = queries.list_comparison_records( + db, user_id=user_id, phone=phone, status=status, + business_type=business_type, limit=limit, cursor=cursor, + ) + return CursorPage( + items=[AdminComparisonListItem.model_validate(r) for r in items], + next_cursor=next_cursor, + total=total, + ) + + +@router.get( + "/{record_id}", + response_model=AdminComparisonDetail, + summary="比价记录详情(含 LLM 每次调用明细 + 原始 payload)", +) +def get_comparison_record(record_id: int, db: AdminDb) -> AdminComparisonDetail: + rec = queries.get_comparison_record(db, record_id) + if rec is None: + raise HTTPException(status_code=404, detail="记录不存在") + return AdminComparisonDetail.model_validate(rec) diff --git a/app/admin/routers/config.py b/app/admin/routers/config.py index e581d24..8a65c61 100644 --- a/app/admin/routers/config.py +++ b/app/admin/routers/config.py @@ -43,6 +43,9 @@ def _validate(key: str, value: Any) -> None: for k, v in value.items() ): raise ValueError("需为 {字符串: 整数} 映射") + elif t == "bool": + if not isinstance(value, bool): + raise ValueError("需为布尔值") def _item(db, key: str) -> ConfigItemOut: diff --git a/app/admin/routers/cps.py b/app/admin/routers/cps.py index 592f363..a1fd267 100644 --- a/app/admin/routers/cps.py +++ b/app/admin/routers/cps.py @@ -1,6 +1,7 @@ -"""admin CPS 分发与对账:群(sid)管理 + 活动池 + 生成带 sid 券链接 + 美团订单对账 + 统计。 +"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 美团订单对账 + 统计。 -当前仅接美团(get_referral_link / query_order)。淘宝/京东待各自联盟凭证。 +平台:meituan(actId+sid 转链 + query_order 对账) / taobao(整段淘口令) / jd(链接)。 +淘宝/京东无 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"。 群/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可。 """ from __future__ import annotations @@ -9,30 +10,35 @@ import time from datetime import datetime, timedelta, timezone from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile from app.admin.audit import write_audit from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role from app.admin.repositories import cps as cps_repo from app.admin.schemas.common import CursorPage -from app.core.config import settings -from app.repositories import cps_link as cps_link_repo from app.admin.schemas.cps import ( CpsActivityCreate, CpsActivityOut, + CpsActivityUpdate, CpsGroupCreate, CpsGroupOut, CpsGroupStat, CpsGroupUpdate, CpsOrderOut, CpsReconcileResult, - CpsReferralLinkOut, - CpsReferralLinkRequest, + CpsReferralLinkItem, + CpsReferralLinksOut, + CpsReferralLinksRequest, CpsStatsOut, ) +from app.core import media +from app.core.config import settings from app.integrations import meituan from app.integrations.meituan import MeituanCpsError from app.models.admin import AdminUser +from app.models.cps_activity import CpsActivity +from app.models.cps_group import CpsGroup +from app.repositories import cps_link as cps_link_repo router = APIRouter( prefix="/admin/api/cps", @@ -40,9 +46,11 @@ router = APIRouter( dependencies=[Depends(get_current_admin)], ) +_VALID_PLATFORMS = {"meituan", "taobao", "jd"} -# ───────────── 群(sid) ───────────── -@router.get("/groups", response_model=CursorPage[CpsGroupOut], summary="群(sid)列表") + +# ───────────── 群 ───────────── +@router.get("/groups", response_model=CursorPage[CpsGroupOut], summary="群列表") def list_groups( db: AdminDb, keyword: Annotated[str | None, Query(max_length=100)] = None, @@ -60,22 +68,27 @@ def list_groups( ) -@router.post("/groups", response_model=CpsGroupOut, summary="新建群(分配 sid)") +@router.post("/groups", response_model=CpsGroupOut, summary="新建群") def create_group( body: CpsGroupCreate, request: Request, admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> CpsGroupOut: - if body.sid and cps_repo.get_group_by_sid(db, body.sid) is not None: + bad = [p for p in body.platforms if p not in _VALID_PLATFORMS] + if bad: + raise HTTPException(status_code=400, detail=f"非法平台: {bad}") + has_meituan = "meituan" in body.platforms + if has_meituan and body.sid and cps_repo.get_group_by_sid(db, body.sid) is not None: raise HTTPException(status_code=409, detail="sid 已存在") group = cps_repo.create_group( - db, name=body.name, sid=body.sid, member_count=body.member_count, - remark=body.remark, commit=False, + db, name=body.name, platforms=body.platforms, sid=body.sid, + member_count=body.member_count, remark=body.remark, commit=False, ) write_audit( db, admin, action="cps.group.create", target_type="cps_group", target_id=group.id, - detail={"sid": group.sid, "name": group.name}, ip=get_client_ip(request), commit=False, + detail={"name": group.name, "platforms": group.platforms, "sid": group.sid}, + ip=get_client_ip(request), commit=False, ) db.commit() db.refresh(group) @@ -93,9 +106,13 @@ def update_group( group = cps_repo.get_group(db, group_id) if group is None: raise HTTPException(status_code=404, detail="群不存在") + if body.platforms is not None: + bad = [p for p in body.platforms if p not in _VALID_PLATFORMS] + if bad: + raise HTTPException(status_code=400, detail=f"非法平台: {bad}") cps_repo.update_group( - db, group, name=body.name, member_count=body.member_count, - status=body.status, remark=body.remark, commit=False, + db, group, name=body.name, platforms=body.platforms, + member_count=body.member_count, status=body.status, remark=body.remark, commit=False, ) write_audit( db, admin, action="cps.group.update", target_type="cps_group", target_id=group_id, @@ -106,6 +123,26 @@ def update_group( return CpsGroupOut.model_validate(group) +@router.delete("/groups/{group_id}", summary="删除群") +def delete_group( + group_id: int, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> dict: + group = cps_repo.get_group(db, group_id) + if group is None: + raise HTTPException(status_code=404, detail="群不存在") + write_audit( + db, admin, action="cps.group.delete", target_type="cps_group", target_id=group_id, + detail={"name": group.name, "sid": group.sid, "platforms": list(group.platforms or [])}, + ip=get_client_ip(request), commit=False, + ) + cps_repo.delete_group(db, group, commit=False) + db.commit() + return {"ok": True} + + # ───────────── 活动 ───────────── @router.get("/activities", response_model=CursorPage[CpsActivityOut], summary="活动列表") def list_activities( @@ -132,15 +169,23 @@ def create_activity( admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> CpsActivityOut: - if not body.act_id and not body.product_view_sign: - raise HTTPException(status_code=400, detail="act_id 或 product_view_sign 必填其一") + if body.platform == "meituan": + if not body.act_id and not body.product_view_sign: + raise HTTPException(status_code=400, detail="美团活动需填 actId 或 productViewSign") + else: # taobao / jd + if not body.payload: + label = "淘口令" if body.platform == "taobao" else "推广链接" + raise HTTPException(status_code=400, detail=f"{body.platform} 活动需填{label}") + if body.platform == "taobao" and not body.image_url: + raise HTTPException(status_code=400, detail="淘宝活动需提供落地页图(上传或选已有)") activity = cps_repo.create_activity( db, name=body.name, platform=body.platform, act_id=body.act_id, - product_view_sign=body.product_view_sign, remark=body.remark, commit=False, + product_view_sign=body.product_view_sign, payload=body.payload, + image_url=media.to_abs_media_url(body.image_url), remark=body.remark, commit=False, ) write_audit( db, admin, action="cps.activity.create", target_type="cps_activity", target_id=activity.id, - detail={"name": activity.name, "act_id": activity.act_id, "platform": activity.platform}, + detail={"name": activity.name, "platform": activity.platform}, ip=get_client_ip(request), commit=False, ) db.commit() @@ -148,65 +193,150 @@ def create_activity( return CpsActivityOut.model_validate(activity) -# ───────────── 转链 ───────────── -@router.post("/referral-link", response_model=CpsReferralLinkOut, summary="生成群发短链(带 sid + 点击统计)") -def generate_referral_link( - body: CpsReferralLinkRequest, +@router.patch("/activities/{activity_id}", response_model=CpsActivityOut, summary="编辑活动") +def update_activity( + activity_id: int, + body: CpsActivityUpdate, request: Request, admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, -) -> CpsReferralLinkOut: +) -> CpsActivityOut: + activity = cps_repo.get_activity(db, activity_id) + if activity is None: + raise HTTPException(status_code=404, detail="活动不存在") + # 用「合并后最终值」校验平台必填项(同新建口径),允许只改部分字段 + platform = body.platform or activity.platform + act_id = body.act_id if body.act_id is not None else activity.act_id + pvs = body.product_view_sign if body.product_view_sign is not None else activity.product_view_sign + payload = body.payload if body.payload is not None else activity.payload + image_url = body.image_url if body.image_url is not None else activity.image_url + if platform == "meituan": + if not act_id and not pvs: + raise HTTPException(status_code=400, detail="美团活动需填 actId 或 productViewSign") + else: # taobao / jd + if not payload: + label = "淘口令" if platform == "taobao" else "推广链接" + raise HTTPException(status_code=400, detail=f"{platform} 活动需填{label}") + if platform == "taobao" and not image_url: + raise HTTPException(status_code=400, detail="淘宝活动需提供落地页图(上传或选已有)") + cps_repo.update_activity( + db, activity, name=body.name, platform=body.platform, act_id=body.act_id, + product_view_sign=body.product_view_sign, payload=body.payload, + image_url=media.to_abs_media_url(body.image_url) if body.image_url else None, + remark=body.remark, status=body.status, commit=False, + ) + write_audit( + db, admin, action="cps.activity.update", target_type="cps_activity", target_id=activity_id, + detail=body.model_dump(exclude_none=True), ip=get_client_ip(request), commit=False, + ) + db.commit() + db.refresh(activity) + return CpsActivityOut.model_validate(activity) + + +@router.post("/upload-image", summary="上传活动落地页图(返回绝对 URL)") +async def upload_activity_image( + admin: Annotated[AdminUser, Depends(require_role("operator"))], + file: UploadFile = File(..., description="落地页图(jpeg/png/webp,≤5MB)"), +) -> dict: + data = await file.read() + try: + url = media.save_cps_image(admin.id, data) + except media.MediaError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return {"url": media.to_abs_media_url(url)} + + +@router.get("/activity-images", summary="已有活动落地页图(供新建复用选择)") +def activity_images(db: AdminDb) -> dict: + return {"images": cps_repo.list_activity_images(db)} + + +@router.delete("/activities/{activity_id}", summary="删除活动") +def delete_activity( + activity_id: int, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> dict: + activity = cps_repo.get_activity(db, activity_id) + if activity is None: + raise HTTPException(status_code=404, detail="活动不存在") + write_audit( + db, admin, action="cps.activity.delete", target_type="cps_activity", target_id=activity_id, + detail={"name": activity.name, "platform": activity.platform}, + ip=get_client_ip(request), commit=False, + ) + cps_repo.delete_activity(db, activity, commit=False) + db.commit() + return {"ok": True} + + +# ───────────── 生成链接(批量,按平台分支) ───────────── +def _gen_one_link(db, group: CpsGroup, activity: CpsActivity): + """给一个活动生成一条落地页 link。美团:转链拿短链(需群 sid);淘宝/京东:用 payload。""" + if activity.platform == "meituan": + if not group.sid: + raise HTTPException(status_code=400, detail=f"群「{group.name}」无 sid,无法生成美团链接") + try: + resp = meituan.get_referral_link( + act_id=activity.act_id, product_view_sign=activity.product_view_sign, + sid=group.sid, link_type_list=[1, 2, 3], + ) + except MeituanCpsError as e: + raise HTTPException(status_code=502, detail=f"美团转链失败: {e}") from e + link_map = {str(k): v for k, v in (resp.get("referralLinkMap") or {}).items()} + if not link_map and resp.get("data"): + link_map = {"1": resp["data"]} + target = link_map.get("2") or link_map.get("1") or link_map.get("3") + if not target: + raise HTTPException(status_code=502, detail=f"美团未返回有效链接(活动「{activity.name}」)") + else: # taobao(淘口令) / jd(链接) + if not activity.payload: + raise HTTPException(status_code=400, detail=f"活动「{activity.name}」缺少内容") + target = activity.payload + return cps_link_repo.create_link( + db, group_id=group.id, activity_id=activity.id, sid=group.sid, + target_url=target, platform=activity.platform, commit=False, + ) + + +@router.post("/referral-links", response_model=CpsReferralLinksOut, summary="批量生成落地页短链") +def generate_referral_links( + body: CpsReferralLinksRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> CpsReferralLinksOut: group = cps_repo.get_group(db, body.group_id) if group is None: raise HTTPException(status_code=404, detail="群不存在") - activity = cps_repo.get_activity(db, body.activity_id) - if activity is None: - raise HTTPException(status_code=404, detail="活动不存在") - if activity.platform != "meituan": - raise HTTPException(status_code=400, detail=f"当前仅支持美团转链(活动平台={activity.platform})") - try: - # 全要:短链作跳转目标(微信可打开),长链/deeplink 作参考/备用 - resp = meituan.get_referral_link( - act_id=activity.act_id, - product_view_sign=activity.product_view_sign, - sid=group.sid, - link_type_list=[1, 2, 3], - ) - except MeituanCpsError as e: - raise HTTPException(status_code=502, detail=f"美团转链失败: {e}") from e + group_platforms = set(group.platforms or []) + base = settings.CPS_REDIRECT_BASE.rstrip("/") - link_map = {str(k): v for k, v in (resp.get("referralLinkMap") or {}).items()} - if not link_map and resp.get("data"): - link_map = {"1": resp["data"]} - # 跳转目标优先短链(2,微信可打开)→ 长链(1)→ deeplink(3) - target_url = link_map.get("2") or link_map.get("1") or link_map.get("3") - if not target_url: - raise HTTPException(status_code=502, detail="美团未返回有效链接(检查 actId 是否在推广有效期)") + items: list[CpsReferralLinkItem] = [] + for aid in body.activity_ids: + activity = cps_repo.get_activity(db, aid) + if activity is None: + raise HTTPException(status_code=404, detail=f"活动 {aid} 不存在") + if activity.platform not in group_platforms: + raise HTTPException( + status_code=400, + detail=f"活动「{activity.name}」平台({activity.platform})不在群「{group.name}」范围内", + ) + link = _gen_one_link(db, group, activity) + items.append(CpsReferralLinkItem( + activity_id=activity.id, activity_name=activity.name, platform=activity.platform, + redirect_url=f"{base}/c/{link.code}" if base else f"/c/{link.code}", code=link.code, + )) - # 存我们的短链:发群用 /c/{code},用户点 → 我们记点击 → 302 跳 target_url - link = cps_link_repo.create_link( - db, group_id=group.id, activity_id=activity.id, sid=group.sid, - target_url=target_url, platform=activity.platform, commit=False, - ) write_audit( - db, admin, action="cps.referral_link.generate", target_type="cps_link", target_id=link.id, - detail={"sid": group.sid, "activity_id": activity.id, "code": link.code}, + db, admin, action="cps.referral_link.generate", target_type="cps_group", target_id=group.id, + detail={"group": group.name, "activity_ids": body.activity_ids, "count": len(items)}, ip=get_client_ip(request), commit=False, ) db.commit() - db.refresh(link) - - base = settings.CPS_REDIRECT_BASE.rstrip("/") - redirect_url = f"{base}/c/{link.code}" if base else f"/c/{link.code}" - return CpsReferralLinkOut( - redirect_url=redirect_url, - code=link.code, - sid=group.sid, - group_name=group.name, - activity_name=activity.name, - target_url=target_url, - link_map=link_map, - ) + return CpsReferralLinksOut(group_name=group.name, results=items) # ───────────── 订单对账 ───────────── @@ -262,7 +392,107 @@ def get_stats( stats = [CpsGroupStat(**r) for r in rows] return CpsStatsOut( groups=stats, - total_order_count=sum(s.order_count for s in stats), - total_est_commission_cents=sum(s.est_commission_cents for s in stats), - total_settled_commission_cents=sum(s.settled_commission_cents for s in stats), + total_order_count=sum(s.order_count or 0 for s in stats), + total_est_commission_cents=sum(s.est_commission_cents or 0 for s in stats), + total_settled_commission_cents=sum(s.settled_commission_cents or 0 for s in stats), ) + + +@router.get("/groups/{group_id}/timeseries", summary="群点击时序(折线图:天/小时级 PV/UV/复制)") +def group_timeseries( + group_id: int, + db: AdminDb, + granularity: Annotated[str, Query(pattern="^(day|hour)$")] = "day", + days: Annotated[int, Query(ge=1, le=30)] = 7, + date: Annotated[str | None, Query()] = None, # hour 粒度看哪天(YYYY-MM-DD 北京),默认今天 +) -> dict: + group = cps_repo.get_group(db, group_id) + if group is None: + raise HTTPException(status_code=404, detail="群不存在") + bj = timezone(timedelta(hours=8)) + now_bj = datetime.now(bj) + if granularity == "hour": + if date: + try: + day0 = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=bj) + except ValueError as e: + raise HTTPException(status_code=400, detail="date 格式应为 YYYY-MM-DD") from e + else: + day0 = now_bj + start = day0.replace(hour=0, minute=0, second=0, microsecond=0) + end = start + timedelta(days=1) - timedelta(microseconds=1) + else: + start = (now_bj - timedelta(days=days - 1)).replace(hour=0, minute=0, second=0, microsecond=0) + end = now_bj + points = cps_repo.group_click_timeseries( + db, group_id=group_id, granularity=granularity, start=start, end=end + ) + return { + "group_id": group.id, + "group_name": group.name, + "granularity": granularity, + "points": points, + } + + +@router.get("/groups/{group_id}/daily", summary="群每天明细大表格(点击+订单按天合并)") +def group_daily( + group_id: int, + db: AdminDb, + days: Annotated[int, Query(ge=1, le=90)] = 7, +) -> dict: + group = cps_repo.get_group(db, group_id) + if group is None: + raise HTTPException(status_code=404, detail="群不存在") + bj = timezone(timedelta(hours=8)) + now_bj = datetime.now(bj) + start = (now_bj - timedelta(days=days - 1)).replace(hour=0, minute=0, second=0, microsecond=0) + end = now_bj + # 点击侧:连续 N 天补零,顺序 = start..end(与下面 while 同序,按 index 对齐避免跨年 MM-DD 碰撞) + click_points = cps_repo.group_click_timeseries( + db, group_id=group_id, granularity="day", start=start, end=end + ) + # 订单侧:仅美团群(有 sid)有对账;淘宝/京东订单列返回 None(前端显示 "-") + is_meituan = "meituan" in (group.platforms or []) and bool(group.sid) + order_daily = ( + cps_repo.group_order_daily(db, sid=group.sid, start=start, end=end) if is_meituan else {} + ) + _ORDER_KEYS = ( + "order_count", "canceled_count", "gmv_cents", + "est_commission_cents", "refund_profit_cents", "settled_commission_cents", + ) + rows = [] + cur = start.astimezone(bj).date() + last = end.astimezone(bj).date() + idx = 0 + while cur <= last: + cp = click_points[idx] if idx < len(click_points) else None + row = { + "date": cur.strftime("%m-%d"), + "click_pv": cp["click_pv"] if cp else 0, + "click_uv": cp["click_uv"] if cp else 0, + "copy_pv": cp["copy_pv"] if cp else 0, + } + if is_meituan: + od = order_daily.get(cur.strftime("%Y-%m-%d")) + row.update({k: (od[k] if od else 0) for k in _ORDER_KEYS}) + else: + row.update({k: None for k in _ORDER_KEYS}) + rows.append(row) + idx += 1 + cur += timedelta(days=1) + return { + "group_id": group.id, + "group_name": group.name, + "is_meituan": is_meituan, + "days": days, + "rows": rows, + } + + +@router.get("/groups/{group_id}/wx-users", summary="群内微信用户(领券画像:头像/昵称/领券次数)") +def group_wx_users(group_id: int, db: AdminDb) -> dict: + group = cps_repo.get_group(db, group_id) + if group is None: + raise HTTPException(status_code=404, detail="群不存在") + return {"users": cps_repo.group_wx_users(db, group_id=group_id)} diff --git a/app/admin/routers/withdraw.py b/app/admin/routers/withdraw.py index 732159f..c6f7ecc 100644 --- a/app/admin/routers/withdraw.py +++ b/app/admin/routers/withdraw.py @@ -34,6 +34,7 @@ from app.admin.schemas.wallet import ( from app.core.config import settings from app.integrations import wxpay from app.models.admin import AdminUser +from app.repositories import app_config from app.repositories import wallet as wallet_repo router = APIRouter( @@ -95,7 +96,7 @@ def withdraws_summary(db: AdminDb) -> WithdrawSummaryOut: summary="提现配置健康检查", dependencies=[Depends(require_role("finance"))], # 暴露密钥路径/配置,限财务+super ) -def withdraw_health_check() -> WxpayHealthCheckOut: +def withdraw_health_check(db: AdminDb) -> WxpayHealthCheckOut: private_path = wxpay._resolve_config_path(settings.WXPAY_MCH_PRIVATE_KEY_PATH) # noqa: SLF001 public_path = wxpay._resolve_config_path(settings.WXPAY_PUBLIC_KEY_PATH) # noqa: SLF001 issues: list[str] = [] @@ -117,8 +118,15 @@ def withdraw_health_check() -> WxpayHealthCheckOut: issues.append("微信支付基础配置不完整") if not settings.WXPAY_AUTH_NOTIFY_URL: issues.append("免确认授权回调地址未配置") - if not settings.WITHDRAW_AUTO_RECONCILE_ENABLED: - issues.append("自动对账未开启") + + # 实际是否自动对账 = env 部署总闸(worker 起没起)AND 运营后台 DB 开关(本轮跑不跑)。 + worker_running = settings.WITHDRAW_AUTO_RECONCILE_ENABLED + daily_on = bool(app_config.get_value(db, "withdraw_auto_reconcile_enabled")) + auto_reconcile_enabled = worker_running and daily_on + if not worker_running: + issues.append("自动对账 worker 未启动(部署侧 env WITHDRAW_AUTO_RECONCILE_ENABLED=false)") + elif not daily_on: + issues.append("自动对账运营开关已关闭(系统配置页可开)") return WxpayHealthCheckOut( ok=not issues, @@ -131,7 +139,7 @@ def withdraw_health_check() -> WxpayHealthCheckOut: public_key_exists=public_path.exists(), public_key_loadable=public_loadable, auth_notify_url_configured=bool(settings.WXPAY_AUTH_NOTIFY_URL), - auto_reconcile_enabled=settings.WITHDRAW_AUTO_RECONCILE_ENABLED, + auto_reconcile_enabled=auto_reconcile_enabled, auto_reconcile_interval_sec=settings.WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC, auto_reconcile_older_than_minutes=settings.WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES, issues=issues, diff --git a/app/admin/schemas/ad_config.py b/app/admin/schemas/ad_config.py new file mode 100644 index 0000000..3f5c033 --- /dev/null +++ b/app/admin/schemas/ad_config.py @@ -0,0 +1,30 @@ +"""admin 广告配置 schemas(穿山甲 app_id/各位ID/验签密钥/各场景开关)。""" +from __future__ import annotations + +from pydantic import BaseModel + + +class AdConfigOut(BaseModel): + """admin 读到的完整广告配置(含 reward_mkey;admin 有权见,但绝不经 /platform 下发客户端)。""" + + app_id: str + reward_code_id: str + compare_feed_code_id: str + coupon_feed_code_id: str + reward_mkey: str + reward_enabled: bool + compare_ad_enabled: bool + coupon_ad_enabled: bool + + +class AdConfigUpdate(BaseModel): + """部分更新:只改传入(非 None)字段。空串是合法值(如清空 mkey 回退 .env)。""" + + app_id: str | None = None + reward_code_id: str | None = None + compare_feed_code_id: str | None = None + coupon_feed_code_id: str | None = None + reward_mkey: str | None = None + reward_enabled: bool | None = None + compare_ad_enabled: bool | None = None + coupon_ad_enabled: bool | None = None diff --git a/app/admin/schemas/comparison.py b/app/admin/schemas/comparison.py new file mode 100644 index 0000000..2fef36d --- /dev/null +++ b/app/admin/schemas/comparison.py @@ -0,0 +1,70 @@ +"""admin 比价记录 debug 页 schema(独立于 C 端 app/schemas/compare_record:admin 看任意用户、 +无 debug_trace 权限闸、展示全量 debug 字段)。phone/nickname 由 queries 瞬态挂在 ORM 实例上。""" +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class AdminComparisonListItem(BaseModel): + """列表项:概要 + debug 维度概览(不含大字段 raw_payload/llm_calls/明细数组)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + user_id: int + phone: str | None = None # join User 瞬态(非 DB 列) + nickname: str | None = None # join User 瞬态 + business_type: str + trace_id: str + # admin 是 debug 工具,无条件下发 trace_url(不看 user.debug_trace_enabled) + trace_url: str | None = None + status: str + information: str | None = None + store_name: str | None = None + source_platform_name: str | None = None + best_platform_name: str | None = None + source_price_cents: int | None = None + best_price_cents: int | None = None + saved_amount_cents: int | None = None + # debug 概览 + total_ms: int | None = None + step_count: int | None = None + llm_call_count: int | None = None + retry_count: int | None = None + device_model: str | None = None + rom_vendor: str | None = None + rom_name: str | None = None + android_version: str | None = None + app_version: str | None = None + created_at: datetime + + +class AdminComparisonDetail(AdminComparisonListItem): + """详情:概要 + 全量明细(逐平台对比 / LLM 每次调用 / 原始 payload)。""" + + source_platform_id: str | None = None + source_package: str | None = None + best_platform_id: str | None = None + best_deeplink: str | None = None + is_source_best: bool | None = None + total_dish_count: int | None = None + skipped_dish_count: int | None = None + device_id: str | None = None + items: list = [] + comparison_results: list = [] # 逐平台对比(价格/rank/coupon/打烊...) + skipped_dish_names: list = [] + # 全量环境 + device_manufacturer: str | None = None + rom_version: int | None = None + android_sdk: int | None = None + app_version_code: int | None = None + source_app_version: str | None = None + longitude: float | None = None + latitude: float | None = None + # 每次 LLM 调用明细 [{scene,model,input_messages,output,usage,latency_ms,error}] + llm_calls: list | None = None + # 原始上报全量;「卡在哪一步」从 raw_payload.platform_results[*].status 读 + # (store_not_found/items_not_found/below_minimum/unsupported = 卡在 找店/加菜/起送/读价)。 + raw_payload: dict | None = None diff --git a/app/admin/schemas/config.py b/app/admin/schemas/config.py index 01b56db..10b694b 100644 --- a/app/admin/schemas/config.py +++ b/app/admin/schemas/config.py @@ -10,7 +10,7 @@ class ConfigItemOut(BaseModel): key: str label: str group: str - type: str # int / int_list / dict_str_int + type: str # int / int_list / dict_str_int / bool help: str | None = None default: Any value: Any diff --git a/app/admin/schemas/cps.py b/app/admin/schemas/cps.py index 9028fe1..e7c3f7e 100644 --- a/app/admin/schemas/cps.py +++ b/app/admin/schemas/cps.py @@ -1,4 +1,8 @@ -"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。""" +"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。 + +平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接,只统计点击)。 +对账类字段对淘宝/京东为 None → 前端显示 "-"(无法对账)。 +""" from __future__ import annotations from datetime import datetime @@ -6,13 +10,14 @@ from datetime import datetime from pydantic import BaseModel, ConfigDict, Field -# ───────────── 群(sid) ───────────── +# ───────────── 群 ───────────── class CpsGroupOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int - sid: str + sid: str | None = None # 仅美团群有 name: str + platforms: list[str] = Field(default_factory=list) member_count: int | None = None status: str remark: str | None = None @@ -21,7 +26,9 @@ class CpsGroupOut(BaseModel): class CpsGroupCreate(BaseModel): name: str = Field(min_length=1, max_length=128) - # 留空 → 后端自动生成 g;填写则必须字母+数字(美团 sid 限制)。 + # 该群发哪些平台,至少选一个 + platforms: list[str] = Field(min_length=1) + # 仅当含 meituan 时需要(留空自动生成);纯淘宝/京东不填,后端置 None。 sid: str | None = Field(default=None, max_length=64, pattern=r"^[A-Za-z0-9]+$") member_count: int | None = Field(default=None, ge=0) remark: str | None = Field(default=None, max_length=256) @@ -29,12 +36,13 @@ class CpsGroupCreate(BaseModel): class CpsGroupUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=128) + platforms: list[str] | None = None member_count: int | None = Field(default=None, ge=0) status: str | None = Field(default=None, pattern=r"^(active|archived)$") remark: str | None = Field(default=None, max_length=256) -# ───────────── 活动(actId) ───────────── +# ───────────── 活动 ───────────── class CpsActivityOut(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -43,6 +51,8 @@ class CpsActivityOut(BaseModel): name: str act_id: str | None = None product_view_sign: str | None = None + payload: str | None = None # 淘宝淘口令 / 京东链接 + image_url: str | None = None # 淘宝落地页图(绝对 URL) status: str remark: str | None = None created_at: datetime @@ -50,36 +60,55 @@ class CpsActivityOut(BaseModel): class CpsActivityCreate(BaseModel): name: str = Field(min_length=1, max_length=128) - platform: str = Field(default="meituan", pattern=r"^(meituan|taobao|jd)$") + platform: str = Field(pattern=r"^(meituan|taobao|jd)$") + # 美团:act_id 或 product_view_sign 二选一 act_id: str | None = Field(default=None, max_length=64) product_view_sign: str | None = Field(default=None, max_length=128) + # 淘宝:整段淘口令文本 / 京东:推广链接 + payload: str | None = Field(default=None, max_length=4096) + # 淘宝落地页图(绝对/相对 URL;淘宝必填,由 router 校验) + image_url: str | None = Field(default=None, max_length=512) remark: str | None = Field(default=None, max_length=256) -# ───────────── 转链 ───────────── -class CpsReferralLinkRequest(BaseModel): +class CpsActivityUpdate(BaseModel): + """编辑活动(PATCH):全字段可选,只更新传入的非 None 项;最终平台对应字段由 router 校验。""" + + name: str | None = Field(default=None, min_length=1, max_length=128) + platform: str | None = Field(default=None, pattern=r"^(meituan|taobao|jd)$") + act_id: str | None = Field(default=None, max_length=64) + product_view_sign: str | None = Field(default=None, max_length=128) + payload: str | None = Field(default=None, max_length=4096) + image_url: str | None = Field(default=None, max_length=512) + remark: str | None = Field(default=None, max_length=256) + status: str | None = Field(default=None, pattern=r"^(active|archived)$") + + +# ───────────── 生成链接(批量) ───────────── +class CpsReferralLinksRequest(BaseModel): group_id: int + activity_ids: list[int] = Field(min_length=1) + + +class CpsReferralLinkItem(BaseModel): activity_id: int - # 链接类型:1长链 2短链 3deeplink。默认短链(微信可打开)+deeplink。 - link_types: list[int] = Field(default_factory=lambda: [2, 3]) - - -class CpsReferralLinkOut(BaseModel): - redirect_url: str # ★ 我们的群发短链(/c/{code}),发群就用这个,点击经我们统计 - code: str - sid: str - group_name: str activity_name: str - target_url: str # 实际 302 跳转的美团短链 - link_map: dict[str, str] # 美团原始 1长链/2短链/3deeplink(参考/备用) + platform: str + redirect_url: str # 我们的落地页 /c/{code}(发群用) + code: str + + +class CpsReferralLinksOut(BaseModel): + group_name: str + results: list[CpsReferralLinkItem] # ───────────── 对账拉单 ───────────── class CpsReconcileResult(BaseModel): - fetched: int # 本次美团返回的订单条数 - inserted: int # 新入库 - updated: int # 已存在→更新状态/佣金 - pages: int # 翻了几页 + fetched: int + inserted: int + updated: int + pages: int # ───────────── 订单明细 ───────────── @@ -101,18 +130,22 @@ class CpsOrderOut(BaseModel): # ───────────── 统计 ───────────── class CpsGroupStat(BaseModel): - group_id: int | None = None # None = 未归群的 sid(历史/其它来源) + group_id: int | None = None sid: str | None = None name: str + platforms: list[str] = Field(default_factory=list) member_count: int | None = None - click_pv: int = 0 # 点击总次数 - click_uv: int = 0 # 独立点击(按 ip+ua 近似去重) - order_count: int # 有效订单数(不含取消/风控) - settled_count: int # 已结算单数 - canceled_count: int # 取消+风控单数 - gmv_cents: int # 成交额(有效单 payPrice 合计) - est_commission_cents: int # 预估佣金(有效单 profit 合计) - settled_commission_cents: int # 已结算佣金 + click_pv: int = 0 + click_uv: int = 0 + copy_pv: int = 0 # 淘宝"复制口令"次数 + copy_uv: int = 0 + # 对账类:淘宝/京东无法对账 → None(前端显示 "-");美团群为数值 + order_count: int | None = None + settled_count: int | None = None + canceled_count: int | None = None + gmv_cents: int | None = None + est_commission_cents: int | None = None + settled_commission_cents: int | None = None class CpsStatsOut(BaseModel): diff --git a/app/api/internal/app_version.py b/app/api/internal/app_version.py new file mode 100644 index 0000000..b3c0173 --- /dev/null +++ b/app/api/internal/app_version.py @@ -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) diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index 9508061..cf866b2 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -24,6 +24,7 @@ from app.repositories import ad_ecpm as crud_ecpm from app.repositories import ad_feed_reward as crud_feed from app.repositories import ad_reward as crud_ad from app.repositories import ad_watch as crud_watch +from app.repositories import app_config from app.repositories import signin as crud_signin from app.schemas.ad import ( AdRewardStatusOut, @@ -80,14 +81,22 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut: 验签失败 403(留给真请求重试);参数缺/坏或 user 不存在 → is_verify=false + reason(不发,不重试)。 granted / capped → is_verify=true + reason=0。 """ - if not settings.pangle_callback_configured: + if not settings.PANGLE_CALLBACK_ENABLED: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured" ) params = dict(request.query_params) - if not pangle.verify_callback_sign(params, settings.PANGLE_REWARD_SECRET): + # 验签密钥:admin 后台配的 reward_mkey 优先,.env 的 PANGLE_REWARD_SECRET* 兜底(兼容/过渡)。 + # 换激励位时后台同步换 mkey 即可,无需改 .env。两者都空才视为未配置。 + ad_mkey = app_config.get_ad_config(db).get("reward_mkey") or "" + secrets = ([ad_mkey] if ad_mkey else []) + settings.pangle_reward_secrets + if not secrets: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured" + ) + if not pangle.verify_callback_sign_any(params, secrets): logger.warning("pangle callback bad sign trans_id=%s", params.get("trans_id")) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad sign") diff --git a/app/api/v1/compare.py b/app/api/v1/compare.py index 93279b3..c05e7ca 100644 --- a/app/api/v1/compare.py +++ b/app/api/v1/compare.py @@ -116,3 +116,11 @@ async def intent_precoupon_step(request: Request) -> dict[str, Any]: @router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传到 pricebot)") async def price_step(request: Request) -> dict[str, Any]: return await _passthrough(request, "/api/price/step") + + +@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传到 pricebot, 终止/未识别拿 trace_url)") +async def trace_finalize(request: Request) -> dict[str, Any]: + # 用户终止 / Phase1 未识别没走到 done 帧, pricebot 没上云也没回传 trace_url。客户端收尾时 + # 打这个, _passthrough 按 trace_id 一致性 hash 落到处理这条 trace 的同一 pricebot 进程 + # (dir_cache 在那, 才能算对 trace 目录), 由后者打包上云返回 {trace_url}。 + return await _passthrough(request, "/api/trace/finalize") diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index 8e527dc..d4b1a03 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -15,10 +15,13 @@ from __future__ import annotations import logging -from fastapi import APIRouter, HTTPException, Query, status +from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status from app.api.deps import CurrentUser, DbSession +from app.db.session import SessionLocal +from app.models.comparison import ComparisonRecord from app.repositories import comparison as crud_compare +from app.services.pricebot_llm_calls import fetch_llm_calls from app.schemas.compare_record import ( CompareStatsOut, ComparisonRecordCreatedOut, @@ -39,11 +42,18 @@ router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"]) summary="上报一次比价结果(幂等)", ) def report_record( - payload: ComparisonRecordIn, user: CurrentUser, db: DbSession + payload: ComparisonRecordIn, + user: CurrentUser, + db: DbSession, + background_tasks: BackgroundTasks, ) -> ComparisonRecordCreatedOut: rec = crud_compare.upsert_record(db, user_id=user.id, payload=payload) + # LLM 调用明细异步回填:同机拉 pricebot llm_calls 最长 5s 且是 best-effort,放后台 + # 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用 + # 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。 + background_tasks.add_task(_backfill_llm_calls, rec.id, rec.trace_id) logger.info( - "compare record user=%s trace=%s biz=%s status=%s saved=%s", + "compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)", user.id, rec.trace_id, rec.business_type, @@ -53,6 +63,28 @@ def report_record( return ComparisonRecordCreatedOut(id=rec.id) +def _backfill_llm_calls(record_id: int, trace_id: str) -> None: + """后台回填本次比价的 LLM 调用明细 + 派生 llm_call_count/retry_count。 + 独立 DB session(请求 session 此时已关);拉取/写库失败只 log,绝不影响已落库的上报。""" + calls = fetch_llm_calls(trace_id) + if not calls: + return + db = SessionLocal() + try: + rec = db.get(ComparisonRecord, record_id) + if rec is None: + return + rec.llm_calls = calls + rec.llm_call_count = len(calls) + rec.retry_count = sum(1 for c in calls if c.get("error")) + db.commit() + logger.info("backfill llm_calls trace=%s n=%d", trace_id, len(calls)) + except Exception as e: # noqa: BLE001 best-effort + logger.warning("backfill llm_calls failed trace=%s: %s", trace_id, e) + finally: + db.close() + + @router.get( "/stats", response_model=CompareStatsOut, @@ -73,13 +105,20 @@ def list_records( db: DbSession, limit: int = Query(20, ge=1, le=100), cursor: int | None = Query(None, description="上一页末条 id"), + include_trace: bool = Query( + False, + description="客户端开了本机 agent 调试模式时带 true,放行本人记录的 trace_url", + ), ) -> ComparisonRecordPage: items, next_cursor = crud_compare.list_records( db, user.id, limit=limit, cursor=cursor ) outs = [ComparisonRecordOut.model_validate(it) for it in items] - # 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它) - if not user.debug_trace_enabled: + # 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它)。 + # include_trace=true 例外:客户端开了本机 agent 调试模式时带上,放行**本人记录**的 trace_url + # ——list_records 只查 user.id 自己的记录,给本人看自己的调试链接无越权,与实时结果页 + # CompResultScreen「debug 权限 OR 本机 agent 调试」同口径(领导 2026-06-12 拍板)。 + if not (user.debug_trace_enabled or include_trace): for o in outs: o.trace_url = None return ComparisonRecordPage(items=outs, next_cursor=next_cursor) diff --git a/app/api/v1/cps_redirect.py b/app/api/v1/cps_redirect.py index 3b4120c..ee7aa0b 100644 --- a/app/api/v1/cps_redirect.py +++ b/app/api/v1/cps_redirect.py @@ -1,21 +1,48 @@ -"""CPS 群发短链跳转:用户点 /c/{code} → 记一条点击 → 302 跳美团。 +"""CPS 群发短链落地:用户点 /c/{code} → 记点击 → 按平台 302 跳 或 返回淘宝落地页。 -公网、无鉴权(群里任何人点都要能跳)。记点击失败绝不影响跳转(用户体验优先)。 +公网无鉴权(群里任何人点都要能跳/能领)。记点击失败绝不影响用户。 +- 美团/京东:记 visit + 302 跳 target(美团短链 / 京东链接) +- 淘宝:记 visit + 返回 H5 落地页(整段淘口令复制按钮);点"复制口令"→ POST /c/{code}/copy 记 copy """ from __future__ import annotations +import html +import json +import logging + from fastapi import APIRouter, Depends, Request -from fastapi.responses import 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"]) -# code 不存在/失效时的兜底落地(避免用户看到报错) +# code 不存在/失效时的兜底落地 _FALLBACK_URL = "https://www.meituan.com/" +# 淘宝活动未设图时的兜底主视觉(存量已回填,基本只在老 link/异常时触发) +_DEFAULT_TAOBAO_IMAGE = "/media/taobao_landing.jpg" + +# 微信网页授权拿到的用户标识 cookie(种在 coupon 域,30 天免重复授权) +_WX_OPENID_COOKIE = "wx_openid" +_WX_UINFO_COOKIE = "wx_uinfo" # "1" = 已拿过昵称头像(userinfo),点领券不再跳授权 +_COOKIE_MAX_AGE = 30 * 86400 + + +def _is_wechat(request: Request) -> bool: + """是否微信内置浏览器(网页授权只在微信内有意义,外部浏览器不跳授权)。""" + return "micromessenger" in (request.headers.get("user-agent", "").lower()) + def _client_ip(request: Request) -> str | None: xff = request.headers.get("x-forwarded-for") @@ -24,16 +51,158 @@ def _client_ip(request: Request) -> str | None: return request.client.host if request.client else None -@router.get("/c/{code}", summary="群发短链跳转(记点击 + 302)") -def cps_redirect(code: str, request: Request, db: Session = Depends(get_db)): - link = cps_link_repo.get_by_code(db, code) - if link is None: - return RedirectResponse(_FALLBACK_URL, status_code=302) +def _record( + db: Session, link: CpsLink, request: Request, event_type: str, openid: str | None = None +) -> None: try: cps_link_repo.record_click( db, link=link, ip=_client_ip(request), - ua=request.headers.get("user-agent"), + ua=request.headers.get("user-agent"), event_type=event_type, openid=openid, ) except Exception: - pass # 记点击失败不阻断跳转 + pass # 记点击失败不阻断 + + +@router.get("/MP_verify_F7wnRQ7xPbhVOWC8.txt", include_in_schema=False) +def wx_mp_domain_verify() -> PlainTextResponse: + """微信「网页授权域名」归属校验文件。coupon.shaguabijia.com 全反代 app-server, + 微信请求 coupon.shaguabijia.com/MP_verify_xxx.txt 验证域名归属;内容=文件名核心串。 + 放域名根目录(本端点即根路径),为微信网页授权(拿 openid)接入做准备。""" + return PlainTextResponse("F7wnRQ7xPbhVOWC8") + + +@router.get("/c/{code}", summary="群发短链落地(微信授权拿 openid + 记点击 + 跳转/淘宝落地页)") +def cps_landing(code: str, request: Request, db: Session = Depends(get_db)): + link = cps_link_repo.get_by_code(db, code) + if link is None: + return RedirectResponse(_FALLBACK_URL, status_code=302) + openid = request.cookies.get(_WX_OPENID_COOKIE) + # 微信内 + 还没拿到 openid + 配了服务号 → 先 base 静默授权拿 openid,再 302 回本页。 + # 非微信 / 已有 cookie / 未配服务号 → 直接走原逻辑(openid 可能为 None,绝不阻断领券)。 + if openid is None and settings.wx_oauth_active and _is_wechat(request): + redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb" + auth_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_base", f"base:{code}") + return RedirectResponse(auth_url, status_code=302) + _record(db, link, request, "visit", openid=openid) + if link.platform == "taobao": + activity = db.get(CpsActivity, link.activity_id) + image_url = (activity.image_url if activity else None) or _DEFAULT_TAOBAO_IMAGE + has_uinfo = request.cookies.get(_WX_UINFO_COOKIE) == "1" + return HTMLResponse( + _taobao_landing_html(link.target_url, image_url, code, openid, has_uinfo) + ) + # 美团短链 / 京东链接:直接 302 跳 return RedirectResponse(link.target_url, status_code=302) + + +@router.get("/wx/oauth/cb", include_in_schema=False) +def wx_oauth_cb(code: str, state: str, db: Session = Depends(get_db)): + """微信网页授权回调。state='base:{原code}'(静默拿 openid) 或 'uinfo:{原code}'(补昵称头像)。 + 换 openid(+userinfo)→ upsert 用户 → 种 cookie → 302 回落地页。任何失败兜底回落地页, + 绝不阻断用户领券。""" + kind, _, orig_code = state.partition(":") + try: + token = wx_oauth.exchange_code(code) # {openid, access_token, scope, ...} + openid = token["openid"] + link = cps_link_repo.get_by_code(db, orig_code) + group_id = link.group_id if link else None + nickname = headimgurl = unionid = None + if kind == "uinfo" and "userinfo" in (token.get("scope") or ""): + info = wx_oauth.get_userinfo(token["access_token"], openid) + nickname, headimgurl, unionid = ( + info.get("nickname"), info.get("headimgurl"), info.get("unionid"), + ) + cps_wx_user_repo.upsert( + db, openid=openid, code=orig_code, group_id=group_id, + nickname=nickname, headimgurl=headimgurl, unionid=unionid, + ) + except Exception: + logger.exception("[wx_oauth] callback failed state=%s", state) + return RedirectResponse(f"/c/{orig_code}" if orig_code else _FALLBACK_URL, status_code=302) + # userinfo 授权回来带 ?authed=1,落地页据此自动触发复制(把"点领券→授权→复制"衔接为一步) + target = f"/c/{orig_code}" + ("?authed=1" if kind == "uinfo" else "") + resp = RedirectResponse(target, status_code=302) + resp.set_cookie(_WX_OPENID_COOKIE, openid, max_age=_COOKIE_MAX_AGE, httponly=True, samesite="lax") + if nickname: + resp.set_cookie(_WX_UINFO_COOKIE, "1", max_age=_COOKIE_MAX_AGE, samesite="lax") + return resp + + +@router.post("/c/{code}/copy", summary="淘宝落地页点了「复制口令」(记 copy 事件,带 openid)") +def cps_copy(code: str, request: Request, db: Session = Depends(get_db)) -> dict: + link = cps_link_repo.get_by_code(db, code) + if link is not None: + _record(db, link, request, "copy", openid=request.cookies.get(_WX_OPENID_COOKIE)) + return {"ok": True} + + +def _taobao_landing_html( + token: str, image_url: str, code: str, openid: str | None, has_uinfo: bool +) -> str: + """淘宝落地页 H5(主视觉图按活动传入,复制淘口令按钮在 75% 屏高)。 + + image_url 经 html.escape 嵌入 (防注入);token 经 json.dumps 安全嵌入 JS。 + uinfo_url:已有 openid 但还没拿过昵称头像时,生成 userinfo 授权链接 —— 用户点「领券」 + 时先跳它(交互触发,避免微信快照页),授权回来自动复制。已拿过 / 无 openid 则为空,直接复制。 + """ + safe_img = html.escape(media.to_abs_media_url(image_url) or image_url, quote=True) + uinfo_url = "" + if openid and not has_uinfo and settings.wx_oauth_active: + redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb" + uinfo_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_userinfo", f"uinfo:{code}") + return ( + _TAOBAO_HTML + .replace("__IMAGE_URL__", safe_img) + .replace("__UINFO_URL__", json.dumps(uinfo_url)) + .replace("__TOKEN_JS__", json.dumps(token)) + ) + + +_TAOBAO_HTML = """ + + + + +淘宝闪购 · 天天领红包 + + + +淘宝闪购 天天领红包 +
+
+ + +""" diff --git a/app/api/v1/platform.py b/app/api/v1/platform.py index 5c3c9d1..31908bd 100644 --- a/app/api/v1/platform.py +++ b/app/api/v1/platform.py @@ -2,6 +2,7 @@ 路由前缀 `/api/v1/platform`: GET /stats 首页三统计(帮助用户 / 完成比价 / 累计节省),按运营后台配的模式算。 + GET /flags 客户端运营 feature flag(比价/领券期广告开关等),客户端拉取后缓存。 展示模式(real/manual/random,每指标独立)与计算逻辑见 app/repositories/ops_stat.py。 """ @@ -12,9 +13,17 @@ import logging from fastapi import APIRouter, Query from app.api.deps import DbSession +from app.repositories import app_config from app.repositories import ops_marquee as marquee_crud from app.repositories import ops_stat as crud -from app.schemas.platform import PlatformStatsOut, SavingsFeedItem, SavingsFeedOut +from app.schemas.platform import ( + AdConfigPublicOut, + AppFlagsOut, + AppVersionOut, + PlatformStatsOut, + SavingsFeedItem, + SavingsFeedOut, +) logger = logging.getLogger("shagua.platform") @@ -35,3 +44,38 @@ def stats(db: DbSession) -> PlatformStatsOut: def savings_feed(db: DbSession, limit: int = Query(8, ge=1, le=30)) -> SavingsFeedOut: items = marquee_crud.get_feed(db, limit=limit) return SavingsFeedOut(items=[SavingsFeedItem(**it) for it in items]) + + +@router.get("/flags", response_model=AppFlagsOut, summary="客户端运营 feature flag(不鉴权)") +def flags(db: DbSession) -> AppFlagsOut: + """客户端拉取运营开关并缓存(app 启动 / 每场比价开始时刷新)。不鉴权:开关非敏感, + 且比价无障碍服务取值时未必有登录态。值来自 app_config(admin 可改),空库回退默认。""" + return AppFlagsOut( + comparing_ad_enabled=bool(app_config.get_value(db, "comparing_ad_enabled")), + ) + + +@router.get("/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) diff --git a/app/api/v1/user.py b/app/api/v1/user.py index 6be689f..dc23388 100644 --- a/app/api/v1/user.py +++ b/app/api/v1/user.py @@ -18,6 +18,7 @@ from app.api.deps import CurrentUser, DbSession from app.core import media from app.repositories import onboarding as onboarding_repo from app.repositories import user as user_repo +from app.repositories import wallet as wallet_repo from app.schemas.auth import UserOut from app.schemas.user import ( OkResponse, @@ -87,6 +88,17 @@ def onboarding_status( @router.delete("", response_model=OkResponse, summary="注销账号(软删除)") def delete_account(user: CurrentUser, db: DbSession) -> OkResponse: + # 资金前置校验:注销是软删 + 匿名化,余额一旦留在 deleted 行就锁死(既退不出、新号也 + # 拿不回)。有可提现现金 / 在审提现单 → 拒绝注销,引导用户先把钱处理掉。 + # (pending 提现转账已在途、对账 worker 不依赖 user.status 照常到账,故不拦 pending。) + if wallet_repo.get_cash_balance_cents(db, user.id) > 0: + raise HTTPException( + status_code=409, detail="账户还有未提现的现金余额,请先提现后再注销" + ) + if wallet_repo.has_reviewing_withdraw(db, user.id): + raise HTTPException( + status_code=409, detail="有提现正在审核中,请等审核完成后再注销" + ) media.delete_avatar(user.avatar_url) user_repo.soft_delete_account(db, user) logger.info("delete account user_id=%d", user.id) diff --git a/app/core/config.py b/app/core/config.py index 719881e..900d3e8 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -121,6 +121,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 一致。 @@ -162,8 +181,15 @@ class Settings(BaseSettings): # ===== 穿山甲激励视频(服务端发奖回调)===== # 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。 - # PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",验签用,从后台取到后填 .env。 + # 穿山甲后台配置的"奖励校验密钥"(m-key),验签用。每个 GroMore 广告位 m-key 不同(后台各自 + # 生成),但共用同一回调 URL → 多个位的 m-key 都要配上。验签时所有非空 m-key 逐个试、任一通过 + # 即接受(见 pangle.verify_callback_sign_any / 下面的 pangle_reward_secrets)。 PANGLE_CALLBACK_ENABLED: bool = False + # 推荐:每个激励位的 m-key 分开一行配,清晰不混淆(留空的忽略)。 + PANGLE_REWARD_SECRET_TEST: str = "" # 测试应用 激励位 104099649 + PANGLE_REWARD_SECRET_TEST_DEDICATED: str = "" # 测试应用 专属激励位 104127529 + PANGLE_REWARD_SECRET_PROD: str = "" # 正式应用 激励位 104099389 + # 旧用法:单个或逗号分隔的多个 m-key,仍兼容(会与上面三个命名项合并去重)。 PANGLE_REWARD_SECRET: str = "" # ⚠️ 仅本地联调:打开后开放 POST /api/v1/ad/test-grant,让(已登录的)客户端在没部署公网、 @@ -171,10 +197,23 @@ class Settings(BaseSettings): # 它让客户端能自助发奖 = 绕过反作弊,**生产必须保持 False**(默认 False;只在本地 .env 设 true)。 AD_REWARD_TEST_GRANT_ENABLED: bool = False + @property + def pangle_reward_secrets(self) -> list[str]: + """汇总所有 m-key 成列表(去空白、去空项、去重保序)。验签时逐个试、任一通过即接受 + (见 pangle.verify_callback_sign_any)。来源可混用:三个命名项 + 旧的逗号分隔 PANGLE_REWARD_SECRET。""" + raw = [ + *self.PANGLE_REWARD_SECRET.split(","), + self.PANGLE_REWARD_SECRET_TEST, + self.PANGLE_REWARD_SECRET_TEST_DEDICATED, + self.PANGLE_REWARD_SECRET_PROD, + ] + cleaned = [s.strip() for s in raw if s and s.strip()] + return list(dict.fromkeys(cleaned)) # 去重保序 + @property def pangle_callback_configured(self) -> bool: - """回调开关打开且验签密钥已配,才接受发奖回调。""" - return bool(self.PANGLE_CALLBACK_ENABLED and self.PANGLE_REWARD_SECRET) + """回调开关打开且至少配了一个验签密钥,才接受发奖回调。""" + return bool(self.PANGLE_CALLBACK_ENABLED and self.pangle_reward_secrets) # ===== Pricebot 上游 (领券/比价业务透传目标) ===== # pricebot-backend 默认跑在 8000。/api/v1/coupon/step 会透传到这里的 /api/coupon/step diff --git a/app/core/config_schema.py b/app/core/config_schema.py index 65d2a9a..5a56c3a 100644 --- a/app/core/config_schema.py +++ b/app/core/config_schema.py @@ -11,7 +11,7 @@ from typing import Any from app.core import rewards as r -# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int +# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool CONFIG_DEFS: dict[str, dict[str, Any]] = { "signin_rewards": { "default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位", @@ -65,4 +65,23 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { "group": "签到", "type": "int", "help": "Day1-Day6 签到后看完激励视频额外发放的固定金币;Day7 不展示也不允许膨胀。", }, + "comparing_ad_enabled": { + "default": True, "label": "比价/领券期信息流广告", + "group": "看广告", "type": "bool", + "help": ( + "开启后,比价进行中 + 领券等候期会在悬浮窗展示穿山甲信息流广告(变现行为);" + "关闭则全程不出广告。客户端按 app 启动 / 每场比价开始时拉取并缓存,故为「最终一致」的" + "远程开关(下一场比价生效),用于出问题时无需发版即可快速止血。debug 包可用本地开关覆盖。" + ), + }, + "withdraw_auto_reconcile_enabled": { + "default": True, "label": "提现自动对账", + "group": "钱包", "type": "bool", + "help": ( + "开启后后台 worker 每隔一段时间自动扫描超时仍「打款中」的提现单并归一化" + "(查微信/退款/撤单)。需部署侧 env WITHDRAW_AUTO_RECONCILE_ENABLED=true 启动 worker " + "进程后此开关才起效;扫描间隔/超时阈值仍由 env 控制。关掉只停自动扫描," + "提现页「批量对账」手动按钮不受影响。" + ), + }, } diff --git a/app/core/heartbeat_monitor_worker.py b/app/core/heartbeat_monitor_worker.py index d5c6a16..6072d26 100644 --- a/app/core/heartbeat_monitor_worker.py +++ b/app/core/heartbeat_monitor_worker.py @@ -87,7 +87,7 @@ def _scan_once(timeout_minutes: int) -> dict: for device in overdue: silent = _silent_seconds(device.last_heartbeat_at) logger.warning( - "🔴 [掉线检测] user_id=%s device_id=%s 已 %s 秒无心跳(阈值 %d 分钟)" + "[掉线检测] user_id=%s device_id=%s 已 %s 秒无心跳(阈值 %d 分钟)" " → 判定 App 已被杀/无障碍已停。【已置 kill_alert_pending: 用户下次进 App 将弹「开启自启动」引导(后置检测);推送本期未接】", device.user_id, device.device_id, diff --git a/app/core/media.py b/app/core/media.py index e7e2611..accce69 100644 --- a/app/core/media.py +++ b/app/core/media.py @@ -67,6 +67,23 @@ def save_report_image(user_id: int, data: bytes) -> str: return _save_image("price_report", user_id, data) +def save_cps_image(admin_id: int, data: bytes) -> str: + """保存 CPS 活动落地页图,返回相对 URL(`/media/cps/`)。admin_id 入文件名便于追溯。""" + return _save_image("cps", admin_id, data) + + +def to_abs_media_url(rel_url: str | None) -> str | None: + """相对 `/media/...` → 基于 CPS_REDIRECT_BASE 的绝对 URL(CPS 落地页域名,跨域可访问)。 + + 已是绝对 URL / 空 / base 未配(dev) 时原样返回。CPS 活动图入库前转绝对,使落地页 + (coupon 域)与 admin-web(另一域,不 serve /media)都能直接 加载。 + """ + if not rel_url or rel_url.startswith(("http://", "https://")): + return rel_url + base = settings.CPS_REDIRECT_BASE.rstrip("/") + return f"{base}{rel_url}" if base else rel_url + + def delete_avatar(url: str | None) -> None: """删除本服务托管的旧头像文件;外部 URL(如微信头像)或空值不处理。""" prefix = f"{settings.MEDIA_URL_PREFIX}/avatars/" diff --git a/app/core/withdraw_reconcile_worker.py b/app/core/withdraw_reconcile_worker.py index eac6dd6..6d667f7 100644 --- a/app/core/withdraw_reconcile_worker.py +++ b/app/core/withdraw_reconcile_worker.py @@ -14,8 +14,11 @@ from sqlalchemy.exc import SQLAlchemyError from app.core.config import settings from app.db.session import SessionLocal from app.integrations.wxpay import WxPayNotConfiguredError +from app.repositories import app_config from app.repositories import wallet as wallet_repo +_AUTO_RECONCILE_KEY = "withdraw_auto_reconcile_enabled" + logger = logging.getLogger("shagua.withdraw_reconcile") _LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "withdraw_reconcile.lock" @@ -59,8 +62,15 @@ def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]: _LOCK_PATH.unlink() -def _reconcile_once(older_than_minutes: int) -> dict: +def _reconcile_once(older_than_minutes: int) -> dict | None: + """读运营开关(app_config):关着返回 None(本轮跳过),开着才真扫单。 + + env WITHDRAW_AUTO_RECONCILE_ENABLED 是部署级总闸(决定 worker 起不起); + 这里的 DB 开关是运营级日常开关,后台一改下一轮即生效、跨进程一致、无需重启。 + """ with SessionLocal() as db: + if not app_config.get_value(db, _AUTO_RECONCILE_KEY): + return None return wallet_repo.reconcile_pending_withdraws(db, older_than_minutes=older_than_minutes) @@ -78,16 +88,18 @@ async def _run_loop() -> None: async def _run_locked_loop(interval: int, older_than: int) -> None: logger.info( - "withdraw auto reconcile started interval=%ss older_than=%sm", + "withdraw auto reconcile worker started interval=%ss older_than=%sm " + "(runtime on/off via app_config '%s')", interval, older_than, + _AUTO_RECONCILE_KEY, ) try: while True: try: _touch_lock() result = await asyncio.to_thread(_reconcile_once, older_than) - if result["checked"] or result["resolved"]: + if result is not None and (result["checked"] or result["resolved"]): logger.info("withdraw auto reconcile result=%s", result) except WxPayNotConfiguredError: logger.warning("withdraw auto reconcile skipped: wxpay not configured") @@ -103,7 +115,8 @@ async def _run_locked_loop(interval: int, older_than: int) -> None: def start_withdraw_reconcile_worker() -> asyncio.Task | None: if not settings.WITHDRAW_AUTO_RECONCILE_ENABLED: - logger.info("withdraw auto reconcile disabled") + # 部署级总闸关:worker 进程不启动(运营后台开关此时无效,需先在 env 打开)。 + logger.info("withdraw auto reconcile worker not started (env master switch off)") return None if not settings.wxpay_configured: logger.warning("withdraw auto reconcile enabled but wxpay not configured") diff --git a/app/integrations/pangle.py b/app/integrations/pangle.py index aacae85..1badabb 100644 --- a/app/integrations/pangle.py +++ b/app/integrations/pangle.py @@ -42,3 +42,13 @@ def verify_callback_sign(params: dict[str, str], secret: str) -> bool: got = params.get("sign") or "" expect = build_sign(trans_id, secret) return hmac.compare_digest(got, expect) + + +def verify_callback_sign_any(params: dict[str, str], secrets: list[str]) -> bool: + """对**多个** m-key 逐个验签,任一通过即接受。 + + 多广告位场景:每个 GroMore 广告位的 m-key 由后台各自生成、互不相同,但本服务用 + 同一个回调 URL 接所有位的回调。配置里放多个密钥(逗号分隔),回调到达时挨个试。 + 仍然安全:伪造者必须知道其中某个 m-key 才能算出合法 sign;空列表 → 一律失败。 + """ + return any(verify_callback_sign(params, s) for s in secrets if s) diff --git a/app/integrations/wx_oauth.py b/app/integrations/wx_oauth.py new file mode 100644 index 0000000..0af50d6 --- /dev/null +++ b/app/integrations/wx_oauth.py @@ -0,0 +1,75 @@ +"""微信服务号网页授权:构造授权链接 + code 换 openid + 拉 userinfo。 + +用于 CPS 落地页(coupon.shaguabijia.com)在微信内拿用户身份。流程(见微信文档): + 授权链接(snsapi_base|snsapi_userinfo) → 回调带 code → sns/oauth2/access_token 换 openid + → (userinfo 时)sns/userinfo 拿昵称头像。 + +注意: + - 这里的 access_token 是【网页授权 token】,与基础 access_token(cgi-bin/token)不同, + 走 sns/* 接口,不需要 IP 白名单。 + - secret 只在服务器,不下发客户端。 + - 用 WX_MP_APPID/SECRET(已认证服务号),区别于 WECHAT_APP_ID(App 移动应用)。 +""" +from __future__ import annotations + +from urllib.parse import quote + +import httpx + +from app.core.config import settings + +_API = "https://api.weixin.qq.com" +_AUTHORIZE = "https://open.weixin.qq.com/connect/oauth2/authorize" +_TIMEOUT = 10 + + +class WxOauthError(Exception): + """网页授权调用失败(凭证/网络/code 失效等)。调用方兜底为无 openid。""" + + +def build_authorize_url(redirect_uri: str, scope: str, state: str) -> str: + """构造网页授权跳转链接。scope: 'snsapi_base'(静默,仅 openid) | 'snsapi_userinfo'(昵称头像)。 + + 微信要求 redirect_uri urlEncode、参数顺序固定、结尾 #wechat_redirect。 + """ + return ( + f"{_AUTHORIZE}?appid={settings.WX_MP_APPID}" + f"&redirect_uri={quote(redirect_uri, safe='')}" + f"&response_type=code&scope={scope}&state={quote(state, safe='')}" + f"#wechat_redirect" + ) + + +def exchange_code(code: str) -> dict: + """code 换网页授权 access_token + openid。返回含 openid/access_token/scope(/unionid)。 + + code 5 分钟内一次性有效。失败(errcode)抛 WxOauthError。 + """ + resp = httpx.get( + f"{_API}/sns/oauth2/access_token", + params={ + "appid": settings.WX_MP_APPID, + "secret": settings.WX_MP_SECRET, + "code": code, + "grant_type": "authorization_code", + }, + timeout=_TIMEOUT, + ) + data = resp.json() + if "openid" not in data: + raise WxOauthError(f"exchange_code failed: {data}") + return data + + +def get_userinfo(access_token: str, openid: str) -> dict: + """拉用户信息(nickname/headimgurl/unionid)。需 scope=snsapi_userinfo 的 access_token。""" + resp = httpx.get( + f"{_API}/sns/userinfo", + params={"access_token": access_token, "openid": openid, "lang": "zh_CN"}, + timeout=_TIMEOUT, + ) + resp.encoding = "utf-8" # 昵称含中文/emoji,强制 utf-8 避免乱码 + data = resp.json() + if "openid" not in data: + raise WxOauthError(f"get_userinfo failed: {data}") + return data diff --git a/app/main.py b/app/main.py index e1c7032..c7ed0e0 100644 --- a/app/main.py +++ b/app/main.py @@ -22,6 +22,7 @@ from app.api.v1.compare_record import router as compare_record_router from app.api.v1.coupon import router as coupon_router from app.api.v1.device import router as device_router from app.api.v1.cps_redirect import router as cps_redirect_router +from app.api.internal.app_version import router as internal_app_version_router from app.api.internal.price import router as internal_price_router from app.api.internal.store import router as internal_store_router from app.api.v1.feedback import router as feedback_router @@ -115,6 +116,7 @@ app.include_router(report_router) # 内部(server→server)端点:pricebot 上报价格观测 / 店铺映射,靠共享密钥头校验,不对客户端开放。 app.include_router(internal_price_router) app.include_router(internal_store_router) +app.include_router(internal_app_version_router) app.include_router(platform_router) # CPS 群发短链跳转 /c/{code}(公网无鉴权:记点击 → 302 跳美团) app.include_router(cps_redirect_router) diff --git a/app/models/__init__.py b/app/models/__init__.py index 2ea2aac..f61d71d 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -10,6 +10,7 @@ from app.models.cps_activity import CpsActivity # noqa: F401 from app.models.cps_group import CpsGroup # noqa: F401 from app.models.cps_link import CpsClick, CpsLink # noqa: F401 from app.models.cps_order import CpsOrder # noqa: F401 +from app.models.cps_wx_user import CpsWxUser # noqa: F401 from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401 from app.models.device import Device # noqa: F401 from app.models.coupon_state import ( # noqa: F401 diff --git a/app/models/ad_ecpm.py b/app/models/ad_ecpm.py index c1aa029..fc10a2f 100644 --- a/app/models/ad_ecpm.py +++ b/app/models/ad_ecpm.py @@ -4,8 +4,8 @@ 由客户端生成,并通过激励视频 extra 透传给 S2S 回调,用于把"展示 eCPM"与 "奖励完成"绑定。没有 session id 的旧上报仍可作为按天对账补充。 -⚠️ `ecpm_raw` 原样存客户端上报的字符串——eCPM 单位(分 / 元)截至 2026-05-31 尚未最终确认, -确认后再加一列解析好的数值;在此之前对账按"待定单位"处理。 +`ecpm_raw` 原样存客户端上报的字符串——单位是穿山甲 getEcpm 官方口径【分/千次展示】 +(非元!),对账由 `core/rewards.parse_ecpm_fen` 解析、`parse_ecpm_yuan` ÷100 转元;本列只存原值。 """ from __future__ import annotations diff --git a/app/models/comparison.py b/app/models/comparison.py index f521bc8..6924510 100644 --- a/app/models/comparison.py +++ b/app/models/comparison.py @@ -18,6 +18,7 @@ from sqlalchemy import ( JSON, Boolean, DateTime, + Float, ForeignKey, Index, Integer, @@ -101,6 +102,32 @@ class ComparisonRecord(Base): # 客户端上报的原始 payload(calibration + done.params 全量),未来取数兜底 raw_payload: Mapped[dict | None] = mapped_column(_JSON, nullable=True) + # ===== debug 维度(客户端上报;旧记录 / 未发版客户端为 None)===== + # 设备环境:无障碍比价高度依赖机型/ROM,这是排"某机型跑不通"类问题的头号线索 + device_model: Mapped[str | None] = mapped_column(String(64), nullable=True) + device_manufacturer: Mapped[str | None] = mapped_column(String(64), nullable=True) + rom_vendor: Mapped[str | None] = mapped_column(String(32), nullable=True) # vivo/OPPO/Xiaomi/HUAWEI/HONOR/samsung + rom_name: Mapped[str | None] = mapped_column(String(32), nullable=True) # OriginOS/ColorOS/HyperOS/MIUI/EMUI/HarmonyOS... + rom_version: Mapped[int | None] = mapped_column(Integer, nullable=True) + android_version: Mapped[str | None] = mapped_column(String(16), nullable=True) # Build.VERSION.RELEASE + android_sdk: Mapped[int | None] = mapped_column(Integer, nullable=True) # Build.VERSION.SDK_INT + app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) # 我们 app versionName + app_version_code: Mapped[int | None] = mapped_column(Integer, nullable=True) + # 被操控的源平台 App 版本(美团/淘宝/京东),排"平台 App 改版导致 UI 适配失效"用 + source_app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) + # 比价时设备定位(影响淘宝 deeplink / 距离匹配) + longitude: Mapped[float | None] = mapped_column(Float, nullable=True) + latitude: Mapped[float | None] = mapped_column(Float, nullable=True) + + # ===== 性能 / 过程统计(debug 用)===== + total_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) # 客户端整场比价墙钟耗时 + step_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 客户端 agent loop 总步数 + llm_call_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 本次 LLM 调用次数(server 从 llm_calls 算) + retry_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # LLM 失败重试次数(server 从 llm_calls error 算) + # 每次 LLM 调用明细 [{scene,model,input_messages,output,usage,latency_ms,error}]; + # server 收上报后按 trace_id 同机拉 pricebot 落库(见 compare_record 端点)。旧记录/未采集为 None。 + llm_calls: Mapped[list | None] = mapped_column(_JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True, nullable=False ) diff --git a/app/models/cps_activity.py b/app/models/cps_activity.py index d52823a..e650cca 100644 --- a/app/models/cps_activity.py +++ b/app/models/cps_activity.py @@ -8,7 +8,7 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import DateTime, Integer, String, func +from sqlalchemy import DateTime, Integer, String, Text, func from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -24,6 +24,10 @@ class CpsActivity(Base): act_id: Mapped[str | None] = mapped_column(String(64), nullable=True) # 美团商品券 productViewSign(与 act_id 二选一转链;多数活动用 act_id)。 product_view_sign: Mapped[str | None] = mapped_column(String(128), nullable=True) + # 淘宝:整段淘口令文本(落地页原样复制);京东:推广链接(落地页 302 跳)。美团不用这个(用 act_id)。 + payload: Mapped[str | None] = mapped_column(Text, nullable=True) + # 淘宝落地页主视觉图(绝对 URL,基于 CPS_REDIRECT_BASE)。运营建活动时上传/选已有,落地页按活动展示。 + image_url: Mapped[str | None] = mapped_column(String(512), nullable=True) status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") # active | archived remark: Mapped[str | None] = mapped_column(String(256), nullable=True) created_at: Mapped[datetime] = mapped_column( diff --git a/app/models/cps_group.py b/app/models/cps_group.py index fcd40fd..8a9e516 100644 --- a/app/models/cps_group.py +++ b/app/models/cps_group.py @@ -7,7 +7,8 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import DateTime, Integer, String, func +from sqlalchemy import JSON, DateTime, Integer, String, func +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -17,9 +18,13 @@ class CpsGroup(Base): __tablename__ = "cps_group" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - # 每群唯一的渠道标识,建群时分配(留空则自动生成 g)。字母+数字,≤64。 - sid: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + # 渠道标识。仅美团需要(sid 追踪);纯淘宝/京东群无 sid(那俩不支持)。含美团时建群分配。 + sid: Mapped[str | None] = mapped_column(String(64), unique=True, index=True, nullable=True) name: Mapped[str] = mapped_column(String(128), nullable=False) + # 该群发哪些平台的券(多选): ["meituan","taobao","jd"]。含 meituan 才需要 sid。 + platforms: Mapped[list] = mapped_column( + JSON().with_variant(JSONB(), "postgresql"), nullable=False, default=list + ) # 群人数,运营填,作转化率分母(可空)。 member_count: Mapped[int | None] = mapped_column(Integer, nullable=True) status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") # active | archived diff --git a/app/models/cps_link.py b/app/models/cps_link.py index ef53859..bb88e6b 100644 --- a/app/models/cps_link.py +++ b/app/models/cps_link.py @@ -23,10 +23,11 @@ class CpsLink(Base): code: Mapped[str] = mapped_column(String(16), unique=True, index=True, nullable=False) group_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) activity_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) - sid: Mapped[str] = mapped_column(String(64), index=True, nullable=False) + # 仅美团 link 有 sid(渠道追踪);淘宝/京东无 sid。 + sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) platform: Mapped[str] = mapped_column(String(20), nullable=False, default="meituan") - # 302 跳转目标(美团短链,微信内可打开并唤起 App)。 - target_url: Mapped[str] = mapped_column(String(1024), nullable=False) + # 跳转/落地目标: 美团短链 / 京东链接(302 跳) / 淘宝整段淘口令文本(H5 落地页复制)。 + target_url: Mapped[str] = mapped_column(String(2048), nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) @@ -41,9 +42,15 @@ class CpsClick(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) link_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) group_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) # 冗余,按群聚合快 - sid: Mapped[str] = mapped_column(String(64), index=True, nullable=False) # 冗余 + sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) # 冗余(淘宝/京东无) + # 事件类型: visit(进落地页/被跳转) | copy(淘宝落地页点了"复制口令") + event_type: Mapped[str] = mapped_column( + String(16), nullable=False, default="visit", server_default="visit" + ) ip: Mapped[str | None] = mapped_column(String(64), nullable=True) ua: Mapped[str | None] = mapped_column(String(512), nullable=True) + # 微信网页授权拿到的用户标识(非微信/未授权为空),做用户级群统计 + openid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) clicked_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True, nullable=False ) diff --git a/app/models/cps_wx_user.py b/app/models/cps_wx_user.py new file mode 100644 index 0000000..669b26c --- /dev/null +++ b/app/models/cps_wx_user.py @@ -0,0 +1,42 @@ +"""CPS 落地页微信用户(cps_wx_user)。 + +用户在微信内打开群发短链落地页 /c/{code},经服务号网页授权: + - base 静默:拿 openid(唯一标识,统计主力) + - userinfo(点领券触发):补 nickname/headimgurl/unionid + +按 openid 唯一,记录首次来源群(first_group_id),用于群内用户级统计(谁领了券)。 +下单归因到人需 user-level sid(另一期),本表只承载身份 + 领券/点击侧。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class CpsWxUser(Base): + __tablename__ = "cps_wx_user" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # 服务号下用户唯一标识(网页授权拿到) + openid: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + # 开放平台 unionid(服务号绑开放平台 + scope=userinfo 才有);跨 App/服务号统一用户 + unionid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + # 昵称/头像:userinfo 授权后才有(base 阶段为空) + nickname: Mapped[str | None] = mapped_column(String(128), nullable=True) + headimgurl: Mapped[str | None] = mapped_column(String(512), nullable=True) + # 首次进入来源(从哪个群的链接授权进来) + first_code: Mapped[str | None] = mapped_column(String(16), nullable=True) + first_group_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True) + first_seen: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + last_seen: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/models/signin.py b/app/models/signin.py index 63a9043..0a94ea0 100644 --- a/app/models/signin.py +++ b/app/models/signin.py @@ -1,7 +1,8 @@ """签到记录表。 每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次。 - - cycle_day: 1..14,14 天循环里今天落在第几档,决定发多少金币;断签后重置回 1。 + - cycle_day: 1..7,7 天循环里今天落在第几档,决定发多少金币;断签后重置回 1 + (周期长度 = rewards.SIGNIN_CYCLE_LEN,2026-06 由 14 天改 7 天一轮)。 - streak: 连续签到天数(不封顶),用于"已连续签到 N 天"展示;断签后重置回 1。 """ from __future__ import annotations diff --git a/app/repositories/app_config.py b/app/repositories/app_config.py index 2cd8c2c..40bfc80 100644 --- a/app/repositories/app_config.py +++ b/app/repositories/app_config.py @@ -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 diff --git a/app/repositories/comparison.py b/app/repositories/comparison.py index 640ffa0..09fa555 100644 --- a/app/repositories/comparison.py +++ b/app/repositories/comparison.py @@ -90,6 +90,21 @@ def upsert_record( items=[it.model_dump(exclude_none=True) for it in payload.items], comparison_results=[r.model_dump() for r in payload.comparison_results], skipped_dish_names=list(payload.skipped_dish_names), + # 客户端环境 / 性能(debug,客户端上报;旧客户端为 None) + device_model=payload.device_model, + device_manufacturer=payload.device_manufacturer, + rom_vendor=payload.rom_vendor, + rom_name=payload.rom_name, + rom_version=payload.rom_version, + android_version=payload.android_version, + android_sdk=payload.android_sdk, + app_version=payload.app_version, + app_version_code=payload.app_version_code, + source_app_version=payload.source_app_version, + longitude=payload.longitude, + latitude=payload.latitude, + total_ms=payload.total_ms, + step_count=payload.step_count, raw_payload=payload.model_dump(), **derived, ) diff --git a/app/repositories/cps_link.py b/app/repositories/cps_link.py index 9991ff8..00b9c1f 100644 --- a/app/repositories/cps_link.py +++ b/app/repositories/cps_link.py @@ -26,7 +26,7 @@ def get_by_code(db: Session, code: str) -> CpsLink | None: def create_link( - db: Session, *, group_id: int, activity_id: int, sid: str, target_url: str, + db: Session, *, group_id: int, activity_id: int, sid: str | None, target_url: str, platform: str = "meituan", commit: bool = True, ) -> CpsLink: """生成唯一短码并存库。冲突重试 5 次,仍冲突用 12 位兜底(概率近 0)。""" @@ -50,10 +50,15 @@ def create_link( return link -def record_click(db: Session, *, link: CpsLink, ip: str | None = None, ua: str | None = None) -> None: +def record_click( + db: Session, *, link: CpsLink, ip: str | None = None, ua: str | None = None, + event_type: str = "visit", openid: str | None = None, +) -> None: + """记一条点击。event_type: visit(进落地页/被跳转) | copy(淘宝点了"复制口令")。 + openid: 微信网页授权拿到的用户标识(非微信/未授权为空),做用户级群统计。""" db.add(CpsClick( - link_id=link.id, group_id=link.group_id, sid=link.sid, - ip=ip, ua=(ua[:500] if ua else None), + link_id=link.id, group_id=link.group_id, sid=link.sid, event_type=event_type, + ip=ip, ua=(ua[:500] if ua else None), openid=openid, )) db.commit() @@ -61,7 +66,8 @@ def record_click(db: Session, *, link: CpsLink, ip: str | None = None, ua: str | def click_stats_by_group( db: Session, *, date_from: datetime | None = None, date_to: datetime | None = None, ) -> dict[int, dict]: - """按 group_id 聚合点击 → {group_id: {"pv": N, "uv": M}}。uv 按 (ip, ua) 近似去重。""" + """按 group_id 聚合点击 → {group_id: {pv, uv, copy_pv, copy_uv}}。 + visit 计入 pv/uv,copy(淘宝复制口令) 计入 copy_pv/copy_uv;uv 按 (ip, ua) 近似去重。""" stmt = select(CpsClick) if date_from is not None: stmt = stmt.where(CpsClick.clicked_at >= date_from) @@ -70,13 +76,21 @@ def click_stats_by_group( clicks = list(db.execute(stmt).scalars().all()) agg: dict[int, dict] = {} - seen: dict[int, set] = {} + seen_visit: dict[int, set] = {} + seen_copy: dict[int, set] = {} for c in clicks: - a = agg.setdefault(c.group_id, {"pv": 0, "uv": 0}) - a["pv"] += 1 - s = seen.setdefault(c.group_id, set()) + a = agg.setdefault(c.group_id, {"pv": 0, "uv": 0, "copy_pv": 0, "copy_uv": 0}) key = (c.ip, c.ua) - if key not in s: - s.add(key) - a["uv"] += 1 + if c.event_type == "copy": + a["copy_pv"] += 1 + s = seen_copy.setdefault(c.group_id, set()) + if key not in s: + s.add(key) + a["copy_uv"] += 1 + else: + a["pv"] += 1 + s = seen_visit.setdefault(c.group_id, set()) + if key not in s: + s.add(key) + a["uv"] += 1 return agg diff --git a/app/repositories/cps_wx_user.py b/app/repositories/cps_wx_user.py new file mode 100644 index 0000000..9fde31e --- /dev/null +++ b/app/repositories/cps_wx_user.py @@ -0,0 +1,43 @@ +"""cps_wx_user 数据访问:按 openid upsert。""" +from __future__ import annotations + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models.cps_wx_user import CpsWxUser + + +def get_by_openid(db: Session, openid: str) -> CpsWxUser | None: + return db.execute( + select(CpsWxUser).where(CpsWxUser.openid == openid) + ).scalar_one_or_none() + + +def upsert( + db: Session, *, openid: str, code: str | None = None, group_id: int | None = None, + nickname: str | None = None, headimgurl: str | None = None, unionid: str | None = None, +) -> CpsWxUser: + """按 openid upsert。 + + - 首次:建行,记来源 code/group。 + - 已存在:仅在传入非 None 时更新昵称/头像/unionid(base 阶段为 None,不覆盖已有画像); + 并刷新 last_seen(显式 set,因 onupdate 仅在字段有变更时触发)。 + """ + u = get_by_openid(db, openid) + if u is None: + u = CpsWxUser( + openid=openid, first_code=code, first_group_id=group_id, + nickname=nickname, headimgurl=headimgurl, unionid=unionid, + ) + db.add(u) + else: + if nickname is not None: + u.nickname = nickname + if headimgurl is not None: + u.headimgurl = headimgurl + if unionid is not None: + u.unionid = unionid + u.last_seen = func.now() + db.commit() + db.refresh(u) + return u diff --git a/app/repositories/user.py b/app/repositories/user.py index 33c53ae..7753e33 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -111,14 +111,27 @@ def set_avatar_url(db: Session, user: User, *, avatar_url: str) -> User: def soft_delete_account(db: Session, user: User) -> None: - """注销账号:软删除 + 匿名化。 + """注销账号:软删除 + 匿名化 + 释放唯一约束/外部绑定。 - 把 phone 改成 `deleted_` 释放唯一约束,允许同号码重新注册成全新账号; + 把 phone 改成 `deleted_` 释放手机号唯一约束,允许同号码重新注册成全新账号; 清空 PII(昵称/头像),保留行用于审计。status=deleted 后将无法再登录该行 (登录接口校验 status==active)。 + + ⚠️ 注销必须连同释放 user 上其余唯一约束/外部身份,否则 deleted 行永久占着槽位, + 对应资源再也无法被复用: + - wechat_openid: 不清 → 这个微信再也无法被任何账号绑定(提现通道彻底锁死); + - invite_code: 不清 → 邀请码槽位被占 + 裸查仍命中死号(发奖已被 bind 的 + status==active 校验兜住,清掉更干净)。 + 资金安全(未提现现金 / 在审提现单)由调用方 delete_account 端点前置校验,这里只匿名化。 """ user.status = "deleted" user.phone = f"deleted_{user.id}" user.nickname = None user.avatar_url = None + # 等价"解绑微信":释放 openid 唯一槽 + 清微信昵称头像 + user.wechat_openid = None + user.wechat_nickname = None + user.wechat_avatar_url = None + # 释放邀请码唯一槽 + user.invite_code = None db.commit() diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index 615f0eb..a38ac88 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -340,6 +340,15 @@ def bind_wechat_openid(db: Session, user_id: int, code: str) -> dict: return info +def get_cash_balance_cents(db: Session, user_id: int) -> int: + """只读查现金余额(分)。账户不存在返回 0,**不创建账户**——注销前置校验用, + 不该给一个即将被删除的用户凭空建一行空账户(get_or_create_account 会建)。""" + val = db.execute( + select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id) + ).scalar_one_or_none() + return val or 0 + + def has_reviewing_withdraw(db: Session, user_id: int) -> bool: """用户是否有「待审核(reviewing)」的提现单。 diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py index ce8a4a0..5f7306c 100644 --- a/app/schemas/compare_record.py +++ b/app/schemas/compare_record.py @@ -69,6 +69,10 @@ class ComparisonRecordIn(BaseModel): # 明细 items: list[ComparisonItemIn] = Field(default_factory=list) comparison_results: list[ComparisonResultIn] = Field(default_factory=list) + # 逐平台结局摘要(含失败平台的细分原因 status: store_not_found/items_not_found/below_minimum/ + # unsupported/...)。来自 done.params.platform_results,客户端透传;落 raw_payload(不单列), + # admin「卡在哪一步」从这里读。list[dict] 宽松存(结构由 pricebot 定,只作 debug 展示)。 + platform_results: list = Field(default_factory=list) skipped_dish_count: int | None = None skipped_dish_names: list[str] = Field(default_factory=list) total_dish_count: int | None = None @@ -82,6 +86,23 @@ class ComparisonRecordIn(BaseModel): # 时分秒前端拼不出,必须由后端透传)。 trace_url: str | None = Field(None, description="本次比价公网调试链接") + # ===== debug 维度(客户端采集上报;旧客户端不带 → None。仅 admin 比价记录页用)===== + # 必须显式声明,否则 model_dump() 落 raw_payload 时被 pydantic 静默丢弃(同上面 coupon_saved 的坑)。 + device_model: str | None = None + device_manufacturer: str | None = None + rom_vendor: str | None = Field(None, description="vivo/OPPO/Xiaomi/HUAWEI/HONOR/samsung") + rom_name: str | None = Field(None, description="OriginOS/ColorOS/HyperOS/MIUI/HarmonyOS...") + rom_version: int | None = None + android_version: str | None = None + android_sdk: int | None = None + app_version: str | None = None + app_version_code: int | None = None + source_app_version: str | None = Field(None, description="被操控的源平台 App 版本") + longitude: float | None = None + latitude: float | None = None + total_ms: int | None = Field(None, description="整场比价墙钟耗时(ms)") + step_count: int | None = Field(None, description="agent loop 总步数") + # ===== 读取出参 ===== diff --git a/app/schemas/platform.py b/app/schemas/platform.py index ec8619c..4c97bd6 100644 --- a/app/schemas/platform.py +++ b/app/schemas/platform.py @@ -1,7 +1,7 @@ """首页平台级展示数据 schemas(客户端首页门面数字)。""" from __future__ import annotations -from pydantic import BaseModel +from pydantic import BaseModel, Field class PlatformStatsOut(BaseModel): @@ -22,3 +22,52 @@ class SavingsFeedItem(BaseModel): class SavingsFeedOut(BaseModel): items: list[SavingsFeedItem] + + +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 = "" diff --git a/app/services/pricebot_llm_calls.py b/app/services/pricebot_llm_calls.py new file mode 100644 index 0000000..1c44bac --- /dev/null +++ b/app/services/pricebot_llm_calls.py @@ -0,0 +1,38 @@ +"""按 trace_id 同机拉 pricebot 的本次比价 LLM 调用明细(供 admin 比价记录 debug)。 + +pricebot 在 chat() 收口处把每次 LLM input/output 落盘,暴露内部接口 +GET /api/internal/llm_calls/{trace_id}(X-Internal-Secret 校验,与本服务 INTERNAL_API_SECRET 同值)。 +app-server 收到比价记录上报后同机拉一次,落 comparison_record.llm_calls。 + +best-effort:拉不到(pricebot 未部署该版本 / 旧 trace 无明细 / 网络)一律返 [],绝不阻断 +比价记录上报。多实例下用 pick_pricebot(trace_id) 选对实例(同 trace 落同进程,明细在 +该进程磁盘),与比价透传同一致性 hash 口径。 +""" +from __future__ import annotations + +import logging + +import httpx + +from app.core.config import settings +from app.core.pricebot_router import pick_pricebot + +logger = logging.getLogger("shagua.pricebot_llm") + + +def fetch_llm_calls(trace_id: str) -> list[dict]: + """返回该次比价的 LLM 调用明细列表(每条 {scene,model,input_messages,output,usage,latency_ms,error}); + 未配密钥 / 无 trace_id / 拉取失败 → []。""" + secret = settings.INTERNAL_API_SECRET + if not secret or not trace_id: + return [] + base = pick_pricebot(trace_id).rstrip("/") + url = f"{base}/api/internal/llm_calls/{trace_id}" + try: + resp = httpx.get(url, headers={"X-Internal-Secret": secret}, timeout=5.0) + if resp.status_code == 200: + return resp.json().get("calls", []) or [] + logger.warning("fetch_llm_calls trace=%s status=%s", trace_id, resp.status_code) + except Exception as e: # noqa: BLE001 — best-effort,任何异常都不该影响上报 + logger.warning("fetch_llm_calls trace=%s failed: %s", trace_id, e) + return [] diff --git a/data/media/taobao_landing.jpg b/data/media/taobao_landing.jpg new file mode 100644 index 0000000..740de7b Binary files /dev/null and b/data/media/taobao_landing.jpg differ diff --git a/docs/README.md b/docs/README.md index 7e9f966..f14588f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,16 +1,22 @@ # 后端文档库(docs/) -`shaguabijia-app-server` 的文档都在这里。各文档作用: +`shaguabijia-app-server` 的文档都在这里。结构:**根目录放总览,其余按领域/用途分目录**。 -| 文档 / 目录 | 作用 | +## 根目录 + +| 文档 | 作用 | |---|---| -| [后端技术实现.md](./后端技术实现.md) | **后端技术方案**:业务概览、分层架构与目录、登录链路、美团 CPS、领券透传、数据模型、配置与部署、已知问题。想了解"整个后端怎么回事"看这份。 | -| [api/](./api/) | **API 接口文档**(目录),采用"索引 + 一接口一文件",结构见下。想查"某个接口的协议"看这里。 | -| [integrations/](./integrations/) | **集成层实现文档**(目录):穿山甲验签 / 微信支付 / 极光 / 短信 / 美团 CPS 等 SDK 集成的签名、加解密、协议细节与踩坑。想知道"接外部服务那块到底怎么实现"看这里。 | -| [数据库迁移.md](./数据库迁移.md) | **Alembic 迁移指南**:clone 后如何建表、日常升级、新增迁移、迁移文件命名约定。想"把数据库跑起来 / 改表结构"看这份。 | -| [待办与技术债.md](./待办与技术债.md) | **待办与技术债账本**:记"现在先简化、以后要补"的事 + 跨前后端技术债(P1 鉴权/用户绑定、引擎移植待办等),免遗忘。想知道"还欠什么、以后要补什么"看这份。 | -| [看广告赚金币上线清单.md](./看广告赚金币上线清单.md) | **看广告发奖上线 checklist**:跨前后端,记上线前必做(GroMore 回调配置、清理调试脚手架、端到端验收)。上线"看广告赚金币"前对照这份。 | -| [邀请功能-实现原理与本地测试.md](./邀请功能-实现原理与本地测试.md) | **好友邀请(invite-mvp)实现原理 + 跨前后端具体实现 + 本地内网测试方法**:剪贴板 deferred-deeplink 归因、双方各发 1 万金币、落地页 dl.html、起本地后端 + 编 debug 包 + 两台真机走全链路 + 上线前还差什么。要本地测 / 接手邀请功能看这份。 | +| [后端技术实现.md](./后端技术实现.md) | **后端技术方案总览**:业务概览、分层架构与目录、登录链路、美团 CPS、领券/比价透传、数据模型、配置与部署、已知问题。想了解"整个后端怎么回事"先看这份。 | +| README.md | 本文件:文档库索引/传送门。 | + +## 子目录 + +| 目录 | 作用 | +|---|---| +| [api/](./api/) | **API 接口文档**:"索引 + 一接口一文件"(组织方式见下)。想查"某个接口的协议"看这里。 | +| [database/](./database/) | **数据库文档**:每张表一个文件(表结构/字段/索引/约定) + 两份迁移指南——[数据库迁移.md](./database/数据库迁移.md)(Alembic 操作:clone 后建表/日常升级/新增迁移/多 head 排查) + [postgres-migration.md](./database/postgres-migration.md)(SQLite → PostgreSQL 切换步骤,配套 `scripts/init_postgres.py`)。想"把库跑起来 / 改表结构 / 查某张表"看这里。 | +| [integrations/](./integrations/) | **集成层实现文档**:穿山甲验签 / 微信支付 / 极光 / 短信 / 美团 CPS 等 SDK 集成的签名、加解密、协议细节与踩坑。想知道"接外部服务那块到底怎么实现"看这里。 | +| [guides/](./guides/) | **开发 / 上线 / 功能 指南**:[待办与技术债.md](./guides/待办与技术债.md)(跨前后端 backlog,P1 鉴权/用户绑定、引擎移植待办等,"还欠什么、以后要补什么"看这份) + [看广告赚金币上线清单.md](./guides/看广告赚金币上线清单.md)(看广告发奖上线 checklist) + [邀请功能-实现原理与本地测试.md](./guides/邀请功能-实现原理与本地测试.md)(invite-mvp 实现原理 + 本地内网全链路测试)。 | ## api/ 目录是怎么组织的(传送门式) diff --git a/docs/api/README.md b/docs/api/README.md index 25e8ec6..642afa1 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -3,7 +3,7 @@ > Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770` > 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case** > 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer ` -> 最后更新:2026-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`)。 diff --git a/docs/api/ad-pangle-callback.md b/docs/api/ad-pangle-callback.md index d32e90f..3d8e13c 100644 --- a/docs/api/ad-pangle-callback.md +++ b/docs/api/ad-pangle-callback.md @@ -22,7 +22,7 @@ GroMore 以 GET 回调,关键参数: | `ecpm` | string | 本次广告 eCPM(同上,可用于收益分析) | | `sign` | string | 签名,见下 | -**验签**:`sign = SHA256("{m-key}:{trans_id}")` 十六进制(只签 `trans_id`,其余参数不参与)。算法细节、m-key 来源、为什么这样设计 → 见集成文档 [integrations/pangle](../integrations/pangle.md)。 +**验签**:`sign = SHA256("{m-key}:{trans_id}")` 十六进制(只签 `trans_id`,其余参数不参与)。多激励位共用同一回调 URL → 服务端把各位的 m-key 都配上,`verify_callback_sign_any` 逐个试、任一过即接受。算法细节、m-key 配置项(`PANGLE_REWARD_SECRET_TEST/_TEST_DEDICATED/_PROD`)、为什么这样设计 → 见集成文档 [integrations/pangle](../integrations/pangle.md)。 ## 出参 响应 `200`,**响应体必须是 `{"is_verify": bool, "reason": int}`**(GroMore 规范)。 diff --git a/docs/api/compare-intent-recognize.md b/docs/api/compare-intent-recognize.md index 1b58626..51cb30e 100644 --- a/docs/api/compare-intent-recognize.md +++ b/docs/api/compare-intent-recognize.md @@ -18,7 +18,7 @@ pricebot-backend 的响应**原样返回**(JSON object)。典型含 `result` 外卖比价由客户端无障碍引擎在源平台(淘宝闪购 / 美团 / 京东外卖)购物车页点悬浮球触发 → 调本接口拿 `query` + `calibration` → 进入 `/price/step` 循环。 -⚠️ **MVP 阶段不鉴权**(同 `coupon/step`):`device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 行为暂绑不到登录用户。待补 JWT,见 [待办与技术债.md](../待办与技术债.md) P1。 +⚠️ **MVP 阶段不鉴权**(同 `coupon/step`):`device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 行为暂绑不到登录用户。待补 JWT,见 [待办与技术债.md](../guides/待办与技术债.md) P1。 **相关配置**: - `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`) diff --git a/docs/api/compare-record-report.md b/docs/api/compare-record-report.md index 162cf2b..21085a4 100644 --- a/docs/api/compare-record-report.md +++ b/docs/api/compare-record-report.md @@ -5,7 +5,7 @@ 比价 `done` 帧后,客户端用**带 JWT 的通道**上报一条比价结果,落 `comparison_record` 表,作为「我的比价记录」数据源 + 用户级行为画像。 > ⚠️ 与不鉴权的透传端点 [`/api/v1/price/step`](./compare-price-step.md) 不同:那是转发壳,本接口按用户维度落库,**必须鉴权**。 -> 本轮只做 server 端;客户端在 done 帧后调本接口的改动另起一轮(见 [待办与技术债.md](../待办与技术债.md) P1)。 +> 本轮只做 server 端;客户端在 done 帧后调本接口的改动另起一轮(见 [待办与技术债.md](../guides/待办与技术债.md) P1)。 ## 入参(JSON body) diff --git a/docs/api/coupon-step.md b/docs/api/coupon-step.md index 333aae5..809183d 100644 --- a/docs/api/coupon-step.md +++ b/docs/api/coupon-step.md @@ -17,7 +17,7 @@ pricebot-backend 的响应**原样返回**(JSON object)。 是产品"一键领券"的接入点,**前端已接通**:首页「去领取」→ `CouponPromptDialog` 确认 → 权限检查 → 无障碍引擎 `PriceBotService.startCouponClaim()` → 循环调本接口,后端逐张券下发 launch/wait/done。 -⚠️ **客户端契约 vs Server 实现**:Android 客户端通过 `AuthInterceptor` 已自动带 `Authorization: Bearer ` 头(契约层 OK);Server MVP 阶段**暂未启用强校验**——[coupon.py](../../app/api/v1/coupon.py) 没接 `Depends(get_current_user)`,只读 `device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 领券行为暂时绑不到登录用户(采集不到用户级画像)。待补强校验 + `device_id↔user_id` 绑定,详见 [待办与技术债.md](../待办与技术债.md) P1。 +⚠️ **客户端契约 vs Server 实现**:Android 客户端通过 `AuthInterceptor` 已自动带 `Authorization: Bearer ` 头(契约层 OK);Server MVP 阶段**暂未启用强校验**——[coupon.py](../../app/api/v1/coupon.py) 没接 `Depends(get_current_user)`,只读 `device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 领券行为暂时绑不到登录用户(采集不到用户级画像)。待补强校验 + `device_id↔user_id` 绑定,详见 [待办与技术债.md](../guides/待办与技术债.md) P1。 **相关配置**: - `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`) diff --git a/docs/database/OVERVIEW.md b/docs/database/OVERVIEW.md index 04ae153..281d20f 100644 --- a/docs/database/OVERVIEW.md +++ b/docs/database/OVERVIEW.md @@ -2,7 +2,7 @@ > 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。 > 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。 -> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **28 张业务表** + `alembic_version`(框架的迁移版本指针)。领券联动的「今日状态」三张表(`coupon_*`)同理:领券过程在 pricebot 内存态跑、**不落库**,只有结果回到 app-server 才落这三张表。 +> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **37 张业务表** + `alembic_version`(框架的迁移版本指针)。注意「比价/领券**过程**」始终在 pricebot 内存态跑、**不落库**——只有**结果**回 app-server 才落库:领券结果落 `coupon_*` 三张今日状态表;比价结果分两路——客户端带 JWT 上报「我的记录」落 `comparison_record`,pricebot 另经 `app/api/internal/` server→server 把客观价格/门店事实落 `price_observation`/`store_mapping`(不鉴权、匿名也记)。此外好友邀请(`invite_*` 2 张)与美团 CPS 群发联盟(`cps_*` 5 张)是两个独立子系统。 --- @@ -16,6 +16,8 @@ | 比价战绩里程碑(逐档领金币) | [`comparison_milestone_claim`](./comparison_milestone_claim.md) | 累计成功比价 N 次解锁;进度读 `comparison_record` 计数 | | profile「累计省了 / 省钱战绩 / 省钱明细」 | [`savings_record`](./savings_record.md) | 真实下单归因(source=compare)+ 无真实数据时 demo 兜底 | | 「上报更低价」提交 / 列表 | [`price_report`](./price_report.md) | 众包纠偏:用户举证某平台更便宜,人工审核发奖 | +| (无 App UI)比价 done 后客观价格沉淀 | [`price_observation`](./price_observation.md) | **pricebot server→server 内部上报**;平台/门店视角的到手价事实,匿名也记,与 `comparison_record`(用户视角)互补 | +| (无 App UI)跨平台门店身份映射 | [`store_mapping`](./store_mapping.md) | **pricebot 内部上报**;同店在淘宝/美团/京东的 id/名/deeplink,供下次比价 `lookup` 反查省掉现场搜店 | ### 领券(每日领券联动 · 今日状态) | App 位置 / 动作 | 表 | 说明 | @@ -31,7 +33,7 @@ | 资产卡 / 钱包余额 | [`coin_account`](./coin_account.md) | 一用户一行的金币+现金余额快照 | | 金币明细 | [`coin_transaction`](./coin_transaction.md) | 每次金币变动一笔流水 | | 现金明细 | [`cash_transaction`](./cash_transaction.md) | 每次现金变动一笔流水(分) | -| 每日签到 | [`signin_record`](./signin_record.md) + [`signin_boost_record`](./signin_boost_record.md) | 14 天循环发币;签到后看广告可膨胀一次 | +| 每日签到 | [`signin_record`](./signin_record.md) + [`signin_boost_record`](./signin_boost_record.md) | 7 天循环发币;签到后看广告可膨胀一次 | | 一次性任务(开消息提醒等) | [`user_task`](./user_task.md) | 领一次发币 | | 看激励视频赚金币 | [`ad_reward_record`](./ad_reward_record.md) + [`ad_watch_log`](./ad_watch_log.md) + [`ad_ecpm_record`](./ad_ecpm_record.md) | 独立数据流:发奖 / 旧版观看时长 / 收益对账 | | 信息流广告结算 | [`ad_feed_reward_record`](./ad_feed_reward_record.md) | 每展示满 10 秒累计一份奖励,完成后一次性入账 | @@ -46,6 +48,21 @@ | 新手引导是否再展示 | [`onboarding_completion`](./onboarding_completion.md) | 按 设备+账号 去重;登录响应回 `onboarding_completed`,走完引导时标记,跨卸载重装 | | 帮助与反馈 | [`feedback`](./feedback.md) | 含截图,后台人工处理 | +### 好友邀请(注册增长) +| App 位置 / 动作 | 表 | 说明 | +|---|---|---| +| 输入/剪贴板邀请码绑定 | [`invite_relation`](./invite_relation.md) | 注册即生效,邀请人+被邀请人各发 1 万金币;`invitee_user_id` 唯一=幂等防重复发奖 | +| 落地页访问指纹(剪贴板归因兜底) | [`invite_fingerprint`](./invite_fingerprint.md) | 剪贴板没拿到码时,用 (ip+机型+屏幕) 7 天内反查邀请人 | + +### 美团 CPS 群发联盟(私域社群比价,运营后台驱动 · 群发选品→点击→对账漏斗) +| 后台/用户动作 | 表 | 说明 | +|---|---|---| +| 运营建可推广活动 | [`cps_activity`](./cps_activity.md) | 预存券/物料(美团 actId / 淘宝淘口令 / 京东链接) | +| 运营建微信推广群 | [`cps_group`](./cps_group.md) | 一群一行,`sid`=美团二级渠道追踪位 | +| 后台批量生成群发短链 | [`cps_link`](./cps_link.md) | 群×活动 → `/c/{code}`,运营复制到微信群 | +| 用户点短链(落地/复制口令) | [`cps_click`](./cps_click.md) | 记 visit/copy 事件,统计 PV/UV | +| 定时拉美团联盟订单对账 | [`cps_order`](./cps_order.md) | `query_order` 按 sid 归群,串成点击→下单→佣金漏斗 | + ### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权) | 后台模块 | 表 | 说明 | |---|---|---| @@ -84,7 +101,9 @@ | 首次进 profile 省钱页且无真实记录 | `savings_record`(C `source=demo`) | 懒种子,`ensure_seeded` 按 user 幂等 | | 上报更低价 `POST /report` | `price_report`(C) | 读 `comparison_record.best_price_cents` 校验 | | 提交反馈 `POST /feedback` | `feedback`(C) | | -| 领券首帧 `POST /api/v1/coupon/step`(step=0) | `coupon_prompt_engagement`(C/U `claim_started`) | `(device_id, 北京日)` 幂等;best-effort | +| 绑定邀请 `POST /invite/bind` | `invite_relation`(C `effective`) + `coin_account`(U×2) + `coin_transaction`(C `invite_inviter` + `invite_invitee`) | 同事务;`invitee_user_id` 唯一幂等,双方各发 1 万金币 | +| 落地页归因 `POST /invite/landing-track` | `invite_fingerprint`(C) | 剪贴板归因兜底线索,登录后用 (ip+机型+屏幕) 反查 | +| 领券首帧 `POST /api/v1/coupon/step`(step=0) | `coupon_prompt_engagement`(C/U `claim_started`) | `(device_id, package, 北京日)` 幂等;best-effort | | 领券每帧结果 `POST /api/v1/coupon/step` | `coupon_claim_record`(C/U) | `(device_id, coupon_id, 北京日)` 幂等;best-effort | | 领券跑完 `POST /api/v1/coupon/step`(action.command=done) | `coupon_daily_completion`(C/U) | `(device_id, 北京日)` 幂等;best-effort | | 拒绝领券引导窗 `POST /api/v1/coupon/prompt/dismiss` | `coupon_prompt_engagement`(C/U `dismissed`) | 同上;客户端通知(透传链路看不到拒绝) | @@ -103,7 +122,19 @@ | 改运营配置 | `app_config`(C/U)+`admin_audit_log`(C) | 同事务 | | 任意写操作 | `admin_audit_log`(C,**永不 U/D**) | | -> 没有任何表会被业务流程物理 DELETE。注销是软删(改 user 行),其余只 C/U。 +> 没有任何表会被业务流程物理 DELETE。注销是软删(改 user 行),其余只 C/U(领券 `/prompt/reset`、`/completed-today/reset` 是开发用删除,非业务流程)。 + +### CPS 群发联盟 & pricebot 内部上报(非 C 端用户、非 admin 常规管理触发) +| 触发 | 写入 | 操作 | +|---|---|---| +| 运营后台建活动 / 建群 | `cps_activity`(C/U) / `cps_group`(C/U) | `app/admin/routers/cps.py`;群含美团平台才分配 `sid` | +| 后台批量生成短链 `POST /admin/api/cps/referral-links` | `cps_link`(C) | 每 群×活动 一条;美团经 `sid` 转链拿 `target_url`(同群同活动重复生成产生多条) | +| 用户点群发短链 `GET /c/{code}` / `POST /c/{code}/copy` | `cps_click`(C `visit`/`copy`) | 公开端点不鉴权;`group_id`/`sid` 从 link 冗余进来免 join | +| 定时拉美团联盟订单对账 | `cps_order`(C/U upsert) | `query_order` 按 `sid` 归群;`order_id` 幂等(状态会变,重复拉则更新) | +| pricebot 比价 done 内部上报 `POST /internal/price-observation` | `price_observation`(C 批量) | `(trace_id,platform,scope)` 幂等;**不走 JWT、靠 `X-Internal-Secret`**(未配→503) | +| pricebot 比价 done 内部上报 `POST /internal/store-mapping` | `store_mapping`(C/U 填空合并) | `trace_id` 幂等;另有 `lookup` 反查 + `invalidate` deeplink 失效标记(淘宝/京东) | + +> CPS 5 表与本 App `user` **无关**(被推广群的用户在美团下单,不是本 App 注册用户);`price_observation`/`store_mapping` 的 `source_user_id` 是可空旁路(链路不鉴权,匿名也记)。 --- @@ -113,6 +144,8 @@ - **17 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`(同时是 PK)、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback`。 - `admin_audit_log.admin_id` → `admin_user.id`。 - `price_report.comparison_record_id` → `comparison_record.id`(可空:关联记录被删后仍留上报历史)。 +- **邀请两表** → `user.id`:`invite_relation.inviter_user_id`、`invite_relation.invitee_user_id`(唯一)、`invite_fingerprint.inviter_user_id`——注意 FK 列名是 `inviter`/`invitee_user_id`,不是 `user_id`。 +- **CPS 5 表 + 比价沉淀 2 表均无硬 FK**(全靠 `sid`/`trace_id` 语义对齐,见下)。 ### 语义 join key(无 FK 约束,靠业务字段对齐 —— 排障/对账必看) - **`coin_transaction.ref_id` 指向随 `biz_type` 变**: @@ -139,6 +172,8 @@ - **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record` 里 `status='success'` 的 `count`。 - **领券三表无硬 FK,全靠软关联**:`coupon_prompt_engagement` / `coupon_daily_completion` / `coupon_claim_record` 的 `user_id` **软指** `user.id`(可空、有登录态才记、不进唯一键、不阻塞判断);`trace_id` **软指** pricebot work_logs(排查回指);唯一键都以 `device_id` + 北京自然日为主(详见 [`coupon_state.md`](./coupon_state.md))。 - **`onboarding_completion.(user_id, device_id)`**:`user_id` 语义关联 `user.id`(无硬 FK,同 `coupon_*` 设备表),`device_id` = 客户端硬件级 `ANDROID_ID`(≠ 领券 per-install `device_id`)。登录读、走完引导写,决定是否再展示新手引导。 +- **CPS 群发 5 表全靠 `sid` / id 语义串联(无硬 FK)**:`cps_link.group_id`→`cps_group.id`、`cps_link.activity_id`→`cps_activity.id`、`cps_click.link_id`→`cps_link.id`;**点击与订单无法对到单笔**,只在群维度(`sid`)汇合——`cps_order.sid` ≈ `cps_group.sid` ≈ `cps_link.sid` ≈ `cps_click.sid`(仅美团有 sid,淘宝/京东无)。统计按群聚合,故 `group_id`/`sid` 冗余进 `cps_click` 免 join。 +- **比价沉淀两表(`price_observation` / `store_mapping`)**:`trace_id` 软指 pricebot work_logs(与 `comparison_record.trace_id` 同源但不互 join,各存各视角);`source_user_id` / `source_device_id` 软指用户/设备(可空,匿名也记)。`store_mapping.lookup` 靠**店名字符串精确相等** + geo 取最近,非 id 级 join。 ### ER 关系(文字版) ``` @@ -154,6 +189,11 @@ admin_user ─1:N─ admin_audit_log app_config (独立, 无外键, key 为主键) coupon_prompt_engagement / coupon_daily_completion / coupon_claim_record (独立, 无硬 FK; 维度=device_id+北京日, user_id/trace_id 仅软关联) +user ─1:N─ invite_relation (inviter_user_id 硬 FK); invitee_user_id ─1:1─ user (唯一硬 FK) +user ─1:N─ invite_fingerprint (inviter_user_id 硬 FK) +cps_activity / cps_group ──语义(无FK)──▶ cps_link ─1:N─ cps_click + (CPS 5 表自成子系统; cps_order 经 sid 归群对账, 与本 App user 无关) +price_observation / store_mapping (独立, 无硬 FK; 维度=trace_id, source_user/device 仅软关联; pricebot 内部上报) ``` --- diff --git a/docs/database/README.md b/docs/database/README.md index 24f91ad..20364ca 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -3,13 +3,13 @@ > 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。 > ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。 > 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`。 -> 最后更新:2026-06-11(合并:新增 3 张领券今日状态表 `coupon_*` + `onboarding_completion` 新手引导完成表;含 [OVERVIEW 总览](./OVERVIEW.md)) +> 最后更新:2026-06-17(补全 9 张此前缺文档的表:CPS 群发 5 张 `cps_*` + 好友邀请 2 张 `invite_*` + 比价沉淀 `price_observation`/`store_mapping`;并全表 review 对齐 model——`signin_record` 改 7 天循环、`coupon_prompt_engagement` 加 `package` 列按 App 频控、`user` 补 `username`/`invite_code`/`debug_trace_enabled` 等;含 [OVERVIEW 总览](./OVERVIEW.md)) > 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。 --- -## 表总览(28 张业务表 + `alembic_version` 框架表) +## 表总览(37 张业务表 + `alembic_version` 框架表) ### 账号 / 反馈 | 表 | 用途 | 模型 | 文档 | @@ -18,6 +18,12 @@ | `onboarding_completion` | 新手引导完成标记(按 设备+账号 去重,登录时据此跳过引导) | `models/onboarding.py` | [详情](./onboarding_completion.md) | | `feedback` | 用户帮助与反馈(含截图) | `models/feedback.py` | [详情](./feedback.md) | +### 好友邀请(注册增长) +| 表 | 用途 | 模型 | 文档 | +|---|---|---|---| +| `invite_relation` | 邀请绑定关系(注册即生效,双方各发1万金币;`invitee_user_id` 唯一=幂等防重复发奖) | `models/invite.py` | [详情](./invite_relation.md) | +| `invite_fingerprint` | 剪贴板归因失败时的指纹兜底(落地页记 ip+屏幕+机型,登录后反查邀请人) | `models/invite_fingerprint.py` | [详情](./invite_fingerprint.md) | + ### 钱包 / 福利(看广告赚钱闭环) | 表 | 用途 | 模型 | 文档 | |---|---|---|---| @@ -26,7 +32,7 @@ | `cash_transaction` | 现金流水账本(分) | `models/wallet.py` | [详情](./cash_transaction.md) | | `withdraw_order` | 提现单(现金→微信零钱,含人工审核态) | `models/wallet.py` | [详情](./withdraw_order.md) | | `wechat_transfer_authorization` | 微信免确认转账授权(一用户一行) | `models/wallet.py` | [详情](./wechat_transfer_authorization.md) | -| `signin_record` | 签到记录(14 天循环) | `models/signin.py` | [详情](./signin_record.md) | +| `signin_record` | 签到记录(7 天循环) | `models/signin.py` | [详情](./signin_record.md) | | `signin_boost_record` | 签到后看广告膨胀记录 | `models/signin.py` | [详情](./signin_boost_record.md) | | `user_task` | 一次性任务领取去重 | `models/task.py` | [详情](./user_task.md) | | `ad_reward_record` | 看激励视频发奖记录(S2S 回调,trans_id 幂等) | `models/ad_reward.py` | [详情](./ad_reward_record.md) | @@ -42,10 +48,16 @@ | `savings_record` | 省钱记录(profile 省钱战绩源;真实下单归因 + demo) | `models/savings.py` | [详情](./savings_record.md) | | `price_report` | 上报更低价(众包纠偏,人工审核发奖) | `models/price_report.py` | [详情](./price_report.md) | +### 比价数据沉淀(pricebot server→server 内部上报,不走用户鉴权) +| 表 | 用途 | 模型 | 文档 | +|---|---|---|---| +| `price_observation` | 价格观测(平台/门店视角的到手价事实,匿名也记;`comparison_record` 的客观对照) | `models/price_observation.py` | [详情](./price_observation.md) | +| `store_mapping` | 跨平台「同一家店」id/名/deeplink 身份映射(淘宝/美团/京东,供 lookup 反查省现场搜店) | `models/store_mapping.py` | [详情](./store_mapping.md) | + ### 领券(每日领券联动 · 今日状态) | 表 | 用途 | 模型 | 文档 | |---|---|---|---| -| `coupon_prompt_engagement` | 领券引导窗频控源(今日是否已 engage,按 device+日) | `models/coupon_state.py` | [详情](./coupon_state.md) | +| `coupon_prompt_engagement` | 领券引导窗频控源(今日是否已 engage,按 device+package+日) | `models/coupon_state.py` | [详情](./coupon_state.md) | | `coupon_daily_completion` | 首页「去领取」置灰源(今日是否已跑完整轮) | `models/coupon_state.py` | [详情](./coupon_state.md) | | `coupon_claim_record` | 每张券领取结果沉淀(资产/画像/排查,不参与判断) | `models/coupon_state.py` | [详情](./coupon_state.md) | @@ -54,6 +66,15 @@ |---|---|---|---| | `meituan_coupon` | 美团 CPS 券本地缓存(智能推荐/销量榜从库出,定时 ETL 灌入) | `models/meituan_coupon.py` | [详情](./meituan_coupon.md) | +### 美团 CPS 群发联盟(私域社群比价 · 群发选品→点击→对账漏斗) +| 表 | 用途 | 模型 | 文档 | +|---|---|---|---| +| `cps_activity` | 可推广活动池(运营预存的券/物料:美团 actId / 淘宝淘口令 / 京东链接) | `models/cps_activity.py` | [详情](./cps_activity.md) | +| `cps_group` | 推广微信群(`sid`=美团二级渠道追踪位,订单按 sid 归群对账) | `models/cps_group.py` | [详情](./cps_group.md) | +| `cps_link` | 群发短链 `/c/{code}`(群×活动生成,302 跳/淘宝落地页) | `models/cps_link.py` | [详情](./cps_link.md) | +| `cps_click` | 短链点击事件流(visit/copy,PV/UV 漏斗源) | `models/cps_link.py` | [详情](./cps_click.md) | +| `cps_order` | 美团联盟 query_order 拉回的对账订单(按 sid 归群算佣金) | `models/cps_order.py` | [详情](./cps_order.md) | + ### 首页门面数据 | 表 | 用途 | 模型 | 文档 | |---|---|---|---| diff --git a/docs/database/ad_feed_reward_record.md b/docs/database/ad_feed_reward_record.md index 99cccb4..f6edbfb 100644 --- a/docs/database/ad_feed_reward_record.md +++ b/docs/database/ad_feed_reward_record.md @@ -1,5 +1,7 @@ # ad_feed_reward_record — 信息流广告奖励记录 +> 模型 `app/models/ad_feed_reward.py` · 仓库 `app/repositories/ad_feed_reward.py` · 接口 `POST /api/v1/ad/feed-reward`(信息流广告结算) · [← 索引](./README.md) · [总览](./OVERVIEW.md) + 点位 2 的奖励记录。每条记录对应客户端完成的一次信息流广告展示/播放事件。 ## 字段 diff --git a/docs/database/ad_reward_record.md b/docs/database/ad_reward_record.md index 07c08be..dc6eaf9 100644 --- a/docs/database/ad_reward_record.md +++ b/docs/database/ad_reward_record.md @@ -21,7 +21,7 @@ | `app_env` | String(16) | nullable | 来源应用 `prod`(傻瓜比价)/`test`(测试);S2S 不带,发奖时按 `ad_session_id` 匹配 `ad_ecpm_record` 回填,查不到 NULL。广告收益报表金币侧按它聚合 | | `our_code_id` | String(64) | nullable | 我们配置的代码位 104xxx(同上回填) | | `coin` | Integer | NOT NULL, default 0 | 实发金币;`capped`/`ecpm_missing`/`closed_early`/业务不满足时为 0 | -| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `closed_early`(展示了但用户提前关/跳过,未发奖,客户端 reward-noshow 留痕)/ `not_signed`/`already_boosted`/`last_day` | +| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `closed_early`(展示了但用户提前关/跳过,未发奖,客户端 reward-noshow 留痕)/ `not_signed`/`already_boosted`/`last_day`/ `unknown_scene`(回调 `reward_scene` 不在支持集合,只留痕不发) | | `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值统计当日发奖次数 | | `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) | | `raw` | String(1024) | nullable | 回调原始参数(审计排查) | diff --git a/docs/database/app_config.md b/docs/database/app_config.md index 947831c..95e7aac 100644 --- a/docs/database/app_config.md +++ b/docs/database/app_config.md @@ -14,8 +14,8 @@ ## 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| -| `key` | String(64) | **PK** | 配置标识,取值见 `config_schema.CONFIG_DEFS`:`signin_rewards` / `min_exchange_coin` / `withdraw_min_cents` / `withdraw_max_cents` / `task_rewards` / `record_milestones` / `ad_reward_coin` / `ad_daily_limit` / `ad_max_coin` / `ad_round_count` / `ad_cooldown_sec` | -| `value` | JSON(PG: JSONB) | NOT NULL | 配置值,类型随 key(`int` / `int_list` 如签到 14 档 / `dict_str_int` 如 task_rewards) | +| `key` | String(64) | **PK** | 配置标识,取值见 `config_schema.CONFIG_DEFS`:`signin_rewards` / `min_exchange_coin` / `withdraw_min_cents` / `withdraw_max_cents` / `task_rewards` / `record_milestones` / `ad_reward_coin` / `ad_daily_limit` / `ad_max_coin` / `ad_round_count` / `ad_cooldown_sec` / `signin_boost_coin` / `withdraw_auto_reconcile_enabled` / `comparing_ad_enabled` | +| `value` | JSON(PG: JSONB) | NOT NULL | 配置值,类型随 key(`int` / `int_list` 如签到 14 档 / `dict_str_int` 如 task_rewards / `bool` 如 withdraw_auto_reconcile_enabled / comparing_ad_enabled) | | `updated_by_admin_id` | Integer | nullable | 最后修改的管理员 id(= `admin_user.id`,软引用,无 FK) | | `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最后修改时间 | diff --git a/docs/database/cash_transaction.md b/docs/database/cash_transaction.md index d379fee..f782184 100644 --- a/docs/database/cash_transaction.md +++ b/docs/database/cash_transaction.md @@ -34,6 +34,7 @@ ## 索引与约束 - PK `id`;index `user_id`、`created_at`。 +- 部分唯一索引 `ux_cash_transaction_withdraw_refund_ref`(`ref_id`),条件 `biz_type = 'withdraw_refund' AND ref_id IS NOT NULL`:一笔提现单只能退款一次(按 `out_bill_no` 去重),挡重复退款。 ## 注意 - 退款流水 `remark` 是用户可见文案(区分"未成功自动退"vs"审核未通过退");技术原因记在 `withdraw_order.fail_reason`,不外露。 diff --git a/docs/database/coin_transaction.md b/docs/database/coin_transaction.md index e1289a1..f29958d 100644 --- a/docs/database/coin_transaction.md +++ b/docs/database/coin_transaction.md @@ -10,8 +10,8 @@ | 动作 / endpoint | `biz_type` | `amount` | `ref_id` 指向 | |---|---|---|---| | 签到 `POST /signin/do` | `signin` | + | 当天日期串(= `signin_record.signin_date` ISO) | - | 签到后看广告膨胀 `POST /signin/boost` | `signin_boost` | + | 当天日期串(= `signin_boost_record.signin_date` ISO) | - | 领任务 `POST /tasks/claim` | `task_`(如 `task_enable_notification`) | + | `user_task.task_key` | + | 签到后看广告膨胀 `POST /signin/boost` | `signin_boost` | + | 广告 `trans_id`(= `signin_boost_record.ad_ref_id`);无 ad_ref_id 时回退当天日期 ISO 串 | + | 领任务 `POST /tasks/claim` | `task_`(如 `task_enable_notification`) | + | 一次性任务=`user_task.task_key`;可重复任务(`enable_notification`)=带序号 `task_key:N` | | 普通激励视频 S2S 回调 `POST /ad/pangle-callback` | `reward_video`(历史兼容:`ad_reward`) | + | `ad_reward_record.trans_id` | | 信息流广告结算 `POST /ad/feed-reward` | `feed_ad_reward` | + | `ad_feed_reward_record.client_event_id` | | 金币兑现金 `POST /wallet/exchange` | `exchange_out` | − | null(配套 `cash_transaction.exchange_in`) | @@ -36,10 +36,11 @@ ## 关系 / Join Key - `user_id` → `user.id`(多对一)。 -- `ref_id` 是**软关联**(无 FK),目标表随 `biz_type`:`signin`→签到日 / `signin_boost`→`ad_reward_record.trans_id` / `task_`→`user_task.task_key` / `reward_video`/`ad_reward`→`ad_reward_record.trans_id` / `feed_ad_reward`→`ad_feed_reward_record.client_event_id` / 其余 null。 +- `ref_id` 是**软关联**(无 FK),目标随 `biz_type`:`signin`→签到日(`signin_record.signin_date` ISO) / `signin_boost`→`signin_boost_record.ad_ref_id`(无则当天日期) / `task_`→一次性任务=`user_task.task_key`、可重复任务=`task_key:N` / `reward_video`/`ad_reward`→`ad_reward_record.trans_id` / `feed_ad_reward`→`ad_feed_reward_record.client_event_id` / 其余 null。 ## 索引与约束 - PK `id`;index `user_id`、`created_at`。 +- 部分唯一索引 `ux_coin_transaction_task_ref`(`user_id`, `biz_type`, `ref_id`),条件 `biz_type LIKE 'task%' AND ref_id IS NOT NULL`:可重复任务(如打开消息提醒)按 `ref_id` 序号(`task_key:N`)去重,挡并发/连点重复发放;仅 `task_*` 且 `ref_id` 非空生效,不影响 signin/exchange/withdraw 等其它 `biz_type`。 ## 注意 - 流水与余额快照(`coin_account`)、与对应业务记录(signin/task/ad_reward/feed_ad_reward 等)在**同一事务**写,不会只发币不留痕。 diff --git a/docs/database/comparison_milestone_claim.md b/docs/database/comparison_milestone_claim.md index a9d6c39..7079b6c 100644 --- a/docs/database/comparison_milestone_claim.md +++ b/docs/database/comparison_milestone_claim.md @@ -5,7 +5,7 @@ App「记录比价战绩」每档(第 1~6 次)只能领一次,领取后写一行去重。解锁进度**不存本表**——由 `comparison_record` 里 `status='success'` 的条数实时算(第 N 档在"成功比价 ≥ N 次"时解锁)。档位金额来自 `rewards.RECORD_MILESTONES`(默认 `(120,180,300,500,800,1200)`,运营后台 `app_config.record_milestones` 可改)。仿 `user_task` 的一次性领取模型。 ## 用在哪 / 增删改查 -- **C(插入)**:`POST /compare/milestone/claim`(`claim`)。档位合法 + 已解锁(`count_success >= milestone`)+ 未领过 → 写一行(`coin_awarded=0`)。越界 `UnknownMilestoneError`(404)、未解锁 `MilestoneLockedError`(409)、已领 `AlreadyClaimedError`(409)。 +- **C(插入)**:`POST /compare/milestones/{milestone}/claim`(`claim`)。档位合法 + 已解锁(`count_success >= milestone`)+ 未领过 → 写一行(`coin_awarded=0`)。越界 `UnknownMilestoneError`(404)、未解锁 `MilestoneLockedError`(409)、已领 `AlreadyClaimedError`(409)。 - **U / D**:无。 - **R**:`GET /compare/milestones`(战绩页:各档 claimed/active/locked + 成功次数),`get_status` 读本表已领集合 + `comparison_record` 成功计数对照。 diff --git a/docs/database/comparison_record.md b/docs/database/comparison_record.md index ba47da1..b49cf8e 100644 --- a/docs/database/comparison_record.md +++ b/docs/database/comparison_record.md @@ -5,6 +5,7 @@ 每完成一次比价(外卖/电商/领券),客户端在 done 帧后用**带 JWT** 的通道上报一条。App「我的比价记录」列表/详情的数据源,也是比价战绩里程碑解锁进度的计数源(`status='success'` 条数),还被「上报更低价」反查原最低价。 > 与 `savings_record` 的区别:本表是「每一次**比价行为**的完整明细」(不省钱、甚至失败也记);`savings_record` 是「真正**下单成交**省了多少」。两表独立、互不喂数据。 +> 与 [`price_observation`](./price_observation.md) / `store_mapping` 的区别:本表是**用户视角**(登录后按 `user_id` 存「我的比价记录」);后两张是 server 侧无条件沉淀的**平台/门店视角客观事实**(价格事实 / 跨平台店铺身份映射),与本表 `trace_id` 同源但不互相 join,各存各的视角。 ## 用在哪 / 增删改查 - **C / U(upsert,幂等)**:`POST /compare/record`(`upsert_record`)。按 `(user_id, trace_id)` 查:不存在→新建;已存在→整行覆盖(客户端重试/重复上报时,**更完整的那次胜出**)。`best_*`/`saved_amount_cents`/`is_source_best`/`status` 由 `_derive` 从 `comparison_results` 算出(协议已按 price 升序、rank=1 最便宜),不信客户端自算。 @@ -19,6 +20,7 @@ | `device_id` | String(64) | nullable | 设备号(多设备区分 / 与不鉴权期对账) | | `business_type` | String(16) | NOT NULL, default `food`, index | 取值:`food`(当前唯一接通)/ `ecom` / `coupon` | | `trace_id` | String(64) | NOT NULL | pricebot 侧 trace_id(关联调试落盘 + 幂等键) | +| `trace_url` | String(512) | nullable | 本次比价公网调试链接(`price.shaguabijia.com/traces/{dir}/`);dir 名含 pricebot 落盘时分秒,前端/server 拼不出必须存。查看接口按 `user.debug_trace_enabled`(或本机 agent 调试 `include_trace`)决定返不返回。旧记录 / 未开上云为 null | | `source_platform_id` / `_name` | String(32) | nullable | 源平台代号 / 中文名 | | `source_package` | String(128) | nullable | 源平台 Android 包名 | | `source_price_cents` | Integer | nullable | 源平台到手价(分) | @@ -32,7 +34,7 @@ | `status` | String(16) | NOT NULL, default `success` | 取值:`success`(有非源且有价的目标结果)/ `failed`(出错/没采到目标价)。**里程碑只数 success** | | `information` | String(256) | nullable | done 帧文案;成功=摘要,失败=具体原因(前端失败时当原因展示) | | `items` | JSON(PG: JSONB) | NOT NULL, default [] | 下单菜品 `[{name, qty, specs?}]` | -| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved(元),coupon_name}]`;`coupon_saved`=该平台主优惠额(美团红包/淘宝平台红包/京东百亿补贴,只取一笔),`coupon_name`=优惠来源名(展示用) | +| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved(元),coupon_name,applied_coupons}]`;`coupon_saved`=该平台主优惠额(美团红包/淘宝平台红包/京东百亿补贴,只取一笔),`coupon_name`=优惠来源名(展示用),`applied_coupons`=`[{name,amount}]` 多券明细 | | `skipped_dish_names` | JSON(PG: JSONB) | NOT NULL, default [] | 被跳过的菜名 | | `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报全量(calibration + done.params),取数兜底 | | `created_at` | DateTime(tz) | server_default now(), index | 时间 | @@ -46,7 +48,7 @@ - 被 `comparison_milestone_claim` 间接依赖:解锁进度 = 本表 `status='success'` 计数。 ## 索引与约束 -- PK `id`;index `user_id`、`business_type`、`created_at`;UNIQUE(`user_id`, `trace_id`) = `uq_comparison_user_trace`(幂等覆盖)。 +- PK `id`;index `user_id`、`business_type`、`created_at`;复合 index `ix_comparison_status_created`(`status`, `created_at`)(按 `status='success'` 过滤 + 近期排序的聚合/轮播,避免随数据量退化为全表扫);UNIQUE(`user_id`, `trace_id`) = `uq_comparison_user_trace`(幂等覆盖)。 ## 注意 - 4 个 JSON 列用 `JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。结构化金额列存「分」,`comparison_results.price`/`coupon_saved` 原样存「元」。 diff --git a/docs/database/coupon_state.md b/docs/database/coupon_state.md index 956f227..c6559b8 100644 --- a/docs/database/coupon_state.md +++ b/docs/database/coupon_state.md @@ -4,7 +4,7 @@ 领券(优惠券自动化)联动产生的三张「今日状态」表,都挂在领券透传端点 `POST /api/v1/coupon/step` 这条链路上(pricebot 跑领券,结果回 app-server 落库;**领券过程本身在 pricebot 内存态跑、不落库**)。三表各管一件事: -- **`coupon_prompt_engagement`** — 弹窗频控源。按 `(device, 自然日)` 记「今天是否对领券引导窗表达过**意向**」(点「一键领取」=`claim_started` / 点拒绝关闭=`dismissed` 都算)。切到外卖 App 时据此决定弹不弹:今天 engage 过就不再弹。 +- **`coupon_prompt_engagement`** — 弹窗频控源。按 `(device, App 包名, 自然日)` 记「今天**这个 App** 是否对领券引导窗表达过**意向**」(弹出即记 `shown` / 点「一键领取」=`claim_started` / 点拒绝关闭=`dismissed` 都算)。切到外卖 App 时据此决定弹不弹:今天**该 App** engage 过就不再弹该 App。频控维度自 2026-06-14 起含 `package`,美团/淘宝/京东各自独立、互不压制。 - **`coupon_daily_completion`** — 首页置灰源。按 `(device, 自然日)` 记「今天是否已**跑完整轮**领券(到 done 帧)」。首页「去领取」卡据此置灰:今天跑完了就不能再领。 - **`coupon_claim_record`** — 资产沉淀层。按 `(device, 券, 自然日)` 记每张券的领取结果(success/already_claimed/failed/skipped),**纯沉淀**(资产/画像/排查/CPS 归因),当前**不参与**「要不要领 / 弹不弹」的判断。 @@ -19,35 +19,38 @@ --- -## coupon_prompt_engagement — 弹窗频控(今日是否已对引导窗表达意向) +## coupon_prompt_engagement — 弹窗频控(今日这个 App 是否已对引导窗表达意向) -`(device_id, engage_date)` 唯一,一台设备一天一条;今天 engage 过(领或拒)就不再弹。 +`(device_id, package, engage_date)` 唯一,一台设备、一个 App、一天一条;今天**这个 App** engage 过(弹/领/拒)就不再弹该 App。各 App 独立:美团弹过不压淘宝/京东。 ### 用在哪 / 增删改查 -- **C / U(幂等 upsert)**:`mark_engagement`。两条触发: +- **C / U(幂等 upsert)**:`mark_engagement`。三条触发: + - `POST /api/v1/coupon/prompt/shown`(引导窗弹出那刻上报)→ 记 `shown`(频控主判据:弹出即占用今天这个 App 的「一次」,领/拒/无视都算用掉); - `POST /api/v1/coupon/step` 且 `step==0`(领券首帧=用户已发起领券)→ 记 `claim_started`; - `POST /api/v1/coupon/prompt/dismiss`(用户点关闭引导窗;server 在透传链路看不到「拒绝」,必须客户端通知)→ 记 `dismissed`。 - - 已有今天那条则覆盖 `engage_type`(并补 `user_id`),否则插入。 -- **D**:`POST /api/v1/coupon/prompt/reset`(`reset_today_engagement`)—— 删这台设备今天那条,开发设置「重置今日领券弹窗状态」按钮调,测频控用;删后今天又能弹。 -- **R**:`GET /api/v1/coupon/prompt/should-show?device_id=…`(`has_engaged_today`)→ `should_show = not 今天已 engage`。客户端切外卖 App 前查,纯后台判据。 + - 已有今天那条则覆盖 `engage_type`(并补 `user_id`),否则插入。`step` 帧的 `package` 取 step body 的 `package`,缺/空则退化为 `""` 占位(全局态)。 +- **D**:`POST /api/v1/coupon/prompt/reset`(`reset_today_engagement`)—— 删这台设备今天**所有 App** 的 engagement(不按 package 过滤:重置=从头测,清全部 App 最符预期),开发设置「重置今日领券弹窗状态」按钮调;删后今天又都能弹。 +- **R**:`GET /api/v1/coupon/prompt/should-show?device_id=…&package=…`(`has_engaged_today`)→ `should_show = not 今天该 App 已 engage`。客户端切外卖 App 前带 `package` 查(老客户端不带 → `""` 全局态)。 ### 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | | | `device_id` | String(64) | NOT NULL | 判断/聚合维度;客户端 `getOrCreateDeviceId`,重装会变 | +| `package` | String(64) | NOT NULL, server_default `""` | 触发弹窗的目标 App 包名(`com.sankuai.meituan` / `com.taobao.taobao` / `com.jingdong.app.mall`)。频控维度,各 App 独立。旧行(2026-06-14 改造前)无此值 → 迁移用占位 `""` 填 | | `user_id` | Integer | index, 可空 | 登录态有就记(资产);不进唯一键、不阻塞判断 | | `engage_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`) | -| `engage_type` | String(16) | NOT NULL | `claim_started`(点一键领取)/ `dismissed`(点拒绝关闭);仅记录区分,**判断只看「今天有没有这条」,type 不影响弹不弹** | +| `engage_type` | String(16) | NOT NULL | `shown`(自动弹出即记)/ `claim_started`(点一键领取)/ `dismissed`(点拒绝关闭);仅记录区分,**判断只看「今天这个 App 有没有这条」,type 不影响弹不弹** | | `created_at` | DateTime(tz) | server_default now() | | | `updated_at` | DateTime(tz) | server_default now(), onupdate now() | | ### 索引与约束 -- PK `id`;index `user_id`;UNIQUE(`device_id`, `engage_date`) = `uq_coupon_engage_device_date`(一台设备一天一条)。 +- PK `id`;index `user_id`;UNIQUE(`device_id`, `package`, `engage_date`) = `uq_coupon_engage_device_pkg_date`(一台设备、一个 App、一天一条)。 + - 历史:原为 UNIQUE(`device_id`, `engage_date`) = `uq_coupon_engage_device_date`,2026-06-14 迁移 `coupon_engage_per_package` 加入 `package` 维度改成现状(修「任一 App 弹过就把整台设备当天标记、压住其余 App」的 bug)。 ### 注意 - `device_id` 重装会变 → 重装当新设备,今天重新弹一次(产品预期)。 -- 判断只看「今天这台设备有没有这条」,不看 `engage_type`(领或拒都算 engage 过、都不再弹)。 +- 判断只看「今天这台设备这个 App 有没有这条」,不看 `engage_type`(弹/领/拒都算 engage 过、都不再弹该 App)。 --- @@ -57,7 +60,7 @@ ### 用在哪 / 增删改查 - **C / U(幂等 upsert)**:`mark_completed_today`,由 `POST /api/v1/coupon/step` 在 pricebot 返回 `action.command == "done"` 那帧调。pricebot 把中途单券 done 改写成 `wait+continue=true`,只有整套全跑完那帧才保留 `command=="done"`,故 **done 已等价「整轮完成」**。已有今天那条则补 `user_id`/`trace_id`,否则插入。 -- **U / D**:无业务删除。 +- **D**:`POST /api/v1/coupon/completed-today/reset`(`reset_today_completion`)—— 删这台设备今天那条,首页「去领取」卡恢复可点。与 `/prompt/reset` 配套,开发设置「重置今日领券弹窗状态」一键把今日状态全清;MVP 不鉴权。 - **R**:`GET /api/v1/coupon/completed-today?device_id=…`(`has_completed_today`)→ `completed`。客户端据此把首页「去领取」卡置灰、不可点。 ### 字段 @@ -119,5 +122,5 @@ ## 三表共性小结 - 数据流向:客户端 → `POST /api/v1/coupon/step`(透传给 pricebot)→ 结果回写这三张表(best-effort,写库失败不影响领券)。 -- 唯一键都含 `device_id` + 某个北京自然日列;`user_id` 永远是可空旁路(资产留痕,不进唯一键、不阻塞判断)。 +- 唯一键都含 `device_id` + 某个北京自然日列(engagement 还含 `package`,按 App 频控);`user_id` 永远是可空旁路(资产留痕,不进唯一键、不阻塞判断)。 - 无硬外键:`user_id` 软指 `user.id`、`trace_id` 软指 pricebot work_logs(详见 [OVERVIEW → 表间关系 & Join Key](./OVERVIEW.md))。 diff --git a/docs/database/cps_activity.md b/docs/database/cps_activity.md new file mode 100644 index 0000000..63d9188 --- /dev/null +++ b/docs/database/cps_activity.md @@ -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` 时退化为相对路径。 diff --git a/docs/database/cps_click.md b/docs/database/cps_click.md new file mode 100644 index 0000000..18be454 --- /dev/null +++ b/docs/database/cps_click.md @@ -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` 包住,落地/跳转优先级高于统计——丢几条点击可接受,挡住用户领券不可接受。 diff --git a/docs/database/cps_group.md b/docs/database/cps_group.md new file mode 100644 index 0000000..3b741fc --- /dev/null +++ b/docs/database/cps_group.md @@ -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`、flush 拿到 id 后回填成 `g`;纯淘宝/京东群 `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`;纯淘宝/京东群为 `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` 自动 sid**:运营不填 sid 时服务端生成 `g<群id>`,既保证唯一又人类可读。先写 `tmp` 占位是因为建群事务里 id 还没生成(flush 前拿不到),拿到 id 后二次 flush 回填。 +- **删群不级联**是刻意的:已发到微信群里的短链不能因为后台删群就失效;历史订单/点击也要保留用于对账。代价是删群后其 `sid` 的订单在 `group_stats` 里变成"未归群"独立行(如历史的 `wonderableai`,见 [`cps_order`](./cps_order.md) 注意)。 +- **`platforms` 用 JSON 列**:遵循全库约定 `JSON().with_variant(JSONB(), "postgresql")`——PG 上 JSONB,SQLite 退化通用 JSON。 diff --git a/docs/database/cps_link.md b/docs/database/cps_link.md new file mode 100644 index 0000000..ab28451 --- /dev/null +++ b/docs/database/cps_link.md @@ -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/`。 diff --git a/docs/database/cps_order.md b/docs/database/cps_order.md new file mode 100644 index 0000000..ad022a2 --- /dev/null +++ b/docs/database/cps_order.md @@ -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`(前端显示 `-`)。这是"对账 = 美团专属"的边界。 diff --git a/docs/database/invite_fingerprint.md b/docs/database/invite_fingerprint.md new file mode 100644 index 0000000..9ea972f --- /dev/null +++ b/docs/database/invite_fingerprint.md @@ -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 1,SQLite/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 的表先建)。 diff --git a/docs/database/invite_relation.md b/docs/database/invite_relation.md new file mode 100644 index 0000000..590b20f --- /dev/null +++ b/docs/database/invite_relation.md @@ -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 设计文档已警示)。 diff --git a/docs/postgres-migration.md b/docs/database/postgres-migration.md similarity index 97% rename from docs/postgres-migration.md rename to docs/database/postgres-migration.md index 8d1ee58..84f6567 100644 --- a/docs/postgres-migration.md +++ b/docs/database/postgres-migration.md @@ -5,7 +5,7 @@ > **相关文档**: > - 这份文档讲 **切引擎的完整步骤**(本机装 PG / 建库 / `scripts/init_postgres.py` 用法 / 生产切换) > - [数据库迁移.md](./数据库迁移.md) 讲 **alembic 操作**(如何建表/升级/新增迁移/多 head 排查/迁移链 12 条) -> - [后端技术实现.md §10](./后端技术实现.md) 列待办与已知问题(含 `init_postgres.py` 已知小 bug) +> - [后端技术实现.md §10](../后端技术实现.md) 列待办与已知问题(含 `init_postgres.py` 已知小 bug) > > **背景假设**:当前生产**无真实用户数据**(MVP 阶段),所以整个迁移本质是"切引擎",不涉及数据搬迁。如果将来已有真实数据,本文档不适用,需补充 `pgloader` 演练 + 钱表金额逐行核对 + 停服窗口 + 回滚预案。 > @@ -248,7 +248,7 @@ sudo systemctl restart shaguabijia-app-server ## 6. 顺便建议:同时装上 Redis -PG 上线后,Redis 是下一个明确要补的基础设施(已在 [待办与技术债.md](./待办与技术债.md) 里列了用途)。**建议本次维护窗口顺手把 Redis 也装上**,不开始用、只装服务,免得下次还要单独申请部署: +PG 上线后,Redis 是下一个明确要补的基础设施(已在 [待办与技术债.md](../guides/待办与技术债.md) 里列了用途)。**建议本次维护窗口顺手把 Redis 也装上**,不开始用、只装服务,免得下次还要单独申请部署: ```bash sudo apt install redis-server diff --git a/docs/database/price_observation.md b/docs/database/price_observation.md new file mode 100644 index 0000000..105d6cd --- /dev/null +++ b/docs/database/price_observation.md @@ -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` | JSON(PG JSONB) | 可空 | 菜篮 `[{name, qty, subtotal?}]`(外卖)。dish 级单价二期从这里 + 引擎增强推。**批级共享** | +| `attrs` | JSON(PG 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 / 未启用时该表不会有新数据。 diff --git a/docs/database/signin_boost_record.md b/docs/database/signin_boost_record.md index 718a21c..ea71341 100644 --- a/docs/database/signin_boost_record.md +++ b/docs/database/signin_boost_record.md @@ -1,6 +1,6 @@ # signin_boost_record — 签到膨胀记录 -App 用户 Day1-Day13 当天签到后,看完激励视频可固定膨胀 2000 金币一次。Day14 不展示也不允许膨胀。本表记录膨胀动作,并用唯一约束防重复补发。 +App 用户当天签到后,看完激励视频可固定膨胀一次(默认 3000 金币,`rewards.SIGNIN_BOOST_COIN`,运营后台 `app_config.signin_boost_coin` 可改)。循环最后一天(`cycle_day == SIGNIN_CYCLE_LEN`,即 7 天循环的第 7 天)不展示也不允许膨胀。本表记录膨胀动作,并用唯一约束防重复补发。 ## 字段 @@ -9,14 +9,14 @@ App 用户 Day1-Day13 当天签到后,看完激励视频可固定膨胀 2000 | `id` | Integer | PK | 自增主键 | | `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 | | `signin_date` | Date | NOT NULL | 被膨胀的签到日期,北京时间 | -| `coin_awarded` | Integer | NOT NULL | 本次补发金币,默认固定 2000 | +| `coin_awarded` | Integer | NOT NULL | 本次补发金币,默认固定 3000(`rewards.get_signin_boost_coin`) | | `ad_ref_id` | String(64) | nullable | 穿山甲 S2S 回调 `trans_id` | -| `created_at` | DateTime | NOT NULL | 创建时间 | +| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间 | ## 约束 -- `UNIQUE(user_id, signin_date)`:同一用户同一天只能膨胀一次。 +- `UNIQUE(user_id, signin_date)` = `uq_signin_boost_user_date`:同一用户同一天只能膨胀一次。 ## 关联 -- 膨胀成功时写 `coin_transaction.biz_type=signin_boost`,`ref_id=ad_ref_id`。 +- 膨胀成功时写 `coin_transaction.biz_type=signin_boost`,`ref_id = ad_ref_id`(无 ad_ref_id 时回退当天日期 ISO 串)。 diff --git a/docs/database/signin_record.md b/docs/database/signin_record.md index 786beab..0f89b21 100644 --- a/docs/database/signin_record.md +++ b/docs/database/signin_record.md @@ -1,13 +1,13 @@ -# signin_record — 签到记录(14 天循环) +# signin_record — 签到记录(7 天循环) > 模型 `app/models/signin.py` · 仓库 `app/repositories/signin.py` · 接口 [signin-status](../api/signin-status.md) / [signin-do](../api/signin-do.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天然防一天签两次。`cycle_day`(1..14)决定发多少金币(档位 `rewards.SIGNIN_REWARDS`,默认 `(200,120,150,180,200,250,500,200,250,300,350,400,500,3000)`,运营后台 `app_config.signin_rewards` 可改),断签重置回 1;第 15 天回到第 1 天;`streak` 是连续天数(不封顶),给"已连续签到 N 天"展示。 +App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天然防一天签两次。`cycle_day`(1..7)决定发多少金币(档位 `rewards.SIGNIN_REWARDS`,默认 `(200,200,300,200,400,400,800)`,一轮 7 天累计 2500 金币,运营后台 `app_config.signin_rewards` 可改),断签重置回 1;第 8 天回到第 1 天;`streak` 是连续天数(不封顶),给"已连续签到 N 天"展示。 ## 用在哪 / 增删改查 - **C(插入)**:`POST /signin/do`(`do_signin`)。今天未签且校验通过 → 算出 `cycle_day`/`streak`(昨天签过则在上次基础上 +1、否则重置 1)→ 写一行 + `grant_coins(biz_type='signin', ref_id=今天日期)` 加币,**同事务 commit**。今天已签再调 → 抛 `AlreadySignedError`(409),不重复写。 - **U / D**:无。每天一行,历史不改。 -- **R**:`GET /signin/status`(签到页:今天签没签、连续天数、14 档状态/金币、能否签),内部 `get_status` 读最近一条 + 推算今天档位。 +- **R**:`GET /signin/status`(签到页:今天签没签、连续天数、7 档状态/金币、能否签),内部 `get_status` 读最近一条 + 推算今天档位。 ## 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | @@ -15,7 +15,7 @@ App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天 | `id` | Integer | PK, autoincrement | | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | | `signin_date` | **Date** | NOT NULL | 签到日期(**北京时间** `cn_today()` 的 date)。其 ISO 串被 `coin_transaction.ref_id` 引用(biz_type=signin) | -| `cycle_day` | Integer | NOT NULL | 取值 `1..14`:14 天循环里今天第几档,决定发币额;断签重置回 1 | +| `cycle_day` | Integer | NOT NULL | 取值 `1..7`:7 天循环里今天第几档,决定发币额;断签重置回 1 | | `streak` | Integer | NOT NULL | 连续签到天数(≥1,不封顶);断签重置回 1 | | `coin_awarded` | Integer | NOT NULL | 本次实发金币(= 当时档位金额) | | `created_at` | DateTime(tz) | server_default now() | 时间 | diff --git a/docs/database/store_mapping.md b/docs/database/store_mapping.md new file mode 100644 index 0000000..d63695d --- /dev/null +++ b/docs/database/store_mapping.md @@ -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 / U(upsert,trace_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 走现场反查老路。 +- **U(deeplink 失效标记)**:`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 shopId;3.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` | JSON(PG 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 / 未启用时该表不会有新数据。 diff --git a/docs/database/user.md b/docs/database/user.md index 04b5adb..ce33696 100644 --- a/docs/database/user.md +++ b/docs/database/user.md @@ -13,24 +13,27 @@ ## 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| -| `id` | Integer | PK, autoincrement | 用户主键;被 15 张表的 `user_id` 外键引用 | +| `id` | Integer | PK, autoincrement | 用户主键;被多张业务表的 `user_id` 外键引用 | | `phone` | String(20) | UNIQUE, index, NOT NULL | 手机号(登录主键)。注销后置 `deleted_` 占位释放唯一约束 | +| `username` | String(11) | UNIQUE, index, NOT NULL | 对外展示账号 ID:11 位纯数字、首位非 1(与手机号天然区分)、全局唯一、创建时随机生成、不可变、**不参与登录**(登录仍走 phone)。生成见 `repositories/user._gen_username` | | `register_channel` | String(20) | NOT NULL, default `jverify` | 取值:`jverify`(极光一键)/ `sms`(短信);`savings_record` 的对比人群另有种子用户用 `seed` | | `nickname` | String(64) | nullable | 通用昵称(`PATCH /user/profile` 写) | | `avatar_url` | String(512) | nullable | 通用头像相对 URL `/media/avatars/...`(`POST /user/avatar` 写) | +| `invite_code` | String(16) | UNIQUE, index, nullable | 邀请码:每用户一个稳定短码(懒生成,见 `repositories/invite.ensure_code`),分享链接/二维码里带它。UNIQUE 允许多个 NULL(未生成的用户) | | `wechat_openid` | String(64) | UNIQUE, index, nullable | 微信 openid(提现转账用);**一微信一账号**,多个 NULL 允许(多个未绑用户) | | `wechat_nickname` | String(64) | nullable | 微信昵称(绑定时拉,展示在提现绑定卡;与通用 nickname 分开存) | | `wechat_avatar_url` | String(512) | nullable | 微信头像 URL | | `status` | String(20) | NOT NULL, default `active` | 取值:`active`(可登录/鉴权)/ `disabled`(admin 禁用)/ `deleted`(已注销);仅 active 能通过鉴权 | +| `debug_trace_enabled` | Boolean | NOT NULL, default false, server_default false | 调试链接权限:开了的用户在比价完成弹窗 + 比价记录页能看到「复制调试链接」按钮(运营后台按用户配置);`/me` 与登录响应带出给前端做条件渲染 | | `created_at` | DateTime(tz) | server_default now() | 注册时间 | | `last_login_at` | DateTime(tz) | 应用层 default utcnow | 最近登录时间(每次登录更新) | ## 关系 / Join Key -- **被引用方(本表是 1,对方是 N/1)**:`coin_account`、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback` 的 `user_id` 均 → `user.id`。 +- **被引用方(本表是 1,对方是 N/1)**:`coin_account`、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback` 的 `user_id` 均 → `user.id`;`invite_relation` 的 `inviter_user_id` / `invitee_user_id` 均 → `user.id`。 - 与 `admin_user` **无任何关联**(C 端用户 vs 后台管理员,两套体系)。 ## 索引与约束 -- PK `id`;UNIQUE+index `phone`;UNIQUE+index `wechat_openid`(允许多 NULL)。 +- PK `id`;UNIQUE+index `phone`、`username`、`invite_code`(允许多 NULL)、`wechat_openid`(允许多 NULL)。 ## 注意 - `nickname/avatar_url`(通用)与 `wechat_nickname/wechat_avatar_url`(微信)**分开存,不互相覆盖**。 diff --git a/docs/database/user_task.md b/docs/database/user_task.md index c2acc22..26eb190 100644 --- a/docs/database/user_task.md +++ b/docs/database/user_task.md @@ -2,10 +2,11 @@ > 模型 `app/models/task.py` · 仓库 `app/repositories/task.py` · 接口 [tasks-list](../api/tasks-list.md) / [tasks-claim](../api/tasks-claim.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -像"打开消息提醒"这类**只能领一次**的任务,领奖后写一行,`(user_id, task_key)` 唯一防重复领。可循环领取的任务(签到)不走这张表,有专表 `signin_record`。任务字典与奖励来自 `rewards.TASK_REWARDS`(默认 `{enable_notification: 1000}`,运营后台 `app_config.task_rewards` 可改)。 +**只能领一次**的任务,领奖后写一行,`(user_id, task_key)` 唯一防重复领。可循环领取的任务(签到)不走这张表,有专表 `signin_record`。任务字典与奖励来自 `rewards.TASK_REWARDS`(默认 `{enable_notification: 750}`,运营后台 `app_config.task_rewards` 可改)。 +> ⚠️ 当前 `TASK_REWARDS` 里唯一的 `enable_notification`(打开消息提醒)已改为**可重复领取**任务(每次减半,见 `rewards.notification_reward`),它**不写本表**——领取次数靠数 `coin_transaction` 的正向流水条数(`biz_type='task_enable_notification'`),`ref_id` 带序号 `task_key:N`。因此本表目前实际**没有被任何已定义任务写入**;待新增真正的一次性任务后才会落行。 ## 用在哪 / 增删改查 -- **C(插入)**:`POST /tasks/claim`(`claim_task`)。`task_key` 合法 + 未领过 → 写一行(`status='completed'`)+ `grant_coins(biz_type='task_', ref_id=task_key)` 加币,**同事务**。未知 key 抛 `UnknownTaskError`(404);已领抛 `AlreadyClaimedError`(409)。 +- **C(插入)**:`POST /tasks/claim`(`claim_task`),**仅一次性任务**走这条路:`task_key` 合法 + 未领过 → 写一行(`status='completed'`)+ `grant_coins(biz_type='task_', ref_id=task_key)` 加币,**同事务**。未知 key 抛 `UnknownTaskError`(404);已领抛 `AlreadyClaimedError`(409)。可重复任务(`enable_notification`)在 `claim_task` 里走另一分支:**不写本表**,只发币(`ref_id=task_key:N`),减半到底(`notification_max_claims`)后再领抛 `AlreadyClaimedError`。 - **U / D**:无。领过即终态。 - **R**:`GET /tasks/list`(任务页:列出所有已知任务 + 是否已领),`list_tasks` 把本表已领的 `task_key` 集合与任务字典对照。 @@ -14,7 +15,7 @@ |---|---|---|---| | `id` | Integer | PK, autoincrement | | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | -| `task_key` | String(48) | NOT NULL | 任务标识,取值见 `rewards.TASK_REWARDS`(当前仅 `enable_notification`)。**被 `coin_transaction.ref_id` 引用**(biz_type=`task_`) | +| `task_key` | String(48) | NOT NULL | 任务标识,取值见 `rewards.TASK_REWARDS`(`enable_notification` 是可重复任务,不落本表)。一次性任务领取时其 `task_key` = 配套 `coin_transaction.ref_id`(biz_type=`task_`) | | `status` | String(16) | NOT NULL, default `completed` | 当前恒为 `completed`(领即完成) | | `coin_awarded` | Integer | NOT NULL, default 0 | 该任务发放金币(= `TASK_REWARDS[task_key]`) | | `completed_at` | DateTime(tz) | server_default now() | 领取时间 | diff --git a/docs/database/withdraw_order.md b/docs/database/withdraw_order.md index edb454c..9b34897 100644 --- a/docs/database/withdraw_order.md +++ b/docs/database/withdraw_order.md @@ -44,6 +44,7 @@ reviewing ──admin 审核拒绝──▶ rejected(已退款) ## 索引与约束 - PK `id`;UNIQUE+index `out_bill_no`;index `user_id`、`created_at`。 +- 部分唯一索引 `ux_withdraw_order_user_active`(`user_id`),条件 `status IN ('reviewing', 'pending')`:每个用户同时只能有一笔在途(待审核 / 打款中)提现单,DB 层挡并发重复提现。 ## 注意 - **资金安全**:原子扣款(`WHERE cash_balance_cents >= amount`)+ `out_bill_no` 幂等 + 结果不明时**先查单再决定,绝不盲目退款**(防退款后又到账)+ 孤儿 pending 单 `reconcile_pending_withdraws` 对账兜底。 diff --git a/docs/数据库迁移.md b/docs/database/数据库迁移.md similarity index 98% rename from docs/数据库迁移.md rename to docs/database/数据库迁移.md index 599f313..9a9c6ce 100644 --- a/docs/数据库迁移.md +++ b/docs/database/数据库迁移.md @@ -6,7 +6,7 @@ > **相关文档**: > - 这份文档讲 **alembic 操作**(如何建表/升级/新增迁移/多 head 排查) > - [postgres-migration.md](./postgres-migration.md) 讲 **从 SQLite 切到 PostgreSQL** 的完整步骤(本机安装/建库/`scripts/init_postgres.py` 用法) -> - 配合 [后端技术实现.md §10](./后端技术实现.md) 看待办与已知问题 +> - 配合 [后端技术实现.md §10](../后端技术实现.md) 看待办与已知问题 --- diff --git a/docs/待办与技术债.md b/docs/guides/待办与技术债.md similarity index 97% rename from docs/待办与技术债.md rename to docs/guides/待办与技术债.md index 14a3a7f..76a38d8 100644 --- a/docs/待办与技术债.md +++ b/docs/guides/待办与技术债.md @@ -40,7 +40,7 @@ ## 技术债(暂不处理,知情即可) - **agent 接口公网裸奔**:不鉴权 = 任何人可调,烧 pricebot 的 LLM 算力。与美团 3 接口现状同级,记账后随 P1 一起补。 -- **既有债(已记录在别处)**:美团 `referral-link` 的 `sid` 客户端可覆盖刷分佣;`logout` 占位不吊销 token;`SMS_MOCK=true` 全站免密登录;`JWT_SECRET_KEY` 默认值可伪造 token。详见 [后端技术实现.md](./后端技术实现.md) 与 [api/](./api/)。 +- **既有债(已记录在别处)**:美团 `referral-link` 的 `sid` 客户端可覆盖刷分佣;`logout` 占位不吊销 token;`SMS_MOCK=true` 全站免密登录;`JWT_SECRET_KEY` 默认值可伪造 token。详见 [后端技术实现.md](../后端技术实现.md) 与 [api/](./api/)。 --- @@ -87,7 +87,7 @@ ## 已解决 -- ✅ **短信验证码接入极光(real 模式,2026-06-03)**:`/sms/send`+`/sms/login` 从 mock 升级为真实可用。`integrations/sms.py` 走**自定义验证码模式**(本服务 `secrets` 生成 6 位码 + 进程内存存储 + 极光 `/v1/messages` 只负责发 + 本地校验一次性/防爆破);鉴权**复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`**(同一极光应用,**上线只需 `SMS_MOCK=false`**,无需额外凭证;`sign_id=31729`/`temp_id=1` 已审核就绪)。防刷四层(单号冷却 + 单号每日上限 + 单 IP `rate_limit` + 单码失败次数上限);`SmsError.status_code` 让发送失败按类型返回 429/503/400。**mock 保留"任意6位通过"**(不动其他 11 个测试文件的 `_login` helper);`tests/test_auth.py` 加 real 模式单测(极光协议 / 一次性 / 防爆破 / 余额回滚)。文档 [integrations/sms.md](./integrations/sms.md)。**遗留**:验证码进程内存,多 worker 需迁 DB/Redis(下方技术债)。 +- ✅ **短信验证码接入极光(real 模式,2026-06-03)**:`/sms/send`+`/sms/login` 从 mock 升级为真实可用。`integrations/sms.py` 走**自定义验证码模式**(本服务 `secrets` 生成 6 位码 + 进程内存存储 + 极光 `/v1/messages` 只负责发 + 本地校验一次性/防爆破);鉴权**复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`**(同一极光应用,**上线只需 `SMS_MOCK=false`**,无需额外凭证;`sign_id=31729`/`temp_id=1` 已审核就绪)。防刷四层(单号冷却 + 单号每日上限 + 单 IP `rate_limit` + 单码失败次数上限);`SmsError.status_code` 让发送失败按类型返回 429/503/400。**mock 保留"任意6位通过"**(不动其他 11 个测试文件的 `_login` helper);`tests/test_auth.py` 加 real 模式单测(极光协议 / 一次性 / 防爆破 / 余额回滚)。文档 [integrations/sms.md](../integrations/sms.md)。**遗留**:验证码进程内存,多 worker 需迁 DB/Redis(下方技术债)。 - ✅ **比价记录落库(server + client,2026-05-31)**:每次比价 done 后客户端上报、按 user 落库,作「我的比价记录」数据源 + 用户级画像沉淀。 - **server**(本仓):新表 `comparison_record`(独立于 `savings_record`;结构化列 + `items`/`comparison_results`/`skipped_dish_names`/`raw_payload` 四个 JSON(B) 列)+ 3 个**鉴权**端点 `POST /api/v1/compare/record`(`(user_id,trace_id)` 幂等 upsert,best/saved/is_source_best/status 服务端从 comparison_results 派生)/ `GET /api/v1/compare/records`(游标分页)/ `GET /records/{id}`(含 raw_payload);`models/comparison.py` + `repositories/comparison.py` + `schemas/compare_record.py` + `api/v1/compare_record.py` + 迁移 `comparison_record_table`(head `b2c3d4e5f6a7`)+ `tests/test_compare_record.py`(8 例全过)+ `docs/api/compare-record-*.md`。 - **client**(android 仓):`PriceBotService.runTask()` 比价 done 后(`lastDoneParams!=null`,成功/引擎失败都报)用独立 IO 协程尽力上报;`Protocol.CompareRecordRequest.fromComparison()` 从 calibration+done.params 零翻译组装;`ApiClient.reportCompareRecord()` 走新建的 `authedClient`(复用 `AuthInterceptor`+`RefreshAuthenticator`,自动 Bearer+401 刷新)。领券不报(非价格对比,本期范围只外卖)。 @@ -99,7 +99,7 @@ - ✅ **无障碍身份统一傻瓜比价**:服务名/图标继承 app;`accessibility_service_description`/前台通知/`GuideCopy`/引导 Activity 里的 "PriceBot" 全改「傻瓜比价」;app 图标换成完整 `logo1`(adaptive 居中+黄边,不裁切)。 - ✅ **GuideOverlayService 组件名 bug**:批量 sed 改包名误把无障碍服务 ComponentName 的 packageName 改成 `…shaguabijia.agent`(应是 applicationId `…shaguabijia`)→ 无障碍开启检测永远 false;改用 `PermissionHelper.isAccessibilityEnabled` 动态构造。 - ✅ **BuildConfig 包名**:`com.pricebot.app.BuildConfig` import 改本 app 包名。 -- ✅ **SQLite → PostgreSQL 切换**(2026-05-29,PR #6 + alembic 合并迁移):新增 `scripts/init_postgres.py` 一键脚本、`psycopg[binary]>=3.1` 依赖、`savings_record.dishes` 列改 JSONB、连接池参数(`pool_size=10/overflow=20/recycle=3600`)。SQLite 仍可作开发兜底(只改 `DATABASE_URL`)。详见 [postgres-migration.md](./postgres-migration.md)。 +- ✅ **SQLite → PostgreSQL 切换**(2026-05-29,PR #6 + alembic 合并迁移):新增 `scripts/init_postgres.py` 一键脚本、`psycopg[binary]>=3.1` 依赖、`savings_record.dishes` 列改 JSONB、连接池参数(`pool_size=10/overflow=20/recycle=3600`)。SQLite 仍可作开发兜底(只改 `DATABASE_URL`)。详见 [postgres-migration.md](../database/postgres-migration.md)。 - ✅ **外卖比价透传 2 端点**(2026-05-27,`feat(compare)`):`/api/v1/intent/recognize` + `/api/v1/price/step` 在 `compare.py`,纯 body 透传到 pricebot-backend,`PRICEBOT_COMPARE_TIMEOUT_SEC=60` 独立超时。电商 2 个待接(`compare.py` 加两行即可)。 - ✅ **用户资料 + 帮助反馈接口**(2026-05-28,PR #4):`PATCH /api/v1/user/profile` 改昵称、`POST /api/v1/user/avatar` 上传头像(魔数嗅探)、`DELETE /api/v1/user` 注销账号(软删+匿名化)、`POST /api/v1/feedback` 提交反馈(含截图);新建 `feedback` 表 + `core/media.py` + `/media` 静态服务。 - ✅ **美团 3 端点未配凭证降级**(2026-05-28,`feat(meituan)`):未配 `MT_CPS_APP_KEY` 时 `coupons`/`feed`/`referral-link` 直接返空(不再 502),解决新开发机首屏炸的问题。**坏副作用**:`/feed` 跟"已配但调用失败"路径在响应上无法区分(都是 200 + 空 items),靠 `/health` 的 `mt_cps_configured` 字段区分。 diff --git a/docs/看广告赚金币上线清单.md b/docs/guides/看广告赚金币上线清单.md similarity index 82% rename from docs/看广告赚金币上线清单.md rename to docs/guides/看广告赚金币上线清单.md index 3f0ccd7..2ef65a4 100644 --- a/docs/看广告赚金币上线清单.md +++ b/docs/guides/看广告赚金币上线清单.md @@ -9,7 +9,7 @@ 客户端**不发奖**,`onRewardArrived` 只触发"去后端刷余额"。客户端被破解也刷不到钱。 - 后端发奖**幂等**(按 `trans_id` 去重)+ **每日上限**(`DAILY_AD_REWARD_LIMIT`,按北京时间)。 - 关键常量:`app/core/rewards.py` → `AD_REWARD_COIN=100`、`DAILY_AD_REWARD_LIMIT=500`、`VIDEO_ROUND_REQUIRED_COUNT=1`、`VIDEO_ROUND_COOLDOWN_SECONDS=3`(每次广告关闭后 3 秒短冷却)。旧的观看时长闸 `DAILY_AD_WATCH_SECONDS_LIMIT=0` 表示停用。 -- **4 态 CTA**(2026-05-29 PR #5):客户端任务行按钮在 Normal / Loading / Capped / CoolingDown 之间切,**全由后端 `reward-status` 的 `round_count` + `cooldown_until` 派生**(权威源),跨设备/重装/杀进程一致。详见 [api/ad-reward-status](./api/ad-reward-status.md)。 +- **4 态 CTA**(2026-05-29 PR #5):客户端任务行按钮在 Normal / Loading / Capped / CoolingDown 之间切,**全由后端 `reward-status` 的 `round_count` + `cooldown_until` 派生**(权威源),跨设备/重装/杀进程一致。详见 [api/ad-reward-status](../api/ad-reward-status.md)。 --- @@ -41,18 +41,31 @@ > 已确认走 **GroMore 广告位层级**回调(客户端 useMediation(true);规范 supportcenter/26240), > **不是**联盟代码位层级(5416)。验签算法 / 响应格式 / reward_amount 解析**代码已按 GroMore 规范实现** > (2026-05-27),剩下的是后台配置 + 填密钥。 +> +> 🔴 **2026-06-16 实测的头号阻塞:穿山甲打线上后端会超时**。真机看广告时 SDK 日志 +> `onRewardArrived valid=false errorCode=50002`,errorMsg = +> `Get "https://app-api.shaguabijia.com/api/v1/ad/pangle-callback...": context deadline exceeded`。 +> 而我们自己 curl 线上是秒回。说明**穿山甲机房到线上后端这条网络路径有问题**(疑似 nginx/云防火墙对 +> 机房/境外 IP 限流或拦截)。**m-key 配对了也救不了——穿山甲根本没把请求送达。** 上线前必须单独验 +> 「线上 `app-api.shaguabijia.com` 对穿山甲机房可达且响应够快」,否则用户看完发不了奖。 +> +> ✅ **多激励位已支持**(2026-06-16):内部测试期 测试位/专属位/正式位 并存,m-key 各配一行 +> (`PANGLE_REWARD_SECRET_TEST/_TEST_DEDICATED/_PROD`),`verify_callback_sign_any` 逐个试。本地已验通。 - [ ] **GroMore 后台配回调**:GroMore 聚合管理 → 搜广告位ID → 编辑 → 勾选「服务端激励回调」→ 回调 URL 填 `https://app-api.shaguabijia.com/api/v1/ad/pangle-callback`(用域名,别用 IP)。 ⚠️ 广告位层级配了就**别再在代码位层级重复配**(会导致发奖出问题)。 -- [ ] **拿 m-key(安全密钥)**:就在上面"编辑广告位"页获取,填到生产 `.env` 的 `PANGLE_REWARD_SECRET`。 +- [ ] **拿 m-key(安全密钥)**:就在上面"编辑广告位"页获取(每个激励位各一把),填到生产 `.env` 的命名变量 + (正式位填 `PANGLE_REWARD_SECRET_PROD`;内部测试期 测试位/专属位 也并存就填 `_TEST`/`_TEST_DEDICATED`)。 (注:这是 GroMore 广告位的 m-key,**不是**联盟代码位那个 Security Key。) - [x] ~~换验签~~:`app/integrations/pangle.py` 已实现 `sign = SHA256("{m-key}:{trans_id}")`(GroMore 真实算法)。 - [x] ~~响应格式~~:已返回 GroMore 要求的 `{"is_verify": bool, "reason": int}`。 - [x] ~~reward_amount~~:回调按 `reward_amount` 发金币(`rewards.resolve_ad_reward_coin`,带回退/夹紧); **后台广告位"奖励数量"须配成与 `AD_REWARD_COIN`(=100)一致**,保证"广告内展示/进度预告/到账"三者一致。 - [x] ~~透传 user_id~~:客户端已 `setUserID(userId)` + `setMediaExtra("uid:...")`。 -- [ ] 生产 `.env`:`PANGLE_CALLBACK_ENABLED=true` + `PANGLE_REWARD_SECRET=`(配齐才不返 503) +- [ ] 生产 `.env`:`PANGLE_CALLBACK_ENABLED=true` + 至少一个 m-key 命名变量(配齐才不返 503)+ 确认 + `AD_REWARD_TEST_GRANT_ENABLED=false`(绕过反作弊,生产红线)。改 `.env` 后**重启 uvicorn** + (watchfiles 不监听 .env)。验证:外部 curl 回调 URL 从 `503` 变 `403 bad sign` 即生效。 ## C. 部署 + 包名 diff --git a/docs/邀请功能-实现原理与本地测试.md b/docs/guides/邀请功能-实现原理与本地测试.md similarity index 100% rename from docs/邀请功能-实现原理与本地测试.md rename to docs/guides/邀请功能-实现原理与本地测试.md diff --git a/docs/integrations/meituan.md b/docs/integrations/meituan.md index e3259bf..ed4a05b 100644 --- a/docs/integrations/meituan.md +++ b/docs/integrations/meituan.md @@ -47,4 +47,4 @@ api 层 3 个端点都在入口处 `if not settings.mt_cps_configured:` 早返 ## 备注 - 美团接口当前**无鉴权**,且换链的 `sid` 允许客户端传值覆盖默认渠道——见对应 api 文档「备注」 -- 未配凭证降级、feed 静默吞错都在 [待办与技术债.md](../待办与技术债.md) 已记账 +- 未配凭证降级、feed 静默吞错都在 [待办与技术债.md](../guides/待办与技术债.md) 已记账 diff --git a/docs/integrations/pangle.md b/docs/integrations/pangle.md index 72837fd..7c6d825 100644 --- a/docs/integrations/pangle.md +++ b/docs/integrations/pangle.md @@ -16,12 +16,18 @@ | 函数 | 说明 | |---|---| | `build_sign(trans_id, secret) -> str` | 计算 `SHA256("{secret}:{trans_id}")` hex。自验签测试 / 模拟回调脚本共用,保证两端一致 | -| `verify_callback_sign(params, secret) -> bool` | 校验回调签名。密钥空 / 缺 `trans_id` 一律失败;比较用 `hmac.compare_digest` 定长比较防时序侧信道 | +| `verify_callback_sign(params, secret) -> bool` | 用**单个** m-key 校验回调签名。密钥空 / 缺 `trans_id` 一律失败;比较用 `hmac.compare_digest` 定长比较防时序侧信道 | +| `verify_callback_sign_any(params, secrets) -> bool` | 用**一组** m-key 逐个验签、任一通过即接受(多激励位共用同一回调 URL 时用)。空列表 → 失败 | + +## 配置(多激励位 = 多 m-key) +每个 GroMore 激励位的 m-key 由后台各自生成、互不相同,但本服务用**同一个回调 URL** 接所有位的回调,因此把用到的位的 m-key 都配上;验签时 `verify_callback_sign_any` 逐个试、任一过即接受(仍安全:伪造者须知道其中某个 m-key 才能造出合法 sign)。m-key 在后台「GroMore 聚合管理 → 搜广告位 ID → 编辑」处获取。`settings.pangle_reward_secrets` 把下列来源汇总去重: -## 配置 | 配置项 | 说明 | |---|---| -| `PANGLE_REWARD_SECRET` | m-key(安全密钥)。后台「GroMore 聚合管理 → 搜广告位 ID → 编辑」处获取 | +| `PANGLE_REWARD_SECRET_TEST` | 测试应用 激励位 104099649 的 m-key | +| `PANGLE_REWARD_SECRET_TEST_DEDICATED` | 测试应用 专属激励位 104127529 的 m-key | +| `PANGLE_REWARD_SECRET_PROD` | 正式应用 激励位 104099389 的 m-key | +| `PANGLE_REWARD_SECRET` | 旧用法:单个或逗号分隔多个 m-key,仍兼容(与上面三个命名项合并去重) | ## 踩坑 - **别用联盟代码位那套**:联盟代码位层级用的是另一套 Security Key + `isValid` 响应体,与 GroMore 广告位层级不通用。我们走 GroMore,别接错。 diff --git a/docs/integrations/sms.md b/docs/integrations/sms.md index 6e0b15b..7086643 100644 --- a/docs/integrations/sms.md +++ b/docs/integrations/sms.md @@ -56,4 +56,4 @@ 3. 真机发一条验证:收到短信 + 能登录 ## 已知局限 -**验证码存进程内存**:单 worker uvicorn 够用;重启丢码(用户重发即可);**多 worker / 多机不共享 → 冷却 / 每日上限 / 校验失效**,扩 worker 前迁移到 DB/Redis。见 [待办与技术债](../待办与技术债.md)。 +**验证码存进程内存**:单 worker uvicorn 够用;重启丢码(用户重发即可);**多 worker / 多机不共享 → 冷却 / 每日上限 / 校验失效**,扩 worker 前迁移到 DB/Redis。见 [待办与技术债](../guides/待办与技术债.md)。 diff --git a/docs/后端技术实现.md b/docs/后端技术实现.md index 9d6eeff..8828ed7 100644 --- a/docs/后端技术实现.md +++ b/docs/后端技术实现.md @@ -36,7 +36,7 @@ | ASGI | uvicorn[standard] | 生产 `--host 127.0.0.1 --port 8770` | | ORM | SQLAlchemy 2.0(Mapped 风格) | | | 迁移 | Alembic | | -| DB | **PostgreSQL 16(生产)/ SQLite(开发兜底)** | 一键脚本 `scripts/init_postgres.py` 建库 + 写 `.env` + 跑迁移;开发环境改 `DATABASE_URL=sqlite:///./data/app.db` 即回退。无 Redis。详见 [docs/postgres-migration.md](./postgres-migration.md) | +| DB | **PostgreSQL 16(生产)/ SQLite(开发兜底)** | 一键脚本 `scripts/init_postgres.py` 建库 + 写 `.env` + 跑迁移;开发环境改 `DATABASE_URL=sqlite:///./data/app.db` 即回退。无 Redis。详见 [docs/database/postgres-migration.md](./database/postgres-migration.md) | | DB 驱动 | psycopg3(`psycopg[binary]>=3.1`) | `DATABASE_URL=postgresql+psycopg://...`,**不要装 psycopg2**——SQLAlchemy 2.0 时代默认 psycopg3 | | Auth | PyJWT(HS256) | access + refresh | | 极光 | httpx + cryptography | REST 验 token + RSA 解密手机号 | @@ -125,8 +125,8 @@ tests/ # pytest(auth / health / welfare / withdraw / ad_re run.sh # 本地启动脚本(自动先跑迁移再起服务) docs/api/ # API 接口文档(索引 README + 一接口一文件) docs/integrations/ # 集成层实现文档(SDK 签名/加解密/协议细节) -docs/数据库迁移.md # Alembic 迁移指南(如何建表/升级/新增迁移) -docs/postgres-migration.md # SQLite → PostgreSQL 切换指南(配套 scripts/init_postgres.py) +docs/database/数据库迁移.md # Alembic 迁移指南(如何建表/升级/新增迁移) +docs/database/postgres-migration.md # SQLite → PostgreSQL 切换指南(配套 scripts/init_postgres.py) ``` > **命名说明**:`api/v1/` 的 `v1` 用于 URL 版本化(移动端无法强制即时升级,需新旧版本并存能力);`integrations` 装外部 SDK 集成、`repositories` 装数据访问、`core` 装基础设施,三者分离。**数据访问层统一在 `repositories/`**(早期叫 `crud/`,2026-05 已整体并入,`crud/` 不再存在)。`coupon.py` 是领券透传,勿与 `meituan.py` 里的 `coupons`(券列表)混淆。 @@ -255,7 +255,7 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert `upsert_user_for_login`:phone 存在则更新 `last_login_at`,不存在则注册(注册即登录)。 -**Alembic 迁移**:`alembic/versions/` 当前 11 个迁移 + 1 个合并迁移(`f01db5d77dac_merge_pg_jsonb_and_feedback_heads.py`,2026-05-29 创建——实习生 PG 分支和 user-profile-feedback 分支并行开发时 down_revision 都挂在 `c8d9e0f1a2b3` 上,导致多 head,合并迁移把两条链合并)。详见 [数据库迁移.md](./数据库迁移.md)。 +**Alembic 迁移**:`alembic/versions/` 当前 11 个迁移 + 1 个合并迁移(`f01db5d77dac_merge_pg_jsonb_and_feedback_heads.py`,2026-05-29 创建——实习生 PG 分支和 user-profile-feedback 分支并行开发时 down_revision 都挂在 `c8d9e0f1a2b3` 上,导致多 head,合并迁移把两条链合并)。详见 [数据库迁移.md](./database/数据库迁移.md)。 --- @@ -277,7 +277,7 @@ scp secrets/jverify_rsa_private.pem server:/opt/shaguabijia-app-server/secrets/ ssh server "cd /opt/shaguabijia-app-server && .venv/bin/alembic upgrade head && systemctl restart shaguabijia-app-server" ``` -完整 PG 切换流程见 [docs/postgres-migration.md](./postgres-migration.md)。 +完整 PG 切换流程见 [docs/database/postgres-migration.md](./database/postgres-migration.md)。 **生产 checklist(均为上线必查)**: @@ -316,11 +316,11 @@ conda activate price # 首次:pip install -e . | 美团接口无鉴权 + sid 可覆盖 | 评估加鉴权/锁定 sid(注意首页要求未登录可见) | | 美团接口未配凭证降级 | 未配 `MT_CPS_APP_KEY` 时 3 端点返空(不报 502),`/feed` 跟"已配但调用失败"路径无法区分——见 [integrations/meituan](./integrations/meituan.md) | | 领券/比价依赖 pricebot | `coupon/step` / `intent/recognize` / `price/step` 仅透传,真正逻辑在 pricebot-backend;前端已接通领券链路,比价 food MVP 也已接通 | -| agent 系列接口 MVP 不鉴权 | 拿不到 user_id → 无法采集"哪个用户领了/买了什么"用户级画像(商业模式核心资产)。见 [待办与技术债.md](./待办与技术债.md) P1 | +| agent 系列接口 MVP 不鉴权 | 拿不到 user_id → 无法采集"哪个用户领了/买了什么"用户级画像(商业模式核心资产)。见 [待办与技术债.md](./guides/待办与技术债.md) P1 | | SMS 已接极光(2026-06-03) | real 模式自定义验证码,上线只需 `SMS_MOCK=false`(复用极光凭证)。详见 [integrations/sms](./integrations/sms.md) | | 短信冷却存内存 | 扩 worker 前需迁移到 Redis | | `MEDIA_ROOT` 进程内 serve | 头像/反馈截图当前用 FastAPI StaticFiles,生产建议 nginx 直 serve 该目录 | -| `init_postgres.py` 已知小 bug | 5 条小坑,见 [待办与技术债.md](./待办与技术债.md) | +| `init_postgres.py` 已知小 bug | 5 条小坑,见 [待办与技术债.md](./guides/待办与技术债.md) | | Alembic 多 head 风险 | 跨分支并行开发要在 PR 合并前 `alembic heads` 检查只有一个;漏检会出现 "Multiple head revisions",需要 `alembic merge` 合并 | -> 完整接口协议见 [`docs/api/`](./api/)(38 个端点 + `/health` + `/media` 静态服务),以代码为准。 +> 完整接口协议见 [`docs/api/`](./api/)(70+ 接口,一接口一文件 + `/health` + `/media` 静态服务),以 `docs/api/README.md` 索引和代码为准。 diff --git a/tests/test_admin_config.py b/tests/test_admin_config.py index 4f51fbe..13c0e29 100644 --- a/tests/test_admin_config.py +++ b/tests/test_admin_config.py @@ -117,6 +117,40 @@ def test_update_ad_limit_takes_effect(admin_client: TestClient, token: str) -> N db.close() +def test_update_bool_config(admin_client: TestClient, token: str) -> None: + # 提现自动对账开关默认 True + items = { + i["key"]: i + for i in admin_client.get("/admin/api/config", headers=_auth(token)).json() + } + assert items["withdraw_auto_reconcile_enabled"]["type"] == "bool" + assert items["withdraw_auto_reconcile_enabled"]["value"] is True + + # 关掉 → DB 落 False、业务读到 False + r = admin_client.patch( + "/admin/api/config/withdraw_auto_reconcile_enabled", + json={"value": False}, + headers=_auth(token), + ) + assert r.status_code == 200, r.text + assert r.json()["value"] is False and r.json()["overridden"] is True + + db = SessionLocal() + try: + from app.repositories import app_config + + assert app_config.get_value(db, "withdraw_auto_reconcile_enabled") is False + finally: + db.close() + + # bool 项不接受非布尔值 + assert admin_client.patch( + "/admin/api/config/withdraw_auto_reconcile_enabled", + json={"value": 1}, + headers=_auth(token), + ).status_code == 400 + + def test_config_validation(admin_client: TestClient, token: str) -> None: # 签到档位长度≠7 assert admin_client.patch( diff --git a/tests/test_app_version.py b/tests/test_app_version.py new file mode 100644 index 0000000..53d2ebf --- /dev/null +++ b/tests/test_app_version.py @@ -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 diff --git a/tests/test_delete_account.py b/tests/test_delete_account.py new file mode 100644 index 0000000..464b02c --- /dev/null +++ b/tests/test_delete_account.py @@ -0,0 +1,141 @@ +"""注销账号(DELETE /api/v1/user):软删 + 释放唯一约束 + 资金前置校验。 + +覆盖: + - 干净注销:status=deleted + phone 匿名化 + wechat_openid/invite_code 等唯一槽全部释放 + - 回归原始 bug:注销释放 openid 后,别的账号能绑同一个微信 + - 资金前置闸:有可提现现金 / 在审提现单 → 409 拒绝注销,账号不被删 + - 注销后旧 token 即时失效(status==active 闸) +""" +from __future__ import annotations + +from sqlalchemy import select + +from app.db.session import SessionLocal +from app.models.user import User +from app.models.wallet import CoinAccount + + +def _login(client, phone: str) -> str: + client.post("/api/v1/auth/sms/send", json={"phone": phone}) + r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"}) + assert r.status_code == 200, r.text + return r.json()["access_token"] + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _patch_userinfo(monkeypatch, openid: str) -> None: + monkeypatch.setattr( + "app.integrations.wxpay.code_to_userinfo", + lambda code: {"openid": openid, "nickname": "微信昵称", "avatar_url": "https://x/a.png", "raw": {}}, + ) + + +def _seed_cash(client, token: str, phone: str, cents: int) -> None: + """先访问 /account 触发建账户,再用 DB 直接灌现金余额。""" + client.get("/api/v1/wallet/account", headers=_auth(token)) + db = SessionLocal() + try: + user = db.execute(select(User).where(User.phone == phone)).scalar_one() + acc = db.get(CoinAccount, user.id) + acc.cash_balance_cents = cents + db.commit() + finally: + db.close() + + +def _get_user(phone: str) -> User | None: + db = SessionLocal() + try: + return db.execute(select(User).where(User.phone == phone)).scalar_one_or_none() + finally: + db.close() + + +def test_delete_clean_account_releases_all_unique_slots(client) -> None: + phone = "13900000001" + token = _login(client, phone) + # 预置:给该用户造昵称 + 邀请码(占 invite_code 唯一槽),验证注销时一并释放 + db = SessionLocal() + try: + u = db.execute(select(User).where(User.phone == phone)).scalar_one() + u.nickname = "张三" + u.invite_code = "INVCODE0001" + db.commit() + uid = u.id + finally: + db.close() + + r = client.delete("/api/v1/user", headers=_auth(token)) + assert r.status_code == 200, r.text + + u = _get_user(f"deleted_{uid}") + assert u is not None + assert u.status == "deleted" + assert u.phone == f"deleted_{uid}" + assert u.nickname is None + assert u.avatar_url is None + assert u.wechat_openid is None + assert u.wechat_nickname is None + assert u.wechat_avatar_url is None + assert u.invite_code is None + + +def test_delete_releases_wechat_so_another_account_can_bind(client, monkeypatch) -> None: + """原始 bug 回归:A 绑微信 → 注销,B 必须能绑同一个 openid(修复前会 409)。""" + openid = "openid_delreuse_a1" # 独特值,避免跨测试共享库污染(如 test_withdraw 也用 openid_shared_xyz) + # A 绑微信 + _patch_userinfo(monkeypatch, openid) + token_a = _login(client, "13900000002") + r = client.post("/api/v1/wallet/bind-wechat", json={"code": "ca"}, headers=_auth(token_a)) + assert r.status_code == 200, r.text + # A 注销(无现金、无提现单 → 允许) + r = client.delete("/api/v1/user", headers=_auth(token_a)) + assert r.status_code == 200, r.text + # B 绑同一个 openid → 必须成功(修复前会 409 "该微信已绑定其他账号") + token_b = _login(client, "13900000003") + r = client.post("/api/v1/wallet/bind-wechat", json={"code": "cb"}, headers=_auth(token_b)) + assert r.status_code == 200, r.text + assert r.json()["bound"] is True + + +def test_delete_blocked_when_cash_balance_positive(client) -> None: + phone = "13900000004" + token = _login(client, phone) + _seed_cash(client, token, phone, 100) # 1 元未提现 + r = client.delete("/api/v1/user", headers=_auth(token)) + assert r.status_code == 409, r.text + assert "现金" in r.json()["detail"] + u = _get_user(phone) # 账号未被删 + assert u is not None and u.status == "active" + + +def test_delete_blocked_when_reviewing_withdraw_exists(client, monkeypatch) -> None: + """cash 提光(=0)但有在审提现单 → 仍拒绝(命中 reviewing 闸,非 cash 闸)。""" + phone = "13900000005" + _patch_userinfo(monkeypatch, "openid_delrev_b2") + token = _login(client, phone) + _seed_cash(client, token, phone, 50) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token)) + # 提光 50 → cash 归 0,单进 reviewing + r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token)) + assert r.status_code == 200 and r.json()["status"] == "reviewing", r.text + assert r.json()["cash_balance_cents"] == 0 + + r = client.delete("/api/v1/user", headers=_auth(token)) + assert r.status_code == 409, r.text + assert "审核" in r.json()["detail"] + u = _get_user(phone) + assert u is not None and u.status == "active" + + +def test_deleted_user_old_token_is_rejected(client) -> None: + phone = "13900000006" + token = _login(client, phone) + r = client.delete("/api/v1/user", headers=_auth(token)) + assert r.status_code == 200, r.text + # 注销后旧 token 立即失效(get_current_user 校验 status==active) + r = client.get("/api/v1/user/onboarding/status", params={"device_id": "d"}, headers=_auth(token)) + assert r.status_code == 401, r.text diff --git a/tests/test_platform.py b/tests/test_platform.py new file mode 100644 index 0000000..883a75f --- /dev/null +++ b/tests/test_platform.py @@ -0,0 +1,13 @@ +"""平台公开端点测试(不鉴权):/api/v1/platform/flags 等。""" +from __future__ import annotations + +from fastapi.testclient import TestClient + + +def test_flags_default_comparing_ad_enabled(client: TestClient) -> None: + """空配置库下,/flags 返回 comparing_ad_enabled 的默认值 True;不需要鉴权。""" + r = client.get("/api/v1/platform/flags") + assert r.status_code == 200, r.text + body = r.json() + assert "comparing_ad_enabled" in body + assert body["comparing_ad_enabled"] is True