diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..df80e80 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,116 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project overview + +Shaguabijia (傻瓜比价) App backend — FastAPI + SQLAlchemy 2.0 + JWT. Covers user auth (Jiguang one-click / SMS), welfare wallet (coins/cash/signin/tasks/savings), WeChat Pay withdrawals, ad-reward callbacks (Pangle/GroMore S2S), Meituan CPS (coupon forwarding / price comparison), and an admin backend. + +## Commands + +```bash +# Install +pip install -e ".[dev]" + +# Run app server (port 8770, auto-migrates, auto-reload) +./run.sh # or: uvicorn app.main:app --reload --port 8770 + +# Run admin server (port 8771, separate process) +uvicorn app.admin.main:admin_app --reload --port 8771 + +# Database +alembic upgrade head # apply all migrations (idempotent) +alembic revision --autogenerate -m "description" # generate new migration + +# Tests +pytest -q # all tests +pytest tests/test_auth.py -q # single file +pytest -k "test_sms_login" -q # single test by name + +# Lint +ruff check . +ruff check --fix . +``` + +## Architecture: two FastAPI apps + +This repo runs **two separate FastAPI processes** sharing the same `app/` codebase (models, repositories, integrations, config): + +| | App server | Admin server | +|---|---|---| +| Entry | `app/main.py` → `app:app` | `app/admin/main.py` → `admin_app` | +| Port | 8770 | 8771 | +| Auth | User JWT (`JWT_SECRET_KEY`) | Admin JWT (`ADMIN_JWT_SECRET`, independent) | +| Audience | Mobile app clients | Internal admin dashboard | +| Docs | `/docs` (non-prod only) | `/admin/docs` (non-prod only) | + +The two apps are intentionally decoupled — `app.main` never imports `app.admin`. Admin has its own auth chain (`app/admin/deps.py`, `app/admin/security.py`), role-based guards (`require_role`), and routers under `app/admin/routers/`. + +## Layered request flow + +``` +api/v1/ (thin: parse → delegate → respond + HTTP errors) + ├── integrations/ (external SDKs: signature, encryption, HTTP calls) + └── repositories/ (data access + transactions) + └── models/ (SQLAlchemy ORM, DeclarativeBase) +``` + +- **`api/v1/`**: Route handlers. Keep these thin — parse request, call repository or integration, return response. Never put business logic or external HTTP here. +- **`api/deps.py`**: Shared FastAPI dependencies — `get_current_user` (Bearer JWT → User ORM object), `get_db` (request-scoped session). +- **`integrations/`**: All external service logic — Jiguang REST + RSA decryption, WeChat Pay V3 signing/encryption, Meituan CPS gateway signing, Pangle callback signature verification, SMS sending. This is the layer you change when swapping vendors. +- **`repositories/`**: Data access. Each file wraps SQLAlchemy queries + transactions for one domain (user, wallet, signin, savings, ad_reward, etc.). Some repositories also call integrations (e.g., `wallet.py` calls `integrations/wxpay.py` for withdrawals). +- **`models/`**: ORM table definitions (SQLAlchemy 2.0 `Mapped` style, `DeclarativeBase`). Every new model must be imported in `app/models/__init__.py` so Alembic can discover it. +- **`schemas/`**: Pydantic request/response contracts. +- **`core/`**: Infrastructure — config (`pydantic-settings`), JWT (`security.py`), in-memory rate limiter (`ratelimit.py`), reward constants (`rewards.py`), logging setup, pricebot router (consistent-hash load balancing), withdraw reconcile worker. + +## Internal (server-to-server) endpoints + +Endpoints under `app/api/internal/` are for server-to-server communication (pricebot → app-server), NOT for clients. They use a shared secret header `X-Internal-Secret` (compared via `hmac.compare_digest`) instead of user JWT. If `INTERNAL_API_SECRET` is empty, these endpoints return 503. + +## Auth system + +- **User login**: Jiguang one-click (`integrations/jiguang.py` — REST token verification + RSA decryption with multi-padding retry) or SMS code (mock by default; `SMS_MOCK=true`). +- **Tokens**: JWT access (2h) + refresh (30d). Both are JWT with `typ` claim (`"access"` vs `"refresh"`) to prevent refresh-as-access. See `core/security.py`. +- **Admin auth**: Separate JWT secret (`ADMIN_JWT_SECRET`), 12h expiry, no refresh. Username + bcrypt password login. Role-based access via `require_role()` guard in `app/admin/deps.py` (`super_admin` bypasses all role checks). +- **Rate limiting**: In-memory fixed-window by client IP (`core/ratelimit.py`). Single-worker only; disabled in tests via `RATE_LIMIT_ENABLED=false`. + +## Database + +- **Dev**: SQLite (`sqlite:///./data/app.db`), `check_same_thread=False`, no connection pool. +- **Prod**: PostgreSQL — just change `DATABASE_URL` in `.env`. Pool size 10 + max overflow 20, pool_recycle 3600. +- **Migrations**: Alembic with `render_as_batch` for SQLite compatibility. ~60+ migration files in `alembic/versions/` (filenames are descriptive, not hex prefixes). Migration chain uses `down_revision` within each file. +- **New models**: Define in `app/models/`, import in `app/models/__init__.py`, then run `alembic revision --autogenerate`. + +## Config + +All config via `pydantic-settings` in `app/core/config.py`. Single `Settings` class with env vars / `.env` file. Access anywhere via `from app.core.config import settings`. Key patterns: +- `*_configured` properties gate features gracefully (e.g., `mt_cps_configured`, `wxpay_configured`, `pangle_callback_configured`) — missing credentials → endpoints return empty/503 rather than crashing at startup. +- Prod validation: `_enforce_prod_secrets` model validator blocks startup if `APP_ENV=prod` with weak JWT secrets. + +## Testing + +- `tests/conftest.py`: Sets env vars BEFORE imports, creates temp SQLite file, builds all tables with `Base.metadata.create_all()`, tears down with `drop_all()` + unlink. +- External integrations are monkeypatched in tests (e.g., WeChat Pay, Jiguang, Pangle callbacks) — tests never make real HTTP calls. +- `TestClient` from FastAPI is used for all tests. Rate limiting is disabled globally in tests. + +## Key integration details + +- **Jiguang one-click login**: REST call to verify `loginToken`, then RSA decrypt the returned phone number. Multiple padding schemes tried in order (PKCS1v15, OAEP with SHA1/SHA256) because Jiguang's encryption padding varies. +- **WeChat Pay withdrawals**: V3 API merchant transfer to user WeChat balance. Lazy-loads merchant certificates from `secrets/`. Withdrawal flow: bind WeChat → create withdraw order → auto-reconcile worker polls pending orders. +- **Pangle ad rewards**: S2S callback verification via SHA256 signature. Multiple `m-key` secrets supported (one per ad placement). Callback is idempotent by `trans_id`. Test grant endpoint (`AD_REWARD_TEST_GRANT_ENABLED`) for local debugging — must be false in prod. +- **Meituan CPS**: Gateway signature-based API calls. Proxy support (`MT_CPS_PROXY`) for local dev (direct connection causes SSL EOF). Coupon endpoints gracefully return empty when credentials are missing. +- **Pricebot forwarding**: `/api/v1/coupon/step` and `/api/v1/compare/*` proxy to pricebot-backend. Multi-instance support with consistent-hash routing by `trace_id` (see `core/pricebot_router.py`). +- **CPS redirect**: `/c/{code}` is a public (no auth) short-link redirect — records a click then 302s to Meituan. Click recording failure never blocks the redirect. + +## Money and units + +All monetary amounts are in **cents** (`*_cents` fields). Coins/gold have their own unit. Conversion constants are in `core/rewards.py`. + +## Scripts + +Key operational scripts in `scripts/`: +- `migrate.sh` — run migrations standalone +- `create_admin.py` — create admin user +- `daily_auto_exchange.py` — auto-convert coins to cash (triggered by systemd timer) +- `reconcile_withdraws.py` — reconcile withdrawal orders with WeChat Pay +- `sim_pangle_callback.py` — simulate Pangle S2S callback for testing diff --git a/docs/database/数据表字典-产品参考.md b/docs/database/数据表字典-产品参考.md new file mode 100644 index 0000000..fa59348 --- /dev/null +++ b/docs/database/数据表字典-产品参考.md @@ -0,0 +1,820 @@ +# 傻瓜比价 数据表字典(产品参考) + +> 面向产品/运营的全量数据表说明,按业务域分组。来源:`app/models/` 下全部 ORM 模型(共 **40 张表**)。 +> 字段名为数据库真实列名;本文档由模型源码整理,后端改表后以模型为准。 + +## 通用约定(先读) + +1. **金额单位**:所有 `*_cents` 字段单位是「**分**」,展示时 ÷100 为「元」。金币(coin)是「个数」,另有自己的兑换比例。 +2. **时间**:存储统一为带时区的 UTC;展示按北京时间。部分「按天聚合」字段直接存北京日期串 `'YYYY-MM-DD'` 或日期类型,避免跨时区比较。 +3. **JSON 字段**:线上 PostgreSQL 用 JSONB、本地/测试 SQLite 用普通 JSON,业务无差别。 +4. **device_id 有两种含义**(重要): + - **per-install**(存手机本地,卸载重装会变):领券、比价、设备存活用。 + - **硬件级稳定**(ANDROID_ID,卸载重装不变):新手引导用。 +5. **审计字段**:多数表有 `created_at`(创建时间)/ `updated_at`(更新时间),下文仅在有特殊含义时展开。 +6. **约束标记**:说明里 **主键** / **唯一** / **外键→表** / **可空** 表示该列的关键约束。 + +--- + +## 表总览 + +| 业务域 | 表名 | 中文名 | 一句话用途 | +|---|---|---|---| +| 用户 | `user` | 用户 | App 用户主表 | +| 钱包 | `coin_account` | 金币账户 | 每用户余额快照(金币/现金) | +| 钱包 | `coin_transaction` | 金币流水 | 金币每笔变动账本 | +| 钱包 | `cash_transaction` | 现金流水 | 现金(分)每笔变动账本 | +| 钱包 | `withdraw_order` | 提现单 | 现金→微信零钱提现 | +| 钱包 | `wechat_transfer_authorization` | 微信转账免确认授权 | 用户授权后转账免逐笔确认 | +| 激励 | `signin_record` | 签到记录 | 每日签到 | +| 激励 | `signin_boost_record` | 签到膨胀记录 | 签到后看广告翻倍补发 | +| 激励 | `user_task` | 一次性任务完成 | 只能领一次的任务 | +| 激励 | `comparison_milestone_claim` | 比价战绩领取 | 比价次数里程碑奖励 | +| 比价 | `comparison_record` | 比价记录 | 用户视角「我的比价记录」 | +| 比价 | `savings_record` | 省钱记录 | 「累计帮你省了」数据源 | +| 比价 | `price_observation` | 价格观测 | 平台/门店价格事实沉淀(后端) | +| 比价 | `store_mapping` | 平台店铺映射 | 跨平台同店身份映射(后端) | +| 比价 | `price_report` | 上报更低价 | 用户举报更低价、审核奖励 | +| 广告 | `ad_reward_record` | 激励视频发奖 | 穿山甲 S2S 回调发金币 | +| 广告 | `ad_ecpm_record` | 广告 eCPM 上报 | 展示收益统计/对账 | +| 广告 | `ad_feed_reward_record` | 信息流广告奖励 | 比价等待/领券信息流广告金币 | +| 广告 | `ad_watch_log` | 看广告时长 | 观看秒数记录(旧版/排查) | +| 领券 | `coupon_claim_record` | 领券记录 | App 内自动领券每张券结果 | +| 领券 | `coupon_daily_completion` | 领券每日完成 | 今日是否跑完整轮领券(置灰源) | +| 领券 | `coupon_prompt_engagement` | 领券弹窗频控 | 控制领券引导窗弹不弹 | +| CPS | `cps_group` | CPS 推广群 | 微信群(渠道追踪位 sid) | +| CPS | `cps_activity` | CPS 活动池 | 可推广的券(美团/淘宝/京东) | +| CPS | `cps_link` | CPS 群发短链 | `/c/{code}` 短链 | +| CPS | `cps_click` | CPS 点击事件 | 落地页点击/复制 | +| CPS | `cps_order` | CPS 对账订单 | 美团订单/佣金 | +| CPS | `cps_wx_user` | CPS 落地页微信用户 | 群发落地页授权用户 | +| CPS | `meituan_coupon` | 美团 CPS 券缓存 | 定时抓券本地缓存排序 | +| 邀请 | `invite_relation` | 邀请关系 | 邀请绑定与发奖 | +| 邀请 | `invite_fingerprint` | 邀请指纹归因 | 剪贴板失效时的兜底归因 | +| 设备 | `device_liveness` | 设备无障碍存活 | 无障碍掉线检测+推送提醒 | +| 设备 | `onboarding_completion` | 新手引导完成 | 引导只跑一次 | +| 反馈 | `feedback` | 用户反馈 | 帮助与反馈 | +| 后台 | `admin_user` | 管理员账号 | 后台账号(角色) | +| 后台 | `admin_audit_log` | 管理员操作审计 | 后台写操作留痕 | +| 配置 | `app_config` | 运营可配置项 | 把硬编码规则挪到 DB | +| 配置 | `ops_marquee_seed` | 首页轮播种子 | 滚动条兜底假数据规则 | +| 配置 | `ops_stat_config` | 首页三统计配置 | 门面数字展示模式 | +| 技术 | `launch_confirm_sample` | 启动确认窗样本 | 比价 agent 兜底样本(内部) | + +--- + +# 一、用户 + +## `user` — 用户表 + +App 用户主表。两种登录(极光一键 / 短信验证码)都映射到同一行,按 `phone` 唯一;注册即登录。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| phone | 字符串 | 手机号,**唯一**,登录主键 | +| username | 字符串 | 对外展示账号 ID:11 位纯数字、首位非 1、**唯一**、创建时随机生成、不可变、不参与登录 | +| register_channel | 字符串 | 注册渠道:jverify(极光)/ sms | +| nickname | 字符串 | 昵称,可空 | +| avatar_url | 字符串 | 头像 URL,可空 | +| invite_code | 字符串 | 邀请码,每用户一个稳定短码(懒生成),**唯一**、可空 | +| wechat_openid | 字符串 | 微信 openid(提现到零钱用),**唯一**、可空 | +| wechat_nickname | 字符串 | 微信昵称(提现绑定卡展示),与通用 nickname 分开 | +| wechat_avatar_url | 字符串 | 微信头像,同上 | +| status | 字符串 | 账号状态:active / disabled / deleted | +| debug_trace_enabled | 布尔 | 调试链接权限:开了的用户能看到「复制调试链接」按钮,默认 false | +| created_at | 时间 | 注册时间 | +| last_login_at | 时间 | 最后登录时间 | + +--- + +# 二、钱包 + +## `coin_account` — 金币账户 + +一个用户一行,余额快照(读取用)。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| user_id | 整数 | **主键 + 外键→user** | +| coin_balance | 整数 | 当前金币余额 | +| cash_balance_cents | 金额(分) | 当前现金余额 | +| total_coin_earned | 整数 | 累计赚取金币(只增不减),用于「历史总收益」展示 | +| updated_at | 时间 | 更新时间 | + +## `coin_transaction` — 金币流水 + +金币每笔变动账本,每次余额变动写一笔并记变动后余额,可逐笔对账。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| user_id | 整数 | **外键→user** | +| amount | 整数 | 变动额:正=入账(赚),负=出账(花/兑换) | +| balance_after | 整数 | 本笔变动后金币余额(对账用) | +| biz_type | 字符串 | 业务类型:signin / task_ / exchange_out / … | +| ref_id | 字符串 | 关联业务 id(签到日期、任务 key 等),可空;可重复任务按它去重防双发 | +| remark | 字符串 | 备注,可空 | +| created_at | 时间 | 时间 | + +## `cash_transaction` — 现金流水 + +现金(分)每笔变动账本。金币兑现金、提现记这里。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| user_id | 整数 | **外键→user** | +| amount_cents | 金额(分) | 正=入账(兑入),负=出账(提现) | +| balance_after_cents | 金额(分) | 本笔后现金余额 | +| biz_type | 字符串 | 业务类型:exchange_in(兑入)/ withdraw(提现)/ withdraw_refund(提现退回) | +| ref_id | 字符串 | 关联业务 id,可空 | +| remark | 字符串 | 备注,可空 | +| created_at | 时间 | 时间 | + +## `withdraw_order` — 提现单 + +现金 → 微信零钱。状态机:reviewing(待审核)→ pending(打款在途)→ success / failed;reviewing →(拒绝)→ rejected(已退款)。同一用户同时只能有一笔进行中。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| user_id | 整数 | **外键→user** | +| out_bill_no | 字符串 | 商户单号(我方生成,微信查单用),**唯一** | +| amount_cents | 金额(分) | 提现金额 | +| user_name | 字符串 | 提现实名(微信达额转账要求),可空 | +| status | 字符串 | reviewing / pending / success / failed / rejected | +| wechat_state | 字符串 | 微信侧原始状态(WAIT_USER_CONFIRM / SUCCESS / FAIL / CANCELLED…),可空 | +| transfer_bill_no | 字符串 | 微信转账单号,可空 | +| package_info | 字符串 | 待用户确认时给 App 拉起确认页的参数,可空 | +| fail_reason | 字符串 | 失败原因,可空 | +| created_at / updated_at | 时间 | 创建 / 更新时间 | + +## `wechat_transfer_authorization` — 微信转账免确认授权 + +用户授权「免确认收款」后,后续转账免逐笔确认。一个用户一条。状态机:pending(待用户确认)→ active(已生效可免确认)→ closed(已关闭需重开)。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| user_id | 整数 | **主键 + 外键→user** | +| openid | 字符串 | 用户微信 openid | +| out_authorization_no | 字符串 | 商户侧授权单号(我方生成),**唯一** | +| authorization_id | 字符串 | 微信侧授权单号(生效后返回,免确认转账要用),可空 | +| state | 字符串 | pending / active / closed | +| created_at / updated_at | 时间 | 创建 / 更新时间 | + +--- + +# 三、激励(签到 · 任务 · 比价奖励) + +## `signin_record` — 签到记录 + +每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| user_id | 整数 | **外键→user** | +| signin_date | 日期 | 签到日期(北京时间),与 user_id 组成**唯一** | +| cycle_day | 整数 | 1..7,7 天循环里今天第几档(决定发多少金币),断签重置回 1 | +| streak | 整数 | 连续签到天数(不封顶),断签重置回 1 | +| coin_awarded | 整数 | 本次发放金币 | +| created_at | 时间 | 时间 | + +## `signin_boost_record` — 签到膨胀记录 + +签到后看广告「膨胀」翻倍,一天最多一次,补发金额=当天签到原始奖励。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| user_id | 整数 | **外键→user** | +| signin_date | 日期 | 与 user_id 组成**唯一**(防并发重复补发) | +| coin_awarded | 整数 | 补发金币 | +| ad_ref_id | 字符串 | 广告会话/交易号,可空 | +| created_at | 时间 | 时间 | + +## `user_task` — 一次性任务完成记录 + +「打开消息提醒」这类只能领一次的任务,完成写一行,(user_id, task_key) 唯一。可循环任务(签到)走专表。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| user_id | 整数 | **外键→user** | +| task_key | 字符串 | 任务标识,与 user_id 组成**唯一** | +| status | 字符串 | 状态,默认 completed | +| coin_awarded | 整数 | 发放金币 | +| completed_at | 时间 | 完成时间 | + +## `comparison_milestone_claim` — 比价战绩里程碑领取 + +「记录比价战绩」每档(第 1~6 次)只能领一次。解锁进度由成功比价条数决定,本表只记「哪几档已领」。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| user_id | 整数 | **外键→user** | +| milestone | 整数 | 档位序号(1-based),与 user_id 组成**唯一** | +| coin_awarded | 整数 | 发放金币 | +| claimed_at | 时间 | 领取时间 | + +--- + +# 四、比价 · 省钱 + +## `comparison_record` — 比价记录 + +用户视角的「我的比价记录」,每完成一次比价客户端登录后上报一条。(user_id, trace_id) 唯一(重试幂等)。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| user_id | 整数 | **外键→user** | +| device_id | 字符串 | 设备号,可空 | +| business_type | 字符串 | food(外卖,当前唯一)/ ecom(电商)/ coupon(领券) | +| trace_id | 字符串 | 比价追踪号,与 user_id 组成**唯一** | +| trace_url | 字符串 | 公网调试链接(按用户 debug 权限决定是否返回),可空 | +| source_platform_id / _name | 字符串 | 源平台(发起比价那家)id / 名 | +| source_package | 字符串 | 源平台 App 包名 | +| source_price_cents | 金额(分) | 源平台价 | +| best_platform_id / _name | 字符串 | 最优(最便宜)平台 id / 名 | +| best_price_cents | 金额(分) | 最优价 | +| best_deeplink | 字符串 | 最优平台深链(「再次比价」直达),可空 | +| saved_amount_cents | 金额(分) | 省下金额=源价−最优价(可为 0/负) | +| is_source_best | 布尔 | 源平台就是最便宜(这次没省到),可空 | +| store_name | 字符串 | 店名 | +| total_dish_count / skipped_dish_count | 整数 | 下单菜数 / 跳过菜数 | +| status | 字符串 | success(有效对比)/ failed | +| information | 字符串 | done 文案:成功的到手价说明 / 失败原因 | +| items | JSON | 下单菜品明细 | +| comparison_results | JSON | 逐平台对比明细(含优惠/名次) | +| skipped_dish_names | JSON | 未找到跳过的菜名 | +| raw_payload | JSON | 客户端原始上报全量(取数兜底),可空 | +| device_model / device_manufacturer | 字符串 | 机型 / 厂商(排障),可空 | +| rom_vendor / rom_name / rom_version | 字符串/整数 | ROM 厂商/名/版本(排障),可空 | +| android_version / android_sdk | 字符串/整数 | 安卓版本,可空 | +| app_version / app_version_code | 字符串/整数 | 我方 App 版本,可空 | +| source_app_version | 字符串 | 源平台 App 版本(排适配失效),可空 | +| longitude / latitude | 小数 | 比价时定位,可空 | +| total_ms / step_count | 整数 | 整场耗时 / agent 步数(排障),可空 | +| llm_call_count / retry_count | 整数 | LLM 调用 / 重试次数,可空 | +| input_tokens / output_tokens | 整数 | LLM 累计 token,可空 | +| llm_calls | JSON | 每次 LLM 调用明细,可空 | +| created_at | 时间 | 时间 | + +## `savings_record` — 省钱记录 + +profile「累计帮你省了」「省钱战绩」的唯一数据源。(user_id, client_event_id) 唯一(真实上报幂等)。当前由 demo seeder 灌入,比价真接入后改上报写入。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| user_id | 整数 | **外键→user** | +| order_amount_cents | 金额(分) | 订单实付金额 | +| saved_amount_cents | 金额(分) | 省下金额 | +| platform | 字符串 | 平台,可空 | +| title | 字符串 | 标题,可空 | +| shop_name | 字符串 | 店铺名(订单卡标题),可空 | +| dishes | JSON | 菜品名列表(前 2 道直接展示) | +| source | 字符串 | demo(演示)/ compare(真实比价上报) | +| original_price_cents | 金额(分) | 源平台原价,可空(真实上报才有) | +| compared_price_cents | 金额(分) | 我们给出的比价价,可空 | +| pay_channel | 字符串 | 支付渠道:wechat / alipay,可空 | +| platform_package | 字符串 | 实际下单平台包名,可空 | +| source_platform_name | 字符串 | 源平台展示名(如「美团」),可空 | +| source_deeplink | 字符串 | 源平台重进链接(预留),可空 | +| client_event_id | 字符串 | 客户端幂等键(UUID),demo 行为空 | +| device_id | 字符串 | 设备号,可空 | +| created_at | 时间 | 时间 | + +## `price_observation` — 价格观测(后端数据资产) + +平台/门店视角的「价格事实」。比价跑到 done 后由服务端无条件沉淀(与登录无关、匿名也记),是未来「查过同店秒回价」的源头。(trace_id, platform, scope) 唯一。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| observed_at | 时间 | 观测时刻(≈比价 done 时) | +| trace_id | 字符串 | 比价追踪号(溯源+幂等) | +| business_type | 字符串 | food / ecom | +| platform | 字符串 | 平台 | +| platform_store_id | 字符串 | 平台内门店 ID,可空 | +| store_name / city | 字符串 | 门店名 / 城市,可空 | +| geohash / lng / lat | 字符串/小数 | 地理位置,可空 | +| is_source | 布尔 | 是否源平台 | +| scope | 字符串 | order(整单到手价,当前唯一)/ dish(单菜价) | +| price_cents | 金额(分) | 该口径到手价;空=采集失败/打烊 | +| coupon_saved_cents | 金额(分) | 优惠额,可空 | +| coupon_name | 字符串 | 优惠名,可空 | +| store_closed | 字符串 | 打烊原因(非空时无有效价),可空 | +| rank | 整数 | 本次比价全局名次(1=最便宜),可空 | +| dishes | JSON | 菜篮明细,可空 | +| attrs | JSON | 灵活字段兜底,可空 | +| source_device_id / source_user_id | 字符串/整数 | 来源设备 / 用户(不鉴权时为空) | +| confidence | 小数 | 置信度,默认 1.0 | +| created_at | 时间 | 时间 | + +## `store_mapping` — 平台店铺映射(后端数据资产) + +跨平台「同一家店」在淘宝/美团/京东各自的店铺 id 与店名映射。每次淘宝比价沉淀一行。trace_id 唯一。⚠️ 数据来自 LLM 店铺匹配,可能匹配错。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| id_taobao / name_taobao | 字符串 | 淘宝店铺 id(shopId)/ 名,可空 | +| id_meituan / name_meituan | 字符串 | 美团店铺 id(预留稳定 poi)/ 名,可空 | +| id_jd / name_jd | 字符串 | 京东店铺 id(storeId)/ 名,可空 | +| city / geohash / lng / lat | 字符串/小数 | 地理位置,可空 | +| taobao_address | 字符串 | 淘宝门店地址,可空 | +| source_platform | 字符串 | 源平台,可空 | +| business_type | 字符串 | 业务类型,默认 food | +| trace_id | 字符串 | 比价追踪号(溯源+**唯一**) | +| source_device_id / source_user_id | 字符串/整数 | 来源设备 / 用户,可空 | +| taobao_share_url / taobao_resolved_url / taobao_deeplink | 字符串/文本 | 淘宝短链 / 解析 URL / 深链 | +| taobao_deeplink_invalid_at | 时间 | 淘宝深链失效标记(空=有效) | +| meituan_poi_id_str | 字符串 | 美团一次性票据(非稳定主键),可空 | +| meituan_share_url / meituan_resolved_url / meituan_deeplink | 字符串/文本 | 美团短链 / 解析 URL / 深链 | +| jd_vender_id | 字符串 | 京东商家 id(深链要用),可空 | +| jd_share_url / jd_resolved_url / jd_deeplink | 字符串/文本 | 京东短链 / 解析 URL / 深链 | +| jd_deeplink_invalid_at | 时间 | 京东深链失效标记(空=有效) | +| attrs | JSON | 灵活字段兜底,可空 | +| created_at | 时间 | 时间 | + +## `price_report` — 上报更低价 + +用户在比价记录里举报「某平台有更低价」+ 截图,人工审核通过奖励金币。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| user_id | 整数 | **外键→user** | +| comparison_record_id | 整数 | **外键→comparison_record**,关联的比价记录,可空(记录删后保留上报) | +| store_name / dish_summary | 字符串 | 比价记录快照:店名 / 菜品摘要,可空 | +| original_platform_id / _name | 字符串 | 原最低价平台快照 | +| original_price_cents | 金额(分) | 原最低价快照 | +| reported_platform_id / _name | 字符串 | 用户上报的平台 | +| reported_price_cents | 金额(分) | 用户上报的更低价 | +| images | JSON | 截图 URL 列表 | +| status | 字符串 | pending(审核中)/ approved / rejected | +| reject_reason | 字符串 | 驳回原因,可空 | +| reward_coins | 整数 | 通过奖励金币,可空 | +| reviewed_at | 时间 | 审核时间,可空 | +| created_at | 时间 | 提交时间 | + +--- + +# 五、广告变现 + +> 看广告有三条并列数据流:`ad_reward_record`(后端 S2S 发金币)、`ad_ecpm_record`(前端报展示收益)、`ad_watch_log`(前端报观看时长);外加点位 2 的 `ad_feed_reward_record`(信息流)。 + +## `ad_reward_record` — 激励视频发奖记录 + +每条=穿山甲/GroMore 一次服务端激励回调。`trans_id` 唯一做幂等键。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| trans_id | 字符串 | 穿山甲交易号,**唯一**(幂等键) | +| user_id | 整数 | **外键→user** | +| coin | 整数 | 实发金币(超限为 0) | +| status | 字符串 | granted(已发)/ capped(当日超限)/ ecpm_missing(缺 eCPM) | +| reward_scene | 字符串 | reward_video(福利页看视频)/ signin_boost(签到膨胀) | +| ad_session_id | 字符串 | 广告会话 id,可空 | +| ecpm_raw | 字符串 | 本次发奖采用的 eCPM 原始值,可空 | +| app_env | 字符串 | 应用环境 prod/test(回填),可空 | +| our_code_id | 字符串 | 我方配置代码位 104xxx(回填),可空 | +| reward_date | 字符串 | 北京日期串,按它统计当日发奖次数 | +| reward_name | 字符串 | 穿山甲上报奖励名(参考),可空 | +| raw | 字符串 | 回调原始参数(审计),可空 | +| created_at | 时间 | 时间 | + +## `ad_ecpm_record` — 广告 eCPM 上报 + +每条=客户端一次广告展示后读到的 eCPM。`ad_session_id` 唯一,把展示收益与奖励完成绑定。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| user_id | 整数 | **外键→user** | +| ad_type | 字符串 | reward_video / draw 等 | +| ad_session_id | 字符串 | 广告会话 id,**唯一**、可空 | +| adn | 字符串 | 实际 ADN(pangle / gdt…),可空 | +| slot_id | 字符串 | 底层代码位 rit,可空 | +| app_env | 字符串 | 应用环境 prod/test,可空 | +| our_code_id | 字符串 | 我方配置代码位 104xxx,可空 | +| ecpm_raw | 字符串 | eCPM 原始字符串(单位:分/千次展示) | +| report_date | 字符串 | 北京日期串(按天聚合) | +| created_at | 时间 | 时间 | + +## `ad_feed_reward_record` — 信息流广告奖励 + +点位 2:比价等待 / 领券信息流广告,每满 10 秒累计一份,视频完成一次性入账。`client_event_id` 唯一。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| client_event_id | 字符串 | 客户端事件 id,**唯一**(幂等) | +| ad_session_id | 字符串 | 广告会话 id,可空 | +| user_id | 整数 | **外键→user** | +| reward_date | 字符串 | 北京日期串 | +| duration_seconds | 整数 | 本次展示秒数 | +| unit_count | 整数 | 累计份数 | +| ecpm_raw | 字符串 | eCPM 原始值 | +| adn / slot_id | 字符串 | ADN / 代码位,可空 | +| feed_scene | 字符串 | comparison(比价等待)/ coupon(领券)/ welfare(福利页),可空 | +| trace_id | 字符串 | 比价场景的 trace_id(归属本场比价金币),可空 | +| app_env / our_code_id | 字符串 | 应用环境 / 代码位,可空 | +| coin | 整数 | 实发金币 | +| status | 字符串 | 状态,默认 granted | +| created_at | 时间 | 时间 | + +## `ad_watch_log` — 看广告时长记录 + +每条=客户端上报的一次激励视频观看秒数。当前发奖以次数上限为准,本表保留作旧客户端兼容/排查。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| user_id | 整数 | **外键→user** | +| watch_seconds | 整数 | 本次观看秒数(服务端已夹区间) | +| watch_date | 字符串 | 北京日期串(按天聚合总时长) | +| created_at | 时间 | 时间 | + +--- + +# 六、领券(App 内自动领券) + +> 判断维度是 **device_id**(per-install,重装会变);日期为北京自然日。 + +## `coupon_claim_record` — 领券记录 + +按 (设备, 券, 自然日) 记每张券领取结果,纯沉淀(资产/画像/排查),当前不参与「要不要领」判断。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| device_id | 字符串 | 设备号(与 coupon_id、claim_date 组成**唯一**) | +| user_id | 整数 | 登录态用户,可空(不进唯一键) | +| coupon_id | 字符串 | 券 id | +| claim_date | 日期 | 北京自然日 | +| status | 字符串 | success / already_claimed / failed / skipped | +| vendor | 字符串 | 券来源,可空 | +| coupon_name | 字符串 | 券名,可空 | +| claimed_count | 整数 | 领到几张,可空 | +| trace_id | 字符串 | 哪次任务领的(排查),可空 | +| reason | 字符串 | 失败/跳过原因,可空 | +| extra | JSON | 券结构化信息兜底,可空 | +| created_at / updated_at | 时间 | 创建 / 更新时间 | + +## `coupon_daily_completion` — 领券每日完成 + +按 (设备, 自然日) 记「今天是否跑完整轮领券(到 done)」——首页「去领取」卡置灰源。到 done 即算(不管单券成败)。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| device_id | 字符串 | 设备号(与 complete_date 组成**唯一**) | +| user_id | 整数 | 登录态用户,可空 | +| complete_date | 日期 | 北京自然日 | +| trace_id | 字符串 | 哪次任务跑到 done(排查),可空 | +| created_at / updated_at | 时间 | 创建 / 更新时间 | + +## `coupon_prompt_engagement` — 领券弹窗频控 + +按 (设备, App, 自然日) 记「今天这个 App 是否对领券引导窗表达过意向」——弹窗频控源。美团/淘宝/京东各自独立。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| device_id | 字符串 | 设备号(与 package、engage_date 组成**唯一**) | +| package | 字符串 | 目标 App 包名(频控维度,各 App 独立) | +| user_id | 整数 | 登录态用户,可空 | +| engage_date | 日期 | 北京自然日 | +| engage_type | 字符串 | claim_started(点一键领取)/ dismissed(拒绝/关闭)/ shown(自动弹出) | +| created_at / updated_at | 时间 | 创建 / 更新时间 | + +--- + +# 七、CPS 群发分发(微信群发券) + +## `cps_group` — CPS 推广群 + +每个微信群一条;`sid` 是美团联盟渠道追踪位(仅美团需要)。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| sid | 字符串 | 渠道追踪位,**唯一**、可空(纯淘宝/京东群无) | +| name | 字符串 | 群名 | +| platforms | JSON | 该群发哪些平台:["meituan","taobao","jd"] | +| member_count | 整数 | 群人数(转化率分母),可空 | +| status | 字符串 | active / archived | +| remark | 字符串 | 备注,可空 | +| created_at | 时间 | 时间 | + +## `cps_activity` — CPS 活动池 + +可推广的券(运营预存)。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| platform | 字符串 | meituan / taobao / jd | +| name | 字符串 | 活动名 | +| act_id | 字符串 | 美团活动物料 ID,可空 | +| product_view_sign | 字符串 | 美团商品券标识(与 act_id 二选一),可空 | +| payload | 文本 | 淘宝整段淘口令 / 京东推广链接,可空 | +| image_url | 字符串 | 淘宝落地页主视觉图(绝对 URL),可空 | +| status | 字符串 | active / archived | +| remark | 字符串 | 备注,可空 | +| created_at | 时间 | 时间 | + +## `cps_link` — CPS 群发短链 + +群发的 `/c/{code}` 短链,每个活动每次生成一条。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| code | 字符串 | 短码(`/c/{code}`),**唯一** | +| group_id | 整数 | 所属群 id | +| activity_id | 整数 | 所属活动 id | +| sid | 字符串 | 渠道追踪位(仅美团),可空 | +| platform | 字符串 | meituan / taobao / jd | +| target_url | 字符串 | 跳转目标:美团短链 / 京东链接 / 淘宝淘口令 | +| created_at | 时间 | 时间 | + +## `cps_click` — CPS 点击事件 + +落地页点击/复制事件,统计 PV/UV 及用户级领券。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| link_id | 整数 | 所属短链 id | +| group_id | 整数 | 所属群 id(冗余,按群聚合快) | +| sid | 字符串 | 渠道追踪位(冗余),可空 | +| event_type | 字符串 | visit(进页/被跳转)/ copy(淘宝点复制口令) | +| ip / ua | 字符串 | 客户端 IP / UA(UV 近似去重),可空 | +| openid | 字符串 | 微信授权用户标识(非微信/未授权为空) | +| clicked_at | 时间 | 点击时间 | + +## `cps_order` — CPS 对账订单 + +从美团联盟拉回、按 sid 归群的订单明细。`order_id` 唯一(upsert 幂等)。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| order_id | 字符串 | 美团订单号,**唯一** | +| sid | 字符串 | 渠道追踪位(按它归群),可空 | +| act_id | 字符串 | 活动物料 id,可空 | +| biz_line | 整数 | 业务线(1=外卖),可空 | +| trade_type | 整数 | 1=cps 2=cpa,可空 | +| pay_price_cents | 金额(分) | 付款金额,可空 | +| commission_cents | 金额(分) | 预估佣金,可空 | +| commission_rate | 字符串 | 佣金率("300"=3%),可空 | +| refund_price_cents / refund_profit_cents | 金额(分) | 退款金额 / 退款佣金,可空 | +| mt_status | 字符串 | 美团状态:2 付款 3 完成 4 取消 5 风控 6 结算 | +| invalid_reason | 字符串 | 失效原因,可空 | +| product_name | 字符串 | 商品名,可空 | +| pay_time / mt_update_time | 时间 | 付款 / 美团更新时间,可空 | +| raw | JSON | 原始订单返回 | +| first_seen / updated_at | 时间 | 首次拉到 / 更新时间 | + +## `cps_wx_user` — CPS 落地页微信用户 + +群发落地页经服务号网页授权拿到的用户。`openid` 唯一。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| openid | 字符串 | 服务号下用户唯一标识,**唯一** | +| unionid | 字符串 | 开放平台 unionid,可空 | +| nickname / headimgurl | 字符串 | 昵称 / 头像(userinfo 授权后才有),可空 | +| first_code | 字符串 | 首次进入来源短码,可空 | +| first_group_id | 整数 | 首次授权来源群,可空 | +| first_seen / last_seen | 时间 | 首次 / 最近出现时间 | + +## `meituan_coupon` — 美团 CPS 券本地缓存 + +定时把美团联盟券抓进本地库,供本地排序(销量/佣金)。(source, product_view_sign) 唯一;查询用 `dedup_key` 跨源去重。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| source | 字符串 | search_waimai / search_meishi / store_supply(与 product_view_sign 组成**唯一**) | +| platform | 整数 | 1 到家/外卖,2 到店 | +| biz_line | 整数 | 到店细分:1 到餐 2 到综 3 酒店 4 门票,可空 | +| city_id | 字符串 | 城市 id | +| product_view_sign | 字符串 | 召回标识(换推广链用) | +| sku_view_id | 字符串 | sku 标识,可空 | +| name / brand_name | 字符串 | 商品名 / 品牌名,可空 | +| sell_price_cents / original_price_cents | 金额(分) | 售价 / 原价,可空 | +| head_url | 字符串 | 头图 URL,可空 | +| image_size / image_type | 整数/字符串 | 头图字节大小 / MIME(分析用),可空 | +| sale_volume | 字符串 | 销量档位文本(如「热销 1w+」),可空 | +| sale_volume_num | 整数 | 销量排序用数值,可空 | +| commission_percent | 小数 | 佣金率(1.4=1.4%),可空 | +| commission_amount_cents | 金额(分) | 佣金额,可空 | +| poi_name | 字符串 | 门店名,可空 | +| available_poi_num | 整数 | 可用门店数,可空 | +| delivery_distance_m | 小数 | 配送距离(米),可空 | +| dedup_key | 字符串 | 跨源去重键 md5(brand\|name\|price) | +| raw | JSON | 原始返回 | +| first_seen / last_seen / updated_at | 时间 | 首次 / 最近 / 更新时间 | + +--- + +# 八、邀请裂变 + +## `invite_relation` — 邀请关系 + +一行=一次成功邀请绑定。`invitee_user_id` 唯一(一人只能被邀一次,防重复发奖)。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| inviter_user_id | 整数 | **外键→user**,邀请人 | +| invitee_user_id | 整数 | **外键→user**,被邀请人,**唯一** | +| channel | 字符串 | clipboard(剪贴板自动)/ manual(手动填码)/ fingerprint(指纹兜底) | +| status | 字符串 | effective(注册即生效),预留 pending | +| inviter_coin / invitee_coin | 整数 | 各自发放金币 | +| created_at | 时间 | 时间 | + +## `invite_fingerprint` — 邀请指纹归因 + +剪贴板归因失效时的兜底:落地页访问留指纹,装机后用 (IP, 屏幕, 机型) 7 天内反查。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| inviter_user_id | 整数 | **外键→user**,邀请人 | +| ip | 字符串 | 落地页访问者 IP | +| device_model | 字符串 | 机型(Build.MODEL / UA 解析) | +| screen | 字符串 | 屏幕分辨率(如 1080x2400) | +| user_agent | 文本 | 完整 UA(debug 用) | +| created_at | 时间 | 时间 | + +--- + +# 九、设备 · 新手引导 + +## `device_liveness` — 设备无障碍存活监控 + +每条=一个用户的一台设备。无障碍存活时上报心跳,掉线超时则极光推送提醒。(user_id, device_id) 唯一。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| user_id | 整数 | **外键→user** | +| device_id | 字符串 | per-install 设备号(与 user_id 组成**唯一**) | +| registration_id | 字符串 | 极光推送目标 id,可空 | +| platform | 字符串 | 平台,默认 android | +| app_version | 字符串 | App 版本,可空 | +| ever_protected | 布尔 | 是否开过无障碍(功能对它有意义) | +| last_heartbeat_at | 时间 | 最近心跳时间(超时=掉线),可空 | +| last_report_protection_on | 布尔 | 最近上报的无障碍开关状态 | +| liveness_state | 字符串 | unknown / alive / silent / notified | +| notified_at | 时间 | 最近推送告警时间,可空 | +| kill_alert_pending | 布尔 | 掉线告警待客户端提醒标记 | +| created_at / updated_at | 时间 | 创建 / 更新时间 | + +## `onboarding_completion` — 新手引导完成 + +同一台设备+同一账号,新手引导只跑一次。device_id 用**硬件级稳定标识**(重装不变)。(user_id, device_id) 唯一。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| user_id | 整数 | 登录态用户(与 device_id 组成**唯一**) | +| device_id | 字符串 | 硬件级稳定设备号(ANDROID_ID) | +| completed_at | 时间 | 完成时间 | + +--- + +# 十、反馈 + +## `feedback` — 用户反馈 + +帮助与反馈,人工审核可奖励金币。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| user_id | 整数 | **外键→user** | +| content | 文本 | 反馈内容(必填) | +| contact | 字符串 | 联系方式(改版后客户端不再采集,新数据存空串) | +| images | JSON | 截图 URL 列表,可空 | +| status | 字符串 | pending(审核中)/ adopted(已采纳)/ rejected | +| reject_reason | 字符串 | 驳回原因,可空 | +| reward_coins | 整数 | 采纳奖励金币,可空 | +| review_note | 字符串 | 审核批注,可空 | +| reviewed_by_admin_id | 整数 | **外键→admin_user**,审核人,可空 | +| reviewed_at | 时间 | 审核时间,可空 | +| created_at | 时间 | 提交时间 | + +--- + +# 十一、运营后台 · 配置 + +## `admin_user` — 管理员账号 + +后台账号,与 App 用户完全隔离(独立 JWT)。账号密码(bcrypt)登录。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| username | 字符串 | 用户名,**唯一** | +| password_hash | 字符串 | bcrypt 密码哈希 | +| role | 字符串 | super_admin(全权)/ finance(钱)/ operator(用户+反馈+大盘) | +| status | 字符串 | active / disabled | +| created_at | 时间 | 创建时间 | +| last_login_at | 时间 | 最后登录时间,可空 | + +## `admin_audit_log` — 管理员操作审计 + +每个后台写操作落一条,记前后值,不可删,用于追溯「谁在何时改了谁的钱/状态」。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| admin_id | 整数 | **外键→admin_user** | +| admin_username | 字符串 | 操作者用户名(冗余存,改名后仍可追溯) | +| action | 字符串 | 操作类型(如 user.coins.grant / withdraw.refresh) | +| target_type | 字符串 | 被操作对象类型 | +| target_id | 字符串 | 被操作对象 id(字符串以兼容非整型主键),可空 | +| detail | JSON | 上下文 + 前后值,可空 | +| ip | 字符串 | 操作 IP,可空 | +| created_at | 时间 | 时间 | + +## `app_config` — 运营可配置项 + +把硬编码规则(奖励数值等)挪到 DB,运营后台可改。空表=现有行为完全不变(fallback 到代码默认)。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| key | 字符串 | **主键**,配置标识 | +| value | JSON | 配置值(任意 int/list/dict) | +| updated_by_admin_id | 整数 | 最后修改管理员,可空 | +| updated_at | 时间 | 更新时间 | + +## `ops_marquee_seed` — 首页轮播种子 + +首页「用户 xxx 比价省了 xx 元」滚动条的兜底假数据**规则**:真实记录不够时用它补齐混播。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| masked_user | 字符串 | 脱敏用户名;留空则展示时随机合成,可空 | +| min_cents / max_cents | 金额(分) | 节省金额区间(展示时区间内随机取值) | +| enabled | 布尔 | 是否启用(停用不参与混播) | +| sort_order | 整数 | 仅后台列表排序(feed 公平随机,不看此字段) | +| created_at | 时间 | 时间 | + +## `ops_stat_config` — 首页三统计配置 + +首页门面三数字(帮助用户/完成比价/累计节省),每指标独立选展示模式。一行一指标。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| metric | 字符串 | **主键**:help_users / total_compares / total_saved | +| mode | 字符串 | real(真实)/ manual(手填固定值)/ random(只增不减伪增长) | +| manual_value | 整数 | manual 模式固定值(total_saved 为分),可空 | +| random_mult_min / random_mult_max | 整数 | random 倍率区间(千分比,≥1000 只增不减) | +| random_tick_seconds | 整数 | random 刷新周期(秒) | +| random_anchor_minutes | 整数 | 刷新触发时刻对齐偏移(距北京 0 点分钟数) | +| random_current | 整数 | random 当前值,可空 | +| random_last_tick_at | 时间 | 上次 tick 时刻,可空 | +| random_kind | 字符串 | mult(×倍率)/ add(+绝对增量) | +| random_step_min / random_step_max | 整数 | add 模式增量区间 | +| real_offset | 整数 | real 模式保底值(展示=max(真实,保底)) | +| allow_decrease | 布尔 | 是否允许下调(默认只增不减) | +| updated_by_admin_id | 整数 | 最后修改管理员,可空 | +| updated_at | 时间 | 更新时间 | + +--- + +# 十二、技术沉淀(内部) + +## `launch_confirm_sample` — 启动确认窗样本 + +比价 agent 用 LLM 兜底放行跨 App 启动确认窗时落的样本,研发人工沉淀进静态规则。内部排障用,不去重。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | 整数 | **主键** | +| created_at | 时间 | 时间 | +| trace_id | 字符串 | 比价追踪号(溯源),可空 | +| device_id | 字符串 | 设备号,可空 | +| host_package | 字符串 | 弹窗宿主包(聚合键),可空 | +| target_app | 字符串 | 被打开的目标 App 包名,可空 | +| system_locale | 字符串 | 系统语言地区(zh-TW / en-US…),可空 | +| exec_success | 布尔 | 本次兜底是否成功放行(研发只沉淀 true 的) | +| dialog_title | 字符串 | 弹窗标题全文,可空 | +| payload | JSON | 完整样本(弹窗树 + LLM plan + 设备信息),可空 | + +--- + +*文档生成基于 `app/models/` 全量模型。如需单表更详细的索引/迁移信息,见 `docs/database/` 下对应同名文件。* diff --git a/scripts/seed_mock_cps_stats.py b/scripts/seed_mock_cps_stats.py new file mode 100644 index 0000000..6a12a46 --- /dev/null +++ b/scripts/seed_mock_cps_stats.py @@ -0,0 +1,337 @@ +"""一次性 mock:造 CPS「按群对账统计」+「群内微信用户(领券画像)」所需假数据。 + +覆盖两个后台接口: + • get_stats (GET /admin/cps/stats) → cps_repo.group_stats + • group_wx_users (GET /admin/cps/groups/{id}/wx-users) → cps_repo.group_wx_users + +涉及四张表: + - cps_group 群(active 才入统计;sid 仅美团群有) + - cps_order 对账订单(按 sid 归群;status 2付款 3完成=有效 / 4取消 5风控=无效 / 6结算) + - cps_click 点击(group_stats 按 group_id 聚合 pv/uv;group_wx_users 只看带 openid 的点击) + - cps_wx_user 落地页微信用户画像(nickname/headimgurl;userinfo 授权后才有) + +覆盖 group_stats 全部分支:① active 美团群(对账) ② 美团+淘宝多平台 ③ 纯淘宝/京东(无 sid, +对账字段 None) ④ archived 群(被过滤) ⑤ 未归群历史 sid(group_id=None 单列)。 + +覆盖 group_wx_users 全部分支(挂在淘宝系群 B/C —— 仅淘宝落地页有"复制口令"触发 userinfo): + • 有完整画像(昵称+可加载头像)的领券用户(visit+copy) + • 只 visit 不 copy 的用户(无领券意向) + • 跨群用户(在 C 首次授权、又来 B 活动)→ 验证"按本群点击判定归属,而非 first_group_id" + • 有 openid 但无画像(base 静默授权)→ nickname/headimgurl 返回 None(前端走色块兜底) + +头像:本地手写纯色 PNG 落 data/media/cps_wx/,headimgurl 存绝对 URL(经 App 后端 :8770 的 +/media 托管),后台 可直接加载。时间均落最近几天(默认窗口内),tz-aware UTC。 + +幂等:重跑先按 mock 标识(群名前缀「【MOCK】」/ 订单号前缀「MOCKCPS-」/ openid 前缀 +「oMOCKcps」)清旧再重建;点击/短链按 mock 群 id 清,头像文件按 glob 清。--clean-only 只清。 + + python -m scripts.seed_mock_cps_stats + python -m scripts.seed_mock_cps_stats --clean-only + +看数据:后台「CPS / 按群对账统计」页 + 群详情「群内微信用户」,或: + GET /admin/cps/stats?days=30 + GET /admin/cps/groups/{B或C的id}/wx-users +""" +from __future__ import annotations + +import argparse +import struct +import sys +import zlib +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from sqlalchemy import delete, select + +from app.core.config import settings +from app.db.session import SessionLocal +from app.models.cps_group import CpsGroup +from app.models.cps_link import CpsClick, CpsLink +from app.models.cps_order import CpsOrder +from app.models.cps_wx_user import CpsWxUser + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") # Windows 控制台输出中文/¥ + +# ── mock 标识(只动带这些标识的数据,便于幂等清理)── +NAME_PREFIX = "【MOCK】" # 群名前缀 +ORDER_PREFIX = "MOCKCPS-" # 订单号前缀 +OPENID_PREFIX = "oMOCKcps" # 微信用户 openid 前缀 +SID_MT_A = "mockcpsmta" # 群A sid(美团);sid 仅字母+数字 +SID_MT_B = "mockcpsmtb" # 群B sid(美团+淘宝) +SID_ARCHIVED = "mockcpsmtz" # 已归档群 sid +SID_ORPHAN = "mockcpsold" # 未归群历史 sid(不属于任何群) + +# 头像:本地纯色 PNG 落 media/cps_wx/,headimgurl 存「绝对 URL」(微信场景本就是完整外链, +# 后台直接当 );本地由 App 后端 :8770 托管 /media。 +APP_MEDIA_BASE = "http://localhost:8770" +_AVATAR_DIR = Path(settings.MEDIA_ROOT) / "cps_wx" +_AVATAR_GLOB = "mock_wx_*.png" + +# 微信用户画像: (openid 后缀, 昵称|None=无画像, 头像色, 首次授权群 key) +WX_PROFILES = [ + ("c1", "省钱小张", (250, 173, 20), "C"), + ("c2", "薅羊毛阿珍", (82, 196, 26), "C"), + ("c3", "随便看看", (24, 144, 255), "C"), + ("sh", "比价小能手", (114, 46, 209), "C"), # 跨群:C 首授权,也来 B + ("b1", "深夜下单选手", (245, 34, 45), "B"), + ("b2", "团购队长", (19, 194, 194), "B"), + ("anon", None, None, "C"), # 无画像(base 静默授权,只有 openid) +] +# 每个用户在各群的点击活动: (群 key, openid 后缀, visit 次数, copy 次数) +WX_ACTIVITY = [ + ("C", "c1", 3, 2), ("C", "c2", 2, 1), ("C", "c3", 1, 0), + ("C", "sh", 5, 3), ("C", "anon", 2, 1), + ("B", "b1", 2, 1), ("B", "b2", 3, 0), ("B", "sh", 2, 1), # sh 跨群 +] + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _openid(suffix: str) -> str: + return f"{OPENID_PREFIX}_{suffix}" + + +def _solid_png(rgb: tuple[int, int, int], size: int = 96) -> bytes: + """纯色 PNG(truecolor RGB)字节,无需 Pillow。色块即可肉眼判断头像加载出来了。""" + def _chunk(typ: bytes, data: bytes) -> bytes: + body = typ + data + return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF) + + ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) # 8bit/通道, color type 2 = RGB + idat = zlib.compress((b"\x00" + bytes(rgb) * size) * size, 9) + return b"\x89PNG\r\n\x1a\n" + _chunk(b"IHDR", ihdr) + _chunk(b"IDAT", idat) + _chunk(b"IEND", b"") + + +def _write_avatar(suffix: str, rgb: tuple[int, int, int]) -> str: + """写一张 mock 头像到 media/cps_wx/,返回可加载的绝对 URL。""" + _AVATAR_DIR.mkdir(parents=True, exist_ok=True) + name = f"mock_wx_{suffix}.png" + (_AVATAR_DIR / name).write_bytes(_solid_png(rgb)) + return f"{APP_MEDIA_BASE}{settings.MEDIA_URL_PREFIX}/cps_wx/{name}" + + +def clean(db) -> dict: + """按 mock 标识清旧数据。点击/短链按 mock 群 id 清,订单按号前缀清。""" + mock_gids = list( + db.execute(select(CpsGroup.id).where(CpsGroup.name.like(f"{NAME_PREFIX}%"))).scalars() + ) + n_click = n_link = n_group = 0 + if mock_gids: + n_click = db.execute( + delete(CpsClick).where(CpsClick.group_id.in_(mock_gids)) + ).rowcount or 0 + n_link = db.execute( + delete(CpsLink).where(CpsLink.group_id.in_(mock_gids)) + ).rowcount or 0 + n_group = db.execute( + delete(CpsGroup).where(CpsGroup.id.in_(mock_gids)) + ).rowcount or 0 + n_order = db.execute( + delete(CpsOrder).where(CpsOrder.order_id.like(f"{ORDER_PREFIX}%")) + ).rowcount or 0 + n_wx = db.execute( + delete(CpsWxUser).where(CpsWxUser.openid.like(f"{OPENID_PREFIX}%")) + ).rowcount or 0 + db.commit() + # 删 mock 头像文件 + if _AVATAR_DIR.exists(): + for f in _AVATAR_DIR.glob(_AVATAR_GLOB): + f.unlink(missing_ok=True) + return {"group": n_group, "order": n_order, "click": n_click, "link": n_link, "wx_user": n_wx} + + +def _add_clicks(db, *, group_id: int, sid: str | None, link_id: int, + visit: int, visit_uv: int, copy: int = 0, copy_uv: int = 0, + now: datetime) -> None: + """造点击:visit 条 visit 事件(visit_uv 个去重身份循环)+ copy 条 copy 事件。 + pv = 总条数,uv = distinct(ip,ua) = min(uv_arg, 条数)。clicked_at 摊在最近 6 天。""" + def emit(n: int, uv: int, event: str) -> None: + uv = max(1, min(uv, n)) if n else 0 + for k in range(n): + ident = k % uv # 循环复用身份 → pv>uv + db.add(CpsClick( + link_id=link_id, group_id=group_id, sid=sid, event_type=event, + ip=f"10.{group_id}.{event[0]}.{ident}", + ua=f"MockUA/{event}-{ident}", + clicked_at=now - timedelta(days=k % 6, hours=k), + )) + emit(visit, visit_uv, "visit") + emit(copy, copy_uv, "copy") + + +def _add_orders(db, *, sid: str, specs: list[tuple[str, int, int, str]], now: datetime, + tag: str) -> None: + """specs: [(mt_status, pay_price_cents, commission_cents, product_name), ...]""" + for i, (status, pay, comm, name) in enumerate(specs): + db.add(CpsOrder( + order_id=f"{ORDER_PREFIX}{tag}-{i:02d}", + sid=sid, act_id="100001", biz_line=1, trade_type=1, + pay_price_cents=pay, commission_cents=comm, commission_rate="500", + refund_price_cents=None, refund_profit_cents=None, + mt_status=status, product_name=name, + pay_time=now - timedelta(days=i % 6, hours=i), + mt_update_time=now - timedelta(days=i % 6), + raw={"mock": True, "status": status}, + )) + + +def seed(db) -> dict: + now = _now() + + # ── 1) 建群(覆盖 active 美团 / 美团+淘宝 / 纯淘宝 / 纯京东 / archived)── + groups = [ + CpsGroup(sid=SID_MT_A, name=f"{NAME_PREFIX}美团外卖群A", + platforms=["meituan"], member_count=480, status="active", + remark="mock-纯美团,走对账"), + CpsGroup(sid=SID_MT_B, name=f"{NAME_PREFIX}美团+淘宝群B", + platforms=["meituan", "taobao"], member_count=320, status="active", + remark="mock-多平台,对账+复制口令"), + CpsGroup(sid=None, name=f"{NAME_PREFIX}淘宝券群C", + platforms=["taobao"], member_count=210, status="active", + remark="mock-无 sid,对账字段 None"), + CpsGroup(sid=None, name=f"{NAME_PREFIX}京东券群D", + platforms=["jd"], member_count=150, status="active", + remark="mock-无 sid,对账字段 None"), + CpsGroup(sid=SID_ARCHIVED, name=f"{NAME_PREFIX}已归档美团群E", + platforms=["meituan"], member_count=90, status="archived", + remark="mock-archived,应被统计排除"), + ] + db.add_all(groups) + db.flush() # 拿 group.id + g_a, g_b, g_c, g_d, g_e = groups + + # ── 2) 每群一条短链(cps_click.link_id 非空;无 FK,activity_id 占位即可)── + links = {} + for g in groups: + link = CpsLink( + code=f"MK{g.id:05d}", group_id=g.id, activity_id=0, + sid=g.sid, platform=(g.platforms[0] if g.platforms else "meituan"), + target_url=f"https://example.com/mock/{g.id}", + ) + db.add(link) + links[g.id] = link + db.flush() # 拿 link.id + + # ── 3) 点击(pv>uv;淘宝群带 copy 复制口令)── + _add_clicks(db, group_id=g_a.id, sid=g_a.sid, link_id=links[g_a.id].id, + visit=5, visit_uv=3, now=now) + _add_clicks(db, group_id=g_b.id, sid=g_b.sid, link_id=links[g_b.id].id, + visit=8, visit_uv=5, copy=3, copy_uv=2, now=now) + _add_clicks(db, group_id=g_c.id, sid=None, link_id=links[g_c.id].id, + visit=6, visit_uv=4, copy=4, copy_uv=3, now=now) + _add_clicks(db, group_id=g_d.id, sid=None, link_id=links[g_d.id].id, + visit=4, visit_uv=3, now=now) + _add_clicks(db, group_id=g_e.id, sid=g_e.sid, link_id=links[g_e.id].id, + visit=3, visit_uv=2, now=now) # archived,统计里看不到 + + # ── 3.5) 微信用户画像 + 带 openid 的点击(挂淘宝系群 B/C;这些点击也计入 stats 的 pv/uv)── + groups_by_key = {"A": g_a, "B": g_b, "C": g_c, "D": g_d, "E": g_e} + # 建画像;无昵称的 anon 不建 CpsWxUser → group_wx_users 里 nickname/headimgurl 为 None + for i, (suf, nickname, rgb, fg_key) in enumerate(WX_PROFILES): + if nickname is None: + continue + fg = groups_by_key[fg_key] + db.add(CpsWxUser( + openid=_openid(suf), nickname=nickname, headimgurl=_write_avatar(suf, rgb), + first_code=links[fg.id].code, first_group_id=fg.id, + first_seen=now - timedelta(days=3, hours=i), last_seen=now - timedelta(hours=1), + )) + # 逐条 openid 点击:同一 openid 固定一个 (ip,ua) 身份(跨群复用=同人同设备) + idx_of = {_openid(suf): i for i, (suf, *_rest) in enumerate(WX_PROFILES)} + counter = [0] + + def _wx_clicks(group: CpsGroup, openid: str, visit: int, copy: int) -> None: + ident = idx_of[openid] + ip, ua = f"100.64.{ident}.{ident}", f"WeChat/8.0 {openid}" + for event, n in (("visit", visit), ("copy", copy)): + for _ in range(n): + counter[0] += 1 + db.add(CpsClick( + link_id=links[group.id].id, group_id=group.id, sid=group.sid, + event_type=event, ip=ip, ua=ua, openid=openid, + clicked_at=now - timedelta(hours=counter[0]), + )) + + for gk, suf, visit, copy in WX_ACTIVITY: + _wx_clicks(groups_by_key[gk], _openid(suf), visit, copy) + + # ── 4) 订单(按 sid 归群;状态覆盖 有效/无效/结算)── + # 群A:有效5(2,3,3,6,6) 结算2 取消2(4,5) + _add_orders(db, sid=SID_MT_A, tag="A", now=now, specs=[ + ("2", 3580, 180, "麦当劳 板烧鸡腿堡套餐"), + ("3", 4200, 210, "瑞幸 生椰拿铁×2"), + ("3", 2890, 140, "肯德基 早餐组合"), + ("6", 5100, 255, "海底捞 双人套餐"), + ("6", 3300, 165, "蜜雪冰城 多杯装"), + ("4", 2600, 130, "(取消)星巴克 美式"), + ("5", 1990, 90, "(风控)某外卖单"), + ]) + # 群B:有效3(3,6,2) 结算1 取消1(4) + _add_orders(db, sid=SID_MT_B, tag="B", now=now, specs=[ + ("3", 6800, 340, "必胜客 双人餐"), + ("6", 4500, 225, "喜茶 多肉葡萄×3"), + ("2", 3120, 150, "华莱士 全鸡套餐"), + ("4", 2200, 110, "(取消)某甜品单"), + ]) + # 未归群历史 sid(不属于任何群)→ 结果里 group_id=None 单列 + _add_orders(db, sid=SID_ORPHAN, tag="OLD", now=now, specs=[ + ("6", 2800, 140, "历史结算单"), + ("3", 3600, 180, "历史完成单"), + ]) + + db.commit() + return { + "groups": len(groups), "orders": 7 + 4 + 2, + "anon_clicks": 5 + (8 + 3) + (6 + 4) + 4 + 3, + "wx_clicks": sum(v + c for *_, v, c in WX_ACTIVITY), + "wx_users": len({suf for _, suf, *_ in WX_ACTIVITY}), + "wx_profiles": sum(1 for _, nn, *_ in WX_PROFILES if nn is not None), + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="造 CPS 按群对账统计 mock 数据(后台 get_stats 用)") + parser.add_argument("--clean-only", action="store_true", help="只清理 mock 数据,不重建") + args = parser.parse_args() + + db = SessionLocal() + try: + removed = clean(db) + if any(removed.values()): + print(f"🧹 已清理旧 mock:群{removed['group']} 订单{removed['order']} " + f"点击{removed['click']} 短链{removed['link']} 微信用户{removed['wx_user']}") + if args.clean_only: + print("✅ 仅清理,已完成。") + return + + r = seed(db) + print(f"\n✅ 已生成:{r['groups']} 群 / {r['orders']} 订单 / " + f"{r['anon_clicks'] + r['wx_clicks']} 点击(其中带 openid {r['wx_clicks']}) / " + f"{r['wx_users']} 微信用户(含画像 {r['wx_profiles']})\n") + print("① 预期 get_stats(days=30)(B/C 的 pv/uv 已含带 openid 的用户点击):") + print(" 群A(美团,sid=%s) : 有效5 结算2 取消2 | GMV ¥190.70 预估 ¥9.50 已结算 ¥4.20 | 点击 pv5/uv3" + % SID_MT_A) + print(" 群B(美团+淘宝,sid=%s): 有效3 结算1 取消1 | GMV ¥144.20 预估 ¥7.15 已结算 ¥2.25 | 点击 pv15/uv8 复制 pv5/uv4" + % SID_MT_B) + print(" 群C(淘宝,无 sid) : 对账字段 '-' | 点击 pv19/uv9 复制 pv11/uv7") + print(" 群D(京东,无 sid) : 对账字段 '-' | 点击 pv4/uv3") + print(" 群E(已归档) : 不出现在结果(status 过滤)") + print(" 未归群 sid=%s : group_id=None 单列 | 有效2 结算1 | GMV ¥64.00 预估 ¥3.20 已结算 ¥1.40" + % SID_ORPHAN) + print(" 合计:订单 10 | 预估佣金 ¥19.85 | 已结算佣金 ¥7.85") + print("\n② 预期 group_wx_users(按本群首次点击倒序):") + print(" 群C(5 人): 省钱小张 v3/c2 | 薅羊毛阿珍 v2/c1 | 随便看看 v1/c0 | " + "比价小能手 v5/c3 | (匿名·无画像) v2/c1") + print(" 群B(3 人): 深夜下单选手 v2/c1 | 团购队长 v3/c0 | 比价小能手 v2/c1(跨群,首授权在 C)") + print(" 群A/D/E : 无带 openid 点击 → 返回 []") + print("\n👉 验收:GET /admin/cps/stats?days=30") + print(" GET /admin/cps/groups/{群B或C的id}/wx-users(头像经 :8770 /media 加载,App 后端需在跑)") + finally: + db.close() + + +if __name__ == "__main__": + main()