Compare commits

..

3 Commits

Author SHA1 Message Date
chenshuobo c1ce109f13 feat: 新增 GET /api/v1/user/records 订单记录分页接口
- 新增 OrderRecord 模型 (user_id, platform, store_name, dishes, order_date, price, original_price, savings)
- 新增 order_record schema (OrderRecordOut + OrderListResponse)
- 新增 CRUD: get_records_page 按 order_date DESC 分页查询
- 新增 /api/v1/user/records 路由, 需 Bearer token 鉴权, 支持 page/size 参数
- Alembic 迁移: add order_record table
- platform 支持: meituan_waimai / taobao_shanguang / jd_waimai / meituan / taobao / jd
- dishes 字段 DB 存 JSON 字符串, 接口返回 list[str]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:11:05 +08:00
chenshuobo bd126635e3 fix: 放宽 Python 版本要求至 >=3.10
代码已使用 from __future__ import annotations, 兼容 3.10 运行时

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:36:36 +08:00
chenshuobo 8fc291a30c feat: 接入美团联盟 CPS 优惠券推荐模块
- 新增 app/core/meituan.py: 美团联盟 API 客户端 (S-Ca 签名, query_coupon, get_referral_link)
- 新增 app/api/v1/meituan.py: /api/v1/meituan/coupons 和 /feed 路由, 支持按经纬度推荐
- 新增 app/schemas/meituan.py: 请求/响应 Pydantic Schema
- app/main.py: 注册 meituan_router
- app/core/config.py: 新增 MT_CPS_* 配置项, .env 路径改为绝对路径避免 CWD 问题
- .env.example: 补充美团联盟 CPS 配置模板

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:36:36 +08:00
496 changed files with 642 additions and 54290 deletions
-93
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=10
HEARTBEAT_SCAN_INTERVAL_SEC=60
# ===== 短信 (mock 模式) =====
# mock = true 时,任意 6 位数字均通过,且 /sms/send 不真发短信(只 log)。
# 后续接阿里云/腾讯云短信时,改成 false 并填供应商相关 key。
@@ -39,101 +34,13 @@ 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。
# 本地开发用 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 之类
CORS_ALLOW_ORIGINS=
# ===== 微信支付(商家转账到零钱 / 提现)=====
# appid/secret 来自微信开放平台移动应用;mch/序列号/公钥ID 来自微信支付商户平台。
# 证书 .pem 放 secrets/(已 gitignore)。
WECHAT_APP_ID=wxxxxxxxxxxxxxxxxx
WECHAT_APP_SECRET=your_app_secret
WXPAY_MCH_ID=your_mch_id
WXPAY_MCH_SERIAL_NO=your_cert_serial_no
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_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 -15
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
@@ -44,7 +34,3 @@ secrets/*
.idea/
.vscode/
*.swp
# 运行日志(run.sh 输出, 不入库)
*.log
logs/
-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
+114 -95
View File
@@ -2,15 +2,10 @@
傻瓜比价正式 App 后端 — FastAPI + SQLAlchemy + JWT。
覆盖:**账号体系**(极光一键登录 / 短信登录 + JWT)、**福利钱包**(金币 / 现金 / 签到 / 任务 / 省钱战绩)、**微信提现**(商家转账到零钱)、**看广告发奖**(穿山甲 GroMore S2S 回调)、**美团 CPS**(领券透传 / 比价推荐)。
> 占坑期已有的试验后台 `shaguabijia-server` 是无状态 mock,本项目是正式版,
> 引入账号体系、JWT 登录态、Alembic 迁移、SQLite 起步可平滑切 PostgreSQL。
> 占坑期的试验后台 `shaguabijia-server` 是无状态 mock;本项目是正式版,引入账号体系、JWT 登录态、Alembic 迁移,SQLite 起步可平滑切 PostgreSQL
**文档导航**(都在 [docs/](docs/README.md)):
- [docs/api/](docs/api/README.md) — **接口契约**(索引 + 一接口一文件)
- [docs/integrations/](docs/integrations/README.md) — **集成层**(SDK 签名 / 加解密 / 协议细节)
- [docs/后端技术实现.md](docs/后端技术实现.md) — **架构与链路**(分层、登录、CPS、数据模型)
- [docs/数据库迁移.md](docs/数据库迁移.md) — **数据库迁移**(Alembic 怎么建表 / 升级)
详细技术说明见 [docs/后端技术实现.md](docs/后端技术实现.md)(登录链路、极光 RSA、部署 checklist)
---
@@ -21,75 +16,57 @@
| 语言 | Python 3.11+ | |
| Web | FastAPI 0.115+ | |
| ASGI | uvicorn[standard] | |
| ORM | SQLAlchemy 2.0 | Mapped / DeclarativeBase 新风格 |
| ORM | SQLAlchemy 2.0 | Mapped/DeclarativeBase 新风格 |
| 迁移 | Alembic | render_as_batch 兼容 SQLite |
| DB | SQLite 起步 | 切 PostgreSQL 只改 `DATABASE_URL` |
| DB | SQLite | 后续切 PostgreSQL 只改 `DATABASE_URL` |
| Auth | JWT (PyJWT) | access(2h) + refresh(30d) |
| HTTP 客户端 | httpx | 调极光 / 微信 / 美团 / 穿山甲 |
| 加解密 | cryptography | 极光 RSA 解密、微信 V3 签名 / 敏感字段加密 |
| 微信支付 | 商家转账到零钱 V3 | 提现;懒加载商户证书 |
| 极光一键登录 | httpx + cryptography | RSA 解密,逻辑参考试验后台 |
| 配置 | pydantic-settings | `.env` 加载 |
| 测试 | pytest + TestClient | |
---
## 架构分层
一个请求自上而下:**api(薄,收发) → integrations(外部 SDK) / repositories(数据) → models / db**。
- `api/v1/` 只做"解析请求 → 调 repositories/integration → 组装响应 + HTTP 错误码映射",不放重逻辑。
- **SDK 集成的重逻辑(签名 / 验签 / 加解密 / 外部 HTTP)一律在 `integrations/`**,换方案 / 换供应商只动这一层。
- 数据访问统一在 `repositories/`(早期叫 `crud/`,已并入)。
## 目录结构
```
shaguabijia-app-server/
├── app/
│ ├── main.py FastAPI 入口:注册全部 router + /health + CORS + lifespan
│ ├── api/
│ │ ├── deps.py 共享依赖:get_current_user(Bearer 校验)、get_db
│ │ └── v1/ 接口层(薄)
│ │ ├── auth.py 认证 6 端点(极光 / 短信 send+login / refresh / me / logout)
│ │ ├── wallet.py 钱包 / 提现 11 端点(余额 / 流水 / 兑换 / 绑微信 / 提现 / 查单)
│ │ ├── signin.py 签到 2 端点
│ │ ├── tasks.py 一次性任务 2 端点
│ │ ├── savings.py 省钱 3 端点(汇总 / 战绩 / 明细)
│ │ ├── ad.py 看广告发奖 3 端点(穿山甲回调 / 进度 / 联调发奖)
│ │ ├── coupon.py 领券透传 /coupon/step(转发 pricebot)
│ │ └── meituan.py 美团 CPS 3 端点(券列表 / feed / 换链)
│ ├── integrations/ 外部服务 / SDK 客户端(重逻辑)
│ │ ├── jiguang.py 极光 REST 验 token + RSA 解密(多 padding 试错)
│ │ ├── sms.py 短信验证码(mock,进程内冷却表)
│ │ ├── meituan.py 美团 CPS 网关签名 + query_coupon / get_referral_link
│ │ ├── pangle.py 穿山甲激励视频发奖回调验签(SHA256)
│ │ └── wxpay.py 微信支付 V3 商家转账(提现)+ code 换 openid
│ ├── core/ 基础设施(无外部业务集成)
│ ├── main.py FastAPI 入口 + /health + CORS
│ ├── core/
│ │ ├── config.py Settings (pydantic-settings)
│ │ ├── logging.py 统一日志配置
│ │ ├── security.py JWT 签发 / 校验 / token 对
│ │ ├── ratelimit.py 同 IP 滑动窗口限流依赖
│ │ ── rewards.py 发奖 / 兑换 / 提现额度等业务常量与换算
│ └── logging.py 统一日志配置
│ ├── repositories/ 数据访问 + 事务(早期叫 crud,已并入)
│ │ ── user.py upsert_user_for_login 等
│ ├── wallet.py 账户 / 流水 / 兑换 / 提现单(调 integrations/wxpay)
│ │ ── signin.py 签到记录 / 连续天数 / 档位
│ ├── task.py 一次性任务领取
│ │ ── savings.py 省钱汇总 / 战绩 / 明细
│ └── ad_reward.py 看广告发奖(按 trans_id 幂等 + 每日上限)
├── models/ ORM 表结构(user / wallet / signin / task / savings / ad_reward)
── schemas/ Pydantic 收发契约(auth / meituan / welfare / ad)
└── db/ base.py(DeclarativeBase) + session.py(engine / get_db)
│ │ ├── jiguang.py 极光 REST 调用 + RSA 解密
│ │ ── sms.py 短信验证码 (mock 实现)
├── db/
│ ├── base.py DeclarativeBase
│ │ ── session.py engine + SessionLocal + get_db
│ ├── models/
│ │ ── user.py User 表
│ ├── schemas/
│ │ ── auth.py 登录/Token 相关 Pydantic
├── crud/
│ └── user.py upsert_user_for_login 等
── api/
├── deps.py get_current_user (Bearer 校验)
│ └── v1/
│ └── auth.py /api/v1/auth/* 路由
├── alembic/ 数据库迁移(versions/ 9 个迁移;文件名已去 hex 前缀,链靠文件内 down_revision)
├── alembic/ 迁移脚本
│ ├── env.py
│ ├── script.py.mako
│ └── versions/ (空,第一次跑 alembic revision 生成)
├── alembic.ini
├── deploy/ systemd(.service) + nginx(.conf)
├── secrets/ 不入 git:极光 RSA 私钥 / 微信支付证书
├── data/ 不入 git:SQLite 文件默认在这里
├── scripts/ 运维脚本(migrate 迁移 / 对账 / 重置签到·福利 / 模拟穿山甲回调)
├── tests/ pytest(auth / health / welfare / withdraw / ad_reward / coupon_proxy)
├── docs/ 文档库(api/ + integrations/ + 技术实现 / 迁移 / 待办)
├── run.sh 本地启动(自动先跑迁移再起服务)
├── deploy/
│ ├── shaguabijia-app-server.service systemd unit
│ └── nginx/
│ └── app-api.shaguabijia.com.conf
├── secrets/ 不入 git。放 jverify_rsa_private.pem
├── data/ 不入 git。SQLite 文件默认在这里
├── tests/ 冒烟 + auth 流程
├── pyproject.toml
├── .env.example
└── README.md
@@ -99,63 +76,98 @@ shaguabijia-app-server/
## API 接口
完整契约见 **[docs/api/README.md](docs/api/README.md)**(总览表 + 一接口一文件)。按组速览:
| 组 | 前缀 | 端点数 | 鉴权 |
| 方法 | 路径 | 说明 | 鉴权 |
|---|---|---|---|
| 健康检查 | `/health` | 1 | 无 |
| 认证 Auth | `/api/v1/auth` | 6 | 登录类无 / `me`·`logout` Bearer |
| 钱包·提现 Wallet | `/api/v1/wallet` | 11 | Bearer(`exchange-info` 无) |
| 签到 Signin | `/api/v1/signin` | 2 | Bearer |
| 任务 Tasks | `/api/v1/tasks` | 2 | Bearer |
| 省钱 Savings | `/api/v1/savings` | 3 | Bearer |
| 看广告发奖 Ad | `/api/v1/ad` | 3 | 回调验签 / 其余 Bearer |
| 领券 Coupon | `/api/v1/coupon` | 1 | Bearer |
| 美团 CPS Meituan | `/api/v1/meituan` | 3 | 无 |
| GET | `/health` | 健康检查 | 无 |
| POST | `/api/v1/auth/jverify-login` | 极光一键登录(`loginToken` → 注册即登录 → 签发 token 对) | 无 |
| POST | `/api/v1/auth/sms/send` | 发短信验证码(mock 阶段任意 6 位通过) | 无 |
| POST | `/api/v1/auth/sms/login` | 手机号+验证码登录 | 无 |
| POST | `/api/v1/auth/refresh` | 用 `refresh_token` 换新的 token 对 | 无 |
| GET | `/api/v1/auth/me` | 获取当前登录用户 | `Bearer access_token` |
| POST | `/api/v1/auth/logout` | 登出(占位,客户端清 token) | `Bearer access_token` |
登录类接口响应统一`TokenWithUser`(access/refresh token 对 + user);金额字段一律以**分**为单位(`*_cents`)。`APP_ENV` 非 prod 时开 `http://localhost:8770/docs`(Swagger UI)。
登录类接口响应统一格式:
```json
{
"access_token": "eyJxxx...",
"refresh_token": "eyJxxx...",
"token_type": "Bearer",
"expires_in": 7200,
"refresh_expires_in": 2592000,
"user": {
"id": 1,
"phone": "13800138000",
"nickname": null,
"avatar_url": null,
"register_channel": "jverify",
"status": "active",
"created_at": "2026-05-23T04:30:00Z",
"last_login_at": "2026-05-23T04:30:00Z"
}
}
```
文档启动后看 `http://localhost:8770/docs` (Swagger UI)。
---
## 本地开发
### 1. 装依赖
```bash
cd shaguabijia-app-server
python3.11 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
source .venv/bin/activate
pip install -e ".[dev]"
```
### 2. 配置
### 2. 准备配置
```bash
cp .env.example .env
# 至少改 JWT_SECRET_KEY;测美团填 MT_CPS_*;测提现填 WXPAY_*/WECHAT_*;测看广告填 PANGLE_*
# 编辑 .env,生产部署务必改 JWT_SECRET_KEY
```
测极光一键登录把 RSA 私钥放到 `./secrets/jverify_rsa_private.pem`(没放也不影响其他接口,只是 `jverify-login` 返 502)。
### 3. 建表 / 升级数据库
如果要测极光一键登录,把 RSA 私钥放到 `./secrets/jverify_rsa_private.pem`
没放也不影响其他接口(`sms`/`/health` 等),只是 `jverify-login` 会 502。
### 3. 建表(首次)
```bash
mkdir -p data # SQLite 库目录
alembic upgrade head # 按迁移链建到最新(幂等)
# 生成首版 migration(已有 User model,会自动生成 create_table)
alembic revision --autogenerate -m "init user table"
# 应用 migration
alembic upgrade head
```
后续改 model 后:
```bash
alembic revision --autogenerate -m "add nickname index"
alembic upgrade head
```
> 详见 [docs/数据库迁移.md](docs/数据库迁移.md)。只想迁移不启服务:`bash scripts/migrate.sh`。
### 4. 跑起来
```bash
./run.sh # 监听 0.0.0.0:8770,自动先跑迁移;改代码自动重启
# 或手动: uvicorn app.main:app --reload --port 8770
uvicorn app.main:app --reload --port 8770
```
冒烟:
```bash
curl http://localhost:8770/health # {"status":"ok"}
# mock 登录闭环
curl -X POST http://localhost:8770/api/v1/auth/sms/send -H 'Content-Type: application/json' -d '{"phone":"13800138000"}'
curl http://localhost:8770/health
# {"status":"ok"}
# mock 走通登录闭环
curl -X POST http://localhost:8770/api/v1/auth/sms/send -H 'Content-Type: application/json' -d '{"phone":"13800138000"}'
curl -X POST http://localhost:8770/api/v1/auth/sms/login -H 'Content-Type: application/json' -d '{"phone":"13800138000","code":"123456"}'
# 拿到 access_token 后:
curl http://localhost:8770/api/v1/auth/me -H "Authorization: Bearer <access_token>"
```
### 5. 测试
```bash
pytest -q
```
@@ -164,13 +176,17 @@ pytest -q
## 部署(生产)
部署到 `app-api.shaguabijia.com`(参考 `deploy/`):
参考 `deploy/`,部署到 `app-api.shaguabijia.com`:
```bash
# 一次性
ssh server "apt-get install -y python3.11-venv python3-pip"
# 后续更新
rsync -avz --exclude='__pycache__' --exclude='.venv' --exclude='data' --exclude='secrets/*' \
rsync -avz --exclude='__pycache__' --exclude='.venv' --exclude='data' --exclude='secrets/jverify_rsa_private.pem' \
./ server:/opt/shaguabijia-app-server/
# 证书 / 私钥单独同步(首次 / 更换时):极光私钥 + 微信支付商户私钥·平台公钥
# 私钥单独同步(首次 / 私钥更换时)
scp secrets/jverify_rsa_private.pem server:/opt/shaguabijia-app-server/secrets/
ssh server "
@@ -180,7 +196,8 @@ ssh server "
systemctl restart shaguabijia-app-server
"
```
详见 `deploy/`。**看广告发奖上线**前对照 [docs/看广告赚金币上线清单.md](docs/看广告赚金币上线清单.md)(GroMore 回调配置、清理调试脚手架、端到端验收)。
详见 `deploy/shaguabijia-app-server.service``deploy/nginx/app-api.shaguabijia.com.conf`
---
@@ -189,9 +206,11 @@ ssh server "
| 维度 | 试验后台 (shaguabijia-server) | 本项目 (shaguabijia-app-server) |
|---|---|---|
| 数据库 | stdlib sqlite3,无 ORM | SQLAlchemy 2.0 + Alembic |
| 业务 | mock parse(无账号体系) | 账号 + 钱包 / 提现 / 签到 / 看广告 / CPS |
| 登录态 | 无,客户端发 phone 当 ID | JWT access + refresh |
| 业务 | mock parse(无账号体系) | 完整账号体系 + JWT |
| 登录态 | 无,客户端发 phone 当 ID | JWT access+refresh |
| 配置 | 硬编码 | pydantic-settings + `.env` |
| 域名 | api.shaguabijia.com(试验) | app-api.shaguabijia.com(正式) |
| 短信登录 | 无 | 有(mock 实现,留扩展点) |
| 域名 | api.shaguabijia.com (试验) | app-api.shaguabijia.com (正式) |
极光一键登录的实现(REST 调用、RSA padding 试错顺序)参考自试验后台,逻辑等价。
极光一键登录的具体实现(REST 调用、RSA padding 试错顺序)直接参考自试验后台,
逻辑等价。
@@ -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 comparison and savings heads
Revision ID: 8ac524a8ea02
Revises: d4e5f6a7b8c9, savings_report_fields
Create Date: 2026-06-02 09:53:06.924912
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '8ac524a8ea02'
down_revision: Union[str, Sequence[str], None] = ('d4e5f6a7b8c9', 'savings_report_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,65 +0,0 @@
"""新增 price_report 上报更低价表
Revision ID: 9258bddde4ea
Revises: ad60a1b2c3d4
Create Date: 2026-06-05 10:15:51.508598
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9258bddde4ea'
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:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('price_report',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('comparison_record_id', sa.Integer(), nullable=True),
sa.Column('store_name', sa.String(length=128), nullable=True),
sa.Column('dish_summary', sa.String(length=256), nullable=True),
sa.Column('original_platform_id', sa.String(length=32), nullable=True),
sa.Column('original_platform_name', sa.String(length=32), nullable=True),
sa.Column('original_price_cents', sa.Integer(), nullable=True),
sa.Column('reported_platform_id', sa.String(length=32), nullable=False),
sa.Column('reported_platform_name', sa.String(length=32), nullable=False),
sa.Column('reported_price_cents', sa.Integer(), nullable=False),
sa.Column('images', sa.JSON(), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('reject_reason', sa.String(length=256), nullable=True),
sa.Column('reward_coins', sa.Integer(), nullable=True),
sa.Column('reviewed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['comparison_record_id'], ['comparison_record.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('price_report', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_price_report_comparison_record_id'), ['comparison_record_id'], unique=False)
batch_op.create_index(batch_op.f('ix_price_report_created_at'), ['created_at'], unique=False)
batch_op.create_index(batch_op.f('ix_price_report_status'), ['status'], unique=False)
batch_op.create_index(batch_op.f('ix_price_report_user_id'), ['user_id'], unique=False)
# 注:autogenerate 另检测到「删除 order_record 表」——那是已降级为审计的历史表
# (model 已移除、库表保留),与本次无关,手动剔除,避免误删他人审计数据。
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# (对称:upgrade 未删 order_record,downgrade 也不重建它)
with op.batch_alter_table('price_report', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_price_report_user_id'))
batch_op.drop_index(batch_op.f('ix_price_report_status'))
batch_op.drop_index(batch_op.f('ix_price_report_created_at'))
batch_op.drop_index(batch_op.f('ix_price_report_comparison_record_id'))
op.drop_table('price_report')
# ### end Alembic commands ###
@@ -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
-47
View File
@@ -1,47 +0,0 @@
"""ad_ecpm_record table (广告展示 eCPM 上报记录,内部收益统计/对账)
Revision ID: a1b2c3d4e5f6
Revises: f01db5d77dac
Create Date: 2026-05-31 11:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, Sequence[str], None] = 'f01db5d77dac'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'ad_ecpm_record',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('ad_type', 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('ecpm_raw', sa.String(length=32), nullable=False),
sa.Column('report_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_ecpm_record', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_ad_ecpm_record_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_ad_ecpm_record_report_date'), ['report_date'], unique=False)
batch_op.create_index(batch_op.f('ix_ad_ecpm_record_created_at'), ['created_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('ad_ecpm_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_ad_ecpm_record_created_at'))
batch_op.drop_index(batch_op.f('ix_ad_ecpm_record_report_date'))
batch_op.drop_index(batch_op.f('ix_ad_ecpm_record_user_id'))
op.drop_table('ad_ecpm_record')
@@ -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")
-50
View File
@@ -1,50 +0,0 @@
"""ad_reward_record table (看激励视频发奖记录)
Revision ID: c8d9e0f1a2b3
Revises: b1c2d3e4f5a6
Create Date: 2026-05-26 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c8d9e0f1a2b3'
down_revision: Union[str, Sequence[str], None] = 'b1c2d3e4f5a6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'ad_reward_record',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('trans_id', sa.String(length=64), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('coin', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('reward_date', sa.String(length=10), nullable=False),
sa.Column('reward_name', sa.String(length=64), nullable=True),
sa.Column('raw', sa.String(length=1024), 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'),
)
with op.batch_alter_table('ad_reward_record', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_ad_reward_record_trans_id'), ['trans_id'], unique=True)
batch_op.create_index(batch_op.f('ix_ad_reward_record_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_ad_reward_record_reward_date'), ['reward_date'], unique=False)
batch_op.create_index(batch_op.f('ix_ad_reward_record_created_at'), ['created_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('ad_reward_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_ad_reward_record_created_at'))
batch_op.drop_index(batch_op.f('ix_ad_reward_record_reward_date'))
batch_op.drop_index(batch_op.f('ix_ad_reward_record_user_id'))
batch_op.drop_index(batch_op.f('ix_ad_reward_record_trans_id'))
op.drop_table('ad_reward_record')
-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")
-65
View File
@@ -1,65 +0,0 @@
"""admin_user + admin_audit_log tables (运营 admin 后台:账号 + 操作审计)
Revision ID: ad60a1b2c3d4
Revises: 8ac524a8ea02
Create Date: 2026-06-03 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ad60a1b2c3d4'
down_revision: Union[str, Sequence[str], None] = '8ac524a8ea02'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'admin_user',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('username', sa.String(length=64), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('role', sa.String(length=20), nullable=False, server_default='operator'),
sa.Column('status', sa.String(length=20), nullable=False, server_default='active'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
with op.batch_alter_table('admin_user', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_admin_user_username'), ['username'], unique=True)
op.create_table(
'admin_audit_log',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('admin_id', sa.Integer(), nullable=False),
sa.Column('admin_username', sa.String(length=64), nullable=False),
sa.Column('action', sa.String(length=64), nullable=False),
sa.Column('target_type', sa.String(length=32), nullable=False),
sa.Column('target_id', sa.String(length=64), nullable=True),
sa.Column('detail', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=True),
sa.Column('ip', sa.String(length=64), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['admin_id'], ['admin_user.id'], ),
sa.PrimaryKeyConstraint('id'),
)
with op.batch_alter_table('admin_audit_log', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_admin_audit_log_admin_id'), ['admin_id'], unique=False)
batch_op.create_index(batch_op.f('ix_admin_audit_log_action'), ['action'], unique=False)
batch_op.create_index(batch_op.f('ix_admin_audit_log_created_at'), ['created_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('admin_audit_log', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_admin_audit_log_created_at'))
batch_op.drop_index(batch_op.f('ix_admin_audit_log_action'))
batch_op.drop_index(batch_op.f('ix_admin_audit_log_admin_id'))
op.drop_table('admin_audit_log')
with op.batch_alter_table('admin_user', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_admin_user_username'))
op.drop_table('admin_user')
-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,32 +0,0 @@
"""comparison_record 加 best_deeplink(再次比价直达商家深链)
Revision ID: b7e2c1a9f4d3
Revises: 9258bddde4ea
Create Date: 2026-06-05
「再次比价」要直达上次最低价平台的商家/商品页。深链(link)在比价时客户端已从剪贴板采到
(collectedLinks[best_index]),本列把它落库;再次比价时写剪贴板 + launch 该平台 App 直达。
旧记录无此列值(None) → 前端降级为打开对应 App 首页。
手写迁移(只加一列):autogenerate 会误报"删 order_record"(降级为审计的历史表,model 已移除、
库表保留),手写规避该噪音。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "b7e2c1a9f4d3"
down_revision: Union[str, Sequence[str], None] = "9258bddde4ea"
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:
batch_op.add_column(sa.Column("best_deeplink", sa.String(length=1024), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("comparison_record", schema=None) as batch_op:
batch_op.drop_column("best_deeplink")
@@ -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")
@@ -0,0 +1,49 @@
"""add order_record table
Revision ID: c64a944a6139
Revises: 9c559acc164a
Create Date: 2026-05-26 21:09:40.047619
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c64a944a6139'
down_revision: Union[str, Sequence[str], None] = '9c559acc164a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('order_record',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('platform', sa.String(length=30), nullable=False),
sa.Column('store_name', sa.String(length=128), nullable=False),
sa.Column('dishes', sa.Text(), nullable=False),
sa.Column('order_date', sa.String(length=10), nullable=False),
sa.Column('price', sa.String(length=20), nullable=False),
sa.Column('original_price', sa.String(length=20), nullable=True),
sa.Column('savings', sa.String(length=20), 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')
)
with op.batch_alter_table('order_record', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_order_record_user_id'), ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('order_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_order_record_user_id'))
op.drop_table('order_record')
# ### end Alembic commands ###
@@ -1,49 +0,0 @@
"""cash_transaction table
Revision ID: 3d9cb19df76f
Revises: 357520ea3015
Create Date: 2026-05-24 16:21:22.950992
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '3d9cb19df76f'
down_revision: Union[str, Sequence[str], None] = '357520ea3015'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('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.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('cash_transaction', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_cash_transaction_created_at'), ['created_at'], unique=False)
batch_op.create_index(batch_op.f('ix_cash_transaction_user_id'), ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('cash_transaction', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_cash_transaction_user_id'))
batch_op.drop_index(batch_op.f('ix_cash_transaction_created_at'))
op.drop_table('cash_transaction')
# ### end Alembic commands ###
@@ -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,41 +0,0 @@
"""comparison_milestone_claim table (比价战绩里程碑领取记录)
Revision ID: d4e5f6a7b8c9
Revises: c3d4e5f6a7b8
Create Date: 2026-05-31 18:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd4e5f6a7b8c9'
down_revision: Union[str, Sequence[str], None] = 'c3d4e5f6a7b8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'comparison_milestone_claim',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('milestone', sa.Integer(), nullable=False),
sa.Column('coin_awarded', sa.Integer(), nullable=False),
sa.Column('claimed_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', 'milestone', name='uq_compare_milestone_user'),
)
with op.batch_alter_table('comparison_milestone_claim', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_comparison_milestone_claim_user_id'), ['user_id'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('comparison_milestone_claim', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_comparison_milestone_claim_user_id'))
op.drop_table('comparison_milestone_claim')
@@ -1,28 +0,0 @@
"""comparison_record.information (done 帧文案/失败原因)
Revision ID: c3d4e5f6a7b8
Revises: b2c3d4e5f6a7
Create Date: 2026-05-31 18:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c3d4e5f6a7b8'
down_revision: Union[str, Sequence[str], None] = 'b2c3d4e5f6a7'
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:
batch_op.add_column(sa.Column('information', sa.String(length=256), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
batch_op.drop_column('information')
@@ -1,64 +0,0 @@
"""comparison_record table (比价记录:每次比价完整明细,「我的比价记录」数据源)
Revision ID: b2c3d4e5f6a7
Revises: a1b2c3d4e5f6
Create Date: 2026-05-31 15:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b2c3d4e5f6a7'
down_revision: Union[str, Sequence[str], None] = 'a1b2c3d4e5f6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'comparison_record',
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=True),
sa.Column('business_type', sa.String(length=16), nullable=False),
sa.Column('trace_id', sa.String(length=64), nullable=False),
sa.Column('source_platform_id', sa.String(length=32), nullable=True),
sa.Column('source_platform_name', sa.String(length=32), nullable=True),
sa.Column('source_package', sa.String(length=128), nullable=True),
sa.Column('source_price_cents', sa.Integer(), nullable=True),
sa.Column('best_platform_id', sa.String(length=32), nullable=True),
sa.Column('best_platform_name', sa.String(length=32), nullable=True),
sa.Column('best_price_cents', sa.Integer(), nullable=True),
sa.Column('saved_amount_cents', sa.Integer(), nullable=True),
sa.Column('is_source_best', sa.Boolean(), nullable=True),
sa.Column('store_name', sa.String(length=128), nullable=True),
sa.Column('total_dish_count', sa.Integer(), nullable=True),
sa.Column('skipped_dish_count', sa.Integer(), nullable=True),
sa.Column('status', sa.String(length=16), nullable=False),
# PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐
sa.Column('items', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=False),
sa.Column('comparison_results', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=False),
sa.Column('skipped_dish_names', sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), 'postgresql'), nullable=False),
sa.Column('raw_payload', 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.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'trace_id', name='uq_comparison_user_trace'),
)
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_comparison_record_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_comparison_record_business_type'), ['business_type'], unique=False)
batch_op.create_index(batch_op.f('ix_comparison_record_created_at'), ['created_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('comparison_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_comparison_record_created_at'))
batch_op.drop_index(batch_op.f('ix_comparison_record_business_type'))
batch_op.drop_index(batch_op.f('ix_comparison_record_user_id'))
op.drop_table('comparison_record')
@@ -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,36 +0,0 @@
"""convert savings_record.dishes from json to jsonb
PG only. SQLite 上 JSON/JSONB 都是 TEXT, 此迁移是 no-op。
Revision ID: ef96beb47b1e
Revises: c8d9e0f1a2b3
Create Date: 2026-05-29 10:57:21.471774
"""
from typing import Sequence, Union
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB
revision: str = 'ef96beb47b1e'
down_revision: Union[str, Sequence[str], None] = 'c8d9e0f1a2b3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
if op.get_bind().dialect.name != "postgresql":
return
op.execute(
"ALTER TABLE savings_record "
"ALTER COLUMN dishes TYPE JSONB USING dishes::JSONB"
)
def downgrade() -> None:
if op.get_bind().dialect.name != "postgresql":
return
op.execute(
"ALTER TABLE savings_record "
"ALTER COLUMN dishes TYPE JSON USING dishes::JSON"
)
@@ -1,26 +0,0 @@
"""merge pg_jsonb and feedback heads
Revision ID: f01db5d77dac
Revises: ef96beb47b1e, d1e2f3a4b5c6
Create Date: 2026-05-29 16:09:58.498935
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f01db5d77dac'
down_revision: Union[str, Sequence[str], None] = ('ef96beb47b1e', 'd1e2f3a4b5c6')
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")
-44
View File
@@ -1,44 +0,0 @@
"""feedback table (用户帮助与反馈)
Revision ID: d1e2f3a4b5c6
Revises: c8d9e0f1a2b3
Create Date: 2026-05-28 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd1e2f3a4b5c6'
down_revision: Union[str, Sequence[str], None] = 'c8d9e0f1a2b3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'feedback',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('contact', sa.String(length=128), nullable=False),
sa.Column('images', sa.JSON(), nullable=True),
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'),
)
with op.batch_alter_table('feedback', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_feedback_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_feedback_created_at'), ['created_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('feedback', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_feedback_created_at'))
batch_op.drop_index(batch_op.f('ix_feedback_user_id'))
op.drop_table('feedback')
@@ -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')
-49
View File
@@ -1,49 +0,0 @@
"""savings_record table
Revision ID: bf2a67c0e2ab
Revises: 3d9cb19df76f
Create Date: 2026-05-24 16:24:34.622394
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'bf2a67c0e2ab'
down_revision: Union[str, Sequence[str], None] = '3d9cb19df76f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('savings_record',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('order_amount_cents', sa.Integer(), nullable=False),
sa.Column('saved_amount_cents', sa.Integer(), nullable=False),
sa.Column('platform', sa.String(length=32), nullable=True),
sa.Column('title', sa.String(length=128), nullable=True),
sa.Column('source', 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')
)
with op.batch_alter_table('savings_record', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_savings_record_created_at'), ['created_at'], unique=False)
batch_op.create_index(batch_op.f('ix_savings_record_user_id'), ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('savings_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_savings_record_user_id'))
batch_op.drop_index(batch_op.f('ix_savings_record_created_at'))
op.drop_table('savings_record')
# ### end Alembic commands ###
-49
View File
@@ -1,49 +0,0 @@
"""savings_record:真实比价上报字段 + (user_id, client_event_id) 幂等唯一约束
把 savings_record 升级为「记账唯一真相表」:真实上报(source='compare')写入
原价/比价价/支付渠道/平台包名/源平台名/源链接/幂等键/device_id;
demo seeder 行(source='demo')这些列均为 NULL。
Revision ID: savings_report_fields
Revises: f01db5d77dac
Create Date: 2026-05-31 19:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'savings_report_fields'
down_revision: Union[str, Sequence[str], None] = 'f01db5d77dac'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('savings_record', schema=None) as batch_op:
batch_op.add_column(sa.Column('original_price_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('compared_price_cents', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('pay_channel', sa.String(length=16), nullable=True))
batch_op.add_column(sa.Column('platform_package', sa.String(length=128), nullable=True))
batch_op.add_column(sa.Column('source_platform_name', sa.String(length=32), nullable=True))
batch_op.add_column(sa.Column('source_deeplink', sa.String(length=512), nullable=True))
batch_op.add_column(sa.Column('client_event_id', sa.String(length=64), nullable=True))
batch_op.add_column(sa.Column('device_id', sa.String(length=128), nullable=True))
# (user_id, client_event_id) 幂等;demo 行 client_event_id 为 NULL 不冲突
batch_op.create_unique_constraint('uq_savings_user_event', ['user_id', 'client_event_id'])
def downgrade() -> None:
with op.batch_alter_table('savings_record', schema=None) as batch_op:
batch_op.drop_constraint('uq_savings_user_event', type_='unique')
batch_op.drop_column('device_id')
batch_op.drop_column('client_event_id')
batch_op.drop_column('source_deeplink')
batch_op.drop_column('source_platform_name')
batch_op.drop_column('platform_package')
batch_op.drop_column('pay_channel')
batch_op.drop_column('compared_price_cents')
batch_op.drop_column('original_price_cents')
-33
View File
@@ -1,33 +0,0 @@
"""savings_record.shop_name + dishes(订单明细卡:店铺名 + 菜品列表)
Revision ID: b1c2d3e4f5a6
Revises: a7c8d9e0f1b2
Create Date: 2026-05-26 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b1c2d3e4f5a6'
down_revision: Union[str, Sequence[str], None] = 'a7c8d9e0f1b2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('savings_record', schema=None) as batch_op:
batch_op.add_column(sa.Column('shop_name', sa.String(length=128), nullable=True))
# dishes 存 JSON(SQLite 落 TEXT);旧行给 '[]' 默认,满足 NOT NULL
batch_op.add_column(
sa.Column('dishes', sa.JSON(), nullable=False, server_default=sa.text("'[]'"))
)
def downgrade() -> None:
with op.batch_alter_table('savings_record', schema=None) as batch_op:
batch_op.drop_column('dishes')
batch_op.drop_column('shop_name')
-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')
-32
View File
@@ -1,32 +0,0 @@
"""unique index on user.wechat_openid (一个微信只绑一个账号)
Revision ID: a7c8d9e0f1b2
Revises: f5b2d3c4e6a7
Create Date: 2026-05-25 19:10:00.000000
注意:升级前需保证 wechat_openid 无重复值(NULL 不算重复)。
本仓库 data/app.db 升级前已手动清掉重复 openid(保留最近绑定的账号)。
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'a7c8d9e0f1b2'
down_revision: Union[str, Sequence[str], None] = 'f5b2d3c4e6a7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# nullable 列上的唯一索引:SQLite / Postgres 都允许多个 NULL,只约束非空值唯一
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.create_index(
batch_op.f('ix_user_wechat_openid'), ['wechat_openid'], unique=True
)
def downgrade() -> None:
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_user_wechat_openid'))
-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,30 +0,0 @@
"""user.wechat_nickname + wechat_avatar_url
Revision ID: f5b2d3c4e6a7
Revises: e4a1c2b3d4f5
Create Date: 2026-05-25 18:40:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f5b2d3c4e6a7'
down_revision: Union[str, Sequence[str], None] = 'e4a1c2b3d4f5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('wechat_nickname', sa.String(length=64), nullable=True))
batch_op.add_column(sa.Column('wechat_avatar_url', sa.String(length=512), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_column('wechat_avatar_url')
batch_op.drop_column('wechat_nickname')
@@ -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,96 +0,0 @@
"""welfare tables: coin account, coin txn, signin, user task
Revision ID: 357520ea3015
Revises: 9c559acc164a
Create Date: 2026-05-24 16:16:20.677711
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '357520ea3015'
down_revision: Union[str, Sequence[str], None] = '9c559acc164a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('coin_account',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('coin_balance', sa.Integer(), nullable=False),
sa.Column('cash_balance_cents', sa.Integer(), nullable=False),
sa.Column('total_coin_earned', sa.Integer(), 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')
)
op.create_table('coin_transaction',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('amount', sa.Integer(), nullable=False),
sa.Column('balance_after', 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.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('coin_transaction', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_coin_transaction_created_at'), ['created_at'], unique=False)
batch_op.create_index(batch_op.f('ix_coin_transaction_user_id'), ['user_id'], unique=False)
op.create_table('signin_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('cycle_day', sa.Integer(), nullable=False),
sa.Column('streak', sa.Integer(), nullable=False),
sa.Column('coin_awarded', sa.Integer(), 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('user_id', 'signin_date', name='uq_signin_user_date')
)
with op.batch_alter_table('signin_record', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_signin_record_user_id'), ['user_id'], unique=False)
op.create_table('user_task',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('task_key', sa.String(length=48), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('coin_awarded', sa.Integer(), nullable=False),
sa.Column('completed_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', 'task_key', name='uq_task_user_key')
)
with op.batch_alter_table('user_task', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_user_task_user_id'), ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user_task', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_user_task_user_id'))
op.drop_table('user_task')
with op.batch_alter_table('signin_record', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_signin_record_user_id'))
op.drop_table('signin_record')
with op.batch_alter_table('coin_transaction', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_coin_transaction_user_id'))
batch_op.drop_index(batch_op.f('ix_coin_transaction_created_at'))
op.drop_table('coin_transaction')
op.drop_table('coin_account')
# ### end Alembic commands ###
@@ -1,56 +0,0 @@
"""withdraw_order table + user.wechat_openid
Revision ID: e4a1c2b3d4f5
Revises: bf2a67c0e2ab
Create Date: 2026-05-25 16:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e4a1c2b3d4f5'
down_revision: Union[str, Sequence[str], None] = 'bf2a67c0e2ab'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'withdraw_order',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('out_bill_no', sa.String(length=64), nullable=False),
sa.Column('amount_cents', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('wechat_state', sa.String(length=32), nullable=True),
sa.Column('transfer_bill_no', sa.String(length=64), nullable=True),
sa.Column('package_info', sa.String(length=512), nullable=True),
sa.Column('fail_reason', sa.String(length=256), 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'),
)
with op.batch_alter_table('withdraw_order', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_withdraw_order_created_at'), ['created_at'], unique=False)
batch_op.create_index(batch_op.f('ix_withdraw_order_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_withdraw_order_out_bill_no'), ['out_bill_no'], unique=True)
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('wechat_openid', sa.String(length=64), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_column('wechat_openid')
with op.batch_alter_table('withdraw_order', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_withdraw_order_out_bill_no'))
batch_op.drop_index(batch_op.f('ix_withdraw_order_user_id'))
batch_op.drop_index(batch_op.f('ix_withdraw_order_created_at'))
op.drop_table('withdraw_order')
@@ -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")
-6
View File
@@ -1,6 +0,0 @@
"""运营 Admin 后台子应用。
独立 FastAPI app(app.admin.main:admin_app),独立进程/端口运行,复用 App 的
models/repositories/integrations + 同一个 DB,但鉴权完全隔离(独立 JWT secret)。
现有 app.main:app 不 import 本包,admin 崩溃不影响 App 主进程。
"""
-38
View File
@@ -1,38 +0,0 @@
"""审计写入门面。
每个 admin 写操作调一次 write_audit,把"谁(admin)在哪个 IP 对什么(target)做了什么
(action)+ 前后值(detail)"落进 admin_audit_log。
⚠️ 涉钱/涉状态的写操作:传 commit=False,和业务写操作放同一事务一起 commit,
保证"改了就有痕、有痕就改了"原子(见 plan 风险点 1)。轻量操作可 commit=True。
"""
from __future__ import annotations
from sqlalchemy.orm import Session
from app.admin.repositories import audit_log as audit_repo
from app.models.admin import AdminUser
def write_audit(
db: Session,
admin: AdminUser,
*,
action: str,
target_type: str,
target_id: str | int | None = None,
detail: dict | None = None,
ip: str | None = None,
commit: bool = True,
) -> None:
audit_repo.add_audit_log(
db,
admin_id=admin.id,
admin_username=admin.username,
action=action,
target_type=target_type,
target_id=str(target_id) if target_id is not None else None,
detail=detail,
ip=ip,
commit=commit,
)
-84
View File
@@ -1,84 +0,0 @@
"""Admin API 共享依赖:DB session、当前 admin、角色守卫、客户端 IP。
仿 app/api/deps.py 的 get_current_user,但验的是独立的 admin JWT、查的是 admin_user 表。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.admin.repositories import admin_user as admin_repo
from app.admin.security import AdminTokenError, decode_admin_token
from app.db.session import get_db
from app.models.admin import AdminUser
_bearer = HTTPBearer(auto_error=False, scheme_name="AdminBearer")
def get_current_admin(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
db: Annotated[Session, Depends(get_db)],
) -> AdminUser:
"""从 Authorization: Bearer <admin_token> 解出当前管理员。失败统一 401。"""
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="missing bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_admin_token(credentials.credentials)
except AdminTokenError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e),
headers={"WWW-Authenticate": "Bearer"},
) from e
admin = admin_repo.get_by_id(db, int(payload["sub"]))
if admin is None or admin.status != "active":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="admin not found or disabled",
headers={"WWW-Authenticate": "Bearer"},
)
return admin
CurrentAdmin = Annotated[AdminUser, Depends(get_current_admin)]
AdminDb = Annotated[Session, Depends(get_db)]
def require_role(*roles: str):
"""角色守卫依赖工厂。super_admin 恒通过(全权)。
用法:在路由签名加 `_: Annotated[AdminUser, Depends(require_role("finance"))]`,
或 `dependencies=[Depends(require_role("finance"))]`(不需要拿 admin 时)。
"""
allowed = set(roles)
def _checker(admin: CurrentAdmin) -> AdminUser:
if admin.role != "super_admin" and admin.role not in allowed:
need = sorted(allowed | {"super_admin"})
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"role '{admin.role}' not allowed (need one of {need})",
)
return admin
return _checker
def get_client_ip(request: Request) -> str:
"""取客户端 IP(审计日志用)。生产经 nginx 反代,优先 X-Forwarded-For 第一段;否则直连 IP。
⚠️ XFF 可被客户端伪造。nginx 必须用 `proxy_set_header X-Forwarded-For $remote_addr`
覆盖客户端传入值(见 M4 部署),否则审计里的 IP 可被伪造。审计 IP 仅作记录、不参与鉴权。
"""
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else ""

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