diff --git a/.env.example b/.env.example index c9d1339..c3ba1d8 100644 --- a/.env.example +++ b/.env.example @@ -185,3 +185,19 @@ PANGLE_REPORT_SECURITY_KEY= # GroMore AppId(报表 site_id 维度)→ 应用环境;默认取现网两个应用,按需覆盖。 PANGLE_REPORT_SITE_ID_PROD=5830519 PANGLE_REPORT_SITE_ID_TEST=5832303 + +# ===== 可观测(OpenObserve 接口指标)===== +# 采集每个接口 QPS + 耗时 + 错误率,批量直采到 OpenObserve(本地 Docker,见 deploy/openobserve/)。 +# 默认关;开启需 ENABLED=true 且填 USER/PASSWORD(与 docker-compose 里 root 账号一致)。 +# 未开/缺凭证 → 中间件透传、worker 不启动,整套 no-op,不影响业务。 +OBSERVE_ENABLED=false +OBSERVE_ENDPOINT=http://localhost:5080 +OBSERVE_ORG=default +OBSERVE_STREAM=app_requests +OBSERVE_USER=admin@shaguabijia.local +OBSERVE_PASSWORD=Complexpass#123 +# 进阶(一般不用改):攒批间隔秒 / 单批最大条数 / 有界队列上限(满则丢) / 上报超时秒 +OBSERVE_FLUSH_INTERVAL_SEC=5 +OBSERVE_BATCH_MAX=200 +OBSERVE_QUEUE_MAX=10000 +OBSERVE_TIMEOUT_SEC=5 diff --git a/.gitignore b/.gitignore index 4c87d95..fa98e0e 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,12 @@ secrets/* # 运行日志(run.sh 输出, 不入库) *.log logs/ + +# Claude Code 自动持久化的权限 allowlist / 个人本地设置(会话专属,不入库)。 +# 需要团队共享的 Claude 配置(commands/ 等)可单独 git add -f,不受此忽略影响。 +.claude/settings.json +.claude/settings.local.json +tests/meituan_coupon_bj.tsv +tests/meituan_coupon_data.tsv +tests/meituan_coupon_fz.tsv +tests/meituan_coupon_xm.tsv diff --git a/alembic/versions/11c44afbea58_analytics_selfstat_tables.py b/alembic/versions/11c44afbea58_analytics_selfstat_tables.py new file mode 100644 index 0000000..1184a31 --- /dev/null +++ b/alembic/versions/11c44afbea58_analytics_selfstat_tables.py @@ -0,0 +1,72 @@ +"""analytics_selfstat tables + +Revision ID: 11c44afbea58 +Revises: admin_user_plain_password +Create Date: 2026-07-08 16:32:49.351817 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = '11c44afbea58' +down_revision: str | Sequence[str] | None = 'admin_user_plain_password' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + 'analytics_selfstat', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('device_id', sa.String(length=64), nullable=False), + sa.Column('epoch_id', sa.String(length=64), nullable=False), + sa.Column('app_ver', sa.String(length=32), nullable=True), + sa.Column('oem', sa.String(length=32), nullable=True), + sa.Column('os', sa.String(length=32), nullable=True), + sa.Column('batches_attempted', sa.BigInteger(), nullable=False, server_default='0'), + sa.Column('batches_ok', sa.BigInteger(), nullable=False, server_default='0'), + sa.Column('batches_fail', sa.BigInteger(), nullable=False, server_default='0'), + sa.Column('retries', sa.BigInteger(), nullable=False, server_default='0'), + sa.Column('queue_depth', sa.Integer(), nullable=False, server_default='0'), + sa.Column('sent_at', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), + server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id'), + ) + with op.batch_alter_table('analytics_selfstat', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_analytics_selfstat_created_at'), ['created_at'], unique=False) + batch_op.create_index(batch_op.f('ix_analytics_selfstat_device_id'), ['device_id'], unique=False) + batch_op.create_index(batch_op.f('ix_analytics_selfstat_epoch_id'), ['epoch_id'], unique=False) + + op.create_table( + 'analytics_selfstat_event', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('snapshot_id', sa.Integer(), nullable=False), + sa.Column('event', sa.String(length=64), nullable=False), + sa.Column('attempted', sa.BigInteger(), nullable=False, server_default='0'), + sa.Column('drop_capture', sa.BigInteger(), nullable=False, server_default='0'), + sa.Column('delivered', sa.BigInteger(), nullable=False, server_default='0'), + sa.Column('drop_undelivered', sa.BigInteger(), nullable=False, server_default='0'), + sa.ForeignKeyConstraint(['snapshot_id'], ['analytics_selfstat.id'], ), + sa.PrimaryKeyConstraint('id'), + ) + with op.batch_alter_table('analytics_selfstat_event', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_analytics_selfstat_event_event'), ['event'], unique=False) + batch_op.create_index(batch_op.f('ix_analytics_selfstat_event_snapshot_id'), ['snapshot_id'], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table('analytics_selfstat_event', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_analytics_selfstat_event_snapshot_id')) + batch_op.drop_index(batch_op.f('ix_analytics_selfstat_event_event')) + op.drop_table('analytics_selfstat_event') + with op.batch_alter_table('analytics_selfstat', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_analytics_selfstat_epoch_id')) + batch_op.drop_index(batch_op.f('ix_analytics_selfstat_device_id')) + batch_op.drop_index(batch_op.f('ix_analytics_selfstat_created_at')) + op.drop_table('analytics_selfstat') diff --git a/alembic/versions/135e79414fd0_add_inactivity_tables.py b/alembic/versions/135e79414fd0_add_inactivity_tables.py new file mode 100644 index 0000000..e522bae --- /dev/null +++ b/alembic/versions/135e79414fd0_add_inactivity_tables.py @@ -0,0 +1,68 @@ +"""add inactivity tables + +Revision ID: 135e79414fd0 +Revises: comparison_llm_cost +Create Date: 2026-07-16 18:31:02.105929 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '135e79414fd0' +down_revision: Union[str, Sequence[str], None] = 'comparison_llm_cost' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "inactivity_reset_log", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("coin_balance_before", sa.Integer(), nullable=False), + sa.Column("cash_balance_cents_before", sa.Integer(), nullable=False), + sa.Column("invite_cash_balance_cents_before", sa.Integer(), nullable=False), + sa.Column("last_active_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("inactive_days", sa.Integer(), nullable=False), + sa.Column("reason", sa.String(length=32), nullable=False), + sa.Column("reset_at", sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("inactivity_reset_log", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_inactivity_reset_log_user_id"), ["user_id"], unique=False) + batch_op.create_index(batch_op.f("ix_inactivity_reset_log_reset_at"), ["reset_at"], unique=False) + + op.create_table( + "inactivity_notification_log", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("stage", sa.Integer(), nullable=False), + sa.Column("inactive_days", sa.Integer(), nullable=False), + sa.Column("coin_balance", sa.Integer(), nullable=False), + sa.Column("cash_balance_cents", sa.Integer(), nullable=False), + sa.Column("invite_cash_balance_cents", sa.Integer(), nullable=False), + sa.Column("channel", sa.String(length=16), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("inactivity_notification_log", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_inactivity_notification_log_user_id"), ["user_id"], unique=False) + batch_op.create_index(batch_op.f("ix_inactivity_notification_log_created_at"), ["created_at"], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table("inactivity_notification_log", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_inactivity_notification_log_created_at")) + batch_op.drop_index(batch_op.f("ix_inactivity_notification_log_user_id")) + op.drop_table("inactivity_notification_log") + with op.batch_alter_table("inactivity_reset_log", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_inactivity_reset_log_reset_at")) + batch_op.drop_index(batch_op.f("ix_inactivity_reset_log_user_id")) + op.drop_table("inactivity_reset_log") diff --git a/alembic/versions/ad_ecpm_trace_id.py b/alembic/versions/ad_ecpm_trace_id.py new file mode 100644 index 0000000..9d2b5a2 --- /dev/null +++ b/alembic/versions/ad_ecpm_trace_id.py @@ -0,0 +1,44 @@ +"""ad_ecpm_record.trace_id(展示收益归属到比价/领券 trace) + +信息流(Draw)展示 eCPM 上报时带上本场比价/领券 trace_id,落此列;领券数据 / 比价记录看板 +按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端为 NULL。 + +本迁移原以 (11c44afbea58, merge_pages_override_coupon_slot) 为双亲、顺带收敛双 head, +但与它并行落 main 的 merge_selfstat_coupon_slot 已用同一对双亲做了纯收敛 → 同一对 +父节点出现两个收敛点、main 上又成双 head。故重挂到该 merge 之后成单链(仅改链接、 +schema 改动不变;两文件都保留,已 stamp 在 merge 上的库可直接线性升级)。 + +Revision ID: ad_ecpm_trace_id +Revises: merge_selfstat_coupon_slot +Create Date: 2026-07-10 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "ad_ecpm_trace_id" +down_revision: Union[str, Sequence[str], None] = "merge_selfstat_coupon_slot" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # SQLite 下 ADD COLUMN(可空)与 CREATE INDEX 均原生支持,无需 batch_alter_table + # (同 ad_feed_reward_trace_id 迁移)。 + op.add_column( + "ad_ecpm_record", + sa.Column("trace_id", sa.String(length=64), nullable=True), + ) + op.create_index( + op.f("ix_ad_ecpm_record_trace_id"), + "ad_ecpm_record", + ["trace_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_ad_ecpm_record_trace_id"), table_name="ad_ecpm_record") + op.drop_column("ad_ecpm_record", "trace_id") diff --git a/alembic/versions/admin_role_table.py b/alembic/versions/admin_role_table.py new file mode 100644 index 0000000..cc20ec2 --- /dev/null +++ b/alembic/versions/admin_role_table.py @@ -0,0 +1,69 @@ +"""admin_role 表(RBAC 角色 → 可见页面)+ 种子内建角色 + +新增后台 RBAC:角色持有一组页面 key,登录后左侧只展示这些页。super_admin 内建全权(pages 存空、 +effective_pages 特判为全部)、不可编辑删除;operator/finance 播默认页集,可由 super_admin 增删改。 +admin_user.role 弱引用本表 name。全新环境顺序应用即得三条内建角色。 + +Revision ID: admin_role_table +Revises: comparison_product_names +Create Date: 2026-07-04 00:00:00.000000 +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision: str = "admin_role_table" +down_revision: str | Sequence[str] | None = "comparison_product_names" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql") + +# 与 app/admin/permissions.py 的 BUILTIN_ROLES 同口径(迁移内联一份,不耦合 app 常量)。 +# 页集对齐 Prototypes/dashboard/permissions.md 的 ROLES。key 承重(require_role),label 为展示名。 +_BUILTIN = [ + {"name": "super_admin", "label": "管理员", "pages": [], "is_builtin": True}, + {"name": "operator", "label": "运营", "pages": [ + "dashboard", "coupon-data", "ad-revenue-report", "comparison-records", + "cps", "device-liveness", "price-reports", "feedbacks", + ], "is_builtin": False}, + {"name": "finance", "label": "财务", "pages": [ + "dashboard", "ad-revenue-report", "cps", "withdraws", + ], "is_builtin": False}, + {"name": "tech", "label": "技术", "pages": [ + "dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs", + ], "is_builtin": False}, +] + + +def upgrade() -> None: + op.create_table( + "admin_role", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("name", sa.String(length=32), nullable=False), + sa.Column("label", sa.String(length=32), nullable=False, server_default=""), + sa.Column("pages", _JSON, nullable=False), + sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column( + "created_at", sa.DateTime(timezone=True), + server_default=sa.func.now(), nullable=False, + ), + ) + op.create_index("ix_admin_role_name", "admin_role", ["name"], unique=True) + + tbl = sa.table( + "admin_role", + sa.column("name", sa.String), + sa.column("label", sa.String), + sa.column("pages", _JSON), + sa.column("is_builtin", sa.Boolean), + ) + op.bulk_insert(tbl, _BUILTIN) + + +def downgrade() -> None: + op.drop_index("ix_admin_role_name", table_name="admin_role") + op.drop_table("admin_role") diff --git a/alembic/versions/admin_user_pages_override.py b/alembic/versions/admin_user_pages_override.py new file mode 100644 index 0000000..ca49110 --- /dev/null +++ b/alembic/versions/admin_user_pages_override.py @@ -0,0 +1,35 @@ +"""admin_user 加 pages_override 列(「自定义」权限:按人存专属可见页 key 列表) + +权限管理页新增「自定义」角色:选它时该成员的可见页不跟随任何共享角色,而由逐页勾选决定, +存这个人专属的一份页 key 列表。仅 role == "custom" 时有效;普通角色为 None(可见页跟随角色)。 +PG 用 JSONB,SQLite 退化为通用 JSON(同 admin_audit_log.detail / comparison_record.raw_payload)。 + +Revision ID: admin_user_pages_override +Revises: admin_user_plain_password +Create Date: 2026-07-08 00:00:00.000000 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + +from alembic import op + +revision: str = "admin_user_pages_override" +down_revision: str | Sequence[str] | None = "admin_user_plain_password" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +# 与 app/models/admin.py 的 _JSON 一致:PG JSONB / 其它 JSON +_JSON = sa.JSON().with_variant(JSONB(), "postgresql") + + +def upgrade() -> None: + with op.batch_alter_table("admin_user", schema=None) as batch_op: + batch_op.add_column(sa.Column("pages_override", _JSON, nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("admin_user", schema=None) as batch_op: + batch_op.drop_column("pages_override") diff --git a/alembic/versions/admin_user_plain_password.py b/alembic/versions/admin_user_plain_password.py new file mode 100644 index 0000000..aea8806 --- /dev/null +++ b/alembic/versions/admin_user_plain_password.py @@ -0,0 +1,29 @@ +"""admin_user 加 plain_password 列(明文登录密码,仅后台 UI 建/重置的账号留存) + +权限管理页需能复看某成员已确定的登录密码转交本人。系统只存哈希无法反推,故对「后台 UI 创建/重置」 +的管理员额外留存一份明文;脚本/起后台建的超管为 None(前端不显示密码)。旧账号无此列值 → 同样不显示。 + +Revision ID: admin_user_plain_password +Revises: admin_role_table +Create Date: 2026-07-04 00:00:00.000000 +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "admin_user_plain_password" +down_revision: str | Sequence[str] | None = "admin_role_table" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.batch_alter_table("admin_user", schema=None) as batch_op: + batch_op.add_column(sa.Column("plain_password", sa.String(length=128), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("admin_user", schema=None) as batch_op: + batch_op.drop_column("plain_password") diff --git a/alembic/versions/analytics_active_idx_active_composite_index.py b/alembic/versions/analytics_active_idx_active_composite_index.py new file mode 100644 index 0000000..8cdaa5c --- /dev/null +++ b/alembic/versions/analytics_active_idx_active_composite_index.py @@ -0,0 +1,36 @@ +"""analytics_event 活跃口径复合索引 + +Revision ID: analytics_active_idx +Revises: 135e79414fd0 +Create Date: 2026-07-18 17:35:00.000000 + +给 analytics_event 加活跃口径热点复合索引 (event, page, user_id, created_at): +activity.active_event_condition 按 (event=show & page=home) ∪ 比价 ∪ 领券 过滤后 +group by user_id、max(created_at)。覆盖索引让该聚合走 index-only,避免高频 show 事件全表扫。 + +⚠️ 本分支迁移树有**既有多头**:135e79414fd0(不活跃两表)与 phone_rebind_log 同从 +comparison_llm_cost 分叉,`alembic upgrade head` 会多头报错。本迁移挂在 135e79414fd0 +一侧;集成到 main 时需 `alembic merge` 合并 phone_rebind_log 那个头(与本迁移无关的既有问题)。 +""" +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "analytics_active_idx" +down_revision: Union[str, Sequence[str], None] = "135e79414fd0" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_index( + "ix_analytics_event_active", + "analytics_event", + ["event", "page", "user_id", "created_at"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_analytics_event_active", table_name="analytics_event") diff --git a/alembic/versions/comparison_llm_cost.py b/alembic/versions/comparison_llm_cost.py new file mode 100644 index 0000000..ef0bfdd --- /dev/null +++ b/alembic/versions/comparison_llm_cost.py @@ -0,0 +1,33 @@ +"""comparison_record: llm_cost_yuan + llm_price_snapshot(比价 LLM 调用成本 + 当时单价快照) + +回填 llm_calls 时按「当时的价」逐模型算出本次比价 LLM 总成本(元),连同所用单价快照一起冻结到 +记录上;admin 比价记录详情展示实际成本(旧记录 NULL → 前端回退估算)。见 services/llm_cost.py。 + +Revision ID: comparison_llm_cost +Revises: ad_ecpm_trace_id +Create Date: 2026-07-13 +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision: str = "comparison_llm_cost" +down_revision: str | Sequence[str] | None = "ad_ecpm_trace_id" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_JSONB = sa.JSON().with_variant(postgresql.JSONB(), "postgresql") + + +def upgrade() -> None: + # 均可空、无索引;SQLite 原生支持 ADD COLUMN,无需 batch_alter_table(同 comparison_debug_fields)。 + op.add_column("comparison_record", sa.Column("llm_cost_yuan", sa.Float(), nullable=True)) + op.add_column("comparison_record", sa.Column("llm_price_snapshot", _JSONB, nullable=True)) + + +def downgrade() -> None: + op.drop_column("comparison_record", "llm_price_snapshot") + op.drop_column("comparison_record", "llm_cost_yuan") diff --git a/alembic/versions/comparison_product_names.py b/alembic/versions/comparison_product_names.py new file mode 100644 index 0000000..305a9aa --- /dev/null +++ b/alembic/versions/comparison_product_names.py @@ -0,0 +1,70 @@ +"""comparison_record 加 product_names 列(下单商品名派生串)+ 回填历史行 + +admin 比价记录页要把原「店/商品」一列拆成「店」+「商品」两列、并支持按商品名搜索。 +items 是 JSON(SQLite 下 json.dumps ensure_ascii 会把中文转义存成 \\uXXXX,无法直接 CAST+LIKE +命中中文),故把商品名派生成普通文本列,跨库 LIKE 一致、可索引。 + +写路径(upsert_record / harvest_done)已同步派生;本迁移建列并从已存 items 回填历史行。 +全新环境顺序应用即得空列 + 回填(表本为空,回填 no-op)。 + +Revision ID: comparison_product_names +Revises: comparison_record_trace_unique +Create Date: 2026-07-04 00:00:00.000000 +""" + +import json +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "comparison_product_names" +down_revision: str | Sequence[str] | None = "comparison_record_trace_unique" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _product_names(items) -> str | None: + """items([{name,...}]) → 顿号分隔的商品名串(去重保序)。与 repositories.comparison + ._product_names_from_items 同口径,迁移内联一份避免耦合 app 代码。""" + if isinstance(items, str): # SQLite: items 以 JSON 文本存,取回是 str + try: + items = json.loads(items) + except (ValueError, TypeError): + return None + if not isinstance(items, list) or not items: + return None + names: list[str] = [] + for it in items: + name = it.get("name") if isinstance(it, dict) else None + if not name: + continue + s = str(name).strip() + if s and s not in names: + names.append(s) + joined = "、".join(names) + return joined[:500] or None + + +def upgrade() -> None: + with op.batch_alter_table("comparison_record", schema=None) as batch_op: + batch_op.add_column(sa.Column("product_names", sa.String(length=512), nullable=True)) + + # 回填:从已存 items 派生商品名。items 小(菜名列表),一次取回即可;只更新有商品名的行。 + bind = op.get_bind() + rows = bind.execute( + sa.text("SELECT id, items FROM comparison_record") + ).fetchall() + for rid, items in rows: + pn = _product_names(items) + if pn: + bind.execute( + sa.text("UPDATE comparison_record SET product_names = :pn WHERE id = :id"), + {"pn": pn, "id": rid}, + ) + + +def downgrade() -> None: + with op.batch_alter_table("comparison_record", schema=None) as batch_op: + batch_op.drop_column("product_names") diff --git a/alembic/versions/comparison_record_trace_unique.py b/alembic/versions/comparison_record_trace_unique.py new file mode 100644 index 0000000..a662991 --- /dev/null +++ b/alembic/versions/comparison_record_trace_unique.py @@ -0,0 +1,49 @@ +"""comparison_record: 唯一键 (user_id,trace_id) → trace_id 单列 + user_id 可空 + +Revision ID: comparison_record_trace_unique +Revises: feedback_type_reply +Create Date: 2026-07-03 12:00:00.000000 + +比价记录改「后端 harvest」:app-server 在帧0(pricebot 出 trace_id)即建行,随 done/finalize +逐步补全,客户端不再 POST 记录。故本表要两处调整: + 1. user_id 改**可空**——harvest 建行那刻(软鉴权 / 老客户端匿名)可能还没有 user_id。 + 2. 唯一键 (user_id, trace_id) → **trace_id 单列**——trace_id 由 app-server 签发、全局唯一, + 一次比价一行;harvest 建行时 user_id 还没有,不能再用复合键去重。 + +⚠️ 上线前(QA)务必确认 comparison_record 无重复 trace_id:旧复合唯一键**允许**同 trace_id + 跨不同 user_id(实际上客户端 UUID 从不重复,但约束没拦),若真有重复,建 trace 单列唯一会失败。 + 查: SELECT trace_id, COUNT(*) c FROM comparison_record GROUP BY trace_id HAVING c > 1; + +SQLite 不支持直接 drop/alter 约束/列,用 batch_alter_table(建临时表+拷数据+换名), +与 coupon_engage_per_package / store_mapping_* 同款;Postgres 直接执行原生 ALTER。 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'comparison_record_trace_unique' +down_revision: Union[str, Sequence[str], None] = 'feedback_type_reply' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('comparison_record', schema=None) as batch_op: + # harvest 帧0 建行时 user_id 可能暂缺 → 放开非空。 + batch_op.alter_column('user_id', existing_type=sa.Integer(), nullable=True) + # 复合唯一 → trace_id 单列唯一。 + batch_op.drop_constraint('uq_comparison_user_trace', type_='unique') + batch_op.create_unique_constraint('uq_comparison_trace', ['trace_id']) + + +def downgrade() -> None: + with op.batch_alter_table('comparison_record', schema=None) as batch_op: + batch_op.drop_constraint('uq_comparison_trace', type_='unique') + batch_op.create_unique_constraint( + 'uq_comparison_user_trace', ['user_id', 'trace_id'] + ) + # 回退非空前提是当时无 null user_id 行(harvest 期可能有孤儿行,回滚需先清理)。 + batch_op.alter_column('user_id', existing_type=sa.Integer(), nullable=False) diff --git a/alembic/versions/coupon_claim_app_env.py b/alembic/versions/coupon_claim_app_env.py new file mode 100644 index 0000000..1b220c1 --- /dev/null +++ b/alembic/versions/coupon_claim_app_env.py @@ -0,0 +1,32 @@ +"""coupon_claim_record 加 app_env 列(领券所属 session 环境;每券成功率表按它过滤 prod/dev) + +Revision ID: coupon_claim_app_env +Revises: coupon_session_platform_success +Create Date: 2026-07-08 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "coupon_claim_app_env" +down_revision: str | Sequence[str] | None = "coupon_session_platform_success" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.batch_alter_table("coupon_claim_record", schema=None) as batch_op: + batch_op.add_column(sa.Column("app_env", sa.String(length=16), nullable=True)) + batch_op.create_index( + "ix_coupon_claim_record_app_env", ["app_env"], unique=False + ) + + +def downgrade() -> None: + with op.batch_alter_table("coupon_claim_record", schema=None) as batch_op: + batch_op.drop_index("ix_coupon_claim_record_app_env") + batch_op.drop_column("app_env") diff --git a/alembic/versions/coupon_session_platform_success.py b/alembic/versions/coupon_session_platform_success.py new file mode 100644 index 0000000..af14669 --- /dev/null +++ b/alembic/versions/coupon_session_platform_success.py @@ -0,0 +1,37 @@ +"""coupon_session 加 platform_success 列(本次至少领到一张的平台 id 列表) + +供 admin「领券数据」算 ②整单成功率 / ③点位成功率(平台粒度)。数据落点:服务端 /step 逐帧 +按 trace_id 并集写入(见 app/repositories/coupon_state.merge_session_platform_success)。旧行 NULL +视作空集,已建表环境靠它补列、全新环境顺序应用不重复加列。设计:docs/guides/领券成功率指标-设计与埋点.md。 + +Revision ID: coupon_session_platform_success +Revises: admin_user_plain_password +Create Date: 2026-07-07 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "coupon_session_platform_success" +down_revision: str | Sequence[str] | None = "admin_user_plain_password" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +# PG 用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 model 的 _JSON variant / 建表迁移)。 +_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql") + + +def upgrade() -> None: + with op.batch_alter_table("coupon_session", schema=None) as batch_op: + batch_op.add_column(sa.Column("platform_success", _JSON, nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("coupon_session", schema=None) as batch_op: + batch_op.drop_column("platform_success") diff --git a/alembic/versions/merge_active_phone_merge_inactivity_analytics_active_idx_.py b/alembic/versions/merge_active_phone_merge_inactivity_analytics_active_idx_.py new file mode 100644 index 0000000..081f9b4 --- /dev/null +++ b/alembic/versions/merge_active_phone_merge_inactivity_analytics_active_idx_.py @@ -0,0 +1,25 @@ +"""merge inactivity(analytics_active_idx) + phone_rebind_log heads + +Revision ID: merge_active_phone +Revises: analytics_active_idx, phone_rebind_log +Create Date: 2026-07-18 18:52:34.001148 + +""" +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = 'merge_active_phone' +down_revision: Union[str, Sequence[str], None] = ('analytics_active_idx', 'phone_rebind_log') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/merge_pages_override_coupon_slot.py b/alembic/versions/merge_pages_override_coupon_slot.py new file mode 100644 index 0000000..1fb6fc4 --- /dev/null +++ b/alembic/versions/merge_pages_override_coupon_slot.py @@ -0,0 +1,28 @@ +"""合并两个 alembic head:admin_user_pages_override(#126 权限)+ coupon_claim_app_env(领券成功率)。 + +两条迁移都从 admin_user_plain_password 分叉——#126 经 pull main 进入本分支,领券成功率为本分支新增—— +于是出现两个 head。本迁移仅把二者收敛成单 head,让 `alembic upgrade head`(单数,部署/run.sh 用) +恢复正常;**不含任何表结构 / 数据改动**(纯 merge)。 + +Revision ID: merge_pages_override_coupon_slot +Revises: admin_user_pages_override, coupon_claim_app_env +Create Date: 2026-07-09 00:00:00.000000 +""" + +from collections.abc import Sequence + +revision: str = "merge_pages_override_coupon_slot" +down_revision: str | Sequence[str] | None = ( + "admin_user_pages_override", + "coupon_claim_app_env", +) +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """纯合并 head,无 schema 改动。""" + + +def downgrade() -> None: + """拆回两个 head,无 schema 改动。""" diff --git a/alembic/versions/merge_selfstat_coupon_slot.py b/alembic/versions/merge_selfstat_coupon_slot.py new file mode 100644 index 0000000..fbebd37 --- /dev/null +++ b/alembic/versions/merge_selfstat_coupon_slot.py @@ -0,0 +1,29 @@ +"""合并两个 alembic head:11c44afbea58(#127 埋点健康度 selfstat)+ merge_pages_override_coupon_slot(#130 自带的合并迁移)。 + +三条分支都从 admin_user_plain_password 分叉(#126 权限 / #127 selfstat / #130 领券成功率)。 +#130 自带的 merge 创建时本地 main 尚无 #127 的 11c44afbea58,只收敛了 #126 + 自身两条, +#130 合入后 main 上仍留两个 head → `alembic upgrade head`(单数,部署/run.sh 用)直接报错、服务起不来。 +本迁移仅把二者收敛成单 head;**不含任何表结构 / 数据改动**(纯 merge)。 + +Revision ID: merge_selfstat_coupon_slot +Revises: 11c44afbea58, merge_pages_override_coupon_slot +Create Date: 2026-07-10 00:00:00.000000 +""" + +from collections.abc import Sequence + +revision: str = "merge_selfstat_coupon_slot" +down_revision: str | Sequence[str] | None = ( + "11c44afbea58", + "merge_pages_override_coupon_slot", +) +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """纯合并 head,无 schema 改动。""" + + +def downgrade() -> None: + """拆回两个 head,无 schema 改动。""" diff --git a/alembic/versions/phone_rebind_log.py b/alembic/versions/phone_rebind_log.py new file mode 100644 index 0000000..d6c0265 --- /dev/null +++ b/alembic/versions/phone_rebind_log.py @@ -0,0 +1,32 @@ +"""phone_rebind_log 表(M2 换绑 30 天限制台账) + +Revision ID: phone_rebind_log +Revises: comparison_llm_cost +""" +from alembic import op +import sqlalchemy as sa + +revision = "phone_rebind_log" +down_revision = "comparison_llm_cost" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "phone_rebind_log", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("phone", sa.String(length=20), nullable=False), + sa.Column("old_user_id", sa.Integer(), nullable=True), + sa.Column("new_user_id", sa.Integer(), nullable=False), + sa.Column("source", sa.String(length=32), nullable=False, server_default="wechat_conflict"), + sa.Column("rebound_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_phone_rebind_log_phone", "phone_rebind_log", ["phone"]) + op.create_index("ix_phone_rebind_log_rebound_at", "phone_rebind_log", ["rebound_at"]) + + +def downgrade() -> None: + op.drop_index("ix_phone_rebind_log_rebound_at", table_name="phone_rebind_log") + op.drop_index("ix_phone_rebind_log_phone", table_name="phone_rebind_log") + op.drop_table("phone_rebind_log") diff --git a/app/admin/main.py b/app/admin/main.py index ca3a374..7345803 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -26,12 +26,14 @@ from app.admin.routers.cps import router as cps_router from app.admin.routers.dashboard import router as dashboard_router from app.admin.routers.device_liveness import router as device_liveness_router from app.admin.routers.ops_stat_config import router as ops_stat_config_router +from app.admin.routers.analytics_health import router as analytics_health_router from app.admin.routers.event_logs import router as event_logs_router from app.admin.routers.feedback import router as feedback_router from app.admin.routers.feedback_qr import router as feedback_qr_router from app.admin.routers.onboarding import router as onboarding_router from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router from app.admin.routers.price_report import router as price_report_router +from app.admin.routers.roles import router as roles_router from app.admin.routers.users import router as users_router from app.admin.routers.wallet import router as wallet_router from app.admin.routers.withdraw import router as withdraw_router @@ -96,8 +98,10 @@ admin_app.include_router(withdraw_router) admin_app.include_router(price_report_router) admin_app.include_router(feedback_router) admin_app.include_router(event_logs_router) +admin_app.include_router(analytics_health_router) admin_app.include_router(feedback_qr_router) admin_app.include_router(admins_router) +admin_app.include_router(roles_router) admin_app.include_router(audit_router) admin_app.include_router(config_router) admin_app.include_router(comparison_router) diff --git a/app/admin/permissions.py b/app/admin/permissions.py new file mode 100644 index 0000000..ca0ee33 --- /dev/null +++ b/app/admin/permissions.py @@ -0,0 +1,83 @@ +"""admin 后台「页面权限目录」—— RBAC 的权限侧单一真源。 + +一个权限 = 一个页面(= 左侧导航项)。角色持有一组 page key,登录后左侧只展示这些页(见前端 +layout.tsx 按 pages 过滤导航)。key 必须与前端路由一级对齐(/dashboard → "dashboard")。 + +super_admin 为内建全权角色,恒可见全部页(effective_pages 特判)。新增页面 = 这里加一条 + +前端导航加对应项(key 一致即可被各角色勾选可见)。 +""" +from __future__ import annotations + +SUPER_ADMIN_ROLE = "super_admin" +# 「自定义」哨兵角色:不是 admin_role 表里的行,而是标记「这个人的可见页由 pages_override 决定」。 +# admin_user.role == CUSTOM_ROLE 时,有效可见页取 admin_user.pages_override(见 auth._admin_out_with_pages)。 +CUSTOM_ROLE = "custom" + +# 分组镜像前端导航(app/(main)/layout.tsx 的 NAV_GROUPS);key = 路由一级 +PERMISSION_CATALOG: list[dict] = [ + {"group": "看板", "pages": [ + {"key": "dashboard", "label": "数据大盘"}, + {"key": "coupon-data", "label": "领券数据"}, + {"key": "ad-revenue-report", "label": "广告收益"}, + {"key": "comparison-records", "label": "比价记录"}, + {"key": "cps", "label": "CPS收益"}, + {"key": "device-liveness", "label": "设备存活"}, + ]}, + {"group": "奖励审核", "pages": [ + {"key": "withdraws", "label": "提现审核"}, + {"key": "price-reports", "label": "低价审核"}, + {"key": "feedbacks", "label": "用户反馈"}, + ]}, + {"group": "数据配置", "pages": [ + {"key": "config", "label": "系统配置"}, + {"key": "ad-revenue", "label": "广告配置"}, + {"key": "users", "label": "用户管理"}, + ]}, + {"group": "其他", "pages": [ + {"key": "admins", "label": "权限管理"}, + {"key": "event-logs", "label": "埋点日志"}, + {"key": "audit-logs", "label": "审计日志"}, + ]}, +] + +# 全部页面 key(super_admin 有效可见 = 此全集;也用于校验角色 pages 合法性) +ALL_PAGE_KEYS: list[str] = [p["key"] for g in PERMISSION_CATALOG for p in g["pages"]] +_ALL_SET = set(ALL_PAGE_KEYS) + +# 内建角色定义(种子 / ensure 兜底的单一真源):(key, 中文展示名, 默认可见页)。 +# 页集对齐 Prototypes/dashboard/permissions.md 的 ROLES;super_admin 恒全权(pages 空、bypass)。 +# key 承重(require_role / admin_user.role),勿改;label 为 UI 展示名。 +BUILTIN_ROLES: list[dict] = [ + {"name": SUPER_ADMIN_ROLE, "label": "管理员", "pages": []}, + {"name": "operator", "label": "运营", "pages": [ + "dashboard", "coupon-data", "ad-revenue-report", "comparison-records", + "cps", "device-liveness", "price-reports", "feedbacks", + ]}, + {"name": "finance", "label": "财务", "pages": [ + "dashboard", "ad-revenue-report", "cps", "withdraws", + ]}, + {"name": "tech", "label": "技术", "pages": [ + "dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs", + ]}, +] + +# 内建 key → 中文展示名(展示 / 自愈用) +BUILTIN_LABELS: dict[str, str] = {r["name"]: r["label"] for r in BUILTIN_ROLES} + + +def sanitize_pages(pages: list[str] | None) -> list[str]: + """过滤掉不在目录里的 key(去重、保序),防脏数据/目录收缩后的悬空 key。""" + seen: set[str] = set() + out: list[str] = [] + for k in pages or []: + if k in _ALL_SET and k not in seen: + seen.add(k) + out.append(k) + return out + + +def effective_pages(role_name: str, role_pages: list[str] | None) -> list[str]: + """某角色的有效可见页:super_admin → 全部;其余 → 其 pages 与目录取交(自愈悬空 key)。""" + if role_name == SUPER_ADMIN_ROLE: + return list(ALL_PAGE_KEYS) + return sanitize_pages(role_pages) diff --git a/app/admin/repositories/ad_revenue.py b/app/admin/repositories/ad_revenue.py index 8dfcf2d..b1e2162 100644 --- a/app/admin/repositories/ad_revenue.py +++ b/app/admin/repositories/ad_revenue.py @@ -385,10 +385,33 @@ def ad_revenue_report( for k, v in type_map.items() } - # DAU:复用大盘「今日活跃」口径(stats.today_dau,last_login_at)。该口径只能算今日, - # 故仅当查询=今日单天时给值;历史 / 多天区间返回 None,前端显示「-」。 - is_today = date_from == date_to == rewards.cn_today().isoformat() - dau = admin_stats.today_dau(db) if is_today else None + # 分场景小计(按 feed_scene:展示条数 + 预估收益),同 type_stats 基于全量 events—— + # 供数据大盘「领券广告 / 比价广告」卡用。此前大盘是在分页 items 里按 feed_scene 现算, + # 2026-07-02 起信息流逐条展示行(唯一带收益 + 场景的行)不再进主表 items,现算恒为 0; + # 改为服务端在全量上聚合下发(也顺带不受 limit 分页截断影响)。feed_scene 为空(激励视频 / + # 旧数据)不计入任何场景桶。 + scene_map: dict[str, dict] = {} + for e in events: + sc = e.get("feed_scene") + if not sc: + continue + s = scene_map.get(sc) + if s is None: + s = {"impressions": 0, "revenue_yuan": 0.0} + scene_map[sc] = s + s["impressions"] += e["impressions"] + s["revenue_yuan"] += e["revenue_yuan"] + scene_stats = { + k: {"impressions": v["impressions"], "revenue_yuan": round(v["revenue_yuan"], 6)} + for k, v in scene_map.items() + } + + # DAU:复用数据大盘活跃用户口径(登录 + 开始比价 + 开始领券,按用户去重),按所选日期区间 + # 统计(含今日),历史 / 多天区间同样有值。ARPU = 区间预估收益 ÷ 区间活跃用户。全局口径, + # 不随 user / ad_type / feed_scene / app_env 筛选变化(活跃用户口径无这些维度)。 + dau = admin_stats.period_active_dau( + db, _date.fromisoformat(date_from), _date.fromisoformat(date_to) + ) # 主表「逐行」= 单次广告行为(2026-07 按「一次比价/领券放一块」聚合):激励视频 = 一次观看一行(展示+发奖 # 按 ad_session_id 合并);一次比价 / 一次领券 = 该次整场多条广告按 ad_session_id 聚成一行(展开看逐条)。 @@ -416,6 +439,7 @@ def ad_revenue_report( "daily": daily, "hourly": hourly, "type_stats": type_stats, + "scene_stats": scene_stats, "dau": dau, "items": main_rows[offset:offset + limit], } diff --git a/app/admin/repositories/admin_role.py b/app/admin/repositories/admin_role.py new file mode 100644 index 0000000..7eda933 --- /dev/null +++ b/app/admin/repositories/admin_role.py @@ -0,0 +1,81 @@ +"""admin_role 表 CRUD + 「角色 → 有效可见页」解析。""" +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.admin.permissions import ( + ALL_PAGE_KEYS, + BUILTIN_ROLES, + SUPER_ADMIN_ROLE, + sanitize_pages, +) +from app.models.admin_role import AdminRole + + +def ensure_builtin_roles(db: Session) -> None: + """空表时播种内建角色(管理员/运营/财务/技术)。幂等:表非空即 no-op。 + 给 create_all 环境(测试 / 未跑迁移的全新库)兜底,迁移已播种则不重复。 + 仅 super_admin 为 is_builtin(锁定不可改删);运营/财务/技术可编辑页/删除。""" + if db.execute(select(AdminRole.id).limit(1)).first() is not None: + return + db.add_all([ + AdminRole( + name=r["name"], label=r["label"], pages=list(r["pages"]), + is_builtin=(r["name"] == SUPER_ADMIN_ROLE), + ) + for r in BUILTIN_ROLES + ]) + db.commit() + + +def list_roles(db: Session) -> list[AdminRole]: + ensure_builtin_roles(db) + # super_admin(内建)恒排最前,其余按创建序 + return list( + db.execute( + select(AdminRole).order_by(AdminRole.is_builtin.desc(), AdminRole.id) + ).scalars().all() + ) + + +def get_role(db: Session, name: str) -> AdminRole | None: + return db.execute( + select(AdminRole).where(AdminRole.name == name) + ).scalar_one_or_none() + + +def create_role(db: Session, *, name: str, label: str, pages: list[str]) -> AdminRole: + # 自定义角色:name(key)= label = 用户输入的名称 + role = AdminRole(name=name, label=label, pages=sanitize_pages(pages), is_builtin=False) + db.add(role) + db.commit() + db.refresh(role) + return role + + +def update_role( + db: Session, role: AdminRole, *, label: str | None = None, pages: list[str] | None = None +) -> AdminRole: + # 只改展示名 label + 可见页;name(key)不可变(admin_user.role / require_role 承重) + if label is not None: + role.label = label + if pages is not None: + role.pages = sanitize_pages(pages) + db.commit() + db.refresh(role) + return role + + +def delete_role(db: Session, role: AdminRole) -> None: + db.delete(role) + db.commit() + + +def effective_pages_of(db: Session, role_name: str) -> list[str]: + """某角色名的有效可见页:super_admin → 全部;其余 → 查表 pages 与目录取交。""" + if role_name == SUPER_ADMIN_ROLE: + return list(ALL_PAGE_KEYS) + ensure_builtin_roles(db) + role = get_role(db, role_name) + return sanitize_pages(role.pages if role else None) diff --git a/app/admin/repositories/admin_user.py b/app/admin/repositories/admin_user.py index 7d2d331..d9055df 100644 --- a/app/admin/repositories/admin_user.py +++ b/app/admin/repositories/admin_user.py @@ -20,12 +20,23 @@ def get_by_username(db: Session, username: str) -> AdminUser | None: def create_admin( - db: Session, *, username: str, password: str, role: str = "operator" + db: Session, + *, + username: str, + password: str, + role: str = "operator", + plain_password: str | None = None, + pages_override: list[str] | None = None, ) -> AdminUser: + """建管理员。plain_password 非空则额外留存明文(后台 UI 建的账号传,供权限管理页复看); + 脚本/起后台建账号不传(留 None → 前端不显示密码)。 + pages_override:role=="custom" 时传专属可见页 key 列表;普通角色为 None。""" admin = AdminUser( username=username, password_hash=hash_password(password), role=role, + plain_password=plain_password, + pages_override=pages_override, ) db.add(admin) db.commit() diff --git a/app/admin/repositories/analytics_health.py b/app/admin/repositories/analytics_health.py new file mode 100644 index 0000000..6098963 --- /dev/null +++ b/app/admin/repositories/analytics_health.py @@ -0,0 +1,146 @@ +"""埋点健康度聚合(埋点成功率 / 上报成功率)。 + +只存原始累计快照,查询时在 Python 侧差分聚合(admin 低频、量级小,跨 PG/SQLite 无方言坑; +与 cps.py / coupon_data.py 同款约定)。差分按 (device_id, epoch_id, event) 分区、created_at +升序,相邻做差、负值夹 0;每增量按其快照 created_at 归入北京天桶。 +""" +from __future__ import annotations + +from collections import defaultdict +from datetime import UTC, datetime + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.core import rewards +from app.models.analytics_selfstat import AnalyticsSelfStat as H +from app.models.analytics_selfstat import AnalyticsSelfStatEvent as E + +_COUNTS = ("attempted", "drop_capture", "delivered", "drop_undelivered") + + +def diff_snapshots(rows: list[dict]) -> list[dict]: + """累计快照行 → 每快照增量行(纯逻辑)。 + + rows 每行含 device_id/epoch_id/event/created_at/app_ver/oem/os + 四个累计计数。 + 返回每行含 dims + created_at + 四个增量 d_*(分区首行增量=累计值;负值夹 0)。 + """ + parts: dict[tuple, list[dict]] = defaultdict(list) + for r in rows: + parts[(r["device_id"], r["epoch_id"], r["event"])].append(r) + + out: list[dict] = [] + for group in parts.values(): + group.sort(key=lambda r: (r["created_at"], r.get("id", 0))) + prev = {k: 0 for k in _COUNTS} + for r in group: + deltas = {f"d_{k}": max(0, int(r[k]) - prev[k]) for k in _COUNTS} + out.append({ + "device_id": r["device_id"], "epoch_id": r["epoch_id"], "event": r["event"], + "created_at": r["created_at"], "app_ver": r["app_ver"], + "oem": r["oem"], "os": r["os"], **deltas, + }) + prev = {k: int(r[k]) for k in _COUNTS} + return out + + +def _cn_day(dt: datetime) -> str: + """created_at(UTC 口径)→ 北京日期字符串 YYYY-MM-DD。naive 当 UTC,tz-aware 直接换算。""" + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + return dt.astimezone(rewards.CN_TZ).date().isoformat() + + +def _rates(sums: dict) -> dict: + """由四个增量和派生两段率(分母 0 → None)。""" + persisted_denom = sums["attempted"] + report_denom = sums["delivered"] + sums["drop_undelivered"] + return { + **sums, + "track_success_rate": ( + (sums["attempted"] - sums["drop_capture"]) / persisted_denom + if persisted_denom else None + ), + "report_success_rate": ( + sums["delivered"] / report_denom if report_denom else None + ), + } + + +def _sum_deltas(deltas: list[dict]) -> dict: + return {k: sum(d[f"d_{k}"] for d in deltas) for k in _COUNTS} + + +def _fetch_rows(db: Session, date_from: datetime, date_to: datetime) -> list[dict]: + """取 [from, to) 区间行 + 每分区在 from 左侧的最后一条基线行(供第一条区间增量做差)。""" + cols = ( + H.id, H.device_id, H.epoch_id, E.event, H.created_at, + H.app_ver, H.oem, H.os, + E.attempted, E.drop_capture, E.delivered, E.drop_undelivered, + ) + in_range = db.execute( + select(*cols).join(E, E.snapshot_id == H.id) + .where(H.created_at >= date_from, H.created_at < date_to) + ).mappings().all() + + # 注:基线子查询无下界扫 from 左侧全量(spec §7 已接受的取舍;量级变大再上物化 rollup)。 + # 用 max(id) 而非 max(created_at) 选"最新一条":id 严格单调,避免 SQLite 秒级时间戳撞车时选歧义。 + sub = ( + select(H.device_id, H.epoch_id, E.event, func.max(H.id).label("max_id")) + .join(E, E.snapshot_id == H.id) + .where(H.created_at < date_from) + .group_by(H.device_id, H.epoch_id, E.event) + .subquery() + ) + baseline = db.execute( + select(*cols).join(E, E.snapshot_id == H.id).join( + sub, sub.c.max_id == H.id + ) + ).mappings().all() + + return [dict(r) for r in list(baseline) + list(in_range)] + + +def _in_range_deltas(db: Session, date_from: datetime, date_to: datetime) -> list[dict]: + """差分后只保留 created_at ∈ [from, to) 的增量(基线行被差分用后丢弃)。 + + Python 侧过滤需对齐 tz 口径:SQLite 返回 naive UTC,PG 返回 aware UTC。 + 统一转成 naive UTC 再比较,兼容两种后端。 + """ + def _to_naive_utc(dt: datetime) -> datetime: + if dt.tzinfo is not None: + return dt.astimezone(UTC).replace(tzinfo=None) + return dt + + from_naive = _to_naive_utc(date_from) + to_naive = _to_naive_utc(date_to) + deltas = diff_snapshots(_fetch_rows(db, date_from, date_to)) + return [d for d in deltas if from_naive <= _to_naive_utc(d["created_at"]) < to_naive] + + +def overview(db: Session, date_from: datetime, date_to: datetime) -> dict: + deltas = _in_range_deltas(db, date_from, date_to) + return _rates(_sum_deltas(deltas)) + + +def trend(db: Session, date_from: datetime, date_to: datetime) -> list[dict]: + deltas = _in_range_deltas(db, date_from, date_to) + by_day: dict[str, list[dict]] = defaultdict(list) + for d in deltas: + by_day[_cn_day(d["created_at"])].append(d) + return [ + {"day": day, **_rates(_sum_deltas(items))} + for day, items in sorted(by_day.items()) + ] + + +def breakdown(db: Session, date_from: datetime, date_to: datetime, dim: str) -> list[dict]: + if dim not in ("event", "app_ver", "oem"): + raise ValueError(f"invalid dim: {dim!r}") + deltas = _in_range_deltas(db, date_from, date_to) + by_key: dict[str, list[dict]] = defaultdict(list) + for d in deltas: + by_key[d.get(dim) or "(unknown)"].append(d) + rows = [{"key": key, **_rates(_sum_deltas(items))} for key, items in by_key.items()] + rows.sort(key=lambda r: (r["report_success_rate"] is None, r["report_success_rate"] or 0.0)) + return rows diff --git a/app/admin/repositories/coupon_data.py b/app/admin/repositories/coupon_data.py index 05b8fbd..0450404 100644 --- a/app/admin/repositories/coupon_data.py +++ b/app/admin/repositories/coupon_data.py @@ -5,17 +5,21 @@ - 发起数 = 区间内全部 session(含 started/completed/failed/abandoned),= 流失统计的基数。 - 完成数 / 耗时均值 / 分位 = 仅 status==completed 子集(成功跑完才有可比的"领券耗时")。 - summary/daily/hourly/total 在全量上算,不受分页;items 为排序后当前页。 +- 另含 coupon_slot_report(数据源 coupon_claim_record):按 coupon_id「按券成功率」表,见设计 §13。 """ from __future__ import annotations -from datetime import UTC, date as _date, datetime +from datetime import UTC, datetime +from datetime import date as _date -from sqlalchemy import func, or_, select +from sqlalchemy import case, func, or_, select from sqlalchemy.orm import Session from app.core import rewards -from app.models.coupon_state import CouponSession +from app.models.coupon_state import CouponClaimRecord, CouponSession from app.models.user import User +from app.repositories import ad_ecpm as crud_ecpm +from app.repositories.coupon_state import DEFAULT_PLATFORMS, coupon_id_to_platform def _cn_hour(dt: datetime) -> int: @@ -42,7 +46,47 @@ def _avg(vals: list[int]) -> int | None: return round(sum(vals) / len(vals)) if vals else None -def _session_to_row(r, phone: str | None = None, nickname: str | None = None) -> dict: +def _success_rates(rows: list) -> dict: + """平台粒度成功率(见 docs/guides/领券成功率指标-设计与埋点.md §3/§12): + + - sel(s) = 勾选平台(`platforms` 空 → 全领三档 DEFAULT_PLATFORMS); + - succ(s) = `platform_success` ∩ sel(至少领到一张的平台); + - ② 整单成功率 = #{sel⊆succ 且 sel≠∅} / 发起数; + - ③ 点位成功率 = Σ|succ| / Σ|sel|;per_platform[p] = 勾了 p 且成功 / 勾了 p。 + 基数含全部 session(started/completed/failed/abandoned),与「发起数」同基数。 + """ + started = len(rows) + full_success = 0 + point_success = 0 + point_total = 0 + per_succ = {p: 0 for p in DEFAULT_PLATFORMS} + per_total = {p: 0 for p in DEFAULT_PLATFORMS} + for r in rows: + sel = set(r.platforms) if r.platforms else set(DEFAULT_PLATFORMS) + succ = set(r.platform_success or []) & sel + point_success += len(succ) + point_total += len(sel) + if sel and succ == sel: + full_success += 1 + for p in sel: + if p in per_total: # 只统计三档已知平台;未知/非法平台 id 不进 per_platform + per_total[p] += 1 + if p in succ: + per_succ[p] += 1 + return { + "full_success_count": full_success, + "full_success_rate": round(full_success / started, 4) if started else None, + "point_success_count": point_success, + "point_total_count": point_total, + "point_success_rate": round(point_success / point_total, 4) if point_total else None, + "per_platform": { + p: (round(per_succ[p] / per_total[p], 4) if per_total[p] else None) + for p in DEFAULT_PLATFORMS + }, + } + + +def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad_revenue_yuan: float = 0.0) -> dict: """CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。""" return { "id": r.id, @@ -61,6 +105,7 @@ def _session_to_row(r, phone: str | None = None, nickname: str | None = None) -> "started_at": r.started_at, "claimed_count": r.claimed_count, "trace_url": r.trace_url, + "ad_revenue_yuan": ad_revenue_yuan, } @@ -84,6 +129,7 @@ def coupon_data_report( date_to: str, user: str | None = None, app_env: str | None = None, + statuses: list[str] | None = None, granularity: str = "day", limit: int = 500, offset: int = 0, @@ -93,6 +139,8 @@ def coupon_data_report( - user:手机号/昵称模糊搜(匹配不到任何用户 → 空结果)。 - app_env:prod/dev 精确;None=全部。 + - statuses:领券状态多选(started/completed/failed/abandoned);None/空=全部。整个视图 + (汇总/成功率/趋势/明细)按选中状态算,与 app_env 同级过滤(方案 A)。 - sort:time=发起时刻倒序(默认) / elapsed=全程耗时倒序(None 末尾)。 """ by_hour = granularity == "hour" @@ -115,6 +163,8 @@ def coupon_data_report( ) if app_env is not None: stmt = stmt.where(CouponSession.app_env == app_env) + if statuses: + stmt = stmt.where(CouponSession.status.in_(statuses)) if user_ids is not None: stmt = stmt.where(CouponSession.user_id.in_(user_ids)) rows = list(db.execute(stmt).scalars()) @@ -131,6 +181,7 @@ def coupon_data_report( "p50_ms": _percentile(completed_elapsed, 50), "p95_ms": _percentile(completed_elapsed, 95), "p99_ms": _percentile(completed_elapsed, 99), + **_success_rates(rows), } # ── 按天趋势(柱=发起/完成数,线=平均耗时)── @@ -197,10 +248,11 @@ def coupon_data_report( select(User.id, User.phone, User.nickname).where(User.id.in_(uids)) ).all() } + rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in page]) items = [] for r in page: phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None) - items.append(_session_to_row(r, phone, nickname)) + items.append(_session_to_row(r, phone, nickname, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0))) return { "summary": summary, @@ -222,4 +274,57 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict: total = db.execute( select(func.count()).select_from(CouponSession).where(CouponSession.user_id == user_id) ).scalar_one() - return {"items": [_session_to_row(r) for r in rows], "total": int(total)} + rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows]) + return { + "items": [_session_to_row(r, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)) for r in rows], + "total": int(total), + } + + +_SLOT_OK = ("success", "already_claimed") +_SLOT_TRIED = ("success", "already_claimed", "failed") + + +def coupon_slot_report( + db: Session, *, date_from: str, date_to: str, app_env: str | None = None +) -> dict: + """按 coupon_id(具体券)聚合成功率(见 docs/guides/领券成功率指标-设计与埋点.md §13)。 + + 数据源 coupon_claim_record(粒度=设备-天,唯一键 device+coupon+day)。 + - 尝试 = status ∈ {success, already_claimed, failed}(skipped 排除); + - 成功 = status ∈ {success, already_claimed};成功率 = 成功/尝试; + - claim_date 区间 + app_env(None=全部)过滤;按 tried 倒序返回。 + """ + d_from = _date.fromisoformat(date_from) + d_to = _date.fromisoformat(date_to) + ok = case((CouponClaimRecord.status.in_(_SLOT_OK), 1), else_=0) + stmt = ( + select( + CouponClaimRecord.coupon_id, + func.max(CouponClaimRecord.coupon_name).label("coupon_name"), + func.count().label("tried"), + func.sum(ok).label("succeeded"), + ) + .where( + CouponClaimRecord.claim_date >= d_from, + CouponClaimRecord.claim_date <= d_to, + CouponClaimRecord.status.in_(_SLOT_TRIED), + ) + .group_by(CouponClaimRecord.coupon_id) + ) + if app_env is not None: + stmt = stmt.where(CouponClaimRecord.app_env == app_env) + items = [] + for coupon_id, coupon_name, tried, succeeded in db.execute(stmt).all(): + tried = int(tried or 0) + succeeded = int(succeeded or 0) + items.append({ + "coupon_id": coupon_id, + "coupon_name": coupon_name, + "platform": coupon_id_to_platform(coupon_id), + "tried": tried, + "succeeded": succeeded, + "success_rate": round(succeeded / tried, 4) if tried else None, + }) + items.sort(key=lambda x: (-x["tried"], x["coupon_id"])) + return {"items": items} diff --git a/app/admin/repositories/cps.py b/app/admin/repositories/cps.py index b678c38..419c62f 100644 --- a/app/admin/repositories/cps.py +++ b/app/admin/repositories/cps.py @@ -51,7 +51,27 @@ def _yuan_to_cents(v: object) -> int | None: def _ts_to_dt(ts: object) -> datetime | None: """秒级时间戳 → tz-aware UTC datetime(绝对时刻,前端按北京展示)。""" - if not ts: + if ts is None: + return None + if isinstance(ts, datetime): + return ts if ts.tzinfo else ts.replace(tzinfo=_BJ_TZ).astimezone(timezone.utc) + s = str(ts).strip() + if not s or s.lower() == "null": + return None + try: + seconds = float(Decimal(s)) + except (InvalidOperation, ValueError): + return None + if seconds == 0: + return None + # 美团文档是秒级时间戳,这里顺手兼容毫秒/微秒,避免上游格式变化导致时间再次落空。 + if abs(seconds) > 10_000_000_000_000: + seconds /= 1_000_000 + elif abs(seconds) > 10_000_000_000: + seconds /= 1_000 + try: + return datetime.fromtimestamp(seconds, tz=timezone.utc) + except (OverflowError, OSError, ValueError): return None @@ -83,10 +103,6 @@ def _pick(row: dict[str, Any], *keys: str) -> Any: if key in row and row[key] is not None: return row[key] return None - try: - return datetime.fromtimestamp(int(ts), tz=timezone.utc) - except (ValueError, OSError, TypeError): - return None # ───────────── 群 ───────────── diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index f0de886..c9e0b74 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -18,12 +18,20 @@ from app.models.ad_reward import AdRewardRecord from app.models.admin import AdminAuditLog from app.models.analytics_event import AnalyticsEvent from app.models.comparison import ComparisonRecord +from app.models.coupon_state import CouponPromptEngagement from app.models.device import DeviceLiveness from app.models.feedback import Feedback from app.models.onboarding import OnboardingCompletion from app.models.price_report import PriceReport from app.models.user import User -from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder +from app.models.wallet import ( + CashTransaction, + CoinAccount, + CoinTransaction, + InviteCashTransaction, + WithdrawOrder, +) +from app.repositories import activity, ad_ecpm # 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取") _NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct") @@ -76,6 +84,43 @@ def offset_paginate( return items, next_cursor, total +def _attach_last_active(db: Session, users: list[User]) -> None: + """给本页用户瞬态挂 last_active_at(非 DB 列,供 AdminUserListItem from_attributes 读)。 + + 口径同 [_last_active_expr];按本页 user_id 批量两次 GROUP BY,防 N+1。 + """ + uids = [u.id for u in users] + if not uids: + return + ev_map = dict( + db.execute( + select(AnalyticsEvent.user_id, func.max(AnalyticsEvent.created_at)) + .where( + AnalyticsEvent.user_id.in_(uids), + activity.active_event_condition(), + ) + .group_by(AnalyticsEvent.user_id) + ).all() + ) + eng_map = dict( + db.execute( + select(CouponPromptEngagement.user_id, func.max(CouponPromptEngagement.created_at)) + .where( + CouponPromptEngagement.user_id.in_(uids), + CouponPromptEngagement.engage_type == "claim_started", + ) + .group_by(CouponPromptEngagement.user_id) + ).all() + ) + for u in users: + candidates = [ + activity.norm_utc(u.created_at), # baseline 由 last_login_at 改为 created_at(登录不算活跃) + activity.norm_utc(ev_map.get(u.id)), + activity.norm_utc(eng_map.get(u.id)), + ] + u.last_active_at = max((c for c in candidates if c is not None), default=None) + + def list_users( db: Session, *, @@ -87,16 +132,30 @@ def list_users( created_to: datetime | None = None, last_login_from: datetime | None = None, last_login_to: datetime | None = None, + last_active_from: datetime | None = None, + last_active_to: datetime | None = None, sort_by: str = "id", sort_order: str = "desc", limit: int = 20, cursor: int | None = None, ) -> tuple[list[User], int | None, int]: - """用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录时间范围筛选, - 按 id·注册时间·最近登录排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一, + """用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录·最近活跃 + 时间范围筛选,按 id·注册时间·最近登录·最近活跃排序;每页附带计算列 last_active_at + (口径见 [_last_active_expr])。**offset 分页**(cursor=offset):任意列排序下游标语义统一, 代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。 日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。""" - stmt = select(User) + # 最近活跃 = max(注册时间, 最近行为事件, 最近领券发起)。baseline 由 last_login_at 改为 created_at + #(登录不代表在用 App;口径统一到 activity.py,含 home_view + 比价 + 领券,见 activity.ACTIVE_EVENTS)。 + # 未命中侧 coalesce 到 created_at(恒非空基线)。派生表 1:1,outerjoin 不放大行数。 + ev_agg, eng_agg = activity.last_active_subqueries(db) + last_active = activity.last_active_expr( + User.created_at, ev_agg, eng_agg, db.get_bind().dialect.name + ) + stmt = ( + select(User) + .outerjoin(ev_agg, ev_agg.c.user_id == User.id) + .outerjoin(eng_agg, eng_agg.c.user_id == User.id) + ) if phone: stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配 if register_channel: @@ -113,16 +172,25 @@ def list_users( stmt = stmt.where(User.last_login_at >= _as_utc(last_login_from)) if last_login_to is not None: stmt = stmt.where(User.last_login_at <= _as_utc(last_login_to)) + if last_active_from is not None: + stmt = stmt.where(last_active >= _as_utc(last_active_from)) + if last_active_to is not None: + stmt = stmt.where(last_active <= _as_utc(last_active_to)) sort_cols = { "id": User.id, "created_at": User.created_at, "last_login_at": User.last_login_at, + "last_active_at": last_active, } sort_col = sort_cols.get(sort_by, User.id) order_fn = asc if sort_order == "asc" else desc id_order = asc(User.id) if sort_order == "asc" else desc(User.id) - return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor) + items, next_cursor, total = offset_paginate( + db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor + ) + _attach_last_active(db, items) + return items, next_cursor, total def _attach_user_info(db: Session, records: list[ComparisonRecord | Feedback | PriceReport]) -> None: @@ -148,11 +216,14 @@ def list_comparison_records( phone: str | None = None, status: str | None = None, business_type: str | None = None, + store: str | None = None, + product: 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 瞬态挂记录上。""" + store(店名)/product(商品名)子串模糊匹配,offset 分页(创建时间倒序、id 兜底)。 + join User 取 phone/nickname 瞬态挂记录上。""" stmt = select(ComparisonRecord) if user_id is not None: stmt = stmt.where(ComparisonRecord.user_id == user_id) @@ -166,12 +237,22 @@ def list_comparison_records( stmt = stmt.where(ComparisonRecord.status == status) if business_type: stmt = stmt.where(ComparisonRecord.business_type == business_type) + if store: + stmt = stmt.where(ComparisonRecord.store_name.like(f"%{store}%")) + if product: + # 商品名搜 product_names 派生文本列(非 items JSON:SQLite 下 JSON 中文被转义无法直接 LIKE)。 + stmt = stmt.where(ComparisonRecord.product_names.like(f"%{product}%")) items, next_cursor, total = offset_paginate( db, stmt, (desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)), limit=limit, cursor=cursor, ) _attach_user_info(db, items) + # 「本次比价看广告的预估收益」:按本页 trace_id 一次性聚合(同 _attach_user_info 逐页范式)。 + # ad_revenue_yuan 非 ORM 列,仅瞬态挂实例上供 AdminComparisonListItem(from_attributes)读出。 + rev = ad_ecpm.revenue_yuan_by_trace(db, [it.trace_id for it in items]) + for it in items: + it.ad_revenue_yuan = rev.get(it.trace_id, 0.0) return items, next_cursor, total @@ -726,26 +807,19 @@ def withdraw_risk_flags( return flags, score -def withdraw_ledger_check(db: Session) -> dict: - cash_balance_total = int( - db.execute(select(func.coalesce(func.sum(CoinAccount.cash_balance_cents), 0))).scalar_one() - ) - cash_txn_total = int( - db.execute(select(func.coalesce(func.sum(CashTransaction.amount_cents), 0))).scalar_one() - ) +def _check_withdraw_ledger_side( + orders: list[WithdrawOrder], txns: list, *, withdraw_biz: str, refund_biz: str +) -> dict: + """对某一本账(普通现金 / 邀请奖励金)做提现单 ↔ 流水的交叉校验。 - orders = list(db.execute(select(WithdrawOrder)).scalars().all()) - cash_txns = list( - db.execute( - select(CashTransaction).where( - CashTransaction.biz_type.in_(("withdraw", "withdraw_refund")) - ) - ).scalars().all() - ) - withdraw_refs = {txn.ref_id for txn in cash_txns if txn.biz_type == "withdraw"} + orders 已按 source 过滤到本账;txns 是本账流水表里 withdraw_biz/refund_biz 两类流水。 + 规则:每单发起应有一条扣款流水(ref_id=out_bill_no);失败/拒绝单应有且仅一条退款流水; + 非退款终态不应出现退款流水。四个计数全为 0 即本账自洽。 + """ + withdraw_refs = {txn.ref_id for txn in txns if txn.biz_type == withdraw_biz} refund_counts: dict[str, int] = {} - for txn in cash_txns: - if txn.biz_type == "withdraw_refund" and txn.ref_id: + for txn in txns: + if txn.biz_type == refund_biz and txn.ref_id: refund_counts[txn.ref_id] = refund_counts.get(txn.ref_id, 0) + 1 missing_withdraw = 0 @@ -760,24 +834,95 @@ def withdraw_ledger_check(db: Session) -> dict: if has_refund and order.status not in {"failed", "rejected"}: refund_on_non_terminal += 1 - duplicate_refund = sum(1 for count in refund_counts.values() if count > 1) - diff = cash_balance_total - cash_txn_total + return { + "missing_withdraw": missing_withdraw, + "missing_refund": missing_refund, + "duplicate_refund": sum(1 for count in refund_counts.values() if count > 1), + "refund_on_non_terminal": refund_on_non_terminal, + } + + +def withdraw_ledger_check(db: Session) -> dict: + """现金账本校验:两本物理隔离的账各自对账(产品红线:coin_cash / invite_cash 不串)。 + + 普通现金:CoinAccount.cash_balance_cents ↔ cash_transaction(withdraw/withdraw_refund); + 邀请奖励金:CoinAccount.invite_cash_balance_cents ↔ invite_cash_transaction + (invite_withdraw/invite_withdraw_refund)。 + 提现单按 source 分流到对应账核对——邀请提现的流水写在 invite_cash_transaction 表, + 绝不能拿去和普通现金流水比(否则每笔邀请提现单都会被误报「缺扣款/缺退款流水」)。 + 分流口径与 create_withdraw 一致:仅 source==invite_cash 走邀请账,其余(含历史空值)归普通现金。 + """ + orders = list(db.execute(select(WithdrawOrder)).scalars().all()) + coin_orders = [o for o in orders if o.source != "invite_cash"] + invite_orders = [o for o in orders if o.source == "invite_cash"] + + # —— 普通现金账(coin_cash) —— + cash_balance_total = int( + db.execute(select(func.coalesce(func.sum(CoinAccount.cash_balance_cents), 0))).scalar_one() + ) + cash_txn_total = int( + db.execute(select(func.coalesce(func.sum(CashTransaction.amount_cents), 0))).scalar_one() + ) + cash_txns = list( + db.execute( + select(CashTransaction).where( + CashTransaction.biz_type.in_(("withdraw", "withdraw_refund")) + ) + ).scalars().all() + ) + coin = _check_withdraw_ledger_side( + coin_orders, cash_txns, withdraw_biz="withdraw", refund_biz="withdraw_refund" + ) + cash_diff = cash_balance_total - cash_txn_total + + # —— 邀请奖励金账(invite_cash,独立账户 + 独立流水表) —— + invite_balance_total = int( + db.execute( + select(func.coalesce(func.sum(CoinAccount.invite_cash_balance_cents), 0)) + ).scalar_one() + ) + invite_txn_total = int( + db.execute( + select(func.coalesce(func.sum(InviteCashTransaction.amount_cents), 0)) + ).scalar_one() + ) + invite_txns = list( + db.execute( + select(InviteCashTransaction).where( + InviteCashTransaction.biz_type.in_(("invite_withdraw", "invite_withdraw_refund")) + ) + ).scalars().all() + ) + invite = _check_withdraw_ledger_side( + invite_orders, invite_txns, + withdraw_biz="invite_withdraw", refund_biz="invite_withdraw_refund", + ) + invite_diff = invite_balance_total - invite_txn_total + ok = ( - diff == 0 - and missing_withdraw == 0 - and missing_refund == 0 - and duplicate_refund == 0 - and refund_on_non_terminal == 0 + cash_diff == 0 + and invite_diff == 0 + and all(v == 0 for v in coin.values()) + and all(v == 0 for v in invite.values()) ) return { "ok": ok, + # 普通现金账(coin_cash:金币兑换的现金) "cash_balance_total_cents": cash_balance_total, "cash_transaction_total_cents": cash_txn_total, - "balance_diff_cents": diff, - "missing_withdraw_txn_count": missing_withdraw, - "missing_refund_txn_count": missing_refund, - "duplicate_refund_txn_count": duplicate_refund, - "refund_txn_on_non_terminal_count": refund_on_non_terminal, + "balance_diff_cents": cash_diff, + "missing_withdraw_txn_count": coin["missing_withdraw"], + "missing_refund_txn_count": coin["missing_refund"], + "duplicate_refund_txn_count": coin["duplicate_refund"], + "refund_txn_on_non_terminal_count": coin["refund_on_non_terminal"], + # 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账) + "invite_cash_balance_total_cents": invite_balance_total, + "invite_cash_transaction_total_cents": invite_txn_total, + "invite_balance_diff_cents": invite_diff, + "invite_missing_withdraw_txn_count": invite["missing_withdraw"], + "invite_missing_refund_txn_count": invite["missing_refund"], + "invite_duplicate_refund_txn_count": invite["duplicate_refund"], + "invite_refund_txn_on_non_terminal_count": invite["refund_on_non_terminal"], } diff --git a/app/admin/repositories/stats.py b/app/admin/repositories/stats.py index 202a5ba..260c159 100644 --- a/app/admin/repositories/stats.py +++ b/app/admin/repositories/stats.py @@ -5,17 +5,23 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索 """ from __future__ import annotations +from collections import Counter from datetime import date, datetime, time, timedelta, timezone from decimal import Decimal, InvalidOperation -from sqlalchemy import func, select +from sqlalchemy import case, func, select from sqlalchemy.orm import Session +from app.admin.repositories.coupon_data import _percentile from app.models.ad_feed_reward import AdFeedRewardRecord from app.models.ad_reward import AdRewardRecord from app.models.analytics_event import AnalyticsEvent from app.models.comparison import ComparisonRecord -from app.models.coupon_state import CouponPromptEngagement +from app.models.coupon_state import ( + CouponClaimRecord, + CouponPromptEngagement, + CouponSession, +) from app.models.cps_order import CpsOrder from app.models.feedback import Feedback from app.models.savings import SavingsRecord @@ -24,11 +30,17 @@ from app.models.user import User from app.models.wallet import CoinTransaction, WithdrawOrder _BEIJING = timezone(timedelta(hours=8)) -COUPON_REWARD_BIZ_TYPES = ("reward_video", "ad_reward", "coupon", "coupon_reward") +REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward") +# 领券/比价奖励金币的真实来源是信息流广告发奖(ad_feed_reward_record,按 feed_scene 分场景); +# coin_transaction 里只有扁平的 feed_ad_reward、biz_type 不分 coupon/comparison,故这俩桶历史从未 +# 被写入,仅留作未来兜底,实际金额在下方按 feed_scene 汇总 ad_feed_reward_record 得出。reward_video/ +# ad_reward 是激励视频,单独成桶、不再混进领券奖励(历史误并会把激励视频金币双计进领券)。 +COUPON_REWARD_BIZ_TYPES = ("coupon", "coupon_reward") COMPARISON_REWARD_BIZ_TYPES = ("comparison", "compare_reward", "comparison_reward") EXCLUDED_REWARD_BIZ_TYPES = ("invite_inviter", "invite_invitee", "admin_grant") UNCLASSIFIED_FEED_BIZ_TYPES = ("feed_ad_reward",) REGULAR_TASK_EXCLUDED_BIZ_TYPES = ( + *REWARD_VIDEO_BIZ_TYPES, *COUPON_REWARD_BIZ_TYPES, *COMPARISON_REWARD_BIZ_TYPES, *EXCLUDED_REWARD_BIZ_TYPES, @@ -55,29 +67,10 @@ def _beijing_today_start_utc() -> datetime: def today_dau(db: Session) -> int: """今日活跃用户数(DAU):登录 + 开始比价 + 开始领券,按用户去重。 - 广告收益报表复用这个函数;历史窗口 DAU 由 dashboard_overview 的 period 口径另算。 + = period_active_dau(今天, 今天);历史 / 多天窗口用 period_active_dau 传区间(广告收益报表复用)。 """ today_bj = datetime.now(_BEIJING).date() - today_start = _beijing_today_start_utc() - tomorrow_start = today_start + timedelta(days=1) - login_user_ids = _id_set( - db, - select(User.id).where(User.last_login_at >= today_start, User.last_login_at < tomorrow_start), - ) - compare_start_user_ids = _event_user_ids( - db, (COMPARE_START_EVENT,), today_start, tomorrow_start - ) - coupon_event_user_ids = _event_user_ids( - db, (COUPON_START_EVENT,), today_start, tomorrow_start - ) - coupon_claim_user_ids = _id_set( - db, - select(CouponPromptEngagement.user_id).where( - CouponPromptEngagement.engage_date == today_bj, - CouponPromptEngagement.engage_type == "claim_started", - ), - ) - return len(login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids) + return period_active_dau(db, today_bj, today_bj) def _default_period_end() -> date: @@ -134,6 +127,56 @@ def _event_user_ids( ) +def _period_active_user_ids( + db: Session, + *, + start_utc: datetime, + end_utc: datetime, + period_from: date, + period_to: date, +) -> set[int]: + """区间去重活跃用户 id 集合:登录(last_login_at)+ 开始比价(real_compare_start)+ + 开始领券(real_coupon_start / claim_started)。 + + user / analytics_event 用 UTC aware 边界 [start_utc, end_utc);coupon_prompt_engagement + 的 engage_date 是北京自然日 date 列,用 [period_from, period_to] 闭区间。今日 / 历史 / 多天通用。 + """ + login_user_ids = _id_set( + db, + select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc), + ) + compare_start_user_ids = _event_user_ids(db, (COMPARE_START_EVENT,), start_utc, end_utc) + coupon_event_user_ids = _event_user_ids(db, (COUPON_START_EVENT,), start_utc, end_utc) + coupon_claim_user_ids = _id_set( + db, + select(CouponPromptEngagement.user_id).where( + CouponPromptEngagement.engage_date >= period_from, + CouponPromptEngagement.engage_date <= period_to, + CouponPromptEngagement.engage_type == "claim_started", + ), + ) + return login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids + + +def period_active_dau(db: Session, date_from: date, date_to: date) -> int: + """任意北京自然日区间 [date_from, date_to] 的去重活跃用户数。 + + 与数据大盘 period.users.active 同口径(登录 + 开始比价 + 开始领券);广告收益报表按所选 + 日期区间(含今日)复用,ARPU = 区间预估收益 ÷ 本数。全局口径,不随用户 / 类型 / 场景筛选变化。 + """ + period_from, period_to = _normalize_period(date_from, date_to) + start_utc, end_utc, _start_local, _end_local = _period_bounds(period_from, period_to) + return len( + _period_active_user_ids( + db, + start_utc=start_utc, + end_utc=end_utc, + period_from=period_from, + period_to=period_to, + ) + ) + + def _commission_rate_percent(raw: str | None) -> Decimal | None: """美团 commissionRate 原值: "300"=3%, "10"=0.1%;也兼容 "3%"。""" if raw is None: @@ -261,31 +304,19 @@ def dashboard_overview( period_new_user_ids = _user_id_set( select(User.id).where(User.created_at >= start_utc, User.created_at < end_utc) ) - login_user_ids = _user_id_set( - select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc) - ) - compare_start_user_ids = _event_user_ids( - db, (COMPARE_START_EVENT,), start_utc, end_utc - ) - coupon_event_user_ids = _event_user_ids( - db, (COUPON_START_EVENT,), start_utc, end_utc - ) - coupon_claim_user_ids = _user_id_set( - select(CouponPromptEngagement.user_id).where( - CouponPromptEngagement.engage_date >= period_from, - CouponPromptEngagement.engage_date <= period_to, - CouponPromptEngagement.engage_type == "claim_started", - ) - ) - period_active_user_ids = ( - login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids - ) - period_retained_new_user_ids = period_new_user_ids & period_active_user_ids - period_retention_rate = ( - round(len(period_retained_new_user_ids) / len(period_new_user_ids), 4) - if period_new_user_ids - else None + period_active_user_ids = _period_active_user_ids( + db, + start_utc=start_utc, + end_utc=end_utc, + period_from=period_from, + period_to=period_to, ) + # 留存口径(2026-07-05 产品改):次日留存——窗口内每天 D,取 **D-1 日(前日)新增**用户, + # 统计其 D 日活跃(登录/开始比价/开始领券)比例,逐日累加。默认窗口=昨日单天,即 + # 「前日新增用户的昨日留存」。原口径(窗口内新增∩窗口内活跃)在单日窗口下≈100% 无意义 + # (注册即登录,当天新增必然当天活跃)。逐日 cohort 在下方 trend 循环内顺带累计。 + retention_cohort_total = 0 + retention_retained_total = 0 trend_points: list[dict] = [] for cur_date in _date_range(period_from, period_to): day_start_utc, day_end_utc, day_start_local, day_end_local = _period_bounds( @@ -295,33 +326,26 @@ def dashboard_overview( ComparisonRecord.created_at >= day_start_local, ComparisonRecord.created_at < day_end_local, ) - daily_login_user_ids = _user_id_set( + daily_active_user_ids = _period_active_user_ids( + db, + start_utc=day_start_utc, + end_utc=day_end_utc, + period_from=cur_date, + period_to=cur_date, + ) + # 次日留存:cohort = 前一日(D-1)新增用户,留存 = 其中当日(D)活跃者(口径见上)。 + cohort_ids = _user_id_set( select(User.id).where( - User.last_login_at >= day_start_utc, - User.last_login_at < day_end_utc, - ) - ) - daily_compare_start_user_ids = _event_user_ids( - db, (COMPARE_START_EVENT,), day_start_utc, day_end_utc - ) - daily_coupon_event_user_ids = _event_user_ids( - db, (COUPON_START_EVENT,), day_start_utc, day_end_utc - ) - daily_coupon_claim_user_ids = _user_id_set( - select(CouponPromptEngagement.user_id).where( - CouponPromptEngagement.engage_date == cur_date, - CouponPromptEngagement.engage_type == "claim_started", + User.created_at >= day_start_utc - timedelta(days=1), + User.created_at < day_end_utc - timedelta(days=1), ) ) + retention_cohort_total += len(cohort_ids) + retention_retained_total += len(cohort_ids & daily_active_user_ids) trend_points.append( { "date": cur_date, - "active_users": len( - daily_login_user_ids - | daily_compare_start_user_ids - | daily_coupon_event_user_ids - | daily_coupon_claim_user_ids - ), + "active_users": len(daily_active_user_ids), "new_users": _count( User, User.created_at >= day_start_utc, @@ -330,6 +354,11 @@ def dashboard_overview( "comparisons": _count(ComparisonRecord, *daily_comparison_conds), } ) + period_retention_rate = ( + round(retention_retained_total / retention_cohort_total, 4) + if retention_cohort_total + else None + ) period_coin_conds = ( CoinTransaction.created_at >= start_local, @@ -339,7 +368,7 @@ def dashboard_overview( period_reward_video_coin_total = _sum( CoinTransaction.amount, *period_coin_conds, - CoinTransaction.biz_type.in_(("reward_video", "ad_reward")), + CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES), ) period_feed_ad_coin_total = _sum( CoinTransaction.amount, @@ -361,15 +390,29 @@ def dashboard_overview( *period_coin_conds, CoinTransaction.biz_type.like("task_%"), ) + # 领券/比价奖励金币 = biz_type 桶(历史空,兜底)+ 该场景信息流广告实发金币 + # (ad_feed_reward_record.feed_scene,granted;reward_date 是北京日期串,与 period 同自然日窗口)。 period_coupon_reward_coin_total = _sum( CoinTransaction.amount, *period_coin_conds, CoinTransaction.biz_type.in_(COUPON_REWARD_BIZ_TYPES), + ) + _sum( + AdFeedRewardRecord.coin, + AdFeedRewardRecord.status == "granted", + AdFeedRewardRecord.feed_scene == "coupon", + AdFeedRewardRecord.reward_date >= period_from.isoformat(), + AdFeedRewardRecord.reward_date <= period_to.isoformat(), ) period_comparison_reward_coin_total = _sum( CoinTransaction.amount, *period_coin_conds, CoinTransaction.biz_type.in_(COMPARISON_REWARD_BIZ_TYPES), + ) + _sum( + AdFeedRewardRecord.coin, + AdFeedRewardRecord.status == "granted", + AdFeedRewardRecord.feed_scene == "comparison", + AdFeedRewardRecord.reward_date >= period_from.isoformat(), + AdFeedRewardRecord.reward_date <= period_to.isoformat(), ) period_regular_task_coin_total = _sum( CoinTransaction.amount, @@ -413,6 +456,98 @@ def dashboard_overview( else None ) + # ===== 领券核心数据(2026-07-05 产品新增)===== + # 数据源:coupon_session(一次领券一行,started_date 北京自然日)+ coupon_claim_record + # (一券/点位一天一条终态,claim_date 北京自然日)。点位与 session 不按 trace_id 关联—— + # record_claims 更新路径不覆盖 trace_id(同设备同券同日重跑归第一次的 trace),按 + # (device_id, 自然日) 桶关联才可靠;同桶多次发起共享同一份点位终态。 + period_coupon_sessions = db.execute( + select( + CouponSession.device_id, + CouponSession.started_date, + CouponSession.status, + CouponSession.elapsed_ms, + ).where( + CouponSession.started_date >= period_from, + CouponSession.started_date <= period_to, + # 只统计正式环境,同「领券数据」页默认口径(防 debug 包调试数据串台; + # 命中 ix_coupon_session_date_env)。点位表无 app_env 列,但点位指标只经 + # 下方 prod session 触达的 (device, 日) 桶进入统计,随之收敛到 prod。 + CouponSession.app_env == "prod", + ) + ).all() + coupon_started = len(period_coupon_sessions) + coupon_completed_elapsed = sorted( + s.elapsed_ms + for s in period_coupon_sessions + if s.status == "completed" and s.elapsed_ms is not None + ) + # 点位桶:(device, 日) → (点位总数, 成功点位数)。成功口径与「我的」页累计领券一致 + # (sum_claimed_count,2026-06-15 产品定):success + already_claimed(已领过=持有券)都算成功。 + point_buckets: dict[tuple[str, date], tuple[int, int]] = { + (dev, d): (int(total), int(succ or 0)) + for dev, d, total, succ in db.execute( + select( + CouponClaimRecord.device_id, + CouponClaimRecord.claim_date, + func.count(), + func.sum( + case( + (CouponClaimRecord.status.in_(("success", "already_claimed")), 1), + else_=0, + ) + ), + ) + .where( + CouponClaimRecord.claim_date >= period_from, + # 上界放宽一天:跨零点场次(23:5x 发起)的点位 claim_date 落在发起日+1, + # 桶只经下方 session 触达的键参与计数,放宽不会引入无关数据。 + CouponClaimRecord.claim_date <= period_to + timedelta(days=1), + ) + .group_by(CouponClaimRecord.device_id, CouponClaimRecord.claim_date) + ).all() + } + # 全部领成功的次数:completed 且其 (device, 日) 桶内点位全部成功(桶为空不算)。 + coupon_all_success = 0 + completed_bucket_totals: list[int] = [] + session_bucket_keys: set[tuple[str, date]] = set() + for s in period_coupon_sessions: + key = (s.device_id, s.started_date) + if key not in point_buckets: + # 跨零点回退:发起日桶不存在(点位终态全部落在次日)时取 (device, 发起日+1)。 + # 仅在发起日桶完全缺失时回退,避免抢占该设备次日 session 自己的桶。 + next_key = (s.device_id, s.started_date + timedelta(days=1)) + if next_key in point_buckets: + key = next_key + bucket = point_buckets.get(key) + if bucket is not None: + session_bucket_keys.add(key) + if s.status != "completed" or bucket is None: + continue + total, succ = bucket + completed_bucket_totals.append(total) + if total > 0 and succ == total: + coupon_all_success += 1 + # 每次发起的应领点位数:取「完成过的领券」实际点位数的众数(done 帧会给所有点位终态, + # 完成场的点位数=当前配置的全量点位数;数据自校准,配置改点位数无需改代码)。本期无完成场 + # 时给不出,点位成功率置空。 + coupon_points_per_session = ( + Counter(completed_bucket_totals).most_common(1)[0][0] + if completed_bucket_totals + else None + ) + # 成功点位数:本期 session 触达过的 (device, 日) 桶内成功点位之和(桶级去重,同桶重试不重复计)。 + coupon_point_success = sum(point_buckets[k][1] for k in session_bucket_keys) + # 点位成功率 = 成功点位数 / (发起数 × 应领点位数):中途退出未跑到的点位不产生记录, + # 但发起数×点位数把它们计入分母 → 视为失败,符合产品口径;重试会拉低该率(分母按次数计)。 + coupon_point_success_rate = ( + round( + min(1.0, coupon_point_success / (coupon_started * coupon_points_per_session)), 4 + ) + if coupon_started and coupon_points_per_session + else None + ) + return { "users": { "total": _count(User), @@ -428,7 +563,7 @@ def dashboard_overview( "reward_video_coin_total": _sum( CoinTransaction.amount, CoinTransaction.amount > 0, - CoinTransaction.biz_type.in_(("reward_video", "ad_reward")), + CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES), ), "reward_video_watch_count": _count( AdRewardRecord, @@ -476,11 +611,13 @@ def dashboard_overview( "users": { "new": len(period_new_user_ids), "active": len(period_active_user_ids), - "retained_new_users": len(period_retained_new_user_ids), + "retained_new_users": retention_retained_total, + "retention_cohort": retention_cohort_total, "retention_rate": period_retention_rate, "retention_note": ( - "口径:登录(last_login_at)+开始比价(real_compare_start)+" - "开始领券(real_coupon_start/claim_started),按用户去重" + "次日留存:窗口内每天取前一日新增用户,统计其当日活跃" + "(登录/开始比价/开始领券,按用户去重)比例,逐日累加;" + "默认窗口=昨日,即前日新增用户的昨日留存" ), }, "comparison": { @@ -491,6 +628,17 @@ def dashboard_overview( "average_duration_ms": period_avg_duration_ms, "average_saved_cents": period_avg_saved_cents, }, + "coupon": { + "started": coupon_started, + "all_success": coupon_all_success, + "success_rate": ( + round(coupon_all_success / coupon_started, 4) if coupon_started else None + ), + "point_success": coupon_point_success, + "points_per_session": coupon_points_per_session, + "point_success_rate": coupon_point_success_rate, + "median_elapsed_ms": _percentile(coupon_completed_elapsed, 50), + }, "coins": { "granted_total": _sum(CoinTransaction.amount, *period_coin_conds), "reward_video_coin_total": period_reward_video_coin_total, diff --git a/app/admin/routers/ad_revenue.py b/app/admin/routers/ad_revenue.py index dc6129f..77685d4 100644 --- a/app/admin/routers/ad_revenue.py +++ b/app/admin/routers/ad_revenue.py @@ -91,6 +91,7 @@ def get_ad_revenue_report( daily=[AdRevenueDaily(**d) for d in result["daily"]], hourly=[AdRevenueHourly(**h) for h in result["hourly"]], type_stats={k: AdRevenueTypeStat(**v) for k, v in result["type_stats"].items()}, + scene_stats={k: AdRevenueTypeStat(**v) for k, v in result["scene_stats"].items()}, dau=result["dau"], total=result["total"], truncated=result["truncated"], diff --git a/app/admin/routers/admins.py b/app/admin/routers/admins.py index 9f7ac15..60d9511 100644 --- a/app/admin/routers/admins.py +++ b/app/admin/routers/admins.py @@ -5,6 +5,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request from app.admin.audit import write_audit from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_role +from app.admin.permissions import CUSTOM_ROLE, SUPER_ADMIN_ROLE, sanitize_pages +from app.admin.repositories import admin_role as role_repo from app.admin.repositories import admin_user as admin_repo from app.admin.schemas.admin import AdminCreateRequest, AdminUpdateRequest from app.admin.schemas.auth import AdminOut @@ -23,9 +25,31 @@ def _active_super_count(db: AdminDb) -> int: ) -@router.get("", response_model=list[AdminOut], summary="管理员列表") +def _validate_role(db: AdminDb, role: str) -> None: + """角色必须是 super_admin / custom(自定义) / admin_role 表里已存在的角色,否则 400。""" + if role in (SUPER_ADMIN_ROLE, CUSTOM_ROLE): + return + role_repo.ensure_builtin_roles(db) # 空表(测试/全新库)兜底播种,再校验 + if role_repo.get_role(db, role) is None: + raise HTTPException(status_code=400, detail=f"角色不存在: {role}") + + +def _clean_override(role: str, pages_override: list[str] | None) -> list[str] | None: + """按最终角色算出该存的 pages_override:custom → 勾选集(过滤非法 key,可空列表); + 非 custom → None(切回普通角色即清空自定义页)。""" + if role == CUSTOM_ROLE: + return sanitize_pages(pages_override) + return None + + +@router.get("", response_model=list[AdminOut], summary="管理员列表(含明文密码,super_admin 专属)") def list_admins(db: AdminDb) -> list[AdminOut]: - return [AdminOut.model_validate(a) for a in admin_repo.list_admins(db)] + out: list[AdminOut] = [] + for a in admin_repo.list_admins(db): + item = AdminOut.model_validate(a) + item.password = a.plain_password # 明文:仅本 super_admin 专属路由下发,供权限管理页复看 + out.append(item) + return out @router.post("", response_model=AdminOut, summary="创建管理员") @@ -34,12 +58,17 @@ def create_admin( ) -> AdminOut: if admin_repo.get_by_username(db, body.username) is not None: raise HTTPException(status_code=409, detail="用户名已存在") + _validate_role(db, body.role) + override = _clean_override(body.role, body.pages_override) new = admin_repo.create_admin( - db, username=body.username, password=body.password, role=body.role + db, username=body.username, password=body.password, role=body.role, + plain_password=body.password, # UI 建的账号留存明文,供权限管理页复看 + pages_override=override, ) write_audit( db, admin, action="admin.create", target_type="admin", target_id=new.id, - detail={"username": new.username, "role": new.role}, ip=get_client_ip(request), commit=True, + detail={"username": new.username, "role": new.role, "pages_override": override}, + ip=get_client_ip(request), commit=True, ) return AdminOut.model_validate(new) @@ -67,8 +96,12 @@ def update_admin( if demotes_super and _active_super_count(db) <= 1: raise HTTPException(status_code=400, detail="不能降级/禁用最后一个超级管理员") + if body.role is not None: + _validate_role(db, body.role) + changes: dict = {} - if body.role is not None and body.role != target.role: + role_changed = body.role is not None and body.role != target.role + if role_changed: changes["role"] = {"before": target.role, "after": body.role} target.role = body.role if body.status is not None and body.status != target.status: @@ -77,6 +110,18 @@ def update_admin( if body.password is not None: changes["password"] = "reset" target.password_hash = hash_password(body.password) + target.plain_password = body.password # 同步留存明文,权限管理页复看保持一致 + + # 自定义可见页:按「最终角色」(target.role,已应用完角色变更)决定该存什么。 + # - 切到/维持 custom 且传了 pages_override → 用勾选集;切到 custom 没传 → 清成空列表。 + # - 切回普通角色 → 清空 override(_clean_override 返回 None)。 + # - 角色没变、只传 pages_override(编辑现有 custom 用户的勾选)→ 也更新。 + if role_changed or body.pages_override is not None: + new_override = _clean_override(target.role, body.pages_override) + if new_override != target.pages_override: + changes["pages_override"] = {"before": target.pages_override, "after": new_override} + target.pages_override = new_override + if not changes: raise HTTPException(status_code=400, detail="无任何变更字段") db.commit() @@ -86,3 +131,27 @@ def update_admin( detail=changes, ip=get_client_ip(request), commit=True, ) return AdminOut.model_validate(target) + + +@router.delete("/{admin_id}", summary="删除管理员(带审计)") +def delete_admin(admin_id: int, request: Request, admin: CurrentAdmin, db: AdminDb) -> dict: + target = admin_repo.get_by_id(db, admin_id) + if target is None: + raise HTTPException(status_code=404, detail="管理员不存在") + if admin_id == admin.id: + raise HTTPException(status_code=400, detail="不能删除自己") + # 防自锁:删掉某个 active super_admin 前,确认后仍至少剩 1 个,否则进「零可用超管」死局。 + if ( + target.role == "super_admin" + and target.status == "active" + and _active_super_count(db) <= 1 + ): + raise HTTPException(status_code=400, detail="不能删除最后一个超级管理员") + username = target.username + db.delete(target) + db.commit() + write_audit( + db, admin, action="admin.delete", target_type="admin", target_id=admin_id, + detail={"username": username, "role": target.role}, ip=get_client_ip(request), commit=True, + ) + return {"deleted": True} diff --git a/app/admin/routers/analytics_health.py b/app/admin/routers/analytics_health.py new file mode 100644 index 0000000..517feb3 --- /dev/null +++ b/app/admin/routers/analytics_health.py @@ -0,0 +1,49 @@ +"""admin 埋点健康度:埋点成功率 / 上报成功率 总览 + 趋势 + 下钻(只读)。""" +from __future__ import annotations + +from datetime import datetime +from typing import Annotated + +from fastapi import APIRouter, Depends, Query + +from app.admin.deps import AdminDb, get_current_admin +from app.admin.repositories import analytics_health as repo +from app.admin.schemas.analytics_health import ( + HealthBreakdownRow, + HealthMetrics, + HealthTrendPoint, +) + +router = APIRouter( + prefix="/admin/api/analytics-health", + tags=["admin-analytics-health"], + dependencies=[Depends(get_current_admin)], +) + + +@router.get("/overview", response_model=HealthMetrics, summary="两段成功率总览") +def overview( + db: AdminDb, + date_from: Annotated[datetime, Query()], + date_to: Annotated[datetime, Query()], +) -> HealthMetrics: + return HealthMetrics(**repo.overview(db, date_from, date_to)) + + +@router.get("/trend", response_model=list[HealthTrendPoint], summary="按北京天趋势") +def trend( + db: AdminDb, + date_from: Annotated[datetime, Query()], + date_to: Annotated[datetime, Query()], +) -> list[HealthTrendPoint]: + return [HealthTrendPoint(**p) for p in repo.trend(db, date_from, date_to)] + + +@router.get("/breakdown", response_model=list[HealthBreakdownRow], summary="按维度下钻") +def breakdown( + db: AdminDb, + date_from: Annotated[datetime, Query()], + date_to: Annotated[datetime, Query()], + dim: Annotated[str, Query(pattern="^(event|app_ver|oem)$")] = "event", +) -> list[HealthBreakdownRow]: + return [HealthBreakdownRow(**r) for r in repo.breakdown(db, date_from, date_to, dim)] diff --git a/app/admin/routers/auth.py b/app/admin/routers/auth.py index 4d80251..27b5bb3 100644 --- a/app/admin/routers/auth.py +++ b/app/admin/routers/auth.py @@ -6,6 +6,8 @@ import logging from fastapi import APIRouter, Depends, HTTPException from app.admin.deps import AdminDb, CurrentAdmin +from app.admin.permissions import CUSTOM_ROLE, sanitize_pages +from app.admin.repositories import admin_role as role_repo from app.admin.repositories import admin_user as admin_repo from app.admin.schemas.auth import AdminLoginRequest, AdminLoginResponse, AdminOut from app.admin.security import create_admin_token @@ -17,6 +19,17 @@ logger = logging.getLogger("shagua.admin.auth") router = APIRouter(prefix="/admin/api/auth", tags=["admin-auth"]) +def _admin_out_with_pages(admin, db: AdminDb) -> AdminOut: # noqa: ANN001 + """AdminOut + 有效可见页(前端左侧导航按此过滤)。 + role=="custom" → 用这个人的 pages_override(按人自定义,过滤悬空 key);其余走角色解析。""" + out = AdminOut.model_validate(admin) + if admin.role == CUSTOM_ROLE: + out.pages = sanitize_pages(admin.pages_override) + else: + out.pages = role_repo.effective_pages_of(db, admin.role) + return out + + @router.post( "/login", response_model=AdminLoginResponse, @@ -39,10 +52,10 @@ def login(req: AdminLoginRequest, db: AdminDb) -> AdminLoginResponse: return AdminLoginResponse( access_token=token, expires_in=expires_in, - admin=AdminOut.model_validate(admin), + admin=_admin_out_with_pages(admin, db), ) -@router.get("/me", response_model=AdminOut, summary="当前管理员") -def me(admin: CurrentAdmin) -> AdminOut: - return AdminOut.model_validate(admin) +@router.get("/me", response_model=AdminOut, summary="当前管理员(含有效可见页)") +def me(admin: CurrentAdmin, db: AdminDb) -> AdminOut: + return _admin_out_with_pages(admin, db) diff --git a/app/admin/routers/comparison.py b/app/admin/routers/comparison.py index 9aeb859..38dc242 100644 --- a/app/admin/routers/comparison.py +++ b/app/admin/routers/comparison.py @@ -32,12 +32,15 @@ def list_comparison_records( phone: Annotated[str | None, Query(description="手机号前缀")] = None, status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None, business_type: Annotated[str | None, Query()] = None, + store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None, + product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = 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, + business_type=business_type, store=store, product=product, + limit=limit, cursor=cursor, ) return CursorPage( items=[AdminComparisonListItem.model_validate(r) for r in items], diff --git a/app/admin/routers/config.py b/app/admin/routers/config.py index 8a65c61..6701c03 100644 --- a/app/admin/routers/config.py +++ b/app/admin/routers/config.py @@ -55,9 +55,14 @@ def _item(db, key: str) -> ConfigItemOut: raise HTTPException(status_code=404, detail="未知配置项") -@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值") +@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值(不含 hidden)") def list_config(db: AdminDb) -> list[ConfigItemOut]: - return [ConfigItemOut(**item) for item in app_config.list_all(db)] + # hidden 项(已下线/由专用页管理,如福利页任务·里程碑·看广告调参、首页轮播数据源)不在本页渲染。 + return [ + ConfigItemOut(**item) + for item in app_config.list_all(db) + if not CONFIG_DEFS[item["key"]].get("hidden") + ] @router.patch("/{key}", response_model=ConfigItemOut, summary="改某项配置(带审计)") diff --git a/app/admin/routers/coupon_data.py b/app/admin/routers/coupon_data.py index f3d2548..82a882d 100644 --- a/app/admin/routers/coupon_data.py +++ b/app/admin/routers/coupon_data.py @@ -18,6 +18,8 @@ from app.admin.schemas.coupon_data import ( CouponDataOut, CouponDataRow, CouponDataSummary, + CouponSlotRow, + CouponSlotsOut, CouponUserRecordsOut, ) from app.core.rewards import cn_today @@ -52,6 +54,10 @@ def get_coupon_data( date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None, user: Annotated[str | None, Query(description="用户手机号/昵称模糊搜;不传=全部")] = None, app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod", + status: Annotated[ + list[str] | None, + Query(description="领券状态多选 started/completed/failed/abandoned;不传=全部"), + ] = None, granularity: Annotated[ str, Query(description="day=按天 / hour=按小时(北京);区间>1 天建议 day") ] = "day", @@ -73,7 +79,7 @@ def get_coupon_data( env = None if app_env == "all" else app_env result = coupon_data.coupon_data_report( db, date_from=d_from.isoformat(), date_to=d_to.isoformat(), - user=user, app_env=env, granularity=granularity, + user=user, app_env=env, statuses=status, granularity=granularity, limit=limit, offset=offset, sort=sort, ) return CouponDataOut( @@ -87,6 +93,35 @@ def get_coupon_data( ) +@router.get( + "/coupons", + response_model=CouponSlotsOut, + summary="按券成功率(coupon_id 粒度;成功/(成功+失败),skipped 排除,设备-天口径)", +) +def get_coupon_slots( + db: AdminDb, + date_from: Annotated[str | None, Query(description="起始日 北京 YYYY-MM-DD,默认今天")] = None, + date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None, + app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod", +) -> CouponSlotsOut: + today = cn_today() + d_from = _parse_day(date_from, field="date_from", default=today) + d_to = _parse_day(date_to, field="date_to", default=d_from) + if d_to < d_from: + raise HTTPException(status_code=422, detail="date_to 不能早于 date_from") + if (d_to - d_from).days + 1 > _MAX_RANGE_DAYS: + raise HTTPException(status_code=422, detail=f"区间最长 {_MAX_RANGE_DAYS} 天") + env = None if app_env == "all" else app_env + result = coupon_data.coupon_slot_report( + db, date_from=d_from.isoformat(), date_to=d_to.isoformat(), app_env=env + ) + return CouponSlotsOut( + date_from=d_from.isoformat(), + date_to=d_to.isoformat(), + items=[CouponSlotRow(**r) for r in result["items"]], + ) + + @router.get( "/user-records", response_model=CouponUserRecordsOut, diff --git a/app/admin/routers/ops_marquee_seed.py b/app/admin/routers/ops_marquee_seed.py index 4c7c153..0b3ff04 100644 --- a/app/admin/routers/ops_marquee_seed.py +++ b/app/admin/routers/ops_marquee_seed.py @@ -8,6 +8,7 @@ from __future__ import annotations from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, Request +from pydantic import BaseModel from app.admin.audit import write_audit from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role @@ -18,11 +19,17 @@ from app.admin.schemas.ops_marquee_seed import ( OpsMarqueeSeedCreate, OpsMarqueeSeedOut, OpsMarqueeSeedUpdate, + OpsRealRecordItem, + OpsRealRecordsOut, OpsSavingsFeedPreviewOut, ) from app.models.admin import AdminUser from app.models.ops_marquee_seed import OpsMarqueeSeed -from app.repositories import ops_marquee +from app.repositories import app_config, ops_marquee + + +class MarqueeModeUpdate(BaseModel): + mode: str # mixed / real / seed router = APIRouter( prefix="/admin/api/marquee-seeds", @@ -52,9 +59,58 @@ def list_seeds(db: AdminDb) -> list[OpsMarqueeSeedOut]: def preview_feed( db: AdminDb, limit: Annotated[int, Query(ge=1, le=30)] = 8, + mode: Annotated[str | None, Query(description="mixed/real/seed;不传=当前持久化模式")] = None, ) -> OpsSavingsFeedPreviewOut: - """返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。""" - return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit)) + """返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。 + + mode 显式指定则预览该模式(**不改持久化配置**,供前端切换开关时实时预览);不传则用当前持久化模式。 + """ + if mode is not None and mode not in ops_marquee.FEED_MODES: + raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed") + return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit, mode=mode)) + + +@router.get("/real-records", response_model=OpsRealRecordsOut, summary="分页浏览当前模式下可展示的记录(审核用)") +def list_real_records( + db: AdminDb, + mode: Annotated[str | None, Query(description="mixed/real/seed;不传=当前持久化模式")] = None, + offset: Annotated[int, Query(ge=0)] = 0, + limit: Annotated[int, Query(ge=1, le=50)] = 8, +) -> OpsRealRecordsOut: + """分页列出**当前模式**下可在 app 轮播展示的全部记录(**不去重**):只真实=真实记录;只种子=各启用 + 种子按生成逻辑各出一行;混播=真实+种子。与 app 同口径洗牌+去连簇,固定种子→翻页稳定、能翻遍全部。 + item.user_id=0 表示种子行。""" + if mode is not None and mode not in ops_marquee.FEED_MODES: + raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed") + items, total = ops_marquee.list_real_records(db, mode=mode, offset=offset, limit=limit) + return OpsRealRecordsOut(items=[OpsRealRecordItem(**it) for it in items], total=total) + + +# 注:/mode 两个端点须在 /{seed_id} 之前注册,否则 PATCH /mode 会被 /{seed_id} 抢先按 id 解析。 +@router.get("/mode", summary="首页轮播数据源模式(mixed/real/seed)") +def get_feed_mode(db: AdminDb) -> dict: + """当前轮播取数模式:mixed=真实优先+种子补位(默认)/ real=只真实 / seed=只种子·合成。""" + return {"mode": ops_marquee.get_feed_mode(db)} + + +@router.patch("/mode", summary="改首页轮播数据源模式(带审计)") +def set_feed_mode( + body: MarqueeModeUpdate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> dict: + if body.mode not in ops_marquee.FEED_MODES: + raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed") + before = ops_marquee.get_feed_mode(db) + app_config.set_value(db, "marquee_feed_mode", body.mode, admin_id=admin.id, commit=False) + write_audit( + db, admin, action="ops_marquee_seed.set_mode", target_type="config", + target_id="marquee_feed_mode", detail={"before": before, "after": body.mode}, + ip=get_client_ip(request), commit=False, + ) + db.commit() + return {"mode": body.mode} @router.post("", response_model=OpsMarqueeSeedOut, summary="新增轮播种子(带审计)") diff --git a/app/admin/routers/roles.py b/app/admin/routers/roles.py new file mode 100644 index 0000000..15416a7 --- /dev/null +++ b/app/admin/routers/roles.py @@ -0,0 +1,129 @@ +"""admin RBAC 角色管理(仅 super_admin):列角色 / 权限目录 / 增删改角色。均写审计。 + +角色 = 一组「可见页面」(见 app/admin/permissions.py)。角色登录后台后左侧只展示其 pages 对应导航项。 +只有 super_admin(内建全权角色)能进本组端点(dependencies=require_role() 无参 = 仅 super_admin)。 +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy import func, select + +from app.admin.audit import write_audit +from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_role +from app.admin.permissions import ( + PERMISSION_CATALOG, + SUPER_ADMIN_ROLE, + effective_pages, + sanitize_pages, +) +from app.admin.repositories import admin_role as role_repo +from app.admin.schemas.role import ( + PermissionGroup, + RoleCreateRequest, + RoleOut, + RoleUpdateRequest, +) +from app.models.admin import AdminUser +from app.models.admin_role import AdminRole + +router = APIRouter( + prefix="/admin/api/roles", + tags=["admin-roles"], + dependencies=[Depends(require_role())], # 无参 = 仅 super_admin +) + + +def _usage_counts(db: AdminDb) -> dict[str, int]: + rows = db.execute( + select(AdminUser.role, func.count(AdminUser.id)).group_by(AdminUser.role) + ).all() + return {r: c for r, c in rows} + + +def _to_out(role: AdminRole, usage: dict[str, int]) -> RoleOut: + return RoleOut( + id=role.id, + name=role.name, + label=role.label or role.name, + pages=effective_pages(role.name, role.pages), + is_builtin=role.is_builtin, + in_use=usage.get(role.name, 0), + ) + + +@router.get("", response_model=list[RoleOut], summary="角色列表(含可见页 + 使用数)") +def list_roles(db: AdminDb) -> list[RoleOut]: + usage = _usage_counts(db) + return [_to_out(r, usage) for r in role_repo.list_roles(db)] + + +@router.get("/catalog", response_model=list[PermissionGroup], summary="页面权限目录(分组)") +def get_catalog() -> list[PermissionGroup]: + return [PermissionGroup(**g) for g in PERMISSION_CATALOG] + + +@router.post("", response_model=RoleOut, summary="新增角色(带审计)") +def create_role( + body: RoleCreateRequest, request: Request, admin: CurrentAdmin, db: AdminDb +) -> RoleOut: + name = body.name.strip() + if not name: + raise HTTPException(status_code=400, detail="角色名称不能为空") + if name == SUPER_ADMIN_ROLE: + raise HTTPException(status_code=400, detail="super_admin 为内建角色,不能新建") + if role_repo.get_role(db, name) is not None: + raise HTTPException(status_code=409, detail="角色名称已存在") + # 自定义角色:name(key)= label = 输入名称 + role = role_repo.create_role(db, name=name, label=name, pages=body.pages) + write_audit( + db, admin, action="role.create", target_type="role", target_id=str(role.id), + detail={"name": name, "pages": role.pages}, ip=get_client_ip(request), commit=True, + ) + return _to_out(role, _usage_counts(db)) + + +@router.patch("/{role_id}", response_model=RoleOut, summary="改角色(展示名/可见页,带审计)") +def update_role( + role_id: int, body: RoleUpdateRequest, request: Request, admin: CurrentAdmin, db: AdminDb +) -> RoleOut: + role = db.get(AdminRole, role_id) + if role is None: + raise HTTPException(status_code=404, detail="角色不存在") + if role.is_builtin: + raise HTTPException(status_code=400, detail="内建角色「管理员」不可编辑") + + new_label = body.label.strip() if body.label is not None else None + changes: dict = {} + if new_label and new_label != role.label: + changes["label"] = {"before": role.label, "after": new_label} + if body.pages is not None: + changes["pages"] = {"before": role.pages, "after": sanitize_pages(body.pages)} + if not changes: + raise HTTPException(status_code=400, detail="无任何变更字段") + + # 只改展示名 + 可见页;key(name)不可变,故无需级联 admin_user.role + role_repo.update_role(db, role, label=new_label, pages=body.pages) + write_audit( + db, admin, action="role.update", target_type="role", target_id=str(role_id), + detail=changes, ip=get_client_ip(request), commit=True, + ) + return _to_out(role, _usage_counts(db)) + + +@router.delete("/{role_id}", summary="删角色(带审计;内建/在用不可删)") +def delete_role(role_id: int, request: Request, admin: CurrentAdmin, db: AdminDb) -> dict: + role = db.get(AdminRole, role_id) + if role is None: + raise HTTPException(status_code=404, detail="角色不存在") + if role.is_builtin: + raise HTTPException(status_code=400, detail="内建角色不可删除") + used = _usage_counts(db).get(role.name, 0) + if used > 0: + raise HTTPException(status_code=400, detail=f"该角色仍有 {used} 名管理员在用,请先改派再删") + name = role.name + role_repo.delete_role(db, role) + write_audit( + db, admin, action="role.delete", target_type="role", target_id=str(role_id), + detail={"name": name}, ip=get_client_ip(request), commit=True, + ) + return {"deleted": True} diff --git a/app/admin/routers/users.py b/app/admin/routers/users.py index 7e4a81b..fce483a 100644 --- a/app/admin/routers/users.py +++ b/app/admin/routers/users.py @@ -42,7 +42,12 @@ def list_users( created_to: Annotated[datetime | None, Query()] = None, last_login_from: Annotated[datetime | None, Query()] = None, last_login_to: Annotated[datetime | None, Query()] = None, - sort_by: Annotated[str, Query(pattern="^(id|created_at|last_login_at)$")] = "id", + # 最近活跃(登录/发起比价/发起领券取最大,见 queries._last_active_expr)筛选与排序 + last_active_from: Annotated[datetime | None, Query()] = None, + last_active_to: Annotated[datetime | None, Query()] = None, + sort_by: Annotated[ + str, Query(pattern="^(id|created_at|last_login_at|last_active_at)$") + ] = "id", sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc", limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, @@ -51,6 +56,7 @@ def list_users( db, phone=phone, register_channel=register_channel, status=status, nickname=nickname, created_from=created_from, created_to=created_to, last_login_from=last_login_from, last_login_to=last_login_to, + last_active_from=last_active_from, last_active_to=last_active_to, sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor, ) return CursorPage( diff --git a/app/admin/schemas/ad_revenue.py b/app/admin/schemas/ad_revenue.py index cec29b2..6f9f49a 100644 --- a/app/admin/schemas/ad_revenue.py +++ b/app/admin/schemas/ad_revenue.py @@ -136,9 +136,16 @@ class AdRevenueReportOut(BaseModel): default_factory=dict, description="按广告类型(ad_type)小计 {ad_type: {impressions, revenue_yuan}};前端取 draw / reward_video 做分类大盘", ) + scene_stats: dict[str, AdRevenueTypeStat] = Field( + default_factory=dict, + description="按信息流场景(feed_scene)小计 {comparison/coupon/welfare: {impressions, revenue_yuan}};" + "全量统计(不受分页截断),供数据大盘「领券广告 / 比价广告」卡;feed_scene 为空的事件不计入", + ) dau: int | None = Field( None, - description="今日活跃用户数(复用大盘口径,last_login_at);**仅查询=今日单天时有值**,历史/多天为 null", + description="所选日期区间的去重活跃用户数(口径同数据大盘 period.users.active:登录 + 开始比价 + " + "开始领券)。按 date_from~date_to 区间统计,含今日、近 7 天、近 30 天等任意区间;全局口径," + "不随 user_id / ad_type / feed_scene / app_env 筛选变化", ) total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)") truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)") diff --git a/app/admin/schemas/admin.py b/app/admin/schemas/admin.py index c1f7949..ae12752 100644 --- a/app/admin/schemas/admin.py +++ b/app/admin/schemas/admin.py @@ -6,21 +6,25 @@ from typing import Literal from pydantic import BaseModel, ConfigDict, Field -_Role = Literal["super_admin", "finance", "operator"] +# 角色不再硬编码枚举:改为任意角色名(自定义角色由 admin_role 表管理),存在性在路由层校验。 class AdminCreateRequest(BaseModel): username: str = Field(..., min_length=3, max_length=64) password: str = Field(..., min_length=8, max_length=72) # bcrypt ≤72 字节 - role: _Role = "operator" + role: str = Field("operator", min_length=1, max_length=32) + # 仅当 role == "custom":这个人专属可见页 key 列表(逐页勾选结果)。其余角色不传/忽略。 + pages_override: list[str] | None = None class AdminUpdateRequest(BaseModel): - """改角色 / 启用禁用 / 重置密码,字段都可选(只改传了的)。""" + """改角色 / 启用禁用 / 重置密码 / 自定义可见页,字段都可选(只改传了的)。""" - role: _Role | None = None + role: str | None = Field(None, min_length=1, max_length=32) status: Literal["active", "disabled"] | None = None password: str | None = Field(None, min_length=8, max_length=72) + # 改成/更新「自定义」可见页;role 切回普通角色时后端会清空 override(见路由)。 + pages_override: list[str] | None = None class AdminAuditLogOut(BaseModel): diff --git a/app/admin/schemas/analytics_health.py b/app/admin/schemas/analytics_health.py new file mode 100644 index 0000000..4d3328d --- /dev/null +++ b/app/admin/schemas/analytics_health.py @@ -0,0 +1,21 @@ +"""埋点健康度 admin 响应 schema。""" +from __future__ import annotations + +from pydantic import BaseModel + + +class HealthMetrics(BaseModel): + attempted: int + drop_capture: int + delivered: int + drop_undelivered: int + track_success_rate: float | None + report_success_rate: float | None + + +class HealthTrendPoint(HealthMetrics): + day: str + + +class HealthBreakdownRow(HealthMetrics): + key: str diff --git a/app/admin/schemas/auth.py b/app/admin/schemas/auth.py index 3826af5..ef64114 100644 --- a/app/admin/schemas/auth.py +++ b/app/admin/schemas/auth.py @@ -20,6 +20,15 @@ class AdminOut(BaseModel): status: str created_at: datetime last_login_at: datetime | None = None + # 该管理员当前角色的有效可见页(= 左侧导航项 key);仅登录 / /me 填充,列表接口默认空。 + # 前端据此过滤左侧导航(super_admin = 全部页;role=="custom" = pages_override)。见 permissions.py。 + pages: list[str] = [] + # 「自定义」可见页原始勾选集(role=="custom" 时非空);列表接口下发,供权限管理页编辑回填勾选。 + # 普通角色为 None。from_attributes 自动从 ORM 列取。 + pages_override: list[str] | None = None + # 明文登录密码:仅「管理员账号列表」(super_admin 专属路由)填充,供权限管理页编辑时复看; + # 无留存(脚本建的超管 / 旧账号)为 None → 前端不显示。登录 / /me 不下发(保持 None)。 + password: str | None = None class AdminLoginResponse(BaseModel): diff --git a/app/admin/schemas/comparison.py b/app/admin/schemas/comparison.py index 19b93bb..ec1d3aa 100644 --- a/app/admin/schemas/comparison.py +++ b/app/admin/schemas/comparison.py @@ -23,6 +23,7 @@ class AdminComparisonListItem(BaseModel): status: str information: str | None = None store_name: str | None = None + product_names: str | None = None # 下单商品名派生串(顿号分隔;「商品」列展示 + 商品搜索) source_platform_name: str | None = None best_platform_name: str | None = None source_price_cents: int | None = None @@ -35,11 +36,14 @@ class AdminComparisonListItem(BaseModel): retry_count: int | None = None input_tokens: int | None = None # Σ usage.prompt_tokens(server 派生) output_tokens: int | None = None # Σ usage.completion_tokens(server 派生) + # 本次比价 LLM 总成本(元,按当时价冻结);旧记录/未回填为 None → 前端「成本」列回退估算。见 services/llm_cost.py。 + llm_cost_yuan: float | None = None device_model: str | None = None rom_vendor: str | None = None rom_name: str | None = None android_version: str | None = None app_version: str | None = None + ad_revenue_yuan: float = 0.0 # 本次比价看的信息流广告预估收益(元),queries 瞬态挂 ORM 实例上 created_at: datetime @@ -70,3 +74,5 @@ class AdminComparisonDetail(AdminComparisonListItem): # 原始上报全量;「卡在哪一步」从 raw_payload.platform_results[*].status 读 # (store_not_found/items_not_found/below_minimum/unsupported = 卡在 找店/加菜/起送/读价)。 raw_payload: dict | None = None + # 算成本所用单价快照 {mode, prices:{model:{...}}}(llm_cost_yuan 继承自列表项)。见 services/llm_cost.py。 + llm_price_snapshot: dict | None = None diff --git a/app/admin/schemas/coupon_data.py b/app/admin/schemas/coupon_data.py index c94938d..c560397 100644 --- a/app/admin/schemas/coupon_data.py +++ b/app/admin/schemas/coupon_data.py @@ -19,6 +19,16 @@ class CouponDataSummary(BaseModel): p50_ms: int | None = Field(None, description="耗时 50 分位(ms,中位数)") p95_ms: int | None = Field(None, description="耗时 95 分位(ms)") p99_ms: int | None = Field(None, description="耗时 99 分位(ms)") + # 平台粒度成功率(见 docs/guides/领券成功率指标-设计与埋点.md):基数含全部 session。 + full_success_count: int = Field(0, description="整单成功数(勾选平台全部领到的 session 数)") + full_success_rate: float | None = Field(None, description="整单成功率②=整单成功数/发起数;无数据为空") + point_success_count: int = Field(0, description="成功平台点位数(Σ 每次成功的平台数)") + point_total_count: int = Field(0, description="总平台点位数(Σ 每次勾选平台数;空勾选=全领三档)") + point_success_rate: float | None = Field(None, description="点位成功率③=成功点位/总点位;无数据为空") + per_platform: dict[str, float | None] = Field( + default_factory=dict, + description="分平台点位成功率 {平台id: rate|None};恒含美团/淘宝/京东三档,区间内无人勾选的平台为 None", + ) class CouponDataDaily(BaseModel): @@ -60,6 +70,9 @@ class CouponDataRow(BaseModel): started_at: datetime = Field(..., description="发起时刻(明细「时间」列)") claimed_count: int | None = None trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id") + ad_revenue_yuan: float = Field( + 0.0, description="本次领券看的信息流广告预估收益(元);按 trace_id 聚合 ad_ecpm_record" + ) class CouponDataOut(BaseModel): @@ -81,3 +94,22 @@ class CouponUserRecordsOut(BaseModel): items: list[CouponDataRow] total: int + + +class CouponSlotRow(BaseModel): + """按券成功率一行(§13):粒度=设备-天;成功率=成功/(成功+失败),skipped 排除。""" + + coupon_id: str + coupon_name: str | None = None + platform: str | None = Field(None, description="美团/淘宝/京东 平台 id;无法识别为空") + tried: int = Field(..., description="尝试数(success+already_claimed+failed 的设备-天数)") + succeeded: int = Field(..., description="成功数(success+already_claimed)") + success_rate: float | None = Field(None, description="成功率=成功/尝试") + + +class CouponSlotsOut(BaseModel): + """按券成功率表响应(§13)。""" + + date_from: str + date_to: str + items: list[CouponSlotRow] diff --git a/app/admin/schemas/dashboard.py b/app/admin/schemas/dashboard.py index 56f2c15..b3e008a 100644 --- a/app/admin/schemas/dashboard.py +++ b/app/admin/schemas/dashboard.py @@ -43,7 +43,10 @@ class DashboardComparison(BaseModel): class DashboardPeriodUsers(BaseModel): new: int active: int + # 次日留存(2026-07-05 起):retained_new_users = 窗口内逐日「前一日新增且当日活跃」用户数之和, + # retention_cohort = 对应的前一日新增基数之和,retention_rate = 两者之比。 retained_new_users: int + retention_cohort: int = 0 retention_rate: float | None = None retention_note: str @@ -57,6 +60,24 @@ class DashboardPeriodComparison(BaseModel): average_saved_cents: int | None = None +class DashboardPeriodCoupon(BaseModel): + """领券核心数据(2026-07-05 产品新增)。点位=一张券(coupon_claim_record 一天一条终态); + 成功口径 success+already_claimed(与「我的」页累计领券一致)。""" + + started: int = 0 + # 全部领成功的次数:completed 且当日该设备全部点位成功 + all_success: int = 0 + success_rate: float | None = None + # 本期 session 触达的点位中成功的条数(同设备同日去重) + point_success: int = 0 + # 每次发起的应领点位数(本期完成场实际点位数的众数;无完成场为空) + points_per_session: int | None = None + # 点位成功率 = point_success / (started × points_per_session);未跑到的点位计入分母视为失败 + point_success_rate: float | None = None + # 耗时中位数(仅 completed 的 elapsed_ms,同「领券数据」页口径) + median_elapsed_ms: int | None = None + + class DashboardPeriodCoins(BaseModel): granted_total: int reward_video_coin_total: int = 0 @@ -85,6 +106,7 @@ class DashboardPeriod(BaseModel): date_to: date users: DashboardPeriodUsers comparison: DashboardPeriodComparison + coupon: DashboardPeriodCoupon = DashboardPeriodCoupon() coins: DashboardPeriodCoins cash: DashboardPeriodCash trend: list[DashboardTrendPoint] = [] diff --git a/app/admin/schemas/ops_marquee_seed.py b/app/admin/schemas/ops_marquee_seed.py index db8c56d..acdf7b9 100644 --- a/app/admin/schemas/ops_marquee_seed.py +++ b/app/admin/schemas/ops_marquee_seed.py @@ -62,3 +62,16 @@ class OpsSavingsFeedPreviewItem(BaseModel): class OpsSavingsFeedPreviewOut(BaseModel): """运营预览:实际混播出来的 feed(真实记录会插队,与客户端一致)。""" items: list[OpsSavingsFeedPreviewItem] + + +class OpsRealRecordItem(BaseModel): + masked_user: str # 脱敏后展示名(与 app 一致) + saved_amount_cents: int # 节省金额(分) + created_at: str # 比价记录时间(YYYY-MM-DD HH:MM) + user_id: int # 真实 user_id(供运营核对,不下发客户端) + + +class OpsRealRecordsOut(BaseModel): + """分页浏览「全部可展示的真实记录」(success+省>0,不去重、稳定顺序)。""" + items: list[OpsRealRecordItem] + total: int # 满足条件的真实记录总数(算页数用) diff --git a/app/admin/schemas/role.py b/app/admin/schemas/role.py new file mode 100644 index 0000000..f97ebfa --- /dev/null +++ b/app/admin/schemas/role.py @@ -0,0 +1,35 @@ +"""admin RBAC 角色 + 权限目录 schemas。""" +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class PermissionPage(BaseModel): + key: str + label: str + + +class PermissionGroup(BaseModel): + group: str + pages: list[PermissionPage] + + +class RoleOut(BaseModel): + id: int + name: str # 角色 key(承重,不可变) + label: str # 展示名(UI 显示) + pages: list[str] # 有效可见页 key(super_admin = 全部页) + is_builtin: bool # 内建角色(super_admin):不可编辑/删除 + in_use: int # 使用该角色的管理员数(删除前端提示 / 拦截用) + + +class RoleCreateRequest(BaseModel): + # 新增自定义角色:name 即用户输入的名称(同时作 key 与展示名) + name: str = Field(..., min_length=1, max_length=32) + pages: list[str] = Field(default_factory=list) + + +class RoleUpdateRequest(BaseModel): + # 只改展示名 + 可见页;key 不可变 + label: str | None = Field(None, min_length=1, max_length=32) + pages: list[str] | None = None diff --git a/app/admin/schemas/user.py b/app/admin/schemas/user.py index 2e554a8..7d673a6 100644 --- a/app/admin/schemas/user.py +++ b/app/admin/schemas/user.py @@ -20,6 +20,9 @@ class AdminUserListItem(BaseModel): wechat_nickname: str | None = None created_at: datetime last_login_at: datetime + # 最近活跃 = max(最近登录, 最近发起比价, 最近发起领券);列表页由 queries._attach_last_active + # 瞬态挂上。其他复用本 schema 的入口(用户 360 等)没挂该属性 → None(前端显示 '-')。 + last_active_at: datetime | None = None class AdminUserOverview(BaseModel): diff --git a/app/admin/schemas/wallet.py b/app/admin/schemas/wallet.py index 06b4c7e..7f61d7b 100644 --- a/app/admin/schemas/wallet.py +++ b/app/admin/schemas/wallet.py @@ -130,6 +130,7 @@ class WithdrawBulkResult(BaseModel): class WithdrawLedgerCheckOut(BaseModel): ok: bool + # 普通现金账(coin_cash:金币兑换的现金) cash_balance_total_cents: int cash_transaction_total_cents: int balance_diff_cents: int @@ -137,6 +138,14 @@ class WithdrawLedgerCheckOut(BaseModel): missing_refund_txn_count: int duplicate_refund_txn_count: int refund_txn_on_non_terminal_count: int + # 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账)。默认 0 向后兼容。 + invite_cash_balance_total_cents: int = 0 + invite_cash_transaction_total_cents: int = 0 + invite_balance_diff_cents: int = 0 + invite_missing_withdraw_txn_count: int = 0 + invite_missing_refund_txn_count: int = 0 + invite_duplicate_refund_txn_count: int = 0 + invite_refund_txn_on_non_terminal_count: int = 0 class WxpayHealthCheckOut(BaseModel): diff --git a/app/api/deps.py b/app/api/deps.py index 20a663c..0b9db63 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -54,5 +54,29 @@ def get_current_user( return user +def get_current_user_optional( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)], + db: Annotated[Session, Depends(get_db)], +) -> User | None: + """软鉴权:有合法 Bearer 就返回 user,否则(无 header / 无效 token / 用户禁用)一律返回 + None,**不 raise**。 + + 比价 step / finalize 灰度期用:新客户端带 JWT → 拿到 user_id 绑定 harvest 行; + 老客户端(不带 JWT)→ None,harvest 行 user_id 暂空,由其后续 /compare/record 上报补齐。 + 等新版覆盖率够高,再把这几条端点从软鉴权收紧成硬 get_current_user。 + """ + if credentials is None or credentials.scheme.lower() != "bearer": + return None + try: + payload = decode_token(credentials.credentials, expected_type="access") + user = db.get(User, int(payload["sub"])) + except (TokenError, KeyError, ValueError, TypeError): + return None + if user is None or user.status != "active": + return None + return user + + CurrentUser = Annotated[User, Depends(get_current_user)] +OptionalUser = Annotated[User | None, Depends(get_current_user_optional)] DbSession = Annotated[Session, Depends(get_db)] diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index 2badd23..3b9cbaf 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -286,6 +286,7 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm ad_session_id=payload.ad_session_id, adn=payload.adn, slot_id=payload.slot_id, feed_scene=payload.feed_scene, + trace_id=payload.trace_id, app_env=payload.app_env, our_code_id=payload.our_code_id, ) logger.info( diff --git a/app/api/v1/analytics.py b/app/api/v1/analytics.py index 265954d..23d2f82 100644 --- a/app/api/v1/analytics.py +++ b/app/api/v1/analytics.py @@ -6,13 +6,18 @@ POST /api/v1/analytics/events — 批量接收新手引导(及后续)埋点,appe """ from __future__ import annotations -from fastapi import APIRouter, Request +import logging + +from fastapi import APIRouter, HTTPException, Request from app.api.deps import DbSession from app.repositories import analytics as analytics_repo +from app.repositories import analytics_selfstat as selfstat_repo from app.schemas.analytics import AnalyticsBatchIn, AnalyticsIngestOut +from app.schemas.analytics_selfstat import SelfStatBatchIn, SelfStatIngestOut router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"]) +logger = logging.getLogger("shagua.analytics") def _client_ip(request: Request) -> str: @@ -29,3 +34,13 @@ def ingest_events( ) -> AnalyticsIngestOut: n = analytics_repo.record_batch(db, batch, client_ip=_client_ip(request)) return AnalyticsIngestOut(received=n) + + +@router.post("/selfstat", response_model=SelfStatIngestOut, summary="上报自报计数快照") +def ingest_selfstat(batch: SelfStatBatchIn, db: DbSession) -> SelfStatIngestOut: + try: + snap_id = selfstat_repo.record_selfstat(db, batch) + except Exception: # noqa: BLE001 — 计数链路要稳,落库失败不裸奔 500,记日志回明确错误 + logger.exception("selfstat ingest failed device=%s epoch=%s", batch.device_id, batch.epoch_id) + raise HTTPException(status_code=503, detail="selfstat ingest failed") from None + return SelfStatIngestOut(snapshot_id=snap_id) diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index 3c4f4bf..e3728be 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -12,19 +12,36 @@ from __future__ import annotations import logging -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, HTTPException, Request +from sqlalchemy.exc import IntegrityError from app.api.deps import CurrentUser, DbSession from app.core import test_account -from app.core.ratelimit import enforce_rate_limit, rate_limit -from app.core.security import TokenError, decode_token, issue_token_pair +from app.core.ratelimit import ( + RateLimitRule, + check_rate_limits, + enforce_rate_limit, + record_rate_limits, +) +from app.core.security import ( + TokenError, + create_bind_ticket, + create_conflict_ticket, + decode_bind_ticket, + decode_conflict_ticket, + decode_token, + issue_token_pair, +) +from app.integrations import wxpay from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone from app.integrations.sms import SmsError, send_code, verify_code from app.repositories import onboarding as onboarding_repo +from app.repositories import phone_rebind as rebind_repo from app.repositories import user as user_repo from app.schemas.auth import ( JverifyLoginRequest, LogoutResponse, + OccupiedAccountInfo, RefreshRequest, SmsLoginRequest, SmsSendRequest, @@ -32,6 +49,13 @@ from app.schemas.auth import ( TokenPair, TokenWithUser, UserOut, + WechatBindPhoneJverifyRequest, + WechatBindPhoneSmsRequest, + WechatBindResultResponse, + WechatConflictContinueRequest, + WechatConflictRebindRequest, + WechatLoginRequest, + WechatLoginResponse, ) logger = logging.getLogger("shagua.auth") @@ -40,9 +64,10 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) # 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。 SMS_LOGIN_MAX_PER_HOUR = 5 -# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。 -# 堵「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞 —— 那两道是单号维度,一机换号能绕开。 -SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 +# 发码防刷(同一设备 device_id + 同一 IP,**只按成功发码计数**;被单号 60s 冷却挡下的重发不占额度): +# 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。 +SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 # 每小时上限 +SMS_SEND_MAX_PER_DAY_PER_DEVICE = 20 # 每天上限(再叠一层日封顶,挡低频长时间轰炸) def _login_response( @@ -99,23 +124,26 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse: logger.info("test_account sms_send short-circuit (不真发)") return SmsSendResponse(sent=True, mock=True, cooldown_sec=0) - # 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码。 - # 补「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞(那两道是单号维度,一机换号能绕);设备维度按机器封顶, - # 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。与路由上 IP 维度(10次/分钟)互补。 - enforce_rate_limit( - request, - scope="sms-send-device", - subject=req.device_id, - limit=SMS_SEND_MAX_PER_HOUR_PER_DEVICE, - window_sec=3600, - detail="操作过于频繁,请稍后再试", - ) + # 发码防刷:同一设备(device_id) + 同一 IP,每小时 / 每天两道闸,**均只按成功发码计数**。 + # 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,挡短信轰炸/烧钱。 + # 关键:被单号 60s 冷却挡下的重发是「没真发、没烧钱」→ 不该占额度。故 check(先判)放在真发之前 + # (超限直接 429、不真发),record(计数)只在 send_code 成功后调 —— 冷却/供应商失败抛 429 时直接返回、不计数。 + send_rules = [ + RateLimitRule("sms-send-device", SMS_SEND_MAX_PER_HOUR_PER_DEVICE, 3600, + "操作过于频繁,请稍后再试"), + RateLimitRule("sms-send-device-daily", SMS_SEND_MAX_PER_DAY_PER_DEVICE, 86400, + "今日验证码发送次数过多,请明天再试"), + ] + check_rate_limits(request, subject=req.device_id, rules=send_rules) try: cooldown = send_code(req.phone) except SmsError as e: raise HTTPException(status_code=e.status_code, detail=str(e)) from e + # 发码成功 → 两道闸各 +1(被单号冷却挡下的重发走不到这里,故不占额度) + record_rate_limits(request, subject=req.device_id, rules=send_rules) + from app.core.config import settings # 局部 import 避免循环 return SmsSendResponse(sent=True, mock=settings.SMS_MOCK, cooldown_sec=cooldown) @@ -125,7 +153,6 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse: "/sms/login", response_model=TokenWithUser, summary="手机号+验证码登录", - dependencies=[Depends(rate_limit(20, 60, "sms-login"))], # 防撞库爆破(另有单码失败次数上限) ) def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWithUser: # 测试账号:免验证码登录 + 每日上限 + 每次都走新手引导(详见 app/core/test_account.py)。 @@ -143,7 +170,7 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit return _login_response(user, onboarding_completed=False, force_onboarding=True) # 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试。放在验证码校验 - # **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破。与路由上 IP 维度的 sms-login 限流(同 IP)互补。 + # **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破(另有单码失败 SMS_MAX_VERIFY_ATTEMPTS 次即作废兜底)。 # ⚠️ 按设备而非手机号 → 一台机器换不同手机号刷登录也受限(防一机狂登多号);device_id 空(老客户端)时 # 退化为该 IP 下所有空设备聚一桶,仍受限。 enforce_rate_limit( @@ -167,6 +194,261 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit return _login_response(user, onboarding_completed=completed) +# ===================== 微信登录 ===================== + +@router.post( + "/wechat-login", + response_model=WechatLoginResponse, + summary="微信登录(openid 命中即登入,否则发绑号令牌)", +) +def wechat_login(req: WechatLoginRequest, db: DbSession) -> WechatLoginResponse: + from app.core.config import settings # 局部 import,避免循环 + + # 微信登录只需 code→openid(sns/oauth2),不需要商户转账证书;故只校验 APP_ID/SECRET。 + if not (settings.WECHAT_APP_ID and settings.WECHAT_APP_SECRET): + raise HTTPException(status_code=503, detail="wechat login not configured") + + try: + info = wxpay.code_to_userinfo(req.code) # {openid, nickname, avatar_url, raw};失败抛 ValueError + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + openid = info["openid"] + user = user_repo.get_user_by_wechat_openid(db, openid) + if user is not None: + # openid 命中 → 直接登入(绝不套用提现 bind-wechat 的"撞号即 409"逻辑) + if user.status != "active": + raise HTTPException(status_code=403, detail="account disabled") + user_repo.touch_last_login(db, user) + completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id) + logger.info("wechat_login hit user_id=%d openid=%s*** onboarded=%s", user.id, openid[:6], completed) + return WechatLoginResponse( + status="logged_in", + token=_login_response(user, onboarding_completed=completed), + ) + + # 未命中 → 签发短时 bind_ticket,进手机号绑定流程(账号此刻还不建) + ticket = create_bind_ticket( + openid=openid, + wechat_nickname=info["nickname"], + wechat_avatar_url=info["avatar_url"], + ) + logger.info("wechat_login new openid=%s*** issue bind_ticket", openid[:6]) + return WechatLoginResponse( + status="need_bind_phone", + bind_ticket=ticket, + wechat_nickname=info["nickname"], + wechat_avatar_url=info["avatar_url"], + ) + + +def _finish_wechat_bind( + db, + *, + openid: str, + wechat_nickname: str | None, + wechat_avatar_url: str | None, + phone: str, + device_id: str, +) -> WechatBindResultResponse: + """绑手机建号的公共尾段:手机号被占用 → 返回 phone_occupied(M2 处理 3 选 1); + 未占用 → 新建微信账号(channel=wechat,昵称头像取微信)→ 签 token 登入。""" + existing = user_repo.get_user_by_phone(db, phone) + if existing is not None: + from app.core.config import settings # 局部 import,避免循环 + + ticket = create_conflict_ticket( + openid=openid, + wechat_nickname=wechat_nickname, + wechat_avatar_url=wechat_avatar_url, + phone=phone, + ) + blocked = rebind_repo.rebound_within_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS) + logger.info( + "wechat bind phone occupied phone=%s by user_id=%d has_wechat=%s", + mask_phone(phone), existing.id, bool(existing.wechat_openid), + ) + return WechatBindResultResponse( + status="phone_occupied", + occupied_account=OccupiedAccountInfo( + nickname=existing.nickname, + avatar_url=existing.avatar_url, + created_at=existing.created_at, + has_wechat=bool(existing.wechat_openid), + ), + conflict_ticket=ticket, + rebind_available=not blocked, + rebind_blocked_days=( + rebind_repo.remaining_block_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS) + if blocked else 0 + ), + ) + user = user_repo.create_wechat_user( + db, + phone=phone, + openid=openid, + wechat_nickname=wechat_nickname, + wechat_avatar_url=wechat_avatar_url, + ) + completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=device_id) + logger.info("wechat bind ok user_id=%d phone=%s openid=%s*** onboarded=%s", + user.id, mask_phone(phone), openid[:6], completed) + return WechatBindResultResponse( + status="logged_in", + token=_login_response(user, onboarding_completed=completed), + ) + + +@router.post( + "/wechat/bind-phone/sms", + response_model=WechatBindResultResponse, + summary="微信登录·其他手机号(短信)绑定", +) +def wechat_bind_phone_sms( + req: WechatBindPhoneSmsRequest, request: Request, db: DbSession +) -> WechatBindResultResponse: + try: + claims = decode_bind_ticket(req.bind_ticket) + except TokenError as e: + raise HTTPException(status_code=401, detail="授权已过期,请重新用微信登录") from e + + # 防刷:同 sms/login,按 设备+IP 每小时限流(放在验证码校验之前,失败也计数) + enforce_rate_limit( + request, + scope="wechat-bind-sms-device", + subject=req.device_id, + limit=SMS_LOGIN_MAX_PER_HOUR, + window_sec=3600, + detail="登录尝试过于频繁,请稍后再试", + ) + + if not verify_code(req.phone, req.code): + raise HTTPException(status_code=400, detail="invalid sms code") + + return _finish_wechat_bind( + db, + openid=claims["openid"], + wechat_nickname=claims["wnk"], + wechat_avatar_url=claims["wav"], + phone=req.phone, + device_id=req.device_id, + ) + + +@router.post( + "/wechat/bind-phone/jverify", + response_model=WechatBindResultResponse, + summary="微信登录·本机号(极光)绑定", +) +def wechat_bind_phone_jverify( + req: WechatBindPhoneJverifyRequest, db: DbSession +) -> WechatBindResultResponse: + try: + claims = decode_bind_ticket(req.bind_ticket) + except TokenError as e: + raise HTTPException(status_code=401, detail="授权已过期,请重新用微信登录") from e + + try: + phone = verify_and_get_phone(req.login_token) + except JiguangError as e: + logger.error("[JG] verify+decrypt failed: %s", e, exc_info=True) + raise HTTPException(status_code=502, detail=f"jiguang verify failed: {e}") from e + + return _finish_wechat_bind( + db, + openid=claims["openid"], + wechat_nickname=claims["wnk"], + wechat_avatar_url=claims["wav"], + phone=phone, + device_id=req.device_id, + ) + + +# ===================== 微信占用冲突(M2) ===================== + +@router.post( + "/wechat/conflict/continue", + response_model=WechatBindResultResponse, + summary="微信占用冲突·继续绑定(登录老账号,能绑就绑)", +) +def wechat_conflict_continue( + req: WechatConflictContinueRequest, request: Request, db: DbSession +) -> WechatBindResultResponse: + try: + claims = decode_conflict_ticket(req.conflict_ticket) + except TokenError as e: + raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e + + enforce_rate_limit( + request, scope="wechat-conflict-device", subject=req.device_id, + limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="操作过于频繁,请稍后再试", + ) + + user = user_repo.get_user_by_phone(db, claims["phone"]) + if user is None: + # P 期间被腾空(老账号改号/注销)→ 前提已变,让前端重走 + raise HTTPException(status_code=409, detail="账号状态已变化,请重新登录") + if user.status != "active": + raise HTTPException(status_code=403, detail="account disabled") + + if user.wechat_openid is None: + try: + user_repo.attach_wechat_to_user( + db, user, openid=claims["openid"], + wechat_nickname=claims["wnk"], wechat_avatar_url=claims["wav"], + ) + except IntegrityError: + db.rollback() # openid 被别处绑走 → 只登入不绑 + user_repo.touch_last_login(db, user) + else: + user_repo.touch_last_login(db, user) # X 已绑别的微信 → 只登入,丢弃本次 openid + + completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id) + logger.info("wechat conflict continue user_id=%d openid=%s***", user.id, claims["openid"][:6]) + return WechatBindResultResponse( + status="logged_in", + token=_login_response(user, onboarding_completed=completed), + ) + + +@router.post( + "/wechat/conflict/rebind", + response_model=WechatBindResultResponse, + summary="微信占用冲突·换绑(注销老账号+用该号重建全新账号)", +) +def wechat_conflict_rebind( + req: WechatConflictRebindRequest, request: Request, db: DbSession +) -> WechatBindResultResponse: + from app.core.config import settings # 局部 import,避免循环 + + try: + claims = decode_conflict_ticket(req.conflict_ticket) + except TokenError as e: + raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e + + enforce_rate_limit( + request, scope="wechat-conflict-device", subject=req.device_id, + limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="操作过于频繁,请稍后再试", + ) + + phone = claims["phone"] + if rebind_repo.rebound_within_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS): + days = rebind_repo.remaining_block_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS) + raise HTTPException(status_code=409, detail=f"该手机号 {days} 天内已换绑过,暂不能再次换绑") + + user = user_repo.rebind_account( + db, phone=phone, openid=claims["openid"], + wechat_nickname=claims["wnk"], wechat_avatar_url=claims["wav"], + ) + completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id) + logger.info("wechat conflict rebind new_user_id=%d phone=%s openid=%s***", + user.id, mask_phone(phone), claims["openid"][:6]) + return WechatBindResultResponse( + status="logged_in", + token=_login_response(user, onboarding_completed=completed), + ) + + # ===================== Refresh ===================== @router.post("/refresh", response_model=TokenPair, summary="用 refresh_token 换新 token 对") diff --git a/app/api/v1/compare.py b/app/api/v1/compare.py index 29362c1..b1f56ae 100644 --- a/app/api/v1/compare.py +++ b/app/api/v1/compare.py @@ -1,49 +1,114 @@ -"""外卖比价业务透传端点。 +"""外卖比价业务透传端点 + 后端 harvest 落库。 -把客户端的 POST /api/v1/intent/recognize 和 /api/v1/price/step 请求原样转发给 -pricebot-backend 的 /api/intent/recognize 和 /api/price/step。MVP 阶段**不鉴权** -(同 coupon/step,见 docs/待办与技术债.md P1:device_id 透传区分设备,待补 JWT + -device_id↔user_id 绑定后才能做用户级画像)。 +把客户端 POST /api/v1/intent/* 和 /api/v1/price/step 转发给 pricebot,同时**由 app-server +在透传里直接落库比价记录**(不再靠客户端 POST /compare/record): + - 帧0(客户端首帧不带 trace_id)→ app-server 用 uuid 签发 trace_id、注入转发 body、回给 + 客户端;并按 trace_id 建 running 行(harvest_running)。 + - 最终 done 帧 → 更新成 success/failed + 结果(harvest_done)。发奖不在这里:#113 已把 + 邀请奖口径从「比价」移到「实际下单」(order.py)。 + - /trace/finalize(用户终止/Phase1 未识别,无 done)→ 更新成 cancelled/failed + (harvest_abort,**不降级 success**)。 +trace_url 从 pricebot 响应**顶层 trace_url** 取(pricebot 每帧都带,见其 goal_engine.process_step)。 -- Phase 1 /intent/recognize:从源平台(淘宝闪购/美团/京东外卖)购物车页识别店名+菜品+ - 价格,一次性。 -- Phase 2 /price/step:多轮循环,在目标平台复现订单读到手价,直到 done。 +鉴权:软鉴权(OptionalUser)——新客户端带 JWT → 绑 user_id;老客户端不带 → user_id 暂空, +由其后续 /compare/record 上报补(灰度期两条写路径按 trace_id reconcile,success 不被降级)。 -真正的目标驱动比价逻辑在 pricebot-backend(GoalEngine,另一个 repo),本接口只是透传壳。 -电商(ecom)那两个端点 food MVP 暂不需要,以后放开 scene 时再加 /ecom/intent/recognize -和 /ecom/step 两行即可。 - -pricebot 协议文档: - pricebot-backend/docs/main/02_api_protocol.md +真正的目标驱动比价逻辑在 pricebot-backend(GoalEngine,另一个 repo),本接口是透传壳 + 落库。 +pricebot 协议文档: pricebot-backend/docs/main/02_api_protocol.md """ from __future__ import annotations import json import logging +import time +import uuid from typing import Any import httpx from fastapi import APIRouter, HTTPException, Request, status +from fastapi.concurrency import run_in_threadpool +from app.api.deps import OptionalUser from app.core.config import settings +from app.core.logging import trace_id_ctx from app.core.pricebot_client import get_pricebot_client from app.core.pricebot_router import pick_pricebot +from app.db.session import SessionLocal +from app.repositories import comparison as crud_compare logger = logging.getLogger("shagua.compare") router = APIRouter(prefix="/api/v1", tags=["compare"]) -async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]: - """把请求体原样转发给 pricebot-backend 的 {upstream_path}。 +# ============================================================ +# harvest 落库(阻塞 SQLAlchemy → run_in_threadpool,独立 SessionLocal,不阻塞事件循环; +# 任何写库异常都吞掉、绝不连累比价透传返回 —— 同 coupon.py 现有 best-effort 写法)。 +# ============================================================ - 跟 coupon_step 同款透传壳:不鉴权、不做 schema 校验,仅读 device_id/trace_id/step - 打日志。比价单帧是大上下文 / 逐帧 LLM,超时用 PRICEBOT_COMPARE_TIMEOUT_SEC(60s, - 比领券的 30s 长)。 + +def _harvest_running_blocking( + trace_id: str, user_id: int | None, business_type: str, + device_id: str | None, device_info: dict | None, trace_url: str | None, +) -> None: + with SessionLocal() as db: + crud_compare.harvest_running( + db, trace_id=trace_id, user_id=user_id, business_type=business_type, + device_id=device_id, device_info=device_info, trace_url=trace_url, + ) + logger.info( + "harvest running row (user=%s)", user_id, + extra={"phase": "harvest_running", "status": "running", "user_id": user_id}, + ) + + +def _harvest_done_blocking( + trace_id: str, user_id: int | None, done_params: dict, business_type: str, + device_id: str | None, device_info: dict | None, trace_url: str | None, +) -> None: + with SessionLocal() as db: + rec, newly_success = crud_compare.harvest_done( + db, trace_id=trace_id, user_id=user_id, done_params=done_params, + business_type=business_type, device_id=device_id, + device_info=device_info, trace_url=trace_url, + ) + logger.info( + "harvest done → %s saved=%s newly=%s", rec.status, + rec.saved_amount_cents, newly_success, + extra={"phase": "harvest_done", "status": rec.status, + "saved_cents": rec.saved_amount_cents, + "best_platform": rec.best_platform_id, "newly_success": newly_success, + "user_id": user_id}, + ) + # 不在此处发邀请奖:#113 已把发奖口径从「比价」移到「实际下单」(order.py),harvest + # 只记录比价、不发奖。否则比价先于下单 + try_reward 幂等闸会让奖落在「比价」这步, + # 架空 #113 的「下单才发奖」防刷意图(newly_success 仅留作日志观测)。 + + +def _harvest_abort_blocking( + trace_id: str, status_hint: str, reason: str | None, trace_url: str | None, +) -> None: + with SessionLocal() as db: + rec = crud_compare.harvest_abort( + db, trace_id=trace_id, status=status_hint, reason=reason, trace_url=trace_url, + ) + logger.info( + "harvest abort → %s", (rec.status if rec else "no-row"), + extra={"phase": "harvest_abort", + "status": (rec.status if rec else None), "reason": reason}, + ) + + +async def _forward( + request: Request, upstream_path: str, user, *, harvest_first_frame: bool = True, +) -> tuple[dict[str, Any], str, dict[str, Any]]: + """透传给 pricebot 的 {upstream_path},并 mint trace_id + 首帧建 running 行。 + 返回 (resp_json, trace_id, meta)。 + + - 客户端首帧不带 trace_id → app-server 用 uuid 签发、写进转发 body、回填进响应顶层 + `trace_id`(客户端据此拿到后续帧都带上)。带了(老客户端 / 后续帧)→ 原样用、走原始 bytes 快路。 + - 仅**本次 mint(=首帧)**建 running 行(idempotent);后续帧不再写库。 """ - # 读原始字节,避免"反序列化→再序列化"的双重 JSON(省 ~一半透传 CPU,让 app-server - # 单 worker 也扛得住高并发)。只 json.loads 一次拿 trace_id 做亲和 + 打日志,转发时 - # 直接发原始 bytes(content=raw),不重新 dumps。 raw = await request.body() try: meta = json.loads(raw) @@ -52,26 +117,41 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]: if not isinstance(meta, dict): meta = {} - # 按 trace_id 一致性 hash 选 pricebot 实例(同一比价的所有帧落同一进程,内存维护 state) - base = pick_pricebot(meta.get("trace_id")) + trace_id = meta.get("trace_id") + minted = False + if not trace_id: + trace_id = str(uuid.uuid4()) + meta["trace_id"] = trace_id + raw = json.dumps(meta).encode() # 仅首帧重新序列化(注入 trace_id);后续帧走原始 bytes + minted = True + + # 请求级 trace_id 贯穿:此后本请求(含 run_in_threadpool 里的 harvest)每行日志自动带 trace_id + trace_id_ctx.set(trace_id) + + base = pick_pricebot(trace_id) url = f"{base.rstrip('/')}{upstream_path}" timeout = settings.PRICEBOT_COMPARE_TIMEOUT_SEC + step = meta.get("step") logger.info( - "compare %s device_id=%s trace_id=%s step=%s", - upstream_path, - meta.get("device_id"), - meta.get("trace_id"), - meta.get("step"), + "→ pricebot %s step=%s%s", upstream_path, step, " [mint]" if minted else "", + extra={"phase": "forward", "endpoint": upstream_path, "step": step, + "device_id": meta.get("device_id"), "minted": minted, + "query": meta.get("query"), "user_id": (user.id if user else None)}, ) + t0 = time.monotonic() try: client = get_pricebot_client() resp = await client.post( url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout ) except httpx.RequestError as e: - logger.error("[pricebot] request failed: %s", e) + logger.error( + "← pricebot %s 不可达: %s", upstream_path, e, + extra={"phase": "resp", "endpoint": upstream_path, "step": step, + "error": "upstream_unreachable"}, + ) raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"pricebot upstream unreachable: {e}", @@ -79,49 +159,112 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]: if resp.status_code >= 500: logger.error( - "[pricebot] 5xx status=%d body=%s", - resp.status_code, - resp.text[:500], + "← pricebot %s 5xx=%d", upstream_path, resp.status_code, + extra={"phase": "resp", "endpoint": upstream_path, "step": step, + "http_status": resp.status_code, "error": "upstream_5xx"}, ) raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"pricebot upstream returned {resp.status_code}", ) - return resp.json() + resp_json = resp.json() + cost_ms = int((time.monotonic() - t0) * 1000) + if not isinstance(resp_json, dict): + return {}, trace_id, meta + # 回传 app-server 签发的 trace_id(新客户端首帧据此拿到,后续帧都带上它) + resp_json.setdefault("trace_id", trace_id) + _action = resp_json.get("action") or {} + logger.info( + "← pricebot %s cmd=%s cont=%s %dms", upstream_path, + _action.get("command"), resp_json.get("continue"), cost_ms, + extra={"phase": "resp", "endpoint": upstream_path, "step": step, + "command": _action.get("command"), "continue": resp_json.get("continue"), + "cost_ms": cost_ms}, + ) + + # 首帧建 running 行(仅本次 mint;老客户端自带 trace_id → 不 mint → 由 done / 其 POST 建行) + if minted and harvest_first_frame: + try: + await run_in_threadpool( + _harvest_running_blocking, trace_id, + (user.id if user else None), "food", + meta.get("device_id"), meta.get("device_info"), + resp_json.get("trace_url"), + ) + except Exception as e: # noqa: BLE001 + logger.warning("harvest_running failed trace=%s: %s", trace_id, e) + + return resp_json, trace_id, meta -@router.post("/intent/recognize", summary="外卖比价 Phase 1 意图识别 (透传到 pricebot)") -async def intent_recognize(request: Request) -> dict[str, Any]: - return await _passthrough(request, "/api/intent/recognize") +@router.post("/intent/recognize", summary="外卖比价 Phase 1 意图识别 (透传 + 建 running 行)") +async def intent_recognize(request: Request, user: OptionalUser) -> dict[str, Any]: + resp, _, _ = await _forward(request, "/api/intent/recognize", user) + return resp -@router.post("/intent/step", summary="外卖比价 Phase 1 多帧意图识别 (透传到 pricebot, 仅淘宝源)") -async def intent_step(request: Request) -> dict[str, Any]: - # 多帧版意图识别(展开+滚动采集→提取): 循环调用直到 done(done 帧顶层带 - # result+calibration)。目前仅淘宝源走这条, 其它源走上面单次 /intent/recognize。 - return await _passthrough(request, "/api/intent/step") +@router.post("/intent/step", summary="外卖比价 Phase 1 多帧意图识别 (透传, 仅淘宝源)") +async def intent_step(request: Request, user: OptionalUser) -> dict[str, Any]: + resp, _, _ = await _forward(request, "/api/intent/step", user) + return resp -@router.post( - "/intent/precoupon/step", - summary="外卖比价 Phase 0 意图识别前先用券 (透传到 pricebot, 仅美团源)", -) -async def intent_precoupon_step(request: Request) -> dict[str, Any]: - # 美团源平台『意图识别前先用券』多帧循环: 客户端在调 /intent/recognize 之前先循环 - # 调本端点到 done(continue=false)。订单页底部有『点击使用X红包』就自动选最大免费券 - # 用上, 已用券/无券则首帧秒过。与 /intent/step 同属 intent 域, 复用同一透传壳。 - return await _passthrough(request, "/api/intent/precoupon/step") +@router.post("/intent/precoupon/step", summary="外卖比价 Phase 0 识别前先用券 (透传, 仅美团源)") +async def intent_precoupon_step(request: Request, user: OptionalUser) -> dict[str, Any]: + resp, _, _ = await _forward(request, "/api/intent/precoupon/step", user) + return resp -@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("/price/step", summary="外卖比价 Phase 2 步进 (透传 + done 落库)") +async def price_step(request: Request, user: OptionalUser) -> dict[str, Any]: + resp, trace_id, meta = await _forward(request, "/api/price/step", user) + # 最终 done 帧(command=done 且 continue=false)→ harvest 更新成终态。 + # (单平台中途 done 被 pricebot 改写成 wait+continue=true,不会命中这里,同 coupon 语义。) + action = resp.get("action") or {} + if action.get("command") == "done" and not resp.get("continue", True): + done_params = action.get("params") or {} + try: + await run_in_threadpool( + _harvest_done_blocking, trace_id, (user.id if user else None), + done_params, "food", + meta.get("device_id"), meta.get("device_info"), + resp.get("trace_url") or done_params.get("trace_url"), + ) + except Exception as e: # noqa: BLE001 + logger.warning("harvest_done failed trace=%s: %s", trace_id, e) + return resp -@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") +@router.post("/trace/epilogue", summary="比价结果页尾声帧 (透传到 pricebot, 用户视角截图入 trace)") +async def trace_epilogue(request: Request, user: OptionalUser) -> dict[str, Any]: + # App 收到 done、渲染完结果页后,把自己页面的截图(base64)传给 pricebot 存进 trace + # 目录并触发重传 —— trace 里补上"用户实际看到的汇总页"(步骤帧只有目标 App 画面)。 + # body ~几百 KB(截图 base64), 纯透传壳(harvest_first_frame=False:不建行/不落库, + # 该 trace 的记录已由 done/finalize 落终态)。#112 原走 _passthrough,合并到 harvest + # 分支后统一走 _forward(_passthrough 已并入它)。 + resp, _, _ = await _forward( + request, "/api/trace/epilogue", user, harvest_first_frame=False, + ) + return resp + + +@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传 + 夭折落库)") +async def trace_finalize(request: Request, user: OptionalUser) -> dict[str, Any]: + # 用户终止 / Phase1 未识别没到 done 帧: pricebot 打包半截上云返回 {trace_url}; + # app-server 顺手把该 trace 的 running 行更新成夭折终态(**不降级 success**)。 + # 客户端 finalize body 带 status(cancelled/failed)+ reason;老客户端只带 trace_id → + # 默认 cancelled,其后续 /compare/record 上报再补精确态。 + resp, trace_id, meta = await _forward( + request, "/api/trace/finalize", user, harvest_first_frame=False, + ) + try: + await run_in_threadpool( + _harvest_abort_blocking, trace_id, + (meta.get("status") or "cancelled"), + (meta.get("reason") or meta.get("information")), + (resp.get("trace_url") if isinstance(resp, dict) else None), + ) + except Exception as e: # noqa: BLE001 + logger.warning("harvest_abort failed trace=%s: %s", trace_id, e) + return resp diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index 3e4fc7b..1c34215 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -1,15 +1,13 @@ -"""比价记录 endpoint(「我的比价记录」数据源)。 +"""比价记录 endpoint(「我的比价记录」+ admin 数据源)。 路由前缀 `/api/v1/compare`: - POST /record 上报一次比价结果(幂等:同 user+trace_id 覆盖) + POST /record (灰度期兼容)老客户端上报,按 **trace_id** 幂等 + 不降级 success GET /records 比价记录列表(游标分页) GET /records/{id} 单条详情(含 raw_payload 全量) -**均需鉴权**(CurrentUser)——与同文件无关的不鉴权透传 `compare.py` 分开:那个是 -转发壳(MVP 不鉴权),这里是按用户维度落库的业务接口,必须有 user_id。 - -注:本轮只做 server 端,客户端(android 仓)在 done 帧后调 POST /record 上报的改动 -另起一轮(见 app-server docs/待办与技术债.md P1)。 +**均需鉴权**(CurrentUser)。⚠️ 写路径现以 `compare.py` 透传壳的**后端 harvest** 为主 +(帧0 建 running 行 → done/finalize 落终态,新客户端不再 POST);本 POST /record 仅灰度期 +给老客户端用,与 harvest 按 trace_id reconcile。新版覆盖够高后可下线本 POST(阶段3)。 """ from __future__ import annotations @@ -21,16 +19,16 @@ 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.repositories import invite as crud_invite -from app.services.pricebot_llm_calls import fetch_llm_calls from app.schemas.compare_record import ( CompareStatsOut, ComparisonRecordCreatedOut, ComparisonRecordDetailOut, ComparisonRecordIn, - ComparisonRecordPage, ComparisonRecordOut, + ComparisonRecordPage, ) +from app.services.llm_cost import compute_llm_cost, get_llm_prices +from app.services.pricebot_llm_calls import fetch_llm_calls logger = logging.getLogger("shagua.compare_record") @@ -53,18 +51,8 @@ def report_record( # 任务做,不阻塞上报响应(顺带给 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) - # 邀请 v2 发奖:被邀请人完成一次【成功】比价 → 给邀请人发邀请奖励金(幂等,只发一次)。 - # best-effort:发奖异常不影响比价上报本身(rec 已 commit),只 log;邀请人补偿靠后续对账。 - if rec.status == "success": - try: - reward = crud_invite.try_reward_on_compare(db, user.id) - if reward.status == "granted": - logger.info( - "invite compare reward granted inviter=%s invitee=%s cents=%s", - reward.inviter_user_id, user.id, reward.reward_cents, - ) - except Exception as e: # noqa: BLE001 best-effort,发奖失败不阻塞上报 - logger.warning("invite compare reward failed invitee=%s: %s", user.id, e) + # 注:邀请发奖已从"比价成功"挪到"实际下单"(见 api/v1/order.py report_order)—— + # 冰拍板:被邀请人完成比价并实际下单才算邀请成功,仅完成比价不再发奖。 logger.info( "compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)", user.id, @@ -94,6 +82,8 @@ def _backfill_llm_calls(record_id: int, trace_id: str) -> None: # error 的调用 usage 可能为 None,or {} 兜底) rec.input_tokens = sum((c.get("usage") or {}).get("prompt_tokens") or 0 for c in calls) rec.output_tokens = sum((c.get("usage") or {}).get("completion_tokens") or 0 for c in calls) + # 本次比价 LLM 成本(元)+ 当时单价快照:按 app_config 现价逐模型算好冻结(services/llm_cost.py)。 + rec.llm_cost_yuan, rec.llm_price_snapshot = compute_llm_cost(calls, get_llm_prices(db)) db.commit() logger.info( "backfill llm_calls trace=%s n=%d in_tok=%d out_tok=%d", diff --git a/app/api/v1/coupon.py b/app/api/v1/coupon.py index 1a5368d..bd2127a 100644 --- a/app/api/v1/coupon.py +++ b/app/api/v1/coupon.py @@ -81,7 +81,16 @@ def _record_claims_blocking( device_id: str, user_id: int | None, trace_id: str | None, results: list[dict] ) -> None: with SessionLocal() as db: - coupon_repo.record_claims(db, device_id, user_id, trace_id, results) + # 取本次 session 环境,给 coupon_claim_record 打 app_env 标(每券成功率表按它过滤;设计 §13)。 + app_env = coupon_repo.session_app_env(db, trace_id) + coupon_repo.record_claims(db, device_id, user_id, trace_id, results, app_env=app_env) + # 顺带把本帧「成功平台」并入 coupon_session.platform_success(admin 领券数据 ②整单/③点位成功率; + # 设计 route B,见 docs/guides/领券成功率指标-设计与埋点.md)。复用同一 SessionLocal、紧接 record_claims, + # 不新增连接;并集幂等(无新平台不写),trace_id 缺失或 session 行未落库则跳过。 + if trace_id: + coupon_repo.merge_session_platform_success( + db, trace_id, coupon_repo.succeeded_platforms(results) + ) def _mark_completed_blocking( diff --git a/app/api/v1/meituan.py b/app/api/v1/meituan.py index 5bb0031..c1d07a1 100644 --- a/app/api/v1/meituan.py +++ b/app/api/v1/meituan.py @@ -25,9 +25,19 @@ from app.schemas.meituan import ( ReferralLinkResponse, TopSalesRequest, ) +from app.utils.meituan_city import get_meituan_city logger = logging.getLogger("shagua.meituan") + +def _resolve_city_id(latitude: float, longitude: float) -> str: + """经纬度 → 美团城市 ID;解析失败返 ""(调用方应降级返空)。""" + try: + return get_meituan_city(latitude, longitude).get("city_id", "") + except Exception: + logger.exception("get_meituan_city 失败") + return "" + router = APIRouter(prefix="/api/v1/meituan", tags=["meituan-cps"]) @@ -175,13 +185,19 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse: status = "degraded" if (not cards and wm_fail and dd_fail) else ("ok" if cards else "empty") return FeedResponse(items=cards, has_next=wm_hn or dd_hn, page=req.page, status=status) - # 智能推荐(rec):走【离线库】筛佣金率 ≥ 3%,分页返回(SQL 侧去重+排序+分页,秒级、不打美团)。 + # 智能推荐(rec):走【离线库】筛佣金率 ≥ 3%,按城市过滤,分页返回(SQL 侧去重+排序+分页,秒级、不打美团)。 # 实测库里佣金≥3% 去重后仅 ~578 条(几乎全是外卖;到店团购佣金普遍 <3%):实时按"同城热销榜单" # 拉既撞限流、又填不满(该榜单中位佣金 ~0.8%,筛完每页剩 0-1 条),故从库出。佣金阈值逻辑不变。 if tab == "rec": + city_id = _resolve_city_id(lat, lon) + if not city_id: + return FeedResponse(items=[], has_next=False, page=req.page, status="degraded") PAGE = 20 try: - base = select(MeituanCoupon).where(MeituanCoupon.commission_percent >= 3.0) + base = select(MeituanCoupon).where( + MeituanCoupon.commission_percent >= 3.0, + MeituanCoupon.city_id == city_id, + ) deduped = base.distinct(MeituanCoupon.dedup_key).order_by( MeituanCoupon.dedup_key, MeituanCoupon.commission_percent.desc(), @@ -211,6 +227,9 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse: card.distance_text = None card.distance_meters = None cards.append(card) + if not cards and req.page == 1: + # 命中城市却 0 券:该城确无 ≥3% 券,或 ETL 灌的 city_id 与 city_dict 口径不一致。 + logger.info("[feed] rec city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id) return FeedResponse(items=cards, has_next=has_next, page=req.page, status="ok" if cards else "empty") @@ -254,14 +273,24 @@ def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse: @router.post("/top-sales", response_model=CouponListResponse, - summary="销量最高(从离线库 meituan_coupon 按销量降序 + 跨源去重,不实时打美团)") + summary="销量最高(从离线库 meituan_coupon 按销量降序 + 跨源去重,按城市过滤,不实时打美团)") def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponListResponse: + # 按设备经纬度定位城市,只查同城券;老客户端不带坐标 → 降级返空(不 422、不误返全城)。 + if req.latitude is None or req.longitude is None: + return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded") + city_id = _resolve_city_id(req.latitude, req.longitude) + if not city_id: + return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded") + # 去重 + 排序 + 分页全在 SQL 做,每页只取并解析当前页 ~20 条。 # (之前实现每翻一页都全表拉取 + 全量 from_raw 解析,翻页慢 → 客户端滑动卡顿/翻不动。) # 库为空(prod 刚部署 / ETL 未跑完)时返空 + status=empty,不崩;库查询异常降级 degraded。 try: # 1) DISTINCT ON (dedup_key):每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金) - base = select(MeituanCoupon).where(MeituanCoupon.sale_volume_num.isnot(None)) + base = select(MeituanCoupon).where( + MeituanCoupon.sale_volume_num.isnot(None), + MeituanCoupon.city_id == city_id, + ) if req.platform is not None: base = base.where(MeituanCoupon.platform == req.platform) deduped = base.distinct(MeituanCoupon.dedup_key).order_by( @@ -292,6 +321,14 @@ def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponList except Exception: # noqa: BLE001 continue if card.product_view_sign: + # 不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导)。 + # 置空后前端"距离 店名"那行只剩店名、自动顶到最左(店名移到原距离的位置)。 + # 逻辑与推荐流保持一致 + card.distance_text = None + card.distance_meters = None cards.append(card) + if not cards and req.page == 1: + # 命中城市却 0 券:可能该城确无券,也可能 ETL 灌的 city_id 与 city_dict 口径不一致(静默降级的隐患)。 + logger.info("[top-sales] city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id) return CouponListResponse(items=cards, has_next=has_next, search_id=None, status="ok" if cards else "empty") diff --git a/app/api/v1/order.py b/app/api/v1/order.py index c2f9bb3..86340e3 100644 --- a/app/api/v1/order.py +++ b/app/api/v1/order.py @@ -1,9 +1,14 @@ +import logging + from fastapi import APIRouter from app.api.deps import CurrentUser, DbSession +from app.repositories import invite as crud_invite from app.repositories import savings as crud_savings from app.schemas.order import OrderReportOut, OrderReportRequest +logger = logging.getLogger("shagua.order") + router = APIRouter(prefix="/api/v1/order", tags=["order"]) @@ -15,6 +20,20 @@ router = APIRouter(prefix="/api/v1/order", tags=["order"]) def report_order(req: OrderReportRequest, user: CurrentUser, db: DbSession) -> OrderReportOut: # 记账唯一真相表是 savings_record(source='compare')。 rec, duplicated = crud_savings.create_from_report(db, user.id, req) + # 邀请 v2 发奖:被邀请人【实际下单】(而非仅完成比价)才给邀请人发邀请奖励金(幂等,只发一次)。 + # 触发点从"比价成功上报"挪到这里(冰:完成比价并实际下单才算成功)。仅首次真实上报触发, + # 重复上报(duplicated)跳过。best-effort:发奖异常不影响订单上报本身(rec 已 commit),只 log; + # try_reward_on_compare 自带 compare_reward_granted 幂等闸,漏发靠后续对账补。 + if not duplicated: + try: + reward = crud_invite.try_reward_on_compare(db, user.id) + if reward.status == "granted": + logger.info( + "invite order reward granted inviter=%s invitee=%s cents=%s", + reward.inviter_user_id, user.id, reward.reward_cents, + ) + except Exception as e: # noqa: BLE001 best-effort,发奖失败不阻塞订单上报 + logger.warning("invite order reward failed invitee=%s: %s", user.id, e) return OrderReportOut( id=rec.id, platform=rec.platform or req.platform, diff --git a/app/api/v1/user.py b/app/api/v1/user.py index dc23388..f310b2b 100644 --- a/app/api/v1/user.py +++ b/app/api/v1/user.py @@ -69,6 +69,18 @@ def complete_onboarding( return OkResponse() +@router.post("/onboarding/reset", response_model=OkResponse, summary="重置新手引导(删该 设备+账号 完成标记,下次登录重走)") +def reset_onboarding( + req: OnboardingCompleteRequest, user: CurrentUser, db: DbSession +) -> OkResponse: + """删该 (当前账号, device_id) 的引导完成标记 → 下次登录 onboarding_completed=false,客户端重走。 + 与 /onboarding/complete 互逆。device_id 取客户端硬件级 ANDROID_ID(与登录/complete 一致)。 + 幂等:无记录也返回 ok。仅删自己(当前 JWT 用户)这台设备的记录,不影响别的账号/设备。""" + deleted = onboarding_repo.delete_completion(db, user_id=user.id, device_id=req.device_id) + logger.info("onboarding reset user_id=%d device_len=%d deleted=%d", user.id, len(req.device_id), deleted) + return OkResponse() + + @router.get( "/onboarding/status", response_model=OnboardingStatusResponse, diff --git a/app/api/v1/wallet.py b/app/api/v1/wallet.py index 29a0407..454ff7c 100644 --- a/app/api/v1/wallet.py +++ b/app/api/v1/wallet.py @@ -43,6 +43,7 @@ from app.schemas.welfare import ( WithdrawRequest, WithdrawResultOut, WithdrawStatusOut, + WithdrawTierOut, ) logger = logging.getLogger("shagua.wallet") @@ -173,8 +174,15 @@ def unbind_wechat( return UnbindWechatResultOut(bound=False) -@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关") -def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut: +@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关/档位") +def withdraw_info( + user: CurrentUser, + db: DbSession, + source: str = Query( + "coin_cash", + description="提现账户:coin_cash(福利页,下发 tiers 档位) / invite_cash(邀请页,tiers 为空走旧逻辑)", + ), +) -> WithdrawInfoOut: u = db.get(User, user.id) # 顺带同步免确认授权状态(捕获首单确认后已生效的授权 pending→active),让开关展示实时 auth = crud_wallet.sync_transfer_auth(db, user.id) @@ -185,6 +193,7 @@ def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut: wechat_nickname=u.wechat_nickname if u else None, wechat_avatar_url=u.wechat_avatar_url if u else None, transfer_auth_enabled=bool(auth and auth.state == "active"), + tiers=[WithdrawTierOut(**t) for t in crud_wallet.withdraw_tier_states(db, user.id, source)], ) @@ -218,6 +227,9 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw status_code=status.HTTP_409_CONFLICT, detail="已有提现申请正在审核或打款中,请处理完成后再申请", ) from e + except crud_wallet.WithdrawTierUnavailableError as e: + # 福利页档位闸(7-9):次数满/已选其他额度。正常客户端已按 tiers 预拦,此处兜底防绕过。 + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="今日额度已达上限") from e except crud_wallet.InsufficientCashError as e: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="现金余额不足") from e diff --git a/app/core/config.py b/app/core/config.py index cdcc5e6..2f13587 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -44,6 +44,11 @@ class Settings(BaseSettings): JWT_ALGORITHM: str = "HS256" JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 120 JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30 + # 微信登录未命中 openid 时签发的"待绑手机"令牌有效期(JWT_SECRET_KEY 签名,typ=wechat_bind; + # 见 security.create_bind_ticket)。需覆盖"授权→输手机号→收短信→输验证码"整个绑定流程。 + WECHAT_BIND_TICKET_EXPIRE_MINUTES: int = 10 + # 一个手机号 30 天内最多换绑一次(微信占用冲突页的"换绑"动作)。见 phone_rebind_log。 + PHONE_REBIND_LIMIT_DAYS: int = 30 # ===== Admin 后台 ===== # admin 用独立 JWT secret(≠ JWT_SECRET_KEY),App 用户 token 无法越权访问后台。 @@ -159,6 +164,9 @@ class Settings(BaseSettings): # 美团调用走的代理。本机开发直连美团会 SSL EOF,需填 http://127.0.0.1:7897; # 线上国内服务器留空(=直连)。见 .env.example 与 integrations/meituan.py。 MT_CPS_PROXY: str = "" + # 本地开发:开启后 /feed 接口直接返回 mock 数据,不调美团 API、不查离线库, + # 方便前端联调 feed 卡片样式、分页、距离排序等 UI。生产必须 false。 + MT_CPS_MOCK_FEED: bool = True @property def mt_cps_configured(self) -> bool: @@ -222,6 +230,15 @@ class Settings(BaseSettings): # 进程内自动兑换 worker 的检查间隔(秒):每隔这么久醒一次,跨过北京 0 点就跑一轮。 # 默认 600s=10min,即 0 点后最多 10 分钟内兑完(客户端文案已注明「可能存在延迟」)。 AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600 + # === 15 天不活跃清零(app.core.inactivity_reset_worker,worker 常驻)=== + # ENABLED 只决定是否**真清**:false(默认)= 只记审计名单、不动钱(dry-run,灰度看名单); + # true = 真清金币 + 折算现金(邀请金不清)。看准名单后再置 true。 + INACTIVITY_RESET_ENABLED: bool = False + INACTIVITY_RESET_DAYS: int = 15 # 不活跃阈值(天),第 (N+1) 日 0 点清 + INACTIVITY_WARN_DAYS_BEFORE: str = "7,2" # 清零前几天各推一次;""=不推。逗号分隔 + INACTIVITY_RESET_RUN_HOUR: int = 3 # 北京时间每日执行点(0-23) + INACTIVITY_NOTIFY_CHANNEL: str = "log" # log(占位) / jpush / sms + INACTIVITY_RESET_CHECK_INTERVAL_SEC: int = 1800 # worker 唤醒间隔(秒) # 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。 # 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容 # (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。 @@ -242,6 +259,19 @@ class Settings(BaseSettings): """免确认收款授权可用 = 微信支付凭证齐全 + 授权回调地址已配。""" return bool(self.wxpay_configured and self.WXPAY_AUTH_NOTIFY_URL) + @property + def inactivity_warn_stages(self) -> list[int]: + """解析 INACTIVITY_WARN_DAYS_BEFORE → 降序去重的提前天数列表。 + 丢弃非数字 / <=0 / >=RESET_DAYS 的项(空串 → 空列表 = 不推)。""" + out: list[int] = [] + for part in (self.INACTIVITY_WARN_DAYS_BEFORE or "").split(","): + part = part.strip() + if part.isdigit(): + v = int(part) + if 0 < v < self.INACTIVITY_RESET_DAYS and v not in out: + out.append(v) + return sorted(out, reverse=True) + # ===== 穿山甲激励视频(服务端发奖回调)===== # 看完激励视频后穿山甲服务器回调本服务发金币(S2S,客户端被破解也刷不到)。 # 穿山甲后台配置的"奖励校验密钥"(m-key),验签用。每个 GroMore 广告位 m-key 不同(后台各自 @@ -369,6 +399,31 @@ class Settings(BaseSettings): return [] return [o.strip() for o in self.CORS_ALLOW_ORIGINS.split(",") if o.strip()] + # ===== 可观测(OpenObserve 接口指标)===== + # 采集每个接口的 QPS + 耗时 + 错误率,批量直采到 OpenObserve(本地 Docker)。 + # 默认关(prod 安全):未开启 → 中间件透传、worker 不启动,整套 no-op。 + # 开启需 ENABLED=true 且 ENDPOINT/USER/PASSWORD 齐全(见 observe_configured)。 + OBSERVE_ENABLED: bool = False + OBSERVE_ENDPOINT: str = "http://localhost:5080" # OpenObserve base URL + OBSERVE_ORG: str = "default" # 组织名 + OBSERVE_STREAM: str = "app_requests" # stream 名(首次上报自动建) + OBSERVE_USER: str = "" # Basic auth 邮箱 + OBSERVE_PASSWORD: str = "" # Basic auth 密码/token + OBSERVE_FLUSH_INTERVAL_SEC: float = 5.0 # worker 最长攒批间隔 + OBSERVE_BATCH_MAX: int = 200 # 单批最大事件数 + OBSERVE_QUEUE_MAX: int = 10000 # 有界队列上限,满则丢 + OBSERVE_TIMEOUT_SEC: float = 5.0 # 上报 HTTP 超时 + + @property + def observe_configured(self) -> bool: + """观测上报可用 = 总开关开 且 endpoint/账号/密码齐全(缺则整套 no-op)。""" + return bool( + self.OBSERVE_ENABLED + and self.OBSERVE_ENDPOINT + and self.OBSERVE_USER + and self.OBSERVE_PASSWORD + ) + @property def is_prod(self) -> bool: return self.APP_ENV == "prod" diff --git a/app/core/config_schema.py b/app/core/config_schema.py index 5a56c3a..9edb043 100644 --- a/app/core/config_schema.py +++ b/app/core/config_schema.py @@ -11,7 +11,10 @@ from typing import Any from app.core import rewards as r -# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool +# type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool / enum +# hidden=True:仍是合法可配项(业务照常 get_value / admin 可经专用端点读写),但**不在通用 +# 「系统配置」页渲染**(admin/routers/config.py:list_config 按此过滤)。用于把已下线/已改由 +# 专用页管理的项从福利页 Tab 收起,同时保留后端默认值与写入能力。 CONFIG_DEFS: dict[str, dict[str, Any]] = { "signin_rewards": { "default": list(r.SIGNIN_REWARDS), "label": "签到 7 天金币档位", @@ -30,18 +33,21 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { "default": r.WITHDRAW_MAX_CENTS, "label": "提现最高额(分)", "group": "钱包", "type": "int", }, + # 从福利页 Tab 收起(hidden)的项:任务 / 里程碑 整组 + 看广告组里的「单次金币(遗留展示值)/ + # 每轮次数 / 比价领券信息流广告开关 comparing_ad_enabled」。key 与默认值保留、后端业务照常读取, + # 仅不在配置页渲染。看广告组保留可见的:每日上限 / 单次金币上限 / 关闭后冷却。 "task_rewards": { "default": dict(r.TASK_REWARDS), "label": "一次性任务奖励", - "group": "任务", "type": "dict_str_int", "help": "task_key → 金币。", + "group": "任务", "type": "dict_str_int", "help": "task_key → 金币。", "hidden": True, }, "record_milestones": { "default": list(r.RECORD_MILESTONES), "label": "比价里程碑金币档位", - "group": "里程碑", "type": "int_list", + "group": "里程碑", "type": "int_list", "hidden": True, "help": "累计成功比价第 1~N 档解锁发的金币。", }, "ad_reward_coin": { "default": r.AD_REWARD_COIN, "label": "看广告单次金币", - "group": "看广告", "type": "int", + "group": "看广告", "type": "int", "hidden": True, "help": "历史兼容/测试展示值;正式发放按 eCPM 公式计算。", }, "ad_daily_limit": { @@ -54,7 +60,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { }, "ad_round_count": { "default": r.VIDEO_ROUND_REQUIRED_COUNT, "label": "每轮看广告次数", - "group": "看广告", "type": "int", "help": "当前为 1,表示每次广告关闭后触发短冷却。", + "group": "看广告", "type": "int", "hidden": True, "help": "当前为 1,表示每次广告关闭后触发短冷却。", }, "ad_cooldown_sec": { "default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)", @@ -67,7 +73,7 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { }, "comparing_ad_enabled": { "default": True, "label": "比价/领券期信息流广告", - "group": "看广告", "type": "bool", + "group": "看广告", "type": "bool", "hidden": True, "help": ( "开启后,比价进行中 + 领券等候期会在悬浮窗展示穿山甲信息流广告(变现行为);" "关闭则全程不出广告。客户端按 app 启动 / 每场比价开始时拉取并缓存,故为「最终一致」的" @@ -84,4 +90,25 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { "提现页「批量对账」手动按钮不受影响。" ), }, + # 首页轮播数据源(hidden:不进通用配置 Tab,由「首页轮播种子」页的专用端点 /marquee-seeds/mode 读写)。 + "marquee_feed_mode": { + "default": "mixed", "label": "首页轮播数据源", + "group": "首页轮播", "type": "enum", "hidden": True, + "help": "mixed=真实优先+种子补位(默认);real=只用真实比价记录;seed=只用种子/合成(演示)。", + }, + # 比价 LLM 调用成本计价。值是嵌套 JSON(非 str→int),借 dict_str_int 类型在配置页走原始 JSON + # 编辑框;set_value 不校验类型,嵌套 JSON 照存。 + "llm_token_price": { + "default": { + "per_model": {"qwen3.5-flash": {"input_per_1m": 0.8, "output_per_1m": 2.0}}, + "default": {"input_per_1m": 3.0, "output_per_1m": 15.0}, + "currency": "CNY", "unit": "per_1m_tokens", + }, + "label": "LLM 模型单价(元/百万 token)", + "group": "LLM 成本", "type": "dict_str_int", + "help": ( + "比价 LLM 调用成本计价。JSON:per_model 按模型配 input/output 单价(元/1M token)," + "default 兜底未登记的模型。改价只影响之后回填的新记录,历史记录用当时价格快照。" + ), + }, } diff --git a/app/core/inactivity_reset_worker.py b/app/core/inactivity_reset_worker.py new file mode 100644 index 0000000..b7e7425 --- /dev/null +++ b/app/core/inactivity_reset_worker.py @@ -0,0 +1,145 @@ +"""15 天不活跃清零的进程内每日任务。 + +仿 daily_exchange_worker:App 启动自带,每 `INACTIVITY_RESET_CHECK_INTERVAL_SEC` 醒一次, +跨进北京新的一天且到达 `INACTIVITY_RESET_RUN_HOUR`(默认 3 点)后跑一轮 `run_once`(预警 + 清零)。 + +健壮性: +- **逐用户幂等**:清完余额=0 次日不再匹配;预警按 streak 去重。启动补跑 / 多次唤醒 / 重启都安全。 +- **同机多进程互斥**:文件锁保证多 worker 只有一个实际跑。 +- **常驻 + dry-run 默认**:worker 一直跑;INACTIVITY_RESET_ENABLED=false(默认)只记审计名单、 + 不动钱(dry-run 灰度看名单),=true 才真清。 + +⚠️ 这是不可逆批量资金操作(清空金币 + 折算现金,**邀请现金不清**)。口径见 +app.repositories.inactivity / app.repositories.activity。 +""" +from __future__ import annotations + +import asyncio +import contextlib +import logging +import os +import time +from collections.abc import Iterator +from datetime import date, datetime +from pathlib import Path + +from sqlalchemy.exc import SQLAlchemyError + +from app.core.config import settings +from app.core.rewards import CN_TZ, cn_today +from app.db.session import SessionLocal +from app.integrations.notifier import get_notifier +from app.repositories import inactivity as inactivity_repo + +logger = logging.getLogger("shagua.inactivity") +_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "inactivity_reset.lock" + + +def _cn_today() -> date: + return cn_today() + + +def _touch_lock() -> None: + with contextlib.suppress(FileNotFoundError): + os.utime(_LOCK_PATH, None) + + +@contextlib.contextmanager +def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]: + """同机多进程保护:同一时间只允许一个清零 worker 运行。""" + _LOCK_PATH.parent.mkdir(parents=True, exist_ok=True) + fd: int | None = None + try: + try: + fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except FileExistsError: + try: + age = time.time() - _LOCK_PATH.stat().st_mtime + except FileNotFoundError: + age = stale_after_sec + 1 + if age > stale_after_sec: + with contextlib.suppress(FileNotFoundError): + _LOCK_PATH.unlink() + try: + fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except FileExistsError: + fd = None + + if fd is None: + yield False + return + + os.write(fd, f"pid={os.getpid()} started_at={int(time.time())}\n".encode("ascii")) + yield True + finally: + if fd is not None: + os.close(fd) + with contextlib.suppress(FileNotFoundError): + _LOCK_PATH.unlink() + + +def _run_once_entry() -> dict: + """跑一轮(预警 + 清零)。独立开 Session。""" + notifier = get_notifier(settings.INACTIVITY_NOTIFY_CHANNEL) + with SessionLocal() as db: + return inactivity_repo.run_once( + db, + notifier=notifier, + reset_days=settings.INACTIVITY_RESET_DAYS, + warn_stages=settings.inactivity_warn_stages, + today=_cn_today(), + dry_run=not settings.INACTIVITY_RESET_ENABLED, # ENABLED=false → 只记审计名单、不清 + ) + + +async def _run_loop() -> None: + interval = max(60, int(settings.INACTIVITY_RESET_CHECK_INTERVAL_SEC)) + lock_stale_after = max(interval * 3, 1800) + with _single_instance_lock(lock_stale_after) as lock_acquired: + if not lock_acquired: + logger.warning("inactivity reset skipped: another worker owns lock") + return + await _run_locked_loop(interval) + + +async def _run_locked_loop(interval: int) -> None: + logger.info( + "inactivity reset worker started interval=%ss run_hour=%s mode=%s", + interval, + settings.INACTIVITY_RESET_RUN_HOUR, + "clear" if settings.INACTIVITY_RESET_ENABLED else "dry-run(audit-only)", + ) + # 本进程上次跑过的北京日;None=尚未跑过本进程(当天到点即补)。 + last_run: date | None = None + try: + while True: + try: + _touch_lock() + today = _cn_today() + hour = datetime.now(CN_TZ).hour + if last_run != today and hour >= int(settings.INACTIVITY_RESET_RUN_HOUR): + result = await asyncio.to_thread(_run_once_entry) + last_run = today + logger.info("inactivity reset done date=%s result=%s", today, result) + except SQLAlchemyError: + logger.exception("inactivity reset db error") + except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出 + logger.exception("inactivity reset unexpected error") + await asyncio.sleep(interval) + except asyncio.CancelledError: + logger.info("inactivity reset worker stopped") + raise + + +def start_inactivity_reset_worker() -> asyncio.Task | None: + # worker 常驻(不再有"完全关"档);INACTIVITY_RESET_ENABLED 只决定是否**真清**: + # false(默认)= 只记审计名单(dry-run,不动钱),true = 真清金币+现金。 + return asyncio.create_task(_run_loop(), name="inactivity-reset") + + +async def stop_inactivity_reset_worker(task: asyncio.Task | None) -> None: + if task is None: + return + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task diff --git a/app/core/logging.py b/app/core/logging.py index 3e63ea5..cb91b5c 100644 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -2,9 +2,15 @@ 业务代码用 `logger = logging.getLogger("shagua.xxx")` 即可, 本模块在 main.py 启动时调一次。 -- 控制台(stdout): 人类可读文本, 给 systemd / 本地看。 -- 文件 `logs/app-server.log`: 单行 JSON, 供阿里云 SLS/Logtail 采集(JSON 模式零正则); - 异常栈作为字段内嵌不换行 → 每条日志一行。 +- 控制台(stdout): 人类可读文本, 给 systemd / 本地看; 有 trace 时行尾附 `trace=xxx`。 +- 文件 `logs/app-server.log`: 单行 JSON, 供**阿里云 SLS / Logtail** 采集(JSON 模式零正则); + 异常栈内嵌为字段不换行 → 每条日志一行。 +- **结构化字段(SLS 可直接查/聚合)**: + - `trace_id`: 请求级贯穿——在入口 `trace_id_ctx.set(...)` 后, 本请求内**每一行日志**(含 + run_in_threadpool 里的 harvest, contextvars 自动拷进线程)都自动带上, 无需手写。 + SLS 里 `trace_id: "xxx"` 一查即得整条比价链路, 按 time 升序即请求顺序。 + - 任意 `logger.info(msg, extra={"phase": ..., "step": ..., "command": ...})` 的 extra + 键都会平铺进 JSON 顶层 → SLS 可按 phase/step/command/cost_ms 等过滤聚合。 - 环境变量: - LOG_JSON_CONSOLE=1 控制台也输出 JSON - LOG_DIR / LOG_FILE 改落盘路径(默认 logs/app-server.log) @@ -17,13 +23,36 @@ import json import logging import os import sys +from contextvars import ContextVar from datetime import datetime from logging.handlers import RotatingFileHandler from pathlib import Path +# 请求级 trace_id:入口(如 compare.py 透传壳)set 之后, 本请求上下文(含 run_in_threadpool +# 拷贝出去的线程)内所有日志自动带上。默认空串 = 非请求上下文(启动/后台 worker)。 +trace_id_ctx: ContextVar[str] = ContextVar("trace_id", default="") + + +# 标准 LogRecord 属性 + 格式化期附加项:凡不在此集合的 record 属性都视为业务 extra, 平铺进 JSON。 +_RESERVED = set( + logging.LogRecord("", 0, "", 0, "", (), None).__dict__ +) | {"message", "asctime", "trace_id", "taskName"} + + +class _ContextFilter(logging.Filter): + """把 trace_id_ctx 注入每条 record(供两个 formatter 取用)。挂在 handler 上, + 命中每条(含 propagate 上来的)记录, 在 format 之前置好 record.trace_id。""" + + def filter(self, record: logging.LogRecord) -> bool: + if not hasattr(record, "trace_id"): + record.trace_id = trace_id_ctx.get() + return True + + class JsonFormatter(logging.Formatter): - """把 LogRecord 序列化成单行 JSON(SLS/Logtail 友好)。异常栈内嵌为字段, 整条仍是一行。""" + """LogRecord → 单行 JSON(SLS/Logtail 友好)。trace_id 提到顶层、extra 键平铺, 异常栈内嵌。""" + def __init__(self, service: str = "app-server"): super().__init__() self.service = service @@ -35,10 +64,17 @@ class JsonFormatter(logging.Formatter): "level": record.levelname, "service": self.service, "logger": record.name, - "func": record.funcName, - "line": record.lineno, - "message": record.getMessage(), } + tid = getattr(record, "trace_id", "") or trace_id_ctx.get() + if tid: + data["trace_id"] = tid + # 业务 extra 字段(phase / step / endpoint / command / cost_ms / status ...)平铺进顶层 + for k, v in record.__dict__.items(): + if k not in _RESERVED and not k.startswith("_"): + data[k] = v + data["func"] = record.funcName + data["line"] = record.lineno + data["message"] = record.getMessage() if record.exc_info: data["exception"] = self.formatException(record.exc_info) if record.stack_info: @@ -46,6 +82,15 @@ class JsonFormatter(logging.Formatter): return json.dumps(data, ensure_ascii=False, default=str) +class TextFormatter(logging.Formatter): + """控制台文本:标准行 + 有 trace_id 时行尾附 `trace=xxx`(无 trace 的启动/后台日志不加噪)。""" + + def format(self, record: logging.LogRecord) -> str: + base = super().format(record) + tid = getattr(record, "trace_id", "") or trace_id_ctx.get() + return f"{base} trace={tid}" if tid else base + + _CONFIGURED = False @@ -63,14 +108,17 @@ def setup_logging(debug: bool = False) -> None: for h in list(root.handlers): root.removeHandler(h) + ctx_filter = _ContextFilter() + # 控制台: 默认文本(systemd/本地看); LOG_JSON_CONSOLE=1 时输出 JSON console = logging.StreamHandler(sys.stdout) if os.getenv("LOG_JSON_CONSOLE") == "1": console.setFormatter(JsonFormatter(service)) else: console.setFormatter( - logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s") + TextFormatter("%(asctime)s %(levelname)s %(name)s: %(message)s") ) + console.addFilter(ctx_filter) root.addHandler(console) # 文件: 单行 JSON, 供 Logtail 采集(自动轮转, 单文件 10MB, 保留 5 个) @@ -82,6 +130,7 @@ def setup_logging(debug: bool = False) -> None: log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8", ) file_handler.setFormatter(JsonFormatter(service)) + file_handler.addFilter(ctx_filter) root.addHandler(file_handler) # 第三方库降噪 diff --git a/app/core/observe.py b/app/core/observe.py new file mode 100644 index 0000000..6c8deb1 --- /dev/null +++ b/app/core/observe.py @@ -0,0 +1,110 @@ +"""接口指标埋点:有界事件队列 + 纯 ASGI 中间件。 + +每个 HTTP 请求测总耗时、抓路由模板 + 状态码,非阻塞塞进有界队列;由 observe_worker +后台批量上报到 OpenObserve。请求路径上无任何 I/O。未配置观测时中间件直接透传。 +""" +from __future__ import annotations + +import asyncio +import os +import time + +from starlette.routing import Match + +from app.core.config import settings + +# 不采集的路径(纯噪音):健康检查。 +_SKIP_PATHS = frozenset({"/health"}) +# 未匹配路由(404/扫描器)归一到此,防维度爆炸。 +_UNMATCHED = "__unmatched__" +# service 字段:与 logging.py 同源(LOG_SERVICE_NAME),默认 app-server。 +_SERVICE = os.getenv("LOG_SERVICE_NAME", "app-server") + +# 有界事件队列(懒创建,见 get_queue):首次取用时在运行中的 loop 里建,避免 import 期 +# 无 loop 的边角问题;put_nowait/get_nowait 不需运行中的 loop → 可在无 loop 下测试。 +_queue: asyncio.Queue[dict] | None = None +# 队列满时的丢弃计数,worker 定期取出打日志。 +_dropped = 0 + + +def get_queue() -> asyncio.Queue[dict]: + """返回全局有界事件队列(懒创建)。测试可 monkeypatch 模块级 _queue 换成小队列。""" + global _queue + if _queue is None: + _queue = asyncio.Queue(maxsize=settings.OBSERVE_QUEUE_MAX) + return _queue + + +def take_dropped() -> int: + """取出并清零累计丢弃数(供 worker 打点)。""" + global _dropped + n, _dropped = _dropped, 0 + return n + + +def record_event(event: dict) -> None: + """非阻塞入队;队列满则丢弃当前事件并计数。永不抛异常、永不阻塞请求。""" + global _dropped + try: + get_queue().put_nowait(event) + except asyncio.QueueFull: + _dropped += 1 + + +def _resolve_route(scope) -> str: + """从 scope 取路由模板(如 /things/{tid})。优先 scope['route'](现代 Starlette + 路由后写入);取不到则手动匹配一次(老版本兜底);仍无 → __unmatched__(404/扫描器)。""" + route = scope.get("route") + path = getattr(route, "path", None) + if path: + return path + app_ = scope.get("app") + router = getattr(app_, "router", None) + for candidate in getattr(router, "routes", []): + try: + match, _ = candidate.matches(scope) + except Exception: # noqa: BLE001 - 匹配兜底,任一路由异常不影响整体 + continue + if match == Match.FULL and getattr(candidate, "path", None): + return candidate.path + return _UNMATCHED + + +class RequestMetricsMiddleware: + """纯 ASGI 中间件:测每个 http 请求耗时,记 method/route/status/duration。 + + 放在最外层(main.py 里 CORS 之后 add),测到含 CORS 的完整耗时。未配置观测 → 透传。 + """ + + def __init__(self, app) -> None: + self.app = app + + async def __call__(self, scope, receive, send) -> None: + if scope["type"] != "http" or not settings.observe_configured: + await self.app(scope, receive, send) + return + if scope.get("path") in _SKIP_PATHS: + await self.app(scope, receive, send) + return + + start = time.perf_counter() + status_holder = {"status": 500} # 下游异常未产出 response 时兜底 500 + + async def send_wrapper(message) -> None: + if message["type"] == "http.response.start": + status_holder["status"] = message["status"] + await send(message) + + try: + await self.app(scope, receive, send_wrapper) + finally: + duration_ms = (time.perf_counter() - start) * 1000.0 + record_event({ + "_timestamp": int(time.time() * 1_000_000), # µs,OpenObserve 时间列 + "service": _SERVICE, + "env": settings.APP_ENV, + "method": scope.get("method", ""), + "route": _resolve_route(scope), + "status": status_holder["status"], + "duration_ms": round(duration_ms, 3), + }) diff --git a/app/core/observe_worker.py b/app/core/observe_worker.py new file mode 100644 index 0000000..aa41723 --- /dev/null +++ b/app/core/observe_worker.py @@ -0,0 +1,128 @@ +"""接口指标后台上报 worker:批量 drain 事件队列 → POST 到 OpenObserve。 + +对齐 heartbeat_monitor_worker 等的 start_*/stop_* 形态。best-effort 遥测:catch 全部 +异常,上报失败直接丢批不重试。未配置观测 → start 返回 None(不启动),整套 no-op。 +""" +from __future__ import annotations + +import asyncio +import contextlib +import logging + +import httpx + +from app.core.config import settings +from app.core.observe import get_queue, take_dropped + +logger = logging.getLogger("shagua.observe") + +# 上报用的 httpx client,start 时建、stop 时关。 +_client: httpx.AsyncClient | None = None + + +async def _collect_batch() -> list[dict]: + """等到 ≥1 条(或到 flush 间隔)后,连抽到 BATCH_MAX 条或抽空。超时且空 → 返回 []。""" + queue = get_queue() + batch: list[dict] = [] + try: + first = await asyncio.wait_for( + queue.get(), timeout=settings.OBSERVE_FLUSH_INTERVAL_SEC + ) + except asyncio.TimeoutError: # noqa: UP041 - 3.10 兼容:该版 wait_for 抛的 asyncio.TimeoutError ≠ 内置 TimeoutError + return batch + batch.append(first) + while len(batch) < settings.OBSERVE_BATCH_MAX: + try: + batch.append(queue.get_nowait()) + except asyncio.QueueEmpty: + break + return batch + + +async def _post_batch(client: httpx.AsyncClient, batch: list[dict]) -> None: + """POST 一批事件到 OpenObserve 的 _json ingest 端点。非 2xx 仅告警。""" + url = f"/api/{settings.OBSERVE_ORG}/{settings.OBSERVE_STREAM}/_json" + resp = await client.post(url, json=batch) + if resp.status_code >= 300: + logger.warning( + "observe ingest failed status=%s body=%s", + resp.status_code, + resp.text[:200], + ) + + +async def _run_loop(client: httpx.AsyncClient) -> None: + try: + while True: + batch = await _collect_batch() + dropped = take_dropped() + if dropped: + logger.warning("observe dropped %d events (queue full)", dropped) + if not batch: + continue + try: + await _post_batch(client, batch) + except Exception: # noqa: BLE001 - best-effort 遥测,失败丢批不重试、不退出 + logger.warning( + "observe post batch failed, dropped %d events", + len(batch), + exc_info=True, + ) + except asyncio.CancelledError: + logger.info("observe worker stopped") + raise + + +def start_observe_worker() -> asyncio.Task | None: + """启动上报 worker。未配置观测 → 返回 None(no-op)。约定每进程只调一次(lifespan)。""" + global _client + if not settings.observe_configured: + return None + if _client is not None: + # 约定 start 每进程只调一次;已启动则不重复建 client(避免泄漏旧连接池)。 + logger.warning("observe worker already started; ignoring duplicate start") + return None + _client = httpx.AsyncClient( + base_url=settings.OBSERVE_ENDPOINT, + auth=(settings.OBSERVE_USER, settings.OBSERVE_PASSWORD), + timeout=settings.OBSERVE_TIMEOUT_SEC, + ) + logger.info( + "observe worker started endpoint=%s org=%s stream=%s", + settings.OBSERVE_ENDPOINT, + settings.OBSERVE_ORG, + settings.OBSERVE_STREAM, + ) + return asyncio.create_task(_run_loop(_client), name="observe-worker") + + +async def stop_observe_worker(task: asyncio.Task | None) -> None: + """收尾:cancel worker → best-effort 发最后一批 → 关 client。""" + global _client + if task is None: + return + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + dropped = take_dropped() # 收口:补记最后一个 flush 窗口累计的丢弃数,不让账丢在关停期 + if dropped: + logger.warning("observe dropped %d events (queue full) before shutdown", dropped) + if _client is not None: + # worker 已停,安全 drain 剩余并 best-effort 发最后一批(短超时,不卡关停); + # 超过一批(BATCH_MAX)的剩余直接丢,不做多轮 flush(best-effort,关停从速)。 + try: + queue = get_queue() + final: list[dict] = [] + while len(final) < settings.OBSERVE_BATCH_MAX: + try: + final.append(queue.get_nowait()) + except asyncio.QueueEmpty: + break + if final: + await asyncio.wait_for( + _post_batch(_client, final), timeout=settings.OBSERVE_TIMEOUT_SEC + ) + except Exception: # noqa: BLE001 - 关停期尽力而为,失败忽略 + pass + await _client.aclose() + _client = None diff --git a/app/core/ratelimit.py b/app/core/ratelimit.py index cff6545..f516ae2 100644 --- a/app/core/ratelimit.py +++ b/app/core/ratelimit.py @@ -9,29 +9,41 @@ from __future__ import annotations import threading import time +from typing import NamedTuple from fastapi import HTTPException, Request, status from app.core.config import settings -# key -> (window_start_ts, count) -_buckets: dict[str, tuple[float, int]] = {} +# key -> (window_start_ts, count, window_sec) +# 存每个 key 自己的 window_sec:_buckets 混着不同窗口(60s 广告 / 3600s 登录 / 86400s 日闸)的 key, +# GC 必须按各 key 自己的窗口判过期(见 [_purge_expired]),否则短窗口调用触发的 GC 会误删长窗口 key。 +_buckets: dict[str, tuple[float, int, float]] = {} _lock = threading.Lock() +_GC_THRESHOLD = 10000 # _buckets 超此阈值才顺手清过期 key(仿 sms.py;测试可 monkeypatch 调小强制每次扫) + + +def _purge_expired(now: float) -> None: + """清过期 key(**仅在持有 _lock 时调用**)。按每个 key 自己存的 window_sec 判过期,而非调用方的窗口 + —— _buckets 是全局共享、混着 60s(广告)/3600s(登录)/86400s(日闸)不同窗口的 key;若用调用方窗口, + 高频的 60s 广告端点触发 GC 时会把本该活 3600s/86400s 的登录/日闸计数一并删掉,使其在规模上(超阈值才 + 触发本清理)被反复清零而失效。仅在超阈值时扫,低频、开销可忽略。""" + if len(_buckets) <= _GC_THRESHOLD: + return + for k in [k for k, (s, _, w) in _buckets.items() if now - s >= w]: + _buckets.pop(k, None) def _hit(key: str, limit: int, window_sec: float) -> bool: """记一次访问。返回 True=放行,False=超限。""" now = time.monotonic() with _lock: - start, count = _buckets.get(key, (now, 0)) + start, count, _ = _buckets.get(key, (now, 0, window_sec)) if now - start >= window_sec: # 窗口过期,重置 start, count = now, 0 count += 1 - _buckets[key] = (start, count) - # 顺手清理过期 key,防内存无限涨(低频访问足够) - if len(_buckets) > 10000: - for k in [k for k, (s, _) in _buckets.items() if now - s >= window_sec]: - _buckets.pop(k, None) + _buckets[key] = (start, count, window_sec) + _purge_expired(now) # 顺手清过期 key(按各自窗口),防内存无限涨 return count <= limit @@ -83,3 +95,76 @@ def enforce_rate_limit( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=detail, ) + + +# ===================== 先判 / 后记(只按「成功」计数)===================== +# _hit 是原子「判+记」:一调用就 +1,适合登录爆破(失败尝试也要计)。但对「短信发码」这类 +# **只想给成功动作计数**的场景不合适 —— 被单号冷却挡下的重发没真发、没烧钱,不该占额度。 +# 故拆成 _peek(只判不记)+ _commit(只记):check_rate_limits 先判 → 动作 → 成功后 record。 + + +class RateLimitRule(NamedTuple): + """一条限流规则。scope 区分不同闸(不同 key 前缀);同一 (subject, IP) 在 window_sec + 内最多 limit 次,超限抛 429 用 detail 文案。 + + (scope, window_sec) 成对绑在一条规则里 —— check(先判)与 record(计数)复用同一条, + 避免两处把窗口/scope 写歪导致 key 对不上。 + """ + + scope: str + limit: int + window_sec: float + detail: str = "操作过于频繁,请稍后再试" + + +def _peek(key: str, limit: int, window_sec: float) -> bool: + """只读:当前窗口内是否还没到上限(count < limit)。**不改计数**。 + 与 [_commit] 配对实现「先判后记」——只在动作成功后才 _commit。""" + now = time.monotonic() + with _lock: + start, count, _ = _buckets.get(key, (now, 0, window_sec)) + if now - start >= window_sec: # 窗口已过期 → 视作已重置(count 归零) + count = 0 + return count < limit + + +def _commit(key: str, window_sec: float) -> None: + """记一次访问(+1)。窗口过期则以本次为起点重置。仅在动作成功后调用。""" + now = time.monotonic() + with _lock: + start, count, _ = _buckets.get(key, (now, 0, window_sec)) + if now - start >= window_sec: # 窗口过期,重置 + start, count = now, 0 + _buckets[key] = (start, count + 1, window_sec) + _purge_expired(now) # 顺手清过期 key(按各自窗口,同 [_hit]) + + +def check_rate_limits(request: Request, subject: str, rules: list[RateLimitRule]) -> None: + """【先判】一组限流:任一规则已达上限即抛 429,且**不改计数**。 + + 配合 [record_rate_limits] 实现「只按成功计数」:先 check 所有闸(全未超才继续)→ 执行动作 + → 动作**成功后**再 record。动作被下游挡下(如短信单号冷却)、没真正发生时不 record → 不占额度。 + key = `scope:subject:client_ip`(与 [enforce_rate_limit] 同款)。 + """ + if not settings.RATE_LIMIT_ENABLED: + return + ip = _client_ip(request) + for rule in rules: + if not _peek(f"{rule.scope}:{subject}:{ip}", rule.limit, rule.window_sec): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=rule.detail, + ) + + +def record_rate_limits(request: Request, subject: str, rules: list[RateLimitRule]) -> None: + """【记一次】一组限流(每条规则 +1)。仅在动作成功后调用,与 [check_rate_limits] 配对。 + + ⚠️ check→动作→record 非原子:并发突发下计数可能略超 limit(每个在途请求各 +1)。对 + 「防脚本/防轰炸」的安全网定位可接受;要精确配额需迁 Redis(见模块 docstring)。 + """ + if not settings.RATE_LIMIT_ENABLED: + return + ip = _client_ip(request) + for rule in rules: + _commit(f"{rule.scope}:{subject}:{ip}", rule.window_sec) diff --git a/app/core/rewards.py b/app/core/rewards.py index d931db4..eccdc25 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -6,6 +6,7 @@ from __future__ import annotations from datetime import date, datetime, timedelta, timezone +from typing import NamedTuple # 业务时区:签到的"今天"按北京时间算,不能用 UTC。 # 否则 UTC+8 的凌晨 0~8 点会被算成 UTC 的前一天,导致签到日期错位。 @@ -52,6 +53,30 @@ WITHDRAW_MIN_CENTS: int = 10 WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元 +# ===== 提现档位(福利页 coin_cash;7-9 对齐原型 withdrawal.html)===== +# 后端是档位唯一真相源:withdraw-info 按此下发,create_withdraw 按此校验(防绕过客户端刷)。 +# 规则(2026-07-09 拍板): +# - 新人档(is_newbie):账号历史一次性,"发起就算用过"(任意状态含被拒),用过即不再下发; +# 0.1 与 0.3 各自独立同天可各提一次,且不参与常规档"每日选一个额度"互斥。 +# - 常规档:按北京日计次(0.5×3 / 10×1 / 20×1),三档每天只能选一个。 +# invite_cash(邀请页)本轮无档位概念,不在此表。改档位=改这里发版。 +class WithdrawTier(NamedTuple): + amount_cents: int + label: str # 客户端档位方块展示文案 + badge: str | None # 角标文案;None=无角标 + daily_limit: int # 每日次数上限(新人档的"历史一次性"另由 is_newbie 判定) + is_newbie: bool + + +WITHDRAW_TIERS_COIN_CASH: tuple[WithdrawTier, ...] = ( + WithdrawTier(10, "0.1", "新人福利", 1, True), + WithdrawTier(30, "0.3", "新人福利", 1, True), + WithdrawTier(50, "0.5", None, 3, False), + WithdrawTier(1000, "10", None, 1, False), + WithdrawTier(2000, "20", None, 1, False), +) + + # ===== 一次性任务(领一次,user_task 去重)===== TASK_ENABLE_NOTIFICATION = "enable_notification" diff --git a/app/core/security.py b/app/core/security.py index 8e852be..ebbbac0 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -87,6 +87,91 @@ def issue_token_pair(user_id: int) -> dict[str, Any]: } +def create_bind_ticket( + *, openid: str, wechat_nickname: str | None, wechat_avatar_url: str | None +) -> str: + """微信登录未命中 openid 时,签发短时"待绑手机"令牌,承载 openid + 微信昵称头像。 + + typ='wechat_bind'、sub=openid;有效期 settings.WECHAT_BIND_TICKET_EXPIRE_MINUTES 分钟。 + 与 access/refresh 用同一 JWT_SECRET_KEY 签名,靠 typ 区分,decode_bind_ticket 校验 typ。 + """ + now = _now() + expire = now + timedelta(minutes=settings.WECHAT_BIND_TICKET_EXPIRE_MINUTES) + payload: dict[str, Any] = { + "sub": openid, + "typ": "wechat_bind", + "wnk": wechat_nickname, + "wav": wechat_avatar_url, + "iat": int(now.timestamp()), + "exp": int(expire.timestamp()), + } + return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + + +def decode_bind_ticket(token: str) -> dict[str, Any]: + """解析"待绑手机"令牌,校验签名/过期/类型。失败抛 TokenError。 + + 返回 {'openid': str, 'wnk': str|None, 'wav': str|None}。 + """ + try: + payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) + except jwt.ExpiredSignatureError as e: + raise TokenError("bind ticket expired") from e + except jwt.InvalidTokenError as e: + raise TokenError(f"invalid bind ticket: {e}") from e + if payload.get("typ") != "wechat_bind": + raise TokenError(f"wrong token type: want=wechat_bind got={payload.get('typ')}") + if "sub" not in payload: + raise TokenError("bind ticket missing sub") + return {"openid": payload["sub"], "wnk": payload.get("wnk"), "wav": payload.get("wav")} + + +def create_conflict_ticket( + *, openid: str, wechat_nickname: str | None, wechat_avatar_url: str | None, phone: str +) -> str: + """手机号占用时签发的短时"冲突处理"令牌。 + + 比 bind_ticket 多编码 **已验证的手机号 phone** —— 换绑/继续绑定只认它,证明"这对 + openid/手机号刚在绑号时验证通过",免用户重输验证码,又堵住"拿自己 openid + 任意手机号 + 去夺号"的接管漏洞。typ='wechat_conflict';有效期复用 WECHAT_BIND_TICKET_EXPIRE_MINUTES。 + """ + now = _now() + expire = now + timedelta(minutes=settings.WECHAT_BIND_TICKET_EXPIRE_MINUTES) + payload: dict[str, Any] = { + "sub": openid, + "typ": "wechat_conflict", + "wnk": wechat_nickname, + "wav": wechat_avatar_url, + "phn": phone, + "iat": int(now.timestamp()), + "exp": int(expire.timestamp()), + } + return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + + +def decode_conflict_ticket(token: str) -> dict[str, Any]: + """解析"冲突处理"令牌,校验签名/过期/类型。失败抛 TokenError。 + + 返回 {'openid': str, 'wnk': str|None, 'wav': str|None, 'phone': str}。 + """ + try: + payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) + except jwt.ExpiredSignatureError as e: + raise TokenError("conflict ticket expired") from e + except jwt.InvalidTokenError as e: + raise TokenError(f"invalid conflict ticket: {e}") from e + if payload.get("typ") != "wechat_conflict": + raise TokenError(f"wrong token type: want=wechat_conflict got={payload.get('typ')}") + if "sub" not in payload or "phn" not in payload: + raise TokenError("conflict ticket missing sub/phn") + return { + "openid": payload["sub"], + "wnk": payload.get("wnk"), + "wav": payload.get("wav"), + "phone": payload["phn"], + } + + # ===================== 密码 hash(admin 后台账号用)===================== # 用户侧是手机号+验证码登录,不存密码;仅 admin 账号用 username+password 登录。 diff --git a/app/integrations/notifier.py b/app/integrations/notifier.py new file mode 100644 index 0000000..5731d12 --- /dev/null +++ b/app/integrations/notifier.py @@ -0,0 +1,43 @@ +"""不活跃预警通知器(可插拔)。 + +v1 仅日志占位(LogNotifier):现状无真实推送能力(极光只用于一键登录解密 + 设备心跳告警, +心跳 worker 也只打印),先把清零主流程 + 审计做扎实。后续实现同协议的 JPushNotifier / +SmsNotifier 即可替换,worker/repo 不改。 +""" +from __future__ import annotations + +import logging +from typing import Protocol + +logger = logging.getLogger("shagua.inactivity") + + +class InactivityNotifier(Protocol): + channel: str + + def warn(self, *, user_id: int, coin: int, cash_cents: int, + stage: int, days_until_reset: int) -> str: + """发预警(只涉及会被清的金币 + 折算现金;邀请现金不清、不预警)。 + 返回状态:'sent' / 'failed' / 'placeholder'。""" + ... + + +class LogNotifier: + """占位实现:只打印,不真推。参照 heartbeat_monitor_worker「本期先不接推送」先例。""" + + channel = "log" + + def warn(self, *, user_id: int, coin: int, cash_cents: int, + stage: int, days_until_reset: int) -> str: + logger.warning( + "[inactivity-warn] user=%s coin=%s cash_cents=%s stage=T-%s days_until_reset=%s", + user_id, coin, cash_cents, stage, days_until_reset, + ) + return "placeholder" + + +def get_notifier(channel: str) -> InactivityNotifier: + """按配置返回通知器。未实现的通道(jpush/sms)暂回退 LogNotifier 占位。""" + # 后续:if channel == "jpush": return JPushNotifier() + # if channel == "sms": return SmsNotifier() + return LogNotifier() diff --git a/app/integrations/sms.py b/app/integrations/sms.py index 6b16aac..eca2a71 100644 --- a/app/integrations/sms.py +++ b/app/integrations/sms.py @@ -8,15 +8,17 @@ 校验)→ 鉴权复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`(同一极光应用)。 验证码存储:**进程内存**(单 worker uvicorn 够用)。重启丢失(用户重发即可)。多 -worker / 多机时内存不共享 → 冷却、每日上限、校验都会失效,届时迁移到 DB/Redis。 +worker / 多机时内存不共享 → 冷却、校验都会失效,届时迁移到 DB/Redis。 见 docs/待办与技术债.md。 -防刷三层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权): +防刷两层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权): 1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件) - 2. 单号每日 `SMS_DAILY_LIMIT_PER_PHONE` 条上限(本文件) - 3. 单设备(device_id)每小时频控(api 层 auth.sms_send 内 enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)。 + 2. 单设备(device_id)+ IP 每小时 / 每天频控(api 层 auth.sms_send 的 check/record_rate_limits, + **只按成功发码计数** —— 被本文件单号冷却挡下的重发不占额度)+ 极光控制台 IP 白名单/防轰炸(运维侧)。 ⚠️ 原「单 IP 频控(rate_limit 依赖)」2026-06-26 按产品要求删除、改设备维度;但 device_id 客户端可伪造/轮换, 脚本轮换 id 能绕过本层 → 挡脚本狂发主要靠极光控制台侧(+ 可选 nginx 限流)。 + ⚠️ 原「单号每日上限」2026-07-03 按精简要求删除(mentor 定:登录风控只留单号冷却 + 单设备频控); + 单号维度现仅剩 60s 冷却,「换号轰炸」由单设备频控封顶。 另:单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废(防爆破),验过即作废(一次性)。 """ from __future__ import annotations @@ -26,7 +28,6 @@ import logging import secrets import time from dataclasses import dataclass -from datetime import datetime from threading import Lock import httpx @@ -56,22 +57,17 @@ class _CodeRecord: # 进程内存(单 worker 有效;多 worker 不共享,见模块 docstring) _codes: dict[str, _CodeRecord] = {} # phone -> 当前有效验证码 _last_sent: dict[str, float] = {} # phone -> 上次发送 epoch(冷却) -_daily_count: dict[str, tuple[str, int]] = {} # phone -> (date_str, 当日发送数) _lock = Lock() _GC_THRESHOLD = 10000 # 任一内存 dict 超此阈值,send 时顺手清过期项(防无限增长,仿 ratelimit) -def _today() -> str: - return datetime.now().strftime("%Y-%m-%d") - - def _gen_code() -> str: """生成 N 位数字验证码(用 secrets 而非 random;允许前导 0)。""" return "".join(secrets.choice("0123456789") for _ in range(settings.SMS_CODE_LENGTH)) def _gc(now: float) -> None: - """顺手清理过期内存项,防三个 dict 无限增长。仅在持锁时调用,且某 dict 超 + """顺手清理过期内存项,防两个 dict 无限增长。仅在持锁时调用,且某 dict 超 _GC_THRESHOLD 才扫它(低频,开销可忽略)。""" if len(_codes) > _GC_THRESHOLD: for p in [p for p, r in _codes.items() if now > r.expires_at]: @@ -80,10 +76,6 @@ def _gc(now: float) -> None: cutoff = now - settings.SMS_SEND_INTERVAL_SEC for p in [p for p, ts in _last_sent.items() if ts < cutoff]: _last_sent.pop(p, None) - if len(_daily_count) > _GC_THRESHOLD: - today = _today() - for p in [p for p, (d, _c) in _daily_count.items() if d != today]: - _daily_count.pop(p, None) def send_code(phone: str) -> int: @@ -102,17 +94,9 @@ def send_code(phone: str) -> int: remain = int(settings.SMS_SEND_INTERVAL_SEC - elapsed) raise SmsError(f"发送过于频繁,请 {remain}s 后再试") - today = _today() - day, cnt = _daily_count.get(phone, ("", 0)) - if day != today: - cnt = 0 - if cnt >= settings.SMS_DAILY_LIMIT_PER_PHONE: - raise SmsError("今日验证码发送次数已达上限,请明天再试") - code = _gen_code() - # 预占:先记冷却/计数/存码,释放锁后再发网络(发失败保留冷却+计数,见下) + # 预占:先记冷却/存码,释放锁后再发网络(发失败保留冷却,见下) _last_sent[phone] = now - _daily_count[phone] = (today, cnt + 1) _codes[phone] = _CodeRecord(code=code, expires_at=now + settings.SMS_CODE_TTL_SEC) # --- lock 外:真正发送(网络 IO 不持锁)--- @@ -123,7 +107,7 @@ def send_code(phone: str) -> int: _send_via_jiguang(phone, code) logger.info("[SMS] sent to %s****", phone[:3]) except Exception as e: - # 发送失败:**保留冷却 + 每日计数**(失败也限速,挡住余额不足/签名失效时 + # 发送失败:**保留冷却**(失败也限速,挡住余额不足/签名失效时 # 前端重试狂打极光),只清掉没发出去的码(用户收不到,留着无意义且占内存)。 with _lock: _codes.pop(phone, None) diff --git a/app/main.py b/app/main.py index b15b1ed..0a1fcd1 100644 --- a/app/main.py +++ b/app/main.py @@ -51,7 +51,16 @@ from app.core.heartbeat_monitor_worker import ( start_heartbeat_monitor, stop_heartbeat_monitor, ) +from app.core.inactivity_reset_worker import ( + start_inactivity_reset_worker, + stop_inactivity_reset_worker, +) from app.core.logging import setup_logging +from app.core.observe import RequestMetricsMiddleware +from app.core.observe_worker import ( + start_observe_worker, + stop_observe_worker, +) from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client from app.core.withdraw_reconcile_worker import ( start_withdraw_reconcile_worker, @@ -73,15 +82,25 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: settings.DATABASE_URL.split("://", 1)[0], ) get_pricebot_client() # 预热透传 client:把建 SSL 上下文的一次性成本付在启动,首个领券请求即热 + try: + # 预热离线地理库:首次加载 ~2.5M 行 CSV + 建 KDTree,摊到启动、不砸首个按城市过滤的请求 + from app.utils import geo + geo.ensure_loaded() + except Exception: # noqa: BLE001 + logger.exception("reverse_geocoder 预热失败(城市反查将在首个请求时懒加载)") reconcile_task = start_withdraw_reconcile_worker() heartbeat_task = start_heartbeat_monitor() daily_exchange_task = start_daily_exchange_worker() + observe_task = start_observe_worker() + inactivity_task = start_inactivity_reset_worker() try: yield finally: await stop_heartbeat_monitor(heartbeat_task) await stop_withdraw_reconcile_worker(reconcile_task) await stop_daily_exchange_worker(daily_exchange_task) + await stop_observe_worker(observe_task) + await stop_inactivity_reset_worker(inactivity_task) await aclose_pricebot_client() logger.info("shutting down") @@ -103,6 +122,9 @@ if settings.cors_origins_list: allow_headers=["*"], ) +# 接口指标埋点(放在 CORS 之后 = 最外层:测到含 CORS 的完整耗时)。未配置观测时中间件自 no-op。 +app.add_middleware(RequestMetricsMiddleware) + @app.get("/health", tags=["meta"]) def health() -> dict[str, str]: diff --git a/app/models/__init__.py b/app/models/__init__.py index 62f2f76..605ddda 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -5,7 +5,12 @@ from app.models.ad_pangle_revenue import AdPangleDailyRevenue # noqa: F401 from app.models.ad_reward import AdRewardRecord # noqa: F401 from app.models.ad_watch_log import AdWatchLog # noqa: F401 from app.models.admin import AdminAuditLog, AdminUser # noqa: F401 +from app.models.admin_role import AdminRole # noqa: F401 from app.models.analytics_event import AnalyticsEvent # noqa: F401 +from app.models.analytics_selfstat import ( # noqa: F401 + AnalyticsSelfStat, + AnalyticsSelfStatEvent, +) from app.models.app_config import AppConfig # noqa: F401 from app.models.comparison import ComparisonRecord # noqa: F401 from app.models.cps_activity import CpsActivity # noqa: F401 @@ -22,12 +27,17 @@ from app.models.coupon_state import ( # noqa: F401 CouponSession, ) from app.models.feedback import Feedback # noqa: F401 +from app.models.inactivity import ( # noqa: F401 + InactivityNotificationLog, + InactivityResetLog, +) from app.models.invite import InviteRelation # noqa: F401 from app.models.invite_fingerprint import InviteFingerprint # noqa: F401 from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401 from app.models.meituan_coupon import MeituanCoupon # noqa: F401 from app.models.notification import Notification # noqa: F401 from app.models.onboarding import OnboardingCompletion # noqa: F401 +from app.models.phone_rebind_log import PhoneRebindLog # noqa: F401 from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401 from app.models.ops_stat_config import OpsStatConfig # noqa: F401 from app.models.price_observation import PriceObservation # noqa: F401 diff --git a/app/models/ad_ecpm.py b/app/models/ad_ecpm.py index a766210..d7c5b64 100644 --- a/app/models/ad_ecpm.py +++ b/app/models/ad_ecpm.py @@ -32,6 +32,9 @@ class AdEcpmRecord(Base): # 点位场景:comparison(比价) / coupon(领券) / welfare(福利),供收益报表区分比价/领券 Draw 收益; # 仅信息流/Draw 上报(比价与领券共用同一代码位,只能客户端各调用点显式打标),激励视频为 NULL。 feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True) + # 本次比价/领券 trace_id(信息流场景客户端带上):把这条展示收益归属到对应比价/领券记录。 + # 领券数据 / 比价记录看板按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端 = NULL。 + trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) # 客户端生成的一次广告会话 id;激励视频 S2S 回调 extra 会透传同值 ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) # 实际投放的 ADN(穿山甲 getShowEcpm().getSdkName(),如 pangle / gdt) diff --git a/app/models/admin.py b/app/models/admin.py index 7046896..8bb72b4 100644 --- a/app/models/admin.py +++ b/app/models/admin.py @@ -25,8 +25,14 @@ class AdminUser(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) username: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) password_hash: Mapped[str] = mapped_column(String(255), nullable=False) - # super_admin(全权+管账号)/ finance(钱:提现+金币)/ operator(用户+反馈+大盘) + # 明文登录密码:仅「后台 UI 创建/重置」的管理员留存,供超管在权限管理页复看转交。 + # 脚本/起后台时建的超管账号不写(为 None → 前端「不显示密码」)。⚠️ 内部工具便利取舍,见 create/list。 + plain_password: Mapped[str | None] = mapped_column(String(128), nullable=True) + # super_admin(全权+管账号)/ finance(钱:提现+金币)/ operator(用户+反馈+大盘)/ custom(按人自定义) role: Mapped[str] = mapped_column(String(20), nullable=False, default="operator") + # 「自定义」权限:仅当 role == "custom" 时有效,存这个人专属的可见页 key 列表(不共享给他人)。 + # 非 custom 用户为 None → 可见页跟随角色。登录/`/me` 下发有效页时,非空即优先用它(见 auth._admin_out_with_pages)。 + pages_override: Mapped[list | None] = mapped_column(_JSON, nullable=True) # active / disabled status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") diff --git a/app/models/admin_role.py b/app/models/admin_role.py new file mode 100644 index 0000000..39c6cd8 --- /dev/null +++ b/app/models/admin_role.py @@ -0,0 +1,42 @@ +"""admin 角色 → 可见页面(权限)映射表。 + +RBAC 的「角色」侧:每个角色持有一组「页面 key」(= 左侧导航项),决定该角色登录后台后左边能看到 +哪些页。super_admin 是内建全权角色(is_builtin=True),恒可见全部页(effective_pages 里特判, +不依赖本表存的 pages)、不可编辑/删除。其余角色(含 operator/finance)可由 super_admin 增删改。 + +admin_user.role 存的是本表的 name(字符串弱引用,不建外键——与既有 require_role 字符串口径一致; +删除在用角色由业务层拦截,见 routers/roles.py)。页面 key 清单见 app/admin/permissions.py。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import JSON, Boolean, DateTime, Integer, String, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + +_JSON = JSON().with_variant(JSONB(), "postgresql") + + +class AdminRole(Base): + __tablename__ = "admin_role" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # 角色标识 key(不可变,承重):admin_user.role 引用它、require_role 按它鉴权。 + # 内建 = super_admin/operator/finance/tech(英文,勿改);自定义 = 创建时的名称。 + name: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False) + # 展示名(可改):内建 = 管理员/运营/财务/技术;自定义默认 = name。UI 一律展示 label。 + label: Mapped[str] = mapped_column(String(32), nullable=False, default="") + # 该角色可见页面 key 列表(= 左侧导航项),见 app/admin/permissions.py 的 PERMISSION_CATALOG + pages: Mapped[list] = mapped_column(_JSON, nullable=False, default=list) + # 内建角色(当前仅 super_admin):不可编辑/删除,恒全权 + is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/models/analytics_event.py b/app/models/analytics_event.py index 63110c3..21f03c5 100644 --- a/app/models/analytics_event.py +++ b/app/models/analytics_event.py @@ -15,7 +15,7 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import JSON, BigInteger, DateTime, Integer, String, func +from sqlalchemy import JSON, BigInteger, DateTime, Index, Integer, String, func from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -23,6 +23,12 @@ from app.db.base import Base class AnalyticsEvent(Base): __tablename__ = "analytics_event" + __table_args__ = ( + # 活跃口径聚合热点(activity.active_event_condition + last_active_subqueries): + # 按 (event,page) 过滤 首页可见(show/home)∪比价∪领券,再 group by user_id 取 + # max(created_at)。覆盖索引 → 该聚合走 index-only,避免高频 show 事件全表扫。 + Index("ix_analytics_event_active", "event", "page", "user_id", "created_at"), + ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) diff --git a/app/models/analytics_selfstat.py b/app/models/analytics_selfstat.py new file mode 100644 index 0000000..03bb79c --- /dev/null +++ b/app/models/analytics_selfstat.py @@ -0,0 +1,59 @@ +"""埋点/上报成功率自报计数快照表(append-only)。 + +客户端周期上报「自 epoch 起算的累计计数」;服务端只存原始快照,查询时在 Python 侧差分聚合 +(见 app/admin/repositories/analytics_health.py)。与既有 analytics_event 表完全独立。 + +- analytics_selfstat :一快照一行(快照头 + 设备维度 + 设备级诊断量) +- analytics_selfstat_event :一 event 一行(四类累计计数),外键指向快照头 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class AnalyticsSelfStat(Base): + __tablename__ = "analytics_selfstat" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + device_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False) + epoch_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False) + # 设备维度(每设备固定,下钻用) + app_ver: Mapped[str | None] = mapped_column(String(32), nullable=True) + oem: Mapped[str | None] = mapped_column(String(32), nullable=True) + os: Mapped[str | None] = mapped_column(String(32), nullable=True) + # 设备级诊断量(累计;queue_depth 是瞬时 gauge) + batches_attempted: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + batches_ok: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + batches_fail: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + retries: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + queue_depth: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + sent_at: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 端上报时刻 epoch ms + # 服务端接收时间(权威,用于时间分桶与分区排序) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" + + +class AnalyticsSelfStatEvent(Base): + __tablename__ = "analytics_selfstat_event" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + snapshot_id: Mapped[int] = mapped_column( + ForeignKey("analytics_selfstat.id"), index=True, nullable=False + ) + event: Mapped[str] = mapped_column(String(64), index=True, nullable=False) + attempted: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + drop_capture: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + delivered: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + drop_undelivered: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/models/comparison.py b/app/models/comparison.py index a8b24d9..d4078e3 100644 --- a/app/models/comparison.py +++ b/app/models/comparison.py @@ -39,16 +39,19 @@ _JSON = JSON().with_variant(JSONB(), "postgresql") class ComparisonRecord(Base): __tablename__ = "comparison_record" __table_args__ = ( - # 同一用户同一次比价(trace_id)只存一条:客户端重试/误点重复上报时幂等覆盖。 - UniqueConstraint("user_id", "trace_id", name="uq_comparison_user_trace"), + # trace_id 由 app-server 签发、全局唯一 → 一次比价一行,后端 harvest 按它 upsert。 + # (原 (user_id,trace_id) 复合唯一改为 trace_id 单列:harvest 帧0 建行时 user_id 可能暂缺。) + UniqueConstraint("trace_id", name="uq_comparison_trace"), # 首页轮播 / 省钱战绩聚合都按 status='success' 过滤 + created_at 近期排序; # 复合索引避免随数据量增大退化成全表扫(单列 created_at 索引不含 status)。 Index("ix_comparison_status_created", "status", "created_at"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - user_id: Mapped[int] = mapped_column( - Integer, ForeignKey("user.id"), index=True, nullable=False + # 后端 harvest 在帧0(pricebot 出 trace_id)即建行,软鉴权下 user_id 可能暂缺(老客户端/匿名)→ 可空。 + # C 端「我的比价记录」按 user_id 过滤天然排除 null-user 行;admin 全看(含孤儿行)。 + user_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("user.id"), index=True, nullable=True ) # 仍记录设备号(同一用户多设备的行为区分 / 与不鉴权期 device_id 数据对账) device_id: Mapped[str | None] = mapped_column(String(64), nullable=True) @@ -83,6 +86,10 @@ class ComparisonRecord(Base): # ===== 订单概要 ===== store_name: Mapped[str | None] = mapped_column(String(128), nullable=True) + # 下单商品名拼接串(顿号分隔,从 items[].name 去重派生),供 admin 列表「商品」列展示 + 商品名搜索。 + # items 是 JSON(SQLite 下 json.dumps ensure_ascii 把中文转义,无法直接 CAST+LIKE),故另派生成普通 + # 文本列——跨库 LIKE 一致、可加索引。写路径(upsert_record / harvest_done)落库时同步派生。 + product_names: Mapped[str | None] = mapped_column(String(512), nullable=True) total_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True) skipped_dish_count: Mapped[int | None] = mapped_column(Integer, nullable=True) @@ -130,6 +137,12 @@ class ComparisonRecord(Base): # 每次 LLM 调用明细 [{scene,model,input_messages,output,usage,latency_ms,error}]; # server 收上报后按 trace_id 同机拉 pricebot 落库(见 compare_record 端点)。旧记录/未采集为 None。 llm_calls: Mapped[list | None] = mapped_column(_JSON, nullable=True) + # 本次比价 LLM 总成本(元):回填时按「当时的价」逐模型算好冻结(见 services/llm_cost.py)。 + # 单次亚分级 → float「元」(不用 *_cents)。旧记录/未回填为 None,前端回退「估算成本」。 + llm_cost_yuan: Mapped[float | None] = mapped_column(Float, nullable=True) + # 算成本所用单价快照 {mode, prices:{model:{input_per_1m,output_per_1m,_source}}}:app_config 只存 + # 当前价、不留历史,故把当时价冻结进来供审计/复算。 + llm_price_snapshot: Mapped[dict | None] = mapped_column(_JSON, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True, nullable=False diff --git a/app/models/coupon_state.py b/app/models/coupon_state.py index c7ba0b9..d461613 100644 --- a/app/models/coupon_state.py +++ b/app/models/coupon_state.py @@ -66,6 +66,9 @@ class CouponClaimRecord(Base): # success / already_claimed / failed / skipped(原样取 pricebot coupon 结果) status: Mapped[str] = mapped_column(String(24), nullable=False) + # 领券所属 session 的环境 prod/dev(/step 按 trace_id 查 coupon_session.app_env 打标)。 + # 旧行 NULL(不回填)。admin「按券成功率」表据此过滤环境。见设计 §13。 + app_env: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True) vendor: Mapped[str | None] = mapped_column(String(48), nullable=True) coupon_name: Mapped[str | None] = mapped_column(String(128), nullable=True) # 这张领到几张(pricebot display_count;给不出时为 None) @@ -240,6 +243,10 @@ class CouponSession(Base): platform_elapsed: Mapped[dict | None] = mapped_column(_JSON, nullable=True) # 领到总张数(收尾帧带)。 claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + # 本次 session 至少领到一张(status∈{success,already_claimed})的平台 id 列表,如 ["meituan-waimai","jd-waimai"]。 + # admin「领券数据」据此算整单成功率(②)/点位成功率(③);服务端 /step 逐帧按 trace_id 并集写入 + # (见 coupon_state.merge_session_platform_success)。旧行=NULL → 视作空集。 + platform_success: Mapped[list | None] = mapped_column(_JSON, nullable=True) # pricebot done 帧回传的公网调试链接(price.shaguabijia.com/traces/{dir});含落盘时分秒、拼不出,只能存 # (同 ComparisonRecord.trace_url)。admin「领券数据」明细据此渲染可点 trace 链接;未到 done(failed/abandoned)为空。 trace_url: Mapped[str | None] = mapped_column(String(512), nullable=True) diff --git a/app/models/inactivity.py b/app/models/inactivity.py new file mode 100644 index 0000000..e67fa27 --- /dev/null +++ b/app/models/inactivity.py @@ -0,0 +1,58 @@ +"""15 天不活跃清零相关表。 + +- inactivity_reset_log:每次清零一行,记清零前三桶余额快照 + 原因 + 判定时活跃时间/不活跃天数, + 供纠纷排查(需求①)。清零同时另写 2 条钱包流水(金币 + 折算现金,biz_type=inactivity_reset), + 资金流可逐笔回溯。**邀请现金是产品红线、不清零**,invite_cash_balance_cents_before 仅为清零时 + 仍保留的邀请现金快照(便于排查、非被清金额;见 wallet.CoinAccount 注释)。 +- inactivity_notification_log:每次预警一行,记推送时余额快照 + 档位 + 通道 + 状态, + 兼作"预警去重"依据(created_at > last_active)与"待推送"占位 outbox(v1 通道=log)。 + +append-only,不更新。user_id 只索引、不设外键(同 analytics_event,避免删用户级联/历史留痕)。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class InactivityResetLog(Base): + __tablename__ = "inactivity_reset_log" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) + coin_balance_before: Mapped[int] = mapped_column(Integer, nullable=False) + cash_balance_cents_before: Mapped[int] = mapped_column(Integer, nullable=False) + invite_cash_balance_cents_before: Mapped[int] = mapped_column(Integer, nullable=False) + last_active_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + inactive_days: Mapped[int] = mapped_column(Integer, nullable=False) + reason: Mapped[str] = mapped_column(String(32), nullable=False) + reset_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" + + +class InactivityNotificationLog(Base): + __tablename__ = "inactivity_notification_log" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False) + stage: Mapped[int] = mapped_column(Integer, nullable=False) # 提前天数档(如 7 / 2) + inactive_days: Mapped[int] = mapped_column(Integer, nullable=False) + coin_balance: Mapped[int] = mapped_column(Integer, nullable=False) + cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False) + invite_cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False) + channel: Mapped[str] = mapped_column(String(16), nullable=False) # log / jpush / sms + status: Mapped[str] = mapped_column(String(16), nullable=False) # placeholder / sent / failed + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/app/models/phone_rebind_log.py b/app/models/phone_rebind_log.py new file mode 100644 index 0000000..c28be70 --- /dev/null +++ b/app/models/phone_rebind_log.py @@ -0,0 +1,31 @@ +"""手机号换绑台账。 + +记录"手机号从老账号被夺走、重建为新账号(X 注销 → Y)"这一破坏性事件,支撑"一个手机号 +30 天内最多换绑一次"的限制。手机号级、渠道无关(source 标来源);普通微信绑定不写此表。 +见 M2 spec §4.1。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class PhoneRebindLog(Base): + __tablename__ = "phone_rebind_log" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # 被换绑的真实手机号(注意:存真实号,不是老账号被腾号后的 deleted_) + phone: Mapped[str] = mapped_column(String(20), index=True, nullable=False) + # 被注销的老账号 X;P 换绑时已被腾空(极边界)则为空 + old_user_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + # 换绑后新建的账号 Y + new_user_id: Mapped[int] = mapped_column(Integer, nullable=False) + # 换绑来源。手机号级配额、渠道无关,留字段给未来其他换绑路径共用同一份 30 天限制。 + source: Mapped[str] = mapped_column(String(32), nullable=False, default="wechat_conflict") + rebound_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) diff --git a/app/repositories/activity.py b/app/repositories/activity.py new file mode 100644 index 0000000..f3e83b1 --- /dev/null +++ b/app/repositories/activity.py @@ -0,0 +1,101 @@ +"""活跃口径唯一真源:worker(不活跃清零)与 admin(最近活跃/DAU)共用,防两处漂移。 + +口径 = max(User.created_at, AnalyticsEvent[首页可见 show/home + 比价 + 领券], CouponPromptEngagement[claim_started])。 +**不含 last_login_at**(登录/re-login 不代表在用 App);created_at 为恒非空基线。 +清零/预警按北京自然日 0 点对齐(见 reset_cutoff)。 +""" +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.orm import Session + +from app.core.rewards import CN_TZ, cn_today +from app.models.analytics_event import AnalyticsEvent +from app.models.coupon_state import CouponPromptEngagement + +# —— 活跃口径事件(与"用户管理"口径一致)—— +# 首页可见:前端埋点 event=show + page=home(组合判定,单个 event 名不足以区分,见 +# active_event_condition);其余为纯 event 名。 +HOME_VIEW_EVENT = "show" +HOME_VIEW_PAGE = "home" +COMPARE_START_EVENT = "real_compare_start" # 发起比价(含浮窗触发) +COUPON_START_EVENT = "real_coupon_start" # 发起领券 +# 纯 event 名即可判定的活跃事件(首页可见是 event+page 组合、不在此列) +ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT) +ACTIVE_ENGAGE_TYPE = "claim_started" # coupon_prompt_engagement 一键领取 + + +def active_event_condition(): + """analytics_event 中算"活跃"的行为过滤:首页可见(event=show & page=home) + ∪ 发起比价 ∪ 发起领券。worker 子查询与 admin 展示共用,单一真源。""" + return or_( + and_(AnalyticsEvent.event == HOME_VIEW_EVENT, AnalyticsEvent.page == HOME_VIEW_PAGE), + AnalyticsEvent.event.in_(ACTIVE_EVENTS), + ) + + +def as_utc(value: datetime) -> datetime: + """任意 datetime → tz-aware UTC(无时区按 UTC 解释)。用于与 DateTime(timezone=True) 列比较, + 比较绝对时刻、与会话时区无关(口径同 admin queries._as_utc)。""" + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def norm_utc(dt: datetime | None) -> datetime | None: + """naive 视为 UTC 补 tzinfo(SQLite 读回 naive、PG 读回 aware,混着 max() 会 TypeError)。""" + if dt is None: + return None + return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc) + + +def cn_midnight_utc(d: date) -> datetime: + """北京 d 日 00:00 → tz-aware UTC datetime。""" + return as_utc(datetime(d.year, d.month, d.day, tzinfo=CN_TZ)) + + +def reset_cutoff(reset_days: int, today: date | None = None) -> datetime: + """应清零边界(tz-aware UTC):last_active < 此值 ⟺ 距末次活跃已满 reset_days 天(北京 0 点对齐)。 + = 北京 00:00 of (today − (reset_days − 1))。例:reset_days=15、today=1/20 → 北京 1/6 00:00。""" + today = today or cn_today() + return cn_midnight_utc(today - timedelta(days=reset_days - 1)) + + +def last_active_subqueries(db: Session): + """两个按 user_id 预聚合的派生表:最近活跃事件(见 active_event_condition)、 + 最近领券发起(claim_started)。返回 (ev_sub, eng_sub)。口径同 admin,LEFT JOIN 用。""" + ev_sub = ( + select( + AnalyticsEvent.user_id.label("user_id"), + func.max(AnalyticsEvent.created_at).label("last_at"), + ) + .where(AnalyticsEvent.user_id.is_not(None), active_event_condition()) + .group_by(AnalyticsEvent.user_id) + .subquery() + ) + eng_sub = ( + select( + CouponPromptEngagement.user_id.label("user_id"), + func.max(CouponPromptEngagement.created_at).label("last_at"), + ) + .where( + CouponPromptEngagement.user_id.is_not(None), + CouponPromptEngagement.engage_type == ACTIVE_ENGAGE_TYPE, + ) + .group_by(CouponPromptEngagement.user_id) + .subquery() + ) + return ev_sub, eng_sub + + +def last_active_expr(base_col, ev_sub, eng_sub, dialect: str): + """max(base_col, 最近活跃事件, 最近领券) 的 SQL 表达式。PG 用 greatest、SQLite 用 max。 + 子聚合缺失(未命中)时 coalesce 到 base_col(= User.created_at,恒非空基线)。""" + greatest = func.greatest if dialect == "postgresql" else func.max + return greatest( + base_col, + func.coalesce(ev_sub.c.last_at, base_col), + func.coalesce(eng_sub.c.last_at, base_col), + ) diff --git a/app/repositories/ad_ecpm.py b/app/repositories/ad_ecpm.py index 7ffa08a..c8a1fbe 100644 --- a/app/repositories/ad_ecpm.py +++ b/app/repositories/ad_ecpm.py @@ -10,6 +10,7 @@ from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from app.core import rewards from app.core.rewards import cn_today from app.models.ad_ecpm import AdEcpmRecord @@ -24,6 +25,7 @@ def create_ecpm_record( adn: str | None = None, slot_id: str | None = None, feed_scene: str | None = None, + trace_id: str | None = None, app_env: str | None = None, our_code_id: str | None = None, ) -> AdEcpmRecord: @@ -43,6 +45,7 @@ def create_ecpm_record( adn=adn, slot_id=slot_id, feed_scene=feed_scene, + trace_id=trace_id, app_env=app_env, our_code_id=our_code_id, ecpm_raw=ecpm_raw, @@ -105,3 +108,27 @@ def count_today(db: Session, user_id: int) -> int: AdEcpmRecord.report_date == cn_today().isoformat(), ) ).scalar_one() + + +def revenue_yuan_by_trace(db: Session, trace_ids: list[str]) -> dict[str, float]: + """各 trace_id 的广告预估收益(元):按 trace_id 聚合 ad_ecpm_record 的展示收益。 + + 单条展示收益 = min(eCPM元, AD_ECPM_MAX_FEN/100) / 1000(与 admin 广告收益报表同口径)。 + ecpm_raw 是字符串且需逐条钳顶,故取回后 Python 求和(行数=本页各 trace 的展示条数,很小)。 + trace_id 仅信息流(比价/领券)场景客户端带,激励视频/旧数据为 NULL,按 trace_id 过滤天然只算对应场景。 + 只喂**当前页**的 trace_id(≤ 一页条数);空集合直接返回(避免 IN () 非法)。 + """ + if not trace_ids: + return {} + rows = db.execute( + select(AdEcpmRecord.trace_id, AdEcpmRecord.ecpm_raw).where( + AdEcpmRecord.trace_id.in_(trace_ids), + ) + ).all() + cap_yuan = rewards.AD_ECPM_MAX_FEN / 100.0 + out: dict[str, float] = {} + for tid, ecpm_raw in rows: + if not tid: + continue + out[tid] = out.get(tid, 0.0) + min(rewards.parse_ecpm_yuan(ecpm_raw), cap_yuan) / 1000.0 + return {tid: round(v, 6) for tid, v in out.items()} diff --git a/app/repositories/ad_feed_reward.py b/app/repositories/ad_feed_reward.py index 6a8a29c..ea6ebc9 100644 --- a/app/repositories/ad_feed_reward.py +++ b/app/repositories/ad_feed_reward.py @@ -176,10 +176,19 @@ def grant_feed_reward( ) return _commit_record(db, rec, client_event_id) + # 按点位场景拆流水文案(2026-07):比价等候期看的广告 vs 领券时看的广告,在收益明细里分开显示。 + # feed_scene=comparison→比价奖励 / coupon→领券奖励;其它(welfare/空/旧端不带)维持通用「信息流广告奖励」。 + # 客户端按此 biz_type 直显固定文案(见 CoinHistoryViewModel.coinTitle),故 remark 只作后台留痕/兜底。 + if feed_scene == "comparison": + reward_biz, reward_remark = "feed_ad_reward_comparison", "比价奖励" + elif feed_scene == "coupon": + reward_biz, reward_remark = "feed_ad_reward_coupon", "领券奖励" + else: + reward_biz, reward_remark = "feed_ad_reward", "信息流广告奖励" crud_wallet.grant_coins( db, user_id, coin, - biz_type="feed_ad_reward", ref_id=client_event_id, - remark="信息流广告奖励", + biz_type=reward_biz, ref_id=client_event_id, + remark=reward_remark, ) rec = AdFeedRewardRecord( client_event_id=client_event_id, diff --git a/app/repositories/analytics_selfstat.py b/app/repositories/analytics_selfstat.py new file mode 100644 index 0000000..ee2a1e4 --- /dev/null +++ b/app/repositories/analytics_selfstat.py @@ -0,0 +1,38 @@ +"""自报计数快照落库。一次事务:插 1 条快照头 + N 条 event 行,返回快照 id。""" +from __future__ import annotations + +from sqlalchemy.orm import Session + +from app.models.analytics_selfstat import AnalyticsSelfStat, AnalyticsSelfStatEvent +from app.schemas.analytics_selfstat import SelfStatBatchIn + + +def record_selfstat(db: Session, batch: SelfStatBatchIn) -> int: + snap = AnalyticsSelfStat( + device_id=batch.device_id, + epoch_id=batch.epoch_id, + app_ver=batch.app_ver, + oem=batch.oem, + os=batch.os, + batches_attempted=batch.batches_attempted, + batches_ok=batch.batches_ok, + batches_fail=batch.batches_fail, + retries=batch.retries, + queue_depth=batch.queue_depth, + sent_at=batch.sent_at, + ) + db.add(snap) + db.flush() # 拿到 snap.id + db.add_all([ + AnalyticsSelfStatEvent( + snapshot_id=snap.id, + event=e.event, + attempted=e.attempted, + drop_capture=e.drop_capture, + delivered=e.delivered, + drop_undelivered=e.drop_undelivered, + ) + for e in batch.events + ]) + db.commit() + return snap.id diff --git a/app/repositories/comparison.py b/app/repositories/comparison.py index d675822..5f186aa 100644 --- a/app/repositories/comparison.py +++ b/app/repositories/comparison.py @@ -24,6 +24,23 @@ def _yuan_to_cents(yuan: float | None) -> int | None: return round(yuan * 100) +def _product_names_from_items(items: list | None) -> str | None: + """下单商品 items([{name, qty, specs?}])→ 顿号分隔的商品名串(去重保序), + 供 admin「商品」列展示 + 商品名 LIKE 搜索。空 / 无名 → None;超列宽(512)截断留余量。""" + if not items: + return None + names: list[str] = [] + for it in items: + name = it.get("name") if isinstance(it, dict) else None + if not name: + continue + s = str(name).strip() + if s and s not in names: + names.append(s) + joined = "、".join(names) + return joined[:500] or None + + def _derive(payload: ComparisonRecordIn) -> dict: """从上报 payload 派生结构化列(best/saved/is_source_best/status)。""" results = payload.comparison_results @@ -74,12 +91,19 @@ def _derive(payload: ComparisonRecordIn) -> dict: def upsert_record( db: Session, *, user_id: int, payload: ComparisonRecordIn ) -> ComparisonRecord: - """按 (user_id, trace_id) 幂等写入:已存在则覆盖(更完整的重试上报胜出),否则新建。""" + """按 **trace_id** 幂等写入(唯一键已从 user_id+trace_id 改为 trace_id):已存在则合并 + (回填 null user_id + 覆盖字段,但**不降级 success**),否则新建。 + + 灰度期老客户端 POST /compare/record 走这条,与后端 harvest 按 trace_id reconcile; + 新客户端不再 POST(改由 compare.py 透传壳 harvest 落库)。 + """ derived = _derive(payload) + items = [it.model_dump(exclude_none=True) for it in payload.items] fields = dict( device_id=payload.device_id, business_type=payload.business_type, store_name=payload.store_name, + product_names=_product_names_from_items(items), source_platform_id=payload.source_platform_id, source_platform_name=payload.source_platform_name, source_package=payload.source_package, @@ -88,7 +112,7 @@ def upsert_record( trace_url=payload.trace_url, total_dish_count=payload.total_dish_count, skipped_dish_count=payload.skipped_dish_count, - items=[it.model_dump(exclude_none=True) for it in payload.items], + items=items, comparison_results=[r.model_dump() for r in payload.comparison_results], skipped_dish_names=list(payload.skipped_dish_names), # 客户端环境 / 性能(debug,客户端上报;旧客户端为 None) @@ -110,14 +134,29 @@ def upsert_record( **derived, ) + # 按 trace_id 定位(唯一键已改 trace_id):后端 harvest 可能已建行,这里的客户端上报 + # (灰度期老客户端 / 新客户端不再走这条)与之 reconcile。 existing = db.execute( select(ComparisonRecord).where( - ComparisonRecord.user_id == user_id, ComparisonRecord.trace_id == payload.trace_id, ) ).scalar_one_or_none() if existing is not None: + # 不降级:harvest 或更早上报已落成 success,收尾期 fromFailure 的 cancelled/failed + # 不许把它盖回去(老 comparisonReported bug 的服务端兜底)——只补 user_id / trace_url。 + if existing.status == "success" and fields.get("status") != "success": + if existing.user_id is None and user_id is not None: + existing.user_id = user_id + if fields.get("trace_url"): + existing.trace_url = fields["trace_url"] + db.commit() + db.refresh(existing) + return existing + # 只**填**空缺 user_id、不 reassign:trace_id 全局唯一,一条 trace 只属一个用户; + # 绝不把已有归属的记录改判给另一个上报者(仅 harvest 建的 null-user 行在此绑上)。 + if existing.user_id is None: + existing.user_id = user_id for k, v in fields.items(): setattr(existing, k, v) db.commit() @@ -139,6 +178,203 @@ def upsert_record( return rec +# ============================================================ +# 后端 harvest:app-server 从 pricebot 透传响应里直接落库(不靠客户端上报)。 +# 帧0 建行(running) → 最终 done 更新(success/failed) → finalize 更新(aborted)。 +# 全按 trace_id upsert;success 行永不被后到的 failed/aborted 降级。 +# ============================================================ + + +def _derive_from_results(results: list[dict]) -> dict: + """从 done 帧 comparison_results(pricebot 原始 dict 列表)派生结构化列。 + 等价 _derive,但吃原始字段(is_source/price/rank/platform_id/store_name...)而非 pydantic 对象。""" + priced = [r for r in results if r.get("price") is not None] + best = None + if priced: + best = min( + priced, + key=lambda r: (r.get("rank") if r.get("rank") is not None else 10**9, r["price"]), + ) + src_row = next((r for r in results if r.get("is_source")), None) + + source_price_cents = None + if src_row is not None and src_row.get("price") is not None: + source_price_cents = _yuan_to_cents(src_row["price"]) + best_price_cents = _yuan_to_cents(best["price"]) if best else None + saved_amount_cents = None + if source_price_cents is not None and best_price_cents is not None: + saved_amount_cents = source_price_cents - best_price_cents + + has_valid_target = any( + (not r.get("is_source")) and r.get("price") is not None for r in results + ) + return { + "source_platform_id": (src_row or {}).get("platform_id"), + "source_platform_name": (src_row or {}).get("platform_name"), + "source_package": (src_row or {}).get("package"), + "source_price_cents": source_price_cents, + "best_platform_id": best.get("platform_id") if best else None, + "best_platform_name": best.get("platform_name") if best else None, + "best_price_cents": best_price_cents, + "saved_amount_cents": saved_amount_cents, + "is_source_best": best.get("is_source") if best else None, + "store_name": (src_row or {}).get("store_name") or None, + "status": "success" if has_valid_target else "failed", + } + + +def _device_cols_from_info(device_info: dict | None) -> dict: + """step 帧 device_info({locale,brand,model,android_version,rom_version})→ 表列。 + step 帧只带这几项;rom_name / android_sdk / app_version / 耗时步数 harvest 拿不到 → 留 None + (灰度期老客户端 fromComparison 会补齐;要新客户端也全带需扩 collectDeviceInfo,后续)。""" + d = device_info or {} + rv = str(d.get("rom_version") or "") + return { + "device_model": d.get("model") or None, + "device_manufacturer": d.get("brand") or None, + "android_version": d.get("android_version") or None, + "rom_version": int(rv) if rv.isdigit() else None, + } + + +def _get_by_trace(db: Session, trace_id: str) -> ComparisonRecord | None: + return db.execute( + select(ComparisonRecord).where(ComparisonRecord.trace_id == trace_id) + ).scalar_one_or_none() + + +def harvest_running( + db: Session, + *, + trace_id: str, + user_id: int | None, + business_type: str = "food", + device_id: str | None = None, + device_info: dict | None = None, + trace_url: str | None = None, +) -> ComparisonRecord: + """帧0(或任一尚未建行的帧)建/补 running 行。幂等:已存在只补空缺(user_id/trace_url/ + device_id/机型),绝不动已落定的 status / 结果。""" + rec = _get_by_trace(db, trace_id) + if rec is None: + rec = ComparisonRecord( + trace_id=trace_id, + user_id=user_id, + business_type=business_type or "food", + device_id=device_id, + status="running", + trace_url=trace_url, + created_at=datetime.now(CN_TZ).replace(tzinfo=None), + **_device_cols_from_info(device_info), + ) + db.add(rec) + db.commit() + db.refresh(rec) + return rec + changed = False + if rec.user_id is None and user_id is not None: + rec.user_id = user_id + changed = True + if not rec.trace_url and trace_url: + rec.trace_url = trace_url + changed = True + if rec.device_id is None and device_id: + rec.device_id = device_id + changed = True + for k, v in _device_cols_from_info(device_info).items(): + if getattr(rec, k) is None and v is not None: + setattr(rec, k, v) + changed = True + if changed: + db.commit() + db.refresh(rec) + return rec + + +def harvest_done( + db: Session, + *, + trace_id: str, + user_id: int | None, + done_params: dict, + business_type: str = "food", + device_id: str | None = None, + device_info: dict | None = None, + trace_url: str | None = None, +) -> tuple[ComparisonRecord, bool]: + """最终 done 帧:running 行 → 终态(success/failed)+结果+trace_url。 + 返回 (记录, 是否本次**新**落成 success)——供调用方据此幂等发一次邀请奖。 + 行不存在(理论上帧0已建;防御)则新建。""" + results = done_params.get("comparison_results") or [] + derived = _derive_from_results(results) + # 菜品:pricebot 已把源单菜品塞进 comparison_results[源行].items + items = next((r.get("items") or [] for r in results if r.get("is_source")), []) + fields = dict( + business_type=business_type or "food", + information=done_params.get("information") or None, + # best_deeplink 来自客户端剪贴板采集,harvest 拿不到 → 留空(灰度期 fromComparison 会补; + # 纯 harvest 行「再次比价」退化为按 package 拉起 App。要精确深链需客户端另传,后续)。 + trace_url=trace_url or done_params.get("trace_url"), + total_dish_count=done_params.get("total_dish_count"), + skipped_dish_count=done_params.get("skipped_dish_count"), + skipped_dish_names=list(done_params.get("skipped_dish_names") or []), + comparison_results=results, + items=items, + product_names=_product_names_from_items(items), + raw_payload=done_params, + **derived, + **_device_cols_from_info(device_info), + ) + + rec = _get_by_trace(db, trace_id) + was_success = rec is not None and rec.status == "success" + if rec is None: + rec = ComparisonRecord( + trace_id=trace_id, + user_id=user_id, + created_at=datetime.now(CN_TZ).replace(tzinfo=None), + device_id=device_id, + **fields, + ) + db.add(rec) + else: + if user_id is not None and rec.user_id is None: + rec.user_id = user_id + if device_id and rec.device_id is None: + rec.device_id = device_id + for k, v in fields.items(): + setattr(rec, k, v) + db.commit() + db.refresh(rec) + newly_success = (rec.status == "success") and not was_success + return rec, newly_success + + +def harvest_abort( + db: Session, + *, + trace_id: str, + status: str, + reason: str | None, + trace_url: str | None = None, +) -> ComparisonRecord | None: + """finalize(用户终止/超时/异常,无 done 帧):running 行 → aborted/failed + trace_url。 + **不降级 success**:行已 success(收尾取消那种 finalize 后到)只 refresh trace_url。 + 行不存在(极少:帧0没建成)→ 返回 None,不凭空造。""" + rec = _get_by_trace(db, trace_id) + if rec is None: + return None + if trace_url and not rec.trace_url: + rec.trace_url = trace_url + if rec.status != "success": + rec.status = status or "cancelled" + if reason: + rec.information = reason + db.commit() + db.refresh(rec) + return rec + + def _ordered_shop_names(db: Session, user_id: int) -> set[str]: """该用户「真实下单」(source='compare')覆盖到的店名集合,用来给比价记录打「已下单」。 diff --git a/app/repositories/coupon_state.py b/app/repositories/coupon_state.py index 336ca0c..508da74 100644 --- a/app/repositories/coupon_state.py +++ b/app/repositories/coupon_state.py @@ -147,12 +147,22 @@ def reset_today_completion(db: Session, device_id: str) -> int: # ===== 领券记录(coupon_claim_record)===== +def session_app_env(db: Session, trace_id: str | None) -> str | None: + """按 trace_id 取 coupon_session.app_env(每券成功率表打环境标用);无 trace_id / 查不到 → None。""" + if not trace_id: + return None + return db.execute( + select(CouponSession.app_env).where(CouponSession.trace_id == trace_id) + ).scalar_one_or_none() + + def record_claims( db: Session, device_id: str, user_id: int | None, trace_id: str | None, results: list[dict], + app_env: str | None = None, ) -> int: """一批券领取结果幂等写入,返回写入(新增 + 更新)条数。 @@ -186,12 +196,15 @@ def record_claims( row.user_id = user_id if count is not None: row.claimed_count = count + if app_env is not None: + row.app_env = app_env row.extra = r else: db.add(CouponClaimRecord( device_id=device_id, user_id=user_id, coupon_id=coupon_id, claim_date=today, - status=status, vendor=r.get("vendor"), coupon_name=r.get("name"), + status=status, app_env=app_env, + vendor=r.get("vendor"), coupon_name=r.get("name"), claimed_count=count, trace_id=trace_id, reason=r.get("reason"), extra=r, )) @@ -235,6 +248,47 @@ def sum_claimed_count(db: Session, user_id: int) -> int: return int(total or 0) +# ===== 领券平台推导(coupon_id → 平台;成功平台集)===== + +# 成功语义:success + already_claimed 算成功(pricebot 代码 emit already_claimed,协议 enum 漏了); +# failed / skipped 不算。与 sum_claimed_count 同口径。 +_SUCCESS_STATUSES = frozenset({"success", "already_claimed"}) + +# 三档平台 id 及固定序(美团→淘宝→京东),与客户端 DEFAULT_PLATFORM_ORDER 对齐。 +DEFAULT_PLATFORMS: tuple[str, ...] = ("meituan-waimai", "taobao-shanguang", "jd-waimai") + + +def coupon_id_to_platform(coupon_id: str | None) -> str | None: + """coupon_id 前缀 → 平台 id;无法识别 / 空 → None。 + + 与客户端 `CouponForegroundService.couponIdToPlatform` 同词表: + mt_→美团外卖 / tb_·ele_·elm_→淘宝闪购 / jd_→京东外卖。 + """ + if not coupon_id: + return None + if coupon_id.startswith("mt_"): + return "meituan-waimai" + if coupon_id.startswith(("tb_", "ele_", "elm_")): + return "taobao-shanguang" + if coupon_id.startswith("jd_"): + return "jd-waimai" + return None + + +def succeeded_platforms(results: list[dict]) -> list[str]: + """一批券结果 → 至少领到一张的平台集(按 DEFAULT_PLATFORMS 去重保序)。 + + 只取 status∈{success, already_claimed} 的券;失败/跳过、无法识别平台的券跳过。 + """ + ok: set[str] = set() + for r in results: + if r.get("status") in _SUCCESS_STATUSES: + platform = coupon_id_to_platform(r.get("coupon_id")) + if platform is not None: + ok.add(platform) + return [p for p in DEFAULT_PLATFORMS if p in ok] + + # ===== 领券任务流水(coupon_session,admin「领券数据」看板数据源)===== def upsert_coupon_session( @@ -321,3 +375,31 @@ def upsert_coupon_session( except IntegrityError: # 并发下另一请求刚插了同 trace_id → 唯一约束撞,回滚忽略(本就幂等)。 db.rollback() + + +def merge_session_platform_success( + db: Session, trace_id: str, platforms: list[str] +) -> None: + """把本帧「成功平台」并入 coupon_session.platform_success(按 trace_id,并集幂等,按 DEFAULT_PLATFORMS 保序)。 + + - 领券 /step 每逢带券结果的帧调一次(平台成败布尔,跨帧取并集天然幂等,不重复计)。 + - 读不到该 trace_id 的行 → **静默跳过**(不建兜底行;设计 §5:started 帧几乎必先落库)。 + - 并集无变化(该平台已记过)→ 不写库,省一次 UPDATE。 + - fire-and-forget:调用方已吞异常;并发唯一冲突回滚忽略。 + """ + if not platforms: + return + row = db.execute( + select(CouponSession).where(CouponSession.trace_id == trace_id) + ).scalar_one_or_none() + if row is None: + return + merged = set(row.platform_success or []) | set(platforms) + new_list = [p for p in DEFAULT_PLATFORMS if p in merged] + if new_list == (row.platform_success or []): + return # 幂等:无新平台,不写 + row.platform_success = new_list + try: + db.commit() + except IntegrityError: + db.rollback() diff --git a/app/repositories/inactivity.py b/app/repositories/inactivity.py new file mode 100644 index 0000000..59335de --- /dev/null +++ b/app/repositories/inactivity.py @@ -0,0 +1,198 @@ +"""15 天不活跃清零业务逻辑(纯同步,可单测)。worker 只是它的 asyncio 外壳。 + +活跃口径复用 app.repositories.activity;清零走 wallet.grant_*(负数出账、写流水、不 commit)。 +逐用户独立事务,一个失败不影响其余。 +""" +from __future__ import annotations + +import logging +from datetime import date, datetime + +from sqlalchemy import or_, select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +from app.core.rewards import CN_TZ +from app.integrations.notifier import InactivityNotifier +from app.models.inactivity import InactivityNotificationLog, InactivityResetLog +from app.models.user import User +from app.models.wallet import CoinAccount +from app.repositories import activity +from app.repositories import wallet as wallet_repo + +logger = logging.getLogger("shagua.inactivity") + +RESET_BIZ_TYPE = "inactivity_reset" +RESET_REMARK = "15天不活跃清零" + +# 清零候选口径:金币或折算现金有余额即入选。**邀请现金不算**——它是产品红线、不清零 +# (见 wallet.CoinAccount 注释),只有邀请现金余额的用户没有可清项,故不入选。 +_ANY_BALANCE = or_( + CoinAccount.coin_balance > 0, + CoinAccount.cash_balance_cents > 0, +) + + +def _base_query(db: Session): + """select(user_id, last_active, 三桶余额),join CoinAccount + 两活跃子查询。""" + ev_sub, eng_sub = activity.last_active_subqueries(db) + dialect = db.get_bind().dialect.name + last_active = activity.last_active_expr(User.created_at, ev_sub, eng_sub, dialect) + stmt = ( + select( + User.id.label("user_id"), + last_active.label("last_active"), + CoinAccount.coin_balance, + CoinAccount.cash_balance_cents, + CoinAccount.invite_cash_balance_cents, + ) + .join(CoinAccount, CoinAccount.user_id == User.id) + .outerjoin(ev_sub, ev_sub.c.user_id == User.id) + .outerjoin(eng_sub, eng_sub.c.user_id == User.id) + ) + return stmt, last_active + + +def _cn_date(dt: datetime) -> date: + """datetime → 北京自然日(naive 视为 UTC)。""" + return activity.norm_utc(dt).astimezone(CN_TZ).date() + + +def _inactive_days(last_active: datetime, today: date) -> int: + return (today - _cn_date(last_active)).days + + +def select_inactive_users(db: Session, *, cutoff: datetime): + """应清零用户:last_active < cutoff 且金币/折算现金有余额(邀请现金不清、不计)。 + 返回 Row 列表(值已快照,可跨 commit)。""" + stmt, last_active = _base_query(db) + stmt = stmt.where(_ANY_BALANCE, last_active < activity.as_utc(cutoff)) + return db.execute(stmt).all() + + +def clear_user(db: Session, *, user_id: int, last_active: datetime, inactive_days: int, + reason: str, dry_run: bool = False) -> bool: + """单用户清零(独立事务、行锁)。金币 + 折算现金归零 + 写审计 + 2 条流水;**邀请现金不清** + (产品红线,见 wallet.CoinAccount 注释),仅作快照记入审计。返回是否真处理了(有可清余额)。 + + dry_run=True:**只写审计名单、不动钱不写流水**(灰度看名单)。按 streak 去重——本 streak + 已记过(reset_at > last_active)就跳,避免 worker 每日重复记。""" + acc = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True) + coin, cash, invite = acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents + if coin == 0 and cash == 0: # 邀请现金不清,故不算"有可清余额" + return False + if dry_run and db.execute( + select(InactivityResetLog.id).where( + InactivityResetLog.user_id == user_id, + InactivityResetLog.reset_at > activity.as_utc(last_active), + ).limit(1) + ).first(): + return False # dry-run:本 streak 已记过审计,不重复记 + log = InactivityResetLog( + user_id=user_id, coin_balance_before=coin, cash_balance_cents_before=cash, + invite_cash_balance_cents_before=invite, last_active_at=activity.norm_utc(last_active), + inactive_days=inactive_days, reason=reason, + ) + db.add(log) + db.flush() # 拿 log.id 作 ref_id 交叉链接审计↔流水 + if not dry_run: # dry-run 只记审计名单,不真出账 + ref = str(log.id) + if coin: + wallet_repo.grant_coins(db, user_id, -coin, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK) + if cash: + wallet_repo.grant_cash(db, user_id, -cash, biz_type=RESET_BIZ_TYPE, ref_id=ref, remark=RESET_REMARK) + # 邀请现金(invite_cash_balance_cents)刻意不动:两本账物理隔离、邀请金是产品红线。 + db.commit() + return True + + +def run_reset_once(db: Session, *, reset_days: int, today: date, dry_run: bool = False) -> dict: + """扫一轮清零。逐用户独立 commit,失败隔离。dry_run=True 只记审计名单、不动钱(见 clear_user)。""" + stats = {"scanned": 0, "cleared": 0, "failed": 0} + cutoff = activity.reset_cutoff(reset_days, today) + reason = f"inactive_{reset_days}d" + ("_dryrun" if dry_run else "") + rows = select_inactive_users(db, cutoff=cutoff) # 先物化,避免边遍历边 commit + for row in rows: + stats["scanned"] += 1 + idays = _inactive_days(row.last_active, today) + try: + if clear_user(db, user_id=row.user_id, last_active=row.last_active, + inactive_days=idays, reason=reason, dry_run=dry_run): + stats["cleared"] += 1 + except SQLAlchemyError: + db.rollback() + stats["failed"] += 1 + return stats + + +def select_warn_candidates(db: Session, *, clear_cutoff: datetime, warn_hi: datetime): + """预警候选:clear_cutoff <= last_active < warn_hi 且有可清余额(即已进预警窗、尚未到清零)。""" + stmt, last_active = _base_query(db) + stmt = stmt.where( + _ANY_BALANCE, + last_active >= activity.as_utc(clear_cutoff), + last_active < activity.as_utc(warn_hi), + ) + return db.execute(stmt).all() + + +def run_warn_once(db: Session, notifier: InactivityNotifier, *, + reset_days: int, warn_stages: list[int], today: date) -> dict: + """扫一轮预警。每人取"最紧急的已到达档",按 streak 去重(notification_log.created_at > last_active)。 + 预警只涉及会被清的金币 + 折算现金;邀请现金不清、不预警(仅在 notification_log 记快照)。 + 逐用户 try/except 隔离:单用户通知器抛错 / DB 错不阻断其余,也绝不能拖累后续清零。""" + stats = {"warned": 0, "warn_skipped": 0, "warn_failed": 0} + if not warn_stages: + return stats + clear_cutoff = activity.reset_cutoff(reset_days, today) # 到此即清零,不再预警 + warn_hi = activity.reset_cutoff(reset_days - max(warn_stages), today) # 最早预警档边界 + ascending = sorted(warn_stages) # 最紧急(最小 k)在前 + for row in select_warn_candidates(db, clear_cutoff=clear_cutoff, warn_hi=warn_hi): + idays = _inactive_days(row.last_active, today) + stage = next((k for k in ascending if idays >= reset_days - k), None) + if stage is None: # 防御:候选已在预警窗内、stage 必命中,此分支实际不可达 + continue + try: + already = db.execute( + select(InactivityNotificationLog.id).where( + InactivityNotificationLog.user_id == row.user_id, + InactivityNotificationLog.stage == stage, + InactivityNotificationLog.created_at > activity.as_utc(row.last_active), + ).limit(1) + ).first() + if already: + stats["warn_skipped"] += 1 + continue + status = notifier.warn( + user_id=row.user_id, coin=row.coin_balance, cash_cents=row.cash_balance_cents, + stage=stage, days_until_reset=reset_days - idays, + ) + db.add(InactivityNotificationLog( + user_id=row.user_id, stage=stage, inactive_days=idays, + coin_balance=row.coin_balance, cash_balance_cents=row.cash_balance_cents, + invite_cash_balance_cents=row.invite_cash_balance_cents, # 快照,不参与"将清"额度 + channel=notifier.channel, status=status, + )) + db.commit() + stats["warned"] += 1 + except Exception: # noqa: BLE001 - 单用户预警失败(通知器抛错/DB 错)隔离,不阻断其余、不拖累清零 + db.rollback() + stats["warn_failed"] += 1 + return stats + + +def run_once(db: Session, *, notifier: InactivityNotifier, reset_days: int, + warn_stages: list[int], today: date, dry_run: bool = False) -> dict: + """一轮完整任务:先预警(阶段 A)再清零(阶段 B)。返回合并统计。 + 预警整段异常也**绝不阻塞清零**——清零是核心、不可逆资金操作,不能被通知故障拖住。 + dry_run=True(灰度默认):只记审计名单、不清、**也不预警**(不通知一个不会发生的清零)。""" + warn = {"warned": 0, "warn_skipped": 0, "warn_failed": 0} + if not dry_run: + try: + warn = run_warn_once(db, notifier, reset_days=reset_days, warn_stages=warn_stages, today=today) + except Exception: # noqa: BLE001 - 预警阶段整体失败(如候选查询失败)也要继续清零 + logger.exception("inactivity warn phase failed; proceeding to reset") + db.rollback() + warn = {"warned": 0, "warn_skipped": 0, "warn_failed": 0, "warn_phase_error": 1} + reset = run_reset_once(db, reset_days=reset_days, today=today, dry_run=dry_run) + return {**warn, **reset} diff --git a/app/repositories/invite.py b/app/repositories/invite.py index 9cf24e8..78ce13a 100644 --- a/app/repositories/invite.py +++ b/app/repositories/invite.py @@ -310,6 +310,9 @@ def get_invitees( "avatar_url": u.avatar_url or u.wechat_avatar_url or None, "coins": rel.inviter_coin, # v3 起恒 0(邀请人收益改走邀请奖励金) "invited_at": rel.created_at, + # 是否已完成过一次比价(= 已发过邀请奖励金)。客户端据此:好友列表分"去提醒/邀请成功"、 + # 在途列表只取未比价(is_compared=False)、算在途好友数与在途收益(未比价数×2元)。 + "is_compared": bool(rel.compare_reward_granted), }) has_more = offset + len(rows) < int(total) return items, int(total), has_more diff --git a/app/repositories/onboarding.py b/app/repositories/onboarding.py index 03e51d3..0750dd1 100644 --- a/app/repositories/onboarding.py +++ b/app/repositories/onboarding.py @@ -5,7 +5,7 @@ device_id 为空(老客户端 / 取不到 ANDROID_ID)一律按"未完成"处理, """ from __future__ import annotations -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -33,3 +33,18 @@ def mark_completed(db: Session, *, user_id: int, device_id: str) -> None: except IntegrityError: # 并发 / 重复提交撞唯一约束:已有行即视为成功。 db.rollback() + + +def delete_completion(db: Session, *, user_id: int, device_id: str) -> int: + """删该 (账号, 设备) 的引导完成标记 → 下次登录 is_completed=False → 客户端重走。 + 与 [mark_completed] 互逆。device_id 为空忽略(返 0);无记录也幂等(返 0)。返回删除行数。""" + if not device_id: + return 0 + result = db.execute( + delete(OnboardingCompletion).where( + OnboardingCompletion.user_id == user_id, + OnboardingCompletion.device_id == device_id, + ) + ) + db.commit() + return result.rowcount diff --git a/app/repositories/ops_marquee.py b/app/repositories/ops_marquee.py index bb4c35f..be5c1d3 100644 --- a/app/repositories/ops_marquee.py +++ b/app/repositories/ops_marquee.py @@ -29,6 +29,18 @@ from app.core.rewards import CN_TZ from app.models.comparison import ComparisonRecord from app.models.ops_marquee_seed import OpsMarqueeSeed from app.models.user import User +from app.repositories import app_config +from app.repositories.user import is_default_nickname + +# 首页轮播数据源模式(存 app_config.marquee_feed_mode): +# mixed=真实优先+种子补位+合成兜底(默认,原行为);real=只真实(不足则少/空);seed=只种子+合成兜底。 +FEED_MODES = ("mixed", "real", "seed") + + +def get_feed_mode(db: Session) -> str: + """读首页轮播数据源模式;非法/未配置回退 mixed(= 原行为)。""" + mode = app_config.get_value(db, "marquee_feed_mode") + return mode if mode in FEED_MODES else "mixed" # feed 运行时随机源(每次请求结果不同 = 轮播想要的「鲜活感」) _rng = random.Random() @@ -129,9 +141,12 @@ def _synth_masked_name(rng: random.Random) -> str: def _mask_real(nickname: str | None, user_id: int) -> str: """真实用户脱敏(对齐 PRD「用户标识打码规则」):设过昵称→昵称脱敏(中英文皆可); - 没昵称→「用户」+5星+id 后 2 位(用户*****08),按 user_id 稳定、刷新不变脸。""" + 没昵称→「用户」+5星+id 后 2 位(用户*****08),按 user_id 稳定、刷新不变脸。 + + 创建时自动分配的默认昵称(「用户」+9 位随机,见 user.is_default_nickname)不算用户主动设的昵称, + 按「无昵称」处理走 id 规则(产品决策 2026-07:默认昵称归入「没昵称」档)。""" nick = (nickname or "").strip() - if nick: + if nick and not is_default_nickname(nick): return _mask_nickname(nick) return _mask_anon(user_id) @@ -202,60 +217,79 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]: return out -def get_feed(db: Session, limit: int = 8) -> list[dict]: +def _shuffle_declustered(rows: list, rng: random.Random | None = None) -> list: + """洗牌 + 「去连簇」:先洗牌,再贪心重排让相邻两条尽量不是同一 user_id(元素 [0] 即 user_id)。 + rng=None → 用全局 _rng(feed 每次新随机);传入 rng(如固定种子 Random)→ 排列确定(admin 稳定分页)。 + 减少同一用户连续出现;只有少数几个用户时 best-effort。""" + rng = rng or _rng + pool = list(rows) + rng.shuffle(pool) + result: list = [] + while pool: + prev_uid = result[-1][0] if result else None + # 优先挑与上一条不同 user 的;挑不到(只剩同 user)才取第一个 + idx = next((i for i, r in enumerate(pool) if r[0] != prev_uid), 0) + result.append(pool.pop(idx)) + return result + + +def get_feed(db: Session, limit: int = 8, mode: str | None = None) -> list[dict]: """返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。 - 真实条:success 且 0 < saved ≤ 上限,按 user 去重(同一用户只取最新一条,避免单人刷屏)。 + mode:显式传入(admin 预览指定模式)则用它、**不改持久化配置**;不传(客户端 /savings-feed)读 + 持久化的 marquee_feed_mode;非法值一律回退到持久化模式。 + 真实条:success 且 0 < saved ≤ 上限,**不按 user 去重**(打乱 + 去连簇:相邻尽量不同用户、减少单人连刷)后取前 limit。 不足用启用的种子补齐——**公平随机抽取** need 个(而非固定取前 N),让所有种子都有机会露出; 种子用户名留空则随机合成(避开撞名),金额取**长尾随机**(小额居多、偶尔大额,更像真实分布)。 真实 + 种子仍不满 limit → 内置合成条**补满**,保证轮播既不空也不稀疏。 展示时间统一「刷新」成相对现在的最近时刻(从 now 往前**随机抖动**递减),保证轮播永远像刚发生、 节奏自然不机械(真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时时间)。 """ - # 真实条:取较多近期记录(带 ~30s 缓存)后按 user 去重;金额超上限的异常值已在查询剔除。 - rows = _recent_real_rows(db) + # 预览可显式指定模式(所见=选中模式,不依赖 PATCH 落库时序);None/非法 → 读持久化配置。 + mode = mode if mode in FEED_MODES else get_feed_mode(db) # mixed / real / seed items: list[dict] = [] used_names: set[str] = set() - seen_users: set[int] = set() - for uid, sc, nick in rows: - if uid in seen_users: - continue - seen_users.add(uid) - name = _mask_real(nick, uid) - used_names.add(name) # 真实名按昵称/id 稳定;偶发撞名可接受 - items.append({"masked_user": name, "saved_amount_cents": int(sc)}) - if len(items) >= limit: - break - need = limit - len(items) - if need > 0: - seeds = db.execute( - select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True)) - ).scalars().all() - # 公平随机抽取 need 个(池子够大则不放回抽样;否则全用并打散),让所有启用种子都有机会露出。 - chosen = _rng.sample(seeds, need) if len(seeds) > need else list(seeds) - _rng.shuffle(chosen) - for s in chosen: - # 用户名:旧的「用户****xxx」统一模板名 / 留空 → 一律走新混合合成(避开同屏撞名、自愈历史种子); - # 仅运营手动设的非模板真名才原样用。 - fixed = (s.masked_user or "").strip() - name = fixed if fixed and not fixed.startswith("用户****") else _unique_name(used_names) + # 真实条(mixed / real):**不按 user 去重**——打乱 + 去连簇(相邻尽量不同用户、减少单人连刷)后取前 + # limit;金额超上限的异常值已在查询剔除。同一用户可多条露出,但被打散、尽量不连续。 + if mode != "seed": + for uid, sc, nick in _shuffle_declustered(_recent_real_rows(db))[:limit]: + name = _mask_real(nick, uid) + used_names.add(name) # 同一用户可多条,名字重复无害(used_names 仅供种子避重) + items.append({"masked_user": name, "saved_amount_cents": int(sc)}) + + # 种子补位 + 合成兜底(mixed / seed):mixed 下补真实不足的部分,seed 下全量用种子/合成。 + # real 模式**跳过**——只出真实,不掺任何假数据(真实不足则少于 limit,为 0 时返回空)。 + if mode != "real": + need = limit - len(items) + if need > 0: + seeds = db.execute( + select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True)) + ).scalars().all() + # 公平随机抽取 need 个(池子够大则不放回抽样;否则全用并打散),让所有启用种子都有机会露出。 + chosen = _rng.sample(seeds, need) if len(seeds) > need else list(seeds) + _rng.shuffle(chosen) + for s in chosen: + # 用户名:旧的「用户****xxx」统一模板名 / 留空 → 一律走新混合合成(避开同屏撞名、自愈历史种子); + # 仅运营手动设的非模板真名才原样用。 + fixed = (s.masked_user or "").strip() + name = fixed if fixed and not fixed.startswith("用户****") else _unique_name(used_names) + used_names.add(name) + # 金额:在 [min,max] 取长尾随机值(小额居多、偶尔大额;固定金额则 min==max)。 + lo = max(0, int(s.min_cents)) + hi = max(lo, int(s.max_cents)) + items.append({"masked_user": name, "saved_amount_cents": _skewed_amount(lo, hi)}) + + # 兜底:真实 + 种子仍凑不满 limit(种子被全停用 / 数量太少)→ 用内置合成补满, + # 保证轮播既不空也不稀疏(稀疏的几条循环同样像假)。 + while len(items) < limit: + name = _unique_name(used_names) used_names.add(name) - # 金额:在 [min,max] 取长尾随机值(小额居多、偶尔大额;固定金额则 min==max)。 - lo = max(0, int(s.min_cents)) - hi = max(lo, int(s.max_cents)) - items.append({"masked_user": name, "saved_amount_cents": _skewed_amount(lo, hi)}) - - # 兜底:真实 + 种子仍凑不满 limit(种子被全停用 / 数量太少)→ 用内置合成补满, - # 保证轮播既不空也不稀疏(稀疏的几条循环同样像假)。 - while len(items) < limit: - name = _unique_name(used_names) - used_names.add(name) - items.append({ - "masked_user": name, - "saved_amount_cents": _skewed_amount(_FALLBACK_MIN_CENTS, _FALLBACK_MAX_CENTS), - }) + items.append({ + "masked_user": name, + "saved_amount_cents": _skewed_amount(_FALLBACK_MIN_CENTS, _FALLBACK_MAX_CENTS), + }) # 统一赋「最近」时间:从 now 往前**随机抖动**递减,避免固定节奏被看出规律。 # 首条几十秒前;其余多数 1~5 分钟,偶尔扎堆(20~55s)或较长(5~9 分钟),降序、像真实流水。 @@ -276,6 +310,78 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]: return items +# ===== admin 侧:分页浏览「当前模式下可展示的记录」(审核用,不去重) ===== +_REAL_BROWSE_CAP = 1000 # 真实记录一次最多纳入这么多去洗牌+分页(足够审核;防超大库全量洗牌) +_BROWSE_SEED = 20260707 # 固定洗牌种子:同一批数据下排列恒定 → 翻页稳定、能翻遍全部 + + +def _seed_browse_row(seed: OpsMarqueeSeed) -> dict: + """把一条种子按其「生成逻辑」**确定性**生成一行浏览项(名字/金额按 seed.id 派生固定种子 → 翻页稳定)。 + 名字:运营手填的非模板名原样用,否则本地合成;金额:区间内确定性长尾取值。user_id=0(种子无真实用户)。""" + r = random.Random(_BROWSE_SEED * 1_000_003 + int(seed.id)) + fixed = (seed.masked_user or "").strip() + name = fixed if (fixed and not fixed.startswith("用户****")) else _synth_name(r) + lo = max(0, int(seed.min_cents)) + hi = max(lo, int(seed.max_cents)) + amt = lo if hi <= lo else lo + int(round((hi - lo) * (r.random() ** 2.2))) + return {"masked_user": name, "saved_amount_cents": amt, "created_at": "", "user_id": 0} + + +def list_real_records( + db: Session, mode: str | None = None, offset: int = 0, limit: int = 8 +) -> tuple[list[dict], int]: + """分页浏览「**当前模式**下可在 app 轮播展示的全部记录」,供 admin 逐页审核(**不去重**): + - 只真实:全部 success+省>0 的真实记录; + - 只种子:每条启用种子按生成逻辑各出一行; + - 混播:真实 + 种子 全部合在一起。 + 与 app 轮播同口径:先洗牌 + 去连簇(相邻尽量不同 user;种子各自独立、不算同 user);**固定种子** → + 同批数据下排列恒定,翻页不跳、能翻遍全部。返回 (items, total);item.user_id=0 表示种子。""" + mode = mode if mode in FEED_MODES else get_feed_mode(db) + # pool: [(cluster_key, item)];cluster_key 供去连簇——真实=user_id、种子=各自唯一负数(互不聚簇) + pool: list[tuple[int, dict]] = [] + if mode != "seed": + rows = db.execute( + select( + ComparisonRecord.user_id, + ComparisonRecord.saved_amount_cents, + User.nickname, + ComparisonRecord.created_at, + ) + .join(User, User.id == ComparisonRecord.user_id) + .where( + ComparisonRecord.status == "success", + ComparisonRecord.saved_amount_cents > 0, + ComparisonRecord.saved_amount_cents <= _REAL_MAX_CENTS, + ) + .order_by(ComparisonRecord.created_at.desc()) + .limit(_REAL_BROWSE_CAP) + ).all() + for uid, sc, nick, ca in rows: + pool.append(( + int(uid), + { + "masked_user": _mask_real(nick, int(uid)), + "saved_amount_cents": int(sc), + "created_at": str(ca)[:16] if ca is not None else "", + "user_id": int(uid), + }, + )) + if mode != "real": + seeds = ( + db.execute(select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True))) + .scalars() + .all() + ) + for i, s in enumerate(seeds): + pool.append((-(i + 1), _seed_browse_row(s))) # 每个种子唯一 key → 互不聚簇 + total = len(pool) + # 每次用同一固定种子新建 Random → 同批数据排列恒定(翻页稳定);同时相邻尽量不同 user。 + ordered = _shuffle_declustered(pool, random.Random(_BROWSE_SEED)) + off = max(0, offset) + items = [item for _key, item in ordered[off : off + limit]] + return items, total + + # ===== 运营侧:种子 CRUD ===== def list_seeds(db: Session) -> list[OpsMarqueeSeed]: return list( diff --git a/app/repositories/ops_stat.py b/app/repositories/ops_stat.py index c4593b8..1152b0d 100644 --- a/app/repositories/ops_stat.py +++ b/app/repositories/ops_stat.py @@ -318,11 +318,14 @@ def update_config( row.random_current = _monotonic(row, random_initial) row.random_last_tick_at = now elif apply_now: - # 立即更新:不等钟点,马上刷新一次 + # 立即更新:不等钟点,马上刷新一次。 + # random:走一档增长;real/manual:直接落到「配置目标值」(manual_value / max(真实,保底)), + # **显式保存即所见即所得,绕过「只增不减」护栏**——运营手动改值就是要按配置显示(含调小)。 + # (护栏仍作用于自动 tick 的 _refresh:防门面在两次保存之间被真实刷新/自增长悄悄缩水。) if row.mode == "random" and row.random_current is not None: row.random_current = _grow(row, row.random_current, 1) else: - row.random_current = _monotonic(row, _current_target(db, row)) + row.random_current = _current_target(db, row) row.random_last_tick_at = now elif row.random_current is None: # 首次无值:按当前模式播种,使配置后立即有合理展示值 diff --git a/app/repositories/phone_rebind.py b/app/repositories/phone_rebind.py new file mode 100644 index 0000000..34275d3 --- /dev/null +++ b/app/repositories/phone_rebind.py @@ -0,0 +1,39 @@ +"""手机号换绑台账(phone_rebind_log)的查询与写入。见 M2 spec §4.1。""" +from __future__ import annotations + +import math +from datetime import datetime, timedelta, timezone + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.models.phone_rebind_log import PhoneRebindLog + + +def rebound_within_days(db: Session, phone: str, days: int) -> bool: + """该手机号在最近 days 天内是否换绑过(命中 → 禁止再次换绑)。""" + since = datetime.now(timezone.utc) - timedelta(days=days) + stmt = ( + select(PhoneRebindLog.id) + .where(PhoneRebindLog.phone == phone, PhoneRebindLog.rebound_at >= since) + .limit(1) + ) + return db.execute(stmt).first() is not None + + +def remaining_block_days(db: Session, phone: str, days: int) -> int: + """距离该手机号可再次换绑还剩几天(向上取整;无记录返回 0)。""" + last = db.execute( + select(func.max(PhoneRebindLog.rebound_at)).where(PhoneRebindLog.phone == phone) + ).scalar_one_or_none() + if last is None: + return 0 + if last.tzinfo is None: # SQLite 取回 naive datetime,按 UTC 归一 + last = last.replace(tzinfo=timezone.utc) + remaining = (last + timedelta(days=days) - datetime.now(timezone.utc)).total_seconds() + return max(0, math.ceil(remaining / 86400)) + + +def add_rebind_log(db: Session, *, phone: str, old_user_id: int | None, new_user_id: int, source: str) -> None: + """写一条换绑台账(**不 commit**,交给调用方 rebind_account 的单事务)。""" + db.add(PhoneRebindLog(phone=phone, old_user_id=old_user_id, new_user_id=new_user_id, source=source)) diff --git a/app/repositories/user.py b/app/repositories/user.py index 7397d44..2157cfc 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -12,6 +12,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from app.models.user import User +from app.repositories import phone_rebind # ===== 创建时分配的标识:用户名(对外展示账号 ID)+ 默认昵称 ===== @@ -42,6 +43,33 @@ def _gen_nickname() -> str: ) +def is_default_nickname(nickname: str | None) -> bool: + """是否为创建时自动分配的默认昵称(= "用户" + 9 位字母数字,见 [_gen_nickname])。 + + 这类不是用户主动设置的昵称,展示脱敏时按「无昵称」处理(走 id 规则,见 ops_marquee._mask_real)。 + 精确匹配生成格式(前缀 + 定长字母数字集),不误伤真人以「用户」开头的昵称(如「用户体验师」含汉字、 + 长度也不符)。用户改过昵称即不再匹配。""" + if not nickname: + return False + s = nickname.strip() + return ( + len(s) == len(_NICKNAME_PREFIX) + _NICKNAME_LEN + and s.startswith(_NICKNAME_PREFIX) + and all(c in _NICKNAME_ALPHABET for c in s[len(_NICKNAME_PREFIX):]) + ) + + +def apply_wechat_display_identity( + user: User, *, wechat_nickname: str | None, wechat_avatar_url: str | None +) -> None: + """§10:用已有账号绑微信时,仅当展示字段仍为默认才用微信昵称/头像替换(两规则独立); + 自定义(改过昵称/传过头像)则保留。只改内存对象,由调用方 commit。""" + if is_default_nickname(user.nickname) and wechat_nickname: + user.nickname = wechat_nickname + if user.avatar_url is None and wechat_avatar_url: + user.avatar_url = wechat_avatar_url + + def get_user_by_username(db: Session, username: str) -> User | None: return db.execute( select(User).where(User.username == username) @@ -70,6 +98,85 @@ def get_user_by_phone(db: Session, phone: str) -> User | None: return db.execute(stmt).scalar_one_or_none() +def get_user_by_wechat_openid(db: Session, openid: str) -> User | None: + stmt = select(User).where(User.wechat_openid == openid) + return db.execute(stmt).scalar_one_or_none() + + +def touch_last_login(db: Session, user: User) -> User: + """openid 命中登录时更新 last_login_at(手机号登录在 upsert_user_for_login 里已更新)。""" + user.last_login_at = datetime.now(timezone.utc) + db.commit() + db.refresh(user) + return user + + +def attach_wechat_to_user( + db: Session, user: User, *, openid: str, wechat_nickname: str | None, wechat_avatar_url: str | None +) -> User: + """继续绑定:把微信 openid + 微信源字段并入已存在账号(调用方保证 user.wechat_openid 为空)。 + + 写 wechat_openid / wechat_nickname / wechat_avatar_url,并按 §10 规则回填展示字段: + 仅当昵称仍为默认值(is_default_nickname)时用微信昵称替换,仅当头像为 null 时用微信头像替换; + 用户已自定义的展示昵称/头像始终保留,两规则相互独立。 + 撞 openid 唯一约束(O 期间被别处绑走,极罕见)时由调用方捕获 IntegrityError 兜底降级为"只登入不绑"。 + """ + user.wechat_openid = openid + user.wechat_nickname = wechat_nickname + user.wechat_avatar_url = wechat_avatar_url + user.last_login_at = datetime.now(timezone.utc) + apply_wechat_display_identity(user, wechat_nickname=wechat_nickname, wechat_avatar_url=wechat_avatar_url) + db.commit() + db.refresh(user) + return user + + +def _build_wechat_user( + db: Session, + *, + phone: str, + openid: str, + wechat_nickname: str | None, + wechat_avatar_url: str | None, +) -> User: + """构造并 db.add 一个微信账号行(register_channel='wechat',展示昵称头像取微信,缺则默认), + **不 commit**。create_wechat_user 与 rebind_account 共用,保证建号逻辑单一来源。""" + user = User( + phone=phone, + username=_gen_unique_username(db), + nickname=wechat_nickname or _gen_nickname(), + avatar_url=wechat_avatar_url, + register_channel="wechat", + wechat_openid=openid, + wechat_nickname=wechat_nickname, + wechat_avatar_url=wechat_avatar_url, + last_login_at=datetime.now(timezone.utc), + ) + db.add(user) + return user + + +def create_wechat_user( + db: Session, + *, + phone: str, + openid: str, + wechat_nickname: str | None, + wechat_avatar_url: str | None, +) -> User: + """微信登录新建账号(未占用分支)。见 _build_wechat_user。 + + openid 唯一约束是并发/重复绑定的最终防线(极罕见,openid 在 wechat-login 刚查过为空)。 + """ + user = _build_wechat_user( + db, phone=phone, openid=openid, + wechat_nickname=wechat_nickname, wechat_avatar_url=wechat_avatar_url, + ) + db.commit() + db.refresh(user) + return user + + def upsert_user_for_login( db: Session, *, @@ -138,3 +245,44 @@ def soft_delete_account(db: Session, user: User) -> None: # 释放邀请码唯一槽 user.invite_code = None db.commit() + + +def rebind_account( + db: Session, + *, + phone: str, + openid: str, + wechat_nickname: str | None, + wechat_avatar_url: str | None, + source: str = "wechat_conflict", +) -> User: + """换绑:**单事务内**注销老账号 X(腾出手机号)+ 用该号建全新微信账号 Y + 写换绑台账。 + + - 老账号可能已不存在(P 被腾空)→ old_user_id=None,直接建 Y(幂等更稳)。 + - 手机号唯一约束靠时序:先把 X.phone 改名并 flush 腾号,再插 Y。 + - 全程不中途 commit,任一步失败整体回滚,绝不出现"X 删了 Y 没建"。 + X 的字段变更等价 soft_delete_account(软删 + 匿名化 + 释放 openid/邀请码唯一槽),但不在此 commit。 + """ + old = get_user_by_phone(db, phone) + old_id = old.id if old is not None else None + if old is not None: + old.status = "deleted" + old.phone = f"deleted_{old.id}" + old.nickname = None + old.avatar_url = None + old.wechat_openid = None + old.wechat_nickname = None + old.wechat_avatar_url = None + old.invite_code = None + db.flush() # 先落 phone 改名,腾出手机号唯一约束,才能给 Y 用 + new_user = _build_wechat_user( + db, phone=phone, openid=openid, + wechat_nickname=wechat_nickname, wechat_avatar_url=wechat_avatar_url, + ) + db.flush() # 拿 new_user.id + phone_rebind.add_rebind_log( + db, phone=phone, old_user_id=old_id, new_user_id=new_user.id, source=source + ) + db.commit() + db.refresh(new_user) + return new_user diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index 4249177..5484337 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -11,7 +11,7 @@ import unicodedata import uuid from datetime import datetime, timedelta, timezone -from sqlalchemy import select, update +from sqlalchemy import func, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -20,6 +20,7 @@ from app.core.config import settings from app.core.rewards import COIN_PER_CENT, coins_to_cents from app.integrations import wxpay from app.models.user import User +from app.repositories.user import apply_wechat_display_identity from app.models.wallet import ( CashTransaction, CoinAccount, @@ -35,6 +36,10 @@ _WX_STATE_SUCCESS = "SUCCESS" _WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"} _WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM" # 用户还没在微信确认页确认 _WITHDRAW_ACTIVE_STATUSES = {"reviewing", "pending"} +# 占用新人档「一次性」资格的提现状态:进行中(reviewing/pending)或成功打款(success)。 +# 被拒/转账失败/解绑退回(rejected/failed,均已退款、钱没到手)不在此列 → 新人档恢复可提 +# (2026-07-16 修正:此前判定不看状态,解绑微信退回后 0.1 被误判已用、资格永久锁死)。 +_NEWBIE_TIER_HELD_STATUSES = {"reviewing", "pending", "success"} # 免确认收款授权状态 _WX_AUTH_ACTIVE = "TAKING_EFFECT" # 已生效,可免确认转账 _WX_AUTH_CLOSED = "CLOSED" # 已关闭(用户/商户/风控),需重新开启 @@ -68,6 +73,10 @@ class WithdrawTooFrequentError(Exception): """提现申请过于频繁,或已有未完成提现单。""" +class WithdrawTierUnavailableError(Exception): + """该档位今日不可提:次数已满,或今天已选了其他额度(7-9 福利页档位规则)。""" + + class WithdrawTransferError(Exception): """调用微信转账失败(已退回余额)。""" @@ -297,6 +306,9 @@ def daily_auto_exchange(db: Session) -> dict: - **逐用户独立事务**:单个用户异常 rollback 不影响其他人;exchange_coins_to_cash 内部按用户 commit。 返回统计 dict(scanned/converted/skipped_done/skipped_dust/failed/total_cents)。 """ + # ⚠️「今天」写死北京时(rewards.cn_today() = datetime.now(CN_TZ).date(), CN_TZ=+8),与服务器/用户 + # 时区无关(用户 2026-07-01 硬约束:兑换一律北京 0 点日切)。**禁止**改成 date.today() / 裸 + # datetime.now().date()——那会跟随进程本地时区,服务器非 CST 时会在错误的"天"兑/重复兑。 today = rewards.cn_today() stats = { "scanned": 0, "converted": 0, "skipped_done": 0, @@ -368,6 +380,7 @@ def bind_wechat_openid(db: Session, user_id: int, code: str) -> dict: user.wechat_openid = info["openid"] user.wechat_nickname = info["nickname"] user.wechat_avatar_url = info["avatar_url"] + apply_wechat_display_identity(user, wechat_nickname=info["nickname"], wechat_avatar_url=info["avatar_url"]) db.commit() return info @@ -611,6 +624,91 @@ def _settle_after_ambiguous(db: Session, order: WithdrawOrder, reason: str) -> N db.commit() +def _beijing_today_start_utc() -> datetime: + """北京时今日 0 点(转 UTC)。WithdrawOrder.created_at 是 func.now()(UTC)存储, + 比较时统一转 UTC,与 admin 看板 today_start 同口径(admin/repositories/queries.py)。""" + return ( + datetime.now(rewards.CN_TZ) + .replace(hour=0, minute=0, second=0, microsecond=0) + .astimezone(timezone.utc) + ) + + +def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -> list[dict]: + """福利页(coin_cash)提现档位的可提现状态。withdraw-info 下发与 create_withdraw 校验共用此口径。 + + 规则(2026-07-09 拍板,7-9提现ui对齐;新人档判定 2026-07-16 修正): + - 新人档(0.1/0.3):账号历史一次性——进行中(reviewing/pending)或成功打款(success)即视为 + 已用,直接**从返回列表消失**;被拒/转账失败/解绑退回(均已退款、钱没到手)则恢复可提,不永久 + 占用资格。两档各自独立互不影响,不参与"每日选一个额度"互斥。 + - 常规档(0.5×3 / 10×1 / 20×1):按北京日计次,"发起就算占用"(当天创建的单不论最终状态 + 都计入,被拒/失败不退当天名额);三档每天只能选一个,选定后其余两档当天 other_tier_selected。 + - invite_cash 本轮无档位概念 → 返回空列表(邀请页客户端仍用本地写死档位,行为不变)。 + 余额是否足够由客户端本地判断(余额随兑换实时变化,不在此快照)。 + """ + if source != "coin_cash": + return [] + tiers = rewards.WITHDRAW_TIERS_COIN_CASH + amounts = [t.amount_cents for t in tiers] + newbie_amounts = [t.amount_cents for t in tiers if t.is_newbie] + # 新人档历史是否用过:进行中或已成功打款的单占用资格;被拒/失败/解绑退回(已退款)不算(恢复可提) + used_newbie: set[int] = set( + db.execute( + select(WithdrawOrder.amount_cents) + .distinct() + .where( + WithdrawOrder.user_id == user_id, + WithdrawOrder.source == "coin_cash", + WithdrawOrder.amount_cents.in_(newbie_amounts), + WithdrawOrder.status.in_(_NEWBIE_TIER_HELD_STATUSES), + ) + ).scalars() + ) if newbie_amounts else set() + # 今日(北京日)每档已发起次数(任意状态) + today_counts: dict[int, int] = { + int(amount): int(cnt) + for amount, cnt in db.execute( + select(WithdrawOrder.amount_cents, func.count(WithdrawOrder.id)) + .where( + WithdrawOrder.user_id == user_id, + WithdrawOrder.source == "coin_cash", + WithdrawOrder.amount_cents.in_(amounts), + WithdrawOrder.created_at >= _beijing_today_start_utc(), + ) + .group_by(WithdrawOrder.amount_cents) + ) + } + # "每日选一个额度":今天发起过的常规档(新人档不算) + selected_regular = next( + (t.amount_cents for t in tiers if not t.is_newbie and today_counts.get(t.amount_cents, 0) > 0), + None, + ) + out: list[dict] = [] + for t in tiers: + if t.is_newbie: + if t.amount_cents in used_newbie: + continue # 用过即消失,不再下发 + out.append({ + "amount_cents": t.amount_cents, "label": t.label, "badge": t.badge, + "is_newbie": True, "available": True, "disabled_reason": None, + "remaining_today": 1, + }) + continue + used = today_counts.get(t.amount_cents, 0) + if selected_regular is not None and selected_regular != t.amount_cents: + available, reason, remaining = False, "other_tier_selected", 0 + elif used >= t.daily_limit: + available, reason, remaining = False, "quota_exhausted", 0 + else: + available, reason, remaining = True, None, t.daily_limit - used + out.append({ + "amount_cents": t.amount_cents, "label": t.label, "badge": t.badge, + "is_newbie": False, "available": available, "disabled_reason": reason, + "remaining_today": remaining, + }) + return out + + def create_withdraw( db: Session, user_id: int, @@ -666,6 +764,19 @@ def create_withdraw( if active_order_id is not None: raise WithdrawTooFrequentError + # 福利页档位闸(7-9):coin_cash 只能提预设档位,且该档今日可提(服务端权威口径,防绕过 + # 客户端刷)。放在幂等返回/在途互斥之后:同号重试仍原样返回旧单,不被档位闸误杀。 + # allow_sub_min(0.01 调试直发)保持原样放行,不受档位约束;invite_cash 本轮无档位概念不校验。 + if source == "coin_cash" and not allow_sub_min: + tier_state = next( + (t for t in withdraw_tier_states(db, user_id, source) if t["amount_cents"] == amount_cents), + None, + ) + if tier_state is None: # 非预设档位金额,或新人档已用过(已从列表消失) + raise InvalidWithdrawAmountError + if not tier_state["available"]: + raise WithdrawTierUnavailableError + # 账户须存在(原子扣款的 UPDATE 不会建账户) get_or_create_account(db, user_id, commit=True) diff --git a/app/schemas/ad.py b/app/schemas/ad.py index aa2ecc1..ceb74af 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -63,6 +63,12 @@ class EcpmReportIn(BaseModel): description="点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页);" "比价与领券共用同一 Draw 代码位,需客户端在各调用点显式标注,供收益报表区分比价/领券;激励视频为空", ) + trace_id: str | None = Field( + None, + max_length=64, + description="本次比价/领券 trace_id(信息流场景带上):把这条展示收益归属到对应比价/领券," + "供领券数据/比价记录看板聚合本场广告收益;激励视频/福利为空", + ) app_env: str | None = Field( None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)" ) diff --git a/app/schemas/analytics_selfstat.py b/app/schemas/analytics_selfstat.py new file mode 100644 index 0000000..6edf545 --- /dev/null +++ b/app/schemas/analytics_selfstat.py @@ -0,0 +1,34 @@ +"""自报计数上报 schema。字段名对齐客户端 payload(snake_case),累计值语义。""" +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class SelfStatEventIn(BaseModel): + event: str = Field(max_length=64) + attempted: int = 0 + drop_capture: int = 0 + delivered: int = 0 + drop_undelivered: int = 0 + + +class SelfStatBatchIn(BaseModel): + device_id: str = Field(max_length=64) + epoch_id: str = Field(max_length=64) + sent_at: int | None = None + app_ver: str | None = Field(default=None, max_length=32) + oem: str | None = Field(default=None, max_length=32) + os: str | None = Field(default=None, max_length=32) + batches_attempted: int = 0 + batches_ok: int = 0 + batches_fail: int = 0 + retries: int = 0 + queue_depth: int = 0 + # 允许空列表:某次快照只上报设备级计数(batches_*/retries/queue_depth)、无 event 细分时也合法 + # (与 AnalyticsBatchIn 的 min_length=1 有意不同——那是行为事件、必须至少一条)。 + events: list[SelfStatEventIn] = Field(default_factory=list, max_length=200) + + +class SelfStatIngestOut(BaseModel): + ok: bool = True + snapshot_id: int diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 3870a52..03a03b1 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -102,3 +102,64 @@ class RefreshRequest(BaseModel): class LogoutResponse(BaseModel): ok: bool = True + + +# ===== 微信登录 ===== + +class WechatLoginRequest(BaseModel): + code: str = Field(..., min_length=1, description="微信 App 授权拿到的 code(单次有效)") + device_id: str = Field( + "", max_length=64, + description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理", + ) + + +class WechatLoginResponse(BaseModel): + # status="logged_in" → openid 命中,token 有值;"need_bind_phone" → 未命中,bind_ticket 有值 + status: str + token: TokenWithUser | None = None + bind_ticket: str | None = None + wechat_nickname: str | None = None + wechat_avatar_url: str | None = None + + +class OccupiedAccountInfo(BaseModel): + """手机号被占用时返回的原账号脱敏展示信息(供冲突页)。""" + nickname: str | None = None + avatar_url: str | None = None + created_at: datetime + has_wechat: bool = False + + +class WechatBindResultResponse(BaseModel): + # status="logged_in" → 未占用,已建号登入,token 有值; + # "phone_occupied" → 手机号被占用,occupied_account + conflict_ticket 有值,token 为 None + status: str + token: TokenWithUser | None = None + occupied_account: OccupiedAccountInfo | None = None + conflict_ticket: str | None = None # 占用时签发,换绑/继续绑定只认它 + rebind_available: bool | None = None # 该手机号 30 天内是否还能换绑(给换绑按钮预置禁用态) + rebind_blocked_days: int | None = None # 被限时剩余天数(rebind_available=False 时>0) + + +class WechatBindPhoneSmsRequest(BaseModel): + bind_ticket: str = Field(..., min_length=1) + phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$") + code: str = Field(..., min_length=4, max_length=8) + device_id: str = Field("", max_length=64) + + +class WechatBindPhoneJverifyRequest(BaseModel): + bind_ticket: str = Field(..., min_length=1) + login_token: str = Field(..., min_length=1, description="客户端 loginAuth 拿到的 loginToken") + device_id: str = Field("", max_length=64) + + +class WechatConflictContinueRequest(BaseModel): + conflict_ticket: str = Field(..., min_length=1) + device_id: str = Field("", max_length=64) + + +class WechatConflictRebindRequest(BaseModel): + conflict_ticket: str = Field(..., min_length=1) + device_id: str = Field("", max_length=64) diff --git a/app/schemas/invite.py b/app/schemas/invite.py index 24e56b4..25da870 100644 --- a/app/schemas/invite.py +++ b/app/schemas/invite.py @@ -69,6 +69,7 @@ class InviteeItem(BaseModel): avatar_url: str | None = None # 头像 URL;null = 前端画默认色块 coins: int # 这次邀请给我(邀请人)发的金币 invited_at: datetime # 邀请绑定时间(前端转"今天/3天前") + is_compared: bool = False # 该好友是否已完成过一次比价(好友列表分"去提醒/邀请成功";在途列表只取 False) class InviteeListOut(BaseModel): diff --git a/app/schemas/meituan.py b/app/schemas/meituan.py index b40f3ed..a15e4c8 100644 --- a/app/schemas/meituan.py +++ b/app/schemas/meituan.py @@ -174,9 +174,13 @@ class FeedResponse(BaseModel): class TopSalesRequest(BaseModel): """销量最高 tab:从离线库 meituan_coupon 按销量降序取(不实时打美团)。""" + # 可选:老客户端(本次改动前发版)不带经纬度。缺省时后端降级返空(status=degraded), + # 不做 422 硬拒,也不误返"全城"结果。新客户端会传坐标 → 按城市过滤。 + longitude: float | None = Field(None, description="经度(用于定位城市;缺省=老客户端,降级返空)") + latitude: float | None = Field(None, description="纬度(用于定位城市;缺省=老客户端,降级返空)") page: int = Field(1, ge=1) page_size: int = Field(20, ge=1, le=50) - platform: int | None = Field(None, description="可选: 1只外卖 / 2只到店; 不填=全部(全城销量)") + platform: int | None = Field(None, description="可选: 1只外卖 / 2只到店; 不填=全部(同城销量)") # ───────────────── 换链 请求 / 响应 ───────────────── diff --git a/app/schemas/welfare.py b/app/schemas/welfare.py index df13be3..d6ba63a 100644 --- a/app/schemas/welfare.py +++ b/app/schemas/welfare.py @@ -75,6 +75,21 @@ class ExchangeResultOut(BaseModel): # ===== 提现(现金 → 微信零钱) ===== +class WithdrawTierOut(BaseModel): + """提现档位(福利页 coin_cash;7-9 对齐原型)。served by rewards.WITHDRAW_TIERS_COIN_CASH。""" + + amount_cents: int = Field(..., description="档位金额(分)") + label: str = Field(..., description="档位方块展示文案,如 0.1 / 10") + badge: str | None = Field(None, description="角标文案(如 新人福利);无则空") + is_newbie: bool = Field(False, description="新人档:历史一次性,用过后不再下发;免广告直提") + available: bool = Field(True, description="当前是否可提(次数/选一额度口径;余额由客户端自判)") + disabled_reason: str | None = Field( + None, + description="不可提原因:quota_exhausted(今日次数满) / other_tier_selected(今日已选其他额度)", + ) + remaining_today: int = Field(0, description="今日剩余可提次数") + + class WithdrawInfoOut(BaseModel): min_cents: int = Field(..., description="单次最低提现(分)") max_cents: int = Field(..., description="单次最高提现(分)") @@ -84,6 +99,10 @@ class WithdrawInfoOut(BaseModel): transfer_auth_enabled: bool = Field( False, description="是否已开启免确认到账(开启后提现免跳微信确认,直接到账)" ) + tiers: list[WithdrawTierOut] = Field( + default_factory=list, + description="提现档位(source=coin_cash 下发;invite_cash 为空,客户端走旧逻辑)", + ) # ===== 免确认收款授权(用户授权免确认模式)===== diff --git a/app/services/llm_cost.py b/app/services/llm_cost.py new file mode 100644 index 0000000..fcf4edf --- /dev/null +++ b/app/services/llm_cost.py @@ -0,0 +1,53 @@ +"""LLM 调用成本计算(纯逻辑,无 DB):按 model 分桶累加 token × 单价,返回总成本(元)+ 价格快照。 + +用量取自 comparison_record.llm_calls[].usage(pricebot 已归一为 prompt/completion_tokens); +error / 无 usage 的调用跳过。price_cfg = {per_model:{model:{input_per_1m,output_per_1m}}, default:{...}}。 +成本单位「元」——单次亚分级,用 float(不用 *_cents);snapshot 只含本次用到的模型的价(审计用, +不存整张价表)。用到但没配价(既无 per_model 又无 default)的模型 → 快照标 unpriced,成本按 0 计。 +""" +from __future__ import annotations + +_PRICE_KEY = "llm_token_price" + + +def get_llm_prices(db) -> dict: + """读 LLM 单价配置(app_config;表内无则回退 CONFIG_DEFS 默认)。返回 compute_llm_cost 的 price_cfg。""" + from app.repositories import app_config # 延迟 import:compute_llm_cost 纯逻辑不牵连 DB 层 + return app_config.get_value(db, _PRICE_KEY) + + +def compute_llm_cost(calls: list[dict], price_cfg: dict) -> tuple[float | None, dict | None]: + """遍历 calls 按 model 分桶,cost = Σ(入/1e6*入价 + 出/1e6*出价);无有效调用 → (None, None)。""" + if not calls: + return None, None + per_model = price_cfg.get("per_model") or {} + default = price_cfg.get("default") + buckets: dict[str, list[int]] = {} # model -> [Σprompt_tokens, Σcompletion_tokens] + for c in calls: + if c.get("error"): + continue + usage = c.get("usage") or {} + model = c.get("model") or "unknown" + b = buckets.setdefault(model, [0, 0]) + b[0] += usage.get("prompt_tokens") or 0 + b[1] += usage.get("completion_tokens") or 0 + if not buckets: # 全是 error / 无 usage + return None, None + total = 0.0 + prices: dict[str, dict] = {} + for model, (tin, tout) in buckets.items(): + price = per_model.get(model, default) + in_p = price.get("input_per_1m") if isinstance(price, dict) else None + out_p = price.get("output_per_1m") if isinstance(price, dict) else None + # 没配价 / 无 default / 单价残缺或非法(配置页手改 JSON 可能存出脏数据)→ 标记待补价、 + # 不计入成本;绝不抛异常,以免连累同一回填里的 token/llm_calls 落库。 + if not isinstance(in_p, (int, float)) or not isinstance(out_p, (int, float)): + prices[model] = {"input_per_1m": in_p, "output_per_1m": out_p, "unpriced": True} + continue + total += tin / 1e6 * in_p + tout / 1e6 * out_p + prices[model] = { + "input_per_1m": in_p, + "output_per_1m": out_p, + "_source": "per_model" if model in per_model else "default", + } + return round(total, 6), {"mode": "per_model", "prices": prices} diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/data/city_dict.txt b/app/utils/data/city_dict.txt new file mode 100644 index 0000000..e8b0958 --- /dev/null +++ b/app/utils/data/city_dict.txt @@ -0,0 +1,360 @@ +城市ID 城市名称 省份名称 +3NUYJKKJXPHVNZUHFK3HWUDHNM 宣城市 安徽省 +LXXSHOY7LNK74ZK2SKVUXFY72Q 阜阳市 安徽省 +ZEBF2LBJOEHGM4XGFPNW4IHBIA 合肥市 安徽省 +WADWEY3GR6IARJLSNGQWG2KI4E 滁州市 安徽省 +Z62QL3X66AOT6MSY7LB6LVO4CI 芜湖市 安徽省 +M6QCWRCECRT6ZEJ6RERR6IWHFI 淮南市 安徽省 +UP2NBJACSAO7FUH4XQQDMBOUE4 马鞍山市 安徽省 +WLL7BSHUBPTLTMITFITJX2GKWY 蚌埠市 安徽省 +ECTBNJ4KNNHPGXEVI4TBJNU7BY 亳州市 安徽省 +KNEGDNOSRGNQPAMOMH54ATLIUU 六安市 安徽省 +YH53FRR55H6VA4KXW36OJ7RNSE 宿州市 安徽省 +LH6LX5DUVFPOFCAQ6NEGPWCLBI 淮北市 安徽省 +RIFXWJ46SEJXE7EP6HZXEUIALU 铜陵市 安徽省 +FK6F7KMO4WNARZKUXMMSBWGATM 安庆市 安徽省 +M3CHGNNUSFSPS5HRV3W7MCBWFI 黄山市 安徽省 +XFI6PM7SRO6NHBGYQUZEEA2WT4 池州市 安徽省 +D2ZILSASTTGEFQWZIDTHA7PWU4 澳门 澳门特别行政区 +WKV2HMXUEK634WP64CUCUQGM64 北京市 北京市 +HPMKHLM3QR6EZGMY7GEI4H3QYQ 泉州市 福建省 +6V523YRU54Y3PEAM2XPADNJM2U 福州市 福建省 +HH3ZZCERPVQIYUZPW4A2U4JKZI 莆田市 福建省 +BQ5RWEJS4O7W27SQMLPMRIRJDU 宁德市 福建省 +TDN7XDQMZEP6ZCK6UO3VVMCSMM 三明市 福建省 +ZKNTORN6YTZL2BXRYUSRGV3CHU 厦门市 福建省 +Y2DI2QLZN5NNACMD3KI2DBR4IA 龙岩市 福建省 +C4RS32I6QAHWLP55UQWI3N5LLY 南平市 福建省 +5T23WDEOAP7RYNU4JNL2ZG7PDQ 漳州市 福建省 +UHZBROATFB2KWNMNLS23DPRJBY 定西市 甘肃省 +WLPAHIUOIVKS644QSN4V5ZY5XQ 金昌市 甘肃省 +J7TO3UHZ57ABNUKIQUBCIJZQFA 白银市 甘肃省 +GX277SS75375VEFYVCEHBL6ISA 临夏回族自治州 甘肃省 +ORA3R7F2LSJHUOCIDTZCP54G7Q 张掖市 甘肃省 +L6VQYYOJNTHSLW5JCR5SXBITDM 武威市 甘肃省 +65OXQPQVFXNOYHXTMCE2RNFRL4 兰州市 甘肃省 +IUIZYQ7E2SPMEAIGOUUNZWBQOA 天水市 甘肃省 +BYBRGRDRV4NKAWCU6GWBIVLX3Q 酒泉市 甘肃省 +KDST2VRETG6WK5SMJO2G2FN2RU 嘉峪关市 甘肃省 +R3Q2XWVFVF4T2ADZZZMPZJRWBA 庆阳市 甘肃省 +7FWNT2TP66SU4QEP6IBBMZWFNE 陇南市 甘肃省 +T33S2GYGPVHAL5SR2FLSPTJSBE 平凉市 甘肃省 +RR6KAWBLOKD4H2UINKYPFCPXO4 甘南藏族自治州 甘肃省 +QKX4DS3CTJJG7SFW5SBHPLD42I 茂名市 广东省 +JJZ75A32XCQNZU4IN2ZCEUGN3M 梅州市 广东省 +SJSOXOSJASLUT6LBH4E32SUKKQ 清远市 广东省 +SQQWAN5BQOVSX55S7EPF7QHMAU 珠海市 广东省 +NGRJMW6JMRS6U2KUJEONSORAEY 韶关市 广东省 +FAUMIGOSOET4E5WR5BL6P3OZHA 佛山市 广东省 +JSBIH55ICFZQ2D3LEV47YMZ2NI 河源市 广东省 +KOYAYPD2DBLDCF2ZI5GCW4LF6Y 中山市 广东省 +647JGFPUYM4VWVLZCPSHT63XKQ 汕头市 广东省 +AMOIPZW3Q2NMTDSEREFVM4SV74 深圳市 广东省 +XIHEJY4H2CDZJCLIXDIN36BKXQ 广州市 广东省 +UZ6OT4CYUR42KCTTED2KJW6EGA 东莞市 广东省 +RM3HLOIUEYKTQ5O2JSVEKDMFRY 阳江市 广东省 +C6XNJAZA6N3NNUDBUU3JIZPTDI 潮州市 广东省 +AUPF3G2ULSV4TDT4L3NMHRTY6Y 揭阳市 广东省 +7HIITKBPRXTVBA2FBBN443XITQ 云浮市 广东省 +TO6ILZ7MPJMJN3S7W2SXMIFQQY 江门市 广东省 +DTTBMGCIOMPCZY5NETUEKWJ6PY 汕尾市 广东省 +HJY7JYWBA6FQY42RYSKX3RZTRI 湛江市 广东省 +SLICHB4FBDVDLI53MR74WVUUNI 肇庆市 广东省 +ZPX4JXJVBBYSSD2KTWHAPXO6NE 惠州市 广东省 +JH4Q44RQA4EZ3Q6MHQVEVE7KZQ 百色市 广西壮族自治区 +H2JXFEJFIL4PPFMYOS4MHZ5IBM 崇左市 广西壮族自治区 +SXIRRISUOEGBU335AWT2ZFL6A4 贵港市 广西壮族自治区 +HEQHKC4KP7YGGYVBZM5JEUI5AQ 北海市 广西壮族自治区 +SKGG7KMFKVDIDKRVQEPTS7SIE4 贺州市 广西壮族自治区 +N4WR7CWCULNA5Z35OTDJSZYDCU 钦州市 广西壮族自治区 +T4RXX2WY6WPQYZEUXVJNZBXQZU 梧州市 广西壮族自治区 +3R23AS3EIY7EYE2D5MWWORZODI 河池市 广西壮族自治区 +57SMWWCV7X44E256P4I23OQ3AA 防城港市 广西壮族自治区 +YHGHVIQ37UCTNQ4JKPQEAUWIQA 桂林市 广西壮族自治区 +MQJZTM455OKZLAN5WQYUTA5TDE 柳州市 广西壮族自治区 +C6FZPLB4NJQ6VUPSKDJH3EWQDM 玉林市 广西壮族自治区 +BFSU5W6E5XBIDFPQLVPGRDSATY 南宁市 广西壮族自治区 +CKXOQUZDNOVNME3PEBOY2CULQQ 来宾市 广西壮族自治区 +LV32FV6IQTKFR7JIBEQMHRUVCA 贵阳市 贵州省 +F26RCNKMFTZONJCSJ5C6FHVY74 毕节市 贵州省 +G2LMYRWVRCK7BTD2WM4NWX4SYM 黔南布依族苗族自治州 贵州省 +MFSOO3NBMB2PVLIVSI5EJK7MWY 黔西南布依族苗族自治州 贵州省 +KWUL44L7SEMJGIMXCWSSEB3OOA 遵义市 贵州省 +T2P3OFGQZUGR7D6TGRCMDF22GI 铜仁市 贵州省 +AGUFUANSZNGC4TMOPZO65IRSPI 六盘水市 贵州省 +2XOCOSNUAK3J5QDGTKIBWKK7KU 安顺市 贵州省 +6XRTSAEYJTA2UBKXO4XEPQE5ZY 黔东南苗族侗族自治州 贵州省 +YRMKRP2GOE2VMRS73N4YRIZUHY 三亚市 海南省 +CJRGVLBNLJAVBJ4ZKKIZ3FZ2LY 白沙黎族自治县 海南省 +5XOUAJ5Z4J4K7SVIQGXL2OM2JQ 保亭黎族苗族自治县 海南省 +2UFQ6A2QRJPXPH3VOYEAQHMVSQ 海口市 海南省 +TGCVXVS4M7NDQM4ROUDCVI6I3A 承德市 河北省 +JEUP6QWCOXPSM3SQTINQCJKIGM 衡水市 河北省 +Z442MNCW6BO2BBHIRUPRBACXPI 唐山市 河北省 +RFE6R34GD4FY3LUKC2ICSF6AFY 张家口市 河北省 +CWJN55M73VZDCYJEQ7AHDBWGGY 沧州市 河北省 +DFL4ES776ECRGBYNOPLWKB247I 雄安新区 河北省 +ZLSXYY34IHBHIC2NOVPQQBFTBE 保定市 河北省 +3DO6Z2QRJQFMPLLDS55PG7DSBU 石家庄市 河北省 +PR57XT25LI3246VGASEPSHP63E 邢台市 河北省 +SKYLNH737BS56TD452FOKYL36U 邯郸市 河北省 +5PWPERL7GQKJD6QPLR2TWUUM7E 秦皇岛市 河北省 +5T2TGV6SJFVL3MO7HIMN2KTQTA 廊坊市 河北省 +ECSTLZ7GP7IX3MB5EVNKS47MLE 焦作市 河南省 +VTWW34QB2F5Q4LW7ISNUMWX7GY 开封市 河南省 +D2NUN47NY4Q55X3UED4JMSI6CM 周口市 河南省 +TR3XJFQR4EFYRRIX7TUQF3B26Y 郑州市 河南省 +CKJGF5S6XMHW5ZJEBU7MJC47QA 新乡市 河南省 +IFASZ625MCFJQKPLJ7EA2SMJUU 商丘市 河南省 +SKXPYKTTRG4YAUHE2HZXWRWXGM 鹤壁市 河南省 +G5LXE74CUHO2K6BBRN7Q5DSRJY 漯河市 河南省 +SLNOAFJV2LTBSH7SJCRXJA36K4 驻马店市 河南省 +65WO7LH7CFDUGKYMXQLRAYKKWM 安阳市 河南省 +RIX2X7FAVTZCAQ5RT2C2CWK22Q 南阳市 河南省 +SZOW5OY3U54SSY4WRC65VJUNTI 平顶山市 河南省 +7VPIDDUS4P2LSZ6Q5S57MAQDEM 信阳市 河南省 +4VYCRORUOZ4DC2U6S3CT6H6KWE 洛阳市 河南省 +M5WNO2BQ3UGLLHBCG4NCEFPP5U 濮阳市 河南省 +LY3O6PBPIWETMA3ZOL6ETY5UB4 三门峡市 河南省 +FXXJLIRE72LS2W4OWWQVJMRJHA 许昌市 河南省 +5XH353QTY3VWF2KYOCZCL3TOXY 牡丹江市 黑龙江省 +2KGRZKF6IECV2W7K5J64Y2LY4M 齐齐哈尔市 黑龙江省 +FASGWS5ADVSTFGJG6TGBZPZP6Q 鹤岗市 黑龙江省 +2O6CDIXSWIKBXILZEEPKCS7MVI 双鸭山市 黑龙江省 +OZ2PTOBYTBG57XJZMIC23QFJKM 佳木斯市 黑龙江省 +FO24MQMULT3J5JW64APNXSQEPU 伊春市 黑龙江省 +ETZ2HYWVU6U7SKU6G4JAO64RUQ 黑河市 黑龙江省 +PR7EJNBY2VZBEUT36JAWE3TM7I 七台河市 黑龙江省 +HADAAVLERKIW4SQGCTQYGX4AL4 哈尔滨市 黑龙江省 +CGTU45YC5C3JYLHMA47USDPA7Y 大庆市 黑龙江省 +T4W7SQIPOM4EYMEFFRAB5BSTII 鸡西市 黑龙江省 +TYGZHNQL6YT7CX6EEG5DJQQHMA 绥化市 黑龙江省 +OOSJTSN2CVUUCKD6XAB7EYYIPY 大兴安岭地区 黑龙江省 +I3YF3EKZHIZTN6TZOTYTGZ2UXQ 随州市 湖北省 +ESGVBOSTHW7JWEVCGYJUTEHEBQ 宜昌市 湖北省 +MTJRWJ53XBW5SBTWHKNNZDLM7U 十堰市 湖北省 +PXZLF2ISKQL5ACM67ZCBNOGDT4 黄石市 湖北省 +44RMTOEHPUFXBHZXX4IQ4IRZVQ 荆州市 湖北省 +OTKZGG743NFC474ADMMRX4ZOOA 鄂州市 湖北省 +EXOUAZAQ73OEFAK72CHQ32GQHQ 恩施土家族苗族自治州 湖北省 +SUCY7I72QJDZD7EBFXREIQ67SI 咸宁市 湖北省 +QENSGB5R7HGYDXCG2LQZQTO3TU 荆门市 湖北省 +OHIWL6SAE2PR4EJR4BOMLAE6FU 武汉市 湖北省 +ZXCE4WV2CDVPQTA4HAOVELQMNE 襄阳市 湖北省 +KEFN5OPSS4ZZF6NU2TTL72S6HE 孝感市 湖北省 +ROAHLMQ67H6M5NDFVXROJG723E 黄冈市 湖北省 +YBEBX2YYN4WPBNH6Z6C73DNE7I 张家界市 湖南省 +45XGRKYGSCPE5VNRYF4FVJGFMM 株洲市 湖南省 +LK3SEIBRU7GTDT4J2EPTLIO33U 永州市 湖南省 +R4YXFIK53W5E556BSGSBJWS4DM 郴州市 湖南省 +PQDO3RNADWXX75OWZW2GSXJ4SE 怀化市 湖南省 +RRRT6QOJYEJ432L3F76ZN5NHCA 长沙市 湖南省 +B6WPNMCZ3ENQSV4NFY5MSTPDAM 岳阳市 湖南省 +KNDZW5EHDPKP2DX7HBLKP4DYLM 益阳市 湖南省 +PA2GHG3XZ7I47HTKZ4YAFH3OYY 湘西土家族苗族自治州 湖南省 +I7CNIUA5PYV2EHDEW3RGYT2R4U 邵阳市 湖南省 +SRI2SU4FN66FMJJCKQOCZD72ZY 常德市 湖南省 +H7UHHJAMQUL7UA5QEUTGKNSL3A 湘潭市 湖南省 +EFB255OBTB2BUDZENR5UVIC7ZQ 衡阳市 湖南省 +RDMANB4KCM3OJSNVGZWVYVME6E 娄底市 湖南省 +LD37PDU5OB4UAV5QDOBMKG5YTY 吉林市 吉林省 +EO3GF4XNF5RXWRPUVAT3KTQO4U 四平市 吉林省 +4GD7OS4CAQABH5YIWVK5SKGHMY 通化市 吉林省 +6DRI2R5VAWMYJHJPCJKUNMDYEQ 延边朝鲜族自治州 吉林省 +TVBCNVGUND4MOUOOUXFGX7DIUA 松原市 吉林省 +JYY62HSKBUVK5OU7KGJDKQ4RTA 白城市 吉林省 +QEDUHKMZ36CHJTKRD6O2ZPLNBU 长春市 吉林省 +4EADVCBJMZ5UBH2FVRT6QCLS2U 辽源市 吉林省 +EUQD5EGS2LR5KJSFNG6PPSIHHI 白山市 吉林省 +YLTIISPCLBEGTZZX3WUWAD7WDE 淮安市 江苏省 +L6U5DZP6MESXPMHOHCDMJS55O4 宿迁市 江苏省 +HQMLYA7TDGMYQAXFCDUXBZPYHI 镇江市 江苏省 +K6XJ4UN65ZD6XQKYEG5YN7HCRI 盐城市 江苏省 +36I4X3EZZU4EHOSCLQI5OAKKBE 南通市 江苏省 +S3GWFQU6QAVRDKLJT77LD6OFLE 泰州市 江苏省 +IO6F4AFGAVIFRGYTZEC4TXM7W4 无锡市 江苏省 +NUXNK2VOFSD2JFTEO2AMWX6NSU 扬州市 江苏省 +TEVZU6CU6SK57HFW7DFNGMQ44A 南京市 江苏省 +UTYSRBQ4FSB7XLWCF3Z2HTKNUA 常州市 江苏省 +OCZOBCJDEXKE7KBN3BD7AYQG2Q 徐州市 江苏省 +6LIBPJGZROLXE3CLZGJRYMYBOU 连云港市 江苏省 +FS4PIU74F7QKYARDWR5ZMOLICI 苏州市 江苏省 +R2F4OWUO65HYZW2IQIKINORZ7Y 赣州市 江西省 +YW346BTN3VFYNRC3744UR5MZXY 抚州市 江西省 +QR3FDR26U2EJIXOMBHL7IJLQSA 南昌市 江西省 +OAJHJL7L7VNW2Q5UXRE7F4CUJQ 九江市 江西省 +OMH7D45R4DX2KNHLV3G2UP56OY 景德镇市 江西省 +YSB2PAEROB2IZSJZFVFH7KJPEI 鹰潭市 江西省 +5OYAMNORCXKYA6UF7DW6KFFBIU 上饶市 江西省 +SMHZOYKE7BXQJ2NT6Q24TFMLEQ 吉安市 江西省 +2RZV26OUPKUHUJ5ZPB673VDGZU 萍乡市 江西省 +232VHZEEZ6SXACE4AC5HQ4ZTFQ 新余市 江西省 +QRLM74YXNDW2QDBWLTFGEMXK2I 宜春市 江西省 +S6OHUVUKIIWPVMQD44RREUMNT4 葫芦岛市 辽宁省 +DQQ4OIFUGFYJY3XZRK5VDWMLCA 辽阳市 辽宁省 +S4YXGFEYXEUG6ISZ6O337OPVSI 阜新市 辽宁省 +D3JHM7A4CG6RJMBD7YRDS5JOYU 盘锦市 辽宁省 +VTRWMOSS6PCUYUAIPG6VPBKUUQ 营口市 辽宁省 +ZPHFGWBIEVLKP5CVZNZUB3CRT4 朝阳市 辽宁省 +NGYYULZ4UAGD3Q2PG726FFXSHU 抚顺市 辽宁省 +Q5BRTSW752VSHIAKLLL7KL5TNA 锦州市 辽宁省 +ZGV3WNPOSS7J4ZWBP6ZQG46BNM 沈阳市 辽宁省 +XEX676YYMTYIV5QPIUZB4TA7IY 本溪市 辽宁省 +PRTEQZMLNLQNZXJHRCYYLWZB4E 丹东市 辽宁省 +4GN4WF6UQRFU64T4FVZPRDXRWQ 鞍山市 辽宁省 +3QTZDFLJFSLLOOVCZ65PSDAVOU 铁岭市 辽宁省 +CC4ZTMKKXI73ZEVT5QQTJN5SMM 大连市 辽宁省 +3MBJEFDLAVOMQZ7L7CM5MNSYKA 鄂尔多斯市 内蒙古自治区 +5NOS4YC5WO2IZCQPVB6MCBYDJ4 呼和浩特市 内蒙古自治区 +OQNIP675H7L5R64652BH7KHUOQ 通辽市 内蒙古自治区 +4WA6I63MGVINV5DNLNWRRHCDDM 阿拉善盟 内蒙古自治区 +ELI6BDJBAN6RCYTETMK2EX2UKU 乌兰察布市 内蒙古自治区 +PV5ZAAXFW2DZCVZKCF4I4KK7BQ 巴彦淖尔市 内蒙古自治区 +YU6UUT6G6T6AMWTJFECDIUQFEQ 乌海市 内蒙古自治区 +V4MYANW5QFZCXG3FIDPXA3HOTE 呼伦贝尔市 内蒙古自治区 +S5DCFJWJ7J3MJSLY2PHKWLNPOQ 包头市 内蒙古自治区 +LY7SAZRFSJJMRU3JEO5SKNKIVM 兴安盟 内蒙古自治区 +NM2XP54CNQCFOILKACYEQWUSGM 锡林郭勒盟 内蒙古自治区 +S5G3IO75IDEJPZQA6VFM3OYPDI 赤峰市 内蒙古自治区 +UUFUUPM5RT6ZU5UKILQC5YQV54 吴忠市 宁夏回族自治区 +VMSRLIATK44WQXQEWAL63AXJ3M 固原市 宁夏回族自治区 +4GWWCAAKGNJV2SMQPSWWZNCGYY 中卫市 宁夏回族自治区 +6IE7GEETBQEF7GUSGU2FLIUEEM 石嘴山市 宁夏回族自治区 +VI4YIH3URSON4Q4MWOEESXJ56Q 银川市 宁夏回族自治区 +SIE4ED6QWVRT727GEHWBFH3DAA 海东市 青海省 +JNJH6OJZIOQKXDXWW5ZGEHG5MA 海西蒙古族藏族自治州 青海省 +MJADYNCKQNDJU2TXACTDP5I52M 海北藏族自治州 青海省 +LRGFXIVB6RJWQWYAFH7EIUHCPE 黄南藏族自治州 青海省 +J4TG3PCK2ZEMNEUMIPZF32UNQY 果洛藏族自治州 青海省 +2YS5POGG53LKZGFBIUMDWP57SM 玉树藏族自治州 青海省 +GRZMJEZCA2DNCZK3O6ZSUHMRPM 西宁市 青海省 +NBFQIACRBBCAH5AZWJ5LVT7AU4 海南藏族自治州 青海省 +MQUKCLQ76P4FRRECDBA3HBKT7Q 滨州市 山东省 +633FVSBDDBM5WSMXSKOCX6QC5I 潍坊市 山东省 +4434FVT3PXLMV6UAWLEW6O3M5A 菏泽市 山东省 +P7PK4UBVCOHW3PI6IPEIA54DLY 济南市 山东省 +I5M6JGTGSQEWX6HL7E5I6GRBAY 德州市 山东省 +V562AOMBVU5NG5GB3EPK6U42XY 烟台市 山东省 +4OSPHTE5TD24J6DYGR6DXMEDKY 淄博市 山东省 +227TLAVTUJABWPJD4S4ZECJ3FY 临沂市 山东省 +DAEZKZU32ZAPJGUTA6LLGO3WTY 聊城市 山东省 +LBRRK2EOYJN5MLYJWT4R3QBSXM 东营市 山东省 +AB6PBGCDBTNTG4KUQROY2FJ4GY 枣庄市 山东省 +EVANGU7WZCVRAAM6NWTDJVP7SU 济宁市 山东省 +GNUEGWZ3OKRWAKKVJ5THHHX6YY 泰安市 山东省 +F3VWSF4ART2FYYBBOZYWKRXTUI 青岛市 山东省 +KD6MNWWLVKB4E655XMV6MMA3KE 日照市 山东省 +LHYVF4LBCOZ34G3WNZYUVEIQGA 威海市 山东省 +ENVYDMYGDO3BMDXSAVQYZXLX74 阳泉市 山西省 +UXOUG4UIF7ZRJYCNMQJ3LDN5FY 临汾市 山西省 +4NZPT6Z35BMYACJ2HZGHUWRJ6E 吕梁市 山西省 +KSNXQME2A3VFHCE3DM3SFZKIJQ 晋城市 山西省 +HDOX7WKYSHJKEHET6TUYMVCTMQ 太原市 山西省 +DFJIZVXJGBGBIABPSL3DGMIDIE 长治市 山西省 +5KQYYTJR2EMP653QIALMA6LXXI 忻州市 山西省 +T76EOJA332RIHML7B6LYS5LF4U 朔州市 山西省 +HVX67CKT5TS6GPRDFDYOOLK4PE 大同市 山西省 +S4NGXQJDOH7E4IHDWOH3EK6IIE 晋中市 山西省 +GWDLZXLAWU54FKQ6G3HRQRR7E4 运城市 山西省 +3FFTTN5PPV7MBCE5AGY2NGYOOI 安康市 陕西省 +GACVPL3SWO3ZKH73JMJV6YI4NY 延安市 陕西省 +6KPS7VRMW57P2DAC6OPR4ISHQQ 商洛市 陕西省 +OMMF6XLNDNYWG5TBSNTWO2ZJZ4 渭南市 陕西省 +EALFXGMWYRS6E6TWXQ2K3YHV4M 咸阳市 陕西省 +WFG7U6JNUWDIS5ZZYM4FSM5C64 榆林市 陕西省 +R3VBMYTCF5LVHO35X3MYJQFOOE 宝鸡市 陕西省 +RQOWP7C234IS4RKSHB26IYZ5IU 西安市 陕西省 +K4YU6B4T5GZLRWVHGVCR3576HI 铜川市 陕西省 +VVFVAPLKSCN5KN4Q6RK2GPGUUA 汉中市 陕西省 +2QSF6IG3KMDXWO5VP7FXHMMKXA 上海市 上海市 +X3JCRNIPTCUU6DGOFFJ4MUK37M 眉山市 四川省 +GQ24IZNTZJ3PUB5FDAMDA4W7UI 攀枝花市 四川省 +6B6WT62WHBZRHPQUT7BAD2N6ZI 泸州市 四川省 +HJ35P4KXL442MLIII7PFFWUNAE 雅安市 四川省 +K4A6VSJH2AJYT46LMUSVQZPCCU 资阳市 四川省 +646ZNPATOOM3MHI3LDU6HI4KFI 阿坝藏族羌族自治州 四川省 +MU735ZDBFPXRQDUZ3I35JK3XEU 内江市 四川省 +4WPGGJ63USY77GSRN2PPFCYKPQ 广安市 四川省 +O4FFS4DALDAAKIFAUH4F5V5VS4 宜宾市 四川省 +IRFJVK2KXBE6BZ7CSN4UFCI624 绵阳市 四川省 +NELFD7FEKKUNDJ46VLD55SMDCE 甘孜藏族自治州 四川省 +TKMVEUPZSQCXNRZPBEIK3F45AI 遂宁市 四川省 +VQW7DPB4KTUI65COJBO3NODU24 巴中市 四川省 +STP4ELXTVGQSB572LFRFJRIUUY 南充市 四川省 +6ST5EX2JVXUCLR5GP5VEFSKN5M 成都市 四川省 +UWNFCMW3HYJRALQI2MJH6EM2O4 德阳市 四川省 +J5ZYU7XRV6CHSJAGOPQKS5YXNA 达州市 四川省 +KYJTF5S746T35RFBMR65BLGM6U 凉山彝族自治州 四川省 +RDUXR23XROLB4NGRVDDLXFXDSE 乐山市 四川省 +4TUBIBHMVESJUCGMTUSLJPHXWI 广元市 四川省 +AXQL57AO27NCHYMEOLRHAAKMTA 自贡市 四川省 +4RXX566RZORCXS6HLEX3DIICSM 花莲县 台湾 +BD5Y7SISWSSQGP3HTVPU6TXAH4 台东县 台湾 +I4DNWLECRYOJAZLGYQB7PBBJXQ 台中市 台湾 +GIZQIESFOMAEQSDQKOEQ5RTTPA 南投县 台湾 +MPM6M2C634FAW7KYG3KIHERDTU 彰化县 台湾 +NH2NK6JVOBBYYTK2G53ADWLX4Y 苗栗县 台湾 +DW2Q2R2UEEDNQA7IHBGYV423K4 新竹市 台湾 +FX5AOPFRPHGHHYZB4XNIPXLNNM 新北市 台湾 +UVNZNB6G4M35RV3IGRUN6OXMXE 屏东县 台湾 +MPB3M2YK24ZORO3EDCJ6UDWIGQ 基隆市 台湾 +EBHVITJPDHJEEMMZTM4TD4UVRU 台北市 台湾 +FQS4PZNCTQEX6I5F34Z2AGJUZM 高雄市 台湾 +WVOJ636Q7MGT6RMN6QYQ4SZWIE 嘉义市 台湾 +QRLER4EEMMKYGQLER2RWEQA74E 台南市 台湾 +K2LHF64R2P4OJ7MDHJ6J2NSTPE 桃园市 台湾 +BILG6LJIWUCTXZ6CDPXYAVM6XI 澎湖县 台湾 +3FYRA3O2HUMLIQAAJQCPX2TETE 宜兰县 台湾 +4MW6X22PAPVMHB6SBGF3RYS324 天津市 天津市 +YEYPP4SQOBXU5UCNDN7ORSR6DI 拉萨市 西藏自治区 +UNE6UPENGWQDOWGABDJEAQ2FEY 山南市 西藏自治区 +VDXKN2YCIUPOHBZKKQHPN6HJWE 林芝市 西藏自治区 +EAWIMNI77H72EYSOAYV3M76CB4 阿里地区 西藏自治区 +HCF6UHTXOOKIOA43AIJH3ARKXA 昌都市 西藏自治区 +Y47QI3KJY352QV3VOPXHM2IDWU 日喀则市 西藏自治区 +R4UWJX44GVAA54NFKHT4Y4ZC5A 那曲市 西藏自治区 +2D37GB5XUALJDXONWJIGXV3QXU 香港 香港特别行政区 +PA5W7Z255K3EGYA4LTA7BGLHRA 巴音郭楞蒙古自治州 新疆维吾尔自治区 +RJZOCU5ECQCOLCOCHJH2UNLQJM 哈密市 新疆维吾尔自治区 +RYK6AR3VDQJFXZLX3MHYFV5VAI 塔城地区 新疆维吾尔自治区 +MXUVGU5NPVTNINAKKPNLWUQ54Q 博尔塔拉蒙古自治州 新疆维吾尔自治区 +T5RKMSH5VIGRTHDZOKV2EIKPIM 伊犁哈萨克自治州 新疆维吾尔自治区 +NABMKFZPOUCZMS4TUVJSZ24DNA 克孜勒苏柯尔克孜自治州 新疆维吾尔自治区 +2YQBXWNFYVJX4NU6WXB5II6X34 昌吉回族自治州 新疆维吾尔自治区 +DBKCQCCQU2URQG2EP5ZNPKBETY 乌鲁木齐市 新疆维吾尔自治区 +DON6KYBCR2XJQOJQGOZTYV4RMM 阿克苏地区 新疆维吾尔自治区 +WKG47NEVYII5JISZJN7QSI2BDQ 克拉玛依市 新疆维吾尔自治区 +DWLK6D3OUOXOVQHSEJVTRIDRAI 喀什地区 新疆维吾尔自治区 +SAPKF2PQGJD4UMVJZTC3IZKI64 阿勒泰地区 新疆维吾尔自治区 +ARWSLGG54LGUGN3XMIWW76NW34 吐鲁番市 新疆维吾尔自治区 +VQCWAKL6ADHYFTSVPBGPDFSB2I 北屯市 新疆维吾尔自治区 +ES5A6MOROAG6F2XAQ25TYYPWUE 铁门关市 新疆维吾尔自治区 +36IUY52AESIPF4QEQAR2RTNQYA 和田地区 新疆维吾尔自治区 +Z26KSUL6ULS65ITZNUCRRWBYJM 文山壮族苗族自治州 云南省 +XBBUUATPBD2IUJR47FCKVUXB2I 昭通市 云南省 +TK2LP3JCYYMPTV4OA3WJCH7UQ4 怒江傈僳族自治州 云南省 +ISJ4FESOYKCQ5LXOQO6NL7TQHA 曲靖市 云南省 +ELBUBVI5UMUIEQGIOAHPMESXFA 西双版纳傣族自治州 云南省 +QHOYHEGZM4WSJZPFEU6FCBYIWY 玉溪市 云南省 +4G4SPJ7MVHMYZAWMQ4642PMLVI 保山市 云南省 +HCHRV2LGJ2TJ4X6BWNWI2IMID4 普洱市 云南省 +IS4Q6NASBWHO3RFO7UCIWOXVI4 昆明市 云南省 +TDJZOAZFQUQPYRR5BHOJTG6RWM 红河哈尼族彝族自治州 云南省 +EIYC62RNU4SHQW3RLAMDPTQYTI 大理白族自治州 云南省 +BA3XFPITAYKBUWDKU3QONRHBC4 德宏傣族景颇族自治州 云南省 +6P6FFFO6C5MLNCVRJAJUICSVQI 临沧市 云南省 +OXHMWH2TSIDI7BQ43EHAMXJ6N4 丽江市 云南省 +QQPDT4LBI2K2KMBNXZ6YH7X2FI 楚雄彝族自治州 云南省 +XBG4EJAWCRJL2TDPNJ23PTFYHQ 迪庆藏族自治州 云南省 +DINNCH54AP74TJ62MICEYAZP74 宁波市 浙江省 +XYTSLYGB2ETU6HG7GIXA7X5SOE 嘉兴市 浙江省 +NNAALJZXGAWALR3LGE2V4UZT6U 丽水市 浙江省 +H5UOJ5MQYJ737GS3TXYN2OJHUU 杭州市 浙江省 +HG5VQGOMSCEGNXJXKO6XCNCHMY 湖州市 浙江省 +LJ2SWEPRINTYDH5A2QHRMI5US4 衢州市 浙江省 +TW4RRM62TDA7WWU77FDSLSAXGY 台州市 浙江省 +HBBN247QZ6YUW5ZYSS6D7RVJCA 绍兴市 浙江省 +GVFB23SJZGRPRXXTIKDCAOCDPI 金华市 浙江省 +UEW4ENX7N7IFGFM7FD5SZ5GI7Y 舟山市 浙江省 +HCCXS5DGRJQMYZMRLGVAMUIQEA 温州市 浙江省 +FDGY55I6IHKY76E3MWDBOT2R6Y 重庆市 重庆市 \ No newline at end of file diff --git a/app/utils/geo.py b/app/utils/geo.py new file mode 100644 index 0000000..03e4577 --- /dev/null +++ b/app/utils/geo.py @@ -0,0 +1,65 @@ +"""通过经纬度反查城市(reverse_geocoder 离线库,零网络调用)。 + +reverse_geocoder 内置 ~2.5M 条全球城市/聚居点的经纬度→地名映射表, +构建一次 KDTree(~几十MB 内存)后,查询为纯内存搜索,不作任何外部网络调用。 + +⚠️ 必须持有单例、且用 mode=1(单进程): + - reverse_geocoder 的模块级 rg.search()/rg.get() **每次调用都会 new 一个 RGeocoder**, + 即每次都重新解析 ~2.5M 行 CSV + 重建 KDTree(数秒/次)。绝不能在服务端按请求调用。 + - 默认 mode=2 用 multiprocessing 按 CPU 数 spawn 子进程做并行查询;在服务端 / Windows + spawn 下会重复 import 主模块(无 __main__ guard 时直接报错),既慢又危险。 + 故本模块持有一个 mode=1 的 RGeocoder 单例,建一次树、复用;查询走单进程内存搜索。 + +⚠️ 精度说明:gazetteer 里的中国数据粒度不一致——直辖市/省会通常直接命中城市名, +但部分城市会命中到区/街道级(如天津→Erwangzhuang、西安→Zhangjiabao), +此时 admin1(省级行政区)可作为回退。业务侧建议优先用 admin1 做城市级判定。 +""" +from __future__ import annotations + +from typing import Any + +import reverse_geocoder as rg # type: ignore[import-untyped] + +# mode=1 单进程 KDTree 的单例;None 表示尚未构建(见 ensure_loaded)。 +_geocoder: rg.RGeocoder | None = None + + +def ensure_loaded() -> None: + """构建(或复用)RGeocoder 单例(幂等)。 + + 首次调用解析 ~2.5M 行 CSV + 构建 KDTree(~秒级、数十 MB)。生产应在 main.py 的 + lifespan 启动阶段主动调用一次,把这份一次性成本摊到启动,避免砸在第一个 + /feed?tab=rec / /top-sales 请求上。mode=1 = 单进程,不 spawn 子进程。 + """ + global _geocoder + if _geocoder is None: + _geocoder = rg.RGeocoder(mode=1, verbose=False) + + +def get_city(latitude: float, longitude: float) -> dict[str, str]: + """根据经纬度反查最近聚居点。 + + 返回 dict: + - name: 最近聚居点名称(英文),如 "Beijing" / "Fengsheng"; + 中国境内可能是区/街道级;海洋/无人区返 "" + - admin1: 省级行政区(英文),如 "Beijing" / "Hubei" / "Chongqing Shi"; + 直辖市 admin1 即为城市名 + - country: ISO 3166-1 alpha-2,如 "CN" + - latitude: 匹配到的参考点纬度(字符串) + - longitude: 匹配到的参考点经度(字符串) + + 未匹配到(海洋/远洋)时返回空字符串字段。 + """ + ensure_loaded() + assert _geocoder is not None # ensure_loaded 保证已构建 + results: list[dict[str, Any]] = _geocoder.query([(latitude, longitude)]) + if not results: + return {"name": "", "admin1": "", "country": "", "latitude": "", "longitude": ""} + r = results[0] + return { + "name": str(r.get("name", "")), + "admin1": str(r.get("admin1", "")), + "country": str(r.get("cc", "")), + "latitude": str(r.get("lat", "")), + "longitude": str(r.get("lon", "")), + } diff --git a/app/utils/meituan_city.py b/app/utils/meituan_city.py new file mode 100644 index 0000000..22d0e0f --- /dev/null +++ b/app/utils/meituan_city.py @@ -0,0 +1,448 @@ +"""美团城市词典 + reverse_geocoder 离线反查。 + +从 feed 入参的 latitude/longitude 计算出美团城市 ID, +用于后续美团 CPS 接口的 cityId 参数。 + +⚠️ 跨系统耦合:本模块返回的 city_id 取自 data/city_dict.txt,而离线库 +`meituan_coupon.city_id` 由 ETL(另一套系统)灌入。二者必须用同一份城市 ID 口径, +否则 `WHERE city_id == <本模块结果>` 会静默查到 0 行 → 接口永久降级返空。 +改动 city_dict.txt 或 ETL 的城市 ID 来源时,务必同步两侧。 +""" +from __future__ import annotations + +import logging +import re +from functools import lru_cache +from pathlib import Path + +from app.utils.geo import get_city as _get_geo_city + +logger = logging.getLogger("shagua.meituan_city") + +# city_dict.txt 作为运行时数据随包分发(见 pyproject [tool.setuptools.package-data]) +_CITY_DICT_PATH = Path(__file__).resolve().parent / "data" / "city_dict.txt" + +# ─────────── 反向地理编码 admin1 → 中文省份名 ─────────── +# reverse_geocoder 的 admin1 格式不统一: +# 直辖市: "Beijing" / "Shanghai Shi" / "Tianjin Shi" / "Chongqing Shi" +# 省份: "Guangdong" / "Jiangsu Sheng" / "Hubei" ... +# 自治区: "Xinjiang Uygur Zizhiqu" / "Tibet Autonomous Region" ... +# 下面用前缀匹配,去掉了 Sheng/Shi/Zizhiqu/Autonomous Region 等后缀。 + +_PROVINCE_EN_PREFIX: list[tuple[str, str]] = [ + # 直辖市 — admin1 即城市名 + ("Beijing", "北京市"), + ("Shanghai", "上海市"), + ("Tianjin", "天津市"), + ("Chongqing", "重庆市"), + # 省 + ("Hebei", "河北省"), + ("Shanxi", "山西省"), # 注意: 指山西省,不是陕西 + ("Liaoning", "辽宁省"), + ("Jilin", "吉林省"), + ("Heilongjiang", "黑龙江省"), + ("Jiangsu", "江苏省"), + ("Zhejiang", "浙江省"), + ("Anhui", "安徽省"), + ("Fujian", "福建省"), + ("Jiangxi", "江西省"), + ("Shandong", "山东省"), + ("Henan", "河南省"), + ("Hubei", "湖北省"), + ("Hunan", "湖南省"), + ("Guangdong", "广东省"), + ("Hainan", "海南省"), + ("Sichuan", "四川省"), + ("Guizhou", "贵州省"), + ("Yunnan", "云南省"), + ("Shaanxi", "陕西省"), # 双写 a 是官方拼音 + ("Gansu", "甘肃省"), + ("Qinghai", "青海省"), + # 自治区 — 注意匹配顺序, Xinjiang 要在 Guangxi 前面(Guangxi 也是 Xi 开头但先匹配 Xin 不会误判) + ("Guangxi", "广西壮族自治区"), + ("Inner Mongolia", "内蒙古自治区"), + ("Nei Mongol", "内蒙古自治区"), + ("Tibet", "西藏自治区"), + ("Xizang", "西藏自治区"), + ("Ningxia", "宁夏回族自治区"), + ("Xinjiang", "新疆维吾尔自治区"), + # 特别行政区 + ("Hong Kong", "香港特别行政区"), + ("Macau", "澳门特别行政区"), + ("Macao", "澳门特别行政区"), + # 台湾(city_dict 里省份名为 "台湾",没有省/自治区后缀) + ("Taiwan", "台湾"), +] + +# ─────────── 常见城市名 英文→中文 映射 ─────────── +# 覆盖所有直辖市 + 省会 + 一线城市 + 部分 reverse_geocoder 只能命中到区/县的城市。 +# key 全小写,匹配时做小写比较。 +_CITY_EN_TO_CN: dict[str, str] = { + # 直辖市 + "beijing": "北京市", + "shanghai": "上海市", + "tianjin": "天津市", + "chongqing": "重庆市", + # 省会 / 副省级 + "guangzhou": "广州市", + "shenzhen": "深圳市", + "chengdu": "成都市", + "hangzhou": "杭州市", + "wuhan": "武汉市", + "xi'an": "西安市", + "nanjing": "南京市", + "changsha": "长沙市", + "zhengzhou": "郑州市", + "jinan": "济南市", + "kunming": "昆明市", + "fuzhou": "福州市", + "harbin": "哈尔滨市", + "lanzhou": "兰州市", + "guiyang": "贵阳市", + "nanning": "南宁市", + "shijiazhuang": "石家庄市", + "taiyuan": "太原市", + "shenyang": "沈阳市", + "changchun": "长春市", + "hefei": "合肥市", + "nanchang": "南昌市", + "haikou": "海口市", + "hohhot": "呼和浩特市", + "huhehaote": "呼和浩特市", + "urumqi": "乌鲁木齐市", + "wulumuqi": "乌鲁木齐市", + "lhasa": "拉萨市", + "yinchuan": "银川市", + "xining": "西宁市", + # 其他常见城市 + "xiamen": "厦门市", + "suzhou": "苏州市", + "qingdao": "青岛市", + "dalian": "大连市", + "ningbo": "宁波市", + "wuxi": "无锡市", + "foshan": "佛山市", + "dongguan": "东莞市", + "zhuhai": "珠海市", + "zhongshan": "中山市", + "wenzhou": "温州市", + "shaoxing": "绍兴市", + "jiaxing": "嘉兴市", + "jinhua": "金华市", + "taizhou": "台州市", + "yangzhou": "扬州市", + "nantong": "南通市", + "changzhou": "常州市", + "xuzhou": "徐州市", + "zhengjiang": "镇江市", + "yantai": "烟台市", + "weifang": "潍坊市", + "zibo": "淄博市", + "linyi": "临沂市", + "weihai": "威海市", + "rizhao": "日照市", + "luoyang": "洛阳市", + "kaifeng": "开封市", + "xinxiang": "新乡市", + "nanyang": "南阳市", + "yichang": "宜昌市", + "xiangyang": "襄阳市", + "huangshi": "黄石市", + "zhuzhou": "株洲市", + "xiangtan": "湘潭市", + "yueyang": "岳阳市", + "hengyang": "衡阳市", + "mianyang": "绵阳市", + "luzhou": "泸州市", + "yibin": "宜宾市", + "nanchong": "南充市", + "zigong": "自贡市", + "qujing": "曲靖市", + "yuxi": "玉溪市", + "zunyi": "遵义市", + "guilin": "桂林市", + "liuzhou": "柳州市", + "sanya": "三亚市", + "tangshan": "唐山市", + "baoding": "保定市", + "handan": "邯郸市", + "qinhuangdao": "秦皇岛市", + "langfang": "廊坊市", + "datong": "大同市", + "changzhi": "长治市", + "linfen": "临汾市", + "baotou": "包头市", + "ordos": "鄂尔多斯市", + "eerduosi": "鄂尔多斯市", + "daqing": "大庆市", + "qiqihar": "齐齐哈尔市", + "jilin_city": "吉林市", + "anshan": "鞍山市", + "fushun": "抚顺市", + "benxi": "本溪市", + "jinzhou": "锦州市", + "yingkou": "营口市", + "dandong": "丹东市", + "huizhou": "惠州市", + "jiangmen": "江门市", + "zhanjiang": "湛江市", + "maoming": "茂名市", + "zhaoqing": "肇庆市", + "chaozhou": "潮州市", + "shantou": "汕头市", + "shaoguan": "韶关市", + "meizhou": "梅州市", + "jieyang": "揭阳市", + "qingyuan": "清远市", + "heyuan": "河源市", + "yangjiang": "阳江市", + "shanwei": "汕尾市", + "yunfu": "云浮市", +} + + +# ─────────── 省会映射(城市匹配失败时回退) ─────────── +# city_dict.txt 内省份的第一个城市不一定是省会,故显式维护。 +_PROVINCE_CAPITAL: dict[str, str] = { + "安徽省": "合肥市", + "澳门特别行政区": "澳门", + "北京市": "北京市", + "福建省": "福州市", + "甘肃省": "兰州市", + "广东省": "广州市", + "广西壮族自治区": "南宁市", + "贵州省": "贵阳市", + "海南省": "海口市", + "河北省": "石家庄市", + "河南省": "郑州市", + "黑龙江省": "哈尔滨市", + "湖北省": "武汉市", + "湖南省": "长沙市", + "吉林省": "长春市", + "江苏省": "南京市", + "江西省": "南昌市", + "辽宁省": "沈阳市", + "内蒙古自治区": "呼和浩特市", + "宁夏回族自治区": "银川市", + "青海省": "西宁市", + "山东省": "济南市", + "山西省": "太原市", + "陕西省": "西安市", + "上海市": "上海市", + "四川省": "成都市", + "台湾": "台北市", + "天津市": "天津市", + "西藏自治区": "拉萨市", + "香港特别行政区": "香港", + "新疆维吾尔自治区": "乌鲁木齐市", + "云南省": "昆明市", + "浙江省": "杭州市", + "重庆市": "重庆市", +} + + +# ─────────── 城市字典加载 ─────────── + +def _parse_city_dict(path: str | Path) -> list[dict[str, str]]: + """解析 city_dict.txt,返回 [{city_id, city_name, province_name}, ...]。 + + city_dict.txt 格式(TSV): + 城市ID\t城市名称\t省份名称 + + 示例行: + 3NUYJKKJXPHVNZUHFK3HWUDHNM\t宣城市\t安徽省 + """ + data: list[dict[str, str]] = [] + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + parts = line.split("\t") + if len(parts) < 3: + continue + city_id, city_name, province_name = parts[0], parts[1], parts[2] + if city_id == "城市ID": + continue # 跳过表头 + if city_id and city_name and province_name: + data.append({ + "city_id": city_id, + "city_name": city_name, + "province_name": province_name, + }) + return data + + +# 模块加载时一次解析 +try: + _CITY_DICT: list[dict[str, str]] = _parse_city_dict(_CITY_DICT_PATH) +except Exception: + logger.exception("加载 city_dict.txt 失败,美团城市反查将不可用") + _CITY_DICT = [] + + +def _build_province_index() -> dict[str, list[dict[str, str]]]: + """构建 省份名 → 该省全部城市列表 的索引。""" + idx: dict[str, list[dict[str, str]]] = {} + for entry in _CITY_DICT: + idx.setdefault(entry["province_name"], []).append(entry) + return idx + + +_PROVINCE_INDEX: dict[str, list[dict[str, str]]] | None = None + + +def _get_province_index() -> dict[str, list[dict[str, str]]]: + global _PROVINCE_INDEX + if _PROVINCE_INDEX is None: + _PROVINCE_INDEX = _build_province_index() + return _PROVINCE_INDEX + + +# ─────────── 查询 ─────────── + +def _map_admin1_to_cn_province(admin1: str) -> str: + """将 reverse_geocoder 的 admin1 映射到 city_dict 中的中文省份名。""" + if not admin1: + return "" + normalized = admin1.strip() + # 多级匹配:先精确、再前缀 + for en_prefix, cn_name in _PROVINCE_EN_PREFIX: + if normalized == en_prefix or normalized.startswith(en_prefix): + return cn_name + return "" + + +def _lookup_city_in_province(city_en_lower: str, province_cn: str) -> str: + """在指定省份内查找匹配的城市名(EN→CN 映射)。""" + if not province_cn: + return "" + index = _get_province_index() + candidates = index.get(province_cn, []) + if not candidates: + return "" + + # 1) 精确映射 + if city_en_lower in _CITY_EN_TO_CN: + cn_city = _CITY_EN_TO_CN[city_en_lower] + for c in candidates: + if c["city_name"] == cn_city: + return cn_city + + # 2) 前缀/包含匹配(处理 admin1 直辖市场景:行政区 → 直辖市本身) + for c in candidates: + # 去掉"市"后缀比较 + city_core = c["city_name"].rstrip("市") + if city_en_lower.startswith(city_core.lower()) or city_core.lower().startswith(city_en_lower): + return c["city_name"] + # city_en_lower 可能是拼音,city_core 是中文,尝试从 EN→CN 映射反向匹配 + for en_k, cn_v in _CITY_EN_TO_CN.items(): + if cn_v == c["city_name"] and (city_en_lower in en_k or en_k in city_en_lower): + return cn_v + + # 3) 匹配不到 → 返回省会 + capital = _PROVINCE_CAPITAL.get(province_cn, "") + if capital: + for c in candidates: + if c["city_name"] == capital: + return capital + return candidates[0]["city_name"] # 终极兜底 + + +def _sanitize_city_name(name: str) -> str: + """去除 reverse_geocoder name 中常见的行政后缀使匹配更鲁棒。""" + # 去掉 " District" / " Qu" / " Shi" 等英文后缀 + for suffix in ("District", "Qu", "Shi", "Sheng", "Xian", "Cun", "Zhen", "Xiang", + "Zizhiqu", "Autonomous Region", "Special Administrative Region"): + name = re.sub(rf"\s+{suffix}$", "", name, flags=re.IGNORECASE) + return name.strip() + + +@lru_cache(maxsize=512) +def _resolve_meituan_city(latitude: float, longitude: float) -> dict[str, str]: + """反查实现;入参已量化(见 get_meituan_city),故 lru_cache 命中率高。 + + 返回的 dict 被缓存复用 —— 调用方勿原地修改(get_meituan_city 已返回副本)。 + """ + if not _CITY_DICT: + return {"city_id": "", "city_name": "", "province_name": ""} + + logger.debug("resolve_meituan_city: lat=%.2f lon=%.2f", latitude, longitude) + geo = _get_geo_city(latitude, longitude) + name_en = _sanitize_city_name(geo.get("name", "")) + admin1 = geo.get("admin1", "") + country = geo.get("country", "") + + if country != "CN": + logger.debug("resolve_meituan_city: 坐标(%.2f,%.2f)不在中国境内(country=%s)", latitude, longitude, country) + return {"city_id": "", "city_name": "", "province_name": ""} + + # 1) 映射省份 + province_cn = _map_admin1_to_cn_province(admin1) + if not province_cn: + logger.warning("get_meituan_city: admin1=%r 无法映射到中文省份", admin1) + return {"city_id": "", "city_name": "", "province_name": ""} + + # 2) 查找城市 + name_lower = name_en.lower() + city_cn = _lookup_city_in_province(name_lower, province_cn) + + # 3) 按省份+城市匹配 city_dict 中的城市 ID + index = _get_province_index() + candidates = index.get(province_cn, []) + for c in candidates: + if city_cn and c["city_name"] == city_cn: + return { + "city_id": c["city_id"], + "city_name": c["city_name"], + "province_name": province_cn, + } + + # 4) 最终回退:返回该省省会 + if candidates: + capital = _PROVINCE_CAPITAL.get(province_cn, "") + if capital: + for c in candidates: + if c["city_name"] == capital: + logger.info("get_meituan_city: 城市匹配失败 name_en=%r, 回退到省会 %s", name_en, capital) + return { + "city_id": c["city_id"], + "city_name": capital, + "province_name": province_cn, + } + # 终极兜底:第一个城市 + fallback = candidates[0] + logger.info("get_meituan_city: 城市匹配失败 name_en=%r, 回退到 %s", name_en, fallback["city_name"]) + return { + "city_id": fallback["city_id"], + "city_name": fallback["city_name"], + "province_name": province_cn, + } + + return {"city_id": "", "city_name": "", "province_name": ""} + + +def get_meituan_city(latitude: float, longitude: float) -> dict[str, str]: + """根据经纬度反查美团城市 ID + 城市名 + 省份名(对外入口)。 + + 返回: + - city_id: 美团城市 ID(如 3NUYJKKJXPHVNZUHFK3HWUDHNM); + 匹配失败时返回 "" + - city_name: 中文城市名(如 "北京市") + - province_name: 中文省份名(如 "北京市") + + 原理: + 1. reverse_geocoder 根据经纬度查出英文地名 + 省份 + 2. 英文省份→中文省份映射(前缀匹配) + 3. 英文地名→中文城市名映射(精确映射 + 省内候选回退) + 4. 在 city_dict.txt 中按省份+城市名匹配城市 ID + + 城市名匹配失败的策略: + - 直辖市(京沪津渝): admin1 本身即城市名,直接取 + - 省会: 回退到该省第一个城市(city_dict.txt 中每个省的省会通常排第一位) + + 实现说明:先把坐标量化到 ~1km(round 到 2 位小数)再进 lru_cache —— 原始 GPS 坐标 + 每次抖动到小数点后 5~6 位,直接做缓存 key 几乎不命中;城市级解析对 1km 误差不敏感, + 量化后"同一地点反复请求"可命中缓存。返回缓存 dict 的副本,调用方可安全读写。 + """ + return dict(_resolve_meituan_city(round(latitude, 2), round(longitude, 2))) diff --git a/deploy/daily-exchange.service b/deploy/daily-exchange.service index aef841a..2a6d949 100644 --- a/deploy/daily-exchange.service +++ b/deploy/daily-exchange.service @@ -19,6 +19,9 @@ Type=oneshot User=root WorkingDirectory=/opt/shaguabijia-app-server Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin" +# 写死北京时:兑换的"当天/0 点"一律按北京时,不随服务器 OS 时区漂(用户 2026-07-01 硬约束)。 +# 脚本内的日期判断本就走 rewards.cn_today()(CN_TZ=+8),这里再把进程 TZ 也钉成北京,双保险。 +Environment="TZ=Asia/Shanghai" EnvironmentFile=/opt/shaguabijia-app-server/.env ExecStart=/opt/shaguabijia-app-server/.venv/bin/python -m scripts.daily_auto_exchange --once SyslogIdentifier=daily-exchange diff --git a/deploy/daily-exchange.timer b/deploy/daily-exchange.timer index d00520b..c6ea4a9 100644 --- a/deploy/daily-exchange.timer +++ b/deploy/daily-exchange.timer @@ -4,8 +4,10 @@ Description=Run daily auto-exchange coins->cash at midnight [Timer] -# 每天 0 点跑。客户端文案已注明「可能存在延迟」,可按需改 00:05 错开整点扎堆。 -OnCalendar=*-*-* 00:00:00 +# 每天【北京时】0 点跑,写死时区(用户 2026-07-01 硬约束:兑换一律北京 0 点,不随服务器本地时区漂)。 +# systemd OnCalendar 支持尾缀时区;不写时区会按服务器 OS 本地时区触发 → 服务器非 CST 时会在错误时刻兑。 +# 客户端文案已注明「可能存在延迟」,可按需改 00:05 错开整点扎堆。 +OnCalendar=*-*-* 00:00:00 Asia/Shanghai # 服务器宕机/重启后,补跑错过的那一轮(而不是干等次日)。 Persistent=true AccuracySec=1min diff --git a/deploy/nginx/app-api.shaguabijia.com.conf b/deploy/nginx/app-api.shaguabijia.com.conf index dcef0f7..4e72656 100644 --- a/deploy/nginx/app-api.shaguabijia.com.conf +++ b/deploy/nginx/app-api.shaguabijia.com.conf @@ -19,7 +19,11 @@ server { ssl_ciphers HIGH:!aNULL:!MD5; ssl_session_cache shared:SSL:10m; - client_max_body_size 4m; + # 上传接口(反馈/上报截图、头像)业务上限 = 最多 6 张 × 每张 5MB + # (见 app _MAX_IMAGES / AVATAR_MAX_BYTES)≈ 30MB,留余量设 32m。 + # 低于此值时带截图的反馈会在到达 uvicorn 前就被 nginx 413,表现为「提交经常失败」 + # (纯文字反馈体积小、不受影响 → 呈现为「时好时坏」)。根治仍需客户端上传前压缩。 + client_max_body_size 32m; location / { proxy_pass http://127.0.0.1:8770; diff --git a/deploy/nginx/observe.shaguabijia.com.conf b/deploy/nginx/observe.shaguabijia.com.conf new file mode 100644 index 0000000..0c9e274 --- /dev/null +++ b/deploy/nginx/observe.shaguabijia.com.conf @@ -0,0 +1,57 @@ +# OpenObserve 监控台反代(observe.shaguabijia.com)。证书走 Certbot/Let's Encrypt,与 admin-web 一致。 +# +# 前置(一次性): +# 1) DNS: observe.shaguabijia.com A 记录 → 本服务器公网 IP +# 2) 证书: sudo certbot certonly --nginx -d observe.shaguabijia.com +# (options-ssl-nginx.conf / ssl-dhparams.pem 首次跑 certbot 时已生成,admin-web 在用即已存在) +# 3) OpenObserve 只绑 127.0.0.1:5080(见 docker-compose.prod.yml),本文件把它反代出公网 +# 4) nginx -t 通过后 systemctl reload nginx +# +# 安全:OO 有自身登录。监控台不必对全网裸开——本机办公网无固定出口 IP,故在 nginx 层加 Basic Auth 兜底; +# 将来有固定 IP 可改用【IP 白名单】块(更省事,可去掉 Basic Auth)。 + +server { + server_name observe.shaguabijia.com; + + client_max_body_size 10m; + + # —— IP 白名单:办公网无固定出口 IP,暂不用;将来有固定 IP 可改用这块(比 Basic Auth 省事)—— + # allow 1.2.3.4; # ← 换成你的真实出口 IP,可多行 + # deny all; + + # —— Basic Auth:无固定 IP 的兜底密码(生成 .htpasswd_observe 的命令见 README/下方)—— + auth_basic "OpenObserve"; + auth_basic_user_file /etc/nginx/conf.d/.htpasswd_observe; + + location / { + proxy_pass http://127.0.0.1:5080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # OpenObserve 有实时/流式面板,需透传 WebSocket + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 300s; + } + + # IPv6 这行不带 ipv6only=on:该选项对 [::]:443 全局只能设一次,admin-web 那个 server 块已设(否则 nginx 报 duplicate listen options) + listen [::]:443 ssl; # managed by Certbot + listen 443 ssl; # managed by Certbot + ssl_certificate /etc/letsencrypt/live/observe.shaguabijia.com/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/observe.shaguabijia.com/privkey.pem; # managed by Certbot + include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot +} + +server { + if ($host = observe.shaguabijia.com) { + return 301 https://$host$request_uri; + } # managed by Certbot + + listen 80; + listen [::]:80; + server_name observe.shaguabijia.com; + return 404; # managed by Certbot +} diff --git a/deploy/openobserve/.gitignore b/deploy/openobserve/.gitignore new file mode 100644 index 0000000..bcf98ee --- /dev/null +++ b/deploy/openobserve/.gitignore @@ -0,0 +1,4 @@ +# OpenObserve 落盘数据(parquet/索引/元数据),运行时产生,不入库。 +data/ +# 生产 compose 的密码文件(OO_ROOT_PASSWORD),含机密,不入库。 +.env diff --git a/deploy/openobserve/README.md b/deploy/openobserve/README.md new file mode 100644 index 0000000..70925f4 --- /dev/null +++ b/deploy/openobserve/README.md @@ -0,0 +1,126 @@ +# OpenObserve 本地部署(接口 QPS / 耗时可观测) + +app-server 通过中间件采集每个接口的 QPS + 耗时 + 错误率,批量上报到这里。 +设计见 [../../docs/superpowers/specs/2026-07-06-openobserve-api-metrics-design.md](../../docs/superpowers/specs/2026-07-06-openobserve-api-metrics-design.md)。 + +## 启动 + +```bash +cd deploy/openobserve +docker compose up -d +``` + +- Web UI:http://localhost:5080 +- 登录:`admin@shaguabijia.local` / `Complexpass#123`(见 `docker-compose.yml`) +- 数据落 `deploy/openobserve/data/`(已挂卷持久化;该目录已 gitignore) + +## 让 app-server 上报 + +在项目根的 `.env` 打开观测(`OBSERVE_*`,账号密码与 compose 里 root 一致): + +```dotenv +OBSERVE_ENABLED=true +OBSERVE_ENDPOINT=http://localhost:5080 +OBSERVE_ORG=default +OBSERVE_STREAM=app_requests +OBSERVE_USER=admin@shaguabijia.local +OBSERVE_PASSWORD=Complexpass#123 +``` + +重启 app-server,随便打几个接口。stream `app_requests` **首次上报自动创建**, +在 UI 的 Logs → 选 `app_requests` 就能看到逐条请求事件(字段:`method` / `route` / +`status` / `duration_ms` / `service` / `env`)。 + +> 未开 `OBSERVE_ENABLED` 或缺账号密码时,中间件透传、worker 不启动,整套 no-op,不影响业务。 + +## 查询(Logs 页 SQL,或建 Dashboard 面板) + +各接口 QPS(1 分钟分桶,面板里再除 60 得每秒): + +```sql +SELECT route, histogram(_timestamp, '1 minute') AS ts, count(*) AS cnt +FROM app_requests GROUP BY route, ts ORDER BY ts +``` + +各接口 P95 耗时(毫秒): + +```sql +SELECT route, approx_percentile_cont(duration_ms, 0.95) AS p95_ms +FROM app_requests GROUP BY route ORDER BY p95_ms DESC +``` + +各接口错误率(5xx 占比): + +```sql +SELECT route, + count(*) FILTER (WHERE status >= 500) * 100.0 / count(*) AS err_pct +FROM app_requests GROUP BY route ORDER BY err_pct DESC +``` + +## 一键导入现成仪表盘(QPS / P95 / 分位 / 错误率) + +备好了 [dashboard-api-metrics.json](dashboard-api-metrics.json),4 个面板:各接口每分钟请求数(QPS 源)、 +P95 耗时折线、P50/P95/P99 分位表、5xx 错误率表。 + +- **UI 导入**:Dashboards → 右上 **Import** → 选该 JSON 文件 → Import(每次导入新建,不覆盖)。 +- **或 API 导入**: + ```bash + curl -u admin@shaguabijia.local:Complexpass#123 -H 'Content-Type: application/json' \ + -X POST 'http://localhost:5080/api/default/dashboards?folder=default' \ + --data-binary @deploy/openobserve/dashboard-api-metrics.json + ``` + +导入后进仪表盘,右上角时间调到「最近 15 分钟 / 1 小时」、开自动刷新即可。低流量下 QPS 面板看「每分钟请求数」比「每秒」直观。 + +## 停止 / 清数据 + +```bash +docker compose down # 停止(保留数据) +docker compose down -v && rm -rf data # 停止并清空数据 +``` + +## 生产部署(单机)+ UI 访问 + +前提:app-server 与 OpenObserve **同机**,app→OO 走 localhost(`127.0.0.1:5080`)、不出网、无需 TLS。 +唯一要防的是**别把 :5080 裸暴露公网**。硬化版编排见 [docker-compose.prod.yml](docker-compose.prod.yml)。 + +### 部署步骤 + +```bash +# 1) 密码文件(本目录,已 gitignore) +echo "OO_ROOT_PASSWORD=$(python -c 'import secrets;print(secrets.token_urlsafe(24))')" > deploy/openobserve/.env + +# 2) 起 OpenObserve(只绑 127.0.0.1、命名卷持久化、mem 1g) +cd deploy/openobserve && docker compose -f docker-compose.prod.yml up -d +sudo systemctl enable docker # 开机自起 +``` + +3) app-server 的 `.env` 打开观测并**重启**(用非 root 的专用 ingest 账号): +```dotenv +OBSERVE_ENABLED=true +OBSERVE_ENDPOINT=http://127.0.0.1:5080 +OBSERVE_ORG=default +OBSERVE_STREAM=app_requests +OBSERVE_USER=ingest@shaguabijia.com # UI → Users 建的非 root 账号 +OBSERVE_PASSWORD=<该账号密码> +``` +```bash +sudo systemctl restart shaguabijia-app-server # 日志出现 "observe worker started" 即生效 +``` + +4) 两个必做收口(磁盘/安全): +- **保留期**:UI → Streams → `app_requests` → Data Retention 设 14/30 天(一请求一行,不封顶迟早撑爆盘)。 +- **专用账号**:UI → Users 建非 root 账号给 app 上报,root 只留人工登 UI。 + +### UI 访问(二选一) + +**A. SSH 隧道(推荐,零暴露、不用域名/证书):** +```bash +ssh -L 5080:127.0.0.1:5080 用户@服务器IP +# 然后本机浏览器开 http://localhost:5080 +``` + +**B. nginx 子域名反代(要固定 URL / 团队常看):** 见 [../nginx/observe.shaguabijia.com.conf](../nginx/observe.shaguabijia.com.conf)。 +需 DNS `observe.shaguabijia.com` → 本机 + 证书放 `/etc/nginx/ssl/`;含 IP 白名单 + TLS + WebSocket 透传。 + +> ⚠️ prod compose 必须保持 `127.0.0.1:5080:5080`;写成 `5080:5080`(绑 0.0.0.0)= 裸暴露公网,这是唯一真正的坑。 diff --git a/deploy/openobserve/dashboard-api-metrics.json b/deploy/openobserve/dashboard-api-metrics.json new file mode 100644 index 0000000..2563ad1 --- /dev/null +++ b/deploy/openobserve/dashboard-api-metrics.json @@ -0,0 +1,302 @@ +{ + "version": 8, + "dashboardId": "api-metrics", + "title": "接口监控 (QPS / 耗时 / 错误率)", + "description": "app-server 接口 QPS、P50/P95/P99 耗时、5xx 错误率。数据流 app_requests。", + "role": "", + "tabs": [ + { + "tabId": "default", + "name": "Default", + "panels": [ + { + "id": "panel_qps", + "type": "line", + "title": "各接口 每分钟请求数 (QPS 源)", + "description": "", + "config": { + "show_legends": true, + "legends_position": null, + "decimals": 2.0, + "axis_border_show": false, + "base_map": null, + "map_view": null + }, + "queryType": "sql", + "queries": [ + { + "query": "SELECT histogram(_timestamp, '1 minute') as ts, route, count(*) as reqs FROM app_requests GROUP BY ts, route ORDER BY ts", + "vrlFunctionQuery": "", + "customQuery": true, + "fields": { + "stream": "app_requests", + "stream_type": "logs", + "x": [ + { + "label": "ts", + "alias": "ts", + "column": "ts", + "color": null, + "sortBy": "ASC" + } + ], + "y": [ + { + "label": "reqs", + "alias": "reqs", + "column": "reqs", + "color": null + } + ], + "z": [], + "breakdown": [ + { + "label": "route", + "alias": "route", + "column": "route", + "color": null + } + ], + "filter": { + "filterType": "group", + "logicalOperator": "AND", + "conditions": [] + } + }, + "config": { + "promql_legend": "", + "layer_type": "scatter", + "weight_fixed": 1.0 + } + } + ], + "layout": { + "x": 0, + "y": 0, + "w": 24, + "h": 9, + "i": 1 + } + }, + { + "id": "panel_p95", + "type": "line", + "title": "各接口 P95 耗时 (ms)", + "description": "", + "config": { + "show_legends": true, + "legends_position": null, + "decimals": 2.0, + "axis_border_show": false, + "base_map": null, + "map_view": null + }, + "queryType": "sql", + "queries": [ + { + "query": "SELECT histogram(_timestamp, '1 minute') as ts, route, approx_percentile_cont(duration_ms, 0.95) as p95_ms FROM app_requests GROUP BY ts, route ORDER BY ts", + "vrlFunctionQuery": "", + "customQuery": true, + "fields": { + "stream": "app_requests", + "stream_type": "logs", + "x": [ + { + "label": "ts", + "alias": "ts", + "column": "ts", + "color": null, + "sortBy": "ASC" + } + ], + "y": [ + { + "label": "p95_ms", + "alias": "p95_ms", + "column": "p95_ms", + "color": null + } + ], + "z": [], + "breakdown": [ + { + "label": "route", + "alias": "route", + "column": "route", + "color": null + } + ], + "filter": { + "filterType": "group", + "logicalOperator": "AND", + "conditions": [] + } + }, + "config": { + "promql_legend": "", + "layer_type": "scatter", + "weight_fixed": 1.0 + } + } + ], + "layout": { + "x": 24, + "y": 0, + "w": 24, + "h": 9, + "i": 2 + } + }, + { + "id": "panel_pctl", + "type": "table", + "title": "各接口 耗时分位 P50/P95/P99 (ms)", + "description": "", + "config": { + "show_legends": true, + "legends_position": null, + "decimals": 2.0, + "axis_border_show": false, + "base_map": null, + "map_view": null + }, + "queryType": "sql", + "queries": [ + { + "query": "SELECT route, approx_percentile_cont(duration_ms,0.5) as p50, approx_percentile_cont(duration_ms,0.95) as p95, approx_percentile_cont(duration_ms,0.99) as p99, count(*) as cnt FROM app_requests GROUP BY route ORDER BY p95 DESC", + "vrlFunctionQuery": "", + "customQuery": true, + "fields": { + "stream": "app_requests", + "stream_type": "logs", + "x": [ + { + "label": "route", + "alias": "route", + "column": "route", + "color": null + } + ], + "y": [ + { + "label": "p50", + "alias": "p50", + "column": "p50", + "color": null + }, + { + "label": "p95", + "alias": "p95", + "column": "p95", + "color": null + }, + { + "label": "p99", + "alias": "p99", + "column": "p99", + "color": null + }, + { + "label": "cnt", + "alias": "cnt", + "column": "cnt", + "color": null + } + ], + "z": [], + "breakdown": [], + "filter": { + "filterType": "group", + "logicalOperator": "AND", + "conditions": [] + } + }, + "config": { + "promql_legend": "", + "layer_type": "scatter", + "weight_fixed": 1.0 + } + } + ], + "layout": { + "x": 0, + "y": 9, + "w": 24, + "h": 9, + "i": 3 + } + }, + { + "id": "panel_err", + "type": "table", + "title": "各接口 错误率 (5xx %)", + "description": "", + "config": { + "show_legends": true, + "legends_position": null, + "decimals": 2.0, + "axis_border_show": false, + "base_map": null, + "map_view": null + }, + "queryType": "sql", + "queries": [ + { + "query": "SELECT route, count(*) FILTER (WHERE status >= 500) * 100.0 / count(*) as err_pct, count(*) as cnt FROM app_requests GROUP BY route ORDER BY err_pct DESC", + "vrlFunctionQuery": "", + "customQuery": true, + "fields": { + "stream": "app_requests", + "stream_type": "logs", + "x": [ + { + "label": "route", + "alias": "route", + "column": "route", + "color": null + } + ], + "y": [ + { + "label": "err_pct", + "alias": "err_pct", + "column": "err_pct", + "color": null + }, + { + "label": "cnt", + "alias": "cnt", + "column": "cnt", + "color": null + } + ], + "z": [], + "breakdown": [], + "filter": { + "filterType": "group", + "logicalOperator": "AND", + "conditions": [] + } + }, + "config": { + "promql_legend": "", + "layer_type": "scatter", + "weight_fixed": 1.0 + } + } + ], + "layout": { + "x": 24, + "y": 9, + "w": 24, + "h": 9, + "i": 4 + } + } + ] + } + ], + "variables": { + "list": [] + } +} \ No newline at end of file diff --git a/deploy/openobserve/docker-compose.prod.yml b/deploy/openobserve/docker-compose.prod.yml new file mode 100644 index 0000000..ddf4b10 --- /dev/null +++ b/deploy/openobserve/docker-compose.prod.yml @@ -0,0 +1,33 @@ +# 生产用 OpenObserve(单机)。相对本地版 docker-compose.yml 的区别: +# - 端口只绑 127.0.0.1 → 公网/外网都到不了(UI 访问走 SSH 隧道或 nginx 反代,见 README) +# - root 密码走环境变量(放同目录 .env,已 gitignore,勿提交) +# - 数据 bind-mount 到宿主 /data 分区(需预建目录 + 确认容器可写)+ CPU/内存上限(与 app/PG 共存防抢内存) +# +# 用法: +# 1) 本目录建 .env(已 gitignore): +# OO_ROOT_PASSWORD=<强随机串> # 生成: python -c "import secrets;print(secrets.token_urlsafe(24))" +# 2) docker compose -f docker-compose.prod.yml up -d +# 3) 开机自起: sudo systemctl enable docker +services: + openobserve: + image: public.ecr.aws/zinclabs/openobserve:v0.91.2 + container_name: openobserve + ports: + - "127.0.0.1:5080:5080" # 只绑本机,安全 + environment: + ZO_ROOT_USER_EMAIL: "admin@shaguabijia.com" + ZO_ROOT_USER_PASSWORD: "${OO_ROOT_PASSWORD:?请先在 deploy/openobserve/.env 里设 OO_ROOT_PASSWORD}" + ZO_DATA_DIR: "/data" + ZO_COMPACT_DATA_RETENTION_DAYS: "30" # 超 30 天自动删,防爆盘(默认 3650 天=10年) + ZO_TELEMETRY: "false" # 关匿名遥测(内网自用);变量名是 ZO_TELEMETRY,不是 *_ENABLED + volumes: + - /data/openobserve/data:/data # 绑定挂载到宿主机的 /data/openobserve/data 目录(建议该目录所在分区有 20G+ 空间) + restart: unless-stopped + deploy: + resources: + limits: # 硬上限:防 OO 查询/ingest 抢爆 CPU/内存,拖垮同机 PG+app + cpus: '2.0' + memory: 3G + logging: # 容器 stdout 日志上限,防爆盘 + driver: json-file + options: { max-size: "10m", max-file: "3" } diff --git a/deploy/openobserve/docker-compose.yml b/deploy/openobserve/docker-compose.yml new file mode 100644 index 0000000..f4cc61a --- /dev/null +++ b/deploy/openobserve/docker-compose.yml @@ -0,0 +1,16 @@ +# 本地开发用 OpenObserve(单容器 = local 模式)。用于接收 app-server 的接口指标(QPS/耗时/错误率)。 +# 启动: cd deploy/openobserve && docker compose up -d +# Web UI: http://localhost:5080 (账号见下方 env) +services: + openobserve: + image: public.ecr.aws/zinclabs/openobserve:latest + container_name: openobserve + ports: + - "5080:5080" + environment: + ZO_ROOT_USER_EMAIL: "admin@shaguabijia.local" + ZO_ROOT_USER_PASSWORD: "Complexpass#123" + ZO_DATA_DIR: "/data" + volumes: + - ./data:/data + restart: unless-stopped diff --git a/docs/README.md b/docs/README.md index 503dd92..bf74c75 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,32 +1,83 @@ -# 后端文档库(docs/) +# 文档索引 -`shaguabijia-app-server` 的文档都在这里。结构:**根目录放总览,其余按领域/用途分目录**。 +项目文档结构说明。后续大模型增补/更新文档时,按此分类找到对应目录。 -## 根目录 +--- -| 文档 | 作用 | -|---|---| -| [后端技术实现.md](./后端技术实现.md) | **后端技术方案总览**:业务概览、分层架构与目录、登录链路、美团 CPS、领券/比价透传、数据模型、配置与部署、已知问题。想了解"整个后端怎么回事"先看这份。 | -| README.md | 本文件:文档库索引/传送门。 | +## API 接口文档 (`api/`) -## 子目录 +按业务领域分类,每个子目录对应一类接口。 -| 目录 | 作用 | -|---|---| -| [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 实现原理 + 本地内网全链路测试) + [CPS发券分发与微信授权.md](./guides/CPS发券分发与微信授权.md)(**CPS 发券系统交接文档**:设群/建活动/生成落地页短链/美团对账/统计 + 微信网页授权拿 openid 做用户级统计;含数据流/表/平台差异/端点/配置/代码地图/排障/技术债)。 | +### 分类目录 -## api/ 目录是怎么组织的(传送门式) +| 目录 | 分类 | URL 前缀 | 说明 | +|------|------|----------|------| +| [api/auth/](api/auth/) | 认证 | `/api/v1/auth` | 用户登录(极光一键登录/短信验证码)、Token 刷新(access + refresh)、登出、当前用户信息查询 | +| [api/ad/](api/ad/) | 广告 | `/api/v1/ad` | 穿山甲 S2S 回调验签发奖、激励视频/信息流广告奖励结算、eCPM 上报、发奖状态查询、测试发奖(仅本地) | +| [api/wallet/](api/wallet/) | 钱包 | `/api/v1/wallet` | 账户资产查询(金币/现金余额)、金币与现金流水、兑换规则与执行、绑定/解绑微信、提现申请/状态/记录、免确认收款授权 | +| [api/coupon/](api/coupon/) | 领券 | `/api/v1/coupon` | CPS 领券透传(step/session)、引导窗频控(should-show/shown/dismiss/reset)、每日完成状态、累计领券统计 | +| [api/compare/](api/compare/) | 比价记录 | `/api/v1/compare` | 比价记录上报/列表/详情/统计、比价战绩里程碑查询及奖励领取(按成功比价数解锁金币) | +| [api/savings/](api/savings/) | 省钱 | `/api/v1/savings` + `/api/v1/platform/savings-feed` | 省钱大作战(battle)、省钱明细/汇总、平台省钱动态 Feed | +| [api/signin/](api/signin/) | 签到 | `/api/v1/signin` | 每日签到执行、签到加速(看广告多领)、签到状态查询 | +| [api/tasks/](api/tasks/) | 任务 | `/api/v1/tasks` | 任务列表查询、任务奖励领取(按 task_key) | +| [api/invite/](api/invite/) | 邀请 | `/api/v1/invite` | 邀请码/分享链接生成、已邀请列表、邀请绑定(支持 clipboard/manual/fingerprint 三种归因) | +| [api/user/](api/user/) | 用户 | `/api/v1/user` | 个人资料编辑(昵称)、头像上传、新手引导状态/完成标记、注销账号 | +| [api/device/](api/device/) | 设备 | `/api/v1/device` | 设备注册/推送 token 更新、无障碍存活心跳、掉线检测(后置 pull)、告警确认 | +| [api/platform/](api/platform/) | 平台配置 | `/api/v1/platform` | 平台统计数据、Feature Flag 开关、广告配置(穿山甲 ID)、App 版本更新检查(OTA);全部不鉴权 | +| [api/intent/](api/intent/) | 意图/电商 | `/api/v1/intent` + `/api/v1/price` + `/api/v1/ecom` | 比价意图识别(Phase 1 单次/多帧/预券)、电商意图识别、比价步进(Phase 2) | +| [api/meituan/](api/meituan/) | 美团 CPS | `/api/v1/meituan` | 美团券列表/信息流/推广链接/销量榜;全部无鉴权 | +| [api/other/](api/other/) | 其它 | 分散 | 健康检查、CPS 短链重定向(含微信 OAuth)、反馈提交/配置/记录、埋点上报、订单上报、更低价上报、Trace 收尾 | +| [api/admin/](api/admin/) | 管理后台 | `/admin/api` | Admin 独立子应用,二级目录按子资源拆分:`auth/`(登录)、`users/`(用户管理/财务操作)、`wallet/`(流水查询)、`withdraws/`(提现审核/对账)、`feedbacks/`(反馈处理)、`admins/`(管理员管理)、`ad/`(广告对账/收益);单文件留根:审计日志、数据大盘、跑马灯种子、统计概览 | +| [api/internal/](api/internal/) | 内部接口 | `/internal` | 服务间调用(pricebot→app-server):价格事实回写、店铺映射、启动确认样本、App 版本写入;X-Internal-Secret 鉴权 | -接口多了之后,单个大文件会臃肿、难维护,所以拆成 **一个索引 + 一个接口一个文件**: +### 接口索引入口 -- **[api/README.md](./api/README.md) — 入口/传送门** - 一张总览表列出**全部接口**(方法、路径、鉴权),每行链接到该接口的独立文档;另含**通用约定**(错误码、时间格式等)和**复用数据结构**(`TokenPair` / `UserOut` / `CouponCard` 等)。它本身不展开每个接口的细节,只负责"指路"。 -- **api/<模块>-<接口>.md — 单接口文档** - 每个接口一个文件,只写自己的入参 / 出参 / 错误码 / 说明。命名按 `<模块>-<接口>`,如 `auth-jverify-login.md`、`coupon-step.md`、`meituan-feed.md`。 +完整接口列表(含路径、方法、鉴权方式)见 [api/README.md](api/README.md)。 -**查某个接口的协议**:先打开 [api/README.md](./api/README.md) 的总览表 → 找到接口 → 点链接进对应文档。 +### 文档命名规则 -> **新增接口时**:在 `api/` 下加一个 `<模块>-<接口>.md`,并到 `api/README.md` 总览表里补一行链接。这样索引始终是唯一的"传送门",细节各自独立、互不干扰。 +单端点文档按 URL 路径命名:`{prefix}-{resource}.md`(如 `wallet-withdraw.md`、`auth-sms-send.md`)。 +含子资源的合并文档按族命名(如 `device-liveness.md` 覆盖 register/heartbeat/liveness/liveness-ack 四个端点)。 +文件名唯一确定文档位置:大类目录 + 文件名前缀 → 直接匹配。 + +--- + +## 数据库文档 (`database/`) + +数据库表结构说明,每表一个文件,[database/OVERVIEW.md](database/OVERVIEW.md) 为索引入口。 + +--- + +## 第三方集成 (`integrations/`) + +外部 SDK/API 集成架构与实现说明:[integrations/README.md](integrations/README.md)。 + +| 文件 | 说明 | +|------|------| +| [jiguang.md](integrations/jiguang.md) | 极光一键登录(REST 验证 + RSA 解密) | +| [pangle.md](integrations/pangle.md) | 穿山甲广告 S2S 回调 | +| [wxpay.md](integrations/wxpay.md) | 微信支付 V3(提现/授权) | +| [meituan.md](integrations/meituan.md) | 美团 CPS 网关 | + +--- + +## 开发指南 (`guides/`) + +| 文件 | 说明 | +|------|------| +| [邀请功能-实现原理与本地测试.md](guides/邀请功能-实现原理与本地测试.md) | 邀请系统实现细节 | +| [CPS发券分发与微信授权.md](guides/CPS发券分发与微信授权.md) | CPS 发券 + 微信网页授权流程 | +| [看广告赚金币上线清单.md](guides/看广告赚金币上线清单.md) | 广告功能上线检查清单 | +| [待办与技术债.md](guides/待办与技术债.md) | 待办事项与技术债务 | + +--- + +## 架构与设计 (`superpowers/`) + +需求规格与设计文档,按 `YYYY-MM-DD--design.md` 命名。 + +--- + +## 后端技术实现 (`后端技术实现.md`) + +后端整体技术架构说明。 diff --git a/docs/api/README.md b/docs/api/README.md index 61b8792..d5e20da 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -3,7 +3,11 @@ > Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770` > 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**(⚠️ 例外:消息通知中心 `notifications` 族与厂商推送 `push` 族按 PRD 前端契约用 **camelCase**,见各自文档) > 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer ` +<<<<<<< HEAD > 最后更新:2026-07-14(新增 **消息通知中心** 3 端点(M1-M3,虚拟数据阶段)与 **厂商推送测试** 3 端点(P1-P3,荣耀/华为/小米/OPPO/vivo);上一次 2026-06-23 补全 device/internal/CPS 短链等整族端点) +======= +> 最后更新:2026-07-09(① 比价透传改「软鉴权 + trace_id 签发 + harvest 落库」(#112 尾声帧 `trace/epilogue` 一并补录);② 新端点:`user/onboarding/reset`(#114)、`GET /internal/launch-confirm-samples`(#91);③ 参数更新:提现族 `source` 分账(#82/#121)、`wallet/account` 邀请奖励金余额、美团 feed/top-sales 按城市过滤(#116)、admin 调现金 `account` 目标账户(#95);④ **Admin 索引补全到当前全量**:新家族 roles(#117/#126)/coupon-data(#99)/device-liveness(#80)/event-logs(#83)/price-reports(#94)/CPS 运营台/提现审核族,及 feedbacks 采纳拒绝(#94/#105)、marquee 模式与真实条浏览(#122/#123)等。上一次 2026-07-03) +>>>>>>> origin/main > 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。 --- @@ -12,90 +16,98 @@ | # | 方法 + 路径 | 鉴权 | 详情 | |---|---|---|---| -| 1 | `GET /health` | 无 | [详情](./health.md) | -| 2 | `POST /api/v1/auth/jverify-login` | 无 | [详情](./auth-jverify-login.md) | -| 3 | `POST /api/v1/auth/sms/send` | 无 | [详情](./auth-sms-send.md) | -| 4 | `POST /api/v1/auth/sms/login` | 无 | [详情](./auth-sms-login.md) | -| 5 | `POST /api/v1/auth/refresh` | 无 | [详情](./auth-refresh.md) | -| 6 | `GET /api/v1/auth/me` | Bearer | [详情](./auth-me.md) | -| 7 | `POST /api/v1/auth/logout` | Bearer | [详情](./auth-logout.md) | -| 8 | `POST /api/v1/coupon/step` | 无 | [详情](./coupon-step.md)(透传 pricebot + best-effort 写 `coupon_*` 三表) | -| 8a | `POST /api/v1/coupon/prompt/shown` | 无 | 引导窗弹出即上报(按 device+package+日记 `shown`,今天这个 App 不再自动弹)(无单独文档) | -| 8b | `POST /api/v1/coupon/prompt/dismiss` | 无 | 用户拒绝/关闭引导窗(透传链路看不到拒绝,客户端通知)(无单独文档) | -| 8c | `GET /api/v1/coupon/prompt/should-show` | 无 | 切到外卖 App 时是否还应弹引导窗(`device_id`+`package`)(无单独文档) | -| 8d | `POST /api/v1/coupon/prompt/reset` | 无 | 重置今日引导窗 engagement(开发测频控用)(无单独文档) | -| 8e | `GET /api/v1/coupon/completed-today` | 无 | 这台设备今天是否已跑完整轮领券(首页「去领取」卡置灰源)(无单独文档) | -| 8f | `POST /api/v1/coupon/completed-today/reset` | 无 | 重置今日已完成(开发用)(无单独文档) | -| 8g | `GET /api/v1/coupon/stats` | Bearer | 累计领券数(「我的」页战绩卡;按 user_id 聚合,**鉴权**)(无单独文档) | -| 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) | -| 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(透传,无单独文档) | +| 1 | `GET /health` | 无 | [详情](./other/health.md) | +| 2 | `POST /api/v1/auth/jverify-login` | 无 | [详情](./auth/auth-jverify-login.md) | +| 3 | `POST /api/v1/auth/sms/send` | 无 | [详情](./auth/auth-sms-send.md) | +| 4 | `POST /api/v1/auth/sms/login` | 无 | [详情](./auth/auth-sms-login.md) | +| 5 | `POST /api/v1/auth/refresh` | 无 | [详情](./auth/auth-refresh.md) | +| 6 | `GET /api/v1/auth/me` | Bearer | [详情](./auth/auth-me.md) | +| 7 | `POST /api/v1/auth/logout` | Bearer | [详情](./auth/auth-logout.md) | +| 8 | `POST /api/v1/coupon/step` | 无 | [详情](./coupon/coupon-step.md)(透传 pricebot + best-effort 写 `coupon_*` 三表) | +| 8a | `POST /api/v1/coupon/prompt/shown` | 无 | [详情](./coupon/coupon-prompt.md)(引导窗弹出即上报) | +| 8b | `POST /api/v1/coupon/prompt/dismiss` | 无 | [详情](./coupon/coupon-prompt.md)(用户拒绝/关闭引导窗) | +| 8c | `GET /api/v1/coupon/prompt/should-show` | 无 | [详情](./coupon/coupon-prompt.md)(切到外卖 App 时是否还应弹引导窗) | +| 8d | `POST /api/v1/coupon/prompt/reset` | 无 | [详情](./coupon/coupon-prompt.md)(重置今日引导窗 engagement,开发测频控用) | +| 8e | `GET /api/v1/coupon/completed-today` | 无 | [详情](./coupon/coupon-completed-today.md)(这台设备今天是否已跑完整轮领券) | +| 8f | `POST /api/v1/coupon/completed-today/reset` | 无 | [详情](./coupon/coupon-completed-today.md)(重置今日已完成,开发用) | +| 8g | `GET /api/v1/coupon/stats` | Bearer | [详情](./coupon/coupon-stats.md)(累计领券数,「我的」页战绩卡) | +| 8h | `POST /api/v1/coupon/session` | 无 | [详情](./coupon/coupon-session.md)(领券流水上报,admin 看板数据源) | +| 9 | `POST /api/v1/meituan/coupons` | 无 | [详情](./meituan/meituan-coupons.md) | +| 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan/meituan-feed.md)(`rec` tab 离线库 + **按城市过滤** #116) | +| 11 | `POST /api/v1/meituan/referral-link` | 无 | [详情](./meituan/meituan-referral-link.md) | +| 11a | `POST /api/v1/meituan/top-sales` | 无 | [详情](./meituan/meituan-top-sales.md)(同城销量榜:离线库按销量降序 + 跨源去重 + 城市过滤 #116,不实时打美团) | +| **比价透传**(前缀 `/api/v1`,透传 pricebot-backend;**软鉴权 OptionalUser** + 首帧签发 trace_id + harvest 落 `comparison_record`,2026-07 起不再是纯透传) ||| +| 12 | `POST /api/v1/intent/recognize` | 软 | [详情](./intent/compare-intent-recognize.md)(Phase 1 意图识别,单次,多数源;mint 帧建 running 行) | +| 12a | `POST /api/v1/intent/precoupon/step` | 软 | [详情](./intent/intent-step.md)(Phase 0 意图识别前先用券,仅美团源) | +| 12b | `POST /api/v1/intent/step` | 软 | [详情](./intent/intent-step.md)(Phase 1 多帧意图识别,仅淘宝源,循环到 done) | +| 13 | `POST /api/v1/price/step` | 软 | [详情](./intent/compare-price-step.md)(Phase 2 步进;done 帧 harvest 写终态) | +| 13a | `POST /api/v1/trace/finalize` | 软 | [详情](./other/trace-finalize.md)(比价 trace 收尾上云 + 夭折落库,终止/未识别拿 trace_url) | +| 13b | `POST /api/v1/trace/epilogue` | 软 | [详情](./other/trace-finalize.md)(结果页尾声帧:App 结果页截图入 trace,纯透传不落库,#112) | | **比价记录**(前缀 `/api/v1/compare`;按用户落库,**鉴权**,区别于上面不鉴权的透传) ||| -| 12a | `POST /api/v1/compare/record` | Bearer | [详情](./compare-record-report.md) | -| 12b | `GET /api/v1/compare/records` | Bearer | [详情](./compare-records.md) | -| 12c | `GET /api/v1/compare/records/{id}` | Bearer | [详情](./compare-record-detail.md) | -| 12e | `GET /api/v1/compare/stats` | Bearer | [详情](./compare-stats.md)(「我的」页省钱战绩卡:完成比价数 + 累计发现可省) | +| 12a | `POST /api/v1/compare/record` | Bearer | [详情](./compare/compare-record-report.md) | +| 12b | `GET /api/v1/compare/records` | Bearer | [详情](./compare/compare-records.md) | +| 12c | `GET /api/v1/compare/records/{id}` | Bearer | [详情](./compare/compare-record-detail.md) | +| 12e | `GET /api/v1/compare/stats` | Bearer | [详情](./compare/compare-stats.md)(「我的」页省钱战绩卡:完成比价数 + 累计发现可省) | | **比价战绩里程碑**(前缀 `/api/v1/compare`;福利页「记录比价战绩」,按成功比价数解锁逐档发金币) ||| -| 12d | `GET /api/v1/compare/milestones` | Bearer | [详情](./compare-milestones.md) | -| 12e | `POST /api/v1/compare/milestones/{milestone}/claim` | Bearer | [详情](./compare-milestone-claim.md) | +| 12d | `GET /api/v1/compare/milestones` | Bearer | [详情](./compare/compare-milestones.md) | +| 12e | `POST /api/v1/compare/milestones/{milestone}/claim` | Bearer | [详情](./compare/compare-milestone-claim.md) | | **设备 / 无障碍存活监控**(前缀 `/api/v1/device`;心跳超时检出 + 掉线召回,#65) ||| -| D1 | `POST /api/v1/device/register` | Bearer | [详情](./device-liveness.md)(注册设备/更新极光 push token) | -| D2 | `POST /api/v1/device/heartbeat` | Bearer | [详情](./device-liveness.md)(无障碍服务存活心跳,心跳也能自注册) | -| D3 | `GET /api/v1/device/liveness` | Bearer | [详情](./device-liveness.md)(进 App 查本机是否被判掉线过) | -| D4 | `POST /api/v1/device/liveness/ack` | Bearer | [详情](./device-liveness.md)(确认已弹引导,清掉线告警) | +| D1 | `POST /api/v1/device/register` | Bearer | [详情](./device/device-liveness.md)(注册设备/更新极光 push token) | +| D2 | `POST /api/v1/device/heartbeat` | Bearer | [详情](./device/device-liveness.md)(无障碍服务存活心跳,心跳也能自注册) | +| D3 | `GET /api/v1/device/liveness` | Bearer | [详情](./device/device-liveness.md)(进 App 查本机是否被判掉线过) | +| D4 | `POST /api/v1/device/liveness/ack` | Bearer | [详情](./device/device-liveness.md)(确认已弹引导,清掉线告警) | | **上报更低价**(前缀 `/api/v1/report`;众包纠偏,人工审核发奖) ||| -| R1 | `POST /api/v1/report` | Bearer | 提交上报(multipart:`comparison_record_id`/`reported_platform_id`/`reported_price`(元) + 1~4 张截图;原最低价反查 `comparison_record.best_*` 校验须更低)(无单独文档) | -| R2 | `GET /api/v1/report/records` | Bearer | 上报记录列表(`?status=` pending/approved/rejected 可选筛选)(无单独文档) | -| **好友邀请**(前缀 `/api/v1/invite`;注册即生效,双方各发 1 万金币) ||| -| I1 | `GET /api/v1/invite/me` | Bearer | 我的邀请码 + 分享链接 + 已邀人数/已得金币(无单独文档) | -| I2 | `GET /api/v1/invite/invitees` | Bearer | 我邀请的人列表(`limit`/`offset` 分页)(无单独文档) | -| I3 | `POST /api/v1/invite/landing-track` | 无 | 落地页 `dl.html` 访问上报指纹(剪贴板归因兜底;浏览器无 token)(无单独文档) | -| I4 | `POST /api/v1/invite/bind` | Bearer | 绑定邀请人;支持 clipboard/manual 邀请码 + fingerprint 指纹反查三种归因(无单独文档) | +| R1 | `POST /api/v1/report` | Bearer | [详情](./other/report-submit.md)(提交上报更低价,multipart:比价记录ID+平台+价格+截图1-4张) | +| R2 | `GET /api/v1/report/records` | Bearer | [详情](./other/report-records.md)(上报记录列表,?status=pending/approved/rejected 可选筛选) | +| **好友邀请**(前缀 `/api/v1/invite`;绑定注册即生效但**不发奖**,#113 起好友「比价并下单」才给邀请人发**邀请奖励金**,经 `POST /order/report` 触发) ||| +| I1 | `GET /api/v1/invite/me` | Bearer | [详情](./invite/invite-me.md)(我的邀请码+分享链接+已邀人数/已得金币) | +| I2 | `GET /api/v1/invite/invitees` | Bearer | [详情](./invite/invite-invitees.md)(我邀请的人列表,limit/offset 分页) | +| I3 | `POST /api/v1/invite/landing-track` | 无 | [详情](./invite/invite-bind.md)(落地页 dl.html 访问上报指纹,剪贴板归因兜底;浏览器无 token) | +| I4 | `POST /api/v1/invite/bind` | Bearer | [详情](./invite/invite-bind.md)(绑定邀请人;支持 clipboard/manual 邀请码+fingerprint 指纹反查三种归因) | | **钱包 / 我的资产**(前缀 `/api/v1/wallet`) ||| -| 14 | `GET /api/v1/wallet/account` | Bearer | [详情](./wallet-account.md) | -| 15 | `GET /api/v1/wallet/coin-transactions` | Bearer | [详情](./wallet-coin-transactions.md) | -| 16 | `GET /api/v1/wallet/cash-transactions` | Bearer | [详情](./wallet-cash-transactions.md) | -| 17 | `GET /api/v1/wallet/exchange-info` | 无 | [详情](./wallet-exchange-info.md) | -| 18 | `POST /api/v1/wallet/exchange` | Bearer | [详情](./wallet-exchange.md) | -| 19 | `POST /api/v1/wallet/bind-wechat` | Bearer | [详情](./wallet-bind-wechat.md) | -| 20 | `POST /api/v1/wallet/unbind-wechat` | Bearer | [详情](./wallet-unbind-wechat.md) | -| 21 | `GET /api/v1/wallet/withdraw-info` | Bearer | [详情](./wallet-withdraw-info.md) | -| 22 | `POST /api/v1/wallet/withdraw` | Bearer | [详情](./wallet-withdraw.md) | -| 23 | `GET /api/v1/wallet/withdraw/status` | Bearer | [详情](./wallet-withdraw-status.md) | -| 24 | `GET /api/v1/wallet/withdraw-orders` | Bearer | [详情](./wallet-withdraw-orders.md) | +| 14 | `GET /api/v1/wallet/account` | Bearer | [详情](./wallet/wallet-account.md) | +| 15 | `GET /api/v1/wallet/coin-transactions` | Bearer | [详情](./wallet/wallet-coin-transactions.md) | +| 16 | `GET /api/v1/wallet/cash-transactions` | Bearer | [详情](./wallet/wallet-cash-transactions.md) | +| 17 | `GET /api/v1/wallet/exchange-info` | 无 | [详情](./wallet/wallet-exchange-info.md) | +| 18 | `POST /api/v1/wallet/exchange` | Bearer | [详情](./wallet/wallet-exchange.md) | +| 19 | `POST /api/v1/wallet/bind-wechat` | Bearer | [详情](./wallet/wallet-bind-wechat.md) | +| 20 | `POST /api/v1/wallet/unbind-wechat` | Bearer | [详情](./wallet/wallet-unbind-wechat.md) | +| 21 | `GET /api/v1/wallet/withdraw-info` | Bearer | [详情](./wallet/wallet-withdraw-info.md) | +| 22 | `POST /api/v1/wallet/withdraw` | Bearer | [详情](./wallet/wallet-withdraw.md)(`source` 分账:coin_cash / invite_cash,#121) | +| 23 | `GET /api/v1/wallet/withdraw/status` | Bearer | [详情](./wallet/wallet-withdraw-status.md) | +| 24 | `GET /api/v1/wallet/withdraw-orders` | Bearer | [详情](./wallet/wallet-withdraw-orders.md)(可按 `source` 过滤) | +| 24a | `POST /api/v1/wallet/transfer-auth` | Bearer | [详情](./wallet/wallet-transfer-auth.md)(开启免确认到账,申请授权,返回拉起微信授权页的 package) | +| 24b | `GET /api/v1/wallet/transfer-auth/status` | Bearer | [详情](./wallet/wallet-transfer-auth.md)(查免确认授权状态,从微信授权页返回后轮询) | +| 24c | `POST /api/v1/wallet/transfer-auth/close` | Bearer | [详情](./wallet/wallet-transfer-auth.md)(关闭免确认到账,解除授权) | | **签到**(前缀 `/api/v1/signin`) ||| -| 25 | `GET /api/v1/signin/status` | Bearer | [详情](./signin-status.md) | -| 26 | `POST /api/v1/signin` | Bearer | [详情](./signin-do.md) | -| 26a | `POST /api/v1/signin/boost` | Bearer | [详情](./signin-boost.md) | +| 25 | `GET /api/v1/signin/status` | Bearer | [详情](./signin/signin-status.md) | +| 26 | `POST /api/v1/signin` | Bearer | [详情](./signin/signin-do.md) | +| 26a | `POST /api/v1/signin/boost` | Bearer | [详情](./signin/signin-boost.md) | | **任务**(前缀 `/api/v1/tasks`) ||| -| 27 | `GET /api/v1/tasks` | Bearer | [详情](./tasks-list.md) | -| 28 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks-claim.md) | +| 27 | `GET /api/v1/tasks` | Bearer | [详情](./tasks/tasks-list.md) | +| 28 | `POST /api/v1/tasks/{task_key}/claim` | Bearer | [详情](./tasks/tasks-claim.md) | | **省钱**(前缀 `/api/v1/savings`) ||| -| 29 | `GET /api/v1/savings/summary` | Bearer | [详情](./savings-summary.md) | -| 30 | `GET /api/v1/savings/battle` | Bearer | [详情](./savings-battle.md) | -| 31 | `GET /api/v1/savings/records` | Bearer | [详情](./savings-records.md) | +| 29 | `GET /api/v1/savings/summary` | Bearer | [详情](./savings/savings-summary.md) | +| 30 | `GET /api/v1/savings/battle` | Bearer | [详情](./savings/savings-battle.md) | +| 31 | `GET /api/v1/savings/records` | Bearer | [详情](./savings/savings-records.md) | | **看广告发奖**(前缀 `/api/v1/ad`) ||| -| 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad-pangle-callback.md) | -| 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad-reward-status.md) | -| 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) | -| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.md) | -| 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad-feed-reward.md) | -| 35b | `POST /api/v1/ad/reward-noshow` | Bearer | [详情](./ad-reward-noshow.md)(激励视频提前关闭/未发奖留痕,只记原因不发币) | -| 35c | `GET /api/v1/ad/feed-reward/units` | Bearer | 信息流广告今日已发份数/上限(配合 `feed-reward` 看进度)(无单独文档) | +| 32 | `GET /api/v1/ad/pangle-callback` | 验签 | [详情](./ad/ad-pangle-callback.md) | +| 33 | `GET /api/v1/ad/reward-status` | Bearer | [详情](./ad/ad-reward-status.md) | +| 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad/ad-test-grant.md) | +| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad/ad-ecpm-report.md) | +| 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad/ad-feed-reward.md) | +| 35b | `POST /api/v1/ad/reward-noshow` | Bearer | [详情](./ad/ad-reward-noshow.md)(激励视频提前关闭/未发奖留痕,只记原因不发币) | +| 35c | `GET /api/v1/ad/feed-reward/units` | Bearer | [详情](./ad/ad-feed-reward.md)(信息流广告今日已发份数/上限,配合 feed-reward 看进度) | +| 35d | `POST /api/v1/ad/watch-report` | Bearer | [详情](./ad/ad-watch-report.md)(上报激励视频观看时长,旧客户端兼容) | | **用户资料**(前缀 `/api/v1/user`) ||| -| 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) | -| 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.md) | -| 36a | `POST /api/v1/user/onboarding/complete` | Bearer | 标记新手引导完成(按 账号+device_id 幂等,跨卸载重装持久)(无单独文档) | -| 36b | `GET /api/v1/user/onboarding/status` | Bearer | 查该 (账号,设备) 是否走过引导(运营在 admin 删记录即触发重走)(无单独文档) | -| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user-delete.md) | +| 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user/user-profile.md) | +| 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user/user-avatar.md) | +| 36a | `POST /api/v1/user/onboarding/complete` | Bearer | [详情](./user/user-onboarding.md)(标记新手引导完成,按 账号+device_id 幂等,跨卸载重装持久) | +| 36b | `GET /api/v1/user/onboarding/status` | Bearer | [详情](./user/user-onboarding.md)(查该 账号+设备 是否走过引导,运营在 admin 删记录即触发重走) | +| 36c | `POST /api/v1/user/onboarding/reset` | Bearer | [详情](./user/user-onboarding.md)(重置本设备引导标记,下次登录重走,#114) | +| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user/user-delete.md) | | **帮助与反馈**(前缀 `/api/v1/feedback`) ||| +<<<<<<< HEAD | 38 | `POST /api/v1/feedback` | Bearer | [详情](./feedback.md) | | 38a | `GET /api/v1/feedback/config` | Bearer | 反馈页「加群二维码」卡配置(开关 + 二维码图 + 三行文案)(无单独文档) | | 38b | `GET /api/v1/feedback/records` | Bearer | 我的反馈历史(pending/adopted/rejected)(无单独文档) | @@ -107,63 +119,65 @@ | P1 | `GET /api/v1/push/vendors` | Bearer | [详情](./push-vendor-test.md)(5 厂商服务端凭据配置状态,缺哪些 .env 键一目了然) | | P2 | `GET /api/v1/push/templates` | Bearer | [详情](./push-vendor-test.md)(13 类通知的 push 标题/正文模板 + PRD 示例渲染效果) | | P3 | `POST /api/v1/push/test` | Bearer | [详情](./push-vendor-test.md)(测试发送:默认 mock 不真发;mock=false 真发;可联动插一条站内 mock 通知闭环验证已读) | +======= +| 38 | `POST /api/v1/feedback` | Bearer | [详情](./other/feedback.md) | +| 38a | `GET /api/v1/feedback/config` | Bearer | [详情](./other/feedback-config.md)(反馈页「加群二维码」卡配置:开关+二维码图+三行文案) | +| 38b | `GET /api/v1/feedback/records` | Bearer | [详情](./other/feedback-records.md)(我的反馈历史,pending/adopted/rejected) | +| **埋点 & 订单上报**(前缀分散;全部 Bearer 除 analytics/events 不强制登录) ||| +| E1 | `POST /api/v1/analytics/events` | 无 | [详情](./other/analytics-events.md)(批量上报埋点事件,不强制登录,每批最多200条) | +| E2 | `POST /api/v1/order/report` | Bearer | [详情](./other/order-report.md)(上报归因订单,比价后5分钟内点链接+支付金额与比价价相差≤1元) | +>>>>>>> origin/main | **首页门面数据 / 客户端配置**(前缀 `/api/v1/platform`;全平台展示数字 + 运营开关,**全部不鉴权**,登录前可读) ||| -| 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform-stats.md) | -| 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./platform-savings-feed.md) | -| 40a | `GET /api/v1/platform/flags` | 无 | 客户端运营 feature flag(比价/领券期广告开关等),拉取后缓存(无单独文档) | -| 40b | `GET /api/v1/platform/ad-config` | 无 | 客户端拉广告配置(穿山甲 app_id + 各位 ID + 各场景开关;不含验签密钥)(无单独文档) | -| 40c | `GET /api/v1/platform/app-version` | 无 | 最新 App 版本(OTA 检查更新;与本机 versionCode 比)(无单独文档) | +| 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform/platform-stats.md) | +| 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./savings/platform-savings-feed.md) | +| 40a | `GET /api/v1/platform/flags` | 无 | [详情](./platform/platform-flags.md)(客户端运营 feature flag,比价/领券期广告开关等,拉取后缓存) | +| 40b | `GET /api/v1/platform/ad-config` | 无 | [详情](./platform/platform-ad-config.md)(客户端拉广告配置:穿山甲 app_id+各位ID+各场景开关;不含验签密钥) | +| 40c | `GET /api/v1/platform/app-version` | 无 | [详情](./platform/platform-app-version.md)(最新 App 版本,OTA 检查更新;与本机 versionCode 比) | | **微信支付回调**(前缀 `/api/v1/wxpay`) ||| | W1 | `POST /api/v1/wxpay/transfer-auth-notify` | 无 | 免确认收款授权结果通知(一期 stub:仅应答 200 不验签不改账,授权状态靠主动查询兜底)(无单独文档) | | **CPS 群发短链落地**(**无前缀**,挂域名根;公网不鉴权) ||| -| C1 | `GET /c/{code}` | 无 | [详情](./cps-redirect.md)(短链落地:微信授权拿 openid + 记点击 + 302 跳/淘宝 H5 落地页) | -| C2 | `POST /c/{code}/copy` | 无 | [详情](./cps-redirect.md)(淘宝落地页点「复制口令」记 `copy`) | -| C3 | `GET /wx/oauth/cb` | 无 | [详情](./cps-redirect.md)(微信网页授权回调;upsert `cps_wx_user` + 种 cookie,`include_in_schema=False`) | -| C4 | `GET /MP_verify_*.txt` | 无 | [详情](./cps-redirect.md)(微信「网页授权域名」归属校验文件,`include_in_schema=False`) | +| C1 | `GET /c/{code}` | 无 | [详情](./other/cps-redirect.md)(短链落地:微信授权拿 openid + 记点击 + 302 跳/淘宝 H5 落地页) | +| C2 | `POST /c/{code}/copy` | 无 | [详情](./other/cps-redirect.md)(淘宝落地页点「复制口令」记 `copy`) | +| C3 | `GET /wx/oauth/cb` | 无 | [详情](./other/cps-redirect.md)(微信网页授权回调;upsert `cps_wx_user` + 种 cookie,`include_in_schema=False`) | +| C4 | `GET /MP_verify_*.txt` | 无 | [详情](./other/cps-redirect.md)(微信「网页授权域名」归属校验文件,`include_in_schema=False`) | | **内部回写端点**(前缀 `/internal`;pricebot/发布流程→app-server,**`X-Internal-Secret` 头**,非客户端接口) ||| -| N1 | `POST /internal/price-observation` | 内部密钥 | [详情](./internal.md)(比价价格事实批量落 `price_observation`) | -| N2 | `GET /internal/store-mapping/lookup` | 内部密钥 | [详情](./internal.md)(按源平台店名反查目标平台已沉淀店铺 id/deeplink) | -| N3 | `POST /internal/store-mapping` | 内部密钥 | [详情](./internal.md)(跨平台店铺身份映射落 `store_mapping`) | -| N4 | `POST /internal/store-mapping/invalidate` | 内部密钥 | [详情](./internal.md)(标记某平台 shopId 缓存 deeplink 失效) | -| N5 | `POST /internal/launch-confirm-sample` | 内部密钥 | [详情](./internal.md)(启动确认窗兜底样本落 `launch_confirm_sample`) | -| N6 | `POST /internal/app-version` | 内部密钥 | [详情](./internal.md)(发布流程写最新 App 版本,落 `app_config`) | +| N1 | `POST /internal/price-observation` | 内部密钥 | [详情](./internal/internal.md)(比价价格事实批量落 `price_observation`) | +| N2 | `GET /internal/store-mapping/lookup` | 内部密钥 | [详情](./internal/internal.md)(按源平台店名反查目标平台已沉淀店铺 id/deeplink) | +| N3 | `POST /internal/store-mapping` | 内部密钥 | [详情](./internal/internal.md)(跨平台店铺身份映射落 `store_mapping`) | +| N4 | `POST /internal/store-mapping/invalidate` | 内部密钥 | [详情](./internal/internal.md)(标记某平台 shopId 缓存 deeplink 失效) | +| N5 | `POST /internal/launch-confirm-sample` | 内部密钥 | [详情](./internal/internal.md)(启动确认窗兜底样本落 `launch_confirm_sample`) | +| N6 | `POST /internal/app-version` | 内部密钥 | [详情](./internal/internal.md)(发布流程写最新 App 版本,落 `app_config`) | +| N7 | `GET /internal/launch-confirm-samples` | 内部密钥 | [详情](./internal/internal.md)(样本列表,供 pricebot distill 脚本聚合沉淀回静态规则,#91) | | **静态资源**(StaticFiles 挂载,见下方 `/media` 静态服务) ||| | - | `GET /media/avatars/` | 无 | 用户头像;返回二进制图片 | | - | `GET /media/feedback/` | 无 | 反馈截图;返回二进制图片 | -| **运营后台 Admin**(独立子应用 `app/admin/`,前缀 `/admin/api`,独立进程 + 独立 admin JWT。鉴权列:`admin`=任意已登录管理员,`operator`/`finance`/`super_admin`=需对应角色(`super_admin` 恒通过)) ||| -| A1 | `POST /admin/api/auth/login` | 无 | [详情](./admin-auth-login.md) | -| A2 | `GET /admin/api/auth/me` | admin | [详情](./admin-auth-me.md) | -| A3 | `GET /admin/api/stats/overview` | admin | [详情](./admin-stats-overview.md) | -| A4 | `GET /admin/api/users` | admin | [详情](./admin-users-list.md) | -| A5 | `GET /admin/api/users/{user_id}` | admin | [详情](./admin-user-detail.md) | -| A6 | `POST /admin/api/users/{user_id}/status` | operator | [详情](./admin-user-status.md) | -| A7 | `POST /admin/api/users/{user_id}/coins` | finance | [详情](./admin-user-coins.md) | -| A8 | `POST /admin/api/users/{user_id}/cash` | finance | [详情](./admin-user-cash.md) | -| A9 | `GET /admin/api/wallet/coin-transactions` | admin | [详情](./admin-wallet-coin-transactions.md) | -| A10 | `GET /admin/api/wallet/cash-transactions` | admin | [详情](./admin-wallet-cash-transactions.md) | -| A11 | `GET /admin/api/withdraws` | admin | [详情](./admin-withdraws-list.md) | -| A12 | `POST /admin/api/withdraws/reconcile` | finance | [详情](./admin-withdraw-reconcile.md) | -| A13 | `POST /admin/api/withdraws/{out_bill_no}/refresh` | finance | [详情](./admin-withdraw-refresh.md) | -| A14 | `GET /admin/api/feedbacks` | admin | [详情](./admin-feedbacks-list.md) | -| A15 | `POST /admin/api/feedbacks/{feedback_id}/handle` | operator | [详情](./admin-feedback-handle.md) | -| A16 | `GET /admin/api/admins` | super_admin | [详情](./admin-admins-list.md) | -| A17 | `POST /admin/api/admins` | super_admin | [详情](./admin-admin-create.md) | -| A18 | `PATCH /admin/api/admins/{admin_id}` | super_admin | [详情](./admin-admin-update.md) | -| A19 | `GET /admin/api/audit-logs` | admin | [详情](./admin-audit-logs.md) | -| A20 | `GET /admin/api/dashboard-display` | admin | [详情](./admin-dashboard-display.md) | -| A21 | `PATCH /admin/api/dashboard-display/{metric}` | operator | [详情](./admin-dashboard-display.md) | -| A22 | `GET /admin/api/marquee-seeds` | admin | [详情](./admin-marquee-seeds.md) | -| A23 | `POST /admin/api/marquee-seeds` | operator | [详情](./admin-marquee-seeds.md) | -| A24 | `PATCH /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) | -| A25 | `DELETE /admin/api/marquee-seeds/{seed_id}` | operator | [详情](./admin-marquee-seeds.md) | -| A26 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) | -| A27 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) | -| A28 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) | -| A29 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin-ad-revenue-report.md)(广告收益报表:按用户/日期/类型/应用/代码位 聚合 条数/收益/金币,只读) | +| **运营后台 Admin**(独立子应用 `app/admin/`,前缀 `/admin/api`,独立进程 + 独立 admin JWT。鉴权列:`admin`=任意已登录管理员,`operator`/`finance`/`super_admin`=需对应角色;#117 起可见页由 [admin_role](../database/admin_role.md) 数据驱动,`super_admin` 恒通过) ||| +| A1 | `POST /admin/api/auth/login` · `GET /auth/me` | 无 / admin | [详情](./admin/auth/admin-auth-login.md) / [me](./admin/auth/admin-auth-me.md)(me 返回有效可见页 `pages`) | +| A2 | `GET /admin/api/stats/overview` | admin | [详情](./admin/admin-stats-overview.md)(大盘核心指标;#103 按 trace 聚合 + 京东收益 #90 + feed_scene 口径 #125) | +| A3 | `GET /admin/api/event-logs` | admin | [详情](./admin/admin-event-logs.md)(埋点日志检索,#83) | +| **A·用户**:`GET /users`(筛选排序分页)、`GET /users/{id}`(360 详情)、`GET /{id}/reward-stats` + `GET /{id}/coin-records`(提现详情联查)、`POST /{id}/status`(封禁)、`POST /{id}/debug-trace`(调试链接权限)、`POST /{id}/coins`、`POST /{id}/cash`(#95 `account` 目标账户) ||| [列表](./admin/users/admin-users-list.md) / [详情](./admin/users/admin-user-detail.md) / [状态+debug-trace](./admin/users/admin-user-status.md) / [金币](./admin/users/admin-user-coins.md) / [现金](./admin/users/admin-user-cash.md) | +| A4 | `GET /admin/api/wallet/coin-transactions` / `cash-transactions` | admin | [金币](./admin/wallet/admin-wallet-coin-transactions.md) / [现金](./admin/wallet/admin-wallet-cash-transactions.md) | +| **A·提现审核台**:`GET /withdraws`(列表)、`/summary`、`/health-check`(finance)、`/ledger-check`(#121 分账对账)、`/{out_bill_no}`(详情)、`POST /reconcile`、单笔 `refresh`/`approve`/`reject`、批量 `bulk/refresh`/`bulk/approve`/`bulk/reject` ||| [列表](./admin/withdraws/admin-withdraws-list.md) / [审核族](./admin/withdraws/admin-withdraw-review.md) / [对账](./admin/withdraws/admin-withdraw-reconcile.md) / [查单](./admin/withdraws/admin-withdraw-refresh.md) | +| **A·反馈**:`GET /feedbacks`、`/summary`、`POST /{id}/approve`(采纳发币 #94)、`/{id}/reject`、`/{id}/handle` ||| [列表](./admin/feedbacks/admin-feedbacks-list.md) / [审核族](./admin/feedbacks/admin-feedback-handle.md) | +| A5 | `GET`/`PATCH` `/admin/api/feedback-config`,`POST`/`DELETE` `…/image` | operator | 反馈页「加群二维码」卡配置(admin 侧;C 端读见 38a)(无单独文档,见 `app/admin/routers/feedback_qr.py`) | +| **A·上报更低价**:`GET /price-reports`、`/summary`、`POST /{id}/approve|reject`(#94) ||| [审核族](./admin/admin-price-reports.md) | +| A6 | `GET /admin/api/comparison-records`(+`/{id}` 详情) | admin | 比价记录检索(按 user/phone/**店与商品名模糊搜** #117 筛;详情含 LLM 调用明细)(无单独文档,见 `app/admin/routers/comparison.py`) | +| A7 | `GET /admin/api/coupon-data`(+`/user-records`) | admin | [详情](./admin/admin-coupon-data.md)(领券数据看板,#99) | +| A8 | `GET /admin/api/device-liveness`(+`/stats`) | admin | [详情](./admin/admin-device-liveness.md)(设备存活监控,#80) | +| A9 | `GET /onboarding/devices`、`POST /devices/{id}/reset`、`POST /reset-all` | operator | 新手引导记录管理(按设备聚合/重置)(无单独文档,见 `app/admin/routers/onboarding.py`) | +| **A·轮播**:`GET /marquee-seeds`、`/preview`、`/real-records`(#123)、`GET`/`PATCH` `/mode`(#122,模式落 `app_config`)、`POST`(+`/bulk`、`/batch-delete`、`/batch-enable`)、`PATCH`/`DELETE` `/{seed_id}` ||| [详情](./admin/admin-marquee-seeds.md) | +| A10 | `GET / PATCH /admin/api/dashboard-display` | admin / operator | [详情](./admin/admin-dashboard-display.md)(首页三统计配置) | +| A11 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin/ad/admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) | +| A12 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin/ad/admin-ad-revenue-report.md)(广告收益报表:分页/场景/`app_env` 筛 + **DAU/ARPU** #120;真实收益侧接穿山甲日表 #92) | +| A13 | `GET / PATCH /admin/api/ad-config` | operator/finance | 广告配置(穿山甲 ID/验签密钥/各场景开关;C 端只读版见 40b)(无单独文档,见 `app/admin/routers/ad_config.py`) | +| A14 | `GET /admin/api/config`、`PATCH /config/{key}` | operator/finance | 运营可配置项([app_config](../database/app_config.md):奖励常量/提现地板价等;#117 修系统配置下发)(无单独文档,见 `app/admin/routers/config.py`) | +| **A·管理员与角色**(super_admin):`GET`/`POST` `/admins`、`PATCH`/`DELETE` `/admins/{id}`(#126 删除+`pages_override`)、`GET`/`POST` `/roles`、`GET /roles/catalog`、`PATCH`/`DELETE` `/roles/{id}`(#117/#126 自定义角色) ||| [列表](./admin/admins/admin-admins-list.md) / [建](./admin/admins/admin-admin-create.md) / [改+删](./admin/admins/admin-admin-update.md) / [角色](./admin/admin-roles.md) | +| A15 | `GET /admin/api/audit-logs` | admin | [详情](./admin/admin-audit-logs.md) | +| **A·CPS 运营台**:群/活动 CRUD、`POST /referral-links`、`POST /orders/reconcile`(美团+京东 #90)、`GET /orders`、`/stats`、群 `timeseries`/`daily`/`wx-users`/`day-users`(#79) ||| [详情](./admin/admin-cps.md) | | - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) | > ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。 -> `coupon/step` 及外卖比价的 `intent/recognize`、`intent/precoupon/step`、`intent/step`、`price/step`、`trace/finalize` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见 `app/api/v1/compare.py`)。 +> `coupon/step` 透传到 pricebot-backend,**仍不鉴权**(device_id 区分设备,待补 JWT)。外卖比价透传族(`intent/*`、`price/step`、`trace/finalize|epilogue`)2026-07 起改**软鉴权 OptionalUser**:带 Bearer 则比价记录绑 `user_id`,不带也放行;并由 app-server 首帧签发 `trace_id` + harvest 落 `comparison_record`(见 `app/api/v1/compare.py` 模块注释)。 > 福利相关业务接口(wallet/signin/tasks/savings、`ad/reward-status`、`ad/feed-reward`)均需 **Bearer**;`wallet/exchange-info` 是静态规则无鉴权;`ad/pangle-callback` 不走 JWT、靠穿山甲**验签**;`ad/test-grant` **仅本地联调**(开关控制,生产 404)。 > 金额字段一律以**分**为单位(`*_cents`)。 @@ -209,8 +223,8 @@ |---|---|---| | `id` | int | 用户主键 | | `phone` | string | 手机号(注销账号后变 `deleted_` 占位释放唯一约束) | -| `nickname` | string \| null | 昵称,经 [`PATCH /api/v1/user/profile`](./user-profile.md) 修改 | -| `avatar_url` | string \| null | 头像相对 URL(`/media/avatars/...`),经 [`POST /api/v1/user/avatar`](./user-avatar.md) 上传 | +| `nickname` | string \| null | 昵称,经 [`PATCH /api/v1/user/profile`](./user/user-profile.md) 修改 | +| `avatar_url` | string \| null | 头像相对 URL(`/media/avatars/...`),经 [`POST /api/v1/user/avatar`](./user/user-avatar.md) 上传 | | `register_channel` | string | 注册渠道:`jverify` / `sms` | | `status` | string | `active` / `disabled` / `deleted` | | `created_at` | datetime | 注册时间 | diff --git a/docs/api/ad-ecpm-report.md b/docs/api/ad/ad-ecpm-report.md similarity index 98% rename from docs/api/ad-ecpm-report.md rename to docs/api/ad/ad-ecpm-report.md index 6359b00..f7f03a6 100644 --- a/docs/api/ad-ecpm-report.md +++ b/docs/api/ad/ad-ecpm-report.md @@ -1,6 +1,6 @@ # POST /api/v1/ad/ecpm-report — 上报本次广告展示的 eCPM(内部收益统计) -> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) +> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) ## 入参 请求体:`EcpmReportIn` diff --git a/docs/api/ad-feed-reward.md b/docs/api/ad/ad-feed-reward.md similarity index 100% rename from docs/api/ad-feed-reward.md rename to docs/api/ad/ad-feed-reward.md diff --git a/docs/api/ad-pangle-callback.md b/docs/api/ad/ad-pangle-callback.md similarity index 98% rename from docs/api/ad-pangle-callback.md rename to docs/api/ad/ad-pangle-callback.md index 3d8e13c..b059afd 100644 --- a/docs/api/ad-pangle-callback.md +++ b/docs/api/ad/ad-pangle-callback.md @@ -1,6 +1,6 @@ # GET /api/v1/ad/pangle-callback — 穿山甲 GroMore 激励视频发奖回调(S2S) -> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:**无 JWT,靠验签**(穿山甲 GroMore 服务器调用) | 限流:同 IP ≤300 次/分 | [← 返回 API 索引](./README.md) +> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:**无 JWT,靠验签**(穿山甲 GroMore 服务器调用) | 限流:同 IP ≤300 次/分 | [← 返回 API 索引](../README.md) > > ⚠️ 我们客户端用 `useMediation(true)`(GroMore 融合),回调走 **GroMore 广告位层级**(规范见 supportcenter/26240),**不是**联盟代码位层级(5416)。两者密钥、响应格式都不同,别混。后台配置入口:**GroMore 聚合管理 → 搜广告位ID → 编辑 → 勾选「服务端激励回调」**(广告位层级配了就别再在代码位层级重复配,会冲突)。 > diff --git a/docs/api/ad-reward-noshow.md b/docs/api/ad/ad-reward-noshow.md similarity index 100% rename from docs/api/ad-reward-noshow.md rename to docs/api/ad/ad-reward-noshow.md diff --git a/docs/api/ad-reward-status.md b/docs/api/ad/ad-reward-status.md similarity index 98% rename from docs/api/ad-reward-status.md rename to docs/api/ad/ad-reward-status.md index 54f8f7f..44bfd9a 100644 --- a/docs/api/ad-reward-status.md +++ b/docs/api/ad/ad-reward-status.md @@ -1,6 +1,6 @@ # GET /api/v1/ad/reward-status — 今日看广告发奖进度 -> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) +> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) ## 入参 无(用户由 token 确定)。 diff --git a/docs/api/ad-test-grant.md b/docs/api/ad/ad-test-grant.md similarity index 97% rename from docs/api/ad-test-grant.md rename to docs/api/ad/ad-test-grant.md index c4a0638..490f96b 100644 --- a/docs/api/ad-test-grant.md +++ b/docs/api/ad/ad-test-grant.md @@ -1,6 +1,6 @@ # POST /api/v1/ad/test-grant — [仅本地联调]模拟穿山甲回调发奖 -> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | 限流:同 IP ≤60 次/分 | [← 返回 API 索引](./README.md) +> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | 限流:同 IP ≤60 次/分 | [← 返回 API 索引](../README.md) > > ⚠️ **仅本地联调**,受 `AD_REWARD_TEST_GRANT_ENABLED` 开关控制,**生产必须关闭**(默认 False → 一律 404)。 diff --git a/docs/api/ad/ad-watch-report.md b/docs/api/ad/ad-watch-report.md new file mode 100644 index 0000000..36ac112 --- /dev/null +++ b/docs/api/ad/ad-watch-report.md @@ -0,0 +1,46 @@ +# POST /api/v1/ad/watch-report — 上报激励视频观看时长 + +> 所属:Ad 组(前缀 `/api/v1/ad`) | 鉴权:Bearer | 限流:同 IP ≤120 次/分 | [← 返回 API 索引](../README.md) + +客户端在激励视频关闭(onAdClose)后上报本次实际观看秒数,服务端累计到当日总时长。当前产品只保留每日 500 次上限,DAILY_AD_WATCH_SECONDS_LIMIT=0 表示时长闸不启用;该接口仍保留用于旧客户端兼容和排查观看时长。 + +## 入参 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `seconds` | int | ✅ (≥0) | 本次观看秒数,服务端夹 [0, MAX_SINGLE_WATCH_SECONDS] | + +Mock 入参: +```json +{ + "seconds": 28 +} +``` + +## 出参 + +响应 `200`:`WatchReportOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `watched_seconds_today` | int | 今日累计观看秒数 | +| `watch_seconds_limit` | int | 每日上限(秒);0 表示当前未启用时长闸 | +| `watch_seconds_remaining` | int | 今日剩余可观看秒数 | + +Mock 出参: +```json +{ + "watched_seconds_today": 128, + "watch_seconds_limit": 0, + "watch_seconds_remaining": 0 +} +``` + +## 错误码 +- `401` 未鉴权 / token 失效 +- `422` `seconds` 缺或为负 + +## 说明 +- 鉴权靠 Bearer,`user_id` 取自 JWT(不信 body) +- 后端只做累计 + 上限裁切,不据此发奖(发奖靠 S2S 回调) +- `watch_seconds_limit=0` 时客户端不据此拦截,按每日 500 次上限走 diff --git a/docs/api/admin-feedback-handle.md b/docs/api/admin-feedback-handle.md deleted file mode 100644 index fff4b5f..0000000 --- a/docs/api/admin-feedback-handle.md +++ /dev/null @@ -1,23 +0,0 @@ -# POST /admin/api/feedbacks/{feedback_id}/handle — 标记反馈已处理 - -> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks`) | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过,`require_role("operator")`) | [← 返回 API 索引](./README.md) - -## 入参 -- 路径:`feedback_id`(int) -- body:无 - -## 出参 -响应 `200`:`OkResponse` = `{ "ok": true }` - -幂等说明:将该反馈 `status` 置为 `handled`(不校验原状态,重复调用结果一致)。 - -## 错误码 -- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`) -- `403` 角色不足(需 `operator` 或 `super_admin`) -- `404` 反馈不存在(`detail: "反馈不存在"`) -- `422` `feedback_id` 非合法 int - -## 说明 -- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action="feedback.handle"`、`target_type="feedback"`、`target_id=`、`detail={"before": <原 status>, "after": "handled"}`、`ip=<客户端 IP>`。 -- 状态变更与审计写入在同一事务(`commit=False` 后统一 `db.commit()`)。 -- 关联表 [feedback](../database/feedback.md)。 diff --git a/docs/api/admin-ad-coin-audit.md b/docs/api/admin/ad/admin-ad-coin-audit.md similarity index 99% rename from docs/api/admin-ad-coin-audit.md rename to docs/api/admin/ad/admin-ad-coin-audit.md index 2956994..65c39b8 100644 --- a/docs/api/admin-ad-coin-audit.md +++ b/docs/api/admin/ad/admin-ad-coin-audit.md @@ -1,6 +1,6 @@ # Admin 看广告金币审计 -> 所属:Admin 组(前缀 `/admin/api/ad-coin-audit`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](./README.md) +> 所属:Admin 组(前缀 `/admin/api/ad-coin-audit`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](../../README.md) 把「看视频赚金币」(`ad_reward_record`)和「比价信息流广告」(`ad_feed_reward_record`)两类发奖记录,用与**正式发奖完全相同**的公式 [`app/core/rewards.py` `calculate_ad_reward_coin`](../../app/core/rewards.py) 复算一遍 `expected_coin`,与实际入账的 `actual_coin` 对比,核对金币公式是否生效。**纯只读对账**,不发币、不改任何数据。 diff --git a/docs/api/admin-ad-revenue-report.md b/docs/api/admin/ad/admin-ad-revenue-report.md similarity index 99% rename from docs/api/admin-ad-revenue-report.md rename to docs/api/admin/ad/admin-ad-revenue-report.md index 898f9d4..b4c2b07 100644 --- a/docs/api/admin-ad-revenue-report.md +++ b/docs/api/admin/ad/admin-ad-revenue-report.md @@ -1,6 +1,6 @@ # Admin 广告收益报表 -> 所属:Admin 组(前缀 `/admin/api/ad-revenue-report`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](./README.md) +> 所属:Admin 组(前缀 `/admin/api/ad-revenue-report`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](../../README.md) 按 **用户 × 日期 × 广告类型 × 我们的应用 × 我们的代码位** 聚合,回答「每个用户某天、每类广告(激励视频 / 信息流 / 历史 Draw)分别**看了多少条**、**收益多少**、按现算法**发了多少金币**、广告来自**哪个应用的哪个代码位**」。**纯只读**,不发币、不改数据,也**不改发奖逻辑**。 diff --git a/docs/api/admin-audit-logs.md b/docs/api/admin/admin-audit-logs.md similarity index 98% rename from docs/api/admin-audit-logs.md rename to docs/api/admin/admin-audit-logs.md index bc93d9f..0ea9b19 100644 --- a/docs/api/admin-audit-logs.md +++ b/docs/api/admin/admin-audit-logs.md @@ -1,6 +1,6 @@ # GET /admin/api/audit-logs — 审计日志(谁改了什么,游标分页) -> 所属:Admin·Audit 组(前缀 `/admin/api/audit-logs`) | 鉴权:Bearer admin_token(角色:任意已登录 admin) | [← 返回 API 索引](./README.md) +> 所属:Admin·Audit 组(前缀 `/admin/api/audit-logs`) | 鉴权:Bearer admin_token(角色:任意已登录 admin) | [← 返回 API 索引](../README.md) ## 入参(query) | 字段 | 类型 | 必填 | 默认 | 说明 | diff --git a/docs/api/admin/admin-coupon-data.md b/docs/api/admin/admin-coupon-data.md new file mode 100644 index 0000000..ad443ee --- /dev/null +++ b/docs/api/admin/admin-coupon-data.md @@ -0,0 +1,17 @@ +# /admin/api/coupon-data — 领券数据看板(#99) + +> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin(任意已登录管理员) | 表 [coupon_session](../../database/coupon_session.md) | [← 返回 API 索引](../README.md) + +数据源是客户端两段上报的 `coupon_session`(一次领券任务一行:发起建行/收尾更新)。看板量化:发起数、完成率、**中途流失**(started 无终态)、平均/分位耗时、各平台耗时、机型/ROM 维度。 + +## 端点 + +| 方法 + 路径 | 说明 | +|---|---| +| `GET /admin/api/coupon-data` | 看板聚合:发起/完成数 + 耗时分位 + 按天趋势 + 逐条明细(按 `started_date` 区间 + `app_env` 筛,默认只看 prod 防测试数据串台) | +| `GET /admin/api/coupon-data/user-records` | 某用户全部领券记录(用户列表点手机号抽屉:领券次数 + 记录列表) | + +## 说明 +- 明细行 LEFT JOIN `user` 出手机号/昵称(匿名领券行 user 列为空)。 +- 「发起平台」列按 `origin_package` 区分:空=App 内首页发起,包名=从对应外卖 App 弹券引导发起。 +- 耗时口径:`elapsed_ms` 客户端全程计时(只统计 completed)。 diff --git a/docs/api/admin/admin-cps.md b/docs/api/admin/admin-cps.md new file mode 100644 index 0000000..d05a1b0 --- /dev/null +++ b/docs/api/admin/admin-cps.md @@ -0,0 +1,28 @@ +# /admin/api/cps — CPS 群发联盟运营台(群/活动/短链/对账) + +> 所属:Admin 子应用(前缀 `/admin/api/cps`) | 鉴权:读=admin,写=operator/finance(对账) | 表 [cps_group](../../database/cps_group.md) / [cps_activity](../../database/cps_activity.md) / [cps_link](../../database/cps_link.md) / [cps_click](../../database/cps_click.md) / [cps_order](../../database/cps_order.md) / [cps_wx_user](../../database/cps_wx_user.md) | [← 返回 API 索引](../README.md) +> +> 业务与授权流程详见 [guides/CPS发券分发与微信授权](../../guides/CPS发券分发与微信授权.md);C 端落地短链见 [cps-redirect](../other/cps-redirect.md)。 + +私域社群 CPS 的完整运营链:建群(拿 `sid`)→ 建活动(券/物料)→ 生成群发短链 `/c/{code}` → 用户点击/复制口令 → 联盟订单按 `sid` 归群对账,汇成「点击→下单→佣金」漏斗。 + +## 端点 + +| 方法 + 路径 | 说明 | +|---|---| +| `GET / POST /admin/api/cps/groups`,`PATCH / DELETE /groups/{id}` | 推广群 CRUD;含美团平台的群自动分配 `sid` | +| `GET / POST /admin/api/cps/activities`,`PATCH / DELETE /activities/{id}` | 可推广活动 CRUD(美团 actId / 淘宝淘口令 / 京东链接) | +| `POST /admin/api/cps/upload-image`、`GET /activity-images` | 活动落地页图上传 / 已有图列表(新建复用) | +| `POST /admin/api/cps/referral-links` | 批量生成群×活动短链(美团经 sid 转链) | +| `POST /admin/api/cps/orders/reconcile` | 拉联盟订单对账(美团 `query_order` + 京东联盟 #90,`order_id` 幂等 upsert;finance) | +| `GET /admin/api/cps/orders` | 订单明细(游标分页,可按 sid / 状态筛) | +| `GET /admin/api/cps/stats` | 按群对账统计(点击/订单/GMV/预估与结算佣金) | +| `GET /admin/api/cps/groups/{id}/timeseries` | 群点击时序(天/小时级 PV/UV/复制,折线图) | +| `GET /admin/api/cps/groups/{id}/daily` | 群每天明细大表格(点击+订单按天合并;#79 起支持按天下钻) | +| `GET /admin/api/cps/groups/{id}/wx-users` | 群内微信用户(领券画像:头像/昵称/领券次数) | +| `GET /admin/api/cps/groups/{id}/day-users` | 某天该群按用户的领券/点击 + 每人点过的券(#79) | + +## 说明 +- 订单与点击**只能在群(sid)维度汇合**,无法对到单笔(联盟只回传 sid)。 +- 京东单有效性按 `jd_valid_code`,美团按 `mt_status`(4 取消/5 风控不计佣,6 结算才到账);淘宝无对账 API,对账列显示 `-`。 +- #119 修美团 `pay_time` 入库为空导致大盘时间窗漏算。 diff --git a/docs/api/admin-dashboard-display.md b/docs/api/admin/admin-dashboard-display.md similarity index 99% rename from docs/api/admin-dashboard-display.md rename to docs/api/admin/admin-dashboard-display.md index 93eec20..e088e9b 100644 --- a/docs/api/admin-dashboard-display.md +++ b/docs/api/admin/admin-dashboard-display.md @@ -1,6 +1,6 @@ # Admin 首页数据配置 — 三统计展示模式 -> 所属:Admin 组(前缀 `/admin/api/dashboard-display`) | 鉴权:Admin Bearer(改需 operator/super) | [← 返回 API 索引](./README.md) +> 所属:Admin 组(前缀 `/admin/api/dashboard-display`) | 鉴权:Admin Bearer(改需 operator/super) | [← 返回 API 索引](../README.md) 配置客户端首页三个门面数字(帮助用户 / 完成比价 / 累计节省)的展示模式。每个指标可独立选 real/manual/random。用户侧读取见 [platform-stats](./platform-stats.md);表见 [ops_stat_config](../database/ops_stat_config.md)。 diff --git a/docs/api/admin/admin-device-liveness.md b/docs/api/admin/admin-device-liveness.md new file mode 100644 index 0000000..2af7bfd --- /dev/null +++ b/docs/api/admin/admin-device-liveness.md @@ -0,0 +1,15 @@ +# /admin/api/device-liveness — 设备存活监控(#80) + +> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin | 表 [device_liveness](../../database/device_liveness.md) | [← 返回 API 索引](../README.md) + +无障碍保护存活的后台视角:哪些设备开过保护(`ever_protected`)、现在在线还是掉线(心跳超时,#107 起阈值 1 小时)、首次开启时间(`first_protected_at`)。 + +## 端点 + +| 方法 + 路径 | 说明 | +|---|---| +| `GET /admin/api/device-liveness/stats` | 顶部卡片统计:设备总数 / 开过保护 / 当前在线 / 掉线数 | +| `GET /admin/api/device-liveness` | 设备存活列表(游标分页):在线情况/设备 id/归属用户 筛选 + 排序,**默认掉线置顶**;行含最近心跳、首次开启、App 版本、push token 有无 | + +## 说明 +- 「在线」= `last_heartbeat_at` 距今 < 超时阈值;掉线召回链路(worker 置 `kill_alert_pending` → 客户端 pull)见表文档。 diff --git a/docs/api/admin/admin-event-logs.md b/docs/api/admin/admin-event-logs.md new file mode 100644 index 0000000..9dd0c69 --- /dev/null +++ b/docs/api/admin/admin-event-logs.md @@ -0,0 +1,15 @@ +# /admin/api/event-logs — 埋点日志(#83) + +> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin | 表 [analytics_event](../../database/analytics_event.md) | [← 返回 API 索引](../README.md) + +客户端埋点(`POST /api/v1/analytics/events` 批量上报)的后台检索页。 + +## 端点 + +| 方法 + 路径 | 说明 | +|---|---| +| `GET /admin/api/event-logs` | 埋点事件列表(游标分页):可按 `event` / `device_id` / `user_id` 筛;行含事件名、props、页面、机型/系统/网络、client_ts | + +## 说明 +- 纯只读;无聚合报表(要分析导出后自己算)。 +- 时间轴用 `client_ts`(事件真实发生时刻),入库时间受客户端攒批影响。 diff --git a/docs/api/admin-marquee-seeds.md b/docs/api/admin/admin-marquee-seeds.md similarity index 74% rename from docs/api/admin-marquee-seeds.md rename to docs/api/admin/admin-marquee-seeds.md index cc1e8dd..2db6d82 100644 --- a/docs/api/admin-marquee-seeds.md +++ b/docs/api/admin/admin-marquee-seeds.md @@ -1,8 +1,8 @@ # Admin 首页轮播种子管理 -> 所属:Admin 组(前缀 `/admin/api/marquee-seeds`) | 鉴权:Admin Bearer(改需 operator/super) | [← 返回 API 索引](./README.md) +> 所属:Admin 组(前缀 `/admin/api/marquee-seeds`) | 鉴权:Admin Bearer(改需 operator/super) | [← 返回 API 索引](../README.md) -管理首页轮播「真实+种子混播」的兜底种子。种子是「生成规则」:`masked_user` 可空(空→feed 随机合成名)、金额是 `[min_cents, max_cents]` 区间(feed 每次随机取值)。用户侧 feed 见 [platform-savings-feed](./platform-savings-feed.md);表见 [ops_marquee_seed](../database/ops_marquee_seed.md)。金额单位:分(前端 ÷100 显示元)。 +管理首页轮播「真实+种子混播」的兜底种子。种子是「生成规则」:`masked_user` 可空(空→feed 随机合成名)、金额是 `[min_cents, max_cents]` 区间(feed 每次随机取值)。用户侧 feed 见 [platform-savings-feed](../savings/platform-savings-feed.md);表见 [ops_marquee_seed](../../database/ops_marquee_seed.md)。金额单位:分(前端 ÷100 显示元)。 ## 复用结构 OpsMarqueeSeedOut | 字段 | 类型 | 说明 | @@ -19,9 +19,20 @@ 出参 `200`:`list[OpsMarqueeSeedOut]`(按 `sort_order,id`)。 ## GET /admin/api/marquee-seeds/preview — 预览实际混播 feed -预览客户端实际会看到的轮播(真实记录会插队、种子随机抽取 / 金额随机 / 名字合成),供运营对效果。**含随机,每次结果不同**。 +预览客户端实际会看到的轮播(真实记录会插队、种子随机抽取 / 金额随机 / 名字合成),供运营对效果。**含随机,每次结果不同**;#122 起按**当前数据源模式**实时预览(mixed/real/seed 各自的真实产出)。 - 入参:`limit`(query,1~30,默认 8) -- 出参 `200`:`{"items": [{masked_user, saved_amount_cents, time}]}`(条目同 [platform-savings-feed](./platform-savings-feed.md)) +- 出参 `200`:`{"items": [{masked_user, saved_amount_cents, time}]}`(条目同 [platform-savings-feed](../savings/platform-savings-feed.md)) + +## GET /admin/api/marquee-seeds/real-records — 分页浏览当前模式下可展示的真实记录(#123) +审核用:看「真实条」到底会拿哪些 `comparison_record` 上轮播(真实条**不按用户去重**,打乱+去连簇后混播;默认昵称归「无昵称」脱敏档,#122)。 +- 入参:`limit` / `cursor`(游标分页) +- 出参 `200`:`{"items": [...], "next_cursor": int|null}` + +## GET /admin/api/marquee-seeds/mode — 首页轮播数据源模式 +出参:`{"mode": "mixed" | "real" | "seed"}`(混播 / 只真实 / 只种子)。 + +## PATCH /admin/api/marquee-seeds/mode — 改数据源模式(带审计) +- 入参:`{"mode": "mixed" | "real" | "seed"}`;operator 起。改动客户端重拉 feed 生效。 ## POST /admin/api/marquee-seeds — 新增(带审计) 入参 `OpsMarqueeSeedCreate`:`masked_user`(可选,空 / 不传 → 随机合成)、`min_cents`(必填,≥0)、`max_cents`(必填,≥min,≤1000 元)、`enabled`(默认 true)、`sort_order`(默认 0)。出参:新建的 `OpsMarqueeSeedOut`。`400`=金额非法。 diff --git a/docs/api/admin/admin-price-reports.md b/docs/api/admin/admin-price-reports.md new file mode 100644 index 0000000..438c76c --- /dev/null +++ b/docs/api/admin/admin-price-reports.md @@ -0,0 +1,16 @@ +# /admin/api/price-reports — 上报更低价审核(#94) + +> 所属:Admin 子应用(前缀 `/admin/api/price-reports`) | 鉴权:读=admin,审=operator | 表 [price_report](../../database/price_report.md) | [← 返回 API 索引](../README.md) +> +> C 端提交/查询见 [report-submit](../other/report-submit.md) / [report-records](../other/report-records.md)。 + +用户众包「上报更低价」的人工审核台:审截图与价格,通过发固定金币。 + +## 端点 + +| 方法 + 路径 | 说明 | +|---|---| +| `GET /admin/api/price-reports` | 上报列表(状态筛选 + 游标分页,含截图、关联比价记录快照) | +| `GET /admin/api/price-reports/summary` | 审核统计(pending/approved/rejected 计数) | +| `POST /admin/api/price-reports/{report_id}/approve` | 通过 → 发固定金币(`grant_coins` 同事务)+ 带审计 | +| `POST /admin/api/price-reports/{report_id}/reject` | 拒绝(填原因,用户端可见)+ 带审计 | diff --git a/docs/api/admin/admin-roles.md b/docs/api/admin/admin-roles.md new file mode 100644 index 0000000..0c00cd3 --- /dev/null +++ b/docs/api/admin/admin-roles.md @@ -0,0 +1,19 @@ +# /admin/api/roles — 角色与可见页管理(RBAC,#117/#126) + +> 所属:Admin 子应用(前缀 `/admin/api`,独立 admin JWT) | 鉴权:**super_admin** | 表 [admin_role](../../database/admin_role.md) | [← 返回 API 索引](../README.md) + +后台 RBAC 的数据驱动层:内建三角色(`super_admin`/`finance`/`operator`,`is_builtin=true` 不可删)+ **自定义角色**(勾任意页面组合)。管理员的有效可见页 = `admin_user.pages_override`(个人覆盖,非空优先)∪ 否则取其角色 `pages`;`super_admin` 恒全通。 + +## 端点 + +| 方法 + 路径 | 说明 | +|---|---| +| `GET /admin/api/roles` | 角色列表:`[{id, name, label, pages, is_builtin, admin_count}]`(含每个角色的使用人数) | +| `GET /admin/api/roles/catalog` | 页面权限目录(分组):全部可勾选的页面 key(来自 `app/admin/permissions.py` 常量,不落库),前端渲染勾选面板用 | +| `POST /admin/api/roles` | 新增自定义角色 `{name, pages}`(**name(key)= label = 输入名称**;不能叫 `super_admin`,重名 `409`);带审计 | +| `PATCH /admin/api/roles/{role_id}` | 改展示名/可见页(内建角色的 `pages` 也可调);带审计 | +| `DELETE /admin/api/roles/{role_id}` | 删角色;**内建(`is_builtin`)与在用(有 `admin_user.role` 引用)不可删**(400);带审计 | + +## 说明 +- 管理员个人覆盖在 [`PATCH /admin/api/admins/{id}`](./admins/admin-admin-update.md) 的 `pages_override` 字段改,不在本组。 +- 加新后台页面要同步登记 `permissions.py` 目录,表里只存勾选结果(目录变更无需迁移)。 diff --git a/docs/api/admin-stats-overview.md b/docs/api/admin/admin-stats-overview.md similarity index 98% rename from docs/api/admin-stats-overview.md rename to docs/api/admin/admin-stats-overview.md index b618eb5..6165445 100644 --- a/docs/api/admin-stats-overview.md +++ b/docs/api/admin/admin-stats-overview.md @@ -1,6 +1,6 @@ # GET /admin/api/stats/overview — 大盘核心指标 -> 所属:Admin·数据大盘 组(前缀 `/admin/api/stats`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](./README.md) +> 所属:Admin·数据大盘 组(前缀 `/admin/api/stats`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](../README.md) ## 入参 无 diff --git a/docs/api/admin-admin-create.md b/docs/api/admin/admins/admin-admin-create.md similarity index 98% rename from docs/api/admin-admin-create.md rename to docs/api/admin/admins/admin-admin-create.md index f93e2b7..f1d013c 100644 --- a/docs/api/admin-admin-create.md +++ b/docs/api/admin/admins/admin-admin-create.md @@ -1,6 +1,6 @@ # POST /admin/api/admins — 创建管理员 -> 所属:Admin·Accounts 组(前缀 `/admin/api/admins`) | 鉴权:Bearer admin_token(角色:super_admin) | [← 返回 API 索引](./README.md) +> 所属:Admin·Accounts 组(前缀 `/admin/api/admins`) | 鉴权:Bearer admin_token(角色:super_admin) | [← 返回 API 索引](../../README.md) ## 入参 **application/json**: diff --git a/docs/api/admin-admin-update.md b/docs/api/admin/admins/admin-admin-update.md similarity index 62% rename from docs/api/admin-admin-update.md rename to docs/api/admin/admins/admin-admin-update.md index a86117a..d6b57f2 100644 --- a/docs/api/admin-admin-update.md +++ b/docs/api/admin/admins/admin-admin-update.md @@ -1,6 +1,6 @@ # PATCH /admin/api/admins/{admin_id} — 改角色/启停/重置密码 -> 所属:Admin·Accounts 组(前缀 `/admin/api/admins`) | 鉴权:Bearer admin_token(角色:super_admin) | [← 返回 API 索引](./README.md) +> 所属:Admin·Accounts 组(前缀 `/admin/api/admins`) | 鉴权:Bearer admin_token(角色:super_admin) | [← 返回 API 索引](../../README.md) ## 入参 **路径参数**: @@ -8,12 +8,13 @@ |---|---|---|---| | `admin_id` | int | ✓ | 目标管理员 id | -**application/json**(三字段都可选,只改传了的;至少传一个): +**application/json**(字段都可选,只改传了的;至少传一个): | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| -| `role` | string | ✗ | 改角色,枚举:`super_admin` / `finance` / `operator` | +| `role` | string | ✗ | 改角色:内建 `super_admin` / `finance` / `operator` **或自定义角色 name**(#117/#126,见 [admin-roles](../admin-roles.md)) | +| `pages_override` | list[string] \| null | ✗ | 个人可见页覆盖(#126):非空优先于角色 pages;传 `null` 清覆盖回归角色 | | `status` | string | ✗ | 启停,枚举:`active`(启用)/ `disabled`(禁用) | -| `password` | string | ✗ | 重置密码,8–72 字(传则覆盖原密码) | +| `password` | string | ✗ | 重置密码,8–72 字(传则覆盖原密码,同时更新 `plain_password` 明文副本) | ## 出参 响应 `200`:`AdminOut`(更新后的管理员) @@ -24,9 +25,15 @@ | `username` | string | 账号 | | `role` | string | 角色 | | `status` | string | 状态 | +| `pages` | list[string] | **有效可见页**(pages_override 优先,否则角色 pages;super_admin 全量) | | `created_at` | datetime | 创建时间(UTC) | | `last_login_at` | datetime \| null | 上次登录时间 | +--- + +## DELETE /admin/api/admins/{admin_id} — 删除管理员(#126) +物理删除(区别于禁用);**不可删自己**(`400`);带审计(`action=admin.delete`)。鉴权同本组(super_admin)。出参 `{"deleted": true}`。 + ## 错误码 - `400` 不能禁用自己(`admin_id == 当前 admin.id` 且 `status=disabled`) / 无任何变更字段(三字段全空) - `401` 未带 admin token / token 无效或过期 / 管理员被禁用 @@ -35,5 +42,5 @@ - `422` `role`/`status` 非法枚举 / `password` 长度不在 8–72 ## 说明 -- 更新成功后写一条审计:`action=admin.update`、`target_type=admin`、`target_id=admin_id`、`detail` 为本次实际变更字段(如 `{"role": "...", "status": "...", "password": "reset"}`,密码只记 `reset` 不记明文)。见 [admin_audit_log](../database/admin_audit_log.md)。 -- 数据表见 [admin_user](../database/admin_user.md)。 +- 更新成功后写一条审计:`action=admin.update`、`target_type=admin`、`target_id=admin_id`、`detail` 为本次实际变更字段(如 `{"role": "...", "status": "...", "password": "reset"}`,密码只记 `reset` 不记明文)。见 [admin_audit_log](../../../database/admin_audit_log.md)。 +- 数据表见 [admin_user](../../../database/admin_user.md)。 diff --git a/docs/api/admin-admins-list.md b/docs/api/admin/admins/admin-admins-list.md similarity index 97% rename from docs/api/admin-admins-list.md rename to docs/api/admin/admins/admin-admins-list.md index 951917e..3e01544 100644 --- a/docs/api/admin-admins-list.md +++ b/docs/api/admin/admins/admin-admins-list.md @@ -1,6 +1,6 @@ # GET /admin/api/admins — 管理员列表 -> 所属:Admin·Accounts 组(前缀 `/admin/api/admins`) | 鉴权:Bearer admin_token(角色:super_admin) | [← 返回 API 索引](./README.md) +> 所属:Admin·Accounts 组(前缀 `/admin/api/admins`) | 鉴权:Bearer admin_token(角色:super_admin) | [← 返回 API 索引](../../README.md) ## 入参 无(按 `id` 升序返回全部,无分页) diff --git a/docs/api/admin-auth-login.md b/docs/api/admin/auth/admin-auth-login.md similarity index 97% rename from docs/api/admin-auth-login.md rename to docs/api/admin/auth/admin-auth-login.md index c228bfa..db7e8d1 100644 --- a/docs/api/admin-auth-login.md +++ b/docs/api/admin/auth/admin-auth-login.md @@ -1,6 +1,6 @@ # POST /admin/api/auth/login — 管理员登录 -> 所属:Admin·Auth 组(前缀 `/admin/api/auth`) | 鉴权:无 | [← 返回 API 索引](./README.md) +> 所属:Admin·Auth 组(前缀 `/admin/api/auth`) | 鉴权:无 | [← 返回 API 索引](../../README.md) ## 入参 **application/json**: diff --git a/docs/api/admin-auth-me.md b/docs/api/admin/auth/admin-auth-me.md similarity index 96% rename from docs/api/admin-auth-me.md rename to docs/api/admin/auth/admin-auth-me.md index 057f2e5..dfa6764 100644 --- a/docs/api/admin-auth-me.md +++ b/docs/api/admin/auth/admin-auth-me.md @@ -1,6 +1,6 @@ # GET /admin/api/auth/me — 当前管理员 -> 所属:Admin·Auth 组(前缀 `/admin/api/auth`) | 鉴权:Bearer admin_token(角色:任意已登录 admin) | [← 返回 API 索引](./README.md) +> 所属:Admin·Auth 组(前缀 `/admin/api/auth`) | 鉴权:Bearer admin_token(角色:任意已登录 admin) | [← 返回 API 索引](../../README.md) ## 入参 无(身份取自 Header token) diff --git a/docs/api/admin/feedbacks/admin-feedback-handle.md b/docs/api/admin/feedbacks/admin-feedback-handle.md new file mode 100644 index 0000000..4e45880 --- /dev/null +++ b/docs/api/admin/feedbacks/admin-feedback-handle.md @@ -0,0 +1,41 @@ +# /admin/api/feedbacks — 反馈审核族(采纳/拒绝/标记处理/统计) + +> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks`) | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过,`require_role("operator")`;summary 任意 admin) | [← 返回 API 索引](../../README.md) +> +> 列表见 [admin-feedbacks-list](./admin-feedbacks-list.md)。#94 引入 采纳(发金币)/拒绝 审核语义,#105 加运营回复 `admin_reply`;旧「标记已处理」保留。 + +## POST /admin/api/feedbacks/{feedback_id}/approve — 采纳并发金币(#94) +- body:`{reward_coins?(int,可 0), admin_reply?(string,用户可见), review_note?(string,内部)}` +- 行为:`status → adopted`;`reward_coins > 0` 时走 `grant_coins` 同事务发币(流水 `coin_transaction`);写审计(`detail.after="adopted"`)。已终态(非 pending/new)→ `400`。 +- 出参:更新后的 FeedbackOut。 + +## POST /admin/api/feedbacks/{feedback_id}/reject — 拒绝采纳(#94) +- body:`{reject_reason(string,用户可见), admin_reply?, review_note?}` +- 行为:`status → rejected`;写审计(`detail.after="rejected"`)。已终态 → `400`。 + +## GET /admin/api/feedbacks/summary — 审核统计 +- 出参:各状态计数(`pending` / `adopted` / `rejected` / `handled`),审核台顶部卡片用;任意 admin 可读。 + +--- + +## POST /admin/api/feedbacks/{feedback_id}/handle — 标记反馈已处理(旧口径) + +## 入参 +- 路径:`feedback_id`(int) +- body:无 + +## 出参 +响应 `200`:`OkResponse` = `{ "ok": true }` + +幂等说明:将该反馈 `status` 置为 `handled`(不校验原状态,重复调用结果一致)。 + +## 错误码 +- `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`) +- `403` 角色不足(需 `operator` 或 `super_admin`) +- `404` 反馈不存在(`detail: "反馈不存在"`) +- `422` `feedback_id` 非合法 int + +## 说明 +- 写操作记审计 [admin_audit_log](../../../database/admin_audit_log.md):`action="feedback.handle"`、`target_type="feedback"`、`target_id=`、`detail={"before": <原 status>, "after": "handled"}`、`ip=<客户端 IP>`。 +- 状态变更与审计写入在同一事务(`commit=False` 后统一 `db.commit()`)。 +- 关联表 [feedback](../../../database/feedback.md)。 diff --git a/docs/api/admin-feedbacks-list.md b/docs/api/admin/feedbacks/admin-feedbacks-list.md similarity index 99% rename from docs/api/admin-feedbacks-list.md rename to docs/api/admin/feedbacks/admin-feedbacks-list.md index af0460f..277d593 100644 --- a/docs/api/admin-feedbacks-list.md +++ b/docs/api/admin/feedbacks/admin-feedbacks-list.md @@ -1,6 +1,6 @@ # GET /admin/api/feedbacks — 反馈工单列表(offset 分页 + 筛选/排序) -> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 `require_role`,仅 `get_current_admin`) | [← 返回 API 索引](./README.md) +> 所属:Admin·反馈 组(前缀 `/admin/api/feedbacks`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 `require_role`,仅 `get_current_admin`) | [← 返回 API 索引](../../README.md) ## 入参(query) | 字段 | 类型 | 必填 | 默认 | 说明 | diff --git a/docs/api/admin-user-cash.md b/docs/api/admin/users/admin-user-cash.md similarity index 64% rename from docs/api/admin-user-cash.md rename to docs/api/admin/users/admin-user-cash.md index c9ea739..7f11e7a 100644 --- a/docs/api/admin-user-cash.md +++ b/docs/api/admin/users/admin-user-cash.md @@ -1,6 +1,6 @@ # POST /admin/api/users/{user_id}/cash — 手动增减/设值现金(带审计) -> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md) +> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](../../README.md) 主要用于给无现金用户直接发钱、好让其测试提现链路。 @@ -10,6 +10,7 @@ | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| | `mode` | string | ✗ | `delta`(默认)=增减 / `set`=设为指定值 | +| `account` | string | ✗ | 目标账户(#95):`coin_cash`(默认,金币兑换的现金)/ `invite_cash`(邀请奖励金)。两本账物理隔离、各调各 | | `amount_cents` | int | ✓ | `delta` 模式:现金变动(分,正=发放,负=扣减,不可为 0);`set` 模式:目标现金值(分,须 ≥ 0) | | `reason` | string | ✓ | 操作原因,1–128 字(必填,入审计与流水备注) | @@ -28,7 +29,7 @@ - 金额单位一律为**分**(`*_cents`);本接口只动现金余额,不涉及金币。 - **set 模式**:读当前余额算出差值 `delta = target - 当前余额`,再复用同一套写入逻辑(故只写一笔差值流水)。目标值须 ≥ 0;差值为 0(已等于目标)直接拒绝。 - 扣减保护:实际写入的 `delta < 0` 时若扣减后现金余额 < 0 直接拒绝(运营误操作保护);set 模式目标值 ≥ 0 天然不会扣成负。 -- 现金变动写流水 [cash_transaction](../database/cash_transaction.md):`biz_type` 实际差值为正记 `admin_grant`、为负记 `admin_deduct`(set 模式同理,不新增流水类型),`remark = admin:`(截断至 128 字)。 -- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.cash.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount_cents(=实际差值), balance_after_cents, reason}`;set 模式额外带 `{mode:"set", target_cents, before_cents}`。并记录操作 IP。 +- 现金变动按 `account` 写对应账本流水:`coin_cash` → [cash_transaction](../../../database/cash_transaction.md),`invite_cash` → [invite_cash_transaction](../../../database/invite_cash_transaction.md)(#95);`biz_type` 实际差值为正记 `admin_grant`、为负记 `admin_deduct`(set 模式同理,不新增流水类型),`remark = admin:`(截断至 128 字)。 +- 写操作记审计 [admin_audit_log](../../../database/admin_audit_log.md):`action = user.cash.grant`,`target_type = user`,`target_id = user_id`,`detail = {amount_cents(=实际差值), balance_after_cents, reason}`;set 模式额外带 `{mode:"set", target_cents, before_cents}`。并记录操作 IP。 - 现金变动 + 审计在同一事务原子提交(改钱必留痕)。 -- 关联用户表 [user](../database/user.md);现金账户 [coin_account](../database/coin_account.md);现金流水 [cash_transaction](../database/cash_transaction.md)。 +- 关联用户表 [user](../../../database/user.md);现金账户 [coin_account](../../../database/coin_account.md);现金流水 [cash_transaction](../../../database/cash_transaction.md)。 diff --git a/docs/api/admin-user-coins.md b/docs/api/admin/users/admin-user-coins.md similarity index 98% rename from docs/api/admin-user-coins.md rename to docs/api/admin/users/admin-user-coins.md index c126afc..1513b91 100644 --- a/docs/api/admin-user-coins.md +++ b/docs/api/admin/users/admin-user-coins.md @@ -1,6 +1,6 @@ # POST /admin/api/users/{user_id}/coins — 手动增减/设值金币(带审计) -> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md) +> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](../../README.md) ## 入参 - 路径:`user_id`(int) diff --git a/docs/api/admin-user-detail.md b/docs/api/admin/users/admin-user-detail.md similarity index 67% rename from docs/api/admin-user-detail.md rename to docs/api/admin/users/admin-user-detail.md index 36571fe..9eb46e0 100644 --- a/docs/api/admin-user-detail.md +++ b/docs/api/admin/users/admin-user-detail.md @@ -1,6 +1,6 @@ # GET /admin/api/users/{user_id} — 用户 360 详情 -> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](./README.md) +> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](../../README.md) ## 入参 - 路径:`user_id`(int) @@ -38,6 +38,15 @@ - `422` `user_id` 非整数 ## 说明 -- 金币三项(`coin_balance` / `cash_balance_cents` / `total_coin_earned`)读 [coin_account](../database/coin_account.md);从未发生金币动作(账户不存在)时统一返回 0。 +- 金币三项(`coin_balance` / `cash_balance_cents` / `total_coin_earned`)读 [coin_account](../../../database/coin_account.md);从未发生金币动作(账户不存在)时统一返回 0。 - 各 count 为聚合数,明细历史走带 `user_id` 过滤的分页接口(金币流水 / 现金流水 / 提现 / 比价 / 反馈)。 -- 关联用户表 [user](../database/user.md);金币账户 [coin_account](../database/coin_account.md);提现单 [withdraw_order](../database/withdraw_order.md);比价记录 [comparison_record](../database/comparison_record.md);反馈 [feedback](../database/feedback.md)。 +- 关联用户表 [user](../../../database/user.md);金币账户 [coin_account](../../../database/coin_account.md);提现单 [withdraw_order](../../../database/withdraw_order.md);比价记录 [comparison_record](../../../database/comparison_record.md);反馈 [feedback](../../../database/feedback.md)。 + +--- + +## 族内配套端点(提现详情页联查用) + +| 方法 + 路径 | 说明 | +|---|---| +| `GET /admin/api/users/{user_id}/reward-stats` | 用户提现/看广告统计(按时间窗口):提现审核时评估该用户金币来源是否健康 | +| `GET /admin/api/users/{user_id}/coin-records` | 用户金币发放记录(按时间窗口分页):提现详情底部表,逐笔看发币来源 | diff --git a/docs/api/admin-user-status.md b/docs/api/admin/users/admin-user-status.md similarity index 60% rename from docs/api/admin-user-status.md rename to docs/api/admin/users/admin-user-status.md index 68d496e..4638f99 100644 --- a/docs/api/admin-user-status.md +++ b/docs/api/admin/users/admin-user-status.md @@ -1,6 +1,6 @@ # POST /admin/api/users/{user_id}/status — 封禁/解封用户 -> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md) +> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:`operator`,`super_admin` 恒通过) | [← 返回 API 索引](../../README.md) ## 入参 - 路径:`user_id`(int) @@ -21,5 +21,11 @@ ## 说明 - 业务写(改用户状态)与审计写在同一事务原子提交:改了就有痕、有痕就真改了。 -- 写操作记审计 [admin_audit_log](../database/admin_audit_log.md):`action = user.status.set`,`target_type = user`,`target_id = user_id`,`detail = {before, after}`,并记录操作 IP。 -- 关联用户表 [user](../database/user.md)。 +- 写操作记审计 [admin_audit_log](../../../database/admin_audit_log.md):`action = user.status.set`,`target_type = user`,`target_id = user_id`,`detail = {before, after}`,并记录操作 IP。 +- 关联用户表 [user](../../../database/user.md)。 + +--- + +## POST /admin/api/users/{user_id}/debug-trace — 开关调试链接权限 +- body:`{"enabled": true|false}` → 写 `user.debug_trace_enabled`(带审计 `action=user.debug_trace.set`)。 +- 开了的用户在比价完成弹窗 + 比价记录页可见「复制调试链接」按钮(trace_url);运营按用户灰度排障用。鉴权同本组(operator)。 diff --git a/docs/api/admin-users-list.md b/docs/api/admin/users/admin-users-list.md similarity index 97% rename from docs/api/admin-users-list.md rename to docs/api/admin/users/admin-users-list.md index f4ec2ba..b5df4a6 100644 --- a/docs/api/admin-users-list.md +++ b/docs/api/admin/users/admin-users-list.md @@ -1,6 +1,6 @@ # GET /admin/api/users — 用户列表(筛选+排序+分页) -> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](./README.md) +> 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](../../README.md) ## 入参(query) | 字段 | 类型 | 必填 | 默认 | 说明 | diff --git a/docs/api/admin-wallet-cash-transactions.md b/docs/api/admin/wallet/admin-wallet-cash-transactions.md similarity index 98% rename from docs/api/admin-wallet-cash-transactions.md rename to docs/api/admin/wallet/admin-wallet-cash-transactions.md index e3942be..c35a1bd 100644 --- a/docs/api/admin-wallet-cash-transactions.md +++ b/docs/api/admin/wallet/admin-wallet-cash-transactions.md @@ -1,6 +1,6 @@ # GET /admin/api/wallet/cash-transactions — 现金流水(游标分页) -> 所属:Admin·钱包 组(前缀 `/admin/api/wallet`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,仅需 `get_current_admin`,无 `require_role`) | [← 返回 API 索引](./README.md) +> 所属:Admin·钱包 组(前缀 `/admin/api/wallet`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,仅需 `get_current_admin`,无 `require_role`) | [← 返回 API 索引](../../README.md) 跨用户查询全量现金流水,可按 `user_id` / `biz_type` 过滤。游标分页(`id` 倒序)。金额单位一律为**分**。 diff --git a/docs/api/admin-wallet-coin-transactions.md b/docs/api/admin/wallet/admin-wallet-coin-transactions.md similarity index 98% rename from docs/api/admin-wallet-coin-transactions.md rename to docs/api/admin/wallet/admin-wallet-coin-transactions.md index fc40802..a0a7fcf 100644 --- a/docs/api/admin-wallet-coin-transactions.md +++ b/docs/api/admin/wallet/admin-wallet-coin-transactions.md @@ -1,6 +1,6 @@ # GET /admin/api/wallet/coin-transactions — 金币流水(游标分页) -> 所属:Admin·钱包 组(前缀 `/admin/api/wallet`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,仅需 `get_current_admin`,无 `require_role`) | [← 返回 API 索引](./README.md) +> 所属:Admin·钱包 组(前缀 `/admin/api/wallet`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,仅需 `get_current_admin`,无 `require_role`) | [← 返回 API 索引](../../README.md) 跨用户查询全量金币流水,可按 `user_id` / `biz_type` 过滤。游标分页(`id` 倒序)。 diff --git a/docs/api/admin-withdraw-reconcile.md b/docs/api/admin/withdraws/admin-withdraw-reconcile.md similarity index 97% rename from docs/api/admin-withdraw-reconcile.md rename to docs/api/admin/withdraws/admin-withdraw-reconcile.md index 0cba53c..aa2e4ce 100644 --- a/docs/api/admin-withdraw-reconcile.md +++ b/docs/api/admin/withdraws/admin-withdraw-reconcile.md @@ -1,6 +1,6 @@ # POST /admin/api/withdraws/reconcile — 批量对账(扫超时 pending 单) -> 所属:Admin·提现 组(前缀 `/admin/api/withdraws`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md) +> 所属:Admin·提现 组(前缀 `/admin/api/withdraws`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](../../README.md) 扫描创建时间超过 `older_than_minutes` 分钟、仍为 `pending` 的提现单,逐单调微信查单并归一化(成功落 `success`;失败/已撤销则退款落 `failed`;查到 `WAIT_USER_CONFIRM` 视为用户放弃,撤单+退款)。用于解开"扣了款但转账没发起/没确认"的孤儿单。单笔失败不影响其余(内部 rollback 后继续,下轮再试)。 diff --git a/docs/api/admin-withdraw-refresh.md b/docs/api/admin/withdraws/admin-withdraw-refresh.md similarity index 97% rename from docs/api/admin-withdraw-refresh.md rename to docs/api/admin/withdraws/admin-withdraw-refresh.md index 6e4261c..145fa6a 100644 --- a/docs/api/admin-withdraw-refresh.md +++ b/docs/api/admin/withdraws/admin-withdraw-refresh.md @@ -1,6 +1,6 @@ # POST /admin/api/withdraws/{out_bill_no}/refresh — 单笔提现重试查单 -> 所属:Admin·提现 组(前缀 `/admin/api/withdraws`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](./README.md) +> 所属:Admin·提现 组(前缀 `/admin/api/withdraws`) | 鉴权:Bearer admin_token(角色:`finance`,`super_admin` 恒通过) | [← 返回 API 索引](../../README.md) 对单笔提现单调微信查单并归一化:`SUCCESS`→`success`;`FAIL`/`CANCELLED`/`CLOSED`→退款+`failed`;查到 `WAIT_USER_CONFIRM` 视为用户放弃(`cancel_if_unconfirmed=True`),撤单+退款;`ACCEPTED`/`PROCESSING` 等仍在途则保持 `pending`。已是终态的单直接返回、不再查。 diff --git a/docs/api/admin/withdraws/admin-withdraw-review.md b/docs/api/admin/withdraws/admin-withdraw-review.md new file mode 100644 index 0000000..a547d97 --- /dev/null +++ b/docs/api/admin/withdraws/admin-withdraw-review.md @@ -0,0 +1,25 @@ +# /admin/api/withdraws — 提现审核台(审核/批量/对账族) + +> 所属:Admin 子应用(前缀 `/admin/api/withdraws`) | 鉴权:读=admin,写=**finance** | 表 [withdraw_order](../../../database/withdraw_order.md) | [← 返回 API 索引](../../README.md) +> +> 列表见 [admin-withdraws-list](./admin-withdraws-list.md);单笔查单见 [admin-withdraw-refresh](./admin-withdraw-refresh.md);超时对账见 [admin-withdraw-reconcile](./admin-withdraw-reconcile.md)。本文覆盖其余审核台端点。 + +提现状态机:`reviewing`(发起即扣款待审)→ 通过 `pending`(微信转账在途)→ `success`/`failed`(失败退款);拒绝 `rejected`(退款)。**#121 起按 `withdraw_order.source` 分账**:退款/流水落 `cash_transaction`(coin_cash)或 `invite_cash_transaction`(invite_cash)。 + +## 端点 + +| 方法 + 路径 | 鉴权 | 说明 | +|---|---|---| +| `GET /admin/api/withdraws/summary` | admin | 审核台统计:各状态计数(待审/在途/成功/失败/拒绝) | +| `GET /admin/api/withdraws/health-check` | finance | 提现配置健康检查(证书/密钥路径/商户配置就位与否;暴露路径故限 finance+super) | +| `GET /admin/api/withdraws/ledger-check` | admin | **资金账本校验**(#121):按 `source` 分账核对「提现单 ↔ 流水」金额闭环,邀请奖励金提现纳入对账 | +| `GET /admin/api/withdraws/{out_bill_no}` | admin | 提现单详情(审核台抽屉;用户维度联查另走 `users/{id}/reward-stats` + `coin-records`) | +| `POST /admin/api/withdraws/{out_bill_no}/approve` | finance | 审核通过 → 发起微信打款(`reviewing`→`pending`→查单归一化);带审计 | +| `POST /admin/api/withdraws/{out_bill_no}/reject` | finance | 审核拒绝 → 按 source 退款 + `rejected`;带审计 | +| `POST /admin/api/withdraws/bulk/refresh` | finance | 批量刷新查单(勾选多笔) | +| `POST /admin/api/withdraws/bulk/approve` | finance | 批量审核通过并打款 | +| `POST /admin/api/withdraws/bulk/reject` | finance | 批量审核拒绝并退款 | + +## 说明 +- 批量接口逐单处理、逐单落审计,单笔失败不中断整批(返回逐单结果)。 +- 结果不明时先查单再定夺、绝不盲目退款(防退款后又到账),同 C 端口径。 diff --git a/docs/api/admin-withdraws-list.md b/docs/api/admin/withdraws/admin-withdraws-list.md similarity index 97% rename from docs/api/admin-withdraws-list.md rename to docs/api/admin/withdraws/admin-withdraws-list.md index f90e6c6..12ae85d 100644 --- a/docs/api/admin-withdraws-list.md +++ b/docs/api/admin/withdraws/admin-withdraws-list.md @@ -1,6 +1,6 @@ # GET /admin/api/withdraws — 提现单列表(游标分页) -> 所属:Admin·提现 组(前缀 `/admin/api/withdraws`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,列表为只读,仅需 `get_current_admin`,无 `require_role`) | [← 返回 API 索引](./README.md) +> 所属:Admin·提现 组(前缀 `/admin/api/withdraws`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,列表为只读,仅需 `get_current_admin`,无 `require_role`) | [← 返回 API 索引](../../README.md) 跨用户查询全量提现单,可按 `user_id` / `status` 过滤。游标分页(`id` 倒序)。 diff --git a/docs/api/auth-jverify-login.md b/docs/api/auth/auth-jverify-login.md similarity index 96% rename from docs/api/auth-jverify-login.md rename to docs/api/auth/auth-jverify-login.md index 182ea6e..c8cae76 100644 --- a/docs/api/auth-jverify-login.md +++ b/docs/api/auth/auth-jverify-login.md @@ -1,6 +1,6 @@ # POST /api/v1/auth/jverify-login — 极光一键登录 -> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无 | [← 返回 API 索引](./README.md) +> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无 | [← 返回 API 索引](../README.md) > > 集成实现:见 [integrations/jiguang](../integrations/jiguang.md)(极光核验链路、RSA 解密策略、私钥配对踩坑)。 diff --git a/docs/api/auth-logout.md b/docs/api/auth/auth-logout.md similarity index 86% rename from docs/api/auth-logout.md rename to docs/api/auth/auth-logout.md index 041c0f6..59b9333 100644 --- a/docs/api/auth-logout.md +++ b/docs/api/auth/auth-logout.md @@ -1,6 +1,6 @@ # POST /api/v1/auth/logout — 登出 -> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md) +> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:Bearer access_token | [← 返回 API 索引](../README.md) ## 入参 无 diff --git a/docs/api/auth-me.md b/docs/api/auth/auth-me.md similarity index 87% rename from docs/api/auth-me.md rename to docs/api/auth/auth-me.md index 01eae87..fc69cb6 100644 --- a/docs/api/auth-me.md +++ b/docs/api/auth/auth-me.md @@ -1,6 +1,6 @@ # GET /api/v1/auth/me — 获取当前登录用户 -> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md) +> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:Bearer access_token | [← 返回 API 索引](../README.md) ## 入参 无(身份取自 Header token) diff --git a/docs/api/auth-refresh.md b/docs/api/auth/auth-refresh.md similarity index 91% rename from docs/api/auth-refresh.md rename to docs/api/auth/auth-refresh.md index c2860df..b2c9dd2 100644 --- a/docs/api/auth-refresh.md +++ b/docs/api/auth/auth-refresh.md @@ -1,6 +1,6 @@ # POST /api/v1/auth/refresh — 刷新 token -> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无(凭 body 里的 refresh_token) | [← 返回 API 索引](./README.md) +> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无(凭 body 里的 refresh_token) | [← 返回 API 索引](../README.md) ## 入参 diff --git a/docs/api/auth-sms-login.md b/docs/api/auth/auth-sms-login.md similarity index 95% rename from docs/api/auth-sms-login.md rename to docs/api/auth/auth-sms-login.md index a858ef0..643db01 100644 --- a/docs/api/auth-sms-login.md +++ b/docs/api/auth/auth-sms-login.md @@ -1,6 +1,6 @@ # POST /api/v1/auth/sms/login — 手机号 + 验证码登录 -> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无 | [← 返回 API 索引](./README.md) +> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无 | [← 返回 API 索引](../README.md) > > 集成实现:见 [integrations/sms](../integrations/sms.md)(验证码校验逻辑)。 diff --git a/docs/api/auth-sms-send.md b/docs/api/auth/auth-sms-send.md similarity index 95% rename from docs/api/auth-sms-send.md rename to docs/api/auth/auth-sms-send.md index c2cb4df..9c046eb 100644 --- a/docs/api/auth-sms-send.md +++ b/docs/api/auth/auth-sms-send.md @@ -1,6 +1,6 @@ # POST /api/v1/auth/sms/send — 发送短信验证码 -> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无 | [← 返回 API 索引](./README.md) +> 所属:Auth 组(前缀 `/api/v1/auth`) | 鉴权:无 | [← 返回 API 索引](../README.md) > > 集成实现:见 [integrations/sms](../integrations/sms.md)(mock 模式、频控、接真供应商 TODO)。 diff --git a/docs/api/compare-price-step.md b/docs/api/compare-price-step.md deleted file mode 100644 index 4bd3f42..0000000 --- a/docs/api/compare-price-step.md +++ /dev/null @@ -1,23 +0,0 @@ -# POST /api/v1/price/step — 外卖比价 Phase 2 步进(透传到 pricebot) - -> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](./README.md) - -## 入参 -任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id`、`trace_id`、`step` 用于日志。 -客户端逐帧上报 `screen_state` + 上一步 `action_result`;`step=0` 还带 `query` + `calibration`(来自 Phase 1)。 - -## 出参 -pricebot-backend 的响应**原样返回**(JSON object)。含 `action`(tap / set_text / launch / wait / done…)、`continue`、`status`;最终 `done` 帧带 `comparison_results`(源 + 各目标平台到手价,按价升序)。 - -## 错误码 -- `400` body 不是合法 JSON -- `502` pricebot 上游不可达(网络错误)或返回 5xx - -## 说明 -把请求体原样转发到 `PRICEBOT_BASE_URL` 的 `/api/price/step`(去掉 `/v1`,async httpx)。**多轮循环**:客户端按返回的 `action` 操作手机、再上报下一帧,直到 `continue=false`。真正的目标驱动比价逻辑(多目标平台串行复现订单、读到手价、聚合排序)在 **pricebot-backend**,本接口只是"透传壳"。 - -⚠️ **MVP 阶段不鉴权**(同 `coupon/step`)。 - -**相关配置**: -- `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`;生产部署应与 pricebot-backend 同内网——比价一单 30~80 步、逐帧多一跳,走公网延迟会累积) -- `PRICEBOT_COMPARE_TIMEOUT_SEC`(默认 60s,price/step 每帧都是 LLM) diff --git a/docs/api/compare-milestone-claim.md b/docs/api/compare/compare-milestone-claim.md similarity index 96% rename from docs/api/compare-milestone-claim.md rename to docs/api/compare/compare-milestone-claim.md index 97f2fd3..eae8f5e 100644 --- a/docs/api/compare-milestone-claim.md +++ b/docs/api/compare/compare-milestone-claim.md @@ -1,6 +1,6 @@ # POST /api/v1/compare/milestones/{milestone}/claim — 领取比价战绩里程碑奖励 -> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) +> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) 领取某一档(第 `milestone` 次)。⚠️ **当前不真发金币**(产品定,后续整体删除该功能):仍写 `comparison_milestone_claim`((user_id, milestone) 唯一)标记该档已领、**每档只能领一次**,但不调 diff --git a/docs/api/compare-milestones.md b/docs/api/compare/compare-milestones.md similarity index 97% rename from docs/api/compare-milestones.md rename to docs/api/compare/compare-milestones.md index 7933913..e8953e0 100644 --- a/docs/api/compare-milestones.md +++ b/docs/api/compare/compare-milestones.md @@ -1,6 +1,6 @@ # GET /api/v1/compare/milestones — 比价战绩里程碑进度 -> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) +> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) 福利页「记录比价战绩」的数据源。返回各档(第 1~6 次)解锁/领取状态。 diff --git a/docs/api/compare-record-detail.md b/docs/api/compare/compare-record-detail.md similarity index 95% rename from docs/api/compare-record-detail.md rename to docs/api/compare/compare-record-detail.md index eed1bcd..44c7075 100644 --- a/docs/api/compare-record-detail.md +++ b/docs/api/compare/compare-record-detail.md @@ -1,6 +1,6 @@ # GET /api/v1/compare/records/{record_id} — 比价记录详情 -> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) +> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) 单条比价记录详情,在列表项基础上额外带 `raw_payload`(客户端上报的原始全量),供未来 UI 展示任意细节。 diff --git a/docs/api/compare-record-report.md b/docs/api/compare/compare-record-report.md similarity index 80% rename from docs/api/compare-record-report.md rename to docs/api/compare/compare-record-report.md index 21085a4..1e7f7fa 100644 --- a/docs/api/compare-record-report.md +++ b/docs/api/compare/compare-record-report.md @@ -1,11 +1,11 @@ # POST /api/v1/compare/record — 上报一次比价结果(幂等) -> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) +> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) -比价 `done` 帧后,客户端用**带 JWT 的通道**上报一条比价结果,落 `comparison_record` 表,作为「我的比价记录」数据源 + 用户级行为画像。 +比价 `done` 帧后上报一条比价结果,落 `comparison_record` 表,作为「我的比价记录」数据源 + 用户级行为画像。 -> ⚠️ 与不鉴权的透传端点 [`/api/v1/price/step`](./compare-price-step.md) 不同:那是转发壳,本接口按用户维度落库,**必须鉴权**。 -> 本轮只做 server 端;客户端在 done 帧后调本接口的改动另起一轮(见 [待办与技术债.md](../guides/待办与技术债.md) P1)。 +> ⚠️ **灰度定位(2026-07)**:写 `comparison_record` 现以透传壳 `compare.py` 的**后端 harvest** 为主(帧0 建 `running` 行 → done/finalize 落终态,新客户端不再 POST)。本接口降级为**老客户端兼容**通道,与 harvest 按 `trace_id` reconcile(**success 不被降级**);新版覆盖够高后可下线本 POST。 +> 与软鉴权透传端点 [`/api/v1/price/step`](./compare-price-step.md) 不同:那是转发壳(顺带 harvest 落库),本接口按用户维度显式上报,**必须鉴权**。 ## 入参(JSON body) @@ -13,7 +13,7 @@ | 字段 | 类型 | 必填 | 默认 | 说明 | |---|---|---|---|---| -| `trace_id` | string | ✅ | — | pricebot 侧 trace_id。**幂等键**:同用户同 trace_id 重复上报覆盖、返回同一 id | +| `trace_id` | string | ✅ | — | 一次比价的唯一标识(app-server 帧0 签发)。**幂等键(trace_id 单列唯一)**:同 trace_id 重复上报覆盖、返回同一 id;与 harvest 行按它 reconcile | | `business_type` | string | ❌ | `food` | `food`(外卖,当前唯一接通) / `ecom`(电商) / `coupon`(领券) | | `device_id` | string \| null | ❌ | null | 设备号 | | `store_name` | string \| null | ❌ | null | 店铺名(外卖,来自 calibration.result) | diff --git a/docs/api/compare-records.md b/docs/api/compare/compare-records.md similarity index 97% rename from docs/api/compare-records.md rename to docs/api/compare/compare-records.md index 70d3889..b1051bf 100644 --- a/docs/api/compare-records.md +++ b/docs/api/compare/compare-records.md @@ -1,6 +1,6 @@ # GET /api/v1/compare/records — 比价记录列表(游标分页) -> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) +> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) 「我的比价记录」列表页数据源。按 `id` 倒序(最新在前)。 diff --git a/docs/api/compare-stats.md b/docs/api/compare/compare-stats.md similarity index 96% rename from docs/api/compare-stats.md rename to docs/api/compare/compare-stats.md index 454f5a7..89eeee0 100644 --- a/docs/api/compare-stats.md +++ b/docs/api/compare/compare-stats.md @@ -1,6 +1,6 @@ # GET /api/v1/compare/stats — 比价口径战绩(「我的」页省钱战绩卡) -> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) +> 所属:比价记录组(前缀 `/api/v1/compare`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) ## 入参 无(用户由 token 确定)。 diff --git a/docs/api/coupon/coupon-completed-today.md b/docs/api/coupon/coupon-completed-today.md new file mode 100644 index 0000000..98183d5 --- /dev/null +++ b/docs/api/coupon/coupon-completed-today.md @@ -0,0 +1,67 @@ +# 今日领券完成状态(completed-today 族) + +> 所属:Coupon 组(前缀 `/api/v1/coupon`) | 鉴权:无(按 device_id 判断) | [← 返回 API 索引](../README.md) + +--- + +## GET /completed-today — 今天是否已完成整轮领券 + +判断这台设备今天是否跑到 done 帧。已完成 → 首页「去领取」卡置灰。 + +### 入参(query) + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `device_id` | string | ✅ | 设备 ID(需与领券循环上报的一致) | + +Mock 请求: +``` +GET /api/v1/coupon/completed-today?device_id=android_abc123def456 +``` + +### 出参 + +响应 `200`:`CouponCompletedTodayOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `completed` | bool | 今天是否已跑完整轮 | + +Mock 出参: +```json +{"completed": true} +``` + +--- + +## POST /completed-today/reset — 重置今日已完成 + +删这台设备今天的 completion → `has_completed_today` 变 false,首页「去领取」卡恢复可点。开发设置用。 + +### 入参 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `device_id` | string | ✅ | 设备 ID | +| `package` | string | ❌ | App 包名(默认 "") | +| `user_id` | int \| null | ❌ | 登录用户 ID | + +Mock 入参: +```json +{ + "device_id": "android_abc123def456" +} +``` + +### 出参 + +```json +{"ok": true} +``` + +--- + +## 说明 +- 判断维度 `device_id`(客户端两端都用 ANDROID_ID) +- 用户决策 A 方案:到 done 即算完成,不管单券成败 +- MVP 不鉴权 diff --git a/docs/api/coupon/coupon-prompt.md b/docs/api/coupon/coupon-prompt.md new file mode 100644 index 0000000..7adcb17 --- /dev/null +++ b/docs/api/coupon/coupon-prompt.md @@ -0,0 +1,128 @@ +# 领券引导窗频控(prompt 族) + +> 所属:Coupon 组(前缀 `/api/v1/coupon`) | 鉴权:无(按 device_id 判断,MVP 阶段) | [← 返回 API 索引](../README.md) + +领券引导窗频控:今天这台设备**这个 App** 已弹过/领过/拒过 → 不再弹。各 App 独立(美团弹过不压淘宝/京东)。 + +--- + +## GET /prompt/should-show — 是否还应弹引导窗 + +客户端切到外卖 App 时查。 + +### 入参(query) + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `device_id` | string | ✅ | 设备 ID | +| `package` | string | ❌ | App 包名(默认 "",老客户端兼容全局态) | + +Mock 请求: +``` +GET /api/v1/coupon/prompt/should-show?device_id=android_abc&package=com.sankuai.meituan +``` + +### 出参 + +响应 `200`:`CouponPromptShouldShowOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `should_show` | bool | 今天是否还应弹引导窗 | + +Mock 出参: +```json +{"should_show": true} +``` + +--- + +## POST /prompt/shown — 引导窗弹出即上报 + +客户端弹出引导窗那刻调 → 记一条今日 engagement(shown),今天这个 App 不再自动弹。频控主判据管跨重装。 + +### 入参 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `device_id` | string | ✅ | 设备 ID | +| `package` | string | ✅ | App 包名 | +| `user_id` | int \| null | ❌ | 登录用户 ID | + +Mock 入参: +```json +{ + "device_id": "android_abc123def456", + "package": "com.sankuai.meituan", + "user_id": 42 +} +``` + +### 出参 + +```json +{"ok": true} +``` + +--- + +## POST /prompt/dismiss — 用户拒绝/关闭引导窗 + +客户端点关闭时调 → 记 dismissed,今天这个 App 不再弹。 + +### 入参 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `device_id` | string | ✅ | 设备 ID | +| `package` | string | ❌ | App 包名(默认 "") | +| `user_id` | int \| null | ❌ | 登录用户 ID | + +Mock 入参: +```json +{ + "device_id": "android_abc123def456", + "package": "com.sankuai.meituan" +} +``` + +### 出参 + +```json +{"ok": true} +``` + +--- + +## POST /prompt/reset — 重置今日引导窗状态 + +删这台设备今天的 engagement → 今天又能弹。开发测频控用。 + +### 入参 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `device_id` | string | ✅ | 设备 ID | +| `package` | string | ❌ | App 包名(默认 "") | +| `user_id` | int \| null | ❌ | 登录用户 ID | + +Mock 入参: +```json +{ + "device_id": "android_abc123def456" +} +``` + +### 出参 + +```json +{"ok": true} +``` + +--- + +## 说明 +- 频控按 `(device, package, 日)`,各 App 独立 +- 弹出即占用今天一次(管跨重装),后续领取/拒绝再升级 type +- user_id 可选,登录态带上就一并记(资产留痕) +- MVP 不鉴权 diff --git a/docs/api/coupon/coupon-session.md b/docs/api/coupon/coupon-session.md new file mode 100644 index 0000000..7a27168 --- /dev/null +++ b/docs/api/coupon/coupon-session.md @@ -0,0 +1,75 @@ +# POST /api/v1/coupon/session — 领券任务流水上报 + +> 所属:Coupon 组(前缀 `/api/v1/coupon`) | 鉴权:无(按 device_id/trace_id 区分) | [← 返回 API 索引](../README.md) + +客户端两段上报一次领券流水(发起 / 收尾),按 `trace_id` upsert 到 `coupon_session`。供 admin「领券数据」看板算发起/完成数、耗时分位、机型维度。 + +## 入参 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `trace_id` | string | ✅ | 领券 trace 标识(同领券循环 step 的 trace_id) | +| `device_id` | string | ✅ | 设备 ID(与领券循环一致) | +| `status` | string | ✅ | `started` / `completed` / `failed` / `abandoned` | +| `started_at_ms` | int | ✅ | 发起墙钟毫秒(客户端 `System.currentTimeMillis`) | +| `user_id` | int \| null | ❌ | 登录用户 ID(未登录可空) | +| `platforms` | list[string] \| null | ❌ | 勾选的平台列表 | +| `origin_package` | string \| null | ❌ | 发起领券的 App 包名 | +| `device_model` | string \| null | ❌ | 机型(如 `24115RA8EC`) | +| `rom` | string \| null | ❌ | ROM 信息(如 `MIUI 14.0.8`) | +| `app_env` | string \| null | ❌ | 应用环境(`prod` / `test`) | +| `elapsed_ms` | int \| null | ❌ | 全程耗时(ms),收尾帧必带 | +| `platform_elapsed` | dict \| null | ❌ | 各平台耗时(如 `{"meituan": 3200, "jd": 2800}`),收尾帧带 | +| `claimed_count` | int \| null | ❌ | 本场实际领到的券张数 | +| `trace_url` | string \| null | ❌ | trace 云端 URL(done 帧带) | + +Mock 入参(started): +```json +{ + "trace_id": "tr_20260703_a1b2c3d4", + "device_id": "android_abc123def456", + "status": "started", + "started_at_ms": 1719993600000, + "user_id": 42, + "platforms": ["meituan", "jd"], + "origin_package": "com.sankuai.meituan", + "device_model": "24115RA8EC", + "rom": "MIUI 14.0.8", + "app_env": "prod" +} +``` + +Mock 入参(completed): +```json +{ + "trace_id": "tr_20260703_a1b2c3d4", + "device_id": "android_abc123def456", + "status": "completed", + "started_at_ms": 1719993600000, + "user_id": 42, + "platforms": ["meituan", "jd"], + "origin_package": "com.sankuai.meituan", + "device_model": "24115RA8EC", + "rom": "MIUI 14.0.8", + "app_env": "prod", + "elapsed_ms": 12500, + "platform_elapsed": {"meituan": 5200, "jd": 4800}, + "claimed_count": 3, + "trace_url": "https://trace.shaguabijia.com/tr_20260703_a1b2c3d4" +} +``` + +## 出参 + +响应 `200`: +```json +{"ok": true} +``` + +## 错误码 +- `422` 必填字段缺失或类型不符 + +## 说明 +- 不鉴权(同领券循环 MVP,按 `device_id`/`trace_id`) +- 写库失败不连累客户端(fire-and-forget,吞掉返回 ok) +- 一次领券两段上报:发起(started)→ 收尾(completed/failed/abandoned) diff --git a/docs/api/coupon/coupon-stats.md b/docs/api/coupon/coupon-stats.md new file mode 100644 index 0000000..cdace56 --- /dev/null +++ b/docs/api/coupon/coupon-stats.md @@ -0,0 +1,33 @@ +# GET /api/v1/coupon/stats — 累计领券数 + +> 所属:Coupon 组(前缀 `/api/v1/coupon`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) + +「我的」页战绩卡「领取优惠券 X 张」数据源。该登录用户累计领到的券数(`SUM(claimed_count)`,与领券完成时给用户看的「本次领了 N 张」同源)。 + +**鉴权(CurrentUser)** — 区别于同文件不鉴权的 `/step` 透传与 `/prompt` 频控:个人战绩按 `user_id` 聚合,必须有登录态。 + +## 入参 + +无(`user_id` 从 JWT 取)。 + +## 出参 + +响应 `200`:`CouponStatsOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `coupon_count` | int | 累计领到的券张数 | + +Mock 出参: +```json +{ + "coupon_count": 42 +} +``` + +## 错误码 +- `401` 未鉴权 / token 失效 + +## 说明 +- 口径:`SUM(claimed_count)` — 各成功领券记录 pricebot 展示张数之和 +- 只算登录用户,按 `user_id` 聚合(非 device_id) diff --git a/docs/api/coupon-step.md b/docs/api/coupon/coupon-step.md similarity index 97% rename from docs/api/coupon-step.md rename to docs/api/coupon/coupon-step.md index 809183d..74442a0 100644 --- a/docs/api/coupon-step.md +++ b/docs/api/coupon/coupon-step.md @@ -1,6 +1,6 @@ # POST /api/v1/coupon/step — 一键领券任务步进(透传到 pricebot) -> 所属:Coupon 组(前缀 `/api/v1/coupon`) | 鉴权:Bearer access_token(客户端契约;Server MVP 阶段不强校验) | [← 返回 API 索引](./README.md) +> 所属:Coupon 组(前缀 `/api/v1/coupon`) | 鉴权:Bearer access_token(客户端契约;Server MVP 阶段不强校验) | [← 返回 API 索引](../README.md) ## 入参 任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id`、`trace_id`、`step` 用于日志。 diff --git a/docs/api/device-liveness.md b/docs/api/device/device-liveness.md similarity index 98% rename from docs/api/device-liveness.md rename to docs/api/device/device-liveness.md index 6021b5d..e0c34d5 100644 --- a/docs/api/device-liveness.md +++ b/docs/api/device/device-liveness.md @@ -1,6 +1,6 @@ # 设备 / 无障碍存活监控(device 族) -> 所属:device 组(前缀 `/api/v1/device`,源 `app/api/v1/device.py`) | 鉴权:**全部 Bearer**(设备绑登录用户) | [← 返回 API 索引](./README.md) +> 所属:device 组(前缀 `/api/v1/device`,源 `app/api/v1/device.py`) | 鉴权:**全部 Bearer**(设备绑登录用户) | [← 返回 API 索引](../README.md) > > 落库:[`device_liveness`](../database/device_liveness.md) 表;后台 `heartbeat_monitor_worker` 据此检出心跳超时的设备并召回。设计见 spec `accessibility-liveness-push.md`(推送版)+ `accessibility-liveness-pull-prompt.md`(后置 pull 提醒版)。#65 新增。 diff --git a/docs/api/compare-intent-recognize.md b/docs/api/intent/compare-intent-recognize.md similarity index 54% rename from docs/api/compare-intent-recognize.md rename to docs/api/intent/compare-intent-recognize.md index 51cb30e..dc7a2b0 100644 --- a/docs/api/compare-intent-recognize.md +++ b/docs/api/intent/compare-intent-recognize.md @@ -1,13 +1,13 @@ -# POST /api/v1/intent/recognize — 外卖比价 Phase 1 意图识别(透传到 pricebot) +# POST /api/v1/intent/recognize — 外卖比价 Phase 1 意图识别(透传 + 首帧 harvest 建行) -> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**无(MVP 阶段不鉴权)** | [← 返回 API 索引](./README.md) +> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**软鉴权 OptionalUser**(带 JWT 则绑 `user_id`,不带也放行) | [← 返回 API 索引](../README.md) ## 入参 -任意 JSON body,**不做 schema 校验**,原样透传给上游。后端仅从中读 `device_id`、`trace_id`、`step` 用于日志。 +任意 JSON body,**不做 schema 校验**,原样透传给上游。后端从中读 `device_id`、`trace_id`、`step`、`device_info` 用于日志与落库。 客户端实际传源平台购物车页的无障碍树采集结果(pricebot 协议里的 `screens`:`cart_page_1` / `cart_page_2`)。 ## 出参 -pricebot-backend 的响应**原样返回**(JSON object)。典型含 `result`(店名)、`calibration`(含 `source_platform_id` / `items` / `price`),客户端在 `step=0` 把它透传进 `/price/step`。 +pricebot-backend 的响应**原样返回**(JSON object),并在顶层补 `trace_id`。典型含 `result`(店名)、`calibration`(含 `source_platform_id` / `items` / `price`),客户端在 `step=0` 把它透传进 `/price/step`。 ## 错误码 - `400` body 不是合法 JSON @@ -18,7 +18,7 @@ pricebot-backend 的响应**原样返回**(JSON object)。典型含 `result` 外卖比价由客户端无障碍引擎在源平台(淘宝闪购 / 美团 / 京东外卖)购物车页点悬浮球触发 → 调本接口拿 `query` + `calibration` → 进入 `/price/step` 循环。 -⚠️ **MVP 阶段不鉴权**(同 `coupon/step`):`device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 行为暂绑不到登录用户。待补 JWT,见 [待办与技术债.md](../guides/待办与技术债.md) P1。 +**trace_id 签发 + harvest 建行(2026-07 起,`compare.py`)**:客户端首帧可不带 `trace_id`——app-server 用 uuid 签发、注入转发 body、回填响应顶层;**仅签发那帧**按 `trace_id` 建 [comparison_record](../../database/comparison_record.md) 的 `running` 行(幂等,best-effort),done / finalize 帧再补终态。**软鉴权**:带 Bearer 则记录绑 `user_id`,匿名行 `user_id` 暂空、由后续 `/compare/record` 上报补。 **相关配置**: - `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`) diff --git a/docs/api/intent/compare-price-step.md b/docs/api/intent/compare-price-step.md new file mode 100644 index 0000000..370aec2 --- /dev/null +++ b/docs/api/intent/compare-price-step.md @@ -0,0 +1,27 @@ +# POST /api/v1/price/step — 外卖比价 Phase 2 步进(透传 + done 帧 harvest 落库) + +> 所属:Compare 组(前缀 `/api/v1`,外卖比价) | 鉴权:**软鉴权 OptionalUser**(带 JWT 则绑 `user_id`,不带也放行) | [← 返回 API 索引](../README.md) + +## 入参 +任意 JSON body,**不做 schema 校验**,原样透传给上游。后端从中读 `device_id`、`trace_id`、`step`、`device_info` 用于日志与落库。 +客户端逐帧上报 `screen_state` + 上一步 `action_result`;`step=0` 还带 `query` + `calibration`(来自 Phase 1)。 + +## 出参 +pricebot-backend 的响应**原样返回**(JSON object),并在顶层补 `trace_id`(见下「trace_id 签发」)。含 `action`(tap / set_text / launch / wait / done…)、`continue`、`status`、每帧顶层 `trace_url`;最终 `done` 帧带 `comparison_results`(源 + 各目标平台到手价,按价升序)。 + +## 错误码 +- `400` body 不是合法 JSON +- `502` pricebot 上游不可达(网络错误)或返回 5xx + +## 说明 +把请求体原样转发到 `PRICEBOT_BASE_URL` 的 `/api/price/step`(去掉 `/v1`,共享 httpx 单例)。**多轮循环**:客户端按返回的 `action` 操作手机、再上报下一帧,直到 `continue=false`。真正的目标驱动比价逻辑(多目标平台串行复现订单、读到手价、聚合排序)在 **pricebot-backend**。 + +**不再是纯透传壳(2026-07 起,`compare.py`)**: +- **trace_id 签发**:客户端首帧可不带 `trace_id`——app-server 用 uuid 签发、注入转发 body、回填进响应顶层 `trace_id`,客户端后续帧都带它(老客户端自带则原样用)。 +- **harvest 落库**([comparison_record](../../database/comparison_record.md)):首帧(mint 时)建 `running` 行 → **done 帧** `harvest_done` 写 `success`/`failed` + 派生 `best_*`/`saved_amount_cents`。写库 best-effort(threadpool 独立 session),失败不连累透传。 +- **软鉴权**:新客户端带 Bearer → 记录绑 `user_id`;老客户端/匿名 → `user_id` 暂空,由其后续 `POST /compare/record` 上报补(灰度期两条写路径按 `trace_id` reconcile,success 不降级)。 +- 邀请发奖**不在这里**:#113 起口径为好友「比价并下单」,发放在 `POST /order/report`。 + +**相关配置**: +- `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`;生产部署应与 pricebot-backend 同内网——比价一单 30~80 步、逐帧多一跳,走公网延迟会累积) +- `PRICEBOT_COMPARE_TIMEOUT_SEC`(默认 60s,price/step 每帧都是 LLM) diff --git a/docs/api/intent/intent-step.md b/docs/api/intent/intent-step.md new file mode 100644 index 0000000..f651178 --- /dev/null +++ b/docs/api/intent/intent-step.md @@ -0,0 +1,113 @@ +# 比价意图多帧步进(intent/step + intent/precoupon/step) + +> 所属:Intent 组(前缀 `/api/v1`,外卖比价透传) | 鉴权:无(MVP 阶段不鉴权) | [← 返回 API 索引](../README.md) + +透传到 pricebot-backend。请求体原样转发、不做 schema 校验。 + +--- + +## POST /intent/step — Phase 1 多帧意图识别(淘宝源) + +淘宝源走多帧版意图识别(展开+滚动采集→提取):循环调用直到 done(done 帧顶层带 `result` + `calibration`)。其它源走单次 `/intent/recognize`。 + +### 入参 + +透传 pricebot,客户端按 pricebot 协议组装。关键字段: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `device_id` | string | 设备 ID | +| `trace_id` | string | 比价 trace 标识 | +| `frame` | int | 帧序号(0 起始) | +| `continue` | bool | 是否继续(pricebot 回传,客户端 next 调用时带上帧) | +| `image` | string | 当前帧截图 base64 | +| `platform` | string | 源平台 | +| `package` | string | 目标平台包名 | +| *(透传)* | | 其余字段由 pricebot 定义,本端点不做校验 | + +Mock 入参(首帧): +```json +{ + "device_id": "android_abc123def456", + "trace_id": "tr_20260703_e5f6g7h8", + "frame": 0, + "continue": true, + "image": "/9j/4AAQSkZJRgABAQEAYABgAAD/...(base64)", + "platform": "taobao", + "package": "com.sankuai.meituan" +} +``` + +### 出参 + +透传 pricebot 原始响应。通常包含 `action.command`(`continue` / `done`)、`action.params` 等。 + +Mock 出参: +```json +{ + "trace_id": "tr_20260703_e5f6g7h8", + "frame": 0, + "continue": true, + "action": { + "command": "continue", + "params": { + "next_frame": 1, + "scroll_distance": 300 + } + } +} +``` + +--- + +## POST /intent/precoupon/step — Phase 0 意图识别前先用券(美团源) + +美团源平台「意图识别前先用券」多帧循环:客户端在调 `/intent/recognize` 之前先循环调本端点到 done(`continue=false`)。订单页底部有「点击使用X红包」就自动选最大免费券用上,已用券/无券则首帧秒过。与 `/intent/step` 同属 intent 域,复用同一透传壳。 + +### 入参 + +同 `/intent/step`,透传 pricebot。 + +Mock 入参(首帧): +```json +{ + "device_id": "android_abc123def456", + "trace_id": "tr_20260703_i9j0k1l2", + "frame": 0, + "continue": true, + "image": "/9j/4AAQSkZJRgABAQEAYABgAAD/...(base64)", + "platform": "meituan", + "package": "com.sankuai.meituan" +} +``` + +### 出参 + +透传 pricebot 原始响应。 + +Mock 出参(无券首帧秒过): +```json +{ + "trace_id": "tr_20260703_i9j0k1l2", + "frame": 0, + "continue": false, + "action": { + "command": "done", + "params": { + "coupon_used": false, + "reason": "no_coupon_available" + } + } +} +``` + +--- + +## 错误码 +- `502` pricebot 不可达或返回 5xx +- `400` 请求体不是合法 JSON + +## 说明 +- 两个端点是同域透传,区别是 pricebot 后端路由不同(`/api/intent/step` vs `/api/intent/precoupon/step`) +- 一致性 hash 按 `trace_id` 路由到同一 pricebot 实例 +- MVP 阶段不鉴权(待补 JWT) diff --git a/docs/api/internal.md b/docs/api/internal/internal.md similarity index 63% rename from docs/api/internal.md rename to docs/api/internal/internal.md index 8c0af60..d335d2f 100644 --- a/docs/api/internal.md +++ b/docs/api/internal/internal.md @@ -1,6 +1,6 @@ # 内部回写端点(internal 族,pricebot / 发布流程 → app-server) -> 所属:internal 组(前缀 `/internal`,源 `app/api/internal/`) | 鉴权:**`X-Internal-Secret` 头**(== `settings.INTERNAL_API_SECRET`) | [← 返回 API 索引](./README.md) +> 所属:internal 组(前缀 `/internal`,源 `app/api/internal/`) | 鉴权:**`X-Internal-Secret` 头**(== `settings.INTERNAL_API_SECRET`) | [← 返回 API 索引](../README.md) **不是给客户端的接口**:不走用户 JWT。`§6` 是 app-server 透传给 pricebot,这里反过来——pricebot(及发布流程)把少量数据 server→server 回写 app-server 落库。 @@ -14,12 +14,13 @@ | 方法 + 路径 | 落库 | 说明 | |---|---|---| -| `POST /internal/price-observation` | [`price_observation`](../database/price_observation.md) | 比价 done 帧整批价格事实上报;`(trace_id, platform, scope)` 幂等,返回 `{inserted, skipped}` | -| `GET /internal/store-mapping/lookup` | (只读 [`store_mapping`](../database/store_mapping.md)) | 比价前按 `source_platform`+`name`(+`lat`/`lng`)反查各目标平台已沉淀的店铺 id/deeplink;命中→pricebot 直接 deeplink 省现场搜店 | +| `POST /internal/price-observation` | [`price_observation`](../../database/price_observation.md) | 比价 done 帧整批价格事实上报;`(trace_id, platform, scope)` 幂等,返回 `{inserted, skipped}` | +| `GET /internal/store-mapping/lookup` | (只读 [`store_mapping`](../../database/store_mapping.md)) | 比价前按 `source_platform`+`name`(+`lat`/`lng`)反查各目标平台已沉淀的店铺 id/deeplink;命中→pricebot 直接 deeplink 省现场搜店 | | `POST /internal/store-mapping` | `store_mapping` | 跨平台「同一家店」身份映射上报;`trace_id` 幂等、填空合并,返回 `{inserted, row_id}` | | `POST /internal/store-mapping/invalidate` | `store_mapping` | 标记某平台 `shop_id` 的缓存 deeplink 失效(pricebot 撞错误页回退时报);当前支持 `taobao`/`jd`,其它平台 no-op,返回 `{ok, affected}` | -| `POST /internal/launch-confirm-sample` | [`launch_confirm_sample`](../database/launch_confirm_sample.md) | 启动确认窗 LLM 兜底放行后回写样本(host 包 + 弹窗树 + plan + locale);**都上报、不去重**,返回 `{id}` | -| `POST /internal/app-version` | [`app_config`](../database/app_config.md)(key=`latest_app_version`) | **发布流程**(非 pricebot)出 APK 后写最新版本号/下载链接/sha256;客户端再 `GET /api/v1/platform/app-version` 读做 OTA。也是应急改版本信息(紧急下线/改 `apk_url`)入口 | +| `POST /internal/launch-confirm-sample` | [`launch_confirm_sample`](../../database/launch_confirm_sample.md) | 启动确认窗 LLM 兜底放行后回写样本(host 包 + 弹窗树 + plan + locale);**都上报、不去重**,返回 `{id}` | +| `GET /internal/launch-confirm-samples` | (只读 `launch_confirm_sample`) | 样本列表(#91,供 pricebot `distill_launch_confirm.py` 聚合沉淀回静态规则);可选筛 `exec_success` / `host_package` / `since_days`,`limit` 默认 1000 | +| `POST /internal/app-version` | [`app_config`](../../database/app_config.md)(key=`latest_app_version`) | **发布流程**(非 pricebot)出 APK 后写最新版本号/下载链接/sha256;客户端再 `GET /api/v1/platform/app-version` 读做 OTA。也是应急改版本信息(紧急下线/改 `apk_url`)入口 | ## 错误 - `401` 密钥不匹配 / 缺失。 diff --git a/docs/api/invite/invite-bind.md b/docs/api/invite/invite-bind.md new file mode 100644 index 0000000..cc93ba7 --- /dev/null +++ b/docs/api/invite/invite-bind.md @@ -0,0 +1,100 @@ +# 邀请绑定(bind + landing-track) + +> 所属:Invite 组(前缀 `/api/v1/invite`) | 鉴权:bind 需 Bearer / landing-track 无需鉴权 | [← 返回 API 索引](../README.md) + +--- + +## POST /bind — 绑定邀请人 + +把当前登录用户(被邀请人)绑定到某邀请码。支持三种归因路径:clipboard(首启读剪贴板)、manual(手动输入邀请码)、fingerprint(指纹兜底反查)。**#113 起绑定只建关系、不发奖**——发奖后置到被邀请人「比价并实际下单」(`POST /order/report` 触发,给邀请人发**邀请奖励金**,`compare_reward_granted` 幂等闸一人一次)。 + +### 入参 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `invite_code` | string \| null | ❌ | 邀请码;走指纹兜底时为空 | +| `channel` | string | ✅ | 归因来源:`clipboard` / `manual` / `fingerprint` | +| `fingerprint` | object \| null | ❌ | 指纹兜底时必传 | +| `fingerprint.screen` | string | - | 屏幕分辨率(如 `1080x2400`) | +| `fingerprint.device_model` | string | - | 设备型号(如 `24115RA8EC`) | + +Mock 入参(clipboard 归因): +```json +{ + "invite_code": "A3F8K2", + "channel": "clipboard", + "fingerprint": null +} +``` + +Mock 入参(指纹兜底): +```json +{ + "invite_code": null, + "channel": "fingerprint", + "fingerprint": { + "screen": "1080x2400", + "device_model": "24115RA8EC" + } +} +``` + +### 出参 + +响应 `200`:`BindInviteOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `status` | string | `success` / `already_bound` / `invalid_code` / `self_invite` / `not_eligible` / `fp_not_found` | +| `coins_awarded` | int | 兼容保留字段(#113 前"绑定即发金币"口径)。**#113 起新绑定恒 0**,前端不应再据此展示发奖 | +| `message` | string | 给前端直接展示的文案 | + +Mock 出参: +```json +{ + "status": "success", + "coins_awarded": 0, + "message": "邀请绑定成功" +} +``` + +--- + +## POST /landing-track — 落地页指纹采集 + +B 浏览器打开 `dl.html?ref=xxx` 时上报指纹(剪贴板归因失败时兜底)。**无需鉴权**(浏览器没 token)。 + +### 入参 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `ref` | string | ✅(4-16 位) | 邀请码(落地页 `?ref=`) | +| `screen` | string | ❌ | 屏幕分辨率(如 `1080x2400`) | + +Mock 入参: +```json +{ + "ref": "A3F8K2", + "screen": "1080x2400" +} +``` + +### 出参 + +响应 `200`:`LandingTrackOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `status` | string | `ok` / `invalid_code` / `no_ip` | + +Mock 出参: +```json +{"status": "ok"} +``` + +--- + +## 说明 +- `bind`: 绑定只在注册后首次有效(`not_eligible` = 已过新人期),自邀屏蔽 +- `landing-track`: IP/UA 服务端从 HTTP 头自动拿,JS 无需上报 +- 指纹反查窗口期由 `INVITE_FP_WINDOW_DAYS` 控制 diff --git a/docs/api/invite/invite-invitees.md b/docs/api/invite/invite-invitees.md new file mode 100644 index 0000000..f402f0b --- /dev/null +++ b/docs/api/invite/invite-invitees.md @@ -0,0 +1,63 @@ +# GET /api/v1/invite/invitees — 我邀请的人列表 + +> 所属:Invite 组(前缀 `/api/v1/invite`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) + +分页查询当前用户成功邀请的人列表。邀请页小窗(取前几条)+ 完整列表页(分页加载)共用。 + +## 入参(query) + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `limit` | int | ❌ | 每页条数(1–50,默认 20) | +| `offset` | int | ❌ | 偏移量(≥0,默认 0) | + +Mock 请求: +``` +GET /api/v1/invite/invitees?limit=5&offset=0 +``` + +## 出参 + +响应 `200`:`InviteeListOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `items` | list[InviteeItem] | 被邀请人列表 | +| `items[].display_name` | string | 显示名(昵称 → 微信昵称 → 脱敏手机号,后端已兜底) | +| `items[].avatar_url` | string \| null | 头像 URL;null = 前端画默认色块 | +| `items[].coins` | int | 这次邀请给邀请人发的金币(**历史留痕**:#113 前旧口径的发放额;新绑定恒 0) | +| `items[].is_compared` | bool | 该好友是否已完成过一次比价(#113:好友列表据此分「邀请成功 / 去提醒」,在途列表只取 `false` 的) | +| `items[].invited_at` | datetime | 邀请绑定时间(ISO 8601 UTC) | +| `total` | int | 我邀请的总人数 | +| `has_more` | bool | 还有下一页吗 | + +Mock 出参: +```json +{ + "items": [ + { + "display_name": "省钱小王", + "avatar_url": "/media/avatars/u2_f1e2d3c4b5a60708.jpg", + "coins": 0, + "is_compared": true, + "invited_at": "2026-06-28T14:30:00Z" + }, + { + "display_name": "138****1234", + "avatar_url": null, + "coins": 0, + "is_compared": false, + "invited_at": "2026-07-01T09:15:00Z" + } + ], + "total": 5, + "has_more": false +} +``` + +## 错误码 +- `401` 未鉴权 / token 失效 + +## 说明 +- 名字/头像降级兜底在后端算好:昵称 → 微信昵称 → 脱敏手机号 +- `limit` 钳到 [1, 50],`offset` 钳到 ≥0 diff --git a/docs/api/invite/invite-me.md b/docs/api/invite/invite-me.md new file mode 100644 index 0000000..167c021 --- /dev/null +++ b/docs/api/invite/invite-me.md @@ -0,0 +1,48 @@ +# GET /api/v1/invite/me — 我的邀请信息 + +> 所属:Invite 组(前缀 `/api/v1/invite`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) + +获取当前用户的邀请码、分享链接、已邀人数、累计获得金币/奖励金,以及 7 天一轮的倒计时信息。 + +## 入参 + +无(`user_id` 从 JWT 取)。 + +## 出参 + +响应 `200`:`InviteInfoOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `invite_code` | string | 我的邀请码(6-8 位) | +| `share_url` | string | 落地页链接(含 `?ref=`),前端据此生成二维码 + 复制分享 | +| `invited_count` | int | 已成功邀请人数 | +| `coins_earned` | int | 累计从邀请获得的金币(v1 口径) | +| `reward_balance_cents` | int | v2 可提现邀请奖励金(分) | +| `reward_withdrawn_cents` | int | v2 累计提现成功的邀请奖励金(分) | +| `countdown_days_left` | int | v2 本轮剩余天数(7 天 1 轮) | +| `countdown_is_fresh_round` | bool | 是否刚进入新一轮(非首轮第 1 天) | +| `countdown_text` | string | 倒计时展示文案(前端直接显示,新轮含换行) | + +Mock 出参: +```json +{ + "invite_code": "A3F8K2", + "share_url": "https://app.shaguabijia.com/dl.html?ref=A3F8K2", + "invited_count": 5, + "coins_earned": 50000, + "reward_balance_cents": 3200, + "reward_withdrawn_cents": 1800, + "countdown_days_left": 4, + "countdown_is_fresh_round": false, + "countdown_text": "还剩 4 天" +} +``` + +## 错误码 +- `401` 未鉴权 / token 失效 + +## 说明 +- 首次调用自动生成邀请码(幂等) +- v2 奖励金与现金隔离(`reward_balance_cents` 独立于 `cash_balance_cents`) +- 7 天 1 轮倒计时:新用户从注册日起算 diff --git a/docs/api/meituan-top-sales.md b/docs/api/meituan-top-sales.md deleted file mode 100644 index 3f91f66..0000000 --- a/docs/api/meituan-top-sales.md +++ /dev/null @@ -1,23 +0,0 @@ -# POST /api/v1/meituan/top-sales — 销量最高(离线库) - -> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md) -> -> 数据来自离线库 [database/meituan_coupon](../database/meituan_coupon.md);**不实时打美团**(美团搜索对销量排序支持差、且有 402 限流)。 - -## 入参 -| 字段 | 类型 | 必填 | 默认 | 说明 | -|---|---|---|---|---| -| `page` | int | ❌ | 1 | ≥1 | -| `page_size` | int | ❌ | 20 | 1–50 | -| `platform` | int \| null | ❌ | null | 1 只外卖 / 2 只到店 / 不填=全部(全城销量) | - -## 出参 -响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: null, status: "ok"|"empty"|"degraded" }`。`CouponCard` 见 [API 索引](./README.md#复用数据结构);`status` 语义见 [feed 接口](./meituan-feed.md#status-字段前端据此显示占位)。 - -## 说明 -- 从 `meituan_coupon` 取 `sale_volume_num` 非空的券,`DISTINCT ON(dedup_key)` 跨源去重(每个「品牌|名|价」只留销量最高一条,同销量再按佣金),按销量降序分页;每页只对当前 ~20 条做 `from_raw` 解析(翻页快,不全表拉取)。 -- **不依赖 MT 凭证**(纯库查询)。库为空(prod 刚部署 / ETL 未跑完)→ `status=empty`;库查询异常 → `status=degraded`。均返 `200`、不抛 5xx。 -- **仅 PostgreSQL**(`DISTINCT ON` 为 PG 专用)。 - -## 错误码 -无业务级错误码:库空 / 异常都返 `200` + 空 `items` + 对应 `status`。 diff --git a/docs/api/meituan-coupons.md b/docs/api/meituan/meituan-coupons.md similarity index 96% rename from docs/api/meituan-coupons.md rename to docs/api/meituan/meituan-coupons.md index c38966b..1d3be3d 100644 --- a/docs/api/meituan-coupons.md +++ b/docs/api/meituan/meituan-coupons.md @@ -1,6 +1,6 @@ # POST /api/v1/meituan/coupons — 券列表 / 搜索 -> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md) +> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](../README.md) > > 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑)。 diff --git a/docs/api/meituan-feed.md b/docs/api/meituan/meituan-feed.md similarity index 98% rename from docs/api/meituan-feed.md rename to docs/api/meituan/meituan-feed.md index 9377ab8..5fd974f 100644 --- a/docs/api/meituan-feed.md +++ b/docs/api/meituan/meituan-feed.md @@ -1,6 +1,6 @@ # POST /api/v1/meituan/feed — 首页推荐流(多 tab) -> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md) +> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](../README.md) > > 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑);离线库见 [database/meituan_coupon](../database/meituan_coupon.md)。 diff --git a/docs/api/meituan-referral-link.md b/docs/api/meituan/meituan-referral-link.md similarity index 96% rename from docs/api/meituan-referral-link.md rename to docs/api/meituan/meituan-referral-link.md index 289b73a..b0d0ff3 100644 --- a/docs/api/meituan-referral-link.md +++ b/docs/api/meituan/meituan-referral-link.md @@ -1,6 +1,6 @@ # POST /api/v1/meituan/referral-link — 换取推广链接 -> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md) +> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](../README.md) > > 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑)。 diff --git a/docs/api/meituan/meituan-top-sales.md b/docs/api/meituan/meituan-top-sales.md new file mode 100644 index 0000000..08a6c65 --- /dev/null +++ b/docs/api/meituan/meituan-top-sales.md @@ -0,0 +1,24 @@ +# POST /api/v1/meituan/top-sales — 销量最高(离线库) + +> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](../README.md) +> +> 数据来自离线库 [database/meituan_coupon](../../database/meituan_coupon.md);**不实时打美团**(美团搜索对销量排序支持差、且有 402 限流)。 + +## 入参 +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `page` | int | ❌ | 1 | ≥1 | +| `page_size` | int | ❌ | 20 | 1–50 | +| `platform` | int \| null | ❌ | null | 1 只外卖 / 2 只到店 / 不填=全部 | +| `longitude` / `latitude` | float \| null | ❌(实际必带) | null | 设备坐标(#116):服务端离线反查城市(`utils/geo` + `meituan_city`)→ **只返回同城券**;老客户端不带坐标 → 返空 + `status=degraded`(不 422、不误返全城) | + +## 出参 +响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: null, status: "ok"|"empty"|"degraded" }`。`CouponCard` 见 [API 索引](../README.md#复用数据结构);`status` 语义见 [feed 接口](./meituan-feed.md#status-字段前端据此显示占位)。 + +## 说明 +- 从 `meituan_coupon` 取 `sale_volume_num` 非空 **且 `city_id` = 反查城市** 的券(#116,同城销量榜),`DISTINCT ON(dedup_key)` 跨源去重(每个「品牌|名|价」只留销量最高一条,同销量再按佣金),按销量降序分页;每页只对当前 ~20 条做 `from_raw` 解析(翻页快,不全表拉取)。 +- **不依赖 MT 凭证**(纯库查询)。库为空(prod 刚部署 / ETL 未跑完)→ `status=empty`;库查询异常 → `status=degraded`。均返 `200`、不抛 5xx。 +- **仅 PostgreSQL**(`DISTINCT ON` 为 PG 专用)。 + +## 错误码 +无业务级错误码:库空 / 异常都返 `200` + 空 `items` + 对应 `status`。 diff --git a/docs/api/other/analytics-events.md b/docs/api/other/analytics-events.md new file mode 100644 index 0000000..76dc7de --- /dev/null +++ b/docs/api/other/analytics-events.md @@ -0,0 +1,82 @@ +# POST /api/v1/analytics/events — 批量上报埋点事件 + +> 所属:Analytics 组(前缀 `/api/v1/analytics`) | 鉴权:无(不强制登录,未登录态也要采集行为) | [← 返回 API 索引](../README.md) + +批量接收新手引导(及后续)埋点,append 落 `analytics_event` 表。`user_id` 由客户端在 body 可选带上,不靠 Bearer。服务端补 `client_ip`(X-Forwarded-For)与 `server_at`(接收时间)。 + +## 入参 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `device_id` | string | ✅(≤64) | 设备 ID | +| `user_id` | int \| null | ❌ | 登录用户 ID(未登录可空) | +| `sent_at` | int \| null | ❌ | 批次发送时间(epoch ms) | +| `oem` | string \| null | ❌ | 厂商(如 `Xiaomi`) | +| `os` | string \| null | ❌ | 操作系统(如 `Android 14`) | +| `model` | string \| null | ❌ | 机型(如 `24115RA8EC`) | +| `app_ver` | string \| null | ❌ | App 版本 | +| `channel` | string \| null | ❌ | 渠道 | +| `events` | list[object] | ✅(1-200 条) | 事件数组 | +| `events[].event` | string | ✅(≤64) | 事件名(如 `onboarding_start`) | +| `events[].client_ts` | int | ✅ | 端事件发生时间(epoch ms) | +| `events[].session_id` | string \| null | ❌ | 会话 ID | +| `events[].page` | string \| null | ❌ | 页面标识 | +| `events[].network` | string \| null | ❌ | 网络类型(如 `wifi`) | +| `events[].props` | dict | ❌ | 事件属性(key-value) | + +Mock 入参: +```json +{ + "device_id": "android_abc123def456", + "user_id": 42, + "sent_at": 1719993700000, + "oem": "Xiaomi", + "os": "Android 14", + "model": "24115RA8EC", + "app_ver": "0.1.5", + "channel": "official", + "events": [ + { + "event": "onboarding_start", + "client_ts": 1719993600000, + "session_id": "sess_a1b2c3", + "page": "onboarding", + "network": "wifi", + "props": {"step": "1", "source": "fresh_install"} + }, + { + "event": "onboarding_step_complete", + "client_ts": 1719993615000, + "session_id": "sess_a1b2c3", + "page": "onboarding", + "network": "wifi", + "props": {"step": "1", "duration_ms": "15000"} + } + ] +} +``` + +## 出参 + +响应 `200`:`AnalyticsIngestOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `ok` | bool | 固定 `true` | +| `received` | int | 成功写入的条数 | + +Mock 出参: +```json +{ + "ok": true, + "received": 2 +} +``` + +## 错误码 +- `422` `events` 为空或超过 200 条 / 字段类型不符 + +## 说明 +- `user_id` 不靠 JWT:未登录态也要采集行为(新手引导可能在登录前) +- 每批最多 200 条,建议客户端攒到一定量再批量上报 +- `client_ts` 是端侧时间(客户端时钟),`server_at` 由服务端补(可靠时间轴) diff --git a/docs/api/cps-redirect.md b/docs/api/other/cps-redirect.md similarity index 97% rename from docs/api/cps-redirect.md rename to docs/api/other/cps-redirect.md index bcb659b..ee00719 100644 --- a/docs/api/cps-redirect.md +++ b/docs/api/other/cps-redirect.md @@ -1,6 +1,6 @@ # CPS 群发短链落地(cps-redirect 族) -> 所属:cps-redirect 组(**无前缀**,挂域名根;源 `app/api/v1/cps_redirect.py`) | 鉴权:**公网无鉴权**(群里任何人点都要能跳/能领) | [← 返回 API 索引](./README.md) +> 所属:cps-redirect 组(**无前缀**,挂域名根;源 `app/api/v1/cps_redirect.py`) | 鉴权:**公网无鉴权**(群里任何人点都要能跳/能领) | [← 返回 API 索引](../README.md) > > 落库:点击落 [`cps_click`](../database/cps_click.md)、微信落地页用户落 [`cps_wx_user`](../database/cps_wx_user.md);短链由 [`cps_link`](../database/cps_link.md) 解析。 diff --git a/docs/api/other/feedback-config.md b/docs/api/other/feedback-config.md new file mode 100644 index 0000000..77e733f --- /dev/null +++ b/docs/api/other/feedback-config.md @@ -0,0 +1,40 @@ +# GET /api/v1/feedback/config — 反馈页二维码卡配置 + +> 所属:Feedback 组(前缀 `/api/v1/feedback`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) + +运营后台配的反馈页「加群二维码」卡配置(开关 + 二维码图 + 三行文案)。客户端进反馈页时拉取,据此渲染整张「加群二维码」卡。 + +## 入参 + +无(`user_id` 从 JWT 取)。 + +## 出参 + +响应 `200`:`FeedbackQrConfigOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `enabled` | bool | 是否展示加群二维码卡 | +| `image_url` | string \| null | 二维码图片 URL(相对 `/media` 路径);空 → 客户端走本地兜底 | +| `title` | string | 卡片标题(如 `加入用户反馈群`) | +| `group_name` | string | 群名称(如 `傻瓜比价用户群`) | +| `subtitle` | string | 副标题/说明文案(如 `扫码加入,你的声音我们听得见`) | + +Mock 出参: +```json +{ + "enabled": true, + "image_url": "/media/feedback_qr/qr_default.png", + "title": "加入用户反馈群", + "group_name": "傻瓜比价用户群", + "subtitle": "扫码加入,你的声音我们听得见" +} +``` + +## 错误码 +- `401` 未鉴权 / token 失效 + +## 说明 +- `image_url` 是相对路径,客户端按自己的 `BASE_URL` 拼绝对地址 +- `enabled=false` 时客户端隐藏整张卡 +- 配置在 admin 后台 `feedback_qr` 表维护 diff --git a/docs/api/other/feedback-records.md b/docs/api/other/feedback-records.md new file mode 100644 index 0000000..6f15e6f --- /dev/null +++ b/docs/api/other/feedback-records.md @@ -0,0 +1,71 @@ +# GET /api/v1/feedback/records — 我的反馈历史 + +> 所属:Feedback 组(前缀 `/api/v1/feedback`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) + +查询当前用户提交的反馈历史,支持按状态筛选。 + +## 入参(query) + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `status` | string \| null | ❌ | 筛选状态:`pending` / `adopted` / `rejected`;不传 = 全部 | + +Mock 请求: +``` +GET /api/v1/feedback/records?status=adopted +``` + +## 出参 + +响应 `200`:`FeedbackRecordsOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `records` | list[FeedbackRecordOut] | 反馈记录列表 | +| `records[].id` | int | 反馈 ID | +| `records[].content` | string | 反馈正文 | +| `records[].scene` | string \| null | 问题场景(比价结果页反馈时带,如 `找错商品`) | +| `records[].images` | list[string] | 截图 URL 列表 | +| `records[].status` | string | `pending` / `adopted` / `rejected` | +| `records[].reject_reason` | string \| null | 驳回原因 | +| `records[].reward_coins` | int \| null | 采纳后发的金币数 | +| `records[].admin_reply` | string \| null | 管理员回复 | +| `records[].created_at` | datetime | 提交时间 | +| `counts` | object | 三态计数(不受 status 筛选影响) | +| `counts.all` | int | 总数 | +| `counts.pending` | int | 待处理 | +| `counts.adopted` | int | 已采纳 | +| `counts.rejected` | int | 已驳回 | + +Mock 出参: +```json +{ + "records": [ + { + "id": 56, + "content": "比价结果显示美团 28.5 元,但实际下单时涨到了 32 元", + "scene": "价格不一致", + "images": ["/media/feedback/u42_f1e2d3c4b5a60708.jpg"], + "status": "adopted", + "reject_reason": null, + "reward_coins": 500, + "admin_reply": "感谢反馈,已核实并修复", + "created_at": "2026-07-02T15:20:00Z" + } + ], + "counts": { + "all": 3, + "pending": 1, + "adopted": 2, + "rejected": 0 + } +} +``` + +## 错误码 +- `400` 无效的 `status` 值(仅 `pending`/`adopted`/`rejected` 合法) +- `401` 未鉴权 + +## 说明 +- `counts` 始终基于全量(不受 `status` 筛选影响),供前端筛选 chip +- `scene` 仅比价结果页反馈时带(区分普通反馈 vs 比价场景反馈) diff --git a/docs/api/feedback.md b/docs/api/other/feedback.md similarity index 96% rename from docs/api/feedback.md rename to docs/api/other/feedback.md index e59e157..8473aef 100644 --- a/docs/api/feedback.md +++ b/docs/api/other/feedback.md @@ -1,6 +1,6 @@ # POST /api/v1/feedback — 提交反馈 -> 所属:Feedback 组(前缀 `/api/v1/feedback`) | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md) +> 所属:Feedback 组(前缀 `/api/v1/feedback`) | 鉴权:Bearer access_token | [← 返回 API 索引](../README.md) ## 入参 **multipart/form-data**: diff --git a/docs/api/health.md b/docs/api/other/health.md similarity index 65% rename from docs/api/health.md rename to docs/api/other/health.md index 26d4e61..58d9a43 100644 --- a/docs/api/health.md +++ b/docs/api/other/health.md @@ -1,6 +1,6 @@ # GET /health — 健康检查 -> 所属:Meta | 鉴权:无 | [← 返回 API 索引](./README.md) +> 所属:Meta | 鉴权:无 | [← 返回 API 索引](../README.md) ## 入参 无 diff --git a/docs/api/other/order-report.md b/docs/api/other/order-report.md new file mode 100644 index 0000000..47e70e8 --- /dev/null +++ b/docs/api/other/order-report.md @@ -0,0 +1,74 @@ +# POST /api/v1/order/report — 上报归因订单 + +> 所属:Order 组(前缀 `/api/v1/order`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) + +比价后 5 分钟内点链接下单、支付金额与比价价相差 ≤1 元时,客户端上报归因订单。落 `savings_record`(`source='compare'`),客户端幂等键防重。 + +## 入参 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `client_event_id` | string | ✅(≤64) | 客户端幂等键(UUID) | +| `platform` | string | ✅(≤32) | 平台展示名(如 `美团`) | +| `platform_package` | string \| null | ❌ | 平台包名 | +| `pay_channel` | string | ✅(≤16) | 支付渠道(`wechat` / `alipay`) | +| `compared_price_cents` | int | ✅(≥0) | 我们给出的比价价(分) | +| `paid_amount_cents` | int | ✅(≥0) | 实际支付金额(分) | +| `device_id` | string \| null | ❌ | 设备 ID | +| `shop_name` | string \| null | ❌ | 门店名(如 `肯德基宅急送(天北路店)`) | +| `dishes` | list[string] | ❌ | 菜品名列表 | +| `original_price_cents` | int \| null | ❌ | 源平台原价(分),省额 = 原价 − 实付 | +| `source_platform_name` | string \| null | ❌ | 源平台展示名(如 `美团`) | +| `source_deeplink` | string \| null | ❌ | 源平台重进链接(预留,本期只存不展示) | + +Mock 入参: +```json +{ + "client_event_id": "550e8400-e29b-41d4-a716-446655440000", + "platform": "美团", + "platform_package": "com.sankuai.meituan", + "pay_channel": "wechat", + "compared_price_cents": 2850, + "paid_amount_cents": 2800, + "device_id": "android_abc123def456", + "shop_name": "肯德基宅急送(天北路店)", + "dishes": ["香辣鸡腿堡套餐", "可口可乐(中)"], + "original_price_cents": 4200, + "source_platform_name": "美团", + "source_deeplink": null +} +``` + +## 出参 + +响应 `200`:`OrderReportOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | int | 省钱记录 ID | +| `platform` | string | 平台 | +| `pay_channel` | string | 支付渠道 | +| `compared_price_cents` | int | 比价价(分) | +| `paid_amount_cents` | int | 实付金额(分) | +| `duplicated` | bool | 是否为重复上报(幂等命中) | + +Mock 出参: +```json +{ + "id": 1234, + "platform": "美团", + "pay_channel": "wechat", + "compared_price_cents": 2850, + "paid_amount_cents": 2800, + "duplicated": false +} +``` + +## 错误码 +- `401` 未鉴权 / token 失效 +- `422` 必填字段缺失或类型不符 + +## 说明 +- 记账唯一真相表是 `savings_record`(`source='compare'`) +- `client_event_id` 幂等防重(网络重试不重复记) +- 省额 = `original_price_cents − paid_amount_cents`(若原价可用) diff --git a/docs/api/other/report-records.md b/docs/api/other/report-records.md new file mode 100644 index 0000000..b50bc91 --- /dev/null +++ b/docs/api/other/report-records.md @@ -0,0 +1,81 @@ +# GET /api/v1/report/records — 上报更低价记录列表 + +> 所属:Report 组(前缀 `/api/v1/report`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) + +当前用户的上报更低价记录列表,支持按状态筛选。 + +## 入参(query) + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `status` | string \| null | ❌ | 筛选状态:`pending` / `approved` / `rejected`;不传 = 全部 | + +Mock 请求: +``` +GET /api/v1/report/records?status=pending +``` + +## 出参 + +响应 `200`:`ReportRecordsOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `records` | list[ReportRecordOut] | 上报记录列表 | +| `records[].id` | int | 记录 ID | +| `records[].store_name` | string \| null | 门店名 | +| `records[].dish_summary` | string \| null | 菜品摘要(如 `香辣鸡腿堡套餐、可口可乐`) | +| `records[].original_platform_id` | string \| null | 原最低价来源平台 ID | +| `records[].original_platform_name` | string \| null | 原最低价来源平台名 | +| `records[].original_price_cents` | int \| null | 原最低价(分) | +| `records[].reported_platform_id` | string | 上报平台标识 | +| `records[].reported_platform_name` | string | 上报平台名(如 `京东外卖`) | +| `records[].reported_price_cents` | int | 上报更低价(分) | +| `records[].images` | list[string] | 截图 URL 列表 | +| `records[].status` | string | `pending` / `approved` / `rejected` | +| `records[].reject_reason` | string \| null | 驳回原因(rejected 时) | +| `records[].reward_coins` | int \| null | 通过后发的金币数 | +| `records[].created_at` | datetime | 提交时间 | +| `counts` | object | 四态计数(不受 status 筛选影响,供前端 chip 展示) | +| `counts.all` | int | 总数 | +| `counts.pending` | int | 审核中 | +| `counts.approved` | int | 已通过 | +| `counts.rejected` | int | 未通过 | + +Mock 出参: +```json +{ + "records": [ + { + "id": 89, + "store_name": "肯德基宅急送(天北路店)", + "dish_summary": "香辣鸡腿堡套餐、可口可乐(中)", + "original_platform_id": "meituan-waimai", + "original_platform_name": "美团外卖", + "original_price_cents": 2850, + "reported_platform_id": "jd-waimai", + "reported_platform_name": "京东外卖", + "reported_price_cents": 1880, + "images": ["/media/price_report/u42_a1b2c3d4e5f6g7h8.jpg"], + "status": "pending", + "reject_reason": null, + "reward_coins": null, + "created_at": "2026-07-03T10:30:00Z" + } + ], + "counts": { + "all": 3, + "pending": 1, + "approved": 1, + "rejected": 1 + } +} +``` + +## 错误码 +- `400` 无效的 `status` 值(仅 `pending`/`approved`/`rejected` 合法) +- `401` 未鉴权 + +## 说明 +- `counts` 始终基于全量(不受 `status` 筛选影响),供前端筛选 chip 显示各状态数量 +- 价格单位均为分(`*_cents`),客户端 ÷100 显示元 diff --git a/docs/api/other/report-submit.md b/docs/api/other/report-submit.md new file mode 100644 index 0000000..48417de --- /dev/null +++ b/docs/api/other/report-submit.md @@ -0,0 +1,57 @@ +# POST /api/v1/report — 提交上报更低价 + +> 所属:Report 组(前缀 `/api/v1/report`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) + +众包纠偏:用户发现比价记录中某平台有更低价格时提交上报。需附截图证明,原最低价由 `comparison_record_id` 反查(不信任客户端传的快照),提交价必须 < 原最低价。提交后 `status=pending`,人工审核通过后发奖。 + +## 入参 + +**multipart/form-data**: + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `comparison_record_id` | int | ✅ | 比价记录 ID | +| `reported_platform_id` | string | ✅ | 上报平台标识:`meituan-waimai` / `jd-waimai` / `taobao-shanguang` | +| `reported_price` | string | ✅ | 用户填的更低价(元,如 `23.5`) | +| `images` | file[] | ✅(1–4 张) | 截图证明 | + +Mock 入参(curl 示例): +```bash +curl -X POST https://app-api.shaguabijia.com/api/v1/report \ + -H "Authorization: Bearer " \ + -F "comparison_record_id=5678" \ + -F "reported_platform_id=jd-waimai" \ + -F "reported_price=18.8" \ + -F "images=@screenshot1.png" \ + -F "images=@screenshot2.png" +``` + +## 出参 + +响应 `200`:`ReportSubmitOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | int | 上报记录 ID | +| `status` | string | 固定 `pending`(待审核) | +| `created_at` | datetime | 提交时间(ISO 8601 UTC) | + +Mock 出参: +```json +{ + "id": 89, + "status": "pending", + "created_at": "2026-07-03T10:30:00Z" +} +``` + +## 错误码 +- `400` 价格不合法(≤0 / 格式错)/ 上报价 ≥ 原最低价 / 图片问题(空/超 4 张/格式不对)/ 不支持的上报平台 +- `401` 未鉴权 +- `404` 比价记录不存在或不属于当前用户 + +## 说明 +- 原最低价由 `comparison_record_id` → `ComparisonRecord.best_price_cents` 反查 +- 校验(D):`reported_price_cents < original_price_cents`,否则 400 +- 截图落盘 `MEDIA_ROOT/price_report/`,文件名随机防覆盖 +- 发奖走人工审核(admin 后台操作),不在此端点 diff --git a/docs/api/other/trace-finalize.md b/docs/api/other/trace-finalize.md new file mode 100644 index 0000000..42607a1 --- /dev/null +++ b/docs/api/other/trace-finalize.md @@ -0,0 +1,59 @@ +# POST /api/v1/trace/finalize + /trace/epilogue — 比价 trace 收尾族 + +> 所属:透传端点(前缀 `/api/v1`,外卖比价) | 鉴权:软鉴权 OptionalUser | [← 返回 API 索引](../README.md) + +## POST /api/v1/trace/finalize — 收尾上云(+夭折落库) + +透传到 pricebot-backend。用户终止 / Phase 1 未识别没走到 done 帧时,pricebot 没上云也没回传 `trace_url`。客户端收尾时打这个,pricebot 按 `trace_id` 一致性 hash 落到处理这条 trace 的同一进程(dir_cache 在那才能算对 trace 目录),打包上云返回 `{trace_url}`。 + +**顺手夭折落库(2026-07 起)**:app-server 把该 trace 的 [comparison_record](../../database/comparison_record.md) `running` 行更新成夭折终态(`harvest_abort`,**不降级已 success**)。body 可带 `status`(`cancelled`/`failed`)+ `reason`;老客户端只带 `trace_id` → 默认 `cancelled`,其后续 `/compare/record` 上报再补精确态。 + +## 入参 + +透传 pricebot,客户端按 pricebot 协议组装。关键字段: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `device_id` | string | 设备 ID | +| `trace_id` | string | 比价 trace 标识 | +| *(透传)* | | 其余字段由 pricebot 定义,本端点不做校验 | + +Mock 入参: +```json +{ + "device_id": "android_abc123def456", + "trace_id": "tr_20260703_m3n4o5p6", + "reason": "user_cancelled" +} +``` + +## 出参 + +透传 pricebot 原始响应,通常包含 `trace_url`。 + +Mock 出参: +```json +{ + "trace_url": "https://trace.shaguabijia.com/tr_20260703_m3n4o5p6", + "ok": true +} +``` + +## 错误码 +- `502` pricebot 不可达或返回 5xx +- `400` 请求体不是合法 JSON + +## 说明 +- 一致性 hash 按 `trace_id` 路由到同一 pricebot 实例(确保 dir_cache 命中) +- 软鉴权(OptionalUser,同比价透传族) +- 与 `/intent/recognize`、`/price/step` 等同属外卖比价透传族 + +--- + +## POST /api/v1/trace/epilogue — 结果页尾声帧(#112) + +App 收到 done、渲染完**结果页**后,把自己页面的截图(base64,body ~几百 KB)传给 pricebot 存进 trace 目录并触发重传——trace 里补上「用户实际看到的汇总页」(步骤帧只有目标 App 画面)。 + +- **纯透传壳**:不建行、不落库(该 trace 的比价记录已由 done / finalize 落终态)。 +- 入参:`{device_id, trace_id, screenshot(base64), ...}`(pricebot 协议);出参:pricebot 原样响应。 +- 错误码同 finalize(`400` / `502`)。 diff --git a/docs/api/platform/platform-ad-config.md b/docs/api/platform/platform-ad-config.md new file mode 100644 index 0000000..fbf0f42 --- /dev/null +++ b/docs/api/platform/platform-ad-config.md @@ -0,0 +1,43 @@ +# GET /api/v1/platform/ad-config — 客户端广告配置 + +> 所属:Platform 组(前缀 `/api/v1/platform`) | 鉴权:无 | [← 返回 API 索引](../README.md) + +客户端启动 / 每场广告前拉取,缓存后用:穿山甲 `app_id` + 各位 ID + 各场景开关。不含验签密钥(密钥只在后端验 S2S 回调用)。 + +## 入参 + +无。 + +## 出参 + +响应 `200`:`AdConfigPublicOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `app_id` | string | 穿山甲应用 ID(改了需冷启才生效,SDK init 一次性读) | +| `reward_code_id` | string | 福利页激励视频位 | +| `compare_draw_code_id` | string | 比价 Draw 代码位 | +| `coupon_draw_code_id` | string | 领券 Draw 代码位(与比价共用同一位,靠 `feed_scene` 区分收益) | +| `reward_enabled` | bool | 福利激励视频开关 | +| `compare_ad_enabled` | bool | 比价广告开关 | +| `coupon_ad_enabled` | bool | 领券广告开关 | +| `withdrawal_ad_enabled` | bool | 提现激励视频开关(关 = 客户端直接放行提现) | + +Mock 出参: +```json +{ + "app_id": "5123456", + "reward_code_id": "104001", + "compare_draw_code_id": "104002", + "coupon_draw_code_id": "104002", + "reward_enabled": true, + "compare_ad_enabled": true, + "coupon_ad_enabled": true, + "withdrawal_ad_enabled": false +} +``` + +## 说明 +- 空库回退默认值(= 客户端内置值,维持现状) +- 不含验签密钥(`m-key`),安全边界 +- `coupon_draw_code_id` 与 `compare_draw_code_id` 通常同值,客户端按场景调不同端点区分 diff --git a/docs/api/platform/platform-app-version.md b/docs/api/platform/platform-app-version.md new file mode 100644 index 0000000..9e63f2b --- /dev/null +++ b/docs/api/platform/platform-app-version.md @@ -0,0 +1,39 @@ +# GET /api/v1/platform/app-version — 最新 App 版本 + +> 所属:Platform 组(前缀 `/api/v1/platform`) | 鉴权:无 | [← 返回 API 索引](../README.md) + +客户端启动 / 手动检查更新时拉取。用 `latest_version_code` 与本机 `versionCode` 比;未配置(返回默认 0)时客户端视为已是最新。 + +## 入参 + +无。 + +## 出参 + +响应 `200`:`AppVersionOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `latest_version_code` | int | 最新版 versionCode;0 = 未配置(无更新) | +| `latest_version_name` | string | 展示用版本号(如 `0.1.4`) | +| `apk_url` | string | 下载链接(发车产出的永久版本化链接) | +| `update_note` | string | 更新说明,弹窗展示(如「修复了部分机型闪退问题」) | +| `min_supported_version_code` | int | 本机低于此版本 = 强制更新;0 = 不强更(全可选) | +| `apk_size_bytes` | int | 包大小(字节),展示「约 x MB」 | + +Mock 出参: +```json +{ + "latest_version_code": 42, + "latest_version_name": "0.1.5", + "apk_url": "https://cdn.shaguabijia.com/releases/app-v0.1.5.apk", + "update_note": "1. 修复了部分机型闪退问题\n2. 优化了比价速度\n3. 新增省钱大作战功能", + "min_supported_version_code": 35, + "apk_size_bytes": 18350080 +} +``` + +## 说明 +- 不鉴权(版本信息非敏感,检查更新可能在登录前) +- `latest_version_code = 0` → 无更新,客户端无需提示 +- `min_supported_version_code > 本机 versionCode` → 强制更新弹窗(不可跳过) diff --git a/docs/api/platform/platform-flags.md b/docs/api/platform/platform-flags.md new file mode 100644 index 0000000..f4997d3 --- /dev/null +++ b/docs/api/platform/platform-flags.md @@ -0,0 +1,28 @@ +# GET /api/v1/platform/flags — 客户端 Feature Flags + +> 所属:Platform 组(前缀 `/api/v1/platform`) | 鉴权:无 | [← 返回 API 索引](../README.md) + +客户端拉取运营开关并缓存(app 启动 / 每场比价开始时刷新)。不鉴权:开关非敏感,且比价无障碍服务取值时未必有登录态。值来自 `app_config`(admin 可改),空库回退默认。 + +## 入参 + +无。 + +## 出参 + +响应 `200`:`AppFlagsOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `comparing_ad_enabled` | bool | 比价/领券期是否展示信息流广告(远程 kill-switch) | + +Mock 出参: +```json +{ + "comparing_ad_enabled": true +} +``` + +## 说明 +- 客户端拉取后本地缓存,避免每次请求 +- 空库回退默认值(false) diff --git a/docs/api/platform-stats.md b/docs/api/platform/platform-stats.md similarity index 99% rename from docs/api/platform-stats.md rename to docs/api/platform/platform-stats.md index 9ddaef6..b95f793 100644 --- a/docs/api/platform-stats.md +++ b/docs/api/platform/platform-stats.md @@ -1,6 +1,6 @@ # GET /api/v1/platform/stats — 首页三统计(全平台门面数字) -> 所属:Platform 组(前缀 `/api/v1/platform`) | 鉴权:**无**(登录前首页也要展示) | [← 返回 API 索引](./README.md) +> 所属:Platform 组(前缀 `/api/v1/platform`) | 鉴权:**无**(登录前首页也要展示) | [← 返回 API 索引](../README.md) 客户端首页顶部「帮助用户 / 完成比价 / 累计节省」三个平台级数字。每个指标的展示模式由运营后台配(见 [admin-dashboard-display](./admin-dashboard-display.md)),本接口只返回算好的结果值。 diff --git a/docs/api/platform-savings-feed.md b/docs/api/savings/platform-savings-feed.md similarity index 98% rename from docs/api/platform-savings-feed.md rename to docs/api/savings/platform-savings-feed.md index 511e8c4..fc1d93d 100644 --- a/docs/api/platform-savings-feed.md +++ b/docs/api/savings/platform-savings-feed.md @@ -1,6 +1,6 @@ # GET /api/v1/platform/savings-feed — 首页轮播 feed(真实+种子混播) -> 所属:Platform 组(前缀 `/api/v1/platform`) | 鉴权:**无** | [← 返回 API 索引](./README.md) +> 所属:Platform 组(前缀 `/api/v1/platform`) | 鉴权:**无** | [← 返回 API 索引](../README.md) 客户端首页顶部「某用户 比价后节省 xx 元」滚动条数据源。全平台真实比价记录优先;不足时用运营配的种子([ops_marquee_seed](../database/ops_marquee_seed.md))补齐到 `limit` 条「混播」,保证轮播不空。 diff --git a/docs/api/savings-battle.md b/docs/api/savings/savings-battle.md similarity index 96% rename from docs/api/savings-battle.md rename to docs/api/savings/savings-battle.md index 1911b99..be19bbb 100644 --- a/docs/api/savings-battle.md +++ b/docs/api/savings/savings-battle.md @@ -1,6 +1,6 @@ # GET /api/v1/savings/battle — 省钱战绩 -> 所属:Savings 组(前缀 `/api/v1/savings`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) +> 所属:Savings 组(前缀 `/api/v1/savings`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) ## 入参 无(用户由 token 确定)。 diff --git a/docs/api/savings-records.md b/docs/api/savings/savings-records.md similarity index 95% rename from docs/api/savings-records.md rename to docs/api/savings/savings-records.md index 31fcf6c..6e43fdb 100644 --- a/docs/api/savings-records.md +++ b/docs/api/savings/savings-records.md @@ -1,6 +1,6 @@ # GET /api/v1/savings/records — 省钱明细(游标分页) -> 所属:Savings 组(前缀 `/api/v1/savings`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) +> 所属:Savings 组(前缀 `/api/v1/savings`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) ## 入参(query) diff --git a/docs/api/savings-summary.md b/docs/api/savings/savings-summary.md similarity index 92% rename from docs/api/savings-summary.md rename to docs/api/savings/savings-summary.md index 3082a28..6d8f21a 100644 --- a/docs/api/savings-summary.md +++ b/docs/api/savings/savings-summary.md @@ -1,6 +1,6 @@ # GET /api/v1/savings/summary — 累计帮你省了 -> 所属:Savings 组(前缀 `/api/v1/savings`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) +> 所属:Savings 组(前缀 `/api/v1/savings`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) ## 入参 无(用户由 token 确定)。 diff --git a/docs/api/signin-boost.md b/docs/api/signin/signin-boost.md similarity index 100% rename from docs/api/signin-boost.md rename to docs/api/signin/signin-boost.md diff --git a/docs/api/signin-do.md b/docs/api/signin/signin-do.md similarity index 90% rename from docs/api/signin-do.md rename to docs/api/signin/signin-do.md index 3bb6504..b484082 100644 --- a/docs/api/signin-do.md +++ b/docs/api/signin/signin-do.md @@ -1,6 +1,6 @@ # POST /api/v1/signin — 执行今日签到 -> 所属:Signin 组(前缀 `/api/v1/signin`,本接口 POST 到前缀本身) | 鉴权:Bearer | [← 返回 API 索引](./README.md) +> 所属:Signin 组(前缀 `/api/v1/signin`,本接口 POST 到前缀本身) | 鉴权:Bearer | [← 返回 API 索引](../README.md) ## 入参 无(用户由 token 确定)。 diff --git a/docs/api/signin-status.md b/docs/api/signin/signin-status.md similarity index 95% rename from docs/api/signin-status.md rename to docs/api/signin/signin-status.md index 541d6c8..23e5498 100644 --- a/docs/api/signin-status.md +++ b/docs/api/signin/signin-status.md @@ -1,6 +1,6 @@ # GET /api/v1/signin/status — 今日签到状态 + 14 天档位 -> 所属:Signin 组(前缀 `/api/v1/signin`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) +> 所属:Signin 组(前缀 `/api/v1/signin`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) ## 入参 无(用户由 token 确定)。 diff --git a/docs/api/tasks-claim.md b/docs/api/tasks/tasks-claim.md similarity index 96% rename from docs/api/tasks-claim.md rename to docs/api/tasks/tasks-claim.md index 5ef1f2c..00f489b 100644 --- a/docs/api/tasks-claim.md +++ b/docs/api/tasks/tasks-claim.md @@ -1,6 +1,6 @@ # POST /api/v1/tasks/{task_key}/claim — 领取任务奖励 -> 所属:Tasks 组(前缀 `/api/v1/tasks`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) +> 所属:Tasks 组(前缀 `/api/v1/tasks`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) ## 入参(path) diff --git a/docs/api/tasks-list.md b/docs/api/tasks/tasks-list.md similarity index 94% rename from docs/api/tasks-list.md rename to docs/api/tasks/tasks-list.md index d826f1d..e921d9d 100644 --- a/docs/api/tasks-list.md +++ b/docs/api/tasks/tasks-list.md @@ -1,6 +1,6 @@ # GET /api/v1/tasks — 任务及领取状态 -> 所属:Tasks 组(前缀 `/api/v1/tasks`,本接口 GET 前缀本身) | 鉴权:Bearer | [← 返回 API 索引](./README.md) +> 所属:Tasks 组(前缀 `/api/v1/tasks`,本接口 GET 前缀本身) | 鉴权:Bearer | [← 返回 API 索引](../README.md) ## 入参 无(用户由 token 确定)。 diff --git a/docs/api/user-avatar.md b/docs/api/user/user-avatar.md similarity index 96% rename from docs/api/user-avatar.md rename to docs/api/user/user-avatar.md index 21d0cc9..9fc6b2b 100644 --- a/docs/api/user-avatar.md +++ b/docs/api/user/user-avatar.md @@ -1,6 +1,6 @@ # POST /api/v1/user/avatar — 上传头像 -> 所属:User 组(前缀 `/api/v1/user`) | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md) +> 所属:User 组(前缀 `/api/v1/user`) | 鉴权:Bearer access_token | [← 返回 API 索引](../README.md) ## 入参 **multipart/form-data**: diff --git a/docs/api/user-delete.md b/docs/api/user/user-delete.md similarity index 95% rename from docs/api/user-delete.md rename to docs/api/user/user-delete.md index 611918b..c6162d7 100644 --- a/docs/api/user-delete.md +++ b/docs/api/user/user-delete.md @@ -1,6 +1,6 @@ # DELETE /api/v1/user — 注销账号 -> 所属:User 组(前缀 `/api/v1/user`) | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md) +> 所属:User 组(前缀 `/api/v1/user`) | 鉴权:Bearer access_token | [← 返回 API 索引](../README.md) ## 入参 无(身份取自 Header token) diff --git a/docs/api/user/user-onboarding.md b/docs/api/user/user-onboarding.md new file mode 100644 index 0000000..2adaac4 --- /dev/null +++ b/docs/api/user/user-onboarding.md @@ -0,0 +1,84 @@ +# 新手引导状态(onboarding 族) + +> 所属:User 组(前缀 `/api/v1/user`) | 鉴权:全部 Bearer | [← 返回 API 索引](../README.md) + +按(账号, 设备)维度跟踪新手引导完成状态,跨卸载重装持久。运营可在 admin 删记录触发重走。 + +--- + +## GET /onboarding/status — 查新手引导是否已完成 + +已登录用户启动时查:该(账号, 设备)走过引导没。`false` → 客户端应重走。 + +### 入参(query) + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `device_id` | string | ❌(≤64 位) | 硬件级 ANDROID_ID;空 = 按未完成处理(不误跳过) | + +Mock 请求: +``` +GET /api/v1/user/onboarding/status?device_id=android_abc123def456 +``` + +### 出参 + +响应 `200`:`OnboardingStatusResponse` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `completed` | bool | 该(设备+账号)是否已走过新手引导 | + +Mock 出参: +```json +{"completed": true} +``` + +--- + +## POST /onboarding/complete — 标记新手引导完成 + +走完新手引导时调一次。按(当前账号, device_id)落一条完成标记,幂等(重复调用不报错)。 + +### 入参 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `device_id` | string | ✅ | 硬件级 ANDROID_ID(与登录请求一致) | + +Mock 入参: +```json +{ + "device_id": "android_abc123def456" +} +``` + +### 出参 + +```json +{"ok": true} +``` + +--- + +## POST /api/v1/user/onboarding/reset — 重置新手引导(#114) + +删除(当前账号, `device_id`)的完成标记 → 该设备下次登录/进 App 重走引导。给客户端「设置 → 重看新手引导」入口用(此前只能运营在 admin 删记录)。 + +### 入参 +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `device_id` | string | ✅ | 硬件级 ANDROID_ID(与 complete 一致) | + +### 出参 +```json +{"ok": true} +``` +幂等:无标记时也返回 ok。 + +--- + +## 说明 +- 替代原 `force_onboarding`(按用户)→ 改设备维度后,运营删记录(或用户自己 reset)即触发重走 +- 幂等:重复标记 / 重复重置都不报错 +- `device_id` 为空时 `status` 一律返回未完成 diff --git a/docs/api/user-profile.md b/docs/api/user/user-profile.md similarity index 94% rename from docs/api/user-profile.md rename to docs/api/user/user-profile.md index b170db8..28fb10f 100644 --- a/docs/api/user-profile.md +++ b/docs/api/user/user-profile.md @@ -1,6 +1,6 @@ # PATCH /api/v1/user/profile — 修改昵称 -> 所属:User 组(前缀 `/api/v1/user`) | 鉴权:Bearer access_token | [← 返回 API 索引](./README.md) +> 所属:User 组(前缀 `/api/v1/user`) | 鉴权:Bearer access_token | [← 返回 API 索引](../README.md) ## 入参 请求体 JSON: diff --git a/docs/api/wallet-account.md b/docs/api/wallet/wallet-account.md similarity index 54% rename from docs/api/wallet-account.md rename to docs/api/wallet/wallet-account.md index c059f4c..ba523ba 100644 --- a/docs/api/wallet-account.md +++ b/docs/api/wallet/wallet-account.md @@ -1,6 +1,6 @@ # GET /api/v1/wallet/account — 金币 + 现金余额(我的资产) -> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) +> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) ## 入参 无(用户由 token 确定)。 @@ -11,8 +11,9 @@ | 字段 | 类型 | 说明 | |---|---|---| | `coin_balance` | int | 当前金币余额 | -| `cash_balance_cents` | int | 当前现金余额(分) | +| `cash_balance_cents` | int | 当前现金余额(分,金币兑换账) | +| `invite_cash_balance_cents` | int | 邀请奖励金余额(分,与现金**物理隔离**的第二本账,#82;好友比价并下单发奖入账,提现走 `source=invite_cash`) | | `total_coin_earned` | int | 累计赚取金币 | ## 说明 -账户不存在时自动创建(零余额)。福利页「我的资产」卡的数据源。 +账户不存在时自动创建(零余额)。福利页「我的资产」卡的数据源;邀请页「奖励金」余额也读它。 diff --git a/docs/api/wallet-bind-wechat.md b/docs/api/wallet/wallet-bind-wechat.md similarity index 93% rename from docs/api/wallet-bind-wechat.md rename to docs/api/wallet/wallet-bind-wechat.md index 34d3022..ad07d7d 100644 --- a/docs/api/wallet-bind-wechat.md +++ b/docs/api/wallet/wallet-bind-wechat.md @@ -1,6 +1,6 @@ # POST /api/v1/wallet/bind-wechat — 微信授权 code 换 openid 并绑定 -> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | 限流:同 IP ≤10 次/分 | [← 返回 API 索引](./README.md) +> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | 限流:同 IP ≤10 次/分 | [← 返回 API 索引](../README.md) > > 集成实现:见 [integrations/wxpay](../integrations/wxpay.md)(code 换 openid / userinfo)。 diff --git a/docs/api/wallet-cash-transactions.md b/docs/api/wallet/wallet-cash-transactions.md similarity index 96% rename from docs/api/wallet-cash-transactions.md rename to docs/api/wallet/wallet-cash-transactions.md index 80463e9..d8b7379 100644 --- a/docs/api/wallet-cash-transactions.md +++ b/docs/api/wallet/wallet-cash-transactions.md @@ -1,6 +1,6 @@ # GET /api/v1/wallet/cash-transactions — 现金流水(游标分页) -> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) +> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) ## 入参(query) diff --git a/docs/api/wallet-coin-transactions.md b/docs/api/wallet/wallet-coin-transactions.md similarity index 95% rename from docs/api/wallet-coin-transactions.md rename to docs/api/wallet/wallet-coin-transactions.md index 70e0b69..c73142c 100644 --- a/docs/api/wallet-coin-transactions.md +++ b/docs/api/wallet/wallet-coin-transactions.md @@ -1,6 +1,6 @@ # GET /api/v1/wallet/coin-transactions — 金币流水(游标分页) -> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) +> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) ## 入参(query) diff --git a/docs/api/wallet-exchange-info.md b/docs/api/wallet/wallet-exchange-info.md similarity index 97% rename from docs/api/wallet-exchange-info.md rename to docs/api/wallet/wallet-exchange-info.md index 23c4b04..49e9f31 100644 --- a/docs/api/wallet-exchange-info.md +++ b/docs/api/wallet/wallet-exchange-info.md @@ -1,6 +1,6 @@ # GET /api/v1/wallet/exchange-info — 金币兑现金 汇率/规则 -> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:**无**(返回静态配置,不读用户) | [← 返回 API 索引](./README.md) +> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:**无**(返回静态配置,不读用户) | [← 返回 API 索引](../README.md) ## 入参 无。 diff --git a/docs/api/wallet-exchange.md b/docs/api/wallet/wallet-exchange.md similarity index 95% rename from docs/api/wallet-exchange.md rename to docs/api/wallet/wallet-exchange.md index 9eaefae..73bd46c 100644 --- a/docs/api/wallet-exchange.md +++ b/docs/api/wallet/wallet-exchange.md @@ -1,6 +1,6 @@ # POST /api/v1/wallet/exchange — 金币兑现金 -> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) +> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) ## 入参 diff --git a/docs/api/wallet/wallet-transfer-auth.md b/docs/api/wallet/wallet-transfer-auth.md new file mode 100644 index 0000000..b900255 --- /dev/null +++ b/docs/api/wallet/wallet-transfer-auth.md @@ -0,0 +1,108 @@ +# 免确认收款授权(transfer-auth 族) + +> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:全部 Bearer | [← 返回 API 索引](../README.md) +> +> 集成实现:见 [integrations/wxpay](../../integrations/wxpay.md)(微信 V3 免确认收款授权)。 + +开启一次后,后续提现走免确认转账直接到账,不再跳微信确认。绑定 openid 是前提。 + +--- + +## POST /transfer-auth — 开启免确认到账 + +申请免确认授权,返回拉起微信授权页的 `package_info`。 + +### 入参 + +无(`user_id` 从 JWT 取)。 + +### 出参 + +响应 `200`:`TransferAuthResultOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `already_active` | bool | 是否已是开启状态(无需再授权) | +| `package_info` | string \| null | 拉起微信授权页的 package;已开启时为空 | +| `mch_id` | string \| null | 商户号 | +| `app_id` | string \| null | 微信 AppID | + +Mock 出参(首次申请): +```json +{ + "already_active": false, + "package_info": "affirmTransferAuth|{\"mchId\":\"1234567890\",\"appId\":\"wxabc123\",\"package\":\"affirm_biz_123\"}", + "mch_id": "1234567890", + "app_id": "wxabc123" +} +``` + +Mock 出参(已开启,无需重复授权): +```json +{ + "already_active": true, + "package_info": null, + "mch_id": "1234567890", + "app_id": "wxabc123" +} +``` + +### 错误码 +- `400` 未绑定微信 +- `502` 微信授权接口调用失败 +- `503` 微信支付授权未配置 + +--- + +## GET /transfer-auth/status — 查免确认授权状态 + +从微信授权页返回后轮询。 + +### 入参 + +无(`user_id` 从 JWT 取)。 + +### 出参 + +响应 `200`:`TransferAuthStatusOut` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `state` | string | `none`(未开启)/ `pending`(待确认)/ `active`(已开启)/ `closed`(已关闭) | +| `enabled` | bool | 是否已开启免确认到账 | + +Mock 出参: +```json +{ + "state": "active", + "enabled": true +} +``` + +--- + +## POST /transfer-auth/close — 关闭免确认到账 + +解除授权,恢复每次提现跳微信确认。 + +### 入参 + +无(`user_id` 从 JWT 取)。 + +### 出参 + +响应 `200`:`TransferAuthStatusOut`(同上) + +Mock 出参: +```json +{ + "state": "closed", + "enabled": false +} +``` + +--- + +## 错误码(通用) +- `401` 未鉴权 / token 失效 +- `503` 微信支付授权未配置 diff --git a/docs/api/wallet-unbind-wechat.md b/docs/api/wallet/wallet-unbind-wechat.md similarity index 97% rename from docs/api/wallet-unbind-wechat.md rename to docs/api/wallet/wallet-unbind-wechat.md index 01211dc..a94baf2 100644 --- a/docs/api/wallet-unbind-wechat.md +++ b/docs/api/wallet/wallet-unbind-wechat.md @@ -1,6 +1,6 @@ # POST /api/v1/wallet/unbind-wechat — 解绑微信(清空 openid) -> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) +> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) > > 集成实现:见 [integrations/wxpay](../integrations/wxpay.md)。 diff --git a/docs/api/wallet-withdraw-info.md b/docs/api/wallet/wallet-withdraw-info.md similarity index 95% rename from docs/api/wallet-withdraw-info.md rename to docs/api/wallet/wallet-withdraw-info.md index 7a6f1ce..1d45d69 100644 --- a/docs/api/wallet-withdraw-info.md +++ b/docs/api/wallet/wallet-withdraw-info.md @@ -1,6 +1,6 @@ # GET /api/v1/wallet/withdraw-info — 提现额度 / 绑定状态 -> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) +> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) > > 集成实现:见 [integrations/wxpay](../integrations/wxpay.md)(提现链路)。 diff --git a/docs/api/wallet-withdraw-orders.md b/docs/api/wallet/wallet-withdraw-orders.md similarity index 71% rename from docs/api/wallet-withdraw-orders.md rename to docs/api/wallet/wallet-withdraw-orders.md index 80b5105..9a1c3d5 100644 --- a/docs/api/wallet-withdraw-orders.md +++ b/docs/api/wallet/wallet-withdraw-orders.md @@ -1,6 +1,6 @@ # GET /api/v1/wallet/withdraw-orders — 提现单列表(游标分页) -> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) +> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) ## 入参(query) @@ -8,9 +8,10 @@ |---|---|---|---|---| | `limit` | int | ❌ | 20 | 1–100 | | `cursor` | int | ❌ | null | 上一页末条 `id`,首页不传 | +| `source` | string | ❌ | null | 按账户来源过滤:`coin_cash` / `invite_cash`;不传=全部(#121) | ## 出参 -响应 `200`:`{ items: WithdrawOrderOut[], next_cursor: int|null }`(分页见 [索引#游标分页约定](./README.md#游标分页约定)) +响应 `200`:`{ items: WithdrawOrderOut[], next_cursor: int|null }`(分页见 [索引#游标分页约定](../README.md#游标分页约定)) **WithdrawOrderOut** @@ -19,7 +20,7 @@ | `id` | int | 单 id(也是游标) | | `out_bill_no` | string | 商户提现单号 | | `amount_cents` | int | 提现额(分) | -| `status` | string | `pending` / `success` / `failed` | +| `status` | string | `reviewing`(待审核)/ `pending` / `success` / `failed` / `rejected` | | `wechat_state` | string \| null | 微信侧原始状态 | | `fail_reason` | string \| null | 失败原因 | | `created_at` | datetime | 发起时间 | diff --git a/docs/api/wallet-withdraw-status.md b/docs/api/wallet/wallet-withdraw-status.md similarity index 96% rename from docs/api/wallet-withdraw-status.md rename to docs/api/wallet/wallet-withdraw-status.md index 31a851f..636c50e 100644 --- a/docs/api/wallet-withdraw-status.md +++ b/docs/api/wallet/wallet-withdraw-status.md @@ -1,6 +1,6 @@ # GET /api/v1/wallet/withdraw/status — 查提现单状态(轮询) -> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](./README.md) +> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | [← 返回 API 索引](../README.md) > > 集成实现:见 [integrations/wxpay](../integrations/wxpay.md)(查单 / 撤销转账)。 diff --git a/docs/api/wallet-withdraw.md b/docs/api/wallet/wallet-withdraw.md similarity index 75% rename from docs/api/wallet-withdraw.md rename to docs/api/wallet/wallet-withdraw.md index ce740f0..18d57b1 100644 --- a/docs/api/wallet-withdraw.md +++ b/docs/api/wallet/wallet-withdraw.md @@ -1,14 +1,15 @@ # POST /api/v1/wallet/withdraw — 发起提现到微信零钱 -> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | 限流:同 IP ≤20 次/分 | [← 返回 API 索引](./README.md) +> 所属:Wallet 组(前缀 `/api/v1/wallet`) | 鉴权:Bearer | 限流:同 IP ≤20 次/分 | [← 返回 API 索引](../README.md) > -> 集成实现:见 [integrations/wxpay](../integrations/wxpay.md)(微信 V3 商家转账、签名、实名加密)。 +> 集成实现:见 [integrations/wxpay](../../integrations/wxpay.md)(微信 V3 商家转账、签名、实名加密)。 ## 入参 | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| | `amount_cents` | int | ✅(>0) | 提现金额(分),须落在 `[min_cents, max_cents]` | +| `source` | string | ❌(默认 `coin_cash`) | 提现账户来源(#121 分账):`coin_cash`(金币兑换的现金)/ `invite_cash`(邀请奖励金)。按它扣对应余额、流水落对应账本([cash_transaction](../../database/cash_transaction.md) / [invite_cash_transaction](../../database/invite_cash_transaction.md)) | | `user_name` | string | ❌ | 实名(达额时微信商家转账要求,可空) | | `out_bill_no` | string | ❌ | **客户端幂等键(商户单号)**:同号重试不重复转账;不传则服务端生成 | diff --git a/docs/database/OVERVIEW.md b/docs/database/OVERVIEW.md index 2c25d00..5f5f525 100644 --- a/docs/database/OVERVIEW.md +++ b/docs/database/OVERVIEW.md @@ -2,7 +2,7 @@ > 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。 > 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。 -> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **40 张业务表** + `alembic_version`(框架的迁移版本指针)。注意「比价/领券**过程**」始终在 pricebot 内存态跑、**不落库**——只有**结果**回 app-server 才落库:领券结果落 `coupon_*` 三张今日状态表;比价结果分两路——客户端带 JWT 上报「我的记录」落 `comparison_record`,pricebot 另经 `app/api/internal/` server→server 把客观价格/门店事实落 `price_observation`/`store_mapping`(不鉴权、匿名也记)+ 启动确认窗兜底样本落 `launch_confirm_sample`。此外好友邀请(`invite_*` 2 张)与美团 CPS 群发联盟(`cps_*` 6 张,含落地页微信身份 `cps_wx_user`)是两个独立子系统。无障碍存活监控 `device_liveness`(#65)按用户设备维度记心跳、检掉线召回。 +> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **45 张业务表** + `alembic_version`(框架的迁移版本指针)。注意「比价/领券**过程**」始终在 pricebot 内存态跑、**不落库**——只有**结果**回 app-server 才落库:领券结果落 `coupon_*` 三张今日状态表 + `coupon_session` 任务流水(#99);比价记录 2026-07 起**由 app-server 透传壳 harvest 直接落库**(`comparison_record`:帧0 建 running 行 → done/finalize 写终态,老客户端带 JWT 的 `POST /compare/record` 仅作兜底),pricebot 另经 `app/api/internal/` server→server 把客观价格/门店事实落 `price_observation`/`store_mapping`(不鉴权、匿名也记)+ 启动确认窗兜底样本落 `launch_confirm_sample`。此外好友邀请(`invite_*` 2 张 + 独立账本 `invite_cash_transaction`)与 CPS 群发联盟(`cps_*` 6 张,含落地页微信身份 `cps_wx_user`;`cps_order` 已含京东联盟单)是两个独立子系统。无障碍存活监控 `device_liveness`(#65)按用户设备维度记心跳、检掉线召回;客户端埋点落 `analytics_event`(#83)。 --- @@ -12,7 +12,7 @@ | App 位置 / 动作 | 表 | 说明 | |---|---|---| | 比价/领券**过程**(看屏→决策→操作) | (无) | 在 pricebot-backend 内存态跑,**过程不落库**;只有结果回到 app-server 才落库 | -| 「我的比价记录」列表 / 详情 | [`comparison_record`](./comparison_record.md) | 每次比价 done 后客户端带 JWT 上报一条完整明细 | +| 「我的比价记录」列表 / 详情 | [`comparison_record`](./comparison_record.md) | **app-server 透传壳 harvest 落库**(2026-07 起):帧0 建 `running` 行 → done 帧写 success/failed → `trace/finalize` 写 cancelled/failed;老客户端带 JWT 的 `POST /compare/record` 兜底 | | 比价战绩里程碑(逐档领金币) | [`comparison_milestone_claim`](./comparison_milestone_claim.md) | 累计成功比价 N 次解锁;进度读 `comparison_record` 计数 | | profile「累计省了 / 省钱战绩 / 省钱明细」 | [`savings_record`](./savings_record.md) | 真实下单归因(source=compare)+ 无真实数据时 demo 兜底 | | 「上报更低价」提交 / 列表 | [`price_report`](./price_report.md) | 众包纠偏:用户举证某平台更便宜,人工审核发奖 | @@ -27,6 +27,7 @@ | 切外卖 App 时是否弹领券引导窗 | [`coupon_prompt_engagement`](./coupon_state.md) | 今天 engage 过(点领/点拒)就不再弹;判断维度 device_id | | 首页「去领取」卡是否置灰 | [`coupon_daily_completion`](./coupon_state.md) | 今天跑完整轮(到 done)就置灰;判断维度 device_id | | 每张券领取结果留痕 | [`coupon_claim_record`](./coupon_state.md) | 资产/画像/排查/CPS;当前**不参与**判断 | +| (admin 领券看板)一次领券任务全程流水 | [`coupon_session`](./coupon_session.md) | 客户端 `POST /coupon/session` 两段上报(发起建行/收尾更新);发起数、完成率、中途流失、平均耗时都从这算(#99) | ### 钱包 / 福利(看广告赚钱闭环) | App 位置 / 动作 | 表 | 说明 | @@ -37,7 +38,9 @@ | 每日签到 | [`signin_record`](./signin_record.md) + [`signin_boost_record`](./signin_boost_record.md) | 7 天循环发币;签到后看广告可膨胀一次 | | 一次性任务(开消息提醒等) | [`user_task`](./user_task.md) | 领一次发币 | | 看激励视频赚金币 | [`ad_reward_record`](./ad_reward_record.md) + [`ad_watch_log`](./ad_watch_log.md) + [`ad_ecpm_record`](./ad_ecpm_record.md) | 独立数据流:发奖 / 旧版观看时长 / 收益对账 | -| 信息流广告结算 | [`ad_feed_reward_record`](./ad_feed_reward_record.md) | 每展示满 10 秒累计一份奖励,完成后一次性入账 | +| 信息流/Draw 广告结算 | [`ad_feed_reward_record`](./ad_feed_reward_record.md) | 每展示满 10 秒累计一份奖励,完成后一次性入账;`ad_type`(feed/draw)+`feed_scene`(compare/coupon)分形态/场景 | +| (无 App UI)穿山甲后台收益对账 | [`ad_pangle_daily_revenue`](./ad_pangle_daily_revenue.md) | 定时脚本拉 GroMore 数据 API 落日表(#92);admin 收益报表/大盘的「真实收益」侧 | +| 好友比价并下单发奖 / 邀请奖励金 | [`invite_cash_transaction`](./invite_cash_transaction.md) + `coin_account.invite_cash_balance_cents` | 与现金**物理隔离**的第二本账(#82/#113):`POST /order/report` 触发 `invite_reward` 入账;`source=invite_cash` 提现出账 | | 金币兑现金 | `coin_account` + `coin_transaction` + `cash_transaction` | exchange_out + exchange_in 两笔流水 | | 提现到微信零钱 | [`withdraw_order`](./withdraw_order.md) + [`wechat_transfer_authorization`](./wechat_transfer_authorization.md) + `cash_transaction` | 人工审核 + 微信商家转账 | | 绑定微信(提现前置) | `user`.wechat_* | openid 唯一,一微信一账号 | @@ -53,8 +56,9 @@ ### 好友邀请(注册增长) | App 位置 / 动作 | 表 | 说明 | |---|---|---| -| 输入/剪贴板邀请码绑定 | [`invite_relation`](./invite_relation.md) | 注册即生效,邀请人+被邀请人各发 1 万金币;`invitee_user_id` 唯一=幂等防重复发奖 | +| 输入/剪贴板邀请码绑定 | [`invite_relation`](./invite_relation.md) | 注册即生效(**绑定不发奖**,#113);`invitee_user_id` 唯一;`compare_reward_granted` 幂等闸=好友**比价并下单**后才给邀请人发奖励金 | | 落地页访问指纹(剪贴板归因兜底) | [`invite_fingerprint`](./invite_fingerprint.md) | 剪贴板没拿到码时,用 (ip+机型+屏幕) 7 天内反查邀请人 | +| 邀请奖励金入账/提现 | [`invite_cash_transaction`](./invite_cash_transaction.md) | 独立账本(见上钱包节);余额在 `coin_account.invite_cash_balance_cents` | ### 美团 CPS 群发联盟(私域社群比价,运营后台驱动 · 群发选品→点击→对账漏斗) | 后台/用户动作 | 表 | 说明 | @@ -66,10 +70,19 @@ | 微信内打开落地页授权 | [`cps_wx_user`](./cps_wx_user.md) | 服务号网页授权拿 openid(base 静默)/ 昵称头像 unionid(userinfo,点领券触发),记首次来源群 | | 定时拉美团联盟订单对账 | [`cps_order`](./cps_order.md) | `query_order` 按 sid 归群,串成点击→下单→佣金漏斗 | +### 埋点 / 首页门面 / 选品缓存 +| 位置 / 动作 | 表 | 说明 | +|---|---|---| +| 客户端行为埋点 | [`analytics_event`](./analytics_event.md) | `POST /analytics/events` 批量上报,一事件一行(#83);admin「埋点日志」检索 | +| 首页三统计数字 | [`ops_stat_config`](./ops_stat_config.md) | real/manual/random 三模式,admin 配 | +| 首页轮播(省钱动态) | [`ops_marquee_seed`](./ops_marquee_seed.md) | 种子条目,与真实 `comparison_record` 混播(数据源模式 mixed/real/seed 可切) | +| 首页推荐/销量榜离线选品 | [`meituan_coupon`](./meituan_coupon.md) | 美团 CPS 券缓存,定时 ETL 灌入;`feed?tab=rec` 与 `top-sales` 纯库出、不实时打美团 | + ### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权) | 后台模块 | 表 | 说明 | |---|---|---| -| 管理员账号 / 登录 | [`admin_user`](./admin_user.md) | 与 C 端 `user` 完全隔离,独立 JWT + RBAC | +| 管理员账号 / 登录 | [`admin_user`](./admin_user.md) | 与 C 端 `user` 完全隔离,独立 JWT + RBAC;`pages_override` 个人可见页覆盖 | +| 角色 / 可见页配置 | [`admin_role`](./admin_role.md) | 内建三角色 + 自定义角色(#117/#126);`admin_user.role` 按名引用 | | 操作审计 | [`admin_audit_log`](./admin_audit_log.md) | 每个写操作落一条,只增不改不删 | | 运营可配置项(改奖励常量) | [`app_config`](./app_config.md) | 空表 = 用代码默认;后台改了即覆盖 | | 用户/钱包/提现/反馈管理 | 跨读写上面的 C 端表 | 见下「写入路径」admin 段 | @@ -92,8 +105,8 @@ | 签到膨胀 `POST /signin/boost` | `signin_boost_record`(C) + `coin_account`(U) + `coin_transaction`(C `signin_boost`) | 同事务;同日一次 | | 领任务 `POST /tasks/claim` | `user_task`(C) + `coin_account`(U) + `coin_transaction`(C `task_`) | 同事务 | | 金币兑现金 `POST /wallet/exchange` | `coin_account`(U) + `coin_transaction`(C `exchange_out` −) + `cash_transaction`(C `exchange_in` +) | 同事务 | -| 发起提现 `POST /wallet/withdraw` | `withdraw_order`(C `reviewing`) + `coin_account`(U 扣现金) + `cash_transaction`(C `withdraw` −) | 同事务,**不打款** | -| 查提现状态 / 用户取消 `GET /wallet/withdraw/status` | `withdraw_order`(U) + 失败→`cash_transaction`(C `withdraw_refund` +) | | +| 发起提现 `POST /wallet/withdraw` | `withdraw_order`(C `reviewing`,记 `source`) + `coin_account`(U 按 source 扣对应余额) + 流水(C −:`cash_transaction.withdraw` 或 `invite_cash_transaction.invite_withdraw`) | 同事务,**不打款**;#121 按 `source` 分账 | +| 查提现状态 / 用户取消 `GET /wallet/withdraw/status` | `withdraw_order`(U) + 失败→对应账本退款流水(C `withdraw_refund` / `invite_withdraw_refund` +) | | | 穿山甲发奖 S2S 回调 `POST /ad/pangle-callback` | `ad_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `reward_video`/`signin_boost`) | `trans_id` 幂等 | | 看广告时长上报 `POST /ad/watch-report` | `ad_watch_log`(C) | | | 广告 eCPM 上报 `POST /ad/ecpm-report` | `ad_ecpm_record`(C) | | @@ -101,13 +114,17 @@ | 注册设备 / 更新 push token `POST /device/register` | `device_liveness`(C/U upsert) | `(user_id, device_id)` 幂等;只在传入非空时更新 `registration_id`/版本 | | 无障碍服务心跳 `POST /device/heartbeat` | `device_liveness`(C/U) | 心跳也能自注册;`accessibility_enabled=true` 时刷 `last_heartbeat_at`、置 `alive`、清 `notified_at` | | 客户端 ack 掉线提醒 `POST /device/liveness/ack` | `device_liveness`(U) | 清 `kill_alert_pending`(幂等;下次真掉线 worker 再置) | -| 比价 done 上报 `POST /compare/record` | `comparison_record`(C 或 U) | `(user_id, trace_id)` 幂等覆盖 | +| 比价透传(帧0 / done / finalize)`POST /intent/recognize`、`/price/step`、`/trace/finalize` | `comparison_record`(C `running` → U 终态) | **harvest 三段式**(2026-07):帧0 mint trace_id 建 running 行 → done 写 success/failed → finalize 写 cancelled/failed(不降级 success);best-effort,写失败不连累透传 | +| 比价 done 上报 `POST /compare/record`(老客户端兜底) | `comparison_record`(C 或 U) | `trace_id` 幂等覆盖(唯一键已从 `(user_id,trace_id)` 改单列) | | 领里程碑 `POST /compare/milestone/claim` | `comparison_milestone_claim`(C) | **当前不发币**(coin_awarded=0) | -| 支付归因上报 `POST /order/report` | `savings_record`(C `source=compare`) | `(user_id, client_event_id)` 幂等 | +| 支付归因上报 `POST /order/report` | `savings_record`(C `source=compare`)+ 触发邀请发奖:`invite_relation`(U `compare_reward_granted`)+`coin_account`(U invite_cash)+`invite_cash_transaction`(C `invite_reward`) | `(user_id, client_event_id)` 幂等;#113 邀请发奖口径=好友**比价并下单**,`compare_reward_granted` 幂等闸,同事务 | +| 批量埋点 `POST /analytics/events` | `analytics_event`(C 批量) | 不强制登录;每批 ≤200 条 | +| 领券任务流水 `POST /coupon/session` | `coupon_session`(C/U upsert) | `trace_id` 幂等:发起建行、收尾更新同一行(#99) | +| 重置新手引导 `POST /user/onboarding/reset` | `onboarding_completion`(**D** 该 设备+账号 行) | #114,删完成标记 → 下次登录重走引导 | | 首次进 profile 省钱页且无真实记录 | `savings_record`(C `source=demo`) | 懒种子,`ensure_seeded` 按 user 幂等 | | 上报更低价 `POST /report` | `price_report`(C) | 读 `comparison_record.best_price_cents` 校验 | | 提交反馈 `POST /feedback` | `feedback`(C) | | -| 绑定邀请 `POST /invite/bind` | `invite_relation`(C `effective`) + `coin_account`(U×2) + `coin_transaction`(C `invite_inviter` + `invite_invitee`) | 同事务;`invitee_user_id` 唯一幂等,双方各发 1 万金币 | +| 绑定邀请 `POST /invite/bind` | `invite_relation`(C `effective`) | `invitee_user_id` 唯一幂等。**#113 起绑定不发奖**——发奖延后到好友比价并下单(`POST /order/report` 行),发的是邀请奖励金非金币 | | 落地页归因 `POST /invite/landing-track` | `invite_fingerprint`(C) | 剪贴板归因兜底线索,登录后用 (ip+机型+屏幕) 反查 | | 领券首帧 `POST /api/v1/coupon/step`(step=0) | `coupon_prompt_engagement`(C/U `claim_started`) | `(device_id, package, 北京日)` 幂等;best-effort | | 领券每帧结果 `POST /api/v1/coupon/step` | `coupon_claim_record`(C/U) | `(device_id, coupon_id, 北京日)` 幂等;best-effort | @@ -121,11 +138,17 @@ | 后台操作 | 写入 | 操作 | |---|---|---| | 手动增减金币 | `coin_account`(U)+`coin_transaction`(C `admin_grant`/`admin_deduct`)+`admin_audit_log`(C) | 同事务 | -| 改用户状态(禁用/启用) | `user`(U)+`admin_audit_log`(C) | 同事务 | -| 审核通过提现 | `withdraw_order`(U→pending/success/failed)+`wechat_transfer_authorization`(C/U)+失败时`cash_transaction`(refund)+`admin_audit_log`(C) | | -| 审核拒绝提现 | `withdraw_order`(U→rejected)+`cash_transaction`(C `withdraw_refund`)+`admin_audit_log`(C) | | -| 处理反馈 | `feedback`(U)+`admin_audit_log`(C) | 同事务 | -| 改运营配置 | `app_config`(C/U)+`admin_audit_log`(C) | 同事务 | +| 手动增减现金(#95 支持目标账户) | `coin_account`(U 按 `account` 选列)+ 流水(C:`cash_transaction` 或 `invite_cash_transaction`,`admin_grant`/`admin_deduct`)+`admin_audit_log`(C) | 同事务;`account=coin_cash`/`invite_cash` 两本账各调各 | +| 改用户状态(禁用/启用)/ 调试链接权限 | `user`(U `status` / `debug_trace_enabled`)+`admin_audit_log`(C) | 同事务 | +| 审核通过提现(含批量) | `withdraw_order`(U→pending/success/failed)+`wechat_transfer_authorization`(C/U)+失败时按 source 退款流水+`admin_audit_log`(C) | | +| 审核拒绝提现(含批量) | `withdraw_order`(U→rejected)+按 source 退款流水(C `withdraw_refund`/`invite_withdraw_refund`)+`admin_audit_log`(C) | | +| 反馈采纳/拒绝/标记处理 | `feedback`(U→adopted/rejected/handled + `admin_reply`/`reject_reason`)+采纳发币时 `coin_account`(U)+`coin_transaction`(C)+`admin_audit_log`(C) | 同事务(#94/#105) | +| 上报更低价审核 | `price_report`(U→approved/rejected)+通过发币 `coin_account`(U)+`coin_transaction`(C)+`admin_audit_log`(C) | 同事务 | +| 改运营配置 / 广告配置 / 反馈页二维码 | `app_config`(C/U)+`admin_audit_log`(C) | 同事务 | +| 轮播种子/数据源模式 | `ops_marquee_seed`(C/U/D)+`app_config`(模式 mixed/real/seed)+`admin_audit_log`(C) | #122/#123 预览/浏览只读 | +| 角色管理(#117/#126) | `admin_role`(C/U/D)+`admin_audit_log`(C) | 内建/在用角色不可删 | +| 管理员管理 | `admin_user`(C/U/**D** #126)+`admin_audit_log`(C) | 删除为物理删,不可删自己 | +| CPS 建群/活动/生成短链/拉单对账 | `cps_group`/`cps_activity`(C/U/D)、`cps_link`(C)、`cps_order`(C/U upsert;美团 `query_order` + 京东联盟 #90) | 见 CPS 段 | | 任意写操作 | `admin_audit_log`(C,**永不 U/D**) | | > 没有任何表会被业务流程物理 DELETE。注销是软删(改 user 行),其余只 C/U(领券 `/prompt/reset`、`/completed-today/reset` 是开发用删除,非业务流程)。 @@ -137,7 +160,9 @@ | 后台批量生成短链 `POST /admin/api/cps/referral-links` | `cps_link`(C) | 每 群×活动 一条;美团经 `sid` 转链拿 `target_url`(同群同活动重复生成产生多条) | | 用户点群发短链 `GET /c/{code}` / `POST /c/{code}/copy` | `cps_click`(C `visit`/`copy`) | 公开端点不鉴权;`group_id`/`sid` 从 link 冗余进来免 join | | 微信落地页授权回调 `GET /wx/oauth/cb` | `cps_wx_user`(C/U upsert) | 按 `openid` 幂等;base 拿 openid,userinfo 补昵称/unionid(非 None 才覆盖);任何失败兜底回落地页不阻断领券 | -| 定时拉美团联盟订单对账 | `cps_order`(C/U upsert) | `query_order` 按 `sid` 归群;`order_id` 幂等(状态会变,重复拉则更新) | +| 定时拉联盟订单对账(美团 `query_order` + 京东联盟 #90) | `cps_order`(C/U upsert) | 按 `sid` 归群;`order_id` 幂等(状态会变,重复拉则更新);京东单 `platform='jd'` + `jd_valid_code` 判有效 | +| 定时拉穿山甲 GroMore 后台收益(#92,systemd 每天 10:30) | `ad_pangle_daily_revenue`(C/U upsert) | 收益报表/大盘「真实收益」侧;与客户端上报的 eCPM 侧互为对照 | +| 定时 ETL 灌美团 CPS 选品库 | `meituan_coupon`(C/U) | `feed?tab=rec` / `top-sales` 纯库出的数据源 | | pricebot 比价 done 内部上报 `POST /internal/price-observation` | `price_observation`(C 批量) | `(trace_id,platform,scope)` 幂等;**不走 JWT、靠 `X-Internal-Secret`**(未配→503) | | pricebot 比价 done 内部上报 `POST /internal/store-mapping` | `store_mapping`(C/U 填空合并) | `trace_id` 幂等;另有 `lookup` 反查 + `invalidate` deeplink 失效标记(淘宝/京东) | | pricebot LLM 兜底放行启动确认窗后上报 `POST /internal/launch-confirm-sample` | `launch_confirm_sample`(C) | **都上报、不去重**;靠 `X-Internal-Secret`(未配→503) | @@ -150,7 +175,7 @@ ## 三、表间关系 & Join Key ### 硬外键(数据库 FK 约束) -- **18 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`(同时是 PK)、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback`、`device_liveness`。 +- **19 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction`、`cash_transaction`、`invite_cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`(同时是 PK)、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`(2026-07 起 `user_id` **可空**——harvest 帧0 建行时软鉴权可能拿不到)、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback`、`device_liveness`。 - `admin_audit_log.admin_id` → `admin_user.id`。 - `price_report.comparison_record_id` → `comparison_record.id`(可空:关联记录被删后仍留上报历史)。 - **邀请两表** → `user.id`:`invite_relation.inviter_user_id`、`invite_relation.invitee_user_id`(唯一)、`invite_fingerprint.inviter_user_id`——注意 FK 列名是 `inviter`/`invitee_user_id`,不是 `user_id`。 @@ -173,13 +198,24 @@ | biz_type | ref_id 指向 | amount 符号 | |---|---|---| - | `withdraw` / `withdraw_refund` | `withdraw_order.out_bill_no` | − / + | + | `withdraw` / `withdraw_refund` | `withdraw_order.out_bill_no`(`source=coin_cash` 的单) | − / + | | `exchange_in` | null | + | + | `admin_grant` / `admin_deduct` | null(原因在 `remark`=`admin:`) | + / − | + +- **`invite_cash_transaction.ref_id`**(邀请奖励金账本,#82): + + | biz_type | ref_id 指向 | amount 符号 | + |---|---|---| + | `invite_reward` | 被邀请人 `user.id`(字符串) | + | + | `invite_withdraw` / `invite_withdraw_refund` | `withdraw_order.out_bill_no`(`source=invite_cash` 的单) | − / + | + | `admin_grant` / `admin_deduct` | null | + / − | - **`comparison_record.store_name` ≈ `savings_record.shop_name`**:无 id 关联,按**店名字符串相等**给比价记录打「已下单」标记(瞬态,不写库)。两边店名同源 = 比价意图识别阶段的门店 query,语义=**店级**(同店比价多次会一并标已下单)。 - **广告流会话关联**:`ad_reward_record.ad_session_id` 可与 `ad_ecpm_record.ad_session_id` 对齐;`ad_watch_log` 仍是旧版兼容统计,不逐条参与发奖。 - **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record` 里 `status='success'` 的 `count`。 -- **领券三表无硬 FK,全靠软关联**:`coupon_prompt_engagement` / `coupon_daily_completion` / `coupon_claim_record` 的 `user_id` **软指** `user.id`(可空、有登录态才记、不进唯一键、不阻塞判断);`trace_id` **软指** pricebot work_logs(排查回指);唯一键都以 `device_id` + 北京自然日为主(详见 [`coupon_state.md`](./coupon_state.md))。 +- **领券三表无硬 FK,全靠软关联**:`coupon_prompt_engagement` / `coupon_daily_completion` / `coupon_claim_record` 的 `user_id` **软指** `user.id`(可空、有登录态才记、不进唯一键、不阻塞判断);`trace_id` **软指** pricebot work_logs(排查回指);唯一键都以 `device_id` + 北京自然日为主(详见 [`coupon_state.md`](./coupon_state.md))。[`coupon_session`](./coupon_session.md)(#99)同口径:`trace_id` 唯一 upsert、`user_id` 软指(admin 明细 LEFT JOIN 出手机号)。 +- **`analytics_event`**(#83)与 **`coupon_session`** 均无硬 FK:埋点/流水不鉴权也收,`device_id`/`user_id` 只作维度。 +- **`admin_user.role` 按名语义引用 `admin_role.name`**(无硬 FK,#117):删除保护在应用层(在用/内建角色不可删);个人 `pages_override` 优先于角色 `pages`。 - **`onboarding_completion.(user_id, device_id)`**:`user_id` 语义关联 `user.id`(无硬 FK,同 `coupon_*` 设备表),`device_id` = 客户端硬件级 `ANDROID_ID`(≠ 领券 per-install `device_id`)。登录读、走完引导写,决定是否再展示新手引导。 - **CPS 群发 6 表全靠 `sid` / id / `openid` 语义串联(无硬 FK)**:`cps_link.group_id`→`cps_group.id`、`cps_link.activity_id`→`cps_activity.id`、`cps_click.link_id`→`cps_link.id`、`cps_wx_user.first_group_id`→`cps_group.id`;**点击与订单无法对到单笔**,只在群维度(`sid`)汇合——`cps_order.sid` ≈ `cps_group.sid` ≈ `cps_link.sid` ≈ `cps_click.sid`(仅美团有 sid,淘宝/京东无)。统计按群聚合,故 `group_id`/`sid` 冗余进 `cps_click` 免 join。`cps_wx_user` 按 `openid` 自成用户身份维度,与 `cps_click`/`cps_order` 无 id 级 join。 - **比价沉淀两表(`price_observation` / `store_mapping`)**:`trace_id` 软指 pricebot work_logs(与 `comparison_record.trace_id` 同源但不互 join,各存各视角);`source_user_id` / `source_device_id` 软指用户/设备(可空,匿名也记)。`store_mapping.lookup` 靠**店名字符串精确相等** + geo 取最近,非 id 级 join。 @@ -188,10 +224,10 @@ ``` user ─1:1─ coin_account user ─1:1─ wechat_transfer_authorization -user ─1:N─ { coin_transaction, cash_transaction, withdraw_order, signin_record, - signin_boost_record, user_task, comparison_record, comparison_milestone_claim, - savings_record, ad_reward_record, ad_watch_log, ad_ecpm_record, ad_feed_reward_record, - price_report, feedback, device_liveness } +user ─1:N─ { coin_transaction, cash_transaction, invite_cash_transaction, withdraw_order, + signin_record, signin_boost_record, user_task, comparison_record(user_id 可空), + comparison_milestone_claim, savings_record, ad_reward_record, ad_watch_log, + ad_ecpm_record, ad_feed_reward_record, price_report, feedback, device_liveness } (device_liveness 硬 FK; (user_id,device_id) 唯一) user ─1:N─ onboarding_completion (user_id, 无硬 FK; (user_id,device_id) 去重) comparison_record ─1:N─ price_report (comparison_record_id, 可空) @@ -199,6 +235,11 @@ admin_user ─1:N─ admin_audit_log app_config (独立, 无外键, key 为主键) coupon_prompt_engagement / coupon_daily_completion / coupon_claim_record (独立, 无硬 FK; 维度=device_id+北京日, user_id/trace_id 仅软关联) +coupon_session (独立, 无硬 FK; trace_id 唯一=一次领券任务一行, #99) +analytics_event (独立, 无硬 FK; 埋点事件流, device/user 仅维度, #83) +admin_role ◀──语义(role 按 name 引用, 无FK)── admin_user (pages_override 个人覆盖, #117/#126) +ops_stat_config / ops_marquee_seed / meituan_coupon / ad_pangle_daily_revenue + (独立运营/缓存/对账表, 无外键) user ─1:N─ invite_relation (inviter_user_id 硬 FK); invitee_user_id ─1:1─ user (唯一硬 FK) user ─1:N─ invite_fingerprint (inviter_user_id 硬 FK) cps_activity / cps_group ──语义(无FK)──▶ cps_link ─1:N─ cps_click @@ -212,13 +253,13 @@ launch_confirm_sample (独立, 无硬 FK; 都上报不去 ## 四、资金模型(金币 / 现金 / 提现,三层) -1. **余额快照** `coin_account`:`coin_balance`(金币个数)+ `cash_balance_cents`(现金分),一用户一行,读取展示用。 -2. **流水账本** `coin_transaction` / `cash_transaction`:每次变动写一笔,`balance_after*` 记变动后余额,可逐笔回溯对账。 -3. **唯一发金币入口** `repositories/wallet.grant_coins`:更新快照 + 写流水,**不 commit**,由调用方在同一事务里 commit(保证"记录"和"加币"原子化)。signin / signin_boost / task / ad_reward / feed_ad_reward / exchange / admin 都走它,靠 `biz_type` 区分来源。 +1. **余额快照** `coin_account`:`coin_balance`(金币个数)+ `cash_balance_cents`(现金分)+ `invite_cash_balance_cents`(邀请奖励金分,#82),一用户一行,读取展示用。 +2. **流水账本** `coin_transaction` / `cash_transaction` / `invite_cash_transaction`:每次变动写一笔,`balance_after*` 记变动后余额,可逐笔回溯对账。**现金与邀请奖励金是两本物理隔离的账**——发放口径与提现对账各自独立。 +3. **唯一变动入口**:金币走 `repositories/wallet.grant_coins`,邀请奖励金走 `grant_invite_cash`——都是「更新快照 + 写流水,**不 commit**,由调用方同一事务 commit」。signin / signin_boost / task / ad_reward / feed_ad_reward / exchange / admin 走 `grant_coins`;`invite_reward` / admin 调整走 `grant_invite_cash`,靠 `biz_type` 区分来源。 - **汇率**:`10000 金币 = 1 元 = 100 分`(`rewards.COIN_PER_YUAN`);兑换额必须是整分倍数。 -- **提现状态机**:`reviewing`(发起即原子扣现金、待人工审核、**不打款**)→ 审核通过 `pending`(微信转账在途)→ `success` / `failed`(失败自动退款);审核拒绝 `rejected`(退款)。扣款/退款都写 `cash_transaction`,`out_bill_no` 幂等,孤儿 pending 单由 `reconcile_pending_withdraws` 对账兜底。 -- **防超额**:扣现金用带条件 `UPDATE ... WHERE cash_balance_cents >= amount`,并发/重试不会双扣。 +- **提现状态机**:`reviewing`(发起即原子扣款、待人工审核、**不打款**)→ 审核通过 `pending`(微信转账在途)→ `success` / `failed`(失败自动退款);审核拒绝 `rejected`(退款)。**按 `withdraw_order.source` 分账**(#121):`coin_cash` 单的扣款/退款写 `cash_transaction`,`invite_cash` 单写 `invite_cash_transaction`;`out_bill_no` 幂等,孤儿 pending 单由 `reconcile_pending_withdraws` 对账兜底,admin `withdraws/ledger-check` 分账校验「单 ↔ 流水」。 +- **防超额**:扣款用带条件 `UPDATE ... WHERE <对应余额列> >= amount`(按 source 选 `cash_balance_cents` / `invite_cash_balance_cents`),并发/重试不会双扣。 --- diff --git a/docs/database/README.md b/docs/database/README.md index bd4c0d4..5f93769 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -3,13 +3,13 @@ > 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。 > ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。 > 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`。 -> 最后更新:2026-06-23(补 3 张表文档:`device_liveness`(#65 无障碍存活监控)、`cps_wx_user`(CPS 落地页微信身份)、`launch_confirm_sample`(启动确认窗兜底样本);`comparison_record` 补 `input_tokens`/`output_tokens`。上一次 2026-06-17 补全 CPS 群发 5 张 `cps_*` + 好友邀请 2 张 `invite_*` + 比价沉淀 `price_observation`/`store_mapping` 并全表 review 对齐 model;含 [OVERVIEW 总览](./OVERVIEW.md)) +> 最后更新:2026-07-09(补 5 张新表文档并入索引:`coupon_session`(#99 领券任务流水)、`analytics_event`(#83 埋点)、`invite_cash_transaction`(#82 邀请奖励金账本)、`admin_role`(#117/#126 RBAC 角色)、`ad_pangle_daily_revenue`(#92,文档已有、补进索引);同步改动列:`comparison_record.product_names`、`withdraw_order.source`、`coin_account.invite_cash_balance_cents`、`feedback` 审核/环境列、`cps_order` 京东列、`device_liveness.first_protected_at`、`ad_feed_reward_record.ad_type/feed_scene`、`admin_user.plain_password/pages_override`。上一次 2026-06-23) > 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。 --- -## 表总览(40 张业务表 + `alembic_version` 框架表) +## 表总览(45 张业务表 + `alembic_version` 框架表) ### 账号 / 反馈 | 表 | 用途 | 模型 | 文档 | @@ -22,7 +22,7 @@ ### 好友邀请(注册增长) | 表 | 用途 | 模型 | 文档 | |---|---|---|---| -| `invite_relation` | 邀请绑定关系(注册即生效,双方各发1万金币;`invitee_user_id` 唯一=幂等防重复发奖) | `models/invite.py` | [详情](./invite_relation.md) | +| `invite_relation` | 邀请绑定关系(注册即生效但**绑定不发奖** #113;好友比价并下单后发邀请奖励金,`compare_reward_granted` 幂等闸;`invitee_user_id` 唯一) | `models/invite.py` | [详情](./invite_relation.md) | | `invite_fingerprint` | 剪贴板归因失败时的指纹兜底(落地页记 ip+屏幕+机型,登录后反查邀请人) | `models/invite_fingerprint.py` | [详情](./invite_fingerprint.md) | ### 钱包 / 福利(看广告赚钱闭环) @@ -30,8 +30,9 @@ |---|---|---|---| | `coin_account` | 金币+现金余额快照(一用户一行) | `models/wallet.py` | [详情](./coin_account.md) | | `coin_transaction` | 金币流水账本 | `models/wallet.py` | [详情](./coin_transaction.md) | -| `cash_transaction` | 现金流水账本(分) | `models/wallet.py` | [详情](./cash_transaction.md) | -| `withdraw_order` | 提现单(现金→微信零钱,含人工审核态) | `models/wallet.py` | [详情](./withdraw_order.md) | +| `cash_transaction` | 现金流水账本(分,金币兑换账) | `models/wallet.py` | [详情](./cash_transaction.md) | +| `invite_cash_transaction` | 邀请奖励金流水账本(分,与现金物理隔离;好友比价并下单发奖 + `source=invite_cash` 提现) | `models/wallet.py` | [详情](./invite_cash_transaction.md) | +| `withdraw_order` | 提现单(现金→微信零钱,含人工审核态;`source` 分账 coin_cash/invite_cash) | `models/wallet.py` | [详情](./withdraw_order.md) | | `wechat_transfer_authorization` | 微信免确认转账授权(一用户一行) | `models/wallet.py` | [详情](./wechat_transfer_authorization.md) | | `signin_record` | 签到记录(7 天循环) | `models/signin.py` | [详情](./signin_record.md) | | `signin_boost_record` | 签到后看广告膨胀记录 | `models/signin.py` | [详情](./signin_boost_record.md) | @@ -39,7 +40,10 @@ | `ad_reward_record` | 看激励视频发奖记录(S2S 回调,trans_id 幂等) | `models/ad_reward.py` | [详情](./ad_reward_record.md) | | `ad_watch_log` | 看广告观看时长(旧版兼容字段) | `models/ad_watch_log.py` | [详情](./ad_watch_log.md) | | `ad_ecpm_record` | 广告展示 eCPM 上报(收益对账) | `models/ad_ecpm.py` | [详情](./ad_ecpm_record.md) | -| `ad_feed_reward_record` | 信息流广告结算记录(10 秒一份,client_event_id 幂等) | `models/ad_feed_reward.py` | [详情](./ad_feed_reward_record.md) | +| `ad_feed_reward_record` | 信息流/Draw 广告结算记录(10 秒一份,client_event_id 幂等;`ad_type`+`feed_scene` 分形态/场景) | `models/ad_feed_reward.py` | [详情](./ad_feed_reward_record.md) | +| `ad_pangle_daily_revenue` | 穿山甲 GroMore 后台收益日表(定时拉取,收益报表/大盘真实收益源,#92) | `models/ad_pangle_revenue.py` | [详情](./ad_pangle_daily_revenue.md) | +| `inactivity_reset_log` | 15 天不活跃清零审计(每次清零一行;清零前三桶余额快照+原因+不活跃天数;只清金币+现金,邀请金仅快照) | `models/inactivity.py` | [详情](./inactivity_reset_log.md) | +| `inactivity_notification_log` | 不活跃清零前预警记录(余额快照+档位+通道+状态;streak 去重依据 + 占位 outbox) | `models/inactivity.py` | [详情](./inactivity_notification_log.md) | ### 比价 / 省钱 | 表 | 用途 | 模型 | 文档 | @@ -62,6 +66,7 @@ | `coupon_prompt_engagement` | 领券引导窗频控源(今日是否已 engage,按 device+package+日) | `models/coupon_state.py` | [详情](./coupon_state.md) | | `coupon_daily_completion` | 首页「去领取」置灰源(今日是否已跑完整轮) | `models/coupon_state.py` | [详情](./coupon_state.md) | | `coupon_claim_record` | 每张券领取结果沉淀(资产/画像/排查,不参与判断) | `models/coupon_state.py` | [详情](./coupon_state.md) | +| `coupon_session` | 一次领券任务一行的全程流水(发起/终态/耗时/机型,admin 领券看板数据源,#99) | `models/coupon_state.py` | [详情](./coupon_session.md) | ### 美团 CPS 券缓存 | 表 | 用途 | 模型 | 文档 | @@ -84,10 +89,16 @@ | `ops_stat_config` | 首页三统计展示配置(real/manual/random) | `models/ops_stat_config.py` | [详情](./ops_stat_config.md) | | `ops_marquee_seed` | 首页轮播种子(真实不足时兜底混播) | `models/ops_marquee_seed.py` | [详情](./ops_marquee_seed.md) | +### 埋点 +| 表 | 用途 | 模型 | 文档 | +|---|---|---|---| +| `analytics_event` | 客户端埋点事件流(批量上报,一事件一行;admin 埋点日志检索,#83) | `models/analytics_event.py` | [详情](./analytics_event.md) | + ### 运营后台 admin(独立子应用 `app/admin/`,独立鉴权) | 表 | 用途 | 模型 | 文档 | |---|---|---|---| -| `admin_user` | 管理员账号(独立 JWT + RBAC) | `models/admin.py` | [详情](./admin_user.md) | +| `admin_user` | 管理员账号(独立 JWT + RBAC;`pages_override` 个人可见页覆盖) | `models/admin.py` | [详情](./admin_user.md) | +| `admin_role` | 后台角色→可见页配置(内建三角色 + 自定义角色,#117/#126) | `models/admin_role.py` | [详情](./admin_role.md) | | `admin_audit_log` | 操作审计日志(只追加) | `models/admin.py` | [详情](./admin_audit_log.md) | | `app_config` | 运营可配置项(覆盖 rewards 常量) | `models/app_config.py` | [详情](./app_config.md) | diff --git a/docs/database/ad_feed_reward_record.md b/docs/database/ad_feed_reward_record.md index f6edbfb..41b721c 100644 --- a/docs/database/ad_feed_reward_record.md +++ b/docs/database/ad_feed_reward_record.md @@ -11,6 +11,8 @@ | `id` | Integer | PK | 自增主键 | | `client_event_id` | String(64) | UNIQUE, NOT NULL | 客户端幂等事件 id | | `ad_session_id` | String(64) | index, nullable | 客户端生成的一次信息流广告会话 id | +| `ad_type` | String(16) | nullable, default `feed` | 广告形态:`feed`(信息流)/ `draw`(Draw 视频流)。旧数据 NULL 视为 feed(迁移随 #83 入库) | +| `feed_scene` | String(16) | nullable | 展示场景:`compare`(比价期)/ `coupon`(领券期)。比价与领券共用同一 Draw 代码位,收益/大盘按它分场景归集(#125 修比价/领券奖励金币恒 0 即改按本列汇总) | | `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 | | `reward_date` | String(10) | index, NOT NULL | 北京时间日期 `YYYY-MM-DD` | | `duration_seconds` | Integer | NOT NULL | 整场比价累计观看秒数(轮播各条相加) | diff --git a/docs/database/admin_role.md b/docs/database/admin_role.md new file mode 100644 index 0000000..71b49ee --- /dev/null +++ b/docs/database/admin_role.md @@ -0,0 +1,32 @@ +# admin_role — 运营后台角色(RBAC 可见页配置) + +> 模型 `app/models/admin_role.py` · 仓库 `app/admin/repositories/admin_role.py` · 权限目录 `app/admin/permissions.py` · 接口 admin `GET/POST /admin/api/roles`、`PATCH/DELETE /admin/api/roles/{id}`、`GET /admin/api/roles/catalog`(`app/admin/routers/roles.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +运营后台的**角色 → 可见页面**配置表。#117 把 RBAC 从「代码写死三角色」升级为数据驱动:内建角色(`super_admin`/`finance`/`operator`)种子进表(`is_builtin=true`,不可删),`super_admin` 可另建**自定义角色**(#126)勾选任意页面组合。`admin_user.role` 存角色 `name` 按名引用本表;单个管理员还可用 `admin_user.pages_override` 在角色之上覆盖个人可见页。 + +## 用在哪 / 增删改查 +- **C(插入)**:内建三角色随迁移种子;`POST /admin/api/roles`(super_admin)新建自定义角色。 +- **U(更新)**:`PATCH /admin/api/roles/{id}` 改展示名/可见页(内建角色的 `pages` 也可调)。 +- **D(删除)**:`DELETE /admin/api/roles/{id}`——**内建角色与在用角色(有 admin_user.role 引用)不可删**。 +- **R**:登录/每次 admin 鉴权时按 `admin_user.role` 查本表算有效可见页(`pages_override` 非空则以覆盖为准);`GET /admin/api/roles/catalog` 返回全部可配页面目录(分组,来自 `permissions.py` 常量,不落库)。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明 | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `name` | String(32) | UNIQUE, index, NOT NULL | 角色标识(被 `admin_user.role` 按名引用):内建 `super_admin`/`finance`/`operator`;**自定义角色 name(key)= label = 创建时的输入名称**(不能叫 super_admin,重名 409) | +| `label` | String(32) | NOT NULL, default `""` | 展示名(后台下拉里显示) | +| `pages` | JSON | NOT NULL, default `[]` | 可见页面 key 列表(全集见 `permissions.py` 目录;前端按它渲染菜单,后端接口守卫同源校验)。**`super_admin` 行 pages 存空数组**,有效可见页特判为全部 | +| `is_builtin` | Boolean | NOT NULL, default false | 内建角色标记(不可删;`super_admin` 恒过所有守卫,不依赖 pages) | +| `created_at` | DateTime(tz) | server_default now() | | + +## 关系 / Join Key +- ← `admin_user.role` **按 `name` 语义引用**(无硬 FK;删除保护在应用层:在用角色不可删)。 +- 与 `admin_user.pages_override` 的关系:有效可见页 = `pages_override`(个人覆盖,非空优先)∪ 否则取角色 `pages`;`super_admin` 无视两者恒全通。 + +## 索引与约束 +- PK `id`;UNIQUE+index `name`。 + +## 注意 +- 页面 key 目录维护在代码 `app/admin/permissions.py`(加新后台页面要同步登记),表里只存勾选结果——目录变更不需要迁移。 +- 角色守卫兼容旧语义:`require_role("finance")` 等旧代码路径仍工作,内建角色名不可改。 diff --git a/docs/database/admin_user.md b/docs/database/admin_user.md index 7e21f68..7690bbe 100644 --- a/docs/database/admin_user.md +++ b/docs/database/admin_user.md @@ -1,13 +1,13 @@ # admin_user — 运营后台管理员账号 -> 模型 `app/models/admin.py` · 仓库 `app/admin/repositories/admin_user.py` · 接口 [admin-auth-login](../api/admin-auth-login.md) / [admin-admins-list](../api/admin-admins-list.md) / [admin-admin-create](../api/admin-admin-create.md) / [admin-admin-update](../api/admin-admin-update.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) +> 模型 `app/models/admin.py` · 仓库 `app/admin/repositories/admin_user.py` · 接口 [admin-auth-login](../api/admin/auth/admin-auth-login.md) / [admin-admins-list](../api/admin/admins/admin-admins-list.md) / [admin-admin-create](../api/admin/admins/admin-admin-create.md) / [admin-admin-update](../api/admin/admins/admin-admin-update.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) 运营后台(`app/admin/` 子应用,端口 8771)的管理员账号,与 App 用户(`user` 表)**完全隔离**:独立 JWT secret、独立鉴权链。密码 bcrypt 存哈希,带角色做 RBAC 权限分级。 ## 用在哪 / 增删改查 - **C(插入)**:① 首个管理员用 `scripts/create_admin.py` 命令行创建(无自助注册);② `super_admin` 在后台「管理员管理」`POST` 新建子管理员。 -- **U(更新)**:登录成功刷 `last_login_at`;`super_admin` 改他人 `role`/`status`/重置密码(`admin-admin-update`)。 -- **D**:无(禁用走 `status='disabled'`,token 立即失效)。 +- **U(更新)**:登录成功刷 `last_login_at`;`super_admin` 改他人 `role`/`status`/重置密码/`pages_override`(`admin-admin-update`)。 +- **D**:`DELETE /admin/api/admins/{id}`(#126,super_admin,带审计;不可删自己)。禁用仍走 `status='disabled'`(token 立即失效)。 - **R**:每个 admin 请求经 `admin/deps` 解 admin token 查本表(校验 `status=='active'` + 角色守卫);管理员列表。 ## 字段 @@ -15,8 +15,10 @@ |---|---|---|---| | `id` | Integer | PK, autoincrement | 被 `admin_audit_log.admin_id` 引用 | | `username` | String(64) | UNIQUE, index, NOT NULL | 登录名 | -| `password_hash` | String(255) | NOT NULL | bcrypt 哈希(明文不落库;⚠️ bcrypt 72 字节截断) | -| `role` | String(20) | NOT NULL, default `operator` | 取值:`super_admin`(全权+管账号)/ `finance`(钱:提现+金币)/ `operator`(用户+反馈+大盘) | +| `password_hash` | String(255) | NOT NULL | bcrypt 哈希(⚠️ bcrypt 72 字节截断) | +| `plain_password` | String(128) | nullable | **明文密码副本**(#117,`super_admin` 在管理员列表可见,便于线下派发/找回;创建/重置密码时同步写)。安全上是有意取舍:后台仅内网+super_admin 可见 | +| `role` | String(20) | NOT NULL, default `operator` | 角色名,按 `name` 引用 [`admin_role`](./admin_role.md)(#117 起数据驱动):内建 `super_admin`(恒全权)/ `finance` / `operator`,或自定义角色(#126) | +| `pages_override` | JSON | nullable | **个人可见页覆盖**(#126):非空时优先于角色 `pages`;NULL=跟随角色。页面 key 目录见 `app/admin/permissions.py` | | `status` | String(20) | NOT NULL, default `active` | 取值:`active` / `disabled`(禁用后 token 立即失效) | | `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间 | | `last_login_at` | DateTime(tz) | nullable | 最近登录时间(登录成功时更新) | @@ -30,4 +32,4 @@ ## 注意 - **鉴权隔离**:admin token `typ=admin` + 独立 `ADMIN_JWT_SECRET`(≠ App 的 `JWT_SECRET_KEY`),App 用户 token 无法当 admin 用;admin 无 refresh,过期(默认 12h)重登。 -- **RBAC**:`super_admin` 恒过所有角色守卫(`require_role`);`finance` 管钱、`operator` 管用户/反馈/大盘。具体守卫见各接口文档。 +- **RBAC**:`super_admin` 恒过所有角色守卫(`require_role`);`finance` 管钱、`operator` 管用户/反馈/大盘;#117 起可见页由 [`admin_role`](./admin_role.md)`.pages` 数据驱动 + `pages_override` 个人覆盖,自定义角色见 `POST /admin/api/roles`。具体守卫见各接口文档。 diff --git a/docs/database/analytics_event.md b/docs/database/analytics_event.md new file mode 100644 index 0000000..8583d58 --- /dev/null +++ b/docs/database/analytics_event.md @@ -0,0 +1,39 @@ +# analytics_event — 客户端埋点事件流 + +> 模型 `app/models/analytics_event.py` · 仓库 `app/repositories/analytics.py` · 接口 C 端 `POST /api/v1/analytics/events`([analytics-events](../api/other/analytics-events.md),批量,不强制登录);admin `GET /admin/api/event-logs`(`app/admin/routers/event_logs.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +客户端行为埋点的落地表,**一事件一行**(append-only)。客户端攒批上报(每批 ≤200 条),服务端展开逐条插入;`props` 装事件自带的任意维度。当前主要供 admin「埋点日志」检索与手工分析,无自动聚合任务。#83 新增(2026-06-27)。 + +## 用在哪 / 增删改查 +- **C(插入)**:`POST /api/v1/analytics/events`(不强制登录:带 JWT 则记 `user_id`,匿名只记 `device_id`)。服务端补 `client_ip`。 +- **U / D**:无。只追加。 +- **R**:admin `GET /admin/api/event-logs`(游标分页,可按 `event`/`device_id`/`user_id` 筛)。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明 | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `event` | String(64) | index, NOT NULL | 事件名(客户端约定,如页面曝光/点击类事件名) | +| `props` | JSON | nullable | 事件属性(任意维度,客户端原样传) | +| `device_id` | String(64) | index, NOT NULL | 客户端 per-install id(匿名也有) | +| `user_id` | Integer | index, nullable | 登录态才有。**无硬 FK** | +| `session_id` | String(64) | index, nullable | 客户端会话 id(一次冷启动一个) | +| `client_ts` | BigInteger | NOT NULL | 事件发生时间(客户端 epoch ms) | +| `sent_at` | BigInteger | nullable | 客户端上报时间(epoch ms);与 `client_ts` 差 = 攒批延迟 | +| `page` | String(64) | nullable | 事件所在页面 | +| `client_ip` | String(64) | nullable | 服务端从请求头取 | +| `oem` / `os` / `model` | String | nullable | 厂商 / 系统版本 / 机型 | +| `app_ver` | String(32) | nullable | App versionName | +| `network` | String(16) | nullable | 网络类型(wifi/cellular) | +| `channel` | String(32) | nullable | 分发渠道 | +| `created_at` | DateTime(tz) | server_default now() | 入库时间 | + +## 关系 / Join Key +- `user_id` 软指 `user.id`、`device_id` 与其他表的 per-install id 同源——均无硬 FK(埋点不鉴权、匿名也收,不能被外键约束卡住)。 + +## 索引与约束 +- PK `id`;index `event`、`device_id`、`user_id`、`session_id`。 + +## 注意 +- 每批最多 200 条,超出整批 422;单条字段超长按模型截断口径处理。 +- 时间轴分析用 `client_ts`(事件真实发生时刻),`created_at` 只是入库时刻(受攒批影响)。 diff --git a/docs/database/cash_transaction.md b/docs/database/cash_transaction.md index f782184..93566a6 100644 --- a/docs/database/cash_transaction.md +++ b/docs/database/cash_transaction.md @@ -1,17 +1,20 @@ # cash_transaction — 现金流水账本(分) -> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-cash-transactions](../api/wallet-cash-transactions.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) +> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-cash-transactions](../api/wallet/wallet-cash-transactions.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) 现金每变动一笔就记一行(单位:**分**,记变动后余额)。金币兑现金、提现、提现退款都落这里。**只增不改不删**。 +> **只管「金币兑换现金」这本账**(`coin_account.cash_balance_cents`)。**邀请奖励金**是另一本物理隔离的账 → [`invite_cash_transaction`](./invite_cash_transaction.md)(同构表);`source=invite_cash` 的提现流水**不落本表**。 + ## 用在哪 / 增删改查 -- **C(插入)**:三个来源,每次写一笔: +- **C(插入)**:四个来源,每次写一笔: | 动作 / endpoint | `biz_type` | `amount_cents` | `ref_id` 指向 | |---|---|---|---| | 金币兑现金 `POST /wallet/exchange` | `exchange_in` | + | null(配套 `coin_transaction.exchange_out`) | - | 发起提现 `POST /wallet/withdraw` | `withdraw` | −(扣现金) | `withdraw_order.out_bill_no` | + | 发起提现 `POST /wallet/withdraw`(`source=coin_cash`) | `withdraw` | −(扣现金) | `withdraw_order.out_bill_no` | | 提现失败/取消/审核拒绝退款 | `withdraw_refund` | +(退回) | `withdraw_order.out_bill_no` | + | admin 手动调整 `POST /admin/api/users/{id}/cash`(`account=coin_cash`) | `admin_grant` / `admin_deduct` | + / − | null(原因记 `remark`=`admin:`) | - **U / D**:无。账本只追加。 - **R**:`GET /wallet/cash-transactions`(现金明细,`id` 倒序游标);admin 跨用户现金流水。 diff --git a/docs/database/coin_account.md b/docs/database/coin_account.md index 7401abd..a2fd0b7 100644 --- a/docs/database/coin_account.md +++ b/docs/database/coin_account.md @@ -1,6 +1,6 @@ # coin_account — 金币 + 现金余额快照 -> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-account](../api/wallet-account.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) +> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-account](../api/wallet/wallet-account.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) 一用户一行的余额快照,App「资产卡 / 钱包」读它展示。每次余额变动都另写一笔流水(`coin_transaction` / `cash_transaction`)并记 `balance_after`,出问题逐笔回溯。`user_id` 既是主键也是外键(一对一)。详见 [总览 §四 资金模型](./OVERVIEW.md#四资金模型金币--现金--提现三层)。 @@ -15,7 +15,8 @@ |---|---|---|---| | `user_id` | Integer | **PK + FK→user.id** | 用户(一用户一行);既是主键也是外键 | | `coin_balance` | Integer | NOT NULL, default 0 | 当前金币余额(个数);= 历次 `coin_transaction.amount` 之和 | -| `cash_balance_cents` | Integer | NOT NULL, default 0 | 当前现金余额(分);= 历次 `cash_transaction.amount_cents` 之和 | +| `cash_balance_cents` | Integer | NOT NULL, default 0 | 当前现金余额(分,**金币兑换账本**);= 历次 `cash_transaction.amount_cents` 之和 | +| `invite_cash_balance_cents` | Integer | NOT NULL, default 0 | 当前**邀请奖励金**余额(分,与现金物理隔离的第二本现金账);= 历次 [`invite_cash_transaction`](./invite_cash_transaction.md)`.amount_cents` 之和。好友比价并下单发奖入账(#113),`source=invite_cash` 提现出账(#121) | | `total_coin_earned` | Integer | NOT NULL, default 0 | 累计赚取金币(**只增不减**,仅正向 grant 累加),用于"历史总收益"展示 | | `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最后更新时间 | @@ -27,5 +28,5 @@ - PK `user_id`(同时是 FK→user.id)。 ## 注意 -- **唯一发金币入口** `wallet.grant_coins`:更新本表 + 写 `coin_transaction`,**不 commit**,由调用方同事务提交(发币与业务记录原子化)。 -- 扣现金用 `UPDATE ... WHERE cash_balance_cents >= amount` 原子条件扣减,并发/重试不会超额。 +- **唯一发金币入口** `wallet.grant_coins`:更新本表 + 写 `coin_transaction`,**不 commit**,由调用方同事务提交(发币与业务记录原子化)。邀请奖励金同款:**唯一变动入口 `wallet.grant_invite_cash`**(更新 `invite_cash_balance_cents` + 写 `invite_cash_transaction`)。 +- 扣现金用 `UPDATE ... WHERE <对应余额列> >= amount` 原子条件扣减(按提现 `source` 选 `cash_balance_cents` 或 `invite_cash_balance_cents`),并发/重试不会超额。 diff --git a/docs/database/comparison_record.md b/docs/database/comparison_record.md index 4b637ce..0545631 100644 --- a/docs/database/comparison_record.md +++ b/docs/database/comparison_record.md @@ -1,25 +1,25 @@ # comparison_record — 比价记录(每次比价完整明细) -> 模型 `app/models/comparison.py` · 仓库 `app/repositories/comparison.py` · 接口 [compare-record-report](../api/compare-record-report.md) / [compare-records](../api/compare-records.md) / [compare-record-detail](../api/compare-record-detail.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) +> 模型 `app/models/comparison.py` · 仓库 `app/repositories/comparison.py` · 接口 [compare-record-report](../api/compare/compare-record-report.md) / [compare-records](../api/compare/compare-records.md) / [compare-record-detail](../api/compare/compare-record-detail.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -每完成一次比价(外卖/电商/领券),客户端在 done 帧后用**带 JWT** 的通道上报一条。App「我的比价记录」列表/详情的数据源,也是比价战绩里程碑解锁进度的计数源(`status='success'` 条数),还被「上报更低价」反查原最低价。 +每完成一次比价(外卖/电商/领券)记一行。**写入以 app-server 后端 harvest 为主**(2026-07 起):比价透传壳 `compare.py` 在帧0(pricebot 出 trace_id)即建 `running` 行,随 done / `trace/finalize` 逐步补全成终态,客户端不再主动 POST 记录;老客户端仍可走**带 JWT** 的 `POST /compare/record` 兜底(灰度期两条写路径按 `trace_id` reconcile)。App「我的比价记录」列表/详情的数据源,也是比价战绩里程碑解锁进度的计数源(`status='success'` 条数),还被「上报更低价」反查原最低价。 > 与 `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 最便宜),不信客户端自算。 +- **C / U(harvest 为主,按 `trace_id` 幂等)**:透传壳 `compare.py` 三段式落库(`app/repositories/comparison.py`)——`harvest_running`(帧0 建 `running` 行)→ `harvest_done`(done 帧转 `success`/`failed` + 派生 `best_*`/`saved_amount_cents`;`newly_success` 仅留日志观测,**不在此发邀请奖**——#113 已把发奖口径移到「实际下单」`POST /order/report`)→ `harvest_abort`(`trace/finalize` 转 `cancelled`/`failed`,**不降级已 success**)。老客户端仍可 `POST /compare/record`(`upsert_record`,按 `trace_id` 查、整行覆盖)兜底。`best_*`/`saved_amount_cents`/`is_source_best`/`status` 一律由 `_derive` 从 `comparison_results` 算出(协议已按 price 升序、rank=1 最便宜),不信客户端自算。 - **D**:无(关联的 `price_report` 也只把 `comparison_record_id` 置空,不删本表)。 -- **R**:`GET /compare/records`(列表,`created_at` 倒序游标 + 「已下单」标记)、`GET /compare/records/{id}`(详情,限本人);`count_success` 给里程碑;`get_stats`(`status='success'` 计数 + `saved_amount_cents` 求和)给 [`GET /compare/stats`](../api/compare-stats.md) 喂「我的」页省钱战绩卡(完成比价 + 累计发现可省,**比价口径**);`report.py` 反查 `best_*`;admin 大盘/明细。 +- **R**:`GET /compare/records`(列表,`created_at` 倒序游标 + 「已下单」标记)、`GET /compare/records/{id}`(详情,限本人);`count_success` 给里程碑;`get_stats`(`status='success'` 计数 + `saved_amount_cents` 求和)给 [`GET /compare/stats`](../api/compare/compare-stats.md) 喂「我的」页省钱战绩卡(完成比价 + 累计发现可省,**比价口径**);`report.py` 反查 `best_*`;admin 大盘/明细。 ## 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | 被 `price_report.comparison_record_id` 引用 | -| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | +| `user_id` | Integer | FK→user.id, index, **nullable**(2026-07 从 NOT NULL 放开) | 归属用户。后端 harvest 帧0 建行时(软鉴权 / 老客户端匿名)可能暂缺 → 可空;C 端「我的比价记录」按 `user_id` 过滤天然排除 null 行,admin 全看(含孤儿行) | | `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_id` | String(64) | NOT NULL, **UNIQUE** | 一次比价的唯一标识。**由 app-server 帧0 用 uuid 签发**(注入转发 body + 回填响应顶层给客户端;老客户端自带),全局唯一 = harvest upsert 键 + 关联 pricebot 调试落盘 | | `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 包名 | @@ -30,8 +30,9 @@ | `saved_amount_cents` | Integer | nullable | 源价 − 最优价(可 0/负:源平台本就最便宜) | | `is_source_best` | Boolean | nullable | 源平台就是最便宜(= 这次没省到) | | `store_name` | String(128) | nullable | 店铺名。**与 `savings_record.shop_name` 按字符串相等关联**,给本记录打「已下单」 | +| `product_names` | String(512) | nullable | 菜品/商品名拼接串(`items[].name` 顿号连接,超长截断),**专供 admin 比价记录按店/商品模糊搜索**(#117:对 JSON 列做 LIKE 不可移植,冗余成扁平列;写入时随 harvest/upsert 同步生成)。C 端不读它 | | `total_dish_count` / `skipped_dish_count` | Integer | nullable | 菜品总数 / 目标平台没找到被跳过数 | -| `status` | String(16) | NOT NULL, default `success` | 取值:`success`(有非源且有价的目标结果)/ `failed`(出错/没采到目标价)。**里程碑只数 success** | +| `status` | String(16) | NOT NULL, default `success` | 取值:`running`(harvest 帧0 建行、比价进行中)/ `success`(有非源且有价的目标结果)/ `failed`(出错/没采到目标价)/ `cancelled`(用户终止 / Phase1 未识别,`harvest_abort` 写,**不降级已 success**)。**里程碑只数 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,applied_coupons}]`;`coupon_saved`=该平台主优惠额(美团红包/淘宝平台红包/京东百亿补贴,只取一笔),`coupon_name`=优惠来源名(展示用),`applied_coupons`=`[{name,amount}]` 多券明细 | @@ -39,6 +40,8 @@ | `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报全量(calibration + done.params),取数兜底 | | `input_tokens` | Integer | nullable | 本次 LLM 累计输入 token = Σ `llm_calls[].usage.prompt_tokens`(server 收上报后从 `llm_calls` 累加;旧记录/未采集为 null) | | `output_tokens` | Integer | nullable | 本次 LLM 累计输出 token = Σ `llm_calls[].usage.completion_tokens`(同上) | +| `llm_cost_yuan` | Float | nullable | 本次比价 LLM 总成本(元),回填时按「当时价」逐模型算好冻结(见 `services/llm_cost.py`);旧记录/未回填为 null → 前端回退「估算成本」 | +| `llm_price_snapshot` | JSON(PG: JSONB) | nullable | 算成本所用单价快照 `{mode, prices:{model:{input_per_1m,output_per_1m,_source}}}`;`app_config` 只存当前价、不留历史,故冻结当时价供审计/复算 | | `created_at` | DateTime(tz) | server_default now(), index | 时间 | > `ordered`(已下单)是**瞬态字段**,不在表里:`list_records` 读取时按 `store_name ∈ 该用户 source='compare' 的 savings_record.shop_name 集合` 现挂到实例上供出参用。 @@ -50,7 +53,8 @@ - 被 `comparison_milestone_claim` 间接依赖:解锁进度 = 本表 `status='success'` 计数。 ## 索引与约束 -- 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`(幂等覆盖)。 +- PK `id`;index `user_id`、`business_type`、`created_at`;复合 index `ix_comparison_status_created`(`status`, `created_at`)(按 `status='success'` 过滤 + 近期排序的聚合/轮播,避免随数据量退化为全表扫);UNIQUE(`trace_id`) = `uq_comparison_trace`(harvest 按它 upsert,一次比价一行)。 + > 2026-07 迁移 `comparison_record_trace_unique`:唯一键从复合 `uq_comparison_user_trace`(`user_id`,`trace_id`)改为 `trace_id` 单列——harvest 帧0 建行时 user_id 可能暂缺,不能再用复合键去重。上线前须确认历史无重复 `trace_id`(`SELECT trace_id,COUNT(*) c FROM comparison_record GROUP BY trace_id HAVING c>1`),否则建单列唯一会失败。 ## 注意 - 4 个 JSON 列用 `JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。结构化金额列存「分」,`comparison_results.price`/`coupon_saved` 原样存「元」。 diff --git a/docs/database/coupon_session.md b/docs/database/coupon_session.md new file mode 100644 index 0000000..dcbc650 --- /dev/null +++ b/docs/database/coupon_session.md @@ -0,0 +1,44 @@ +# coupon_session — 领券任务全程流水(admin「领券数据」看板数据源) + +> 模型 `app/models/coupon_state.py`(`CouponSession`) · 仓库 `app/repositories/coupon_state.py`(`upsert_session`) · 接口 C 端 `POST /api/v1/coupon/session`([coupon-session](../api/coupon/coupon-session.md));admin `GET /admin/api/coupon-data/*`(聚合看板 + 明细,`app/admin/routers/coupon_data.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +**一次领券任务一行**(`trace_id` 唯一),记从发起(`started`)到收尾(`completed`/`failed`/`abandoned`)的全程:总耗时、各平台耗时、领到张数、机型/ROM、发起来源。与同文件的三张「今日状态」表([coupon_state](./coupon_state.md))分工不同:那三张按「设备×日」管**频控/置灰**,本表按「一次任务」管**漏斗与体验指标**——admin「领券数据」看板的发起数、完成率、中途流失(started 无终态)、平均耗时都从这算。#99 新增(2026-06-30)。 + +## 用在哪 / 增删改查 +- **C / U(upsert,按 `trace_id`)**:客户端 `POST /api/v1/coupon/session` **两段上报**——发起时建行(`status='started'`,带 platforms/origin_package/机型),收尾时按同 `trace_id` 更新同一行(终态 + `elapsed_ms` + `platform_elapsed` + `claimed_count`)。发起即落库 → 收尾丢失(App 被杀/断网)的行永远停在 `started` = 中途流失,可量化。 +- **D**:无。 +- **R**:admin `GET /admin/api/coupon-data`(按 `started_date` × `app_env` 聚合趋势/漏斗 + 逐条明细,join `user` 出手机号)+ `GET /admin/api/coupon-data/user-records`(用户抽屉:某用户全部领券记录)。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `trace_id` | String(64) | NOT NULL, **UNIQUE**(`uq_coupon_session_trace`) | 一次领券唯一 id(客户端 UUID,全程贯穿),upsert 幂等键;与 pricebot work_logs / `coupon_claim_record.trace_id` 同源 | +| `device_id` | String(64) | NOT NULL | 客户端 per-install id | +| `user_id` | Integer | index, nullable | 登录态才带(admin join `user` 出手机号/昵称);匿名领券为空。**无硬 FK** | +| `status` | String(16) | NOT NULL | `started`(发起)/ `completed` / `failed` / `abandoned`(用户中止)。`started` 无终态 = 中途流失 | +| `app_env` | String(16) | index, nullable | `prod` / `dev`(客户端 BuildConfig.DEBUG)。admin 报表默认只看 prod,防测试数据串台 | +| `platforms` | JSON(PG: JSONB) | nullable | 发起勾选平台 `["meituan-waimai", ...]`;空=全领 | +| `origin_package` | String(64) | nullable | 发起来源外卖 App 包名;null=App 内(傻瓜比价首页)发起,非空=切到美团/淘宝/京东被弹券引导发起。admin「发起平台」列据此区分 | +| `device_model` | String(128) | nullable | 机型(Build.MANUFACTURER + MODEL) | +| `rom` | String(64) | nullable | ROM(OemDetector,如 `ColorOS 14`) | +| `started_at` | DateTime(tz) | NOT NULL | 发起时刻(客户端墙钟) | +| `started_date` | Date | NOT NULL | 发起的 Asia/Shanghai 自然日;admin 按天聚合/筛选(复合索引) | +| `finished_at` | DateTime(tz) | nullable | 收尾时刻(服务端 now);未收尾(流失)为空 | +| `elapsed_ms` | Integer | nullable | 全程耗时(客户端点发起→收尾计时,权威);平均/分位只统计 completed | +| `platform_elapsed` | JSON(PG: JSONB) | nullable | 各平台领券耗时 `{"meituan-waimai": 3200, ...}`(ms) | +| `claimed_count` | Integer | nullable | 本次领到张数(收尾上报) | +| `trace_url` | String(512) | nullable | pricebot 公网调试链接(admin 明细可点开复盘) | +| `created_at` / `updated_at` | DateTime(tz) | server_default now() / +onupdate | | + +## 关系 / Join Key +- `user_id` 软指 `user.id`(无硬 FK,可空;admin 明细 LEFT JOIN 出手机号)。 +- `trace_id` 与 pricebot work_logs、[`coupon_claim_record`](./coupon_state.md).`trace_id` 同源(一次任务),无 id 级 join。 +- `device_id` 与领券三表同源(per-install id)。 + +## 索引与约束 +- PK `id`;UNIQUE `trace_id`;index `user_id`、`app_env`;复合 index `ix_coupon_session_date_env`(`started_date`, `app_env`)= admin 看板主查询路径。 + +## 注意 +- 写库 best-effort 口径与领券三表一致:上报失败不影响领券本身(且本表数据源是客户端**独立上报**,不是 `/coupon/step` 透传顺手写)。 +- `elapsed_ms` 以客户端计时为准(墙钟差不影响);`finished_at - started_at` 只做时刻留痕,不用来算时长。 diff --git a/docs/database/coupon_state.md b/docs/database/coupon_state.md index c6559b8..f131465 100644 --- a/docs/database/coupon_state.md +++ b/docs/database/coupon_state.md @@ -1,14 +1,16 @@ -# coupon_state — 领券今日状态三张表(弹窗频控 / 首页置灰 / 领券记录) +# coupon_state — 领券状态表(今日状态三张 + 领券流水 coupon_session) > 模型 `app/models/coupon_state.py` · 仓库 `app/repositories/coupon_state.py` · 接口 `app/api/v1/coupon.py`(prefix `/api/v1/coupon`) · [← 索引](./README.md) · [总览](./OVERVIEW.md) +> 同模型文件里还有第四张表 [`coupon_session`](./coupon_session.md)(一次领券任务一行的全程流水,admin「领券数据」看板数据源,#99)——维度与本文三张「设备×日」状态表不同,单独成文。 领券(优惠券自动化)联动产生的三张「今日状态」表,都挂在领券透传端点 `POST /api/v1/coupon/step` 这条链路上(pricebot 跑领券,结果回 app-server 落库;**领券过程本身在 pricebot 内存态跑、不落库**)。三表各管一件事: - **`coupon_prompt_engagement`** — 弹窗频控源。按 `(device, App 包名, 自然日)` 记「今天**这个 App** 是否对领券引导窗表达过**意向**」(弹出即记 `shown` / 点「一键领取」=`claim_started` / 点拒绝关闭=`dismissed` 都算)。切到外卖 App 时据此决定弹不弹:今天**该 App** engage 过就不再弹该 App。频控维度自 2026-06-14 起含 `package`,美团/淘宝/京东各自独立、互不压制。 - **`coupon_daily_completion`** — 首页置灰源。按 `(device, 自然日)` 记「今天是否已**跑完整轮**领券(到 done 帧)」。首页「去领取」卡据此置灰:今天跑完了就不能再领。 - **`coupon_claim_record`** — 资产沉淀层。按 `(device, 券, 自然日)` 记每张券的领取结果(success/already_claimed/failed/skipped),**纯沉淀**(资产/画像/排查/CPS 归因),当前**不参与**「要不要领 / 弹不弹」的判断。 +- **`coupon_session`** — admin「领券数据」看板数据源(**独立流水表,不是「今日状态」表**)。按 `trace_id` 一次领券一行,走 `POST /api/v1/coupon/session` 两段上报(发起/收尾),记全程耗时 + 各平台耗时 + `platform_success`(成功平台,算整单②/点位③成功率)。详见下方专节。 -三表共同口径: +前三张「今日状态」表的共同口径: - **判断维度是 `device_id`,不是 `user_id`**:券发到的是设备上登录的那个外卖账号,device 比 user 更贴近「哪个登录环境」,且 `device_id` 全链路现成、不依赖领券鉴权(领券 MVP 阶段 `/coupon/step` 不鉴权)。客户端 `getOrCreateDeviceId` 生成存 SP,**卸载重装会变 → 当新设备重新弹一次**(产品预期)。 - **日期 = `Asia/Shanghai` 自然日**(`claim_date` / `engage_date` / `complete_date`,`repositories/coupon_state.today_cn()`)。每日可领的券(签到/天天红包)靠这天然每天一条。 - **`user_id` 可空**:领券登录态有就记(资产/画像),可空、**不进唯一键、不阻塞判断**。 @@ -90,7 +92,7 @@ ### 用在哪 / 增删改查 - **C / U(幂等 upsert)**:`record_claims`,由 `POST /api/v1/coupon/step` 写入。一帧的券结果来自 pricebot 的 `last_coupon_result`(最后一张)+ `action.params.coupon_results`(全量)——**会重复带同一张券**,端点 `_extract_coupon_results` 先**按 `coupon_id` 去重**(全量覆盖单张),仓库再靠唯一键幂等:已有则更新 `status`/`reason`/`claimed_count`/`extra`(以最后一次为准),否则插入。 - **U / D**:无业务删除。 -- **R**:**当前无读取端点**(纯写入沉淀,未来做去重/归因/画像时再用)。 +- **R**:`GET /admin/api/coupon-data/coupons`(`coupon_slot_report`)—— admin「按券成功率」表,按 `coupon_id` 聚合 成功/(成功+失败)(`skipped` 排除,设备-天口径,按 `app_env` 过滤)。见设计 §13。 ### 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | @@ -101,6 +103,7 @@ | `coupon_id` | String(64) | NOT NULL | 券标识(取自 pricebot 结果) | | `claim_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`);每日可领的券靠它天然每天一条 | | `status` | String(24) | NOT NULL | `success` / `already_claimed` / `failed` / `skipped`(原样取 pricebot coupon 结果) | +| `app_env` | String(16) | index, 可空 | 领券所属 session 环境 `prod`/`dev`(`/step` 按 `trace_id` 取 `coupon_session.app_env` 打标);旧行 NULL(不回填)。admin「按券成功率」表按它过滤。见设计 §13 | | `vendor` | String(48) | 可空 | 券提供方 | | `coupon_name` | String(128) | 可空 | 取 pricebot `name` | | `claimed_count` | Integer | 可空 | 这张领到几张(pricebot `display_count`,给不出时 None;兼容 `claimed_count`) | @@ -120,7 +123,49 @@ --- -## 三表共性小结 +## coupon_session — 领券任务流水(一次领券一行,admin「领券数据」看板数据源) + +`trace_id` 唯一,一次领券一行。与上面三张「今日状态」表不同:本表走 `POST /api/v1/coupon/session`(客户端**两段上报**:发起 `started` 建行、收尾 `completed`/`failed`/`abandoned` 按 `trace_id` 更新同一行),记从发起到收尾的全程耗时 + 各平台耗时 + 机型/ROM。发起即落库 → admin 可算「发起数」与中途流失(started 无终态 = 未完成)。 + +### 用在哪 / 增删改查 +- **C / U(幂等 upsert)**:`upsert_coupon_session`,由 `POST /api/v1/coupon/session` 两段上报。**状态只前进**(started 帧重复到不覆盖已有终态);终态补 `finished_at`。 +- **U(并集写)**:`merge_session_platform_success`,由 `POST /api/v1/coupon/step` 每逢**带券结果的帧**调用——把本帧「成功平台」(`status∈{success,already_claimed}` 的券 → `coupon_id` 前缀映射平台)**并入** `platform_success`(并集幂等,无新平台不写;读不到该 trace 行则跳过)。复用 `record_claims` 的同一 `SessionLocal`,不新增连接。 +- **R**:admin `GET /admin/api/coupon-data`(`coupon_data_report`)—— 发起/完成数、耗时分位、**整单成功率②/点位成功率③**、按天/小时趋势、逐条明细;`GET /admin/api/coupon-data/user-records` 某用户全部领券。 + +### 字段 +| 列 | 类型 | 约束 / 默认 | 说明 | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `trace_id` | String(64) | NOT NULL, UNIQUE | 一次领券唯一 id(客户端 UUID,全程贯穿),upsert 键 | +| `device_id` | String(64) | NOT NULL | | +| `user_id` | Integer | index, 可空 | 登录态才带(admin join 用户表出手机号/昵称);匿名领券为空 | +| `status` | String(16) | NOT NULL | `started` / `completed` / `failed` / `abandoned`;started 无终态 = 中途流失 | +| `app_env` | String(16) | index, 可空 | `prod` / `dev`;admin 报表默认只看 prod(防测试串台) | +| `platforms` | JSON | 可空 | 发起勾选平台 `["meituan-waimai",…]`(空 = 全领三档);**③点位成功率的分母来源** | +| `origin_package` | String(64) | 可空 | 发起来源外卖 App 包名;null = App 内(傻瓜比价首页)发起,非空 = 外卖侧弹券 | +| `device_model` | String(128) | 可空 | Build.MANUFACTURER + MODEL | +| `rom` | String(64) | 可空 | OemDetector,如 "ColorOS 14" | +| `started_at` | DateTime(tz) | NOT NULL | 发起时刻(客户端墙钟);明细「时间」列、趋势 X 轴 | +| `started_date` | **Date** | NOT NULL | 发起的**北京**自然日;admin 按天聚合/筛选(索引) | +| `finished_at` | DateTime(tz) | 可空 | 收尾时刻(服务端 now);未收尾(流失)为空 | +| `elapsed_ms` | Integer | 可空 | 全程耗时(ms,客户端点发起→收尾);均值/分位只统计 completed | +| `platform_elapsed` | JSON | 可空 | 各平台领券耗时 `{"meituan-waimai":3200,…}`(ms) | +| `claimed_count` | Integer | 可空 | 领到总张数(收尾帧带) | +| `platform_success` | JSON(PG JSONB) | 可空 | **本次至少领到一张(`status∈{success,already_claimed}`)的平台 id 列表**,如 `["meituan-waimai","jd-waimai"]`。`/step` 逐帧按 `trace_id` **并集**写入(`merge_session_platform_success`);旧行 NULL 视作空集。admin 据此算整单成功率②(`platforms`⊆`platform_success`)/点位成功率③(Σ交集/Σ勾选)。设计:[领券成功率指标](../guides/领券成功率指标-设计与埋点.md) | +| `trace_url` | String(512) | 可空 | pricebot done 帧回传的公网 trace 链接;未到 done(failed/abandoned)为空 | +| `created_at` | DateTime(tz) | server_default now() | | +| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | | + +### 索引与约束 +- PK `id`;index `user_id`、`app_env`;UNIQUE(`trace_id`) = `uq_coupon_session_trace`;Index(`started_date`, `app_env`) = `ix_coupon_session_date_env`(admin 主聚合/筛选)。 + +### 注意 +- `platform_success` 是**布尔性质**的平台集,跨帧**并集**天然幂等 → `/step` 每帧并入不重复计;失败/中途退出的 session 也能拿到崩溃前已成的平台。写放大 ≈ 领券券数(仅带券结果的帧写)。 +- 成功率**基数 = 区间全部 session**(含 abandoned/failed),与「发起数」同基数(设计 §3)。`coupon_id → 平台` 用前缀(`mt_`/`tb_`·`ele_`·`elm_`/`jd_`),与客户端 `couponIdToPlatform` 同词表。 + +--- + +## 今日状态三表共性小结 - 数据流向:客户端 → `POST /api/v1/coupon/step`(透传给 pricebot)→ 结果回写这三张表(best-effort,写库失败不影响领券)。 - 唯一键都含 `device_id` + 某个北京自然日列(engagement 还含 `package`,按 App 频控);`user_id` 永远是可空旁路(资产留痕,不进唯一键、不阻塞判断)。 - 无硬外键:`user_id` 软指 `user.id`、`trace_id` 软指 pricebot work_logs(详见 [OVERVIEW → 表间关系 & Join Key](./OVERVIEW.md))。 diff --git a/docs/database/cps_order.md b/docs/database/cps_order.md index ad022a2..44c02fe 100644 --- a/docs/database/cps_order.md +++ b/docs/database/cps_order.md @@ -2,7 +2,7 @@ > 模型 `app/models/cps_order.py` · 仓库 `app/admin/repositories/cps.py`(`reconcile_orders` / `_map_order_fields` / `list_orders`、统计 `group_stats`) · 接口 admin `POST /admin/api/cps/orders/reconcile`、`GET /admin/api/cps/orders`、`GET /admin/api/cps/stats` · [← 索引](./README.md) · [总览](./OVERVIEW.md) -从美团联盟 `query_order` 按时间窗拉回、按 `sid` 归群的订单明细,是 CPS 群发漏斗的**最下游"赚了多少佣金"**:用户点 [`cps_link`](./cps_link.md)(携群 [`cps_group`](./cps_group.md) 的 `sid`)下单后,美团把订单连同 `sid` 回传,这里按 `sid` 归群做对账,与 [`cps_click`](./cps_click.md) 的点击量在 `group_stats` 汇成"点击→下单→佣金"漏斗。**仅美团有此表**——淘宝/京东无对账 API,统计里对账字段显示 `-`。 +从联盟 API 按时间窗拉回、按 `sid` 归群的订单明细,是 CPS 群发漏斗的**最下游"赚了多少佣金"**:用户点 [`cps_link`](./cps_link.md)(携群 [`cps_group`](./cps_group.md) 的 `sid`)下单后,联盟把订单连同 `sid` 回传,这里按 `sid` 归群做对账,与 [`cps_click`](./cps_click.md) 的点击量在 `group_stats` 汇成"点击→下单→佣金"漏斗。**平台覆盖**:初版仅美团(`query_order`);#90(2026-06-30)接入**京东联盟**订单(拉单进同一张表,`platform='jd'`,配 `jd_*`/`external_*` 列,喂数据大盘);淘宝仍无对账 API,统计里对账字段显示 `-`。 ## 用在哪 / 增删改查 - **C/U(upsert)**:`POST /admin/api/cps/orders/reconcile`(`reconcile_orders`,需 `finance` 角色)。调 `meituan.query_order(sid?, start_time, end_time, page, limit=100)` 分页拉单(`max_pages=200` 防死循环),每条经 `_map_order_fields` 转字段,按 `order_id` **upsert**:不存在则 insert,存在则逐字段覆盖(订单状态会随时间变 付款→完成→结算/退款,重复拉则更新)。返回 `{fetched, inserted, updated, pages}`。`order_id` 全局唯一即幂等键。 @@ -15,7 +15,10 @@ | 列 | 类型 | 约束 / 默认 | 说明(取值 / join / 源字段) | |---|---|---|---| | `id` | Integer | PK, autoincrement | | -| `order_id` | String(64) | **UNIQUE, index, NOT NULL** | 美团订单号(加密串),`orderId`。upsert 幂等键 | +| `platform` | String(20) | index, NOT NULL, default `meituan` | 订单来源联盟:`meituan` / `jd`(#90)。统计/大盘按它分平台 | +| `order_id` | String(64) | **UNIQUE, index, NOT NULL** | 订单号(美团加密串 `orderId`;京东为联盟订单号)。upsert 幂等键 | +| `external_order_id` | String(128) | index, nullable | 平台原始订单号(#90,京东 `orderId`;美团 null) | +| `external_row_id` | String(128) | index, nullable | 平台订单行号(#90,京东一单多 sku 时区分行;美团 null) | | `sid` | String(64) | index, **nullable** | 渠道追踪位 = 群 `sid`,源 `sid`。**按它归群聚合**;历史无 sid 订单为空 | | `act_id` | String(64) | index, nullable | 活动物料 ID,源 `actId`(转字符串) | | `biz_line` | Integer | nullable | 业务线,源 `businessLine`。`1=外卖` | @@ -25,9 +28,14 @@ | `commission_rate` | String(16) | nullable | 佣金率,源 `commissionRate`。`"300"=3%`、`"10"=0.1%`(原样字符串,前端解释) | | `refund_price_cents` | Integer | nullable | 退款金额(分),源 `refundPrice`(元) | | `refund_profit_cents` | Integer | nullable | 退款佣金(分),源 `refundProfit`(元) | -| `mt_status` | String(8) | index, nullable | 美团订单状态,源 `status`:`2`付款 `3`完成 `4`取消 `5`风控 `6`结算 | +| `estimated_commission_cents` | Integer | nullable | 预估佣金(分,#90 京东口径;美团用 `commission_cents`) | +| `actual_commission_cents` | Integer | nullable | 实际/结算佣金(分,#90 京东口径) | +| `mt_status` | String(8) | index, nullable | 美团订单状态,源 `status`:`2`付款 `3`完成 `4`取消 `5`风控 `6`结算;京东订单为 null | +| `jd_valid_code` | String(16) | index, nullable | 京东订单有效码(#90,联盟 `validCode`,判有效/无效/风控);美团订单为 null | | `invalid_reason` | String(128) | nullable | 失效原因,源 `invalidReason` | | `product_name` | String(512) | nullable | 商品名,源 `productName`(超 500 截断) | +| `settle_month` | String(16) | nullable | 结算月份(#90 京东) | +| `site_id` / `position_id` / `pid` | String(128) | nullable | 京东推广位维度(#90):站点/推广位/联盟 pid,归因用 | | `pay_time` | DateTime(tz) | index, nullable | 付款时间,源 `payTime`(秒级 ts)。统计按时间窗过滤的就是它 | | `mt_update_time` | DateTime(tz) | nullable | 美团侧更新时间,源 `updateTime`(秒级 ts) | | `raw` | JSON / JSONB | NOT NULL, default `{}` | `query_order` 单条原始 dataList,留底排查/补字段 | @@ -48,4 +56,5 @@ - **"未归群"独立行**:`group_stats` 遍历完所有群后,对剩下的、有订单但 `sid` 不属任何现存群的 sid(历史遗留如 `wonderableai`、或别处来源、或群被删),单列一行 `group_id=None`、有对账无点击。所以本表 `sid` 可空/可孤立是设计内的,不是脏数据。 - **金额/时间统一转换的原因**:`query_order` 返回金额是「元」字符串、时间是秒级 ts;`_yuan_to_cents`(Decimal 防浮点,`"null"`/空 → None)与 `_ts_to_dt`(转 tz-aware UTC,前端按北京展示)在入库时归一,与全站"金额存分、时间存 tz-aware"口径对齐。`raw` 整条留底,字段不够时不用重拉。 - **upsert 而非 append**:同一订单会被多次拉到(状态变化),按 `order_id` 覆盖即可拿到最新状态;`reconcile` 可重复跑(幂等)。 -- **本表自始至终只服务美团**:初版(`277f9b1`)就定型,`3a40f61` 接淘宝/京东时**没动本表**——那俩平台无对账 API,统计里对账列直接给 `None`(前端显示 `-`)。这是"对账 = 美团专属"的边界。 +- **平台边界的演变**:初版(`277f9b1`)只服务美团;#90(2026-06-30)接入**京东联盟**拉单进同一张表(`platform='jd'` + `external_*`/`jd_valid_code`/`estimated|actual_commission_cents` 等列,有效性按 `jd_valid_code` 判,喂 admin 数据大盘的京东收益)。**淘宝仍无对账 API**,统计对账列给 `None`(前端显示 `-`)。 +- **#119 修 `pay_time` 缺失**:早期美团拉单部分订单 `payTime` 空导致 `pay_time` 为 null → 大盘按时间窗过滤漏算美团收益;#119 起入库补齐/回填,时间窗统计以 `pay_time` 为准。 diff --git a/docs/database/device_liveness.md b/docs/database/device_liveness.md index 277c367..f2ef1ea 100644 --- a/docs/database/device_liveness.md +++ b/docs/database/device_liveness.md @@ -9,7 +9,7 @@ ## 用在哪 / 增删改查 - **C / U(upsert)**:`register_or_update`(`POST /device/register`,App 拿到 push token 时调)按 `(user_id, device_id)` upsert,只在传入非空时更新 `registration_id`/`platform`/`app_version`;`touch_heartbeat`(`POST /device/heartbeat`,无障碍服务存活时周期调,**心跳也能自注册**)在 `accessibility_enabled=true` 时刷 `last_heartbeat_at`、置 `ever_protected=true`、状态机重置回 `alive`、清 `notified_at`(掉线恢复→下次再断才再推一条)。 - **U(worker)**:`mark_notified`(`heartbeat_monitor_worker` 检出掉线后)置 `liveness_state='notified'` + `notified_at` + **`kill_alert_pending=True`**;`ack_kill_alert`(`POST /device/liveness/ack`,客户端弹过引导后)清 `kill_alert_pending`(幂等)。 -- **R**:`list_overdue`(worker 扫描:`ever_protected=true` + `liveness_state='alive'` + `last_heartbeat_at` 早于 `now - timeout`)→ 掉线设备列表;`get_device`(`GET /device/liveness`,客户端进 App 拉本机是否被判掉线过)。 +- **R**:`list_overdue`(worker 扫描:`ever_protected=true` + `liveness_state='alive'` + `last_heartbeat_at` 早于 `now - timeout`)→ 掉线设备列表;`get_device`(`GET /device/liveness`,客户端进 App 拉本机是否被判掉线过);admin `GET /admin/api/device-liveness/stats` + 列表(#80,后台「设备存活监控」页:总数/在线/掉线卡片 + 明细)。 - **D**:无。 ## 字段 @@ -22,6 +22,7 @@ | `platform` | String(16) | NOT NULL, default `android` | | | `app_version` | String(32) | nullable | 上报时 App 版本 | | `ever_protected` | Boolean | NOT NULL, default false | 收到过 service 心跳即 true(=该设备开过无障碍,功能对它有意义)。`list_overdue` 的过滤前提 | +| `first_protected_at` | DateTime(tz) | nullable | **首次**开启无障碍(首次收到心跳)的时刻(#80);置 `ever_protected=true` 时一并写、之后不再变。admin 设备存活监控用它算「开启保护耗时/转化」 | | `last_heartbeat_at` | DateTime(tz) | index, nullable | 最近一次 service 心跳时间(存活证明);超时即视为保护掉线 | | `last_report_protection_on` | Boolean | NOT NULL, default false | 最近一次上报的无障碍开关状态(观测用) | | `liveness_state` | String(16) | NOT NULL, default `unknown` | 状态机:`unknown` → `alive`(收到 service 心跳)→ `silent`/`notified`(扫描发现超时并已推送);心跳恢复 handler 重置回 `alive` | @@ -40,4 +41,4 @@ ## 注意 - **`kill_alert_pending` 为什么和 `liveness_state` 解耦**:服务随 App 重启会先发心跳把 `state` 重置回 `alive`,若复用 `state` 判「待提醒」,客户端进 App 这一刻可能恰好已被重置 → 漏看这次掉线。故另设一个只由 worker 置、只由客户端 ack 清的标记,规避竞态。 - **`list_overdue` 本期不要求有 `registration_id`**:本期只做终端打印检测、未真推送,没接极光 token 的设备也要检出。 -- 心跳超时阈值由 worker 的 `timeout_minutes` 决定(不在表里)。 +- 心跳超时阈值由 worker 的 `timeout_minutes` 决定(不在表里);#107 起默认 **1 小时**(原 10 分钟误报率高:息屏/省电模式下心跳会正常停发)。 diff --git a/docs/database/feedback.md b/docs/database/feedback.md index d916b30..f867e0c 100644 --- a/docs/database/feedback.md +++ b/docs/database/feedback.md @@ -1,14 +1,14 @@ -# feedback — 用户帮助与反馈 +# feedback — 用户帮助与反馈(含审核发奖) -> 模型 `app/models/feedback.py` · 仓库 `app/repositories/feedback.py` · 接口 [feedback](../api/feedback.md) · admin [admin-feedbacks-list](../api/admin-feedbacks-list.md) / [admin-feedback-handle](../api/admin-feedback-handle.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) +> 模型 `app/models/feedback.py` · 仓库 `app/repositories/feedback.py` · 接口 C 端 [feedback](../api/other/feedback.md) / [feedback-records](../api/other/feedback-records.md);admin `GET /admin/api/feedbacks`、`/summary`、`POST /{id}/approve|reject|handle`(`app/admin/routers/feedback.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -App「帮助与反馈」每次提交写一行。`content` 必填;`contact` 原必填,**原型改版后客户端不再采集,新数据存空串**(列保持 NOT NULL、免迁移,历史数据仍有值);`images` 为可选截图(≤6 张)。后台人工处理后置 `handled`。与 `price_report`(结构化上报更低价)不同,本表是**自由文本**反馈。 +App「帮助与反馈」每次提交写一行。2026-06 起演进为**轻审核工单**:运营在后台采纳(`adopted`,可发金币)/拒绝(`rejected`,填原因)并可写**运营回复**(#105),C 端「我的反馈」列表把状态与回复展示给用户;提交侧自动采集**来源/场景 + 端环境**(App 版本/机型/ROM/Android 版本,#94)供排障。与 `price_report`(结构化上报更低价)不同,本表是**自由文本**反馈。 ## 用在哪 / 增删改查 -- **C(插入)**:`POST /api/v1/feedback`(multipart:`content` + 可选 `contact` + 可选 `images`;`create_feedback`)。截图先经 `core.media` 落 `/media/feedback/` 拿相对路径,再随反馈写入,`status='new'`。 -- **U(更新)**:admin 处理反馈 `update_feedback_status` → `status='handled'`(同事务写 `admin_audit_log`)。 +- **C(插入)**:`POST /api/v1/feedback`(multipart:`content` + 可选 `contact`/`images`/`source`/`scene`;客户端自动带 `app_version`/`device_model`/`rom_name`/`android_version`)→ `status='pending'`。截图经 `core.media` 落 `/media/feedback/`。 +- **U(更新,admin,均写 `admin_audit_log`)**:`approve`(→`adopted`,可选发金币 `reward_coins`,走 `grant_coins` 同事务)/ `reject`(→`rejected`,`reject_reason`)/ `handle`(→`handled`,旧口径「标记已处理」保留)。三者均可写 `admin_reply`(用户可见回复)与 `review_note`(内部备注)。 - **D**:无。 -- **R**:admin 反馈列表(可按 `status` 筛)。C 端当前无"我的反馈列表"读接口。 +- **R**:C 端 `GET /api/v1/feedback/records`(我的反馈历史,展示状态/回复/奖励);admin 列表(状态/来源筛选)+ `summary`(各状态计数)。 ## 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | @@ -16,17 +16,31 @@ App「帮助与反馈」每次提交写一行。`content` 必填;`contact` 原 | `id` | Integer | PK, autoincrement | | | `user_id` | Integer | FK→user.id, index, NOT NULL | 提交用户 | | `content` | Text | NOT NULL | 反馈正文 | -| `contact` | String(128) | NOT NULL | 联系方式(微信/QQ/手机)。客户端改版后不再采集,新数据为空串;列仍 NOT NULL | -| `images` | JSON | nullable | 截图相对 URL 列表 `/media/feedback/...`;无图为 NULL | -| `status` | String(16) | NOT NULL, default `new` | 取值:`new`(待处理)/ `handled`(已处理) | +| `contact` | String(128) | NOT NULL | 联系方式。客户端改版后不再采集,新数据为空串;列仍 NOT NULL | +| `source` | String(16) | NOT NULL, default `profile`, index | 提交入口来源(#105):`profile`(设置/我的页普通反馈)/ 比价场景值;客户端显式传优先,未传由 `scene` 有无派生 | +| `scene` | String(32) | nullable | 比价反馈的「问题场景」(找错商品/优惠不对/比价太慢…,#105);比价结果页反馈才有,普通反馈为 NULL | +| `images` | JSON | nullable | 截图相对 URL 列表 `/media/feedback/...` | +| `app_version` | String(32) | nullable | 提交时 App versionName(#94) | +| `device_model` | String(64) | nullable | 机型 Build.MODEL(#94) | +| `rom_name` | String(32) | nullable | ROM(OemDetector:ColorOS/MIUI/…,#94) | +| `android_version` | String(16) | nullable | Android 版本(#94) | +| `status` | String(16) | NOT NULL, default `pending`, index | `pending`(待处理)/ `adopted`(已采纳,#94)/ `rejected`(已拒绝)/ `handled`(旧「已处理」口径,兼容保留;历史另有 `new`) | +| `reject_reason` | String(256) | nullable | 拒绝原因(用户可见) | +| `reward_coins` | Integer | nullable | 采纳发的金币数(未发为 null;发币走 `coin_transaction`) | +| `review_note` | String(256) | nullable | 运营内部备注(不外露) | +| `admin_reply` | String(256) | nullable | **运营回复**(用户可见,#105;C 端反馈历史展示) | +| `reviewed_by_admin_id` | Integer | nullable | 审核管理员 id(软指 `admin_user.id`) | +| `reviewed_at` | DateTime(tz) | nullable | 审核时间 | | `created_at` | DateTime(tz) | server_default now(), index | 提交时间 | ## 关系 / Join Key - `user_id` → `user.id`(多对一)。 -- admin 处理时被 `admin_audit_log` 记录(`target_type='feedback'`、`target_id`=本行 id)。 +- admin 审核动作被 `admin_audit_log` 记录(`target_type='feedback'`);`reviewed_by_admin_id` 软指 `admin_user.id`(无硬 FK)。 +- 采纳发奖时写 `coin_transaction`(发币入口 `grant_coins` 同事务)。 ## 索引与约束 -- PK `id`;index `user_id`、`created_at`。 +- PK `id`;index `user_id`、`status`、`created_at`。 ## 注意 - `images` 用通用 `JSON`(本表**未**用 JSONB variant,与 comparison/savings 不同)。 +- 状态机是单向的:`pending → adopted/rejected/handled`。`approve`/`reject` 只接受 `pending`(或历史 `new`)态,重复审核报 400;旧口径 `handle` **幂等不校验原状态**(重复调用结果一致)。 diff --git a/docs/database/inactivity_notification_log.md b/docs/database/inactivity_notification_log.md new file mode 100644 index 0000000..e5c3b6e --- /dev/null +++ b/docs/database/inactivity_notification_log.md @@ -0,0 +1,36 @@ +# inactivity_notification_log — 不活跃清零前预警记录 + +> 模型 `app/models/inactivity.py` · 仓库 `app/repositories/inactivity.py` · 通知器 `app/integrations/notifier.py` · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +清零前按可配置节奏(`INACTIVITY_WARN_DAYS_BEFORE`,默认清零前 7 天、2 天各一次)向用户预警"账户里的 xx 金币和 xx 现金将被清零"。每发一次预警写一行,记推送时的余额快照 + 提前天数档 + 通道 + 状态。兼作两用:**预警去重**依据(同 streak 内 `stage==k 且 created_at > last_active` 即已推过、不重推)与**占位 outbox**(v1 通道=`log`,只打日志不真推;后续接 JPush/短信同层扩展)。append-only,不更新。**预警只涉及会被清的金币 + 折算现金;邀请奖励金不清、不预警**(`invite_cash_balance_cents` 仅作账户状态快照)。 + +## 用在哪 / 增删改查 +- **C(插入)**:`inactivity.run_warn_once` 命中预警档、且本 streak 未推过时,调 `notifier.warn` 后写一行(`status` = 通知器返回,占位实现为 `placeholder`)。 +- **U / D**:无(append-only)。 +- **R**:预警去重查询(`user_id + stage + created_at > last_active`);未来接真实推送时作待推送 outbox。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | **PK**, autoincrement | 主键 | +| `user_id` | Integer | NOT NULL, index | 预警对象;只索引不设外键(同 `analytics_event`) | +| `stage` | Integer | NOT NULL | 提前天数档(如 `7` / `2`,即清零前第几天推) | +| `inactive_days` | Integer | NOT NULL | 推送时的不活跃天数(北京自然日) | +| `coin_balance` | Integer | NOT NULL | 推送时金币余额快照(将被清) | +| `cash_balance_cents` | Integer | NOT NULL | 推送时折算现金余额快照(分,将被清) | +| `invite_cash_balance_cents` | Integer | NOT NULL | 推送时**邀请奖励金**余额快照(分,**不清、不在预警额度内**) | +| `channel` | String(16) | NOT NULL | 通道:`log`(占位) / `jpush` / `sms` | +| `status` | String(16) | NOT NULL | 状态:`placeholder`(占位未真推) / `sent` / `failed` | +| `created_at` | DateTime(tz) | server_default now(), index | 推送时刻;去重比 `created_at > last_active`(用户回归后 `last_active` 前移 → 旧行自然失效、开启新 streak) | + +## 关系 / Join Key +- `user_id` → `user.id`(无外键直连,靠 `user_id` 关联)。 +- 与 `inactivity_reset_log` 无直接外键;同一 streak 内先有若干预警行,到期后有一行清零。 + +## 索引与约束 +- PK `id`;`ix_inactivity_notification_log_user_id`、`ix_inactivity_notification_log_created_at`。 + +## 注意 +- **预警去重按 streak**:判据是 `created_at > last_active`;用户一有活跃(`home_view`/比价/领券),`last_active` 前移,旧预警行"失效",回归后可重新进入预警。 +- **占位实现**:v1 `LogNotifier` 只 `logger.warning("[inactivity-warn] ...")`、返回 `placeholder`,不真推(参照心跳告警"本期先不接推送"先例)。 +- **漏跑补发**:worker 漏跑数天后某用户可能同时满足多档,只补发**最紧急的未推档**(最小提前天数),避免刷屏。 diff --git a/docs/database/inactivity_reset_log.md b/docs/database/inactivity_reset_log.md new file mode 100644 index 0000000..4de9578 --- /dev/null +++ b/docs/database/inactivity_reset_log.md @@ -0,0 +1,35 @@ +# inactivity_reset_log — 15 天不活跃清零审计 + +> 模型 `app/models/inactivity.py` · 仓库 `app/repositories/inactivity.py` · worker `app/core/inactivity_reset_worker.py` · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +连续 15 天不活跃(北京自然日,活跃口径见 `app/repositories/activity.py`:`home_view` + 发起比价 + 发起领券,**不含登录**)的用户,worker 每日自动清零其**金币 + 折算现金**。每清一个用户写一行,记清零前三桶余额快照 + 原因 + 判定时的活跃时间/不活跃天数,供纠纷排查。清零同时另写 2 条钱包流水(`coin_transaction` / `cash_transaction`,`biz_type=inactivity_reset`,`ref_id=` 本表 `id`),资金流可逐笔回溯、人工恢复。**邀请奖励金(`invite_cash_balance_cents`)是产品红线、不清零**,本表 `invite_cash_balance_cents_before` 仅为清零时仍保留的邀请金快照(非被清金额)。append-only,不更新。 + +## 用在哪 / 增删改查 +- **C(插入)**:`inactivity.clear_user` 逐用户清零(独立事务、行锁)时写一行,`db.flush()` 拿 `id` 作流水 `ref_id` 交叉链接。 +- **U / D**:无(append-only 审计)。 +- **R**:纠纷排查 / 对账(与 `coin_transaction` / `cash_transaction` 的 `ref_id` 交叉核对)。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | +|---|---|---|---| +| `id` | Integer | **PK**, autoincrement | 主键;作 `ref_id` 写入两条清零流水 | +| `user_id` | Integer | NOT NULL, index | 被清零用户;只索引不设外键(同 `analytics_event`,避免删用户级联 / 留历史) | +| `coin_balance_before` | Integer | NOT NULL | 清零前金币余额(个数);= 对应 `coin_transaction.amount` 绝对值 | +| `cash_balance_cents_before` | Integer | NOT NULL | 清零前折算现金余额(分);= 对应 `cash_transaction.amount_cents` 绝对值 | +| `invite_cash_balance_cents_before` | Integer | NOT NULL | 清零时的**邀请奖励金**余额快照(分)——**不清、原封保留**,仅记录以证明"未动邀请金" | +| `last_active_at` | DateTime(tz) | nullable | 判定时的最近活跃时刻(UTC);无任何活跃信号时兜底为 `user.created_at` | +| `inactive_days` | Integer | NOT NULL | 判定时的不活跃天数(北京自然日) | +| `reason` | String(32) | NOT NULL | 清零原因,如 `inactive_15d` | +| `reset_at` | DateTime(tz) | server_default now(), index | 清零时刻 | + +## 关系 / Join Key +- `user_id` → `user.id`(无外键直连,靠 `user_id` 关联)。 +- `id` → `coin_transaction.ref_id` / `cash_transaction.ref_id`(`biz_type=inactivity_reset`):审计行 ↔ 资金流水交叉对账。 + +## 索引与约束 +- PK `id`;`ix_inactivity_reset_log_user_id`(按用户查)、`ix_inactivity_reset_log_reset_at`(按时间查)。 + +## 注意 +- **只清 2 桶**:金币 + 折算现金;**邀请现金不清**(两本账物理隔离,见 [`coin_account`](./coin_account.md) / `wallet.CoinAccount` 注释)。 +- **天然幂等**:清完余额=0,次日不再匹配;worker 重启 / 多次唤醒 / 补跑都不会重复清零或重复流水。 +- **总闸默认关**(`INACTIVITY_RESET_ENABLED=false`),灰度验证清零名单后再开。 diff --git a/docs/database/invite_cash_transaction.md b/docs/database/invite_cash_transaction.md new file mode 100644 index 0000000..0207feb --- /dev/null +++ b/docs/database/invite_cash_transaction.md @@ -0,0 +1,42 @@ +# invite_cash_transaction — 邀请奖励金流水账本(分) + +> 模型 `app/models/wallet.py`(`InviteCashTransaction`) · 仓库 `app/repositories/wallet.py`(`grant_invite_cash` / 提现路径按 `source` 分账) · 接口 [wallet-account](../api/wallet/wallet-account.md)(余额)/ [wallet-withdraw](../api/wallet/wallet-withdraw.md)(`source=invite_cash` 提现) · [← 索引](./README.md) · [总览](./OVERVIEW.md) + +**邀请奖励金**(现金,分)的流水账本,与金币兑换的 `cash_transaction` **物理隔离**、结构同构:`balance_after_cents` 记的是 `coin_account.invite_cash_balance_cents`。隔离原因:邀请奖励金有独立的发放口径(好友**比价并下单**才发,#113)与独立的提现对账(`withdraw_order.source='invite_cash'`,#121),混在现金账本里无法分账校验。#82 新增(2026-06-27)。 + +## 用在哪 / 增删改查 +- **C(插入)**:每次奖励金变动一笔: + + | 动作 / endpoint | `biz_type` | `amount_cents` | `ref_id` 指向 | + |---|---|---|---| + | 好友比价并下单发奖(`POST /order/report` → `invite.try_reward_on_compare` → `grant_invite_cash`) | `invite_reward` | + | 被邀请人 `user.id`(字符串) | + | 发起提现 `POST /wallet/withdraw`(`source=invite_cash`) | `invite_withdraw` | − | `withdraw_order.out_bill_no` | + | 该提现失败/审核拒绝退款 | `invite_withdraw_refund` | + | `withdraw_order.out_bill_no` | + | admin 手动调整 `POST /admin/api/users/{id}/cash`(`account=invite_cash`,#95) | `admin_grant` / `admin_deduct` | + / − | null(原因记 `remark`=`admin:`) | + +- **U / D**:无。账本只追加。 +- **R**:admin 用户 360 / 提现资金账本校验 `GET /admin/api/withdraws/ledger-check`(按 `source` 分账对账,#121);C 端邀请页战绩(`invite.get_reward_stats`:余额 + 累计已提)。 + +## 字段 +| 列 | 类型 | 约束 / 默认 | 说明 | +|---|---|---|---| +| `id` | Integer | PK, autoincrement | | +| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(= 邀请人) | +| `amount_cents` | Integer | NOT NULL | 本笔变动(分);正=入账(发奖/退款),负=出账(提现) | +| `balance_after_cents` | Integer | NOT NULL | 本笔后奖励金余额(= 当时 `coin_account.invite_cash_balance_cents`) | +| `biz_type` | String(32) | NOT NULL | `invite_reward` / `invite_withdraw` / `invite_withdraw_refund` | +| `ref_id` | String(64) | nullable | 见上表:发奖=被邀请人 id;提现/退款=`withdraw_order.out_bill_no` | +| `remark` | String(128) | nullable | 用户可见备注(如「好友比价奖励」「提现到微信零钱(待审核)」) | +| `created_at` | DateTime(tz) | server_default now(), index | | + +## 关系 / Join Key +- `user_id` → `user.id`(多对一,硬 FK)。 +- `ref_id` →(withdraw 类)`withdraw_order.out_bill_no`(软关联;该单 `source='invite_cash'`)/(invite_reward)被邀请人 `user.id`。 +- 与 `invite_relation` 的联动:发奖同事务置 `invite_relation.compare_reward_granted=true`(幂等闸,一个被邀请人只发一次)。 + +## 索引与约束 +- PK `id`;index `user_id`、`created_at`。 + +## 注意 +- **唯一变动入口 `wallet.grant_invite_cash`**:更新 `coin_account.invite_cash_balance_cents` + 写本表,**不 commit**,调用方同事务提交(与 `grant_coins` 同款约定)。 +- 提现按 `source` 走不同账本:`coin_cash` → `cash_transaction`,`invite_cash` → 本表;`withdraw_order` 两种共用一张表靠 `source` 列区分。 diff --git a/docs/database/invite_relation.md b/docs/database/invite_relation.md index 590b20f..61b65a2 100644 --- a/docs/database/invite_relation.md +++ b/docs/database/invite_relation.md @@ -2,15 +2,16 @@ > 模型 `app/models/invite.py`(`InviteRelation`) · 仓库 `app/repositories/invite.py` · 接口 `app/api/v1/invite.py`:`POST /api/v1/invite/bind`(写)、`GET /api/v1/invite/me`(战绩)、`GET /api/v1/invite/invitees`(列表) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -一行 = 一次**成功的**邀请绑定。被邀请人(B)用邀请人(A)的邀请码完成绑定后写入,**注册即生效**:同一事务里给 A、B 各发 1 万金币(= 1 元,可提现)。数据来自 `bind()`,触发它的归因来源有三种(`channel`)。这是邀请功能的**结果表 / 账本**;邀请码本身存在 `user.invite_code`,不在这张表。 +一行 = 一次**成功的**邀请绑定。被邀请人(B)用邀请人(A)的邀请码完成绑定后写入,**绑定关系注册即生效,但绑定本身不发奖**(#113 起):发奖后置到 B **完成比价并实际下单**(`POST /order/report` → `try_reward_on_compare`),给 A 发**邀请奖励金**(入独立账本 [invite_cash_transaction](./invite_cash_transaction.md),非金币),本表 `compare_reward_granted` 做幂等闸。数据来自 `bind()`,触发它的归因来源有三种(`channel`)。这是邀请功能的**结果表 / 账本**;邀请码本身存在 `user.invite_code`,不在这张表。 ## 用在哪 / 增删改查 -- **C(插入)**:`POST /api/v1/invite/bind`(`bind_invite`)→ `invite_repo.bind()`。过四道防线后建一行 `status='effective'`,**同事务**复用 `wallet.grant_coins` 给双方发金币(`grant_coins` 只 flush,由 `bind()` 统一 commit)→ "建关系 + 双方加金币"原子。 - - **幂等键 = `invitee_user_id` 唯一**:一个 B 只能被绑一次,重复请求返回 `already_bound`,不重复发奖(仿 `ad_reward_record.trans_id` 思路)。并发下两请求同时插同一 invitee → 后者撞唯一约束 `IntegrityError`,`bind()` rollback 后改判 `already_bound` 兜底。 - - 四道防线(`bind()` 内):① invitee 已绑 → `already_bound`;② 无效码 / 邀请人非 `active` → `invalid_code`;③ 自邀(inviter==invitee)→ `self_invite`;④ 新人闸 `_is_new_user`(B 的 `created_at` 在 `rewards.INVITE_NEW_USER_WINDOW_HOURS`=72h 内)→ 否则 `not_eligible`。只有全过才插行 + 发奖。 -- **U / D**:无。这张表只增不改不删(纯账本)。 +- **C(插入)**:`POST /api/v1/invite/bind`(`bind_invite`)→ `invite_repo.bind()`。过四道防线后建一行 `status='effective'`。**#113 起绑定只建关系、不发奖**(`inviter_coin`/`invitee_coin` 新行恒 0)。 + - **幂等键 = `invitee_user_id` 唯一**:一个 B 只能被绑一次,重复请求返回 `already_bound`(仿 `ad_reward_record.trans_id` 思路)。并发下两请求同时插同一 invitee → 后者撞唯一约束 `IntegrityError`,`bind()` rollback 后改判 `already_bound` 兜底。 + - 四道防线(`bind()` 内):① invitee 已绑 → `already_bound`;② 无效码 / 邀请人非 `active` → `invalid_code`;③ 自邀(inviter==invitee)→ `self_invite`;④ 新人闸 `_is_new_user`(B 的 `created_at` 在 `rewards.INVITE_NEW_USER_WINDOW_HOURS`=72h 内)→ 否则 `not_eligible`。 +- **U(发奖,#113)**:B 比价后下单 `POST /order/report` → `try_reward_on_compare(invitee_user_id)`:`compare_reward_granted=False` 才发 → 置 `True` + 记 `compare_reward_cents`/`compare_rewarded_at`,**同事务** `wallet.grant_invite_cash` 给 A 入邀请奖励金 → "置闸 + 入账"原子,一个 B 只发一次。 +- **D**:无。 - **R**: - - `GET /api/v1/invite/me`(`my_invite`)→ `get_stats(inviter_id)`:`count(*)` 得已邀人数、`sum(inviter_coin)` 得累计金币。 + - `GET /api/v1/invite/me`(`my_invite`)→ `get_stats(inviter_id)`:`count(*)` 得已邀人数、`sum(inviter_coin)` 得累计金币(历史口径);另调 `get_reward_stats` 出邀请奖励金余额/累计已提(读 `coin_account.invite_cash_balance_cents` + `withdraw_order(source=invite_cash)`)。 - `GET /api/v1/invite/invitees`(`my_invitees`)→ `get_invitees(inviter_id, limit, offset)`:join `user` 出被邀请人列表(倒序分页),名字降级兜底 `nickname → wechat_nickname → 脱敏手机号`,`coins` 取 `inviter_coin`。 ## 字段 @@ -20,15 +21,18 @@ | `inviter_user_id` | Integer | FK→user.id, index, NOT NULL | 邀请人(A) | | `invitee_user_id` | Integer | FK→user.id, **UNIQUE**, index, NOT NULL | 被邀请人(B)。**唯一 = 幂等键**,一个 B 只能被归因一次 | | `channel` | String(16) | NOT NULL, default `clipboard` | 归因来源:`clipboard`(剪贴板自动)/ `manual`(手动填码)/ `fingerprint`(指纹反查兜底)。入库前 `[:16]` 截断 | -| `status` | String(16) | NOT NULL, default `effective` | 当前只有 `effective`(注册即生效)。**预留** `pending`/`effective`:将来若改"完成首单才生效"时启用 | -| `inviter_coin` | Integer | NOT NULL, default 0 | 本次给 A 发的金币(记账留痕,=`rewards.INVITE_INVITER_COINS`=10000) | -| `invitee_coin` | Integer | NOT NULL, default 0 | 本次给 B 发的金币(=`rewards.INVITE_INVITEE_COINS`=10000) | +| `status` | String(16) | NOT NULL, default `effective` | 当前只有 `effective`(绑定关系注册即生效)。「发奖后置」没有走 status,而是用下面的 `compare_reward_granted` 闸表达 | +| `inviter_coin` | Integer | NOT NULL, default 0 | **历史留痕**(#113 前"绑定即发金币"时代给 A 发的金币,当时=10000);#113 起新绑定恒 0 | +| `invitee_coin` | Integer | NOT NULL, default 0 | 同上,给 B 发的金币历史留痕;新绑定恒 0 | +| `compare_reward_granted` | Boolean | NOT NULL, default false | **发奖幂等闸**(#113):B 首次「比价并下单」后置 true,一个 B 只给 A 发一次邀请奖励金 | +| `compare_reward_cents` | Integer | NOT NULL, default 0 | 实发的邀请奖励金(分,留痕;改常量不影响历史行) | +| `compare_rewarded_at` | DateTime(tz) | nullable | 发奖时间 | | `created_at` | DateTime(tz) | server_default now(), index | 绑定时间(= 列表倒序键) | ## 关系 / Join Key - `inviter_user_id` → `user.id`(硬 FK,多对一):一个 A 可邀多个 B。 - `invitee_user_id` → `user.id`(硬 FK,**一对一**,唯一约束):一个 B 至多一行。 -- 发金币时落 `coin_transaction`:`biz_type='invite_inviter'`(给 A,`ref_id=invitee.id`)/ `biz_type='invite_invitee'`(给 B,`ref_id=inviter.id`),双方 `ref_id` 互指对方便于对账。 +- 流水关联:**#113 前**的绑定发金币落 `coin_transaction`(`biz_type='invite_inviter'`/`'invite_invitee'`,`ref_id` 互指对方)——现为历史类型,新绑定不再产生;**#113 起**发奖落 [`invite_cash_transaction`](./invite_cash_transaction.md)(`biz_type='invite_reward'`,`ref_id=被邀请人 id`)。 - `get_invitees` 用 `InviteRelation JOIN user ON user.id = invitee_user_id` 取被邀请人资料;`total` 单独 `count` 算 `has_more`。 ## 索引与约束 @@ -39,8 +43,8 @@ ## 注意 - **防重复发奖三道**(`repositories/invite.py` docstring):① `invitee_user_id` 唯一(应用层 `_relation_of_invitee` 先查 + DB 唯一约束并发兜底);② 自邀屏蔽;③ 手机号天然唯一(每个 B = 一个真实手机号账号)= 限制刷量规模。 -- **`status` 当前恒为 `effective`**:产品取"注册即生效"而非"完成首单才生效",`pending` 取值是为后者预留、目前不写入。 -- **`inviter_coin`/`invitee_coin` 是留痕字段**:写死当时发的金币值,即便日后改奖励常量,历史行仍保留发奖时的额度,便于对账。 +- **`status` 当前恒为 `effective`**(绑定关系维度);「发奖后置」由 `compare_reward_granted` 闸表达,没有引入 `pending` 状态。 +- **金币留痕字段已冻结**:`inviter_coin`/`invitee_coin` 只反映 #113 前旧口径的历史发放额,便于对账;新奖励额看 `compare_reward_cents`。 - **`channel` 三种取值**:`clipboard`(deferred-deeplink 主路径,落地页写剪贴板、首启读回)/ `manual`(用户在邀请页手输)/ `fingerprint`(剪贴板被覆盖时走指纹兜底,见 [invite_fingerprint](./invite_fingerprint.md));三者都汇入同一个 `bind()`,只 `channel` 不同。 -- **风控缺口(#24 设计文档「上线前还差什么」标注)**:1 万金币可提现且**邀请人无总数上限**,接码平台批量注册新号绑同码即可刷;上线前需加 inviter 上限 + 基础风控。当前 MVP 仅靠"手机号唯一 + 72h 新人闸"挡。 +- **风控演进**:#24 时代的缺口是"注册即发 1 万可提现金币、接码批量刷"——**#113 把发奖后置到真实比价+下单**(且发的是邀请奖励金独立账本),批量注册空号不再直接得利,刷奖成本显著抬高;inviter 总数上限等进一步风控仍待补。 - **alembic 多 head**:本表迁移 `invite_code_and_relation`(`down_revision=11a1d08c6f55`)与 `coupon_state_tables` 是同一父的兄弟迁移,合 main 前需建 merge 迁移,否则 prod `alembic upgrade head` 撞 `Multiple head revisions`(#24 设计文档已警示)。 diff --git a/docs/database/withdraw_order.md b/docs/database/withdraw_order.md index 9b34897..e06c0e0 100644 --- a/docs/database/withdraw_order.md +++ b/docs/database/withdraw_order.md @@ -1,6 +1,6 @@ # withdraw_order — 提现单(现金 → 微信零钱) -> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-withdraw](../api/wallet-withdraw.md) / [wallet-withdraw-status](../api/wallet-withdraw-status.md) / [wallet-withdraw-orders](../api/wallet-withdraw-orders.md) · admin [admin-withdraws-list](../api/admin-withdraws-list.md) / [admin-withdraw-refresh](../api/admin-withdraw-refresh.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) +> 模型 `app/models/wallet.py` · 仓库 `app/repositories/wallet.py` · 接口 [wallet-withdraw](../api/wallet/wallet-withdraw.md) / [wallet-withdraw-status](../api/wallet/wallet-withdraw-status.md) / [wallet-withdraw-orders](../api/wallet/wallet-withdraw-orders.md) · admin [admin-withdraws-list](../api/admin/withdraws/admin-withdraws-list.md) / [admin-withdraw-refresh](../api/admin/withdraws/admin-withdraw-refresh.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) 用户把现金余额提到微信零钱的工单。**含人工审核**(2026-06 起):发起即扣现金、进 `reviewing` 待审核、**不打款**;管理员后台审核通过才真正发起微信商家转账,拒绝则退款。 @@ -26,7 +26,8 @@ reviewing ──admin 审核拒绝──▶ rejected(已退款) |---|---|---|---| | `id` | Integer | PK, autoincrement | | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | -| `out_bill_no` | String(64) | UNIQUE, index, NOT NULL | 商户单号(幂等键 + 微信查单键)。客户端可传(`[0-9A-Za-z_-]{8,32}`),不传则服务端 `uuid4().hex`。**被 `cash_transaction.ref_id` 引用** | +| `out_bill_no` | String(64) | UNIQUE, index, NOT NULL | 商户单号(幂等键 + 微信查单键)。客户端可传(`[0-9A-Za-z_-]{8,32}`),不传则服务端 `uuid4().hex`。**被 `cash_transaction.ref_id` / `invite_cash_transaction.ref_id` 引用**(按 `source` 落对应账本) | +| `source` | String(16) | NOT NULL, default `coin_cash` | 提现账户来源(#121 分账):`coin_cash`(金币兑换的现金,扣 `coin_account.cash_balance_cents`、流水落 `cash_transaction`)/ `invite_cash`(邀请奖励金,扣 `invite_cash_balance_cents`、流水落 [`invite_cash_transaction`](./invite_cash_transaction.md)) | | `amount_cents` | Integer | NOT NULL | 提现金额(分) | | `user_name` | String(64) | nullable | 提现实名;微信**达额转账要求实名**,发起时存下、审核打款时传给微信 | | `status` | String(16) | NOT NULL, default `reviewing` | 归一化状态:`reviewing`(待审核,已扣款未打款)/ `pending`(打款在途)/ `success` / `failed`(打款失败已退)/ `rejected`(审核拒绝已退) | @@ -39,7 +40,7 @@ reviewing ──admin 审核拒绝──▶ rejected(已退款) ## 关系 / Join Key - `user_id` → `user.id`(多对一)。 -- `out_bill_no` ← 被 `cash_transaction.ref_id` 引用(发起 `withdraw` 一笔 −,失败/拒绝 `withdraw_refund` 一笔 +)。 +- `out_bill_no` ← 被流水 `ref_id` 引用,**账本按 `source` 分**:`coin_cash` 单 → `cash_transaction`(`withdraw` − / `withdraw_refund` +);`invite_cash` 单 → `invite_cash_transaction`(`invite_withdraw` − / `invite_withdraw_refund` +)。admin `GET /admin/api/withdraws/ledger-check` 按 source 分账做「单 ↔ 流水」对账(#121)。 - 打款方式依赖 `wechat_transfer_authorization`(用户有生效授权 → 免确认转账,否则确认模式)。 ## 索引与约束 diff --git a/docs/guides/领券成功率指标-设计与埋点.md b/docs/guides/领券成功率指标-设计与埋点.md new file mode 100644 index 0000000..a1bb0cb --- /dev/null +++ b/docs/guides/领券成功率指标-设计与埋点.md @@ -0,0 +1,294 @@ +# 领券「整单成功率」与「点位成功率」指标 — 设计与埋点 + +- 日期:2026-07-07 +- 状态:待评审 +- 涉及仓库:`shaguabijia-app-server`(**纯服务端**;客户端零改动) +- 数据源表:`coupon_session`(admin「领券数据」看板) + +## 1. 背景与目标 + +admin「领券数据」看板(数据源 `coupon_session`,见 `app/admin/repositories/coupon_data.py`)当前能算:**领券发起数、完成数、全程耗时均值/分位**。产品还想要两个成功率指标,现有埋点算不出来: + +- **② 整单成功率** = 一次发起里勾选的平台**全部**领到券的次数 / 领券发起数 +- **③ 点位成功率** = 平台维度的领取成功率(每个平台「点位」成没成功) + +> 口径决定(2026-07-07):「点位」= **平台粒度**(美团 / 淘宝闪购 / 京东),不是「每张券」。 + +耗时中位数、发起数已由 `coupon_session.elapsed_ms` / `status=started` 计数满足,本设计只补 ②③。 + +## 2. 关键结论:per-slot 信号已在库,缺的是「按 session 可靠归因 + 看板可过滤」 + +服务端 `/api/v1/coupon/step`(`app/api/v1/coupon.py`)每帧都调 `record_claims`,把**每张券**的结果(`status ∈ success / already_claimed / failed / skipped`)写进 `coupon_claim_record`,还带 `trace_id`。原始成败信号**已经落库**。 + +但该表**不能**直接支撑本指标: + +1. 唯一键是 `(device_id, coupon_id, claim_date)`,**不含 trace_id**;且 `record_claims` 冲突更新时**不更新 trace_id**(`app/repositories/coupon_state.py` 的 `record_claims`,仅 INSERT 时写 trace_id)。→ 同一张券当天跨多次 session 会折叠成一行、只归属**最早**那次 → **按 session 归因不可靠**(直接砸 ②「整单全成功」)。 +2. `coupon_claim_record` 无 `app_env` / `origin_package` → 无法像看板那样只看 prod、也无法拆 Path A(App 内发起)/ Path B(外卖侧弹券)。 + +因此采用 **route B**:在 `/step` 里服务端推导「本次 session 哪些平台成功」,直接写到 `coupon_session` 行——该表按 `trace_id` 唯一、已带 `app_env` / `origin_package` / `platforms`,指标干净可过滤、可拆路径。 + +## 3. 指标口径(平台粒度,已定) + +记一次 session 为 `s`: + +- `sel(s)` = **勾选平台集** = `coupon_session.platforms`;为空表示「全领」→ 取默认 `{meituan-waimai, taobao-shanguang, jd-waimai}`。`|sel(s)|` = 该次「点位数」。 +- `succ(s)` = **成功平台集** = 本次 session 里**至少领到一张**(`status ∈ {success, already_claimed}`)的平台集合。 + +指标: + +- **③ 点位成功率** = `Σ_s |succ(s) ∩ sel(s)|` / `Σ_s |sel(s)|` + (分母即「发起数 × 各自点位数」;全部全领时等于 发起数 × 3) +- **② 整单成功率** = `#{ s : sel(s) ⊆ succ(s) 且 sel(s) ≠ ∅ }` / `发起数` + +已定边界: + +1. **③ 分母用「勾选平台」`sel(s)`**。勾了淘宝但淘宝没领到 = 该点位未成功(不特判「平台没券」)。若日后要「真没券的平台不计入分母」,再引入 `platform_attempted`(见 §8)。 +2. **②③ 基数 = 区间内全部 session**(含 `started` / `failed` / `abandoned`),与看板「发起数」同基数。**失败 / 中途退出的 session 的 `succ(s)` 取它崩溃前真领到的平台**(不一律算 0)。 +3. **成功语义**:`status ∈ {success, already_claimed}` = 成功;`failed` / `skipped` = 未成功。与 `sum_claimed_count`(`app/repositories/coupon_state.py`)一致。 + > pricebot 协议文档把 `status` enum 写作 `success|failed|skipped`,但**代码实际还会 emit `already_claimed`**(pricebot `app/services/coupon_provider.py` 等多处)——以代码为准,含 already_claimed 是对的。`skipped` 目前 MVP 阶段基本不出现。 + +## 4. 数据模型改动 + +`coupon_session`(`app/models/coupon_state.py::CouponSession`)新增一列: + +| 列 | 类型 | 说明 | +|----|------|------| +| `platform_success` | `_JSON`(PG→JSONB / SQLite→JSON),nullable | 本次 session **至少领到一张**的平台 id 列表,如 `["meituan-waimai","jd-waimai"]`。旧行 = `NULL` → 视作空集。 | + +- alembic 新迁移:`add_column coupon_session.platform_success`,nullable、无 server_default。 +- **分母 `sel(s)` 复用已有 `platforms` 列,不新增字段。** 净新增仅此一列。 +- 同步更新表字典 `docs/database/coupon_state.md`。 + +## 5. 服务端推导逻辑(`/step`) + +在 `app/api/v1/coupon.py::coupon_step` 内(已有 `results = _extract_coupon_results(resp_json)`)新增: + +1. 对每条 result 求平台:**按 `coupon_id` 前缀映射**(与客户端 `CouponForegroundService.couponIdToPlatform` 对齐,且与 `platforms` / `platform_elapsed` 用同一套平台 id 词表): + - `mt_` → `meituan-waimai` + - `tb_` / `ele_` / `elm_` → `taobao-shanguang` + - `jd_` → `jd-waimai` + - 其余 → 跳过(无法识别) + > 不用券的 `vendor` 字段做映射:vendor 词表未必等于这三档平台 id,而 `sel(s)` 用的就是这三档,`succ(s)` 必须同词表。 +2. 收集 `status ∈ {success, already_claimed}` 的平台集合 `ok_platforms`。 +3. 若 `device_id` 且 `ok_platforms` 非空:调用新 repo 函数把 `ok_platforms` **并入** `coupon_session.platform_success`(按 `trace_id`)。 + +新增 `app/repositories/coupon_state.py::merge_session_platform_success(db, trace_id, ok_platforms)`: + +- 读现有行 → `platform_success = 现有 ∪ ok_platforms`(去重、保序)→ 写回、commit;`IntegrityError` 回滚忽略(同现有 upsert 兜底)。 +- **并集幂等**:跨帧多次并入同一平台不会重复;done 帧的全量 `coupon_results` 保证完整;失败 / 中途退出的 session 靠中间帧 `last_coupon_result` 已并入的平台拿到「部分成功」。 +- **fire-and-forget**:包 `run_in_threadpool` + 整段 try/except 只 log,绝不连累 `/step` 返回(与现有 `record_claims` / `mark_completed` 同规格)。 + +**建行 / 时序(已定:行不存在则跳过本次并入)**: + +- 客户端 `/session started` 在 `start()`(任务发起那刻)就发,而首个带券的 `/step` 要等领券循环跑起来、晚几秒;到 `/step` 有 `ok_platforms` 时,`coupon_session` 行几乎必然已存在。故 `merge_session_platform_success` **读不到行就跳过**,不建兜底行——实现最简,也不引入 `started_at` 不精确的脏行。 +- 残留丢数窗口:仅当「`started` 上报丢失」**且**「`/step` done 先于 `/session` terminal 落库」两者同时成立,该 session 的 `platform_success` 才会缺(terminal 帧会建行但那之后没有 `/step` 再并入)。两条件叠加概率极低,且指标是聚合口径、可容忍个别缺失。 +- 若上线后观测到该缺失不可忽略,再降级为「读不到行则 upsert 建最小兜底行(`started_at=now()`)」——届时改 `merge_session_platform_success` 一处即可,不影响其余设计。 + +**写放大(重要,非每 step)**:`merge_session_platform_success` **只在「本帧带券结果」时触发**——即 pricebot 在**单券完成帧**给 `last_coupon_result`、**最终 done 帧**给全量 `coupon_results` 的那些帧;领单张券途中的导航/点击帧(占 step 大头)`_extract_coupon_results` 返回空 → **不写**。所以写频次 ≈ **本次领的券数**(通常个位数),且落在**服务端今天已有的** `record_claims` 写的**同一批帧**上,不新增写的帧。 + +实现:把 merge 放进**现有 `_record_claims_blocking` 的同一个 `SessionLocal`**(紧接 `record_claims`),边际成本 = 每张券完成时多一条 `UPDATE coupon_session`(按 `trace_id` 唯一索引),不新增连接 / 不新增 `run_in_threadpool` 调用。 + +可选降级(若要「一次 session 只写一次」):只在 done 帧写 `platform_success`(全量 `coupon_results` 一次算完)。代价:`failed` / `abandoned`(没 done 帧)拿不到「崩溃前已成平台」→ 失败单部分成功丢失,与 §3「失败单取实际成的平台」相悖。**默认取每券帧并入**(失败单也如实统计),此降级留作观测到写压力后再启用。 + +## 6. admin 聚合与呈现 + +`app/admin/repositories/coupon_data.py::coupon_data_report` 的 `summary` 增加(沿用「全量拉区间 → Python 聚合」风格,与分位一致): + +- `full_success_rate`(②)、`point_success_rate`(③) +- 可选 `per_platform`:`{platform: rate}`(各平台点位成功率,拆美团/淘宝/京东) + +计算:对区间内 sessions,`sel = platforms or 默认三档`,`succ = set(platform_success) ∩ sel`;按 §3 公式汇总。 + +- 过滤:默认 `app_env == 'prod'`(同现有分位口径,防测试串台)。 +- 拆路径:`origin_package` 已在表上 → 看板后续可加「Path A / Path B」筛选项(本设计不含前端图表细节)。 +- schema:`app/admin/schemas/coupon_data.py` 的 summary 加对应字段(+ 可选 `per_platform`)。 + +## 7. 改动清单 + +- [x] `app/models/coupon_state.py`:`CouponSession` 加 `platform_success` +- [x] `alembic/versions/`:新迁移 add column `coupon_session.platform_success` +- [x] `app/repositories/coupon_state.py`:新增 `merge_session_platform_success` +- [x] `app/api/v1/coupon.py`:`/step` 推导 `ok_platforms` 并 union(前缀映射 + fire-and-forget) +- [x] `app/admin/repositories/coupon_data.py`:`summary` 加 ②③(+ 可选 `per_platform`) +- [x] `app/admin/schemas/coupon_data.py`:`summary` schema 加字段 +- [x] `docs/database/coupon_state.md`:补 `platform_success` 列说明(并补 coupon_session 整节) +- [x] `tests/`:`tests/test_coupon_platform_success.py`(10 测试,见 §9) + +## 8. 不做(YAGNI / 边界) + +- **客户端不改、历史不回填**:`platform_success` 只对新 session 生效(route B 的固有取舍,用户已接受)。 +- **不引入 `platform_attempted`**:③ 分母用勾选平台。若日后要「排除真没券的平台」,注意 `platform_elapsed.keys()` 已近似「被处理过的平台」,可作 attempted 的现成来源,多半仍不必加列。 +- **不动 `coupon_claim_record`** 的去重 / 归因:本指标绕开它,避免牵动频控 / 资产 / CPS 语义。 +- **不做券级(每张券)成功率**:已选平台粒度。 + +## 9. 测试口径要点 + +- **union 幂等**:同一 `trace_id` 多帧并入同一平台,`platform_success` 不重复、保序。 +- **失败单部分成功**:session `failed`,但美团已成 → `succ = {meituan-waimai}`,计入 ③ 分子;② 仅当 `sel ⊆ succ` 才算整单成功。 +- **空 `platforms` → `sel` 取默认三档**(全领)。 +- **成功语义**:`already_claimed` 计成功;`skipped` 不计。 +- **基数**:`abandoned` 计入 ②③ 基数,`succ` 取实际成的平台。 +- **prod 过滤**:`dev` 环境 session 不进指标。 + +## 10. 对 pricebot-backend 的影响 + +**结论:不需要改 pricebot,也不改发往 pricebot 的请求 / 不加调用 / 不加负载。** + +- `/step` 仍原样透传请求 bytes 给 pricebot;本设计只**多解析 pricebot 的响应**(`coupon_results` / `last_coupon_result`),而这两个字段服务端**今天已在** `_extract_coupon_results` / `record_claims` 里解析。零新增字段需求、零额外上游调用、pricebot 负载不变。 +- **只读依赖(既有耦合,非新引入)**:平台映射靠 pricebot 的 `coupon_id` 前缀约定(`mt_` / `tb_` / `ele_` / `elm_` / `jd_`)。客户端 `couponIdToPlatform` 早就依赖同一套;本设计只是加了这份映射的第二个消费者。维护耦合:pricebot 若改 `coupon_id` 前缀,客户端与本指标会**一起**失效——但这是既有风险,依赖方向不变。无法识别前缀的券按「跳过」处理(与客户端一致)。 +- 认账的 pricebot 事实(源:`app/models/response.py` + `docs/projects/领券-客户端对接协议.md`): + - `CouponResult = {coupon_id, name, vendor, status, reason?, duration_ms?}`;`coupon_results` 仅最终 done 帧全量,中间帧走 `last_coupon_result`(单张)。 + - `vendor` 是来源标签(`meituan_internal` / `dianping_cps` …),**不等于**三档平台 id;且 `mt_dianping_xxx`(大众点评 CPS)也带 `mt_` 前缀归美团 → 印证「用 `coupon_id` 前缀、不用 `vendor`」正确。 + +--- + +## 11. 实现状态(交付记录 · 2026-07-07) + +**状态:实现完成、TDD 全绿、未提交、迁移未应用。** 纯服务端(shaguabijia-app-server),客户端 / pricebot 未动。 + +### 已交付改动 +| 文件 | 改动 | +|---|---| +| `app/models/coupon_state.py` | `CouponSession` 加 `platform_success`(`_JSON`, nullable) | +| `app/repositories/coupon_state.py` | `coupon_id_to_platform` / `succeeded_platforms` / `merge_session_platform_success` + 常量 `DEFAULT_PLATFORMS` / `_SUCCESS_STATUSES` | +| `app/api/v1/coupon.py` | `_record_claims_blocking` 内、`record_claims` 之后并入本帧成功平台(同一 `SessionLocal`) | +| `app/admin/repositories/coupon_data.py` | `_success_rates(rows)` → summary 加 `full_success_rate②` / `point_success_rate③` + 3 个支撑计数 | +| `app/admin/schemas/coupon_data.py` | `CouponDataSummary` 加 5 字段 | +| `alembic/versions/coupon_session_platform_success.py` | add column;revision=`coupon_session_platform_success`,down=`admin_user_plain_password`(当前 head) | +| `docs/database/coupon_state.md` | 补 `coupon_session` 整节(表原本无文档)+ 新列 | +| `tests/test_coupon_platform_success.py` | 10 个测试(TDD) | + +> §7 里「可选 `per_platform`」原标 YAGNI 延后;**已在 §12(2026-07-08)补做**(随 admin 前端看板卡一并接入,见下)。 + +### 测试与验证 +- 本特性:`Set-Location e:\project\shaguabijia-app-server; & .\.venv\Scripts\python.exe -m pytest tests\test_coupon_platform_success.py -q` → **10 passed**。 +- 全量 `pytest -q`:**306 passed / 5 failed**。5 个失败**全部预存、与本次无关**: + - `test_coupon_proxy.py::test_coupon_step_passes_body_through`(测试断言 `json=` 但 handler 用 `content=` 转发;写代码前 sanity run 就红)。 + - `test_invite.py` + `test_invite_compare_reward.py` 共 4 个(单独跑也红;本次改动集零 invite 文件)。 +- 迁移:临时库 `alembic upgrade head` 通过、列已建、单一 head。 +- lint:新增代码 `ruff` 全清;`coupon_state.py` 剩 3 处 pre-existing UP017(`upsert_coupon_session` 的 `timezone.utc`)未动。 + +### 待办(在后端项目里继续) +1. **应用迁移**:`alembic upgrade head`(DDL 已验;线上只前进统计、历史不回填)。 +2. **提交**:尚未提交;建议先开分支再提交。 +3. **admin 前端图表**:后端指标已就绪(summary 的 `full_success_rate` / `point_success_rate` 等),看板卡 / 趋势展示待接前端。 +4. (可选)修预存 `test_coupon_step_passes_body_through`(一行:capture `content` 而非 `json`)。 + +### 续开发须知 +- `platform_success` 只在**带券结果的帧**写(`/step` 里 `succeeded_platforms(results)` 非空才 merge),≈ 领券券数量级、非每 step;复用 `record_claims` 同一 `SessionLocal`、不新增连接。 +- merge **读不到 session 行则跳过**(不建兜底行);并集幂等、无新平台不写。 +- 口径:成功=`status∈{success,already_claimed}`;基数含 `abandoned`/`failed`;③ 分母=`platforms`(空→全领三档);`coupon_id`→平台走前缀,与客户端 `couponIdToPlatform` 同词表。 + +--- + +## 12. 续做:admin 前端看板卡 + 分平台点位成功率(设计 · 2026-07-08) + +承 §11 待办 #3(前端图表)与 §7「可选 `per_platform`」。本轮把 ②③ 接入 admin「领券数据」页,并把 ③ 按平台拆(`per_platform`)。**改前端 + 后端 + 测试**;客户端 / pricebot 仍零改动。 + +- 状态:设计已评审通过(2026-07-08),待实现。 +- 涉及仓库:`shaguabijia-app-server`(后端)+ `shaguabijia-admin-web`(admin 前端,Next.js + antd)。 + +### 12.1 后端:`per_platform` 分平台点位成功率 + +`app/admin/repositories/coupon_data.py::_success_rates(rows)` 在现有合计基础上,对每个 `p ∈ DEFAULT_PLATFORMS`(美团/淘宝/京东)累加: + +- 分母 `per_total[p]` = 勾选了 p 的 session 数(`p ∈ sel`,空勾选 `sel` 按全领三档); +- 分子 `per_succ[p]` = 其中 `p ∈ succ`(该平台至少领到一张)的 session 数; +- `per_platform[p]` = `round(per_succ[p] / per_total[p], 4)`;分母 0 → `None`。 + +**不变量**:`Σ_p per_succ[p] == point_success_count`、`Σ_p per_total[p] == point_total_count`(三档词表下恒成立;非三档平台 id 不计入 `per_platform`,由 `if p in per_total` 守卫)。用 §9 / `test_coupon_data_success_rates` 数据自检:美团 3/4=0.75、淘宝 2/3=0.6667、京东 1/2=0.5;合计仍 6/9=0.6667。 + +- summary 加一项 `per_platform`:**恒含三档键**,如 `{"meituan-waimai":0.75,"taobao-shanguang":0.6667,"jd-waimai":0.5}`(区间内无人勾选的平台 → 值 `None`)。 +- schema `app/admin/schemas/coupon_data.py::CouponDataSummary` 加 `per_platform: dict[str, float | None]`。 + +### 12.2 前端:admin-web「领券数据」汇总卡补一段 + +`shaguabijia-admin-web/src/app/(main)/coupon-data/page.tsx`(单文件,`CouponDataSummary` 为该页内联类型): + +- 内联 `CouponDataSummary` 补:`full_success_count` / `full_success_rate` / `point_success_count` / `point_total_count` / `point_success_rate` + `per_platform: Record`。 +- 新 helper `fmtPct(v) = v == null ? '-' : ${(v*100).toFixed(1)}%`(沿用本页 `-` 空值风格,数学同大盘 `pct`);antd 导入补 `Tooltip`,新增 `import { InfoCircleOutlined } from '@ant-design/icons'`。 +- 汇总卡「耗时分位」行之后,`Divider` + 两行 `Statistic`(各 `Col flex="1 1 0"`): + - 行1:**整单成功率** `fmtPct(full_success_rate)` · **点位成功率(合计)** `fmtPct(point_success_rate)`;标题各带 ⓘ `Tooltip`(口径说明;合计率注明「平台粒度、= 三档之和,与『数据大盘』券粒度口径不同」)。 + - 行2:**美团 / 淘宝 / 京东 点位成功率** `fmtPct(per_platform['meituan-waimai' | 'taobao-shanguang' | 'jd-waimai'])`(平台名同「美团耗时」列既有叫法)。 +- 不显示支撑数(合计与分平台均纯百分比);tooltip 不带分母。 + +### 12.3 测试 + +- 扩 `tests/test_coupon_platform_success.py::test_coupon_data_success_rates`:断言 `per_platform == {"meituan-waimai":0.75,"taobao-shanguang":round(2/3,4),"jd-waimai":0.5}`,并断言和不变量(`Σ 分子 == point_success_count == 6`、`Σ 分母 == point_total_count == 9`)。 +- 前端 `shaguabijia-admin-web` lint / type-check 通过。 +- 端到端:随下一步「真实 /step 实测」在跑起来的 admin-web + 后端页面上核对卡片渲染。 + +### 12.4 不做(YAGNI) + +- 成功率**趋势线**:后端 `daily` / `hourly` 不含率字段,加趋势要另改聚合,超出本轮范围。 +- 合计 / 分平台的**支撑数副文本**、tooltip 带分母。 +- 客户端 / pricebot 改动;历史回填。 + +--- + +## 13. 续做:每券(coupon_id)成功率明细表(设计 · 2026-07-08) + +产品要更细粒度:到**具体领券点位**(如「美团外卖红包天天领」「美团甄选好店」),即按 `coupon_id` 算成功/失败率,比 §12 的平台粒度再细一层。**改前端 + 后端 + 测试**;客户端 / pricebot 仍零改动。 + +- 状态:设计已评审通过(2026-07-08),待实现。 +- 关键取舍(已定):数据源用 **`coupon_claim_record`**(它本就是「单张券一天一条」的系统记录,已带 `coupon_name` / `status` / `vendor` / `trace_id`),唯一缺 `app_env` → 补一列即可。§2 当初绕开它是因为**平台指标要按 session 归因**;而**每券成功率是全局聚合、不需要 session 归因**,`(device,券,天)` 折叠反而天然去重防刷,故这里用它是对的。 + +### 13.1 指标口径(已定) + +- **成功** = `status ∈ {success, already_claimed}`;**尝试(分母)** = `status ∈ {success, already_claimed, failed}`;**`skipped` 排除**(无券可领/不适用,不算尝试、不进分母、不展示)。 +- **成功率** = 成功 / 尝试(某券区间内无 tried 行 → 不出现在表里,无除零)。 +- **粒度 = 「设备-天」**(非「每次点击」):`coupon_claim_record` 唯一键 `(device, coupon_id, claim_date)`,同设备当天同券只留最后状态。所以「尝试」= 有多少**设备-天**尝试过该券,「成功」= 其中最终领到的。要每次点击级须换源(route B / 原始事件),本轮不做。 +- **环境**:跟随页面「环境」(prod/dev/全部),按 `app_env` 过滤;旧行 `app_env=NULL` **不回填** → 仅「全部」视图可见。 +- **日期**:按 `claim_date`(Asia/Shanghai 自然日),与页面日期范围一致。 + +### 13.2 数据模型 + +`CouponClaimRecord`(`app/models/coupon_state.py`)加一列: + +| 列 | 类型 | 说明 | +|----|------|------| +| `app_env` | `String(16)`,index,nullable | prod / dev。`/step` 落库时按 session 的 app_env 打标;旧行 NULL(不回填)。 | + +- alembic 新迁移:`add_column coupon_claim_record.app_env`,nullable + index,无 server_default。 +- 同步更新 `docs/database/coupon_state.md` 的 coupon_claim_record 节。 + +### 13.3 写路径(`/step`) + +- `app/repositories/coupon_state.py::record_claims` 加参数 `app_env: str | None = None`;INSERT 时写入,UPDATE 时 `if app_env is not None: row.app_env = app_env`(不用 None 覆盖已有)。 +- 新增轻量 repo 助手 `session_app_env(db, trace_id) -> str | None`(按 trace_id 取 `coupon_session.app_env`,查不到返回 None)。 +- `app/api/v1/coupon.py::_record_claims_blocking`:`trace_id` 存在则先 `app_env = coupon_repo.session_app_env(db, trace_id)`,传给 `record_claims(..., app_env=app_env)`;`merge_session_platform_success` 保持原样(其自身 select 不变)。查不到 session / 无 trace_id → `app_env=None`(行为同旧)。 + > 写放大:仅「带券结果的帧」触发(同 §5),每帧多一次 `session_app_env` 小查询(按 trace_id 唯一索引),可忽略。 + +### 13.4 admin 聚合(新 repo 函数) + +`app/admin/repositories/coupon_data.py` 新增 `coupon_slot_report(db, *, date_from, date_to, app_env)`(与 `coupon_user_records` 同居本文件,同属「领券数据」看板;更新模块 docstring 注明本文件现读 `coupon_session` + `coupon_claim_record` 两源): + +- `SELECT coupon_id, MAX(coupon_name) AS coupon_name, COUNT(*) AS tried, SUM(CASE WHEN status IN (success,already_claimed) THEN 1 ELSE 0 END) AS succeeded` + `WHERE claim_date ∈ [from,to] AND status IN (success,already_claimed,failed)` (+ `AND app_env = :env` 当 env 非「全部」) `GROUP BY coupon_id`。 +- 每行:`platform = coupon_id_to_platform(coupon_id)`(复用前缀映射,无法识别→None),`success_rate = round(succeeded/tried, 4)`。 +- 按 `tried` 倒序、`coupon_id` 次序返回 `{"items": [...]}`。 + +### 13.5 接口 + schema(新子端点) + +- 路由 `app/admin/routers/coupon_data.py`:加 `@router.get("/coupons")` → `get_coupon_slots`,参数 `date_from` / `date_to` / `app_env`(同主 report 的解析与 `_MAX_RANGE_DAYS` 校验,`app_env="all"→None`),调 `coupon_slot_report`。路径全称 `/admin/api/coupon-data/coupons`(与现有 `/coupon-data/user-records` 同款子端点)。 +- schema `app/admin/schemas/coupon_data.py`: + - `CouponSlotRow{coupon_id: str, coupon_name: str|None, platform: str|None, tried: int, succeeded: int, success_rate: float|None}` + - `CouponSlotsOut{date_from: str, date_to: str, items: list[CouponSlotRow]}` + +### 13.6 前端 + +`shaguabijia-admin-web/src/app/(main)/coupon-data/page.tsx`: + +- 汇总卡下方(或趋势图下方)新增一张「按券成功率」表:列 **券名**(`coupon_name || coupon_id`)/ **平台**(美团/淘宝/京东/其他)/ **尝试** / **成功** / **成功率**(`fmtPct`)。默认按尝试倒序;支持 antd 列排序。 +- 点「查询」时,除主 report 外并行 `api.get('/admin/api/coupon-data/coupons', {params:{date_from,date_to,app_env}})`;空则不显示表。 +- 新增内联类型 `CouponSlotRow`;平台名复用映射(`meituan-waimai→美团` 等,null→其他)。 + +### 13.7 测试 + +- 后端:`record_claims` stamp `app_env`(INSERT/UPDATE 两路);`session_app_env` 助手;`/step` 集成写入 `app_env`;`coupon_slot_report` 聚合(多券 × success/already_claimed/failed/skipped × 两 env:断言 tried/succeeded/rate、**skipped 排除**、env 过滤、排序);schema 契约。 +- 前端:`tsc --noEmit`。 + +### 13.8 不做(YAGNI) + +- 每次点击级成功率(需换数据源);成功率趋势线;`app_env` 历史回填(可选一次性 `join trace_id→session.app_env`,默认不做);券级 tooltip / 明细下钻。 +- 券表为**全局聚合**,**不随页面「用户」搜索框过滤**(用户维度非本需求;`coupon_claim_record` 虽有 `user_id`,YAGNI)。与主明细表按用户过滤的行为不同,属有意为之。 diff --git a/docs/integrations/sms.md b/docs/integrations/sms.md index 7086643..c8f9bdd 100644 --- a/docs/integrations/sms.md +++ b/docs/integrations/sms.md @@ -16,9 +16,9 @@ ## 函数 / 异常 | 函数 | 行为 | |---|---| -| `send_code(phone) -> int` | 防刷检查(冷却 + 每日上限)→ `secrets` 生成 N 位码 → **预占**(冷却/计数/存码)→ mock 打日志 / real 调极光 → 失败**回滚预占**。返回距下次可发秒数 | +| `send_code(phone) -> int` | 防刷检查(冷却)→ `secrets` 生成 N 位码 → **预占**(冷却/存码)→ mock 打日志 / real 调极光 → 失败**回滚预占**。返回距下次可发秒数 | | `verify_code(phone, code) -> bool` | mock 放行任意 6 位;real 比对存码,匹配即作废,失败累计到上限作废 | -| `SmsError(msg, status_code)` | `status_code` 决定 HTTP 码:过频/每日超限 **429**、供应商失败 **503**、号码无效 **400** | +| `SmsError(msg, status_code)` | `status_code` 决定 HTTP 码:过频 **429**、供应商失败 **503**、号码无效 **400** | ## 配置 | 配置项 | 默认 | 说明 | @@ -30,17 +30,16 @@ | `SMS_SIGN_ID` | 31729 | 极光签名 ID | | `SMS_TEMPLATE_ID` | 1 | 极光模板 ID(变量名 `code`) | | `SMS_CODE_LENGTH` | 6 | 验证码位数(本服务生成;前端 code 4-8 位兼容) | -| `SMS_DAILY_LIMIT_PER_PHONE` | 10 | 单号每日发送上限(防刷 + 控费) | | `SMS_MAX_VERIFY_ATTEMPTS` | 5 | 单码最多校验失败次数,超过作废(防爆破) | > **鉴权复用极光一键登录**:`/v1/messages` 用 `base64(JG_APP_KEY:JG_MASTER_SECRET)` 做 HTTP Basic Auth——短信与一键登录是**同一个极光应用**(同 AppKey)。**上线不需要额外凭证,只需 `SMS_MOCK=false`**(`JG_*` 一键登录已配)。 ## 防刷(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权) -1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却 -2. 单号每日 `SMS_DAILY_LIMIT_PER_PHONE` 条上限 -3. 单 IP 频控:`/sms/send` 挂 `rate_limit(10,60)`、`/sms/login` 挂 `rate_limit(20,60)` -4. 单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废 -5. 运维侧建议在极光控制台叠加:**IP 白名单**(只许服务器 IP)+ **防轰炸设置** +> 2026-07-03 精简:登录风控只留「单号冷却 + 单设备频控」两道主策略(删单号每日上限、删登录纯 IP `rate_limit`);单码失败上限属验证码安全底线,保留。 +1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(单号维度) +2. 单设备(`device_id` + IP)每小时频控:`/sms/send` ≤ `SMS_SEND_MAX_PER_HOUR_PER_DEVICE`(5)、`/sms/login` ≤ `SMS_LOGIN_MAX_PER_HOUR`(5)——堵「换号绕开单号冷却」+ 挡登录撞库,在 `app/api/v1/auth.py` 内 `enforce_rate_limit` +3. 单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废 + 验过即作废(一次性) +4. 运维侧建议在极光控制台叠加:**IP 白名单**(只许服务器 IP)+ **防轰炸设置** ## 极光错误码(节选,映射在 `_send_via_jiguang`) | code | 含义 | 处理 | @@ -56,4 +55,4 @@ 3. 真机发一条验证:收到短信 + 能登录 ## 已知局限 -**验证码存进程内存**:单 worker uvicorn 够用;重启丢码(用户重发即可);**多 worker / 多机不共享 → 冷却 / 每日上限 / 校验失效**,扩 worker 前迁移到 DB/Redis。见 [待办与技术债](../guides/待办与技术债.md)。 +**验证码存进程内存**:单 worker uvicorn 够用;重启丢码(用户重发即可);**多 worker / 多机不共享 → 冷却 / 校验失效**,扩 worker 前迁移到 DB/Redis。见 [待办与技术债](../guides/待办与技术债.md)。 diff --git a/docs/superpowers/plans/2026-06-30-h5-home-feed-h2b.md b/docs/superpowers/plans/2026-06-30-h5-home-feed-h2b.md new file mode 100644 index 0000000..3f563fc --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-h5-home-feed-h2b.md @@ -0,0 +1,624 @@ +# H2b 首页 feed 真接入 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把 `h5/home/index.html` 写死的 mock feed 换成真实 `/api/v1/meituan/*` 数据,三排序 tab 接通、「抢」拉起美团,对齐原生 `HomeViewModel`/`HomeScreen` 全功能。 + +**Architecture:** 纯前端 feed 模块(裸 `fetch` 同源相对 `/api/v1`)按 `CouponCard` 渲染进现有 `.feed-card` 标记;`rec`/`distance`→`/meituan/feed`,`sales`→`/meituan/top-sales`,「抢」→`/meituan/referral-link`→`SGBridge.openMeituan`。坐标由新增 `SGBridge.getLocation()` 提供(安卓取 FusedLocation,拿不到落北京默认)。后端零改动。 + +**Tech Stack:** 原生 ES5 风格 JS(对齐 home 现有写法)+ FastAPI StaticFiles + pytest(TestClient 内容冒烟)+ Kotlin(安卓 SGBridge / WebViewScreen)。 + +**测试说明(本仓约定):** 本仓**无 JS 单测框架**;H5 的既有测试是 `tests/test_h5_hosting.py` 的**服务端内容冒烟**(断言 StaticFiles 下发的 HTML/JS 含关键标记 + 响应头)。故本计划:① 用 pytest 内容冒烟做可自动化的回归护栏(test-first);② feed 的**运行时行为**(渲染/翻页/三态/抢)用**浏览器手动验证**步骤(无桥→mock 北京)逐项核对;③ 安卓改动用**编译 + 真机**验证。这是与本仓现状一致的取舍,非偷懒。 + +**参考源(原生权威实现):** +- `shaguabijia-app-android` `ui/home/HomeViewModel.kt`(tab 映射 / 翻页 / 缓存 / 竞态 / getReferralLink) +- `ui/home/HomeScreen.kt:1694`(`FeedCard` 渲染)、`:615`(抢点击)、`:421`(FusedLocation lastLocation) +- 后端 `app/api/v1/meituan.py` + `app/schemas/meituan.py`(`CouponCard` 字段) + +--- + +## File Structure + +| 文件 | 责任 | 改动 | +|---|---|---| +| `h5/shared/bridge.js` | SGBridge JS 桥 | 新增 `getLocation()`(查询类同步) | +| `h5/home/index.html` | 首页 H5 | 删 mock 静态卡;新增 feed 模块 ``(行 10179)**之后**新增一个 ` +``` + +- [ ] **Step 4: 浏览器手动验证(无桥→mock 北京)** + +Run:`uvicorn app.main:app --port 8770`(或 `./run.sh`),浏览器开 `http://localhost:8770/h5/home/` +临时在 console 执行 `HomeFeed.loadFirst()`(Task 4 接 applyLocationState 后会自动触发)。 +Expected:`.home-feed` 出现真实券卡(头图/店名/价格/标签);滚到底自动追加下一页;点「智能推荐/距离最近/销量最高」切换列表;再切回 1s 内命中缓存不转圈。 + +- [ ] **Step 5: 提交** + +```bash +git add h5/home/index.html +git commit -m "feat(h5): 首页 feed 接 /meituan/feed+top-sales(渲染/翻页/缓存/切 tab/防竞态;删 mock 静态卡)" +``` + +--- + +## Task 4: 接定位状态(applyLocationState 联动)+ 三态 UI + 抢按钮 + +**Files:** +- Modify: `h5/home/index.html`(`applyLocationState` @9135;新增 `HomeFeedStates` + 抢委托) + +- [ ] **Step 1: 新增 `HomeFeedStates`(loading/empty/degraded → 复用 H2a 容器)** + +在 Task 3 的 feed 模块 ` +``` + +- [ ] **Step 2: 加 loading / degraded 两个空态容器** + +在 `#homeFeedNoDealEmpty`(行 5041 那个 div)**之后**插入两个容器(复用其样式骨架): + +```html + + + + +``` + +- [ ] **Step 3: applyLocationState 接 feed 加载** + +在 `applyLocationState`(行 9135)函数体**末尾 `}` 之前**加:已授权则首次拉/刷新坐标,未授权则清 feed 三态由 H2a 空态接管: + +```javascript + // H2b: 已授权 → 拉当前 tab(首次)或按坐标变化刷新;未授权 → 关 feed 三态(H2a 去授权空态接管) + if (locationGranted) { + if (window.HomeFeed) { + if (!HomeFeed.state.loadedOnce) HomeFeed.loadFirst(); + else HomeFeed.refreshIfCoordsChanged(); + } + } else { + ['homeFeedLoading', 'homeFeedDegraded', 'homeFeedNoDealEmpty'].forEach(function (id) { + var n = document.getElementById(id); if (n) n.style.display = 'none'; + }); + } +``` + +- [ ] **Step 4: 抢按钮 → referral-link → openMeituan(事件委托)** + +在 `HomeFeedStates` 的 `