Compare commits

..

4 Commits

Author SHA1 Message Date
xianze f983b0d369 feat(compare): 比价记录列表改按创建时间倒序(id 兜底)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:18:57 +08:00
xianze 7aeed94196 feat(compare): 比价记录列表按店名匹配真实下单标记已下单
list_records 查出本页后,按 store_name 命中该用户 source='compare' 的
savings_record.shop_name 集合算出 ordered(店级、瞬态属性,不加列不迁移);
ComparisonRecordOut 加 ordered 字段(from_attributes 读出,缺省 false)。
下单上报不带 trace_id,只能按店名对齐(两边店名同源:比价意图识别的门店名)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:59:51 +08:00
xianze 0662f2ce47 feat(compare): comparison_record 加 best_deeplink 列 + 迁移(再次比价直达商家深链备用)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:27:49 +08:00
xianze fcfac7ad07 feat(report): 上报更低价后端——price_report 表 + 提交/列表接口
- 新表 price_report:关联比价记录,存原最低价快照(反查 best_*)/上报平台+价/截图URL/审核状态(pending/approved/rejected)/奖励字段
- POST /api/v1/report (multipart):反查比价记录填原最低价,校验上报价 < 原最低价(D),截图复用 core.media 落 /media/price_report/,提交一律 pending
- GET /api/v1/report/records?status= :按状态筛选 + 四态 counts,供「上报记录」页
- core.media 加 save_report_image;main 挂 report 路由;models 登记 PriceReport
- alembic 迁移仅建 price_report(autogenerate 误检的「删 order_record」已手动剔除——那是降级为审计的历史表)

奖励发放(通过→钱包+1000金币)是人工审核后台动作,本期不含。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:22:21 +08:00
496 changed files with 1490 additions and 49593 deletions
+4 -68
View File
@@ -27,11 +27,6 @@ JG_PRIVATE_KEY_PATH=./secrets/jverify_rsa_private.pem
JG_VERIFY_ENDPOINT=https://api.verification.jpush.cn/v1/web/loginTokenVerify
JG_REQUEST_TIMEOUT_SEC=15
# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)=====
HEARTBEAT_MONITOR_ENABLED=true
HEARTBEAT_TIMEOUT_MINUTES=60
HEARTBEAT_SCAN_INTERVAL_SEC=60
# ===== 短信 (mock 模式) =====
# mock = true 时,任意 6 位数字均通过,且 /sms/send 不真发短信(只 log)。
# 后续接阿里云/腾讯云短信时,改成 false 并填供应商相关 key。
@@ -39,51 +34,19 @@ SMS_MOCK=true
SMS_CODE_TTL_SEC=300
SMS_SEND_INTERVAL_SEC=60
# ===== 测试账号(release 包全流程联调用)=====
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
# (real 模式下也跳过校验)、每次登录【都重走新手引导】,并有【每日登录上限】防被人猜到号后脚本刷。
# 逻辑见 app/core/test_account.py,与其他业务解耦。
# ⚠️ 留空 = 关闭整功能(生产默认);要启用才填号(如 11111111111)。改完重启生效,随时可清空停用。
TEST_ACCOUNT_PHONE=
# 该测试号每日最多登录次数,当日超过即拒绝(429),次日归零。
TEST_ACCOUNT_DAILY_LIMIT=500
# ===== 美团联盟 CPS =====
# 美团联盟后台 → 媒体管理 拿到的 appkey 和密钥
MT_CPS_APP_KEY=
MT_CPS_APP_SECRET=
# 默认渠道追踪标识(sid),用于区分不同 app 的 CPS 数据
MT_CPS_DEFAULT_SID=sgbjia
# 美团调用走的代理。⚠️ 本机/内网开发直连美团会 SSL EOF,必须填本地代理(如 http://127.0.0.1:7897);
# 线上国内服务器留空(=直连)。留空且本机直连失败时 /feed、/coupons、/top-sales 会返回空。
MT_CPS_PROXY=
# ===== 京东联盟 CPS =====
# 京东联盟/京东宙斯开放平台创建应用后填写。AUTH_KEY 是工具商授权 key,自有应用可留空
JD_UNION_APP_KEY=
JD_UNION_APP_SECRET=
JD_UNION_SITE_ID=
JD_UNION_AUTH_KEY=
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
# 客户端调本服务的 /api/v1/coupon/step 等,我们透传到 pricebot-backend。
# ===== Pricebot 上游 (领券业务透传目标) =====
# 客户端调本服务的 /api/v1/coupon/step,我们透传到 pricebot-backend 的 /api/coupon/step
# 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。
PRICEBOT_BASE_URL=http://localhost:8000
# 【多实例】单机多进程部署时,填逗号分隔的实例列表(端口与 pricebot 集群对齐),透传层按
# trace_id 一致性 hash 选实例 → 同一比价所有帧落同一进程(进程内维护 state,无需 Redis)。
# 留空 = 单实例(用上面的 PRICEBOT_BASE_URL)。详见 pricebot-backend/docs/并发部署设计.md
# PRICEBOT_INSTANCES=http://127.0.0.1:8001,http://127.0.0.1:8002,http://127.0.0.1:8003,http://127.0.0.1:8004,http://127.0.0.1:8005,http://127.0.0.1:8006
# 领券单帧最多 wait 6s,加网络往返,30s 兜底
PRICEBOT_REQUEST_TIMEOUT_SEC=30
# 比价(intent/recognize + price/step)透传超时:大上下文 LLM + 逐帧 LLM,给 60s
PRICEBOT_COMPARE_TIMEOUT_SEC=60
# ===== 内部端点 (server→server, pricebot 上报价格观测) =====
# pricebot 比价 done 后把各平台到手价 POST 到本服务 /internal/price-observation 落库
# (price_observation 表,比价资产沉淀层),靠共享密钥头 X-Internal-Secret 校验。
# 必须与 pricebot 侧的 INTERNAL_API_SECRET **同值**;留空 = 内部写端点关闭(返 503)。
# 启用前两边都填同一高熵串:python -c "import secrets; print(secrets.token_urlsafe(48))"
INTERNAL_API_SECRET=
# ===== CORS =====
# 逗号分隔,生产留空(只让 app 调,不开放 web)。本地开发可加 http://localhost:5173 之类
@@ -100,40 +63,13 @@ WXPAY_MCH_PRIVATE_KEY_PATH=./secrets/apiclient_key.pem
WXPAY_PUBLIC_KEY_ID=PUB_KEY_ID_xxxxxxxx
WXPAY_PUBLIC_KEY_PATH=./secrets/pub_key.pem
WXPAY_TRANSFER_SCENE_ID=1000
WXPAY_AUTH_NOTIFY_URL=
WITHDRAW_AUTO_RECONCILE_ENABLED=false
WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC=300
WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES=15
# 0 点自动兑金币开关(deploy/daily-exchange.timer 触发的脚本读它;false=脚本 no-op)。默认 true。
AUTO_EXCHANGE_ENABLED=true
# ===== 穿山甲激励视频(服务端发奖回调)=====
# 看完激励视频后穿山甲服务器 S2S 回调本服务发金币(客户端不参与发奖)。
# 穿山甲"奖励校验密钥"(m-key),验签,从 GroMore 后台各广告位取到后填这里
# 配齐(任一非空)并把 ENABLED=true 后,/api/v1/ad/pangle-callback 才受理回调(否则 503)。
# 每个激励位 m-key 不同但共用同一回调 URL → 各位分开一行配(留空的忽略);验签逐个试、任一通过即接受。
# PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",用于验签,从后台取到后填这里;
# 配齐并把 ENABLED=true 后,/api/v1/ad/pangle-callback 才受理回调(否则 503)。
PANGLE_CALLBACK_ENABLED=false
# 测试应用 激励位 104099649
PANGLE_REWARD_SECRET_TEST=
# 测试应用 专属激励位 104127529
PANGLE_REWARD_SECRET_TEST_DEDICATED=
# 正式应用 激励位 104099389
PANGLE_REWARD_SECRET_PROD=
# (旧用法,仍兼容:单个或逗号分隔的多个 m-key,会与上面三个合并去重)
PANGLE_REWARD_SECRET=
# ⚠️ 仅本地联调:true 时开放 POST /api/v1/ad/test-grant,让 debug 客户端看完广告直接发奖,
# 验证"看广告→金币到账"全链路(未部署公网、穿山甲 S2S 打不到本地时用)。生产必须 false(绕过反作弊)。
AD_REWARD_TEST_GRANT_ENABLED=false
# ===== 穿山甲 GroMore 数据 API(按天拉收益报表,供后台广告收益报表的「穿山甲后台收益」)=====
# ⚠️ 与上面发奖回调的 m-key 是【两套不同凭证】:这三样在穿山甲后台「接入中心 → GroMore-API →
# 聚合数据报告 API」文档页领取。只读拉取 GroMore 天级 revenue(预估)/ api_revenue(收益Api),
# 不参与发奖。三样齐全才生效;留空 = scripts/sync_pangle_revenue 直接 no-op。
# 子账号(role_id≠user_id)需主账号在「角色管理」授予「查看全部数据」权限,否则查不到收益(接口 118);
# role_id 填成 = user_id 即查主账号数据。同步:线上每天 ~10:30 由 timer 跑 python -m scripts.sync_pangle_revenue。
PANGLE_REPORT_USER_ID=0
PANGLE_REPORT_ROLE_ID=0
PANGLE_REPORT_SECURITY_KEY=
# GroMore AppId(报表 site_id 维度)→ 应用环境;默认取现网两个应用,按需覆盖。
PANGLE_REPORT_SITE_ID_PROD=5830519
PANGLE_REPORT_SITE_ID_TEST=5832303
+1 -21
View File
@@ -23,17 +23,7 @@ dist/
*.db-journal
*.db-shm
*.db-wal
# data/ 整体不入库(运行时数据/上传文件/大二进制)。例外:邀请落地页 dl.html 及其
# 引用的静态插画(coupon-page-bg.png 底图 + sb-brand.png logo)——既是生产落地页资产
# 又是本地测试资产,纳入 git 便于同事一致测试、且随部署进生产 /media(否则线上 404→落地页毛坯)。
# (见 docs/邀请功能-实现原理与本地测试.md)。其余(avatars/ / *.apk / app.db 等)仍忽略。
data/*
!data/media/
data/media/*
!data/media/dl.html
!data/media/taobao_landing.jpg
!data/media/coupon-page-bg.png
!data/media/sb-brand.png
data/
secrets/*
!secrets/.gitkeep
@@ -47,13 +37,3 @@ 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
-116
View File
@@ -1,116 +0,0 @@
# 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
@@ -1,26 +0,0 @@
"""merge coupon_state and invite heads
Revision ID: 0cf18d590b1d
Revises: coupon_state_tables, invite_code_and_relation
Create Date: 2026-06-08 05:49:29.604571
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0cf18d590b1d'
down_revision: Union[str, Sequence[str], None] = ('coupon_state_tables', 'invite_code_and_relation')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -1,26 +0,0 @@
"""merge meituan_coupon 与 ops/price_observation 迁移头
Revision ID: 11a1d08c6f55
Revises: meituan_coupon_table, opsrename01
Create Date: 2026-06-08 00:06:01.633796
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '11a1d08c6f55'
down_revision: Union[str, Sequence[str], None] = ('meituan_coupon_table', 'opsrename01')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -1,65 +0,0 @@
"""add analytics_event table
Revision ID: 1699fc2c069f
Revises: bcfcaf07152b
Create Date: 2026-06-26 16:35:16.133975
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '1699fc2c069f'
down_revision: str | Sequence[str] | None = 'bcfcaf07152b'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('analytics_event',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('event', sa.String(length=64), nullable=False),
sa.Column('props', sa.JSON(), nullable=True),
sa.Column('device_id', sa.String(length=64), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('session_id', sa.String(length=64), nullable=True),
sa.Column('client_ts', sa.BigInteger(), nullable=False),
sa.Column('sent_at', sa.BigInteger(), nullable=True),
sa.Column('page', sa.String(length=64), nullable=True),
sa.Column('client_ip', sa.String(length=64), nullable=True),
sa.Column('oem', sa.String(length=32), nullable=True),
sa.Column('os', sa.String(length=32), nullable=True),
sa.Column('model', sa.String(length=64), nullable=True),
sa.Column('app_ver', sa.String(length=32), nullable=True),
sa.Column('network', sa.String(length=16), nullable=True),
sa.Column('channel', sa.String(length=32), 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_event', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_analytics_event_created_at'), ['created_at'], unique=False)
batch_op.create_index(batch_op.f('ix_analytics_event_device_id'), ['device_id'], unique=False)
batch_op.create_index(batch_op.f('ix_analytics_event_event'), ['event'], unique=False)
batch_op.create_index(batch_op.f('ix_analytics_event_session_id'), ['session_id'], unique=False)
batch_op.create_index(batch_op.f('ix_analytics_event_user_id'), ['user_id'], unique=False)
# 注:autogenerate 顺带检出 ad_ecpm/cps_*/invite_fingerprint 的历史索引漂移,
# 与本次「新增埋点表」无关,已手动移除,避免本迁移误改他人表。
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('analytics_event', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_analytics_event_user_id'))
batch_op.drop_index(batch_op.f('ix_analytics_event_session_id'))
batch_op.drop_index(batch_op.f('ix_analytics_event_event'))
batch_op.drop_index(batch_op.f('ix_analytics_event_device_id'))
batch_op.drop_index(batch_op.f('ix_analytics_event_created_at'))
op.drop_table('analytics_event')
# ### end Alembic commands ###
@@ -1,26 +0,0 @@
"""merge store_mapping_jd_dl_invalid and ad_revenue heads (0.1.2)
Revision ID: 45047b5a884c
Revises: d4e68464761d, store_mapping_jd_dl_invalid
Create Date: 2026-06-16 01:38:26.273226
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '45047b5a884c'
down_revision: Union[str, Sequence[str], None] = ('d4e68464761d', 'store_mapping_jd_dl_invalid')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -1,26 +0,0 @@
"""merge device#65 and comparison_token_cols heads
Revision ID: 4dc2af7ebe74
Revises: comparison_token_cols, ad_feed_reward_trace_id
Create Date: 2026-06-23 17:57:54.083531
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4dc2af7ebe74'
down_revision: Union[str, Sequence[str], None] = ('comparison_token_cols', 'ad_feed_reward_trace_id')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -1,26 +0,0 @@
"""merge jd_cps_order_fields and coupon_session_origin_package heads
Revision ID: 761ef181ce7c
Revises: coupon_session_origin_package, jd_cps_order_fields
Create Date: 2026-07-01 13:52:16.068808
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '761ef181ce7c'
down_revision: Union[str, Sequence[str], None] = ('coupon_session_origin_package', 'jd_cps_order_fields')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -1,26 +0,0 @@
"""merge invite_cash + analytics + device heads
Revision ID: 7db22acee504
Revises: c2874d2bf705, device_first_protected_at, invite_cash_compare_reward
Create Date: 2026-06-27 03:06:52.594401
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7db22acee504'
down_revision: Union[str, Sequence[str], None] = ('c2874d2bf705', 'device_first_protected_at', 'invite_cash_compare_reward')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -1,26 +0,0 @@
"""merge user_force_onboarding and comparison_idx_coupon_daily heads
Revision ID: 9b894f5fff05
Revises: d4b87c5847de, user_force_onboarding
Create Date: 2026-06-10 22:49:02.248506
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9b894f5fff05'
down_revision: Union[str, Sequence[str], None] = ('d4b87c5847de', 'user_force_onboarding')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -1,26 +0,0 @@
"""merge invite_fingerprint and ad_reward heads
Revision ID: a8c47fc4dc39
Revises: invite_fingerprint_table, 044dce6e9b1f
Create Date: 2026-06-10 01:03:03.699443
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a8c47fc4dc39'
down_revision: Union[str, Sequence[str], None] = ('invite_fingerprint_table', '044dce6e9b1f')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -1,38 +0,0 @@
"""add ad_session_id to ad feed reward record
Revision ID: ad_feed_reward_session
Revises: coin_reward_phase2
Create Date: 2026-06-09
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "ad_feed_reward_session"
down_revision = "coin_reward_phase2"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"ad_feed_reward_record",
sa.Column("ad_session_id", sa.String(length=64), nullable=True),
)
op.create_index(
op.f("ix_ad_feed_reward_record_ad_session_id"),
"ad_feed_reward_record",
["ad_session_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(
op.f("ix_ad_feed_reward_record_ad_session_id"),
table_name="ad_feed_reward_record",
)
op.drop_column("ad_feed_reward_record", "ad_session_id")
@@ -1,42 +0,0 @@
"""ad_feed_reward_record.trace_id (比价看广告金币归属到比价记录)
Revision ID: ad_feed_reward_trace_id
Revises: device_kill_alert_pending
Create Date: 2026-06-21
比价等候期信息流广告(feed_ad_reward)结算时,客户端带上本场比价 trace_id 落此列;
比价记录页按 trace_id 聚合本场实发金币,显示「比价赚 N 金币」。领券/福利/旧客户端 = NULL。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ad_feed_reward_trace_id'
down_revision: Union[str, Sequence[str], None] = 'device_kill_alert_pending'
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。
op.add_column(
'ad_feed_reward_record',
sa.Column('trace_id', sa.String(length=64), nullable=True),
)
op.create_index(
op.f('ix_ad_feed_reward_record_trace_id'),
'ad_feed_reward_record',
['trace_id'],
unique=False,
)
def downgrade() -> None:
op.drop_index(
op.f('ix_ad_feed_reward_record_trace_id'),
table_name='ad_feed_reward_record',
)
op.drop_column('ad_feed_reward_record', 'trace_id')
@@ -1,63 +0,0 @@
"""ad_pangle_daily_revenue: 穿山甲 GroMore 天级收益报表(后台结算口径)
新建表存放从 GroMore 数据 API 按天拉取的收益(revenue 预估 + api_revenue 收益Api),
粒度 = 日期 × 应用(app_env) × 代码位(our_code_id) × 广告源(adn)。供广告收益报表的
汇总/趋势级展示「穿山甲后台收益」,与客户端自报 eCPM 折算的预估互为对照。
Revision ID: ad_pangle_daily_revenue
Revises: 7db22acee504
Create Date: 2026-06-28
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "ad_pangle_daily_revenue"
down_revision = "7db22acee504"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"ad_pangle_daily_revenue",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("report_date", sa.String(length=10), nullable=False),
sa.Column("app_env", sa.String(length=16), nullable=False),
sa.Column("site_id", sa.String(length=32), nullable=True),
sa.Column("our_code_id", sa.String(length=64), nullable=False),
sa.Column("adn", sa.String(length=16), nullable=False, server_default=""),
sa.Column("revenue_yuan", sa.Float(), nullable=False, server_default="0"),
sa.Column("api_revenue_yuan", sa.Float(), nullable=True),
sa.Column("ecpm", sa.String(length=32), nullable=True),
sa.Column("impressions", sa.Integer(), nullable=False, server_default="0"),
sa.Column("currency", sa.String(length=8), nullable=False, server_default="cny"),
sa.Column(
"synced_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.UniqueConstraint(
"report_date", "app_env", "our_code_id", "adn", name="uq_ad_pangle_daily"
),
)
op.create_index(
"ix_ad_pangle_daily_revenue_report_date",
"ad_pangle_daily_revenue",
["report_date"],
)
op.create_index(
"ix_ad_pangle_daily_revenue_our_code_id",
"ad_pangle_daily_revenue",
["our_code_id"],
)
def downgrade() -> None:
op.drop_index("ix_ad_pangle_daily_revenue_our_code_id", table_name="ad_pangle_daily_revenue")
op.drop_index("ix_ad_pangle_daily_revenue_report_date", table_name="ad_pangle_daily_revenue")
op.drop_table("ad_pangle_daily_revenue")
@@ -1,39 +0,0 @@
"""ad revenue report columns: app_env + our_code_id
给 ad_ecpm_record / ad_reward_record / ad_feed_reward_record 各加两列:
- app_env:我们的穿山甲应用环境(prod=傻瓜比价正式 / test=测试应用)
- our_code_id:我们在穿山甲后台配置的代码位 ID(104xxx,非底层 mediation rit)
供「广告收益报表」按 用户/日期/广告类型/应用/代码位 聚合 展示条数/收益/金币。
旧数据这两列为 NULL(报表里来源列留空),新数据由客户端上报/发奖时回填。
Revision ID: ad_revenue_report_cols
Revises: coupon_engage_per_package
Create Date: 2026-06-15
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "ad_revenue_report_cols"
down_revision = "coupon_engage_per_package"
branch_labels = None
depends_on = None
_TABLES = ("ad_ecpm_record", "ad_reward_record", "ad_feed_reward_record")
def upgrade() -> None:
for table in _TABLES:
op.add_column(table, sa.Column("app_env", sa.String(length=16), nullable=True))
op.add_column(table, sa.Column("our_code_id", sa.String(length=64), nullable=True))
def downgrade() -> None:
for table in _TABLES:
op.drop_column(table, "our_code_id")
op.drop_column(table, "app_env")
-34
View File
@@ -1,34 +0,0 @@
"""add feed_scene to ad_feed_reward_record
信息流广告点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页)。
比价与领券共用同一个信息流代码位(AdConfig.feedCodeId),slot_id / our_code_id 都分不出来,
只能由客户端在各调用点(PriceBotService / CouponForegroundService / WelfareScreen)显式打标。
可空:历史记录与未升级客户端为 NULL = 未分类。
Revision ID: add_feed_scene
Revises: drop_force_onboarding
Create Date: 2026-06-20 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "add_feed_scene"
down_revision: Union[str, Sequence[str], None] = "drop_force_onboarding"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"ad_feed_reward_record",
sa.Column("feed_scene", sa.String(length=16), nullable=True),
)
def downgrade() -> None:
op.drop_column("ad_feed_reward_record", "feed_scene")
-69
View File
@@ -1,69 +0,0 @@
"""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")
@@ -1,35 +0,0 @@
"""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")
@@ -1,29 +0,0 @@
"""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")
-33
View File
@@ -1,33 +0,0 @@
"""app_config table (运营可配置项:rewards 常量等后台化)
Revision ID: cfg1a2b3c4d5
Revises: ad60a1b2c3d4
Create Date: 2026-06-04 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'cfg1a2b3c4d5'
down_revision: Union[str, Sequence[str], None] = 'ad60a1b2c3d4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'app_config',
sa.Column('key', sa.String(length=64), nullable=False),
sa.Column('value', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=False),
sa.Column('updated_by_admin_id', sa.Integer(), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('key'),
)
def downgrade() -> None:
op.drop_table('app_config')
@@ -1,79 +0,0 @@
"""user.username(对外展示账号 ID)+ 默认昵称,并回填存量用户
Revision ID: b3f1a2c4d5e6
Revises: 45047b5a884c
Create Date: 2026-06-16 12:00:00.000000
加 user.username(11 位纯数字、首位非 1 与手机号天然区分、全局唯一)。存量用户:
生成唯一 username,昵称为空的补 9 位字母数字随机昵称。
迁移自包含(不 import app 业务代码,逻辑冻结为当时快照)。
"""
import secrets
import string
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b3f1a2c4d5e6'
down_revision: Union[str, Sequence[str], None] = '45047b5a884c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_USERNAME_FIRST = "23456789" # 首位避前导 0、避 1(手机号都以 1 开头)
_USERNAME_DIGITS = "0123456789"
_NICKNAME_ALPHABET = string.ascii_letters + string.digits
def _gen_username() -> str:
return secrets.choice(_USERNAME_FIRST) + "".join(
secrets.choice(_USERNAME_DIGITS) for _ in range(10)
)
def _gen_nickname() -> str:
return "".join(secrets.choice(_NICKNAME_ALPHABET) for _ in range(9))
def upgrade() -> None:
# 1. 先加可空列(存量行此刻还没值)
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('username', sa.String(length=11), nullable=True))
# 2. 回填存量:每人一个唯一 username;昵称为空的补随机昵称。
# 表里 username 原本全空,本批用 used 防互撞即可(无既有非空值需规避)。
# "user" 是 PostgreSQL 保留字,必须加双引号(SQLite 也接受双引号标识符)。
bind = op.get_bind()
rows = bind.execute(sa.text('SELECT id, nickname FROM "user"')).fetchall()
used = set()
for row in rows:
uid, nick = row[0], row[1]
while True:
uname = _gen_username()
if uname not in used:
used.add(uname)
break
if nick is None or not str(nick).strip():
bind.execute(
sa.text('UPDATE "user" SET username = :u, nickname = :n WHERE id = :i'),
{"u": uname, "n": _gen_nickname(), "i": uid},
)
else:
bind.execute(
sa.text('UPDATE "user" SET username = :u WHERE id = :i'),
{"u": uname, "i": uid},
)
# 3. 收紧:NOT NULL + 唯一索引(对齐模型 unique=True, index=True)
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.alter_column('username', existing_type=sa.String(length=11), nullable=False)
batch_op.create_index('ix_user_username', ['username'], unique=True)
def downgrade() -> None:
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_index('ix_user_username')
batch_op.drop_column('username')
@@ -1,26 +0,0 @@
"""merge meituan_coupon_image_meta and comparison/ad heads
Revision ID: bcfcaf07152b
Revises: 4dc2af7ebe74, meituan_coupon_image_meta
Create Date: 2026-06-24 20:34:09.475641
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'bcfcaf07152b'
down_revision: Union[str, Sequence[str], None] = ('4dc2af7ebe74', 'meituan_coupon_image_meta')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -1,46 +0,0 @@
"""add ad_type to ad_feed_reward and feed_scene to ad_ecpm
把信息流广告全面改造成 Draw 信息流(draw):
- ad_feed_reward_record.ad_type:广告形态 feed(信息流) / draw(Draw 信息流)。可空,旧数据 NULL
一律视为 feed(向后兼容);每日上限与因子2(LT)仍按本表全表 unit 累计,不按 ad_type 拆。
- ad_ecpm_record.feed_scene:点位场景 comparison(比价) / coupon(领券) / welfare(福利),供广告
收益报表区分比价/领券 Draw 收益;仅信息流/Draw 上报,激励视频为 NULL。
注:autogenerate 会顺带探测到 analytics_event / cps_* / invite_fingerprint 等无关索引差异(本地库与
metadata 漂移、analytics 模型尚未并入 __init__),与本次改动无关,已手工剔除——本迁移只 add 两列。
SQLite 经 env.py 的 render_as_batch 自动走 batch_alter_table 重建表。
Revision ID: c2874d2bf705
Revises: 1699fc2c069f
Create Date: 2026-06-26 16:48:52.158609
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c2874d2bf705"
down_revision: str | Sequence[str] | None = "1699fc2c069f"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# 信息流发奖记录:新增广告形态 feed/draw(可空,旧数据 NULL=feed)
op.add_column(
"ad_feed_reward_record",
sa.Column("ad_type", sa.String(length=16), nullable=True),
)
# eCPM 展示上报:新增点位场景 comparison/coupon/welfare(可空,激励视频/旧数据 NULL)
op.add_column(
"ad_ecpm_record",
sa.Column("feed_scene", sa.String(length=16), nullable=True),
)
def downgrade() -> None:
op.drop_column("ad_ecpm_record", "feed_scene")
op.drop_column("ad_feed_reward_record", "ad_type")
@@ -1,26 +0,0 @@
"""merge add_feed_scene and launch_confirm_sample heads
Revision ID: ceb286289426
Revises: add_feed_scene, launch_confirm_sample_table
Create Date: 2026-06-20 23:52:57.853400
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ceb286289426'
down_revision: Union[str, Sequence[str], None] = ('add_feed_scene', 'launch_confirm_sample_table')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -1,76 +0,0 @@
"""coin reward points phase 1 tables
Revision ID: coin_reward_phase1
Revises: pstat3growth
Create Date: 2026-06-07 15:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'coin_reward_phase1'
down_revision: Union[str, Sequence[str], None] = 'pstat3growth'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'signin_boost_record',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('signin_date', sa.Date(), nullable=False),
sa.Column('coin_awarded', sa.Integer(), nullable=False),
sa.Column('ad_ref_id', sa.String(length=64), nullable=True),
sa.Column(
'created_at', sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False,
),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'signin_date', name='uq_signin_boost_user_date'),
)
with op.batch_alter_table('signin_boost_record', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_signin_boost_record_user_id'), ['user_id'], unique=False)
op.create_table(
'ad_feed_reward_record',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('client_event_id', sa.String(length=64), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('reward_date', sa.String(length=10), nullable=False),
sa.Column('duration_seconds', sa.Integer(), nullable=False),
sa.Column('unit_count', sa.Integer(), nullable=False),
sa.Column('ecpm_raw', sa.String(length=32), nullable=False),
sa.Column('adn', sa.String(length=32), nullable=True),
sa.Column('slot_id', sa.String(length=64), nullable=True),
sa.Column('coin', sa.Integer(), 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.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('client_event_id', name='uq_ad_feed_reward_client_event'),
)
with op.batch_alter_table('ad_feed_reward_record', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_ad_feed_reward_record_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_ad_feed_reward_record_reward_date'), ['reward_date'], unique=False)
batch_op.create_index(batch_op.f('ix_ad_feed_reward_record_created_at'), ['created_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('ad_feed_reward_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_ad_feed_reward_record_created_at'))
batch_op.drop_index(batch_op.f('ix_ad_feed_reward_record_reward_date'))
batch_op.drop_index(batch_op.f('ix_ad_feed_reward_record_user_id'))
op.drop_table('ad_feed_reward_record')
with op.batch_alter_table('signin_boost_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_signin_boost_record_user_id'))
op.drop_table('signin_boost_record')
@@ -1,71 +0,0 @@
"""coin reward points phase2
Revision ID: coin_reward_phase2
Revises: 0cf18d590b1d
Create Date: 2026-06-08
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "coin_reward_phase2"
down_revision = "0cf18d590b1d"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"ad_ecpm_record",
sa.Column("ad_session_id", sa.String(length=64), nullable=True),
)
op.create_index(
op.f("ix_ad_ecpm_record_ad_session_id"),
"ad_ecpm_record",
["ad_session_id"],
unique=False,
)
op.create_index(
"uq_ad_ecpm_record_session",
"ad_ecpm_record",
["ad_session_id"],
unique=True,
)
op.add_column(
"ad_reward_record",
sa.Column(
"reward_scene",
sa.String(length=32),
nullable=False,
server_default="reward_video",
),
)
op.add_column(
"ad_reward_record",
sa.Column("ad_session_id", sa.String(length=64), nullable=True),
)
op.add_column(
"ad_reward_record",
sa.Column("ecpm_raw", sa.String(length=32), nullable=True),
)
op.create_index(
op.f("ix_ad_reward_record_ad_session_id"),
"ad_reward_record",
["ad_session_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(op.f("ix_ad_reward_record_ad_session_id"), table_name="ad_reward_record")
op.drop_column("ad_reward_record", "ecpm_raw")
op.drop_column("ad_reward_record", "ad_session_id")
op.drop_column("ad_reward_record", "reward_scene")
op.drop_index("uq_ad_ecpm_record_session", table_name="ad_ecpm_record")
op.drop_index(op.f("ix_ad_ecpm_record_ad_session_id"), table_name="ad_ecpm_record")
op.drop_column("ad_ecpm_record", "ad_session_id")
@@ -1,40 +0,0 @@
"""add coin_transaction task ref unique index
可重复任务(task_enable_notification「打开消息提醒」逐次减半领取)的发放路径是无锁的
读-算-写:并发/连点的两个 claim 会都读到同一已领次数、算出同一 ref_id(task_key:N)、
各发一笔,导致双倍发钱。给 coin_transaction 加 (user_id, biz_type, ref_id) 部分唯一索引
(仅 biz_type LIKE 'task%' 且 ref_id 非空),第二笔撞唯一约束 → IntegrityError,被
task.claim_task 兜底成 AlreadyClaimedError(409)。与 cash_transaction 提现退款、
withdraw_order 在途单的「唯一约束 + IntegrityError 兜底」同模式。
Revision ID: coin_txn_task_ref_uq
Revises: drop_force_onboarding
Create Date: 2026-06-12 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "coin_txn_task_ref_uq"
down_revision: Union[str, Sequence[str], None] = "drop_force_onboarding"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index(
"ux_coin_transaction_task_ref",
"coin_transaction",
["user_id", "biz_type", "ref_id"],
unique=True,
sqlite_where=sa.text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
postgresql_where=sa.text("biz_type LIKE 'task%' AND ref_id IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index("ux_coin_transaction_task_ref", table_name="coin_transaction")
@@ -1,54 +0,0 @@
"""comparison_record: 客户端环境 / 性能统计 / LLM 明细 debug 字段
Revision ID: comparison_debug_fields
Revises: cps_activity_image
Create Date: 2026-06-17 22:00:00.000000
给 admin 比价记录 debug 页新增采集列,全部 nullable(旧记录 / 未发版客户端为 NULL,向后兼容):
- 客户端环境: device_*/rom_*/android_*/app_version*/source_app_version/经纬度
- 过程统计: total_ms/step_count/llm_call_count/retry_count
- LLM 明细: llm_calls(JSONBserver 按 trace_id 同机拉 pricebot 落库)
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "comparison_debug_fields"
down_revision: Union[str, Sequence[str], None] = "cps_activity_image"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_JSONB = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
def upgrade() -> None:
op.add_column("comparison_record", sa.Column("device_model", sa.String(64), nullable=True))
op.add_column("comparison_record", sa.Column("device_manufacturer", sa.String(64), nullable=True))
op.add_column("comparison_record", sa.Column("rom_vendor", sa.String(32), nullable=True))
op.add_column("comparison_record", sa.Column("rom_name", sa.String(32), nullable=True))
op.add_column("comparison_record", sa.Column("rom_version", sa.Integer(), nullable=True))
op.add_column("comparison_record", sa.Column("android_version", sa.String(16), nullable=True))
op.add_column("comparison_record", sa.Column("android_sdk", sa.Integer(), nullable=True))
op.add_column("comparison_record", sa.Column("app_version", sa.String(32), nullable=True))
op.add_column("comparison_record", sa.Column("app_version_code", sa.Integer(), nullable=True))
op.add_column("comparison_record", sa.Column("source_app_version", sa.String(32), nullable=True))
op.add_column("comparison_record", sa.Column("longitude", sa.Float(), nullable=True))
op.add_column("comparison_record", sa.Column("latitude", sa.Float(), nullable=True))
op.add_column("comparison_record", sa.Column("total_ms", sa.Integer(), nullable=True))
op.add_column("comparison_record", sa.Column("step_count", sa.Integer(), nullable=True))
op.add_column("comparison_record", sa.Column("llm_call_count", sa.Integer(), nullable=True))
op.add_column("comparison_record", sa.Column("retry_count", sa.Integer(), nullable=True))
op.add_column("comparison_record", sa.Column("llm_calls", _JSONB, nullable=True))
def downgrade() -> None:
for col in (
"llm_calls", "retry_count", "llm_call_count", "step_count", "total_ms",
"latitude", "longitude", "source_app_version", "app_version_code",
"app_version", "android_sdk", "android_version", "rom_version",
"rom_name", "rom_vendor", "device_manufacturer", "device_model",
):
op.drop_column("comparison_record", col)
@@ -1,70 +0,0 @@
"""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")
@@ -1,49 +0,0 @@
"""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)
@@ -1,48 +0,0 @@
"""add composite index (status, created_at) on comparison_record
首页轮播 feed(按 status='success' 过滤 + created_at 近期排序)与省钱战绩聚合
都吃这个过滤+排序;复合索引避免随数据量增大退化成全表扫。
Revision ID: comparison_status_created_idx
Revises: a8c47fc4dc39
Create Date: 2026-06-10
"""
from __future__ import annotations
from alembic import op
revision = "comparison_status_created_idx"
down_revision = "a8c47fc4dc39"
branch_labels = None
depends_on = None
INDEX_NAME = "ix_comparison_status_created"
def upgrade() -> None:
bind = op.get_bind()
if bind.dialect.name == "postgresql":
# PG 上 comparison_record 已有数据量, 普通 CREATE INDEX 持表写锁会阻塞线上写入;
# 用 CONCURRENTLY 不锁表(须脱离事务, autocommit_block 切到自动提交)。
with op.get_context().autocommit_block():
op.create_index(
INDEX_NAME, "comparison_record", ["status", "created_at"],
unique=False, postgresql_concurrently=True,
)
else:
op.create_index(
INDEX_NAME, "comparison_record", ["status", "created_at"], unique=False
)
def downgrade() -> None:
bind = op.get_bind()
if bind.dialect.name == "postgresql":
with op.get_context().autocommit_block():
op.drop_index(
INDEX_NAME, table_name="comparison_record",
postgresql_concurrently=True,
)
else:
op.drop_index(INDEX_NAME, table_name="comparison_record")
-37
View File
@@ -1,37 +0,0 @@
"""add input_tokens / output_tokens to comparison_record
比价记录增加本次 LLM 累计 token(server 从 llm_calls[].usage 累加),
供 admin 比价记录页展示 token 数与估算成本。
Revision ID: comparison_token_cols
Revises: feedback_review_fields
Create Date: 2026-06-23 00:40:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "comparison_token_cols"
down_revision: Union[str, Sequence[str], None] = "feedback_review_fields"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"comparison_record",
sa.Column("input_tokens", sa.Integer(), nullable=True),
)
op.add_column(
"comparison_record",
sa.Column("output_tokens", sa.Integer(), nullable=True),
)
def downgrade() -> None:
op.drop_column("comparison_record", "output_tokens")
op.drop_column("comparison_record", "input_tokens")
@@ -1,58 +0,0 @@
"""coupon daily completion table(今日跑完整轮领券 → 首页置灰源)
Revision ID: coupon_daily_completion
Revises: f8d3b1e60a27
Create Date: 2026-06-10 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "coupon_daily_completion"
down_revision: str | Sequence[str] | None = "f8d3b1e60a27"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"coupon_daily_completion",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("device_id", sa.String(length=64), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("complete_date", sa.Date(), nullable=False),
sa.Column("trace_id", sa.String(length=64), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("device_id", "complete_date", name="uq_coupon_completion_device_date"),
)
with op.batch_alter_table("coupon_daily_completion", schema=None) as batch_op:
batch_op.create_index(
batch_op.f("ix_coupon_daily_completion_user_id"), ["user_id"], unique=False
)
batch_op.create_index(
batch_op.f("ix_coupon_daily_completion_trace_id"), ["trace_id"], unique=False
)
def downgrade() -> None:
with op.batch_alter_table("coupon_daily_completion", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_coupon_daily_completion_trace_id"))
batch_op.drop_index(batch_op.f("ix_coupon_daily_completion_user_id"))
op.drop_table("coupon_daily_completion")
@@ -1,53 +0,0 @@
"""coupon_prompt_engagement 频控加 package 维度(美团/淘宝/京东各自独立弹)
Revision ID: coupon_engage_per_package
Revises: store_mapping_jd_cols
Create Date: 2026-06-14 11:00:00.000000
需求:领券引导窗按 (device, App, 自然日) 频控——在美团弹过/领过,不影响淘宝、京东今天
仍各弹一次。原表唯一键是 (device_id, engage_date),缺 package → 任一 App 弹过就把整台
设备当天标记 engage,其余 App 被压住不弹(bug)。
本迁移:
1. 加 package 列(NOT NULL,旧行用 server_default "" 填占位,不影响新逻辑判断)。
2. 旧唯一约束 (device_id, engage_date) → 新 (device_id, package, engage_date)。
SQLite 不支持直接 drop/add 约束,用 batch_alter_table(建临时表 + 拷数据 + 换名,
与 store_mapping_* 同款)。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'coupon_engage_per_package'
down_revision: Union[str, Sequence[str], None] = 'store_mapping_jd_cols'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op:
# 加 package 列。旧行(改造前的全局记录)填 "" 占位:它们对应的是"老的全局态",
# 新逻辑按 (device, package, 日) 判,占位 "" 不会与真实包名(com.xxx)碰撞。
batch_op.add_column(
sa.Column('package', sa.String(length=64), nullable=False, server_default='')
)
# 旧唯一约束 (device_id, engage_date) → 新三元组 (device_id, package, engage_date)。
batch_op.drop_constraint('uq_coupon_engage_device_date', type_='unique')
batch_op.create_unique_constraint(
'uq_coupon_engage_device_pkg_date',
['device_id', 'package', 'engage_date'],
)
def downgrade() -> None:
with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op:
batch_op.drop_constraint('uq_coupon_engage_device_pkg_date', type_='unique')
batch_op.create_unique_constraint(
'uq_coupon_engage_device_date',
['device_id', 'engage_date'],
)
batch_op.drop_column('package')
@@ -1,32 +0,0 @@
"""coupon_session 加 origin_package 列(发起来源 App 包名 → admin「发起平台」)
null=App 内(傻瓜比价首页)发起,非空=从美团/淘宝/京东弹券发起。与 trace_url 同理单独成迁移,
已建表环境靠它补列、全新环境顺序应用,不重复加列。
Revision ID: coupon_session_origin_package
Revises: coupon_session_trace_url
Create Date: 2026-06-30 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "coupon_session_origin_package"
down_revision: str | Sequence[str] | None = "coupon_session_trace_url"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.add_column(sa.Column("origin_package", sa.String(length=64), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.drop_column("origin_package")
-81
View File
@@ -1,81 +0,0 @@
"""coupon session table(领券任务全程流水 → admin「领券数据」看板数据源)
一次领券一行(trace_id 唯一):客户端 POST /api/v1/coupon/session 两段上报 —— 发起建行
(status=started)、收尾(completed/failed/abandoned)按 trace_id 更新同一行。记全程耗时
elapsed_ms + 各平台耗时 platform_elapsed + 机型/ROM,供 admin 算发起/完成数、耗时分位、机型维度。
Revision ID: coupon_session_table
Revises: feedback_submit_env
Create Date: 2026-06-30 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_table"
down_revision: str | Sequence[str] | None = "feedback_submit_env"
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:
op.create_table(
"coupon_session",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("trace_id", sa.String(length=64), nullable=False),
sa.Column("device_id", sa.String(length=64), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("app_env", sa.String(length=16), nullable=True),
sa.Column("platforms", _JSON, nullable=True),
sa.Column("device_model", sa.String(length=128), nullable=True),
sa.Column("rom", sa.String(length=64), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("started_date", sa.Date(), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("elapsed_ms", sa.Integer(), nullable=True),
sa.Column("platform_elapsed", _JSON, nullable=True),
sa.Column("claimed_count", sa.Integer(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("trace_id", name="uq_coupon_session_trace"),
)
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.create_index(
batch_op.f("ix_coupon_session_user_id"), ["user_id"], unique=False
)
batch_op.create_index(
batch_op.f("ix_coupon_session_app_env"), ["app_env"], unique=False
)
# admin 主聚合/筛选:按上海自然日 + 环境。
batch_op.create_index(
"ix_coupon_session_date_env", ["started_date", "app_env"], unique=False
)
def downgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.drop_index("ix_coupon_session_date_env")
batch_op.drop_index(batch_op.f("ix_coupon_session_app_env"))
batch_op.drop_index(batch_op.f("ix_coupon_session_user_id"))
op.drop_table("coupon_session")
@@ -1,32 +0,0 @@
"""coupon_session 加 trace_url 列(pricebot done 帧公网调试链接)
建表迁移 coupon_session_table 落地后才追加本列,故单独成一个迁移:已建表的环境(本地/已 upgrade 过)
靠它补列,全新环境则「建表(无 trace_url)→ 本迁移加列」,两条路一致、不重复加列。
Revision ID: coupon_session_trace_url
Revises: coupon_session_table
Create Date: 2026-06-30 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "coupon_session_trace_url"
down_revision: str | Sequence[str] | None = "coupon_session_table"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.add_column(sa.Column("trace_url", sa.String(length=512), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.drop_column("trace_url")
-77
View File
@@ -1,77 +0,0 @@
"""coupon state tables (领券今日状态:弹窗频控 engagement + 领券记录 claim)
Revision ID: coupon_state_tables
Revises: 11a1d08c6f55
Create Date: 2026-06-08 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'coupon_state_tables'
down_revision: Union[str, Sequence[str], None] = '11a1d08c6f55'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# PG 上为 JSONB,其它(SQLite dev)为 JSON——与模型层 with_variant 对齐
_JSON = sa.JSON().with_variant(postgresql.JSONB(), 'postgresql')
def upgrade() -> None:
# 领券记录(资产层,当前不参与去重判断)
op.create_table(
'coupon_claim_record',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('device_id', sa.String(length=64), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('coupon_id', sa.String(length=64), nullable=False),
sa.Column('claim_date', sa.Date(), nullable=False),
sa.Column('status', sa.String(length=24), nullable=False),
sa.Column('vendor', sa.String(length=48), nullable=True),
sa.Column('coupon_name', sa.String(length=128), nullable=True),
sa.Column('claimed_count', sa.Integer(), nullable=True),
sa.Column('trace_id', sa.String(length=64), nullable=True),
sa.Column('reason', sa.String(length=255), nullable=True),
sa.Column('extra', _JSON, nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('device_id', 'coupon_id', 'claim_date', name='uq_coupon_claim_device_coupon_date'),
)
with op.batch_alter_table('coupon_claim_record', schema=None) as batch_op:
batch_op.create_index('ix_coupon_claim_device_date', ['device_id', 'claim_date'], unique=False)
batch_op.create_index(batch_op.f('ix_coupon_claim_record_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_coupon_claim_record_trace_id'), ['trace_id'], unique=False)
# 弹窗频控(今天 engage 过就不再弹)
op.create_table(
'coupon_prompt_engagement',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('device_id', sa.String(length=64), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('engage_date', sa.Date(), nullable=False),
sa.Column('engage_type', sa.String(length=16), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('device_id', 'engage_date', name='uq_coupon_engage_device_date'),
)
with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_coupon_prompt_engagement_user_id'), ['user_id'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('coupon_prompt_engagement', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_coupon_prompt_engagement_user_id'))
op.drop_table('coupon_prompt_engagement')
with op.batch_alter_table('coupon_claim_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_coupon_claim_record_trace_id'))
batch_op.drop_index(batch_op.f('ix_coupon_claim_record_user_id'))
batch_op.drop_index('ix_coupon_claim_device_date')
op.drop_table('coupon_claim_record')
-38
View File
@@ -1,38 +0,0 @@
"""cps activity 落地页图: cps_activity.image_url + 回填存量淘宝活动
新建淘宝活动时运营提供落地页主视觉图(上传或选已有),落地页按活动展示对应图。
存量淘宝活动回填为此前写死的那张图,无缝衔接。
Revision ID: cps_activity_image
Revises: cps_v2_platforms
Create Date: 2026-06-17 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from app.core.config import settings
revision: str = "cps_activity_image"
down_revision: Union[str, Sequence[str], None] = "cps_v2_platforms"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("cps_activity", sa.Column("image_url", sa.String(512), nullable=True))
# 回填存量淘宝活动 = 此前写死的那张图(绝对 URL,基于落地页域名 CPS_REDIRECT_BASE;
# dev 未配则相对路径)。新活动由运营自传/选,不回填。
base = settings.CPS_REDIRECT_BASE.rstrip("/")
default_img = f"{base}/media/taobao_landing.jpg" if base else "/media/taobao_landing.jpg"
op.execute(
sa.text(
"UPDATE cps_activity SET image_url = :img WHERE platform = 'taobao'"
).bindparams(img=default_img)
)
def downgrade() -> None:
op.drop_column("cps_activity", "image_url")
-61
View File
@@ -1,61 +0,0 @@
"""cps_link / cps_click tables (群发短链 + 点击统计)
Revision ID: cps_link_tables
Revises: cps_tables
Create Date: 2026-06-16 13:20:00.000000
群发券链接套一层 /c/{code} 做点击统计:cps_link(短码→美团目标) + cps_click(点击事件)。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "cps_link_tables"
down_revision: Union[str, Sequence[str], None] = "cps_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_NOW = sa.text("CURRENT_TIMESTAMP")
def upgrade() -> None:
op.create_table(
"cps_link",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=16), nullable=False),
sa.Column("group_id", sa.Integer(), nullable=False),
sa.Column("activity_id", sa.Integer(), nullable=False),
sa.Column("sid", sa.String(length=64), nullable=False),
sa.Column("platform", sa.String(length=20), nullable=False, server_default="meituan"),
sa.Column("target_url", sa.String(length=1024), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("code", name="uq_cps_link_code"),
)
op.create_index("ix_cps_link_code", "cps_link", ["code"])
op.create_index("ix_cps_link_group_id", "cps_link", ["group_id"])
op.create_index("ix_cps_link_activity_id", "cps_link", ["activity_id"])
op.create_index("ix_cps_link_sid", "cps_link", ["sid"])
op.create_table(
"cps_click",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("link_id", sa.Integer(), nullable=False),
sa.Column("group_id", sa.Integer(), nullable=False),
sa.Column("sid", sa.String(length=64), nullable=False),
sa.Column("ip", sa.String(length=64), nullable=True),
sa.Column("ua", sa.String(length=512), nullable=True),
sa.Column("clicked_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_cps_click_link_id", "cps_click", ["link_id"])
op.create_index("ix_cps_click_group_id", "cps_click", ["group_id"])
op.create_index("ix_cps_click_sid", "cps_click", ["sid"])
op.create_index("ix_cps_click_clicked_at", "cps_click", ["clicked_at"])
def downgrade() -> None:
op.drop_table("cps_click")
op.drop_table("cps_link")
-92
View File
@@ -1,92 +0,0 @@
"""cps_group / cps_activity / cps_order tables (CPS 分发与对账)
Revision ID: cps_tables
Revises: b3f1a2c4d5e6
Create Date: 2026-06-16 12:40:00.000000
群发 CPS 优惠券的分发与对账:cps_group(群=sid) + cps_activity(可推广活动池) +
cps_order(美团 query_order 按 sid 拉回的订单明细)。金额统一存「分」。
order_id 全局唯一(reconcile 按它 upsert,订单状态会变则更新)。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "cps_tables"
down_revision: Union[str, Sequence[str], None] = "b3f1a2c4d5e6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_NOW = sa.text("CURRENT_TIMESTAMP")
def upgrade() -> None:
op.create_table(
"cps_group",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("sid", sa.String(length=64), nullable=False),
sa.Column("name", sa.String(length=128), nullable=False),
sa.Column("member_count", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default="active"),
sa.Column("remark", sa.String(length=256), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("sid", name="uq_cps_group_sid"),
)
op.create_index("ix_cps_group_sid", "cps_group", ["sid"])
op.create_table(
"cps_activity",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("platform", sa.String(length=20), nullable=False, server_default="meituan"),
sa.Column("name", sa.String(length=128), nullable=False),
sa.Column("act_id", sa.String(length=64), nullable=True),
sa.Column("product_view_sign", sa.String(length=128), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default="active"),
sa.Column("remark", sa.String(length=256), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"cps_order",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("order_id", sa.String(length=64), nullable=False),
sa.Column("sid", sa.String(length=64), nullable=True),
sa.Column("act_id", sa.String(length=64), nullable=True),
sa.Column("biz_line", sa.Integer(), nullable=True),
sa.Column("trade_type", sa.Integer(), nullable=True),
sa.Column("pay_price_cents", sa.Integer(), nullable=True),
sa.Column("commission_cents", sa.Integer(), nullable=True),
sa.Column("commission_rate", sa.String(length=16), nullable=True),
sa.Column("refund_price_cents", sa.Integer(), nullable=True),
sa.Column("refund_profit_cents", sa.Integer(), nullable=True),
sa.Column("mt_status", sa.String(length=8), nullable=True),
sa.Column("invalid_reason", sa.String(length=128), nullable=True),
sa.Column("product_name", sa.String(length=512), nullable=True),
sa.Column("pay_time", sa.DateTime(timezone=True), nullable=True),
sa.Column("mt_update_time", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"raw",
sa.JSON().with_variant(postgresql.JSONB(), "postgresql"),
nullable=False,
),
sa.Column("first_seen", sa.DateTime(timezone=True), server_default=_NOW, nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=_NOW, nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("order_id", name="uq_cps_order_order_id"),
)
op.create_index("ix_cps_order_order_id", "cps_order", ["order_id"])
op.create_index("ix_cps_order_sid", "cps_order", ["sid"])
op.create_index("ix_cps_order_act_id", "cps_order", ["act_id"])
op.create_index("ix_cps_order_mt_status", "cps_order", ["mt_status"])
op.create_index("ix_cps_order_pay_time", "cps_order", ["pay_time"])
def downgrade() -> None:
op.drop_table("cps_order")
op.drop_table("cps_activity")
op.drop_table("cps_group")
-86
View File
@@ -1,86 +0,0 @@
"""cps v2: 活动 payload(淘口令/京东链接) + 群多平台/sid 可空 + 点击 event_type
Revision ID: cps_v2_platforms
Revises: cps_link_tables
Create Date: 2026-06-17 11:00:00.000000
接入淘宝/京东:
- cps_activity.payload 淘宝整段淘口令文本 / 京东推广链接(美团仍用 act_id)
- cps_group.platforms 该群发哪些平台(多选);sid 改可空(纯淘宝/京东无 sid)
- cps_link.sid 可空 + target_url 加长(淘口令较长)
- cps_click.event_type visit / copy(淘宝落地页"复制口令")
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "cps_v2_platforms"
down_revision: Union[str, Sequence[str], None] = "cps_link_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_column(insp: sa.Inspector, table: str, column: str) -> bool:
return any(c["name"] == column for c in insp.get_columns(table))
def upgrade() -> None:
bind = op.get_bind()
insp = sa.inspect(bind)
# SQLite 不认 ::jsonb cast(会报 "unrecognized token: :"),只有 PG 用显式 cast。
# 两边列类型都靠上面的 with_variant 区分,这里只管 server_default 文本。
platforms_default = (
sa.text("'[\"meituan\"]'::jsonb")
if bind.dialect.name == "postgresql"
else sa.text("'[\"meituan\"]'")
)
# 活动:淘宝淘口令 / 京东链接
# 加 add_column 存在性守卫:本迁移历史上在 SQLite 第一次跑时加完 payload 即在
# platforms 那步炸掉(::jsonb),DDL 非事务不回滚 → 半应用状态。守卫让其可自愈重跑。
if not _has_column(insp, "cps_activity", "payload"):
op.add_column("cps_activity", sa.Column("payload", sa.Text(), nullable=True))
# 群:多平台(现有群都是美团,server_default 回填)+ sid 可空。
# SQLite 不支持 ALTER COLUMN,必须用 batch(重建表)才能改 nullable;PG 走原生 ALTER。
with op.batch_alter_table("cps_group") as batch_op:
if not _has_column(insp, "cps_group", "platforms"):
batch_op.add_column(
sa.Column(
"platforms",
sa.JSON().with_variant(postgresql.JSONB(), "postgresql"),
nullable=False,
server_default=platforms_default,
)
)
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
# link:sid 可空 + target 加长
with op.batch_alter_table("cps_link") as batch_op:
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
batch_op.alter_column("target_url", existing_type=sa.String(1024), type_=sa.String(2048))
# click:sid 可空 + 事件类型
with op.batch_alter_table("cps_click") as batch_op:
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
if not _has_column(insp, "cps_click", "event_type"):
batch_op.add_column(
sa.Column("event_type", sa.String(16), nullable=False, server_default="visit")
)
def downgrade() -> None:
with op.batch_alter_table("cps_click") as batch_op:
batch_op.drop_column("event_type")
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
with op.batch_alter_table("cps_link") as batch_op:
batch_op.alter_column("target_url", existing_type=sa.String(2048), type_=sa.String(1024))
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
with op.batch_alter_table("cps_group") as batch_op:
batch_op.drop_column("platforms")
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
with op.batch_alter_table("cps_activity") as batch_op:
batch_op.drop_column("payload")
-48
View File
@@ -1,48 +0,0 @@
"""cps 微信用户表 cps_wx_user + cps_click 加 openid
CPS 落地页微信网页授权拿 openid/昵称头像,做用户级群统计。
Revision ID: cps_wx_user
Revises: comparison_debug_fields
Create Date: 2026-06-19 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "cps_wx_user"
down_revision: Union[str, Sequence[str], None] = "comparison_debug_fields"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"cps_wx_user",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("openid", sa.String(64), nullable=False),
sa.Column("unionid", sa.String(64), nullable=True),
sa.Column("nickname", sa.String(128), nullable=True),
sa.Column("headimgurl", sa.String(512), nullable=True),
sa.Column("first_code", sa.String(16), nullable=True),
sa.Column("first_group_id", sa.Integer(), nullable=True),
sa.Column("first_seen", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("last_seen", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_cps_wx_user_openid", "cps_wx_user", ["openid"], unique=True)
op.create_index("ix_cps_wx_user_unionid", "cps_wx_user", ["unionid"])
op.create_index("ix_cps_wx_user_first_group_id", "cps_wx_user", ["first_group_id"])
op.add_column("cps_click", sa.Column("openid", sa.String(64), nullable=True))
op.create_index("ix_cps_click_openid", "cps_click", ["openid"])
def downgrade() -> None:
op.drop_index("ix_cps_click_openid", table_name="cps_click")
op.drop_column("cps_click", "openid")
op.drop_index("ix_cps_wx_user_first_group_id", table_name="cps_wx_user")
op.drop_index("ix_cps_wx_user_unionid", table_name="cps_wx_user")
op.drop_index("ix_cps_wx_user_openid", table_name="cps_wx_user")
op.drop_table("cps_wx_user")
@@ -1,26 +0,0 @@
"""merge comparison idx and coupon daily completion heads
Revision ID: d4b87c5847de
Revises: comparison_status_created_idx, onboarding_completion
Create Date: 2026-06-10 16:45:50.285917
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd4b87c5847de'
down_revision: Union[str, Sequence[str], None] = ('comparison_status_created_idx', 'onboarding_completion')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -1,26 +0,0 @@
"""merge ad_revenue_report_cols and store_mapping_tb_dl_invalid heads
Revision ID: d4e68464761d
Revises: ad_revenue_report_cols, store_mapping_tb_dl_invalid
Create Date: 2026-06-15 21:55:58.115692
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd4e68464761d'
down_revision: Union[str, Sequence[str], None] = ('ad_revenue_report_cols', 'store_mapping_tb_dl_invalid')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -1,31 +0,0 @@
"""device_liveness 加 first_protected_at(首次开无障碍时刻)
Revision ID: device_first_protected_at
Revises: bcfcaf07152b
Create Date: 2026-06-25 00:00:00.000000
admin 设备存活页要展示「首次无障碍开启时间」。touch_heartbeat 在 ever_protected 首次翻 true
时记一次(后续心跳不覆盖)。仅新增可空列,SQLite 原生支持 add_column、不用 batch;downgrade
的 drop_column 在 SQLite 走 batch_alter_table 兜底。老设备无此时刻 → 留 NULL(无法准确回填)。
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "device_first_protected_at"
down_revision: Union[str, Sequence[str], None] = "bcfcaf07152b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"device_liveness",
sa.Column("first_protected_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
with op.batch_alter_table("device_liveness") as batch_op:
batch_op.drop_column("first_protected_at")
@@ -1,39 +0,0 @@
"""device.kill_alert_pending (无障碍掉线「后置检测」待提醒标记)
Revision ID: device_kill_alert_pending
Revises: device_liveness_table
Create Date: 2026-06-19
后置检测 pull 版(spec/accessibility-liveness-pull-prompt.md):worker 检出心跳掉线即置此标记,
客户端下次进 App 拉取到 → 弹「开启自启动」引导,交互后 ack 清回。与 liveness_state 解耦,故单列。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'device_kill_alert_pending'
down_revision: Union[str, Sequence[str], None] = 'device_liveness_table'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# server_default=sa.false() 跨方言: SQLite 渲染 0 / PG 渲染 false(别用 sa.text("0"),PG 布尔严格会报错),
# 给存量行回填 False。
with op.batch_alter_table('device_liveness', schema=None) as batch_op:
batch_op.add_column(
sa.Column(
'kill_alert_pending',
sa.Boolean(),
nullable=False,
server_default=sa.false(),
)
)
def downgrade() -> None:
with op.batch_alter_table('device_liveness', schema=None) as batch_op:
batch_op.drop_column('kill_alert_pending')
-53
View File
@@ -1,53 +0,0 @@
"""device table (无障碍保护存活检测 + 极光推送)
Revision ID: device_liveness_table
Revises: ceb286289426
Create Date: 2026-06-15 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'device_liveness_table'
down_revision: Union[str, Sequence[str], None] = 'ceb286289426'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'device_liveness',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('device_id', sa.String(length=128), nullable=False),
sa.Column('registration_id', sa.String(length=64), nullable=True),
sa.Column('platform', sa.String(length=16), nullable=False),
sa.Column('app_version', sa.String(length=32), nullable=True),
sa.Column('ever_protected', sa.Boolean(), nullable=False),
sa.Column('last_heartbeat_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_report_protection_on', sa.Boolean(), nullable=False),
sa.Column('liveness_state', sa.String(length=16), nullable=False),
sa.Column('notified_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'device_id', name='uq_device_liveness_user_device'),
)
with op.batch_alter_table('device_liveness', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_device_liveness_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_device_liveness_device_id'), ['device_id'], unique=False)
batch_op.create_index(batch_op.f('ix_device_liveness_last_heartbeat_at'), ['last_heartbeat_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('device_liveness', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_device_liveness_last_heartbeat_at'))
batch_op.drop_index(batch_op.f('ix_device_liveness_device_id'))
batch_op.drop_index(batch_op.f('ix_device_liveness_user_id'))
op.drop_table('device_liveness')
@@ -1,40 +0,0 @@
"""drop user.force_onboarding column
force_onboarding(运营「按用户」强制新手引导)机制废弃:客户端实际按 onboarding_completion
(设备+账号 维度)判断是否走引导,该 force_onboarding 列从未被客户端正确响应(被
onboarding_completed 压过)。改用 admin「设备维度」重置(删 onboarding_completion 记录)替代,
见 app/admin/routers/onboarding.py。本迁移删列。
Revision ID: drop_force_onboarding
Revises: 9b894f5fff05
Create Date: 2026-06-12 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "drop_force_onboarding"
down_revision: Union[str, Sequence[str], None] = "9b894f5fff05"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_column("user", "force_onboarding")
def downgrade() -> None:
# 回滚:重建列(同 user_force_onboarding 迁移的 upgrade)。sa.false() 两端兼容。
op.add_column(
"user",
sa.Column(
"force_onboarding",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
@@ -1,26 +0,0 @@
"""merge app_config and price_report heads
Revision ID: ebb6af5c0b56
Revises: cfg1a2b3c4d5, b7e2c1a9f4d3
Create Date: 2026-06-06 02:29:13.475265
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ebb6af5c0b56'
down_revision: Union[str, Sequence[str], None] = ('cfg1a2b3c4d5', 'b7e2c1a9f4d3')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -1,49 +0,0 @@
"""add user.debug_trace_enabled + comparison_record.trace_url
调试链接权限功能:
- user.debug_trace_enabled:运营后台给指定用户开「复制调试链接」权限
- comparison_record.trace_url:比价记录页「复制调试链接」的数据(pricebot done 帧给,
dir 名含落盘时分秒、前端/server 拼不出,必须落库)
注:本迁移最初基于旧 main#31,双 head)写、down_revision 曾指向那对父;但 #33 已用
a8c47fc4dc39 合并了那对双 head,故改为线性接在 a8c47fc4dc39 之后、只负责加两列
(避免与 a8c47fc4dc39 平行再造一个双 head)。
Revision ID: f8d3b1e60a27
Revises: a8c47fc4dc39
Create Date: 2026-06-10 02:40:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "f8d3b1e60a27"
down_revision: Union[str, Sequence[str], None] = "a8c47fc4dc39"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# sa.false() 渲染成 PG 的 false / SQLite 的 0,两端兼容(避免字符串 "false" 在 SQLite 上存歪)
op.add_column(
"user",
sa.Column(
"debug_trace_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
op.add_column(
"comparison_record",
sa.Column("trace_url", sa.String(length=512), nullable=True),
)
def downgrade() -> None:
op.drop_column("comparison_record", "trace_url")
op.drop_column("user", "debug_trace_enabled")
@@ -1,78 +0,0 @@
"""feedback review fields
Revision ID: feedback_review_fields
Revises: ceb286289426
Create Date: 2026-06-22 20:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "feedback_review_fields"
down_revision: str | Sequence[str] | None = "ceb286289426"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("feedback", schema=None) as batch_op:
batch_op.add_column(sa.Column("reject_reason", sa.String(length=256), nullable=True))
batch_op.add_column(sa.Column("reward_coins", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("review_note", sa.String(length=256), nullable=True))
batch_op.add_column(sa.Column("reviewed_by_admin_id", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True))
batch_op.create_index(batch_op.f("ix_feedback_status"), ["status"], unique=False)
batch_op.create_foreign_key(
batch_op.f("fk_feedback_reviewed_by_admin_id_admin_user"),
"admin_user",
["reviewed_by_admin_id"],
["id"],
)
feedback = sa.table(
"feedback",
sa.column("status", sa.String(length=16)),
)
op.execute(
feedback.update()
.where(feedback.c.status == "new")
.values(status="pending")
)
op.execute(
feedback.update()
.where(feedback.c.status == "handled")
.values(status="adopted")
)
def downgrade() -> None:
feedback = sa.table(
"feedback",
sa.column("status", sa.String(length=16)),
)
op.execute(
feedback.update()
.where(feedback.c.status == "pending")
.values(status="new")
)
op.execute(
feedback.update()
.where(feedback.c.status == "adopted")
.values(status="handled")
)
with op.batch_alter_table("feedback", schema=None) as batch_op:
batch_op.drop_constraint(
batch_op.f("fk_feedback_reviewed_by_admin_id_admin_user"),
type_="foreignkey",
)
batch_op.drop_index(batch_op.f("ix_feedback_status"))
batch_op.drop_column("reviewed_at")
batch_op.drop_column("reviewed_by_admin_id")
batch_op.drop_column("review_note")
batch_op.drop_column("reward_coins")
batch_op.drop_column("reject_reason")
-34
View File
@@ -1,34 +0,0 @@
"""feedback 加提交端环境快照(app_version / device_model / rom_name / android_version)
Revision ID: feedback_submit_env
Revises: ad_pangle_daily_revenue
Create Date: 2026-06-29 00:00:00.000000
admin 用户反馈页要展示「提交版本号」「机型OS版本」,需在提交时落库端环境。仅新增可空列,
SQLite 原生支持 add_column、不用 batch;downgrade 的 drop_column 在 SQLite 走 batch 兜底。
历史反馈无此快照 → 留 NULL(无法回填);客户端改版带上后的新反馈才有值。
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "feedback_submit_env"
down_revision: Union[str, Sequence[str], None] = "ad_pangle_daily_revenue"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("feedback", sa.Column("app_version", sa.String(length=32), nullable=True))
op.add_column("feedback", sa.Column("device_model", sa.String(length=64), nullable=True))
op.add_column("feedback", sa.Column("rom_name", sa.String(length=32), nullable=True))
op.add_column("feedback", sa.Column("android_version", sa.String(length=16), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("feedback") as batch_op:
batch_op.drop_column("android_version")
batch_op.drop_column("rom_name")
batch_op.drop_column("device_model")
batch_op.drop_column("app_version")
-48
View File
@@ -1,48 +0,0 @@
"""feedback 加反馈来源/场景/运营回复(source / scene / admin_reply)
admin「用户反馈」页要展示并筛选「反馈类型」(比价反馈 / 普通反馈),并支持审核时给用户留言:
- source: 反馈来源入口(profile=「我的」页 / comparison=比价结果页)。NOT NULL,
旧数据 + 普通反馈默认 profile(server_default)。加索引供 admin 按类型筛选。
- scene: 比价反馈的问题场景(找错商品/优惠不对…),普通反馈为 NULL。
- admin_reply: 运营给用户的回复留言(用户端可见),随「我的反馈」历史下发。
均为新增列(SQLite 原生支持 add_column);downgrade 的 drop_column 在 SQLite 走 batch 兜底。
Revision ID: feedback_type_reply
Revises: 761ef181ce7c
Create Date: 2026-07-02 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "feedback_type_reply"
down_revision: str | Sequence[str] | None = "761ef181ce7c"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"feedback",
sa.Column(
"source",
sa.String(length=16),
nullable=False,
server_default="profile",
),
)
op.add_column("feedback", sa.Column("scene", sa.String(length=32), nullable=True))
op.add_column("feedback", sa.Column("admin_reply", sa.String(length=256), nullable=True))
op.create_index("ix_feedback_source", "feedback", ["source"])
def downgrade() -> None:
with op.batch_alter_table("feedback") as batch_op:
batch_op.drop_index("ix_feedback_source")
batch_op.drop_column("admin_reply")
batch_op.drop_column("scene")
batch_op.drop_column("source")
@@ -1,88 +0,0 @@
"""invite cash account isolation + compare reward tracking (🅱-1)
Revision ID: invite_cash_compare_reward
Revises: feedback_review_fields
Create Date: 2026-06-23 00:00:00.000000
邀请功能 v2 账户隔离 + 比价发奖追踪:
- coin_account 加 invite_cash_balance_cents(邀请奖励金独立余额,与金币兑换的 cash 物理隔离)
- withdraw_order 加 source(标记提现扣哪个账户,退款退回对应账户;旧单默认 coin_cash)
- invite_relation 加比价发奖追踪三列(好友比价多次只发一次)
- 新增 invite_cash_transaction 表(邀请奖励金独立流水账本)
加列均 NOT NULL + server_default,存量行自动填默认值,安全。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'invite_cash_compare_reward'
down_revision: Union[str, Sequence[str], None] = 'feedback_review_fields'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. 邀请奖励金独立余额(与金币兑换的 cash_balance_cents 物理隔离;红线:两本账不可累加)
op.add_column(
'coin_account',
sa.Column('invite_cash_balance_cents', sa.Integer(), nullable=False, server_default='0'),
)
# 2. 提现单标记账户来源:coin_cash / invite_cash,退款退回对应账户(旧单默认 coin_cash)
op.add_column(
'withdraw_order',
sa.Column('source', sa.String(length=16), nullable=False, server_default='coin_cash'),
)
# 3. 邀请关系加比价发奖追踪(好友"下载+登录+比价一次"→ 给邀请人发奖,只发一次)
op.add_column(
'invite_relation',
sa.Column('compare_reward_granted', sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.add_column(
'invite_relation',
sa.Column('compare_reward_cents', sa.Integer(), nullable=False, server_default='0'),
)
op.add_column(
'invite_relation',
sa.Column('compare_rewarded_at', sa.DateTime(timezone=True), nullable=True),
)
# 4. 邀请奖励金独立流水表(结构同 cash_transaction;balance_after 记 invite_cash_balance_cents)
op.create_table(
'invite_cash_transaction',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('amount_cents', sa.Integer(), nullable=False),
sa.Column('balance_after_cents', sa.Integer(), nullable=False),
sa.Column('biz_type', sa.String(length=32), nullable=False),
sa.Column('ref_id', sa.String(length=64), nullable=True),
sa.Column('remark', sa.String(length=128), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id']),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_invite_cash_transaction_user_id', 'invite_cash_transaction', ['user_id'])
op.create_index('ix_invite_cash_transaction_created_at', 'invite_cash_transaction', ['created_at'])
# 提现退款幂等:一个提现单只退一次(partial unique,对齐 cash_transaction 的 withdraw_refund 去重)
op.create_index(
'ux_invite_cash_txn_refund_ref',
'invite_cash_transaction',
['ref_id'],
unique=True,
sqlite_where=sa.text("biz_type = 'invite_withdraw_refund' AND ref_id IS NOT NULL"),
postgresql_where=sa.text("biz_type = 'invite_withdraw_refund' AND ref_id IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index('ux_invite_cash_txn_refund_ref', table_name='invite_cash_transaction')
op.drop_index('ix_invite_cash_transaction_created_at', table_name='invite_cash_transaction')
op.drop_index('ix_invite_cash_transaction_user_id', table_name='invite_cash_transaction')
op.drop_table('invite_cash_transaction')
op.drop_column('invite_relation', 'compare_rewarded_at')
op.drop_column('invite_relation', 'compare_reward_cents')
op.drop_column('invite_relation', 'compare_reward_granted')
op.drop_column('withdraw_order', 'source')
op.drop_column('coin_account', 'invite_cash_balance_cents')
@@ -1,54 +0,0 @@
"""user.invite_code + invite_relation table
Revision ID: invite_code_and_relation
Revises: 11a1d08c6f55
Create Date: 2026-06-08 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'invite_code_and_relation'
down_revision: Union[str, Sequence[str], None] = '11a1d08c6f55'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# user 加邀请码列(唯一索引;nullable 允许多个 NULL=未生成的用户)
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('invite_code', sa.String(length=16), nullable=True))
batch_op.create_index('ix_user_invite_code', ['invite_code'], unique=True)
# 邀请关系表
op.create_table(
'invite_relation',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('inviter_user_id', sa.Integer(), nullable=False),
sa.Column('invitee_user_id', sa.Integer(), nullable=False),
sa.Column('channel', sa.String(length=16), nullable=False, server_default='clipboard'),
sa.Column('status', sa.String(length=16), nullable=False, server_default='effective'),
sa.Column('inviter_coin', sa.Integer(), nullable=False, server_default='0'),
sa.Column('invitee_coin', sa.Integer(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(['inviter_user_id'], ['user.id']),
sa.ForeignKeyConstraint(['invitee_user_id'], ['user.id']),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_invite_relation_inviter_user_id', 'invite_relation', ['inviter_user_id'])
op.create_index('ix_invite_relation_invitee_user_id', 'invite_relation', ['invitee_user_id'], unique=True)
op.create_index('ix_invite_relation_created_at', 'invite_relation', ['created_at'])
def downgrade() -> None:
op.drop_index('ix_invite_relation_created_at', table_name='invite_relation')
op.drop_index('ix_invite_relation_invitee_user_id', table_name='invite_relation')
op.drop_index('ix_invite_relation_inviter_user_id', table_name='invite_relation')
op.drop_table('invite_relation')
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_index('ix_user_invite_code')
batch_op.drop_column('invite_code')
@@ -1,50 +0,0 @@
"""invite_fingerprint table (任务 3 指纹归因兜底)
Revision ID: invite_fingerprint_table
Revises: 0cf18d590b1d
Create Date: 2026-06-08 14:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'invite_fingerprint_table'
down_revision: Union[str, Sequence[str], None] = '0cf18d590b1d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 邀请指纹归因表:落地页访问时记一行,登录后剪贴板没拿到邀请码时反查
op.create_table(
'invite_fingerprint',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('inviter_user_id', sa.Integer(), nullable=False),
sa.Column('ip', sa.String(length=64), nullable=False),
sa.Column('device_model', sa.String(length=64), nullable=False, server_default=''),
sa.Column('screen', sa.String(length=32), nullable=False, server_default=''),
sa.Column('user_agent', sa.Text(), nullable=False, server_default=''),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(['inviter_user_id'], ['user.id']),
sa.PrimaryKeyConstraint('id'),
)
# 反查主索引:(ip, screen, device_model, created_at)
# /bind 兜底分支 WHERE ip=? AND screen=? AND device_model=? AND created_at > now - 7d
# ORDER BY created_at DESC LIMIT 1 — SQLite/PG 优化器都能反向扫该索引
op.create_index(
'ix_invite_fp_match',
'invite_fingerprint',
['ip', 'screen', 'device_model', 'created_at'],
)
# 兜底/统计索引:按 inviter 看"这个邀请人的落地页被谁访问过"
op.create_index('ix_invite_fp_inviter', 'invite_fingerprint', ['inviter_user_id'])
def downgrade() -> None:
op.drop_index('ix_invite_fp_inviter', table_name='invite_fingerprint')
op.drop_index('ix_invite_fp_match', table_name='invite_fingerprint')
op.drop_table('invite_fingerprint')
-56
View File
@@ -1,56 +0,0 @@
"""add jd cps order fields
Revision ID: jd_cps_order_fields
Revises: 7db22acee504
Create Date: 2026-06-28 20:30:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "jd_cps_order_fields"
down_revision = "7db22acee504"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("cps_order") as batch_op:
batch_op.add_column(
sa.Column("platform", sa.String(length=20), nullable=False, server_default="meituan")
)
batch_op.add_column(sa.Column("external_order_id", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("external_row_id", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("estimated_commission_cents", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("actual_commission_cents", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("jd_valid_code", sa.String(length=16), nullable=True))
batch_op.add_column(sa.Column("settle_month", sa.String(length=16), nullable=True))
batch_op.add_column(sa.Column("site_id", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("position_id", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("pid", sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column("sub_union_id", sa.String(length=128), nullable=True))
batch_op.create_index("ix_cps_order_platform", ["platform"])
batch_op.create_index("ix_cps_order_external_order_id", ["external_order_id"])
batch_op.create_index("ix_cps_order_external_row_id", ["external_row_id"])
batch_op.create_index("ix_cps_order_jd_valid_code", ["jd_valid_code"])
def downgrade() -> None:
with op.batch_alter_table("cps_order") as batch_op:
batch_op.drop_index("ix_cps_order_jd_valid_code")
batch_op.drop_index("ix_cps_order_external_row_id")
batch_op.drop_index("ix_cps_order_external_order_id")
batch_op.drop_index("ix_cps_order_platform")
batch_op.drop_column("sub_union_id")
batch_op.drop_column("pid")
batch_op.drop_column("position_id")
batch_op.drop_column("site_id")
batch_op.drop_column("settle_month")
batch_op.drop_column("jd_valid_code")
batch_op.drop_column("actual_commission_cents")
batch_op.drop_column("estimated_commission_cents")
batch_op.drop_column("external_row_id")
batch_op.drop_column("external_order_id")
batch_op.drop_column("platform")
@@ -1,53 +0,0 @@
"""launch_confirm_sample table (启动确认窗 agent 兜底样本: LLM 放行的弹窗样本沉淀)
Revision ID: launch_confirm_sample_table
Revises: cps_wx_user
Create Date: 2026-06-20 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "launch_confirm_sample_table"
down_revision: Union[str, Sequence[str], None] = "cps_wx_user"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"launch_confirm_sample",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
sa.Column("trace_id", sa.String(length=64), nullable=True),
sa.Column("device_id", sa.String(length=64), nullable=True),
sa.Column("host_package", sa.String(length=128), nullable=True),
sa.Column("target_app", sa.String(length=128), nullable=True),
sa.Column("system_locale", sa.String(length=32), nullable=True),
sa.Column("exec_success", sa.Boolean(), nullable=False),
sa.Column("dialog_title", sa.String(length=256), nullable=True),
# PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐
sa.Column("payload", sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), "postgresql"), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("launch_confirm_sample", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_created_at"), ["created_at"], unique=False)
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_trace_id"), ["trace_id"], unique=False)
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_device_id"), ["device_id"], unique=False)
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_host_package"), ["host_package"], unique=False)
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_system_locale"), ["system_locale"], unique=False)
def downgrade() -> None:
with op.batch_alter_table("launch_confirm_sample", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_system_locale"))
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_host_package"))
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_device_id"))
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_trace_id"))
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_created_at"))
op.drop_table("launch_confirm_sample")
-50
View File
@@ -1,50 +0,0 @@
"""marquee_seed 升级为「生成规则」:用户名可空 + 金额拆 min/max 区间
把种子从「死记录」升级:
- masked_user 改 nullable(留空 → feed 随机合成脱敏用户名)
- saved_amount_cents(单值)拆成 min_cents / max_cents(feed 在区间内随机取值;固定金额则相等)
现有 6 条种子:min_cents = max_cents = 原 saved_amount_cents,masked_user 原样保留。
Revision ID: marquee_seed02
Revises: marquee_seed01
Create Date: 2026-06-07 11:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'marquee_seed02'
down_revision: Union[str, Sequence[str], None] = 'marquee_seed01'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1) 先加可空新列,使现有行合法
with op.batch_alter_table('marquee_seed', schema=None) as batch_op:
batch_op.add_column(sa.Column('min_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('max_cents', sa.Integer(), nullable=True))
# 2) 用旧单值回填区间(固定金额)
op.execute('UPDATE marquee_seed SET min_cents = saved_amount_cents, max_cents = saved_amount_cents')
# 3) 收紧:区间非空、用户名可空、删旧列
with op.batch_alter_table('marquee_seed', schema=None) as batch_op:
batch_op.alter_column('min_cents', existing_type=sa.Integer(), nullable=False)
batch_op.alter_column('max_cents', existing_type=sa.Integer(), nullable=False)
batch_op.alter_column('masked_user', existing_type=sa.String(length=64), nullable=True)
batch_op.drop_column('saved_amount_cents')
def downgrade() -> None:
# 反向:恢复单值列(取下限),用户名回非空
with op.batch_alter_table('marquee_seed', schema=None) as batch_op:
batch_op.add_column(sa.Column('saved_amount_cents', sa.Integer(), nullable=True))
op.execute('UPDATE marquee_seed SET saved_amount_cents = min_cents')
# 自动合成名的种子 masked_user 为 NULL,降级前补占位符以满足非空
op.execute("UPDATE marquee_seed SET masked_user = '用户********000' WHERE masked_user IS NULL")
with op.batch_alter_table('marquee_seed', schema=None) as batch_op:
batch_op.alter_column('saved_amount_cents', existing_type=sa.Integer(), nullable=False)
batch_op.alter_column('masked_user', existing_type=sa.String(length=64), nullable=False)
batch_op.drop_column('max_cents')
batch_op.drop_column('min_cents')
-52
View File
@@ -1,52 +0,0 @@
"""marquee_seed table (首页轮播种子) + 合并 platform_stat / price_observation 两 head
Revision ID: marquee_seed01
Revises: pstat2anchor01, price_observation_table
Create Date: 2026-06-07 10:00:00.000000
兼并当前两个 head 为单 head,同时建 marquee_seed 表并播种客户端原写死的 6 条轮播
(作为初始种子,保证上线即有内容)。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'marquee_seed01'
down_revision: Union[str, Sequence[str], None] = ('pstat2anchor01', 'price_observation_table')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
table = op.create_table(
'marquee_seed',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('masked_user', sa.String(length=64), nullable=False),
sa.Column('saved_amount_cents', sa.Integer(), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
sa.Column(
'created_at', sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False,
),
sa.PrimaryKeyConstraint('id'),
)
# 初始种子 = 客户端原写死的 6 条(金额转成分)
op.bulk_insert(
table,
[
{'masked_user': '用户********a52', 'saved_amount_cents': 1860, 'enabled': True, 'sort_order': 1},
{'masked_user': '用户********k89', 'saved_amount_cents': 4240, 'enabled': True, 'sort_order': 2},
{'masked_user': '用户********m71', 'saved_amount_cents': 790, 'enabled': True, 'sort_order': 3},
{'masked_user': '用户********p46', 'saved_amount_cents': 15630, 'enabled': True, 'sort_order': 4},
{'masked_user': '用户********r93', 'saved_amount_cents': 2350, 'enabled': True, 'sort_order': 5},
{'masked_user': '用户********c28', 'saved_amount_cents': 8940, 'enabled': True, 'sort_order': 6},
],
)
def downgrade() -> None:
op.drop_table('marquee_seed')
@@ -1,34 +0,0 @@
"""meituan_coupon image size & type
给 meituan_coupon 增加 image_size(字节)/ image_type(MIME)两列,采集时 HEAD 头图得到,
供读取侧按图片大小决定是否拼缩放参数提速。两列均 nullable,不动存量行;下一轮全量 ETL
upsert 自动回填,无需手动 backfill。
Revision ID: meituan_coupon_image_meta
Revises: feedback_review_fields
Create Date: 2026-06-23 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "meituan_coupon_image_meta"
down_revision: str | Sequence[str] | None = "feedback_review_fields"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("meituan_coupon", schema=None) as batch_op:
batch_op.add_column(sa.Column("image_size", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("image_type", sa.String(length=32), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("meituan_coupon", schema=None) as batch_op:
batch_op.drop_column("image_type")
batch_op.drop_column("image_size")
-62
View File
@@ -1,62 +0,0 @@
"""meituan_coupon table(美团 CPS 券本地缓存,供销量/佣金排序从库里捞、本地排序)
Revision ID: meituan_coupon_table
Revises: withdraw_review_ad_watch
Create Date: 2026-06-06 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'meituan_coupon_table'
down_revision: Union[str, Sequence[str], None] = 'withdraw_review_ad_watch'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'meituan_coupon',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('source', sa.String(length=16), nullable=False),
sa.Column('platform', sa.Integer(), nullable=False),
sa.Column('biz_line', sa.Integer(), nullable=True),
sa.Column('city_id', sa.String(length=32), nullable=False),
sa.Column('product_view_sign', sa.String(length=128), nullable=False),
sa.Column('sku_view_id', sa.String(length=128), nullable=True),
sa.Column('name', sa.String(length=256), nullable=True),
sa.Column('brand_name', sa.String(length=128), nullable=True),
sa.Column('sell_price_cents', sa.Integer(), nullable=True),
sa.Column('original_price_cents', sa.Integer(), nullable=True),
sa.Column('head_url', sa.String(length=512), nullable=True),
sa.Column('sale_volume', sa.String(length=32), nullable=True),
sa.Column('sale_volume_num', sa.Integer(), nullable=True),
sa.Column('commission_percent', sa.Float(), nullable=True),
sa.Column('commission_amount_cents', sa.Integer(), nullable=True),
sa.Column('poi_name', sa.String(length=128), nullable=True),
sa.Column('available_poi_num', sa.Integer(), nullable=True),
sa.Column('delivery_distance_m', sa.Float(), nullable=True),
sa.Column('dedup_key', sa.String(length=64), nullable=False),
sa.Column('raw', sa.JSON().with_variant(postgresql.JSONB(), 'postgresql'), nullable=False),
sa.Column('first_seen', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('last_seen', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('source', 'product_view_sign', name='uq_meituan_coupon_source_sign'),
)
op.create_index('ix_meituan_coupon_source', 'meituan_coupon', ['source'])
op.create_index('ix_meituan_coupon_city_id', 'meituan_coupon', ['city_id'])
op.create_index('ix_meituan_coupon_brand_name', 'meituan_coupon', ['brand_name'])
op.create_index('ix_meituan_coupon_sale_volume_num', 'meituan_coupon', ['sale_volume_num'])
op.create_index('ix_meituan_coupon_commission_percent', 'meituan_coupon', ['commission_percent'])
op.create_index('ix_meituan_coupon_dedup_key', 'meituan_coupon', ['dedup_key'])
op.create_index('ix_meituan_coupon_last_seen', 'meituan_coupon', ['last_seen'])
def downgrade() -> None:
op.drop_table('meituan_coupon')
@@ -1,26 +0,0 @@
"""merge ad feed reward session and withdraw safety heads
Revision ID: 044dce6e9b1f
Revises: ad_feed_reward_session, withdraw_safety_indexes
Create Date: 2026-06-09 09:52:08.276767
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '044dce6e9b1f'
down_revision: Union[str, Sequence[str], None] = ('ad_feed_reward_session', 'withdraw_safety_indexes')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -1,39 +0,0 @@
"""onboarding_completion table (新手引导完成标记,按 user+device 去重)
Revision ID: onboarding_completion
Revises: coupon_daily_completion
Create Date: 2026-06-10 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'onboarding_completion'
down_revision: Union[str, Sequence[str], None] = 'coupon_daily_completion'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# (账号, 设备) 一条完成标记;device_id = 客户端硬件级 ANDROID_ID(跨卸载重装稳定)。
op.create_table(
'onboarding_completion',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('device_id', sa.String(length=64), nullable=False),
sa.Column('completed_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'device_id', name='uq_onboarding_user_device'),
)
with op.batch_alter_table('onboarding_completion', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_onboarding_completion_user_id'), ['user_id'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('onboarding_completion', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_onboarding_completion_user_id'))
op.drop_table('onboarding_completion')
-28
View File
@@ -1,28 +0,0 @@
"""运营可配表加 ops_ 前缀:platform_stat_display→ops_stat_config / marquee_seed→ops_marquee_seed
把首页门面数据的两张「运营可配」表重命名,与代码侧 ops_ 前缀(模型 OpsStatConfig/OpsMarqueeSeed、
repo ops_stat/ops_marquee、admin ops_stat_config/ops_marquee_seed)对齐。仅改表名,不动列与数据。
Revision ID: opsrename01
Revises: coin_reward_phase1
Create Date: 2026-06-07 16:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = 'opsrename01'
down_revision: Union[str, Sequence[str], None] = 'coin_reward_phase1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.rename_table('platform_stat_display', 'ops_stat_config')
op.rename_table('marquee_seed', 'ops_marquee_seed')
def downgrade() -> None:
op.rename_table('ops_stat_config', 'platform_stat_display')
op.rename_table('ops_marquee_seed', 'marquee_seed')
@@ -1,30 +0,0 @@
"""platform_stat_display 增加 random_anchor_minutes(自增长触发时刻对齐偏移)
Revision ID: pstat2anchor01
Revises: pstat1d2e3f4a
Create Date: 2026-06-06 13:00:00.000000
自增长 tick 从「启动时刻 + 间隔」改为「北京时间钟点对齐(anchor + k*interval)」,
新增列存对齐偏移(距 0 点分钟数)。已有行默认 0(= 对齐到自然边界:天→0点、小时→整点)。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'pstat2anchor01'
down_revision: Union[str, Sequence[str], None] = 'pstat1d2e3f4a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'platform_stat_display',
sa.Column('random_anchor_minutes', sa.Integer(), nullable=False, server_default='0'),
)
def downgrade() -> None:
op.drop_column('platform_stat_display', 'random_anchor_minutes')
@@ -1,59 +0,0 @@
"""platform_stat_display table (首页三统计展示配置:每指标独立 real/manual/random)
Revision ID: pstat1d2e3f4a
Revises: withdraw_review_ad_watch
Create Date: 2026-06-06 12:00:00.000000
播种 3 行:默认 manual + 客户端原写死门面值(12847 人 / 86532 次 / 37621.4 元),
保证上线前后展示一致,运营再按需切模式。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'pstat1d2e3f4a'
down_revision: Union[str, Sequence[str], None] = 'withdraw_review_ad_watch'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
table = op.create_table(
'platform_stat_display',
sa.Column('metric', sa.String(length=32), nullable=False),
sa.Column('mode', sa.String(length=16), nullable=False),
sa.Column('manual_value', sa.Integer(), nullable=True),
sa.Column('random_mult_min', sa.Integer(), nullable=False),
sa.Column('random_mult_max', sa.Integer(), nullable=False),
sa.Column('random_tick_seconds', sa.Integer(), nullable=False),
sa.Column('random_current', sa.Integer(), nullable=True),
sa.Column('random_last_tick_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('updated_by_admin_id', sa.Integer(), nullable=True),
sa.Column(
'updated_at', sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False,
),
sa.PrimaryKeyConstraint('metric'),
)
_common = {
'mode': 'manual',
'random_mult_min': 1000, # 1.000(只增不减下限)
'random_mult_max': 1100, # 1.100
'random_tick_seconds': 86400, # 1 天
}
op.bulk_insert(
table,
[
{'metric': 'help_users', 'manual_value': 12847, **_common},
{'metric': 'total_compares', 'manual_value': 86532, **_common},
{'metric': 'total_saved', 'manual_value': 3762140, **_common}, # 37621.40 元
],
)
def downgrade() -> None:
op.drop_table('platform_stat_display')
@@ -1,39 +0,0 @@
"""platform_stat_display 增强:绝对增量增长 / 真实值偏移 / 只增不减护栏
给首页三统计配置加 5 列:
- random_kind ('mult'/'add') + random_step_min/max:自增长支持「绝对增量」(每周期 +[a,b]),不只是 ×倍率
- real_offset:真实值模式基数偏移(展示=真实+偏移,冷启动既真实又体面)
- allow_decrease:门面数字默认「只增不减」,开此开关才允许展示值下降
Revision ID: pstat3growth
Revises: marquee_seed02
Create Date: 2026-06-07 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'pstat3growth'
down_revision: Union[str, Sequence[str], None] = 'marquee_seed02'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('platform_stat_display', schema=None) as batch_op:
batch_op.add_column(sa.Column('random_kind', sa.String(length=8), nullable=False, server_default='mult'))
batch_op.add_column(sa.Column('random_step_min', sa.Integer(), nullable=False, server_default='0'))
batch_op.add_column(sa.Column('random_step_max', sa.Integer(), nullable=False, server_default='0'))
batch_op.add_column(sa.Column('real_offset', sa.Integer(), nullable=False, server_default='0'))
batch_op.add_column(sa.Column('allow_decrease', sa.Boolean(), nullable=False, server_default=sa.false()))
def downgrade() -> None:
with op.batch_alter_table('platform_stat_display', schema=None) as batch_op:
batch_op.drop_column('allow_decrease')
batch_op.drop_column('real_offset')
batch_op.drop_column('random_step_max')
batch_op.drop_column('random_step_min')
batch_op.drop_column('random_kind')
@@ -1,74 +0,0 @@
"""price_observation table (价格观测:比价沉淀的价格资产层,server 侧无条件落库)
Revision ID: price_observation_table
Revises: wx_transfer_auth
Create Date: 2026-06-07 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'price_observation_table'
down_revision: Union[str, Sequence[str], None] = 'wx_transfer_auth'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'price_observation',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('observed_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('trace_id', sa.String(length=64), nullable=False),
sa.Column('business_type', sa.String(length=16), nullable=False),
sa.Column('platform', sa.String(length=32), nullable=False),
sa.Column('platform_store_id', sa.String(length=64), nullable=True),
sa.Column('store_name', sa.String(length=128), nullable=True),
sa.Column('city', sa.String(length=64), nullable=True),
sa.Column('geohash', sa.String(length=16), nullable=True),
sa.Column('lng', sa.Float(), nullable=True),
sa.Column('lat', sa.Float(), nullable=True),
sa.Column('is_source', sa.Boolean(), nullable=False),
sa.Column('scope', sa.String(length=16), nullable=False),
sa.Column('price_cents', sa.Integer(), nullable=True),
sa.Column('coupon_saved_cents', sa.Integer(), nullable=True),
sa.Column('coupon_name', sa.String(length=64), nullable=True),
sa.Column('store_closed', sa.String(length=32), nullable=True),
sa.Column('rank', sa.Integer(), nullable=True),
# PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐
sa.Column('dishes', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=True),
sa.Column('attrs', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=True),
sa.Column('source_device_id', sa.String(length=64), nullable=True),
sa.Column('source_user_id', sa.Integer(), nullable=True),
sa.Column('confidence', sa.Float(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('trace_id', 'platform', 'scope', name='uq_price_obs_trace_platform_scope'),
)
with op.batch_alter_table('price_observation', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_price_observation_observed_at'), ['observed_at'], unique=False)
batch_op.create_index(batch_op.f('ix_price_observation_trace_id'), ['trace_id'], unique=False)
batch_op.create_index(batch_op.f('ix_price_observation_business_type'), ['business_type'], unique=False)
batch_op.create_index(batch_op.f('ix_price_observation_platform'), ['platform'], unique=False)
batch_op.create_index(batch_op.f('ix_price_observation_store_name'), ['store_name'], unique=False)
batch_op.create_index(batch_op.f('ix_price_observation_geohash'), ['geohash'], unique=False)
batch_op.create_index(batch_op.f('ix_price_observation_source_device_id'), ['source_device_id'], unique=False)
batch_op.create_index(batch_op.f('ix_price_observation_source_user_id'), ['source_user_id'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('price_observation', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_price_observation_source_user_id'))
batch_op.drop_index(batch_op.f('ix_price_observation_source_device_id'))
batch_op.drop_index(batch_op.f('ix_price_observation_geohash'))
batch_op.drop_index(batch_op.f('ix_price_observation_store_name'))
batch_op.drop_index(batch_op.f('ix_price_observation_platform'))
batch_op.drop_index(batch_op.f('ix_price_observation_business_type'))
batch_op.drop_index(batch_op.f('ix_price_observation_trace_id'))
batch_op.drop_index(batch_op.f('ix_price_observation_observed_at'))
op.drop_table('price_observation')
-40
View File
@@ -1,40 +0,0 @@
"""store_mapping 加京东原料列(jd_vender_id + 分享链/反查 URL/deeplink)
Revision ID: store_mapping_jd_cols
Revises: store_mapping_meituan_cols
Create Date: 2026-06-13 00:00:00.000000
京东秒送接入店内搜索 deeplink 链路: 3.cn 短链 → 跟随重定向反查 venderId+storeId →
openapp.jdmobile:// deeplink。京东店铺身份是**两个**稳定数字 id: storeId 进 id_jd(稳定
店主键, 同 taobao 的 shopId→id_taobao), venderId 进单列 jd_vender_id(deeplink 还需它)。
其余三列与 taobao_*/meituan_* 原料列平行。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'store_mapping_jd_cols'
down_revision: Union[str, Sequence[str], None] = 'store_mapping_meituan_cols'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.add_column(sa.Column('jd_vender_id', sa.String(length=64), nullable=True))
batch_op.add_column(sa.Column('jd_share_url', sa.String(length=256), nullable=True))
batch_op.add_column(sa.Column('jd_resolved_url', sa.Text(), nullable=True))
batch_op.add_column(sa.Column('jd_deeplink', sa.Text(), nullable=True))
batch_op.create_index(batch_op.f('ix_store_mapping_jd_vender_id'), ['jd_vender_id'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_store_mapping_jd_vender_id'))
batch_op.drop_column('jd_deeplink')
batch_op.drop_column('jd_resolved_url')
batch_op.drop_column('jd_share_url')
batch_op.drop_column('jd_vender_id')
@@ -1,32 +0,0 @@
"""store_mapping 加京东 deeplink 失效标记列 jd_deeplink_invalid_at
Revision ID: store_mapping_jd_dl_invalid
Revises: store_mapping_tb_dl_invalid
Create Date: 2026-06-15 00:00:00.000000
京东同淘宝: 缓存的店内搜索 deeplink 会失效(打开是"当前门店超出配送范围"页)。pricebot 比价撞到
失效页时回退正常搜店, 并 server→server 通知把该 storeId 的 deeplink 标记失效。本列记失效时刻
(NULL=有效); lookup 反查时过滤掉已失效的京东候选, 不再返回坏 deeplink。
与淘宝 taobao_deeplink_invalid_at 对称; 用时间戳而非布尔: 留痕可审计、可统计失效率, 不销毁原 deeplink。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'store_mapping_jd_dl_invalid'
down_revision: Union[str, Sequence[str], None] = 'store_mapping_tb_dl_invalid'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.add_column(sa.Column('jd_deeplink_invalid_at', sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.drop_column('jd_deeplink_invalid_at')
@@ -1,39 +0,0 @@
"""store_mapping 加美团原料列(poi_id_str + 分享链/反查 URL/deeplink)
Revision ID: store_mapping_meituan_cols
Revises: store_mapping_table
Create Date: 2026-06-13 00:00:00.000000
美团接入店内搜索 deeplink 链路: dpurl.cn 短链 → 302 反查 poi_id_str → imeituan:// deeplink。
poi_id_str 每次分享重新加密、非稳定主键, 单列 meituan_poi_id_str 存(不占 id_meituan,
后者留给将来 CPS API 的稳定数字 poi_id)。其余三列与 taobao_* 原料列平行。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'store_mapping_meituan_cols'
down_revision: Union[str, Sequence[str], None] = 'store_mapping_table'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.add_column(sa.Column('meituan_poi_id_str', sa.String(length=64), nullable=True))
batch_op.add_column(sa.Column('meituan_share_url', sa.String(length=256), nullable=True))
batch_op.add_column(sa.Column('meituan_resolved_url', sa.Text(), nullable=True))
batch_op.add_column(sa.Column('meituan_deeplink', sa.Text(), nullable=True))
batch_op.create_index(batch_op.f('ix_store_mapping_meituan_poi_id_str'), ['meituan_poi_id_str'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_store_mapping_meituan_poi_id_str'))
batch_op.drop_column('meituan_deeplink')
batch_op.drop_column('meituan_resolved_url')
batch_op.drop_column('meituan_share_url')
batch_op.drop_column('meituan_poi_id_str')
-77
View File
@@ -1,77 +0,0 @@
"""store_mapping table (平台店铺表:跨平台同店 id/名 映射,server 侧无条件落库)
Revision ID: store_mapping_table
Revises: coin_txn_task_ref_uq
Create Date: 2026-06-13 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'store_mapping_table'
down_revision: Union[str, Sequence[str], None] = 'coin_txn_task_ref_uq'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'store_mapping',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
# 跨平台身份
sa.Column('id_taobao', sa.String(length=64), nullable=True),
sa.Column('name_taobao', sa.String(length=128), nullable=True),
sa.Column('id_meituan', sa.String(length=64), nullable=True),
sa.Column('name_meituan', sa.String(length=128), nullable=True),
sa.Column('id_jd', sa.String(length=64), nullable=True),
sa.Column('name_jd', sa.String(length=128), nullable=True),
# 地理
sa.Column('city', sa.String(length=64), nullable=True),
sa.Column('geohash', sa.String(length=16), nullable=True),
sa.Column('lng', sa.Float(), nullable=True),
sa.Column('lat', sa.Float(), nullable=True),
sa.Column('taobao_address', sa.String(length=256), nullable=True),
# 溯源
sa.Column('source_platform', sa.String(length=32), nullable=True),
sa.Column('business_type', sa.String(length=16), nullable=False),
sa.Column('trace_id', sa.String(length=64), nullable=False),
sa.Column('source_device_id', sa.String(length=64), nullable=True),
sa.Column('source_user_id', sa.Integer(), nullable=True),
# 淘宝原料(URL 可能很长 → Text)
sa.Column('taobao_share_url', sa.String(length=256), nullable=True),
sa.Column('taobao_resolved_url', sa.Text(), nullable=True),
sa.Column('taobao_deeplink', sa.Text(), nullable=True),
# PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐
sa.Column('attrs', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
# 一次比价一行,trace_id 幂等去重(防 pricebot 重试 / replay 重复写)
sa.UniqueConstraint('trace_id', name='uq_store_mapping_trace'),
)
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_store_mapping_id_taobao'), ['id_taobao'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_id_meituan'), ['id_meituan'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_id_jd'), ['id_jd'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_geohash'), ['geohash'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_source_platform'), ['source_platform'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_source_device_id'), ['source_device_id'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_source_user_id'), ['source_user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_store_mapping_created_at'), ['created_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_store_mapping_created_at'))
batch_op.drop_index(batch_op.f('ix_store_mapping_source_user_id'))
batch_op.drop_index(batch_op.f('ix_store_mapping_source_device_id'))
batch_op.drop_index(batch_op.f('ix_store_mapping_source_platform'))
batch_op.drop_index(batch_op.f('ix_store_mapping_geohash'))
batch_op.drop_index(batch_op.f('ix_store_mapping_id_jd'))
batch_op.drop_index(batch_op.f('ix_store_mapping_id_meituan'))
batch_op.drop_index(batch_op.f('ix_store_mapping_id_taobao'))
op.drop_table('store_mapping')
@@ -1,33 +0,0 @@
"""store_mapping 加淘宝 deeplink 失效标记列 taobao_deeplink_invalid_at
Revision ID: store_mapping_tb_dl_invalid
Revises: coupon_engage_per_package
Create Date: 2026-06-15 00:00:00.000000
缓存的淘宝店内搜索 deeplink 会失效(打开是"页面出错了"降级页)。pricebot 比价撞到错误页时
回退正常搜店, 并 server→server 通知把该 shopId 的 deeplink 标记失效。本列记失效时刻
(NULL=有效); lookup 反查时过滤掉已失效的淘宝候选, 不再返回坏 deeplink。
只加淘宝一列(当前只接淘宝); 用时间戳而非布尔: 留痕可审计、可统计失效率, 且不销毁原 deeplink。
无需索引: 过滤总叠在 name_taobao== 之后, 候选集已小。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'store_mapping_tb_dl_invalid'
down_revision: Union[str, Sequence[str], None] = 'coupon_engage_per_package'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.add_column(sa.Column('taobao_deeplink_invalid_at', sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.drop_column('taobao_deeplink_invalid_at')
-39
View File
@@ -1,39 +0,0 @@
"""add user.force_onboarding
运营后台「一键开启新手引导」:置 true → 用户下次启动 App 被强制重走引导教程(即便本地已完成),
走完引导(/api/v1/user/onboarding/complete)后端自动清回 false,不无限循环。
见 app/models/user.py force_onboarding 列、admin POST /admin/api/users/{id}/force-onboarding。
Revision ID: user_force_onboarding
Revises: onboarding_completion
Create Date: 2026-06-10 21:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "user_force_onboarding"
down_revision: Union[str, Sequence[str], None] = "onboarding_completion"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# sa.false() 渲染成 PG 的 false / SQLite 的 0,两端兼容(同 debug_trace_enabled)。
op.add_column(
"user",
sa.Column(
"force_onboarding",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
def downgrade() -> None:
op.drop_column("user", "force_onboarding")
@@ -1,48 +0,0 @@
"""wechat_transfer_authorization 表(免确认收款授权)
商家转账「用户授权免确认收款模式」:用户授权一次后,后续提现免逐笔确认直接到账。
一个用户一条(user_id 主键)。out_authorization_no 我方生成,authorization_id 微信 active 后返回。
Revision ID: wx_transfer_auth
Revises: withdraw_review_ad_watch
Create Date: 2026-06-06 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'wx_transfer_auth'
down_revision: Union[str, Sequence[str], None] = 'withdraw_review_ad_watch'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'wechat_transfer_authorization',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('openid', sa.String(length=64), nullable=False),
sa.Column('out_authorization_no', sa.String(length=64), nullable=False),
sa.Column('authorization_id', sa.String(length=64), nullable=True),
sa.Column('state', sa.String(length=16), server_default='pending', nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('user_id'),
)
with op.batch_alter_table('wechat_transfer_authorization', schema=None) as batch_op:
batch_op.create_index(
batch_op.f('ix_wechat_transfer_authorization_out_authorization_no'),
['out_authorization_no'],
unique=True,
)
def downgrade() -> None:
with op.batch_alter_table('wechat_transfer_authorization', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_wechat_transfer_authorization_out_authorization_no'))
op.drop_table('wechat_transfer_authorization')
@@ -1,62 +0,0 @@
"""withdraw_order.user_name 列 + ad_watch_log 表
提现审核功能:WithdrawOrder 加 user_name(审核异步打款时传给微信的实名,达额转账要求)。
看广告时长防刷:新增 ad_watch_log 表(前端 onAdClose 上报观看秒数,按 (user_id, watch_date)
聚合做"每天 50 分钟"硬上限)。
Revision ID: withdraw_review_ad_watch
Revises: ebb6af5c0b56
Create Date: 2026-06-05 12:00:00.000000
注:本迁移原 down_revision=ad60a1b2c3d4(分支创建时的 head);rebase 合 main 后 main 侧已新增
app_config / price_report 等迁移并 merge 出 head=ebb6af5c0b56,故改挂到其上,保持单 head(本迁移
与那些表互不相干,叠加顺序无影响)。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'withdraw_review_ad_watch'
down_revision: Union[str, Sequence[str], None] = 'ebb6af5c0b56'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ① 提现单加实名列:审核通过后异步打款时传给微信(达额转账要求),发起提现时存下
with op.batch_alter_table('withdraw_order', schema=None) as batch_op:
batch_op.add_column(sa.Column('user_name', sa.String(length=64), nullable=True))
# ② 看广告观看时长表:前端上报观看秒数,(user_id, watch_date) 聚合做每日 50 分钟防刷主闸
op.create_table(
'ad_watch_log',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('watch_seconds', sa.Integer(), server_default='0', nullable=False),
sa.Column('watch_date', sa.String(length=10), nullable=False),
sa.Column(
'created_at', sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False,
),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
)
with op.batch_alter_table('ad_watch_log', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_ad_watch_log_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_ad_watch_log_watch_date'), ['watch_date'], unique=False)
batch_op.create_index(batch_op.f('ix_ad_watch_log_created_at'), ['created_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('ad_watch_log', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_ad_watch_log_created_at'))
batch_op.drop_index(batch_op.f('ix_ad_watch_log_watch_date'))
batch_op.drop_index(batch_op.f('ix_ad_watch_log_user_id'))
op.drop_table('ad_watch_log')
with op.batch_alter_table('withdraw_order', schema=None) as batch_op:
batch_op.drop_column('user_name')
@@ -1,42 +0,0 @@
"""add withdraw concurrency safety indexes
Revision ID: withdraw_safety_indexes
Revises: 0cf18d590b1d
Create Date: 2026-06-08 16:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "withdraw_safety_indexes"
down_revision: Union[str, Sequence[str], None] = "0cf18d590b1d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index(
"ux_withdraw_order_user_active",
"withdraw_order",
["user_id"],
unique=True,
sqlite_where=sa.text("status IN ('reviewing', 'pending')"),
postgresql_where=sa.text("status IN ('reviewing', 'pending')"),
)
op.create_index(
"ux_cash_transaction_withdraw_refund_ref",
"cash_transaction",
["ref_id"],
unique=True,
sqlite_where=sa.text("biz_type = 'withdraw_refund' AND ref_id IS NOT NULL"),
postgresql_where=sa.text("biz_type = 'withdraw_refund' AND ref_id IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index("ux_cash_transaction_withdraw_refund_ref", table_name="cash_transaction")
op.drop_index("ux_withdraw_order_user_active", table_name="withdraw_order")
-30
View File
@@ -13,26 +13,11 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.admin.routers.ad_audit import router as ad_audit_router
from app.admin.routers.ad_config import router as ad_config_router
from app.admin.routers.ad_revenue import router as ad_revenue_router
from app.admin.routers.admins import router as admins_router
from app.admin.routers.audit import router as audit_router
from app.admin.routers.auth import router as auth_router
from app.admin.routers.comparison import router as comparison_router
from app.admin.routers.config import router as config_router
from app.admin.routers.coupon_data import router as coupon_data_router
from app.admin.routers.cps import router as cps_router
from app.admin.routers.dashboard import router as dashboard_router
from app.admin.routers.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.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
@@ -87,24 +72,9 @@ def health() -> dict[str, str]:
admin_app.include_router(auth_router)
admin_app.include_router(dashboard_router)
admin_app.include_router(device_liveness_router)
admin_app.include_router(ops_stat_config_router)
admin_app.include_router(ops_marquee_seed_router)
admin_app.include_router(users_router)
admin_app.include_router(onboarding_router)
admin_app.include_router(wallet_router)
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(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)
admin_app.include_router(cps_router)
admin_app.include_router(coupon_data_router)
admin_app.include_router(ad_audit_router)
admin_app.include_router(ad_config_router)
admin_app.include_router(ad_revenue_router)
-83
View File
@@ -1,83 +0,0 @@
"""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)
-292
View File
@@ -1,292 +0,0 @@
"""看广告金币审计:复算 expected_coin 并与实发对比。
只读。复用 [app.core.rewards] 的公式函数(不另写公式,避免与正式发奖口径漂移):
- 看视频:每条 granted = 1 份,第 N 份 = 该用户 granted 的 reward_video **账号累计**顺序号
(与 ad_reward.grant_ad_reward 里 `_granted_cumulative + 1` 一致;LT 因子不按天重置,
故复算时要把当日序号叠加上该用户在本日**之前**的累计已发份数)。
- 信息流:**每条 granted = 1 份**(与 ad_feed_reward.grant_feed_reward 同口径:看满一份即发该条
满额,**不按 unit_count 逐份累加**),LT 序号 = 该用户 granted **条数**账号累计
(与 ad_feed_reward.granted_unit_total 的 COUNT 一致;不按天重置,复算需叠加本日之前的累计条数)。
非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。
"""
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core import rewards
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
from app.repositories.ad_feed_reward import FEED_REWARD_UNIT_SECONDS
def _prior_granted_counts(
db: Session, *, date: str, user_id: int | None
) -> dict[int, int]:
"""各用户在 date **之前**已发奖的 reward_video 累计份数,作为当日复算的 LT 序号起点。
LT 因子改账号累计后,当日第 1 份并非全局第 1 份,需叠加历史累计。"""
stmt = (
select(AdRewardRecord.user_id, func.count())
.where(
AdRewardRecord.reward_date < date,
AdRewardRecord.reward_scene == "reward_video",
AdRewardRecord.status == "granted",
)
.group_by(AdRewardRecord.user_id)
)
if user_id is not None:
stmt = stmt.where(AdRewardRecord.user_id == user_id)
return {uid: n for uid, n in db.execute(stmt).all()}
def _reward_video_rows(
db: Session, *, date: str, user_id: int | None
) -> list[dict]:
"""看视频记录复算。按 (user_id, created_at) 升序还原账号累计第 N 份(含本日之前的累计)。"""
stmt = (
select(AdRewardRecord)
.where(
AdRewardRecord.reward_date == date,
AdRewardRecord.reward_scene == "reward_video",
)
.order_by(AdRewardRecord.user_id, AdRewardRecord.created_at, AdRewardRecord.id)
)
if user_id is not None:
stmt = stmt.where(AdRewardRecord.user_id == user_id)
# 用本日之前的累计份数做起点,当日 granted 在其上继续递增 → 与 _granted_cumulative+1 对齐
granted_n: dict[int, int] = _prior_granted_counts(db, date=date, user_id=user_id)
rows: list[dict] = []
for rec in db.execute(stmt).scalars():
if rec.status == "granted":
nth = granted_n.get(rec.user_id, 0) + 1
granted_n[rec.user_id] = nth
expected = rewards.calculate_ad_reward_coin(rec.ecpm_raw, nth)
rows.append({
"scene": "reward_video",
"record_id": rec.id,
"user_id": rec.user_id,
"ad_session_id": rec.ad_session_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at,
"status": rec.status,
"ecpm": rec.ecpm_raw,
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
"units": 1,
"lt_index_start": nth,
"lt_index_end": nth,
"lt_factor_start": rewards.ad_lt_factor(nth),
"lt_factor_end": rewards.ad_lt_factor(nth),
"expected_coin": expected,
"actual_coin": rec.coin,
"matched": expected == rec.coin,
})
else:
# capped / ecpm_missing:不发金币,校验实发确为 0
rows.append({
"scene": "reward_video",
"record_id": rec.id,
"user_id": rec.user_id,
"ad_session_id": rec.ad_session_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at,
"status": rec.status,
"ecpm": rec.ecpm_raw,
"ecpm_factor": None,
"units": 1,
"lt_index_start": None,
"lt_index_end": None,
"lt_factor_start": None,
"lt_factor_end": None,
"expected_coin": 0,
"actual_coin": rec.coin,
"matched": rec.coin == 0,
})
return rows
def _feed_prior_granted_count(
db: Session, *, date: str, user_id: int | None
) -> dict[int, int]:
"""各用户在 date **之前** granted 的信息流**条数**累计,作为当日复算的 LT 序号起点。
与发奖侧 ad_feed_reward.granted_unit_total(COUNT status=granted)对齐:一条广告 = 1 份,
LT 按账号累计**条数**递进。**不再用 SUM(unit_count)**——那是「一条按时长折多份」的过时口径,
与现行发奖(每条 1 份)漂移,会让 unit_count>1 的记录复算虚高、对账恒「不符」。"""
stmt = (
select(
AdFeedRewardRecord.user_id,
func.count(),
)
.where(
AdFeedRewardRecord.reward_date < date,
AdFeedRewardRecord.status == "granted",
)
.group_by(AdFeedRewardRecord.user_id)
)
if user_id is not None:
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
return {uid: int(n) for uid, n in db.execute(stmt).all()}
def _feed_scene_matches(rec: AdFeedRewardRecord, scene: str | None) -> bool:
"""该信息流记录是否落入请求的展示筛选 scene。
- scene=="feed":ad_type in ("feed", NULL)(旧数据 NULL 视为 feed,向后兼容)
- scene=="draw":ad_type=="draw"
- scene=="feed_all":所有信息流(feed/draw/NULL 都要)——业务已全切 Draw 信息流,收益报表把「Draw 信息流」
当作整个信息流口径(含历史误标 feed/NULL),用它避免筛选漏历史。
- scene 为 None:不筛(两类都要)。
"""
if scene == "feed":
return rec.ad_type in (None, "feed")
if scene == "draw":
return rec.ad_type == "draw"
if scene == "feed_all":
return True
return True
def _feed_rows(
db: Session, *, date: str, user_id: int | None, scene: str | None = None
) -> list[dict]:
"""信息流记录复算。**每条 granted = 1 份**(与发奖同口径,不按 unit_count 累加),
LT 序号沿用账号累计**条数**(含本日之前)。
**关键:LT 因子账号累计按全表 granted 条数累计(feed+draw 共享同一发奖池/上限),不按 ad_type 拆分**——
故无论 scene 怎么筛展示,这里都遍历当日**全部**信息流记录维持 granted_count 累加;scene 只决定
哪些行被**留下展示**(由 _feed_scene_matches 判断),不影响累计基线,保证复算序号与正式发奖一致。
"""
stmt = (
select(AdFeedRewardRecord)
.where(AdFeedRewardRecord.reward_date == date)
.order_by(AdFeedRewardRecord.user_id, AdFeedRewardRecord.created_at, AdFeedRewardRecord.id)
)
if user_id is not None:
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
# 本日之前的累计**条数**做起点,与发奖侧 granted_unit_total(COUNT granted)对齐
granted_count: dict[int, int] = _feed_prior_granted_count(db, date=date, user_id=user_id)
rows: list[dict] = []
for rec in db.execute(stmt).scalars():
keep = _feed_scene_matches(rec, scene) # 累计照常推进,这里只决定是否展示本行
if rec.status == "granted":
# 一条广告 = 1 份(与 grant_feed_reward 同口径:看满一份即发该条满额,不按 unit_count 累加)。
# nth = 账号累计第几**条**(含本日之前),与发奖侧 granted_unit_total+1 对齐;累计照常推进
# (即便 scene 不匹配不展示也要 +1,保证序号与正式发奖一致)。
nth = granted_count.get(rec.user_id, 0) + 1
granted_count[rec.user_id] = nth
if not keep:
continue
expected = rewards.calculate_ad_reward_coin(rec.ecpm_raw, nth)
rows.append({
"scene": "feed",
"ad_type": rec.ad_type or "feed",
"feed_scene": rec.feed_scene,
"record_id": rec.id,
"user_id": rec.user_id,
"ad_session_id": rec.ad_session_id,
"trace_id": rec.trace_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at,
"status": rec.status,
"ecpm": rec.ecpm_raw,
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
"units": 1,
"lt_index_start": nth,
"lt_index_end": nth,
"lt_factor_start": rewards.ad_lt_factor(nth),
"lt_factor_end": rewards.ad_lt_factor(nth),
"expected_coin": expected,
"actual_coin": rec.coin,
"matched": expected == rec.coin,
})
else:
if not keep:
continue
rows.append({
"scene": "feed",
"ad_type": rec.ad_type or "feed",
"feed_scene": rec.feed_scene,
"record_id": rec.id,
"user_id": rec.user_id,
"ad_session_id": rec.ad_session_id,
"trace_id": rec.trace_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at,
"status": rec.status,
"ecpm": rec.ecpm_raw,
"ecpm_factor": None,
"units": rec.unit_count,
"lt_index_start": None,
"lt_index_end": None,
"lt_factor_start": None,
"lt_factor_end": None,
"expected_coin": 0,
"actual_coin": rec.coin,
"matched": rec.coin == 0,
})
return rows
def audit_rows(
db: Session, *, date: str, user_id: int | None, scene: str | None = None
) -> list[dict]:
"""当日逐条发奖复算行(未排序)。scene: None=两类 / "reward_video" / "feed" / "draw"
"feed""draw" 都查 ad_feed_reward_record(同一发奖表),按 ad_type 区分:feed 含历史 NULL,
draw 仅 ad_type=="draw"。信息流行额外带 `ad_type`/`feed_scene`,供收益报表区分比价/领券 Draw 收益。
每行含 `app_env`/`our_code_id`/`expected_coin`/`actual_coin` 等,供金币审计逐条对账,
也供广告收益报表把「应发/实发」按 用户×类型×应用×代码位 聚合(见 ad_revenue,复用同一复算口径)。
**LT 因子账号累计仍按全表 unit 累计(feed+draw 共享),scene 只筛展示,不拆累计。**
"""
rows: list[dict] = []
if scene in (None, "reward_video"):
rows.extend(_reward_video_rows(db, date=date, user_id=user_id))
if scene in (None, "feed", "draw", "feed_all"):
rows.extend(_feed_rows(db, date=date, user_id=user_id, scene=scene))
return rows
def ad_coin_audit(
db: Session,
*,
date: str,
user_id: int | None,
scene: str | None,
limit: int,
only_mismatch: bool = False,
) -> dict:
"""当日发奖复算。返回 {total, mismatch_count, truncated, items}。
scene: None=两类都要 / "reward_video" / "feed";only_mismatch=True 只展示不一致(✗)行。
关键:`total` 与 `mismatch_count` 在**全量**(截断前)上统计,故对账数字始终可信,不受 limit
影响;`items` 才是展示集(only_mismatch 时只取 ✗ 行)按 created_at 倒序截断到 limit。
份序号在全天数据上已算好,limit 只影响展示条数、不影响 expected 复算正确性。
"""
rows = audit_rows(db, date=date, user_id=user_id, scene=scene)
rows.sort(key=lambda r: (r["created_at"], r["record_id"]), reverse=True)
total = len(rows)
mismatch_count = sum(1 for r in rows if not r["matched"])
display = [r for r in rows if not r["matched"]] if only_mismatch else rows
return {
"total": total,
"mismatch_count": mismatch_count,
"truncated": len(display) > limit,
"items": display[:limit],
}
def formula_snapshot() -> dict:
"""当前公式参数快照(给前端展示规则参照)。直接读 rewards 常量,与发奖同源。"""
return {
"coin_per_yuan": rewards.COIN_PER_YUAN,
"feed_unit_seconds": FEED_REWARD_UNIT_SECONDS,
"ecpm_factor_tiers": [list(t) for t in rewards.AD_ECPM_FACTOR_TABLE],
"lt_factor_tiers": [list(t) for t in rewards.AD_LT_FACTOR_TABLE],
}
-445
View File
@@ -1,445 +0,0 @@
"""admin 广告收益报表:**逐条广告事件**列表(每行一次广告,含展示 + 发奖对账)。
只读。每行 = 一次广告事件(不再按用户聚合):
- **激励视频**:一次观看 = 1 条展示(ad_ecpm)+ 1 条发奖(ad_reward),按 ad_session_id 合并成一行,
直接给出 eCPM / 收益 + 状态 / 应发 / 实发 / 一致;点开看该条金币复算因子。
- **信息流(比价/领券)**:一次比价 / 一次领券 = 一条整场发奖(ad_feed_reward)一行,给出 eCPM /
发奖金币 + 应发 / 实发 / 一致;点开看金币复算因子。⚠️ draw 的逐条展示(ad_ecpm,impressionId 各自
独立、与整场发奖无公共键、无法归到「哪一次」)**不再单独占行**(2026-07 按「一次比价/领券放一块」调整)——
其展示数 / eCPM / 预估收益仍进全量统计(合计 / 趋势 / 分类大盘 / 穿山甲对照),只是主表不逐条铺开。
- 兜底:激励视频有展示无发奖(中途关 / 未达发奖)、有发奖无展示(未上报 eCPM)仍各自成行。
展示与收益来自 ad_ecpm_record(收益 = eCPM元 ÷ 1000);应发 / 实发金币复用金币审计逐条复算
(ad_audit.audit_rows,与正式发奖同一公式口径,不另写公式)。合计与对账在全量上统计,
不受 limit(只截断 items)影响。
每行带 ad_type(reward_video/feed/draw)与 feed_scene(comparison/coupon/welfare),供前端区分
「比价 Draw 收益」与「领券 Draw 收益」(比价/领券共用同一代码位,只能靠 feed_scene 分)。
⚠️ 局限:① 历史信息流/Draw 发奖 ad_type 为 NULL 的旧记录统一视为 feed(向后兼容);Draw 仅
ad_type=="draw" 的新记录单独成类。② 跨天 S2S 回调:同一次广告的展示与发奖偶尔落相邻日,各自按
report_date / reward_date 归日。
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from datetime import date as _date
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.admin.repositories import ad_audit
from app.admin.repositories import stats as admin_stats
from app.core import rewards
from app.models.ad_ecpm import AdEcpmRecord
from app.models.user import User
from app.repositories import ad_pangle_revenue
def _cn_hour(dt: datetime) -> int:
"""created_at(UTC 口径)→ 北京时间小时(023)。naive 当 UTC 处理(sqlite),tz-aware 直接换算(pg)。"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return dt.astimezone(rewards.CN_TZ).hour
def _date_range(date_from: str, date_to: str) -> list[str]:
"""闭区间内逐日 'YYYY-MM-DD' 串(含首尾)。date_from > date_to 时返回空。"""
d0 = _date.fromisoformat(date_from)
d1 = _date.fromisoformat(date_to)
out: list[str] = []
d = d0
while d <= d1:
out.append(d.isoformat())
d += timedelta(days=1)
return out
# 报表 ad_type 与审计 scene 取值一致(reward_video / feed / draw):feed 与 draw 同查发奖表
# ad_feed_reward_record,由 audit 内部按 ad_type 区分(feed 含历史 NULL,draw 仅 ad_type=="draw")。
_AUDIT_SCENES = {"reward_video", "feed", "draw"}
# 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。
_REWARD_DETAIL_KEYS = (
"record_id", "created_at", "status", "ecpm", "ecpm_factor", "units",
"lt_index_start", "lt_index_end", "lt_factor_start", "lt_factor_end",
"expected_coin", "actual_coin", "matched",
)
def _reward_detail(row: dict) -> dict:
"""从 audit 行抽出发奖复算明细(给前端展开行渲染因子1/因子2/份数/LT/应发实发)。"""
return {k: row[k] for k in _REWARD_DETAIL_KEYS}
def ad_revenue_report(
db: Session,
*,
date_from: str,
date_to: str,
user_id: int | None = None,
ad_type: str | None = None,
feed_scene: str | None = None,
app_env: str | None = None,
granularity: str = "day",
limit: int = 500,
offset: int = 0,
sort: str = "time",
) -> dict:
"""日期区间(北京时间,闭区间)**逐条广告事件**列表 + 发奖对账。单日时 date_from==date_to。
每个 item = 一次广告事件(展示与发奖按 ad_session_id 合并;信息流展示 / 发奖各自成行)。
ad_type: None=全部 / reward_video / feed / draw。feed_scene: None=全部 /
comparison / coupon / welfare,作为全局筛选(同时作用于明细、合计与 daily/hourly 趋势)。
granularity=hour 时每行带北京小时(由各自时间算),并额外返回全量 hourly 序列。
事件按时间倒序(新→旧)排列;limit/offset 对排序后的全量做分页切片(items 为当前页),
total 与 total_* / daily / hourly 在全量上统计,不受分页影响。
"""
by_hour = granularity == "hour"
# 1) 发奖行(逐日 audit 复算):建 (user_id, ad_session_id) → [行] 映射用于和展示合并;
# 同时保留全量列表,未被展示合并的成「纯发奖」事件。
reward_by_session: dict[tuple[int, str], list[dict]] = {}
all_reward_rows: list[dict] = []
# 报表 ad_type → audit scene:reward_video/feed 直传;**draw(前端「Draw 信息流」)映射成 feed_all**
# ——业务已全切 Draw,把「Draw 信息流」当作整个信息流口径(含历史误标 feed/NULL),否则筛选会漏历史。
if ad_type == "draw":
audit_scene = "feed_all"
elif ad_type in _AUDIT_SCENES:
audit_scene = ad_type
else:
audit_scene = None
if ad_type is None or audit_scene is not None:
for d in _date_range(date_from, date_to):
for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene):
row["_report_date"] = d
all_reward_rows.append(row)
sid = row.get("ad_session_id")
if sid:
reward_by_session.setdefault((row["user_id"], sid), []).append(row)
used_reward_ids: set[int] = set()
events: list[dict] = []
def _pop_reward(uid: int, sid: str | None) -> dict | None:
"""取一条与 (uid, sid) 匹配且未被用过的发奖行(激励视频展示↔发奖按会话 1:1 合并)。"""
if not sid:
return None
for r in reward_by_session.get((uid, sid), ()):
if r["record_id"] not in used_reward_ids:
used_reward_ids.add(r["record_id"])
return r
return None
# 2) 展示记录(ad_ecpm):每条一个事件;能匹配到发奖则合并成「展示 + 发奖」一行。
stmt = select(AdEcpmRecord).where(
AdEcpmRecord.report_date >= date_from,
AdEcpmRecord.report_date <= date_to,
)
if user_id is not None:
stmt = stmt.where(AdEcpmRecord.user_id == user_id)
if ad_type == "draw":
# draw = 所有信息流展示(业务已全 Draw,含历史误标 feed);展示行只进统计,不占主表行
stmt = stmt.where(AdEcpmRecord.ad_type.in_(["draw", "feed"]))
elif ad_type is not None:
stmt = stmt.where(AdEcpmRecord.ad_type == ad_type)
for rec in db.execute(stmt).scalars():
rwd = _pop_reward(rec.user_id, rec.ad_session_id)
ev = {
"event_key": f"imp-{rec.id}",
"report_date": rec.report_date,
"user_id": rec.user_id,
"ad_type": rec.ad_type,
"feed_scene": rec.feed_scene,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at,
"hour": _cn_hour(rec.created_at) if by_hour else None,
"has_impression": True,
"impressions": 1,
"ecpm": rec.ecpm_raw,
# 单次展示收益(元)= eCPM元 ÷ 1000(每千次→单次)。eCPM 先钳到 AD_ECPM_MAX_FEN(¥500 CPM)
# 再折收益,与发奖口径 [rewards.calculate_ad_reward_coin] 一致(2026-06-29 修:原裸 parse_ecpm_yuan
# 不钳,伪造/异常天价 eCPM 会把报表预估收益冲到任意大;金币侧已钳、收益侧漏钳)。
"revenue_yuan": round(
min(rewards.parse_ecpm_yuan(rec.ecpm_raw), rewards.AD_ECPM_MAX_FEN / 100.0) / 1000.0, 6,
),
"adn": rec.adn,
"slot_id": rec.slot_id,
"sub_rewards": [],
"sub_count": 1,
}
if rwd is not None:
ev.update({
"has_reward": True,
"status": rwd["status"],
"expected_coin": int(rwd["expected_coin"]),
"actual_coin": int(rwd["actual_coin"]),
"matched": bool(rwd["matched"]),
"reward_detail": _reward_detail(rwd),
})
else:
# 纯展示(信息流逐条展示、激励视频缺发奖记录):不计对账,matched=True。
ev.update({
"has_reward": False, "status": None,
"expected_coin": 0, "actual_coin": 0, "matched": True,
"reward_detail": None,
})
events.append(ev)
# 3) 未被展示合并的发奖行 → 事件:
# - 激励视频(reward_video):逐条成「纯发奖」事件(每次一个 ad_session_id;有发奖无展示等)。
# - 信息流(feed/draw):同一次比价/领券的多条广告共享**整场 ad_session_id**(客户端整场复用),
# 按 (user_id, ad_session_id) 聚成**一次比价 / 一次领券**父事件;sub_rewards 为组内逐条明细,
# 应发/实发取组内合计;业务已全 Draw → 类型统一 "draw"。session 缺失(极少旧数据)各自单独成组。
feed_groups: dict[tuple[int, str], list[dict]] = {}
for row in all_reward_rows:
if row["record_id"] in used_reward_ids:
continue
if row["scene"] == "reward_video":
events.append({
"event_key": f"rwd-{row['record_id']}",
"report_date": row["_report_date"],
"user_id": row["user_id"],
"ad_type": "reward_video",
"feed_scene": row.get("feed_scene"),
"app_env": row.get("app_env"),
"our_code_id": row.get("our_code_id"),
"created_at": row["created_at"],
"hour": _cn_hour(row["created_at"]) if by_hour else None,
"has_impression": False,
"impressions": 0,
"ecpm": row["ecpm"],
"revenue_yuan": 0.0,
"adn": None,
"slot_id": None,
"has_reward": True,
"status": row["status"],
"expected_coin": int(row["expected_coin"]),
"actual_coin": int(row["actual_coin"]),
"matched": bool(row["matched"]),
"reward_detail": _reward_detail(row),
"sub_rewards": [],
"sub_count": 1,
})
else:
# 聚合单位 = 一次完整比价/领券流程:优先用 trace_id(比价带 comparisonTraceId、领券带 sessionTraceId,
# 整个流程不变;即使中途点广告致浮层关闭重弹、ad_session_id 变了,trace_id 仍不变 → 全流程聚成一行)。
# 无 trace_id(历史领券未上报 / 旧数据)回退整场 ad_session_id;再无则 record_id 各自成组、不误并。
grp_key = row.get("trace_id") or row.get("ad_session_id") or f"_rid-{row['record_id']}"
feed_groups.setdefault((row["user_id"], grp_key), []).append(row)
# 信息流分组 → 「一次比价 / 一次领券」父事件(收益恒 0:收益只算展示侧,避免与展示行重复计)。
for (uid, grp_key), group in feed_groups.items():
group.sort(key=lambda r: (r["created_at"], r["record_id"]))
rep = group[-1] # 代表条(最新一条):时间/场景/应用/代码位取它
expected_sum = sum(int(g["expected_coin"]) for g in group)
actual_sum = sum(int(g["actual_coin"]) for g in group)
# 父行 eCPM:组内各条 eCPM(分)均值(展示用,各条不同);无有效值则取代表条
ecpm_fens = [rewards.parse_ecpm_fen(g["ecpm"]) for g in group if g.get("ecpm")]
avg_ecpm = str(round(sum(ecpm_fens) / len(ecpm_fens))) if ecpm_fens else rep.get("ecpm")
# 主表逐行显示用:这次发奖广告的预估收益之和(发奖侧 eCPM 折算,钳顶同展示侧)。只放进
# row_revenue_yuan 给主表逐行展示,不进 revenue_yuan/合计/趋势——避免与展示侧 total 重复计。
row_revenue = round(sum(
min(rewards.parse_ecpm_yuan(g["ecpm"]), rewards.AD_ECPM_MAX_FEN / 100.0) / 1000.0
for g in group if g.get("ecpm")
), 6)
events.append({
"event_key": f"feedgrp-{uid}-{grp_key}",
"report_date": rep["_report_date"],
"user_id": uid,
"ad_type": "draw", # 业务已全切 Draw 信息流,聚合行统一 draw
"feed_scene": rep.get("feed_scene"),
"app_env": rep.get("app_env"),
"our_code_id": rep.get("our_code_id"),
"created_at": rep["created_at"],
"hour": _cn_hour(rep["created_at"]) if by_hour else None,
"has_impression": False,
"impressions": 0,
"ecpm": avg_ecpm,
"revenue_yuan": 0.0,
"row_revenue_yuan": row_revenue,
"adn": None,
"slot_id": None,
"has_reward": True,
"status": rep["status"], # 代表状态(逐条见展开)
"expected_coin": expected_sum,
"actual_coin": actual_sum,
"matched": all(bool(g["matched"]) for g in group),
"reward_detail": None,
"sub_rewards": [_reward_detail(g) for g in group],
"sub_count": len(group),
})
# 「场景」作为全局筛选(与 user_id/ad_type 一致):同时作用于明细、合计与 daily/hourly 趋势。
# feed_scene 仅信息流 / Draw 有值,激励视频与旧数据为 None;选中后只保留该场景事件。
if feed_scene is not None:
events = [e for e in events if e.get("feed_scene") == feed_scene]
# app_env 过滤(2026-06-29 新增能力,修隐患:测试应用上报的假 eCPM 如 ¥678 CPM 会污染正式收益合计/平均):
# 显式传 "prod"/"test" 只看该环境;不传=全部(维持现状)。**不擅自把默认改成排除 test**——本地 dev 库多为
# test 数据、默认排除会使本地报表空,且「正式报表是否含 test」属产品口径。建议前端报表页加 app_env 筛选器
# (默认选 prod),或产品确认后再把默认改成排除 test。注:穿山甲后台收益列(total_pangle_*)暂未联动此过滤
# (它是独立对照列,且 pangle 的 test 是真实小额、非客户端那种假值)。
if app_env is not None:
events = [e for e in events if e.get("app_env") == app_env]
# 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排;
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
if sort == "ecpm":
events.sort(key=lambda e: rewards.parse_ecpm_fen(e["ecpm"]), reverse=True)
else:
events.sort(key=lambda e: (e["report_date"], e["created_at"]), reverse=True)
# 补手机号(admin 展示用,完整不脱敏,与用户 / 钱包 / 比价记录页一致):批量一次查,避免 N+1。
uids = {e["user_id"] for e in events}
phone_map: dict[int, str] = {}
if uids:
phone_map = {
uid: phone
for uid, phone in db.execute(
select(User.id, User.phone).where(User.id.in_(uids))
).all()
}
for e in events:
e["user_phone"] = phone_map.get(e["user_id"])
total_impressions = sum(e["impressions"] for e in events)
total_revenue_yuan = round(sum(e["revenue_yuan"] for e in events), 6)
total_expected_coin = sum(e["expected_coin"] for e in events)
total_actual_coin = sum(e["actual_coin"] for e in events)
mismatch_count = sum(1 for e in events if e["has_reward"] and not e["matched"])
# 按日期汇总(全量,不受 limit):供前端按天趋势图。
daily_map: dict[str, dict] = {}
for e in events:
d = daily_map.get(e["report_date"])
if d is None:
d = {"date": e["report_date"], "impressions": 0, "revenue_yuan": 0.0,
"expected_coin": 0, "actual_coin": 0}
daily_map[e["report_date"]] = d
d["impressions"] += e["impressions"]
d["revenue_yuan"] += e["revenue_yuan"]
d["expected_coin"] += e["expected_coin"]
d["actual_coin"] += e["actual_coin"]
daily = [
{**d, "revenue_yuan": round(d["revenue_yuan"], 6)}
for d in sorted(daily_map.values(), key=lambda x: x["date"])
]
# 穿山甲后台收益(GroMore 数据 API,T+1 入库 ad_pangle_daily_revenue):汇总 + 按天趋势级展示,
# 与上面客户端自报 eCPM 折算的预估并列对照(看 gap)。穿山甲数据**无用户/场景/类型维度**,故仅在
# 「全量视图」(未按 user_id / ad_type / feed_scene 过滤)给值;一旦带这些过滤,穿山甲数无法对应口径
# → 置 None,前端显示「-」并提示。逐条事件行不动(仍是客户端预估)。
pangle_filterable = user_id is None and ad_type is None and feed_scene is None
total_pangle_revenue_yuan: float | None = None
total_pangle_api_revenue_yuan: float | None = None
if pangle_filterable:
pangle_aggs = ad_pangle_revenue.aggregate_by_date(db, date_from=date_from, date_to=date_to)
if pangle_aggs:
by_date = {a["date"]: a for a in pangle_aggs}
for d in daily:
pa = by_date.get(d["date"])
d["pangle_revenue_yuan"] = pa["revenue_yuan"] if pa else None
d["pangle_api_revenue_yuan"] = pa["api_revenue_yuan"] if pa else None
total_pangle_revenue_yuan = round(sum(a["revenue_yuan"] for a in pangle_aggs), 6)
api_vals = [a["api_revenue_yuan"] for a in pangle_aggs if a["api_revenue_yuan"] is not None]
total_pangle_api_revenue_yuan = round(sum(api_vals), 6) if api_vals else None
# 按小时汇总(全量,不受分页 limit/offset 影响):供前端按小时趋势图(单日 granularity=hour 时用)。
# 只在 by_hour 下聚合(此时每个 event 带 hour);否则空。前端按天趋势仍用 daily。
hourly: list[dict] = []
if by_hour:
hour_map: dict[int, dict] = {}
for e in events:
h = e["hour"]
if h is None:
continue
hd = hour_map.get(h)
if hd is None:
hd = {"hour": h, "impressions": 0, "revenue_yuan": 0.0,
"expected_coin": 0, "actual_coin": 0}
hour_map[h] = hd
hd["impressions"] += e["impressions"]
hd["revenue_yuan"] += e["revenue_yuan"]
hd["expected_coin"] += e["expected_coin"]
hd["actual_coin"] += e["actual_coin"]
hourly = [
{**hd, "revenue_yuan": round(hd["revenue_yuan"], 6)}
for hd in sorted(hour_map.values(), key=lambda x: x["hour"])
]
# 分广告类型小计(按 ad_type:展示条数 + 预估收益;eCPM 由前端用 收益÷展示×1000 算)。
# 基于全量(已按 feed_scene 过滤)events;前端只取 draw / reward_video 两类展示。
type_map: dict[str, dict] = {}
for e in events:
t = type_map.get(e["ad_type"])
if t is None:
t = {"impressions": 0, "revenue_yuan": 0.0}
type_map[e["ad_type"]] = t
t["impressions"] += e["impressions"]
t["revenue_yuan"] += e["revenue_yuan"]
type_stats = {
k: {"impressions": v["impressions"], "revenue_yuan": round(v["revenue_yuan"], 6)}
for k, v in type_map.items()
}
# 分场景小计(按 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 聚成一行(展开看逐条)。
# 信息流(draw/feed)的逐条展示(ad_ecpm,impressionId 各自独立、与整场发奖无公共键)不再单独占行
# ——其展示数 / eCPM / 预估收益已计入上面的全量统计(total_*、daily / hourly、type_stats、穿山甲对照),
# 只是主表不逐条铺开;逐条明细在父行展开里看(sub_rewards)。合计 / 趋势 / 分类大盘均基于全量 events,
# 不受此过滤影响;total / 分页只作用于主表行。
main_rows = [
e for e in events
if not (e["ad_type"] in ("draw", "feed") and e["has_impression"] and not e["has_reward"])
]
return {
"total": len(main_rows),
"truncated": len(main_rows) > offset + limit,
"total_impressions": total_impressions,
"total_revenue_yuan": total_revenue_yuan,
# 穿山甲后台收益合计(元):预估 revenue + 收益Api;非全量视图(带 user/类型/场景过滤)或无数据为 None。
"total_pangle_revenue_yuan": total_pangle_revenue_yuan,
"total_pangle_api_revenue_yuan": total_pangle_api_revenue_yuan,
"pangle_revenue_available": total_pangle_revenue_yuan is not None,
"total_expected_coin": total_expected_coin,
"total_actual_coin": total_actual_coin,
"mismatch_count": mismatch_count,
"daily": daily,
"hourly": hourly,
"type_stats": type_stats,
"scene_stats": scene_stats,
"dau": dau,
"items": main_rows[offset:offset + limit],
}
-81
View File
@@ -1,81 +0,0 @@
"""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)
+1 -12
View File
@@ -20,23 +20,12 @@ def get_by_username(db: Session, username: str) -> AdminUser | None:
def create_admin(
db: Session,
*,
username: str,
password: str,
role: str = "operator",
plain_password: str | None = None,
pages_override: list[str] | None = None,
db: Session, *, username: str, password: str, role: str = "operator"
) -> 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()
+11 -14
View File
@@ -4,7 +4,7 @@
"""
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.admin import AdminAuditLog
@@ -49,9 +49,8 @@ def list_audit_logs(
admin_id: int | None = None,
limit: int = 50,
cursor: int | None = None,
) -> tuple[list[AdminAuditLog], int | None, int]:
"""offset 分页(id 倒序)+ total。cursor 即 offset((page-1)*pageSize),支持页码跳页。
返回 (rows, next_cursor, total)。"""
) -> tuple[list[AdminAuditLog], int | None]:
"""游标分页(id 倒序),与现有 list_* 约定一致。返回 (rows, next_cursor)。"""
stmt = select(AdminAuditLog)
if action:
stmt = stmt.where(AdminAuditLog.action == action)
@@ -59,15 +58,13 @@ def list_audit_logs(
stmt = stmt.where(AdminAuditLog.target_type == target_type)
if admin_id is not None:
stmt = stmt.where(AdminAuditLog.admin_id == admin_id)
total = int(db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one())
offset = max(cursor or 0, 0)
rows = list(
db.execute(
stmt.order_by(AdminAuditLog.id.desc()).offset(offset).limit(limit + 1)
).scalars().all()
)
if cursor is not None:
stmt = stmt.where(AdminAuditLog.id < cursor)
stmt = stmt.order_by(AdminAuditLog.id.desc())
rows = list(db.execute(stmt.limit(limit + 1)).scalars().all())
has_more = len(rows) > limit
items = rows[:limit]
next_cursor = offset + limit if has_more else None
return items, next_cursor, total
# next_cursor 必须是"本页返回的最后一条"的 id(下一页查 id < 它),不能用 rows[limit]——
# rows[limit] 是探测下一页用的第 limit+1 条,它既不在本页也不在下页 → 每页边界丢一条。
next_cursor = items[-1].id if has_more else None
return items, next_cursor
-225
View File
@@ -1,225 +0,0 @@
"""admin「领券数据」看板聚合:发起/完成数、耗时均值与分位、按天/小时趋势、逐条明细。
数据源 coupon_session(一次领券一行,客户端 /api/v1/coupon/session 两段上报)。量级不大,全量拉
区间数据后 Python 聚合(分位 SQLite 无 percentile,统一 Python 算,PG 上也一致)。
- 发起数 = 区间内全部 session(含 started/completed/failed/abandoned),= 流失统计的基数。
- 完成数 / 耗时均值 / 分位 = 仅 status==completed 子集(成功跑完才有可比的"领券耗时")。
- summary/daily/hourly/total 在全量上算,不受分页;items 为排序后当前页。
"""
from __future__ import annotations
from datetime import UTC, date as _date, datetime
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.core import rewards
from app.models.coupon_state import CouponSession
from app.models.user import User
def _cn_hour(dt: datetime) -> int:
"""started_at(UTC 口径)→ 北京时间小时(023)。naive 当 UTC(sqlite),tz-aware 直接换算(pg)。"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return dt.astimezone(rewards.CN_TZ).hour
def _percentile(sorted_vals: list[int], q: float) -> int | None:
"""线性插值分位(q=0..100,numpy 默认法)。sorted_vals 须已升序;空返回 None。"""
if not sorted_vals:
return None
if len(sorted_vals) == 1:
return sorted_vals[0]
idx = (len(sorted_vals) - 1) * q / 100.0
lo = int(idx)
hi = min(lo + 1, len(sorted_vals) - 1)
frac = idx - lo
return round(sorted_vals[lo] * (1 - frac) + sorted_vals[hi] * frac)
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:
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
return {
"id": r.id,
"trace_id": r.trace_id,
"user_id": r.user_id,
"user_phone": phone,
"user_nickname": nickname,
"status": r.status,
"platforms": r.platforms,
"origin_package": r.origin_package,
"elapsed_ms": r.elapsed_ms,
"platform_elapsed": r.platform_elapsed,
"device_model": r.device_model,
"rom": r.rom,
"app_env": r.app_env,
"started_at": r.started_at,
"claimed_count": r.claimed_count,
"trace_url": r.trace_url,
}
def _empty_result() -> dict:
return {
"summary": {
"started_count": 0, "completed_count": 0, "avg_elapsed_ms": None,
"p5_ms": None, "p50_ms": None, "p95_ms": None, "p99_ms": None,
},
"daily": [],
"hourly": [],
"total": 0,
"items": [],
}
def coupon_data_report(
db: Session,
*,
date_from: str,
date_to: str,
user: str | None = None,
app_env: str | None = None,
granularity: str = "day",
limit: int = 500,
offset: int = 0,
sort: str = "time",
) -> dict:
"""日期区间(北京自然日 started_date,闭区间)领券数据:汇总卡 + 趋势 + 逐条明细。
- user:手机号/昵称模糊搜(匹配不到任何用户 → 空结果)。
- app_env:prod/dev 精确;None=全部。
- sort:time=发起时刻倒序(默认) / elapsed=全程耗时倒序(None 末尾)。
"""
by_hour = granularity == "hour"
d_from = _date.fromisoformat(date_from)
d_to = _date.fromisoformat(date_to)
# user 模糊 → 先定位匹配用户 id;匹配不到直接空结果(不全表扫)。
user_ids: set[int] | None = None
if user:
like = f"%{user}%"
user_ids = set(db.execute(
select(User.id).where(or_(User.phone.like(like), User.nickname.like(like)))
).scalars().all())
if not user_ids:
return _empty_result()
stmt = select(CouponSession).where(
CouponSession.started_date >= d_from,
CouponSession.started_date <= d_to,
)
if app_env is not None:
stmt = stmt.where(CouponSession.app_env == app_env)
if user_ids is not None:
stmt = stmt.where(CouponSession.user_id.in_(user_ids))
rows = list(db.execute(stmt).scalars())
# ── 汇总卡 ──
completed_elapsed = sorted(
r.elapsed_ms for r in rows if r.status == "completed" and r.elapsed_ms is not None
)
summary = {
"started_count": len(rows),
"completed_count": sum(1 for r in rows if r.status == "completed"),
"avg_elapsed_ms": _avg(completed_elapsed),
"p5_ms": _percentile(completed_elapsed, 5),
"p50_ms": _percentile(completed_elapsed, 50),
"p95_ms": _percentile(completed_elapsed, 95),
"p99_ms": _percentile(completed_elapsed, 99),
}
# ── 按天趋势(柱=发起/完成数,线=平均耗时)──
daily_map: dict[str, dict] = {}
for r in rows:
d = r.started_date.isoformat()
b = daily_map.get(d)
if b is None:
b = {"date": d, "started_count": 0, "completed_count": 0, "_elapsed": []}
daily_map[d] = b
b["started_count"] += 1
if r.status == "completed":
b["completed_count"] += 1
if r.elapsed_ms is not None:
b["_elapsed"].append(r.elapsed_ms)
daily = [
{
"date": b["date"],
"started_count": b["started_count"],
"completed_count": b["completed_count"],
"avg_elapsed_ms": _avg(b["_elapsed"]),
}
for b in sorted(daily_map.values(), key=lambda x: x["date"])
]
# ── 按小时趋势(单日 hour 粒度)──
hourly: list[dict] = []
if by_hour:
hour_map: dict[int, dict] = {}
for r in rows:
h = _cn_hour(r.started_at)
b = hour_map.get(h)
if b is None:
b = {"hour": h, "started_count": 0, "completed_count": 0, "_elapsed": []}
hour_map[h] = b
b["started_count"] += 1
if r.status == "completed":
b["completed_count"] += 1
if r.elapsed_ms is not None:
b["_elapsed"].append(r.elapsed_ms)
hourly = [
{
"hour": b["hour"],
"started_count": b["started_count"],
"completed_count": b["completed_count"],
"avg_elapsed_ms": _avg(b["_elapsed"]),
}
for b in sorted(hour_map.values(), key=lambda x: x["hour"])
]
# ── 明细:排序 + 分页 + 补用户手机号/昵称(批量,防 N+1)──
if sort == "elapsed":
rows.sort(key=lambda r: (r.elapsed_ms is None, -(r.elapsed_ms or 0)))
else: # time:发起时刻倒序
rows.sort(key=lambda r: r.started_at, reverse=True)
page = rows[offset:offset + limit]
uids = {r.user_id for r in page if r.user_id is not None}
user_map: dict[int, tuple[str | None, str | None]] = {}
if uids:
user_map = {
uid: (phone, nickname)
for uid, phone, nickname in db.execute(
select(User.id, User.phone, User.nickname).where(User.id.in_(uids))
).all()
}
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))
return {
"summary": summary,
"daily": daily,
"hourly": hourly,
"total": len(rows),
"items": items,
}
def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict:
"""某用户全部领券记录(点手机号抽屉用):按发起时刻倒序、不限日期,total=该用户领券总次数。"""
rows = list(db.execute(
select(CouponSession)
.where(CouponSession.user_id == user_id)
.order_by(CouponSession.started_at.desc())
.limit(limit)
).scalars())
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)}
-794
View File
@@ -1,794 +0,0 @@
"""admin CPS 数据访问:群/活动 CRUD + 美团订单拉单入库(对账) + 按群统计聚合。
美团 query_order 返回的金额是「元」字符串、时间是秒级时间戳,这里统一转「分」+ tz-aware。
统计在 Python 侧聚合(订单量级小、admin 低频),逻辑清晰、跨 PG/SQLite 无 SQL 方言坑。
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
from typing import Any
from uuid import uuid4
from sqlalchemy import desc, func, select
from sqlalchemy.orm import Session
from app.admin.repositories.queries import _as_utc, offset_paginate
from app.integrations import jd_union, meituan
from app.repositories import cps_link as cps_link_repo
from app.models.cps_activity import CpsActivity
from app.models.cps_group import CpsGroup
from app.models.cps_link import CpsClick, CpsLink
from app.models.cps_order import CpsOrder
from app.models.cps_wx_user import CpsWxUser
# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账
_INVALID_STATUS = {"4", "5"}
_SETTLED_STATUS = "6"
_JD_INVALID_CODES = {
"2", "3", "4", "5", "6", "7", "8", "9", "11", "13", "14", "19", "20", "21",
"22", "23", "25", "26", "27", "28", "29", "30", "31", "34", "35", "36",
}
_JD_UNPAID_CODES = {"15"}
# CPS 点击时序按北京时区分桶(运营看的是北京时间)
_BJ_TZ = timezone(timedelta(hours=8))
# ───────────── 单位换算 ─────────────
def _yuan_to_cents(v: object) -> int | None:
"""「元」字符串/数 → 分。None / 空 / 字面 "null" → None。"""
if v is None:
return None
s = str(v).strip()
if not s or s.lower() == "null":
return None
try:
return int((Decimal(s) * 100).to_integral_value())
except (InvalidOperation, ValueError):
return None
def _ts_to_dt(ts: object) -> datetime | None:
"""秒级时间戳 → tz-aware UTC datetime(绝对时刻,前端按北京展示)。"""
if 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
def _jd_dt_to_utc(value: object) -> datetime | None:
"""京东时间字符串(北京时间) → UTC aware datetime。"""
if value is None:
return None
s = str(value).strip()
if not s:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
try:
dt = datetime.strptime(s, fmt)
return dt.replace(tzinfo=_BJ_TZ).astimezone(timezone.utc)
except ValueError:
continue
return None
def _text(value: object) -> str | None:
if value is None:
return None
s = str(value).strip()
return s or None
def _pick(row: dict[str, Any], *keys: str) -> Any:
for key in keys:
if key in row and row[key] is not None:
return row[key]
return None
# ───────────── 群 ─────────────
def get_group(db: Session, group_id: int) -> CpsGroup | None:
return db.get(CpsGroup, group_id)
def get_group_by_sid(db: Session, sid: str) -> CpsGroup | None:
return db.execute(select(CpsGroup).where(CpsGroup.sid == sid)).scalar_one_or_none()
def list_groups(
db: Session, *, keyword: str | None = None, status: str | None = None,
limit: int = 20, cursor: int | None = None,
) -> tuple[list[CpsGroup], int | None, int]:
stmt = select(CpsGroup)
if keyword and keyword.strip():
kw = f"%{keyword.strip()}%"
stmt = stmt.where(CpsGroup.name.ilike(kw) | CpsGroup.sid.ilike(kw))
if status:
stmt = stmt.where(CpsGroup.status == status)
return offset_paginate(db, stmt, (desc(CpsGroup.id),), limit=limit, cursor=cursor)
def create_group(
db: Session, *, name: str, platforms: list[str], sid: str | None = None,
member_count: int | None = None, remark: str | None = None, commit: bool = True,
) -> CpsGroup:
"""建群。含 meituan 才有 sid(留空自动 g<id>);纯淘宝/京东 sid=None(那俩不支持 sid)。"""
has_meituan = "meituan" in (platforms or [])
# 含美团:用填的 sid,或临时占位待回填 g<id>;不含美团:sid 恒 None
seed_sid = (sid or f"tmp{uuid4().hex[:20]}") if has_meituan else None
group = CpsGroup(
name=name,
platforms=list(platforms or []),
sid=seed_sid,
member_count=member_count,
remark=remark,
)
db.add(group)
db.flush()
if has_meituan and not sid:
group.sid = f"g{group.id}"
db.flush()
if commit:
db.commit()
db.refresh(group)
return group
def update_group(
db: Session, group: CpsGroup, *, name: str | None = None,
platforms: list[str] | None = None,
member_count: int | None = None, status: str | None = None,
remark: str | None = None, commit: bool = True,
) -> CpsGroup:
if name is not None:
group.name = name
if platforms is not None:
group.platforms = list(platforms)
if member_count is not None:
group.member_count = member_count
if status is not None:
group.status = status
if remark is not None:
group.remark = remark
if commit:
db.commit()
db.refresh(group)
else:
db.flush()
return group
def delete_group(db: Session, group: CpsGroup, *, commit: bool = True) -> None:
"""硬删群。不级联删已生成的 cps_link(已发链接继续可用);该群历史点击/订单按 sid 仍在库。"""
db.delete(group)
if commit:
db.commit()
else:
db.flush()
# ───────────── 活动 ─────────────
def get_activity(db: Session, activity_id: int) -> CpsActivity | None:
return db.get(CpsActivity, activity_id)
def list_activities(
db: Session, *, platform: str | None = None, status: str | None = None,
limit: int = 20, cursor: int | None = None,
) -> tuple[list[CpsActivity], int | None, int]:
stmt = select(CpsActivity)
if platform:
stmt = stmt.where(CpsActivity.platform == platform)
if status:
stmt = stmt.where(CpsActivity.status == status)
return offset_paginate(db, stmt, (desc(CpsActivity.id),), limit=limit, cursor=cursor)
def list_activity_images(db: Session) -> list[str]:
"""已用过的活动落地页图(distinct 非空 image_url),供新建活动复用选择。"""
rows = (
db.execute(
select(CpsActivity.image_url)
.where(CpsActivity.image_url.is_not(None))
.distinct()
)
.scalars()
.all()
)
return [r for r in rows if r]
def create_activity(
db: Session, *, name: str, platform: str = "meituan", act_id: str | None = None,
product_view_sign: str | None = None, payload: str | None = None,
image_url: str | None = None, remark: str | None = None, commit: bool = True,
) -> CpsActivity:
activity = CpsActivity(
name=name, platform=platform, act_id=act_id,
product_view_sign=product_view_sign, payload=payload,
image_url=image_url, remark=remark,
)
db.add(activity)
if commit:
db.commit()
db.refresh(activity)
else:
db.flush()
return activity
def update_activity(
db: Session, activity: CpsActivity, *, name: str | None = None,
platform: str | None = None, act_id: str | None = None,
product_view_sign: str | None = None, payload: str | None = None,
image_url: str | None = None, remark: str | None = None,
status: str | None = None, commit: bool = True,
) -> CpsActivity:
if name is not None:
activity.name = name
if platform is not None:
activity.platform = platform
if act_id is not None:
activity.act_id = act_id
if product_view_sign is not None:
activity.product_view_sign = product_view_sign
if payload is not None:
activity.payload = payload
if image_url is not None:
activity.image_url = image_url
if remark is not None:
activity.remark = remark
if status is not None:
activity.status = status
if commit:
db.commit()
db.refresh(activity)
else:
db.flush()
return activity
def delete_activity(db: Session, activity: CpsActivity, *, commit: bool = True) -> None:
"""硬删活动。不级联删 cps_link(已发链接继续可用;淘宝落地页缺活动时取图走兜底默认图)。"""
db.delete(activity)
if commit:
db.commit()
else:
db.flush()
# ───────────── 订单(对账) ─────────────
def _map_order_fields(r: dict) -> dict:
"""美团 query_order 单条 dataList → CpsOrder 字段(金额转分、时间转 datetime)。"""
pn = r.get("productName")
if pn and len(pn) > 500:
pn = pn[:500]
return {
"sid": (r.get("sid") or None),
"act_id": str(r["actId"]) if r.get("actId") is not None else None,
"biz_line": r.get("businessLine"),
"trade_type": r.get("tradeType"),
"pay_price_cents": _yuan_to_cents(r.get("payPrice")),
"commission_cents": _yuan_to_cents(r.get("profit")),
"commission_rate": str(r["commissionRate"]) if r.get("commissionRate") is not None else None,
"refund_price_cents": _yuan_to_cents(r.get("refundPrice")),
"refund_profit_cents": _yuan_to_cents(r.get("refundProfit")),
"mt_status": str(r["status"]) if r.get("status") is not None else None,
"invalid_reason": (r.get("invalidReason") or None),
"product_name": pn or None,
"pay_time": _ts_to_dt(r.get("payTime")),
"mt_update_time": _ts_to_dt(r.get("updateTime")),
"raw": r,
}
def _jd_order_key(r: dict[str, Any]) -> str | None:
row_id = _text(_pick(r, "id", "rowId", "orderRowId"))
if row_id:
return f"jd:{row_id}"
order_id = _text(_pick(r, "orderId", "parentOrderId"))
sku_id = _text(_pick(r, "skuId"))
if order_id and sku_id:
return f"jd:{order_id}:{sku_id}"
if order_id:
return f"jd:{order_id}"
return None
def _map_jd_order_fields(r: dict[str, Any]) -> dict:
"""京东 order.row.query 单条订单行 → CpsOrder 字段。"""
sku_name = _text(_pick(r, "skuName", "goodsName", "productName"))
if sku_name and len(sku_name) > 500:
sku_name = sku_name[:500]
valid_code = _text(_pick(r, "validCode", "valid_code"))
actual_fee = _yuan_to_cents(_pick(r, "actualFee", "actual_fee"))
estimate_fee = _yuan_to_cents(_pick(r, "estimateFee", "estimate_fee"))
commission = actual_fee if actual_fee not in (None, 0) else estimate_fee
order_time = _jd_dt_to_utc(_pick(r, "orderTime", "order_time"))
return {
"platform": "jd",
"external_order_id": _text(_pick(r, "orderId", "parentOrderId")),
"external_row_id": _text(_pick(r, "id", "rowId", "orderRowId")),
"sid": _text(_pick(r, "subUnionId", "sub_union_id")),
"act_id": None,
"biz_line": None,
"trade_type": None,
"pay_price_cents": _yuan_to_cents(
_pick(r, "actualCosPrice", "estimateCosPrice", "price")
),
"commission_cents": commission,
"commission_rate": _text(_pick(r, "commissionRate", "commission_rate")),
"refund_price_cents": None,
"refund_profit_cents": None,
"estimated_commission_cents": estimate_fee,
"actual_commission_cents": actual_fee,
"mt_status": None,
"jd_valid_code": valid_code,
"invalid_reason": None if _is_jd_valid_code(valid_code) else f"validCode={valid_code}",
"product_name": sku_name,
"settle_month": _text(_pick(r, "payMonth", "settleMonth", "pay_month")),
"site_id": _text(_pick(r, "siteId", "site_id")),
"position_id": _text(_pick(r, "positionId", "position_id")),
"pid": _text(_pick(r, "pid")),
"sub_union_id": _text(_pick(r, "subUnionId", "sub_union_id")),
"pay_time": order_time,
"mt_update_time": _jd_dt_to_utc(_pick(r, "modifyTime", "updateTime", "modify_time"))
or order_time,
"raw": r,
}
def _is_jd_valid_code(valid_code: str | None) -> bool:
code = str(valid_code).strip() if valid_code is not None else ""
return bool(code and code not in _JD_INVALID_CODES and code not in _JD_UNPAID_CODES)
def is_jd_order_valid(order: CpsOrder) -> bool:
return _is_jd_valid_code(order.jd_valid_code)
def effective_commission_cents(order: CpsOrder) -> int:
if order.platform == "jd":
if order.actual_commission_cents not in (None, 0):
return order.actual_commission_cents or 0
if order.estimated_commission_cents is not None:
return order.estimated_commission_cents or 0
return order.commission_cents or 0
def reconcile_orders(
db: Session, *, start_time: int, end_time: int,
query_time_type: int = 1, sid: str | None = None, max_pages: int = 200,
) -> dict:
"""调美团 query_order 分页拉单 → 按 order_id upsert。返回 {fetched, inserted, updated, pages}。
订单状态会随时间变(付款→完成→结算/退款),重复拉同一单则更新。max_pages 防异常死循环。
"""
fetched = inserted = updated = pages = 0
page = 1
while page <= max_pages:
resp = meituan.query_order(
sid=sid, start_time=start_time, end_time=end_time,
query_time_type=query_time_type, page=page, limit=100,
)
rows = ((resp.get("data") or {}).get("dataList")) or []
if not rows:
break
pages += 1
for r in rows:
order_id = str(r.get("orderId") or "").strip()
if not order_id:
continue
fetched += 1
fields = _map_order_fields(r)
fields.setdefault("platform", "meituan")
fields.setdefault("external_order_id", order_id)
fields.setdefault("external_row_id", None)
fields.setdefault("estimated_commission_cents", fields.get("commission_cents"))
fields.setdefault("actual_commission_cents", None)
existing = db.execute(
select(CpsOrder).where(CpsOrder.order_id == order_id)
).scalar_one_or_none()
if existing is None:
db.add(CpsOrder(order_id=order_id, **fields))
inserted += 1
else:
for k, v in fields.items():
setattr(existing, k, v)
updated += 1
if len(rows) < 100:
break
page += 1
db.commit()
return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages}
def reconcile_jd_orders(
db: Session, *, start_time: datetime, end_time: datetime,
query_time_type: int = 3, max_pages: int = 100,
) -> dict:
"""调京东 order.row.query 拉单 → 按订单行 upsert。
京东单次查询窗口最多 1 小时,这里按北京自然时间切窗并逐页拉取。
"""
fetched = inserted = updated = pages = 0
cur = start_time
while cur < end_time:
win_end = min(cur + timedelta(hours=1), end_time)
page = 1
while page <= max_pages:
resp = jd_union.query_order_rows(
start_time=cur,
end_time=win_end,
query_time_type=query_time_type,
page_index=page,
page_size=200,
)
rows = resp.get("rows") or []
has_more = bool(resp.get("has_more"))
if not rows:
break
pages += 1
for r in rows:
order_id = _jd_order_key(r)
if not order_id:
continue
fetched += 1
fields = _map_jd_order_fields(r)
existing = db.execute(
select(CpsOrder).where(CpsOrder.order_id == order_id)
).scalar_one_or_none()
if existing is None:
db.add(CpsOrder(order_id=order_id, **fields))
inserted += 1
else:
for k, v in fields.items():
setattr(existing, k, v)
updated += 1
if not has_more or len(rows) < 200:
break
page += 1
cur = win_end
db.commit()
return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages}
def list_orders(
db: Session, *, sid: str | None = None, mt_status: str | None = None,
limit: int = 20, cursor: int | None = None,
) -> tuple[list[CpsOrder], int | None, int]:
stmt = select(CpsOrder)
if sid:
stmt = stmt.where(CpsOrder.sid == sid)
if mt_status:
stmt = stmt.where(CpsOrder.mt_status == mt_status)
return offset_paginate(db, stmt, (desc(CpsOrder.id),), limit=limit, cursor=cursor)
# ───────────── 统计(按群聚合) ─────────────
def group_stats(
db: Session, *, date_from: datetime | None = None, date_to: datetime | None = None,
) -> list[dict]:
"""按 sid 聚合订单 + join 群信息。已建但本期无单的活跃群也列出(全 0)。
未归群的 sid(历史/其它来源)单独成行 group_id=None。按预估佣金降序。
"""
ostmt = select(CpsOrder)
if date_from is not None:
ostmt = ostmt.where(CpsOrder.pay_time >= _as_utc(date_from))
if date_to is not None:
ostmt = ostmt.where(CpsOrder.pay_time <= _as_utc(date_to))
orders_by_sid: dict[str | None, list[CpsOrder]] = {}
for o in db.execute(ostmt).scalars().all():
orders_by_sid.setdefault(o.sid, []).append(o)
clicks = cps_link_repo.click_stats_by_group(db, date_from=date_from, date_to=date_to)
groups = list(db.execute(select(CpsGroup)).scalars().all())
def _order_agg(items: list[CpsOrder]) -> dict:
valid = [o for o in items if o.mt_status not in _INVALID_STATUS]
settled = [o for o in items if o.mt_status == _SETTLED_STATUS]
canceled = [o for o in items if o.mt_status in _INVALID_STATUS]
return {
"order_count": len(valid),
"settled_count": len(settled),
"canceled_count": len(canceled),
"gmv_cents": sum(o.pay_price_cents or 0 for o in valid),
"est_commission_cents": sum(o.commission_cents or 0 for o in valid),
"settled_commission_cents": sum(o.commission_cents or 0 for o in settled),
}
# 淘宝/京东无法对账 → 对账字段全 None(前端显示 "-")
_no_recon = {
"order_count": None, "settled_count": None, "canceled_count": None,
"gmv_cents": None, "est_commission_cents": None, "settled_commission_cents": None,
}
rows: list[dict] = []
seen_sids: set[str] = set()
for g in groups:
if g.status != "active":
continue
c = clicks.get(g.id) or {}
row = {
"group_id": g.id, "sid": g.sid, "name": g.name,
"platforms": list(g.platforms or []), "member_count": g.member_count,
"click_pv": c.get("pv", 0), "click_uv": c.get("uv", 0),
"copy_pv": c.get("copy_pv", 0), "copy_uv": c.get("copy_uv", 0),
}
# 对账只对美团群(有 sid);淘宝/京东 → None
if "meituan" in (g.platforms or []) and g.sid:
row.update(_order_agg(orders_by_sid.get(g.sid, [])))
seen_sids.add(g.sid)
else:
row.update(_no_recon)
rows.append(row)
# 未归群的历史美团 sid(如 wonderableai):单列,有对账无点击
for sid, items in orders_by_sid.items():
if sid is None or sid in seen_sids:
continue
row = {
"group_id": None, "sid": sid, "name": sid, "platforms": ["meituan"],
"member_count": None, "click_pv": 0, "click_uv": 0, "copy_pv": 0, "copy_uv": 0,
}
row.update(_order_agg(items))
rows.append(row)
# 佣金降序(None 当 0),次按点击
rows.sort(key=lambda x: ((x["est_commission_cents"] or 0), x["click_pv"]), reverse=True)
return rows
def group_click_timeseries(
db: Session, *, group_id: int, granularity: str,
start: datetime, end: datetime,
) -> list[dict]:
"""该群点击时序(北京时区分桶,granularity=day|hour)。
每桶:click_pv/uv(visit)+copy_pv/uv;UV=distinct(ip,ua)。**生成连续桶(无数据补 0)**,
让折线图 x 轴按所选范围铺满、不断裂(CPS 数据稀疏,补零是关键)。
"""
rows = db.execute(
select(CpsClick.clicked_at, CpsClick.event_type, CpsClick.ip, CpsClick.ua)
.where(CpsClick.group_id == group_id)
.where(CpsClick.clicked_at >= _as_utc(start))
.where(CpsClick.clicked_at <= _as_utc(end))
).all()
agg: dict = {} # bucket_key -> {vp, vu(set), cp, cu(set)}
for clicked_at, event_type, ip, ua in rows:
# naive(本地 SQLite 读回无 tz)按 UTC 兜底,再转北京——与 queries._as_utc 约定一致,
# 否则 astimezone 会把 UTC 瞬刻当系统本地时区解释,分桶整体偏移。
ca = clicked_at if clicked_at.tzinfo else clicked_at.replace(tzinfo=timezone.utc)
bj = ca.astimezone(_BJ_TZ)
key = bj.strftime("%Y-%m-%d") if granularity == "day" else bj.hour
b = agg.setdefault(key, {"vp": 0, "vu": set(), "cp": 0, "cu": set()})
uid = f"{ip or ''}|{ua or ''}"
if event_type == "copy":
b["cp"] += 1
b["cu"].add(uid)
else:
b["vp"] += 1
b["vu"].add(uid)
def _point(label: str, b: dict | None) -> dict:
return {
"label": label,
"click_pv": b["vp"] if b else 0,
"click_uv": len(b["vu"]) if b else 0,
"copy_pv": b["cp"] if b else 0,
"copy_uv": len(b["cu"]) if b else 0,
}
points: list[dict] = []
if granularity == "day":
cur = start.astimezone(_BJ_TZ).date()
last = end.astimezone(_BJ_TZ).date()
while cur <= last:
points.append(_point(cur.strftime("%m-%d"), agg.get(cur.strftime("%Y-%m-%d"))))
cur += timedelta(days=1)
else:
for h in range(24):
points.append(_point(f"{h:02d}:00", agg.get(h)))
return points
def group_order_daily(
db: Session, *, sid: str, start: datetime, end: datetime,
) -> dict[str, dict]:
"""美团群订单按天(北京时区,key=YYYY-MM-DD)聚合。口径与 group_stats._order_agg 一致:
取消(4)/风控(5)不计有效;结算只数 status=6。退款佣金取当天全部单的 refund_profit_cents 之和。
返回 {date: {order_count, canceled_count, gmv_cents, est_commission_cents,
refund_profit_cents, settled_commission_cents}};无单的天不在 dict 里(端点侧补零)。
"""
rows = (
db.execute(
select(CpsOrder)
.where(CpsOrder.sid == sid)
.where(CpsOrder.pay_time >= _as_utc(start))
.where(CpsOrder.pay_time <= _as_utc(end))
)
.scalars()
.all()
)
by_day: dict[str, list] = {}
for o in rows:
if not o.pay_time:
continue
pt = o.pay_time if o.pay_time.tzinfo else o.pay_time.replace(tzinfo=timezone.utc)
d = pt.astimezone(_BJ_TZ).strftime("%Y-%m-%d")
by_day.setdefault(d, []).append(o)
out: dict[str, dict] = {}
for d, items in by_day.items():
valid = [o for o in items if o.mt_status not in _INVALID_STATUS]
settled = [o for o in items if o.mt_status == _SETTLED_STATUS]
canceled = [o for o in items if o.mt_status in _INVALID_STATUS]
out[d] = {
"order_count": len(valid),
"canceled_count": len(canceled),
"gmv_cents": sum(o.pay_price_cents or 0 for o in valid),
"est_commission_cents": sum(o.commission_cents or 0 for o in valid),
"refund_profit_cents": sum(o.refund_profit_cents or 0 for o in items),
"settled_commission_cents": sum(o.commission_cents or 0 for o in settled),
}
return out
def group_wx_users(db: Session, *, group_id: int, limit: int = 200) -> list[dict]:
"""该群来过的微信用户:在本群有带 openid 点击的所有用户 + 本群 visit/copy 次数 + 画像。
用「本群点击」判定归属,而非 cps_wx_user.first_group_id —— 同一用户会在多个群活动,
first_group_id 只记首次授权群,会漏掉"先在别处授权、cookie 已存、又来本群"的用户。
copy=本群点「复制口令」次数(领券意向),visit=进落地页次数。按本群首次点击倒序。
昵称/头像来自授权画像;美团/京东群只 302 跳转、无 userinfo 触发点,故多为空。
"""
rows = db.execute(
select(CpsClick.openid, CpsClick.event_type, func.count())
.where(CpsClick.group_id == group_id)
.where(CpsClick.openid.is_not(None))
.group_by(CpsClick.openid, CpsClick.event_type)
).all()
if not rows:
return []
stat: dict[str, dict] = {}
for openid, event_type, cnt in rows:
s = stat.setdefault(openid, {"visit": 0, "copy": 0})
if event_type == "copy":
s["copy"] = cnt
else:
s["visit"] = cnt
openids = list(stat.keys())
# 本群每个 openid 的首次点击时间(排序 + 展示"首次进入")
first_seen = dict(
db.execute(
select(CpsClick.openid, func.min(CpsClick.clicked_at))
.where(CpsClick.group_id == group_id)
.where(CpsClick.openid.is_not(None))
.group_by(CpsClick.openid)
).all()
)
# 关联画像(未授权 userinfo 的昵称/头像为空)
users = {
u.openid: u
for u in db.execute(select(CpsWxUser).where(CpsWxUser.openid.in_(openids))).scalars().all()
}
result = [
{
"openid": openid,
"nickname": users[openid].nickname if openid in users else None,
"headimgurl": users[openid].headimgurl if openid in users else None,
"first_seen": first_seen.get(openid),
"visit_count": stat[openid]["visit"],
"copy_count": stat[openid]["copy"],
}
for openid in openids
]
result.sort(key=lambda x: x["first_seen"], reverse=True)
return result[:limit]
def group_day_users(
db: Session, *, group_id: int, start: datetime, end: datetime, limit: int = 200,
) -> list[dict]:
"""该群某天(北京)以用户为单位的领券/点击 + 每人 visit 过的券。
时间窗为半开区间 [start, end)(end=次日 00:00),避免午夜双计。只统计 openid 非空
(可归属到人)的点击 —— 匿名点击(美团/京东 302 多为匿名)不计入。券名 = 该点击 link
对应活动名;活动被硬删则兜底 活动#{id}。copy=领券次数、visit=点击次数;coupons 仅
取 visit 事件按活动分组、按次数倒序(合计 = visit_count)。排序:领券 desc、再点击 desc。
与 group_wx_users 同风格(Python 侧聚合,跨 PG/SQLite 无方言坑)。
注:每日明细行的 click_pv/copy_pv 计全部点击(含匿名、UV 按 ip,ua);本函数只计 openid
用户,故各用户求和 <= 当天行总数,二者口径不同、不必相等。
"""
rows = db.execute(
select(CpsClick.openid, CpsClick.event_type, CpsClick.link_id)
.where(CpsClick.group_id == group_id)
.where(CpsClick.clicked_at >= _as_utc(start))
.where(CpsClick.clicked_at < _as_utc(end))
.where(CpsClick.openid.is_not(None))
).all()
if not rows:
return []
# link_id -> activity_id -> 券名(活动名)
link_ids = {r.link_id for r in rows}
link_to_act = dict(
db.execute(
select(CpsLink.id, CpsLink.activity_id).where(CpsLink.id.in_(link_ids))
).all()
)
act_ids = {aid for aid in link_to_act.values() if aid is not None}
act_name = (
dict(
db.execute(
select(CpsActivity.id, CpsActivity.name).where(CpsActivity.id.in_(act_ids))
).all()
)
if act_ids
else {}
)
def _coupon_name(link_id: int) -> str:
aid = link_to_act.get(link_id)
if aid is None:
return f"链接#{link_id}"
return act_name.get(aid) or f"活动#{aid}"
stat: dict[str, dict] = {}
for openid, event_type, link_id in rows:
s = stat.setdefault(openid, {"copy": 0, "visit": 0, "coupons": {}})
if event_type == "copy":
s["copy"] += 1
else:
s["visit"] += 1
name = _coupon_name(link_id)
s["coupons"][name] = s["coupons"].get(name, 0) + 1
openids = list(stat.keys())
users = {
u.openid: u
for u in db.execute(
select(CpsWxUser).where(CpsWxUser.openid.in_(openids))
).scalars().all()
}
result = [
{
"openid": openid,
"nickname": users[openid].nickname if openid in users else None,
"headimgurl": users[openid].headimgurl if openid in users else None,
"copy_count": s["copy"],
"visit_count": s["visit"],
"coupons": [
{"name": name, "count": cnt}
# 次数倒序;同次数按券名升序兜底,保证 PG 无 ORDER BY 行序下输出稳定
for name, cnt in sorted(
s["coupons"].items(), key=lambda kv: (-kv[1], kv[0])
)
],
}
for openid, s in stat.items()
]
result.sort(key=lambda x: (x["copy_count"], x["visit_count"]), reverse=True)
return result[:limit]
-103
View File
@@ -8,15 +8,9 @@ set_user_status / update_feedback_status 支持 commit=False,让 router 把"业
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import delete
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ
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
@@ -30,20 +24,6 @@ def set_user_status(db: Session, user: User, *, status: str, commit: bool = True
return user
def set_user_debug_trace(
db: Session, user: User, *, enabled: bool, commit: bool = True
) -> User:
"""开关用户「复制调试链接」权限。同 set_user_status:支持 commit=False 让 router 把
业务写 + 审计写放进同一事务。"""
user.debug_trace_enabled = enabled
if commit:
db.commit()
db.refresh(user)
else:
db.flush()
return user
def update_feedback_status(
db: Session, feedback: Feedback, *, status: str, commit: bool = True
) -> Feedback:
@@ -54,86 +34,3 @@ def update_feedback_status(
else:
db.flush()
return feedback
def review_feedback(
db: Session,
feedback: Feedback,
*,
status: str,
reviewed_by_admin_id: int,
reward_coins: int | None = None,
reject_reason: str | None = None,
review_note: str | None = None,
admin_reply: str | None = None,
commit: bool = True,
) -> Feedback:
"""审核反馈:置 adopted/rejected + 记录奖励/原因/回复留言/审核人/审核时间。
发金币(wallet.grant_coins)由 router 在同一事务里调,确保状态、金币流水、审计一起提交。
admin_reply=给用户的回复留言(用户端可见),与 review_note(内部备注)区分。
"""
feedback.status = status
feedback.reward_coins = reward_coins
feedback.reject_reason = reject_reason
feedback.review_note = review_note
feedback.admin_reply = admin_reply
feedback.reviewed_by_admin_id = reviewed_by_admin_id
feedback.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None)
if commit:
db.commit()
db.refresh(feedback)
else:
db.flush()
return feedback
def review_price_report(
db: Session,
report: PriceReport,
*,
status: str,
reward_coins: int | None = None,
reject_reason: str | None = None,
commit: bool = True,
) -> PriceReport:
"""审核上报:置 approved/rejected + 记审核时间;通过填 reward_coins、拒绝填 reject_reason。
发金币(wallet.grant_coins)不在这里——由 router 在同一事务里调,涉钱逻辑不重写(见模块头注释)。
"""
report.status = status
if reward_coins is not None:
report.reward_coins = reward_coins
if reject_reason is not None:
report.reject_reason = reject_reason
report.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None) # 北京 wall-clock,同 created_at
if commit:
db.commit()
db.refresh(report)
else:
db.flush()
return report
def delete_device_onboarding(db: Session, device_id: str, *, commit: bool = True) -> int:
"""删某设备(device_id)的全部 onboarding 完成记录 → 该设备上所有账号下次登录重走引导。
返回删除行数。支持 commit=False 让 router 把业务写 + 审计写放进同一事务。"""
n = db.execute(
delete(OnboardingCompletion).where(OnboardingCompletion.device_id == device_id)
).rowcount
if commit:
db.commit()
else:
db.flush()
return n
def delete_all_onboarding(db: Session, *, commit: bool = True) -> int:
"""清空 onboarding_completion 表 → 所有用户在所有设备下次登录都重走引导。返回删除行数。
支持 commit=False(同 delete_device_onboarding)。"""
n = db.execute(delete(OnboardingCompletion)).rowcount
if commit:
db.commit()
else:
db.flush()
return n
File diff suppressed because it is too large Load Diff
+9 -608
View File
@@ -5,56 +5,17 @@ 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 datetime import datetime, timedelta, timezone
from sqlalchemy import case, func, select
from sqlalchemy import 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 (
CouponClaimRecord,
CouponPromptEngagement,
CouponSession,
)
from app.models.cps_order import CpsOrder
from app.models.feedback import Feedback
from app.models.savings import SavingsRecord
from app.models.signin import SigninBoostRecord, SigninRecord
from app.models.user import User
from app.models.wallet import CoinTransaction, WithdrawOrder
_BEIJING = timezone(timedelta(hours=8))
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,
*UNCLASSIFIED_FEED_BIZ_TYPES,
)
MEITUAN_CPS_INVALID_STATUSES = ("4", "5")
MEITUAN_CPS_SETTLED_STATUS = "6"
COMPARE_START_EVENT = "real_compare_start"
COUPON_START_EVENT = "real_coupon_start"
JD_CPS_INVALID_CODES = {
"2", "3", "4", "5", "6", "7", "8", "9", "11", "13", "14", "19", "20", "21",
"22", "23", "25", "26", "27", "28", "29", "30", "31", "34", "35", "36",
}
JD_CPS_UNPAID_CODES = {"15"}
def _beijing_today_start_utc() -> datetime:
@@ -64,161 +25,20 @@ def _beijing_today_start_utc() -> datetime:
return start_bj.astimezone(timezone.utc)
def today_dau(db: Session) -> int:
"""今日活跃用户数(DAU):登录 + 开始比价 + 开始领券,按用户去重。
= period_active_dau(今天, 今天);历史 / 多天窗口用 period_active_dau 传区间(广告收益报表复用)。
"""
today_bj = datetime.now(_BEIJING).date()
return period_active_dau(db, today_bj, today_bj)
def _default_period_end() -> date:
"""新版大盘不含今日,默认窗口结束日=北京时间昨天。"""
return datetime.now(_BEIJING).date() - timedelta(days=1)
def _normalize_period(date_from: date | None, date_to: date | None) -> tuple[date, date]:
end = date_to or _default_period_end()
start = date_from or end
if start > end:
start, end = end, start
return start, end
def _period_bounds(date_from: date, date_to: date) -> tuple[datetime, datetime, datetime, datetime]:
"""返回同一北京自然日窗口的 UTC aware 边界和北京 naive 边界。
user.created_at / last_login_at 是 UTC aware 口径;比较/金币等历史上有北京 naive
写入,所以两套边界同时保留。
"""
start_bj = datetime.combine(date_from, time.min, tzinfo=_BEIJING)
end_bj = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=_BEIJING)
start_utc = start_bj.astimezone(timezone.utc)
end_utc = end_bj.astimezone(timezone.utc)
return (
start_utc,
end_utc,
start_bj.replace(tzinfo=None),
end_bj.replace(tzinfo=None),
)
def _date_range(date_from: date, date_to: date) -> list[date]:
days = (date_to - date_from).days
return [date_from + timedelta(days=i) for i in range(days + 1)]
def _id_set(db: Session, stmt) -> set[int]:
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
def _event_user_ids(
db: Session, event_names: tuple[str, ...], start_utc: datetime, end_utc: datetime
) -> set[int]:
return _id_set(
db,
select(AnalyticsEvent.user_id).where(
AnalyticsEvent.user_id.is_not(None),
AnalyticsEvent.event.in_(event_names),
AnalyticsEvent.created_at >= start_utc,
AnalyticsEvent.created_at < end_utc,
),
)
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:
return None
s = str(raw).strip()
if not s:
return None
try:
if s.endswith("%"):
return Decimal(s[:-1])
val = Decimal(s)
except (InvalidOperation, ValueError):
return None
return val / Decimal("100")
def _jd_valid_order(order: CpsOrder) -> bool:
code = str(order.jd_valid_code).strip() if order.jd_valid_code is not None else ""
return bool(code and code not in JD_CPS_INVALID_CODES and code not in JD_CPS_UNPAID_CODES)
def dashboard_overview(
db: Session, *, date_from: date | None = None, date_to: date | None = None
) -> dict:
def dashboard_overview(db: Session) -> dict:
today_start = _beijing_today_start_utc()
period_from, period_to = _normalize_period(date_from, date_to)
start_utc, end_utc, start_local, end_local = _period_bounds(period_from, period_to)
def _count(model, *conds) -> int:
stmt = select(func.count(model.id))
if conds:
stmt = stmt.where(*conds)
return int(db.execute(stmt).scalar_one())
return db.execute(stmt).scalar_one()
def _sum(col, *conds) -> int:
stmt = select(func.coalesce(func.sum(col), 0))
if conds:
stmt = stmt.where(*conds)
return int(db.execute(stmt).scalar_one())
def _user_id_set(stmt) -> set[int]:
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
return db.execute(stmt).scalar_one()
# ===== 用户 =====
by_status = dict(
@@ -238,315 +58,6 @@ def dashboard_overview(
comparison_total = _count(ComparisonRecord)
comparison_success = _count(ComparisonRecord, ComparisonRecord.status == "success")
success_rate = round(comparison_success / comparison_total, 4) if comparison_total else 0.0
period_comparison_conds = (
ComparisonRecord.created_at >= start_local,
ComparisonRecord.created_at < end_local,
)
period_comparison_total = _count(ComparisonRecord, *period_comparison_conds)
period_comparison_success = _count(
ComparisonRecord,
*period_comparison_conds,
ComparisonRecord.status == "success",
)
period_comparison_success_rate = (
round(period_comparison_success / period_comparison_total, 4)
if period_comparison_total
else 0.0
)
period_saved_positive_count = _count(
ComparisonRecord,
*period_comparison_conds,
ComparisonRecord.status == "success",
ComparisonRecord.saved_amount_cents > 0,
)
period_saved_positive_sum = _sum(
ComparisonRecord.saved_amount_cents,
*period_comparison_conds,
ComparisonRecord.status == "success",
ComparisonRecord.saved_amount_cents > 0,
)
period_avg_saved_cents = (
round(period_saved_positive_sum / period_saved_positive_count)
if period_saved_positive_count
else None
)
period_avg_duration_ms = db.execute(
select(func.avg(ComparisonRecord.total_ms)).where(
*period_comparison_conds,
ComparisonRecord.total_ms.is_not(None),
ComparisonRecord.total_ms > 0,
)
).scalar_one()
period_avg_duration_ms = (
round(float(period_avg_duration_ms))
if period_avg_duration_ms is not None
else None
)
ordered_exists = (
select(SavingsRecord.id)
.where(
SavingsRecord.user_id == ComparisonRecord.user_id,
SavingsRecord.source == "compare",
SavingsRecord.shop_name.is_not(None),
SavingsRecord.shop_name == ComparisonRecord.store_name,
)
.exists()
)
period_ordered_count = _count(
ComparisonRecord,
*period_comparison_conds,
ComparisonRecord.store_name.is_not(None),
ordered_exists,
)
# ===== 日期窗口用户 =====
period_new_user_ids = _user_id_set(
select(User.id).where(User.created_at >= start_utc, User.created_at < end_utc)
)
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(
cur_date, cur_date
)
daily_comparison_conds = (
ComparisonRecord.created_at >= day_start_local,
ComparisonRecord.created_at < day_end_local,
)
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.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_active_user_ids),
"new_users": _count(
User,
User.created_at >= day_start_utc,
User.created_at < day_end_utc,
),
"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,
CoinTransaction.created_at < end_local,
CoinTransaction.amount > 0,
)
period_reward_video_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
)
period_feed_ad_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type == "feed_ad_reward",
)
period_signin_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type == "signin",
)
period_signin_boost_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type == "signin_boost",
)
period_task_coin_total = _sum(
CoinTransaction.amount,
*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,
*period_coin_conds,
CoinTransaction.biz_type.notin_(REGULAR_TASK_EXCLUDED_BIZ_TYPES),
)
period_cps_orders = list(
db.execute(
select(CpsOrder).where(
CpsOrder.pay_time >= start_utc,
CpsOrder.pay_time < end_utc,
)
).scalars()
)
period_meituan_orders = [
o for o in period_cps_orders if (o.platform or "meituan") == "meituan"
]
period_meituan_valid_orders = [
o for o in period_meituan_orders if o.mt_status not in MEITUAN_CPS_INVALID_STATUSES
]
period_jd_orders = [o for o in period_cps_orders if o.platform == "jd"]
period_jd_valid_orders = [o for o in period_jd_orders if _jd_valid_order(o)]
period_jd_invalid_orders = [
o for o in period_jd_orders if o.jd_valid_code and not _jd_valid_order(o)
]
period_meituan_hit_count = 0
period_meituan_miss_count = 0
period_meituan_unknown_rate_count = 0
for order in period_meituan_valid_orders:
rate = _commission_rate_percent(order.commission_rate)
if rate is None:
period_meituan_unknown_rate_count += 1
elif rate < Decimal("1"):
period_meituan_miss_count += 1
else:
period_meituan_hit_count += 1
period_meituan_hit_denominator = period_meituan_hit_count + period_meituan_miss_count
period_meituan_hit_rate = (
round(period_meituan_hit_count / period_meituan_hit_denominator, 4)
if period_meituan_hit_denominator
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": {
@@ -555,42 +66,11 @@ def dashboard_overview(
"disabled": by_status.get("disabled", 0),
"deleted": by_status.get("deleted", 0),
"new_today": _count(User, User.created_at >= today_start),
"dau": today_dau(db),
"dau": _count(User, User.last_login_at >= today_start),
},
"coins": {
# 累计发放金币(coin_transaction 里所有 amount>0 之和;负数是兑换/扣减不计)
"granted_total": _sum(CoinTransaction.amount, CoinTransaction.amount > 0),
"reward_video_coin_total": _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
),
"reward_video_watch_count": _count(
AdRewardRecord,
AdRewardRecord.reward_scene == "reward_video",
AdRewardRecord.status == "granted",
),
"feed_ad_coin_total": _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type == "feed_ad_reward",
),
"feed_ad_watch_count": _count(
AdFeedRewardRecord,
AdFeedRewardRecord.status == "granted",
),
"signin_coin_total": _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type == "signin",
),
"signin_count": _count(SigninRecord),
"signin_boost_coin_total": _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type == "signin_boost",
),
"signin_boost_watch_count": _count(SigninBoostRecord),
},
"cash": {
"withdraw_success_cents": _sum(
@@ -605,86 +85,7 @@ def dashboard_overview(
"success": comparison_success,
"success_rate": success_rate,
},
"period": {
"date_from": period_from,
"date_to": period_to,
"users": {
"new": len(period_new_user_ids),
"active": len(period_active_user_ids),
"retained_new_users": retention_retained_total,
"retention_cohort": retention_cohort_total,
"retention_rate": period_retention_rate,
"retention_note": (
"次日留存:窗口内每天取前一日新增用户,统计其当日活跃"
"(登录/开始比价/开始领券,按用户去重)比例,逐日累加;"
"默认窗口=昨日,即前日新增用户的昨日留存"
),
},
"comparison": {
"total": period_comparison_total,
"success": period_comparison_success,
"success_rate": period_comparison_success_rate,
"ordered": period_ordered_count,
"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,
"feed_ad_coin_total": period_feed_ad_coin_total,
"signin_coin_total": period_signin_coin_total,
"signin_boost_coin_total": period_signin_boost_coin_total,
"task_coin_total": period_task_coin_total,
"coupon_reward_coin_total": period_coupon_reward_coin_total,
"comparison_reward_coin_total": period_comparison_reward_coin_total,
"regular_task_coin_total": period_regular_task_coin_total,
},
"cash": {
"withdraw_success_cents": _sum(
WithdrawOrder.amount_cents,
WithdrawOrder.status == "success",
WithdrawOrder.created_at >= start_local,
WithdrawOrder.created_at < end_local,
),
},
"trend": trend_points,
},
"feedback": {
"new": _count(Feedback, Feedback.status.in_(("pending", "new"))),
},
"cps": {
"available": True,
"note": "美团/JD CPS 读 cps_order 对账订单;淘宝佣金暂空",
"meituan_order_count": len(period_meituan_valid_orders),
"meituan_commission_cents": sum(
o.commission_cents or 0 for o in period_meituan_valid_orders
),
"meituan_hit_count": period_meituan_hit_count,
"meituan_miss_count": period_meituan_miss_count,
"meituan_unknown_rate_count": period_meituan_unknown_rate_count,
"meituan_hit_rate": period_meituan_hit_rate,
"jd_order_count": len(period_jd_valid_orders),
# 数据大盘京东 CPS 只看实际佣金,不再用预估佣金兜底。
"jd_commission_cents": sum(
o.actual_commission_cents or 0 for o in period_jd_valid_orders
),
"jd_actual_commission_cents": sum(
o.actual_commission_cents or 0 for o in period_jd_valid_orders
),
"jd_estimated_commission_cents": sum(
o.estimated_commission_cents or 0 for o in period_jd_valid_orders
),
"jd_invalid_count": len(period_jd_invalid_orders),
},
"feedback": {"new": _count(Feedback, Feedback.status == "new")},
# CPS 收入数据源未接(referral-link 只换链接,转化/佣金未回收)→ 前端显示"待接入"。
"cps": {"available": False, "note": "CPS 转化数据未接入(P2)"},
}
-48
View File
@@ -1,48 +0,0 @@
"""admin 看广告金币审计:只读对账,核对发奖金币是否按公式计算。
任意已登录 admin 可看(只读,不涉及资金操作)。复算逻辑在 app/admin/repositories/ad_audit.py。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import ad_audit
from app.admin.schemas.ad_audit import AdCoinAuditOut, AdCoinAuditRow, AdCoinFormulaOut
from app.core.rewards import cn_today
router = APIRouter(
prefix="/admin/api/ad-coin-audit",
tags=["admin-ad-coin-audit"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=AdCoinAuditOut, summary="看广告金币公式审计(复算对比)")
def get_ad_coin_audit(
db: AdminDb,
date: Annotated[str | None, Query(description="北京时间 YYYY-MM-DD,默认今天")] = None,
user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None,
scene: Annotated[
str | None, Query(description="reward_video / feed / draw;不传=全部")
] = None,
limit: Annotated[int, Query(ge=1, le=500)] = 100,
only_mismatch: Annotated[
bool, Query(description="只看不一致(✗)行;统计数仍按全量,不受影响")
] = False,
) -> AdCoinAuditOut:
audit_date = date or cn_today().isoformat()
result = ad_audit.ad_coin_audit(
db, date=audit_date, user_id=user_id, scene=scene, limit=limit,
only_mismatch=only_mismatch,
)
return AdCoinAuditOut(
date=audit_date,
formula=AdCoinFormulaOut(**ad_audit.formula_snapshot()),
total=result["total"],
mismatch_count=result["mismatch_count"],
truncated=result["truncated"],
items=[AdCoinAuditRow(**r) for r in result["items"]],
)
-45
View File
@@ -1,45 +0,0 @@
"""admin 广告配置:读/写穿山甲 app_id / 各场景广告位ID / 验签密钥 / 各场景开关。
存在 app_config 表的 ad_config dict(见 repositories/app_config.get_ad_config/set_ad_config)。
客户端经 /api/v1/platform/ad-config 拉取(不含 reward_mkey)。权限 operator/finance + 审计。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.schemas.ad_config import AdConfigOut, AdConfigUpdate
from app.models.admin import AdminUser
from app.repositories import app_config
router = APIRouter(
prefix="/admin/api/ad-config",
tags=["admin-ad-config"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=AdConfigOut, summary="广告配置(穿山甲ID/验签密钥/各场景开关)")
def get_ad_config(db: AdminDb) -> AdConfigOut:
return AdConfigOut(**app_config.get_ad_config(db))
@router.patch("", response_model=AdConfigOut, summary="改广告配置(部分更新,带审计)")
def update_ad_config(
body: AdConfigUpdate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator", "finance"))],
db: AdminDb,
) -> AdConfigOut:
data = body.model_dump(exclude_none=True)
app_config.set_ad_config(db, data, admin_id=admin.id, commit=False)
# 审计只记改了哪些字段,不记 mkey 明文(机密)
write_audit(
db, admin, action="ad_config.set", target_type="ad_config", target_id="ad_config",
detail={"changed": sorted(data.keys())}, ip=get_client_ip(request), commit=False,
)
db.commit()
return AdConfigOut(**app_config.get_ad_config(db))
-107
View File
@@ -1,107 +0,0 @@
"""admin 广告收益报表:按 用户/日期/广告类型/应用/代码位 聚合 展示条数 / 收益 / 金币。
任意已登录 admin 可看(只读,不涉及资金操作)。聚合逻辑在 app/admin/repositories/ad_revenue.py。
"""
from __future__ import annotations
from datetime import date as _date
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import ad_revenue
from app.admin.schemas.ad_revenue import (
AdRevenueDaily,
AdRevenueHourly,
AdRevenueReportOut,
AdRevenueRow,
AdRevenueTypeStat,
)
from app.core.rewards import cn_today
router = APIRouter(
prefix="/admin/api/ad-revenue-report",
tags=["admin-ad-revenue-report"],
dependencies=[Depends(get_current_admin)],
)
# 区间最大跨度(天);超出拒绝,避免审计页一次拉过多天(逐日审计 + 大查询)拖垮接口。
_MAX_RANGE_DAYS = 92
def _parse_day(value: str | None, *, field: str, default: _date) -> _date:
if value is None:
return default
try:
return _date.fromisoformat(value)
except ValueError as e:
raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e
@router.get("", response_model=AdRevenueReportOut, summary="广告收益报表(按 日期区间/用户/类型/应用/代码位 聚合)")
def get_ad_revenue_report(
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,
user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None,
ad_type: Annotated[
str | None,
Query(description="reward_video / feed / draw;不传=全部类型"),
] = None,
feed_scene: Annotated[
str | None,
Query(
description="comparison(比价) / coupon(领券) / welfare(福利);不传=全部场景。"
"全局筛选,同时影响明细 / 合计 / 趋势"
),
] = None,
app_env: Annotated[
str | None,
Query(
description="prod(正式应用) / test(测试应用);不传=全部。"
"建议正式收益报表选 prod,避免测试应用的假 eCPM 污染收益合计/平均"
),
] = None,
granularity: Annotated[
str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day")
] = "day",
limit: Annotated[int, Query(ge=1, le=1000, description="每页条数(分页大小)")] = 500,
offset: Annotated[int, Query(ge=0, description="分页偏移(已跳过的条数)=(页码-1)×每页条数")] = 0,
sort: Annotated[
str, Query(description="排序:time=时间倒序(默认) / ecpm=按 eCPM 数值倒序")
] = "time",
) -> AdRevenueReportOut:
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}")
result = ad_revenue.ad_revenue_report(
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
user_id=user_id, ad_type=ad_type, feed_scene=feed_scene, app_env=app_env,
granularity=granularity, limit=limit, offset=offset, sort=sort,
)
return AdRevenueReportOut(
date_from=d_from.isoformat(),
date_to=d_to.isoformat(),
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"],
total_impressions=result["total_impressions"],
total_revenue_yuan=result["total_revenue_yuan"],
total_pangle_revenue_yuan=result["total_pangle_revenue_yuan"],
total_pangle_api_revenue_yuan=result["total_pangle_api_revenue_yuan"],
pangle_revenue_available=result["pangle_revenue_available"],
total_expected_coin=result["total_expected_coin"],
total_actual_coin=result["total_actual_coin"],
mismatch_count=result["mismatch_count"],
items=[AdRevenueRow(**r) for r in result["items"]],
)
+9 -97
View File
@@ -5,8 +5,6 @@ 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
@@ -19,37 +17,9 @@ router = APIRouter(
)
def _active_super_count(db: AdminDb) -> int:
return sum(
1 for a in admin_repo.list_admins(db) if a.role == "super_admin" and a.status == "active"
)
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 专属)")
@router.get("", response_model=list[AdminOut], summary="管理员列表")
def list_admins(db: AdminDb) -> list[AdminOut]:
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
return [AdminOut.model_validate(a) for a in admin_repo.list_admins(db)]
@router.post("", response_model=AdminOut, summary="创建管理员")
@@ -58,17 +28,12 @@ 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,
plain_password=body.password, # UI 建的账号留存明文,供权限管理页复看
pages_override=override,
db, username=body.username, password=body.password, role=body.role
)
write_audit(
db, admin, action="admin.create", target_type="admin", target_id=new.id,
detail={"username": new.username, "role": new.role, "pages_override": override},
ip=get_client_ip(request), commit=True,
detail={"username": new.username, "role": new.role}, ip=get_client_ip(request), commit=True,
)
return AdminOut.model_validate(new)
@@ -83,45 +48,16 @@ def update_admin(
if admin_id == admin.id and body.status == "disabled":
raise HTTPException(status_code=400, detail="不能禁用自己")
# 防自锁:降级 / 禁用某个 super_admin 前,确认操作后仍至少剩 1 个 active super_admin,
# 否则会进入「零可用超管」死局——本路由仅 super 可进,只能改库恢复。
demotes_super = (
target.role == "super_admin"
and target.status == "active"
and (
(body.role is not None and body.role != "super_admin")
or body.status == "disabled"
)
)
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 = {}
role_changed = body.role is not None and body.role != target.role
if role_changed:
changes["role"] = {"before": target.role, "after": body.role}
if body.role is not None:
target.role = body.role
if body.status is not None and body.status != target.status:
changes["status"] = {"before": target.status, "after": body.status}
changes["role"] = body.role
if body.status is not None:
target.status = body.status
changes["status"] = body.status
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
changes["password"] = "reset"
if not changes:
raise HTTPException(status_code=400, detail="无任何变更字段")
db.commit()
@@ -131,27 +67,3 @@ 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}
+2 -4
View File
@@ -26,11 +26,9 @@ def list_audit_logs(
limit: Annotated[int, Query(ge=1, le=100)] = 50,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[AdminAuditLogOut]:
items, next_cursor, total = audit_repo.list_audit_logs(
items, next_cursor = audit_repo.list_audit_logs(
db, action=action, target_type=target_type, admin_id=admin_id, limit=limit, cursor=cursor,
)
return CursorPage(
items=[AdminAuditLogOut.model_validate(x) for x in items],
next_cursor=next_cursor,
total=total,
items=[AdminAuditLogOut.model_validate(x) for x in items], next_cursor=next_cursor,
)
+4 -17
View File
@@ -6,8 +6,6 @@ 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
@@ -19,17 +17,6 @@ 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,
@@ -52,10 +39,10 @@ def login(req: AdminLoginRequest, db: AdminDb) -> AdminLoginResponse:
return AdminLoginResponse(
access_token=token,
expires_in=expires_in,
admin=_admin_out_with_pages(admin, db),
admin=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)
@router.get("/me", response_model=AdminOut, summary="当前管理员")
def me(admin: CurrentAdmin) -> AdminOut:
return AdminOut.model_validate(admin)
-61
View File
@@ -1,61 +0,0 @@
"""admin 比价记录 debug:按 user_id / phone 查列表 + 单条详情(含 LLM 每次调用明细 / 原始 payload)。
只读。读权限默认即可(get_current_admin),不额外 require_role(查记录是基础 debug 能力)。
trace_url 无条件下发——admin 是内部 debug 工具,不走 C 端 user.debug_trace_enabled 权限闸。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import queries
from app.admin.schemas.common import CursorPage
from app.admin.schemas.comparison import AdminComparisonDetail, AdminComparisonListItem
router = APIRouter(
prefix="/admin/api/comparison-records",
tags=["admin-comparison"],
dependencies=[Depends(get_current_admin)],
)
@router.get(
"",
response_model=CursorPage[AdminComparisonListItem],
summary="比价记录列表(按 user_id/phone 筛 + 分页)",
)
def list_comparison_records(
db: AdminDb,
user_id: Annotated[int | None, Query()] = None,
phone: Annotated[str | None, Query(description="手机号前缀")] = None,
status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None,
business_type: Annotated[str | None, Query()] = None,
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, store=store, product=product,
limit=limit, cursor=cursor,
)
return CursorPage(
items=[AdminComparisonListItem.model_validate(r) for r in items],
next_cursor=next_cursor,
total=total,
)
@router.get(
"/{record_id}",
response_model=AdminComparisonDetail,
summary="比价记录详情(含 LLM 每次调用明细 + 原始 payload)",
)
def get_comparison_record(record_id: int, db: AdminDb) -> AdminComparisonDetail:
rec = queries.get_comparison_record(db, record_id)
if rec is None:
raise HTTPException(status_code=404, detail="记录不存在")
return AdminComparisonDetail.model_validate(rec)
-90
View File
@@ -1,90 +0,0 @@
"""admin 运营配置:列出所有可配项 + 改某项(带审计 + 改值校验)。
配置项定义见 app.core.config_schema.CONFIG_DEFS;业务读配置 fallback 默认(见 app_config repo)。
权限:operator / finance 都可改(运营调奖励、财务调额度),super 恒可。
"""
from __future__ import annotations
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.schemas.config import ConfigItemOut, ConfigUpdateRequest
from app.core.config_schema import CONFIG_DEFS
from app.core.rewards import SIGNIN_CYCLE_LEN
from app.models.admin import AdminUser
from app.repositories import app_config
router = APIRouter(
prefix="/admin/api/config",
tags=["admin-config"],
dependencies=[Depends(get_current_admin)],
)
def _validate(key: str, value: Any) -> None:
"""按配置项 type 校验新值,不合法抛 ValueError(router 转 400)。"""
t = CONFIG_DEFS[key]["type"]
if t == "int":
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
raise ValueError("需为非负整数")
elif t == "int_list":
if not isinstance(value, list) or not value:
raise ValueError("需为非空整数列表")
if not all(isinstance(x, int) and not isinstance(x, bool) and x >= 0 for x in value):
raise ValueError("列表元素需为非负整数")
if key == "signin_rewards" and len(value) != SIGNIN_CYCLE_LEN:
raise ValueError(f"签到档位必须正好 {SIGNIN_CYCLE_LEN} 个(对应 {SIGNIN_CYCLE_LEN} 天循环)")
elif t == "dict_str_int":
if not isinstance(value, dict) or not all(
isinstance(k, str) and isinstance(v, int) and not isinstance(v, bool)
for k, v in value.items()
):
raise ValueError("需为 {字符串: 整数} 映射")
elif t == "bool":
if not isinstance(value, bool):
raise ValueError("需为布尔值")
def _item(db, key: str) -> ConfigItemOut:
for item in app_config.list_all(db):
if item["key"] == key:
return ConfigItemOut(**item)
raise HTTPException(status_code=404, detail="未知配置项")
@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值(不含 hidden)")
def list_config(db: AdminDb) -> list[ConfigItemOut]:
# 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="改某项配置(带审计)")
def update_config(
key: str,
body: ConfigUpdateRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator", "finance"))],
db: AdminDb,
) -> ConfigItemOut:
if key not in CONFIG_DEFS:
raise HTTPException(status_code=404, detail="未知配置项")
try:
_validate(key, body.value)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
before = app_config.get_value(db, key)
app_config.set_value(db, key, body.value, admin_id=admin.id, commit=False)
write_audit(
db, admin, action="config.set", target_type="config", target_id=key,
detail={"before": before, "after": body.value}, ip=get_client_ip(request), commit=False,
)
db.commit()
return _item(db, key)

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