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
321 changed files with 572 additions and 23950 deletions
-49
View File
@@ -40,56 +40,7 @@ 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=
# ===== 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
# ===== 穿山甲激励视频(服务端发奖回调)=====
# 看完激励视频后穿山甲服务器 S2S 回调本服务发金币(客户端不参与发奖)。
# PANGLE_REWARD_SECRET 是穿山甲后台配置的"奖励校验密钥",用于验签,从后台取到后填这里;
# 配齐并把 ENABLED=true 后,/api/v1/ad/pangle-callback 才受理回调(否则 503)。
PANGLE_CALLBACK_ENABLED=false
PANGLE_REWARD_SECRET=
# ⚠️ 仅本地联调:true 时开放 POST /api/v1/ad/test-grant,让 debug 客户端看完广告直接发奖,
# 验证"看广告→金币到账"全链路(未部署公网、穿山甲 S2S 打不到本地时用)。生产必须 false(绕过反作弊)。
AD_REWARD_TEST_GRANT_ENABLED=false
+1 -11
View File
@@ -23,13 +23,7 @@ dist/
*.db-journal
*.db-shm
*.db-wal
# data/ 整体不入库(运行时数据/上传文件/大二进制)。例外:邀请落地页 dl.html——
# 它既是生产落地页又是本地测试资产,纳入 git 便于同事一致测试
# (见 docs/邀请功能-实现原理与本地测试.md)。其余(avatars/ / *.apk / app.db 等)仍忽略。
data/*
!data/media/
data/media/*
!data/media/dl.html
data/
secrets/*
!secrets/.gitkeep
@@ -40,7 +34,3 @@ secrets/*
.idea/
.vscode/
*.swp
# 运行日志(run.sh 输出, 不入库)
*.log
logs/
+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,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 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")
-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')
-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,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")
@@ -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,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,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')
-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')
@@ -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")
-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,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')
-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')
-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
-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')
-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'))
@@ -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 ""
-88
View File
@@ -1,88 +0,0 @@
"""Admin 后台 FastAPI app(独立进程)。
启动:uvicorn app.admin.main:admin_app --host 127.0.0.1 --port 8771
复用 App 的 DB/models/repositories/integrations;鉴权独立(admin JWT,见 app/admin/security.py)。
现有 app.main:app 不 import 本模块,两进程互不影响。
"""
from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.admin.routers.admins import router as admins_router
from app.admin.routers.audit import router as audit_router
from app.admin.routers.auth import router as auth_router
from app.admin.routers.config import router as config_router
from app.admin.routers.dashboard import router as dashboard_router
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
from app.admin.routers.feedback import router as feedback_router
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router
from app.admin.routers.price_report import router as price_report_router
from app.admin.routers.users import router as users_router
from app.admin.routers.wallet import router as wallet_router
from app.admin.routers.withdraw import router as withdraw_router
from app.core.config import settings
from app.core.logging import setup_logging
setup_logging(debug=settings.APP_DEBUG)
logger = logging.getLogger("shagua.admin")
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
logger.info(
"admin app started env=%s db=%s",
settings.APP_ENV,
settings.DATABASE_URL.split("://", 1)[0],
)
yield
logger.info("admin app shutting down")
admin_app = FastAPI(
title=f"{settings.APP_NAME} · Admin",
version="0.1.0",
docs_url="/admin/docs" if not settings.is_prod else None,
redoc_url=None,
lifespan=lifespan,
)
# admin 前端独立部署。生产同域(nginx)无需 CORS;本地 next dev 跨域需放行开发源。
_dev_origins = [
"http://localhost:3001",
"http://127.0.0.1:3001",
"http://localhost:3000",
"http://127.0.0.1:3000",
]
_origins = settings.cors_origins_list or ([] if settings.is_prod else _dev_origins)
if _origins:
admin_app.add_middleware(
CORSMiddleware,
allow_origins=_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@admin_app.get("/admin/api/health", tags=["meta"])
def health() -> dict[str, str]:
return {"status": "ok", "service": "admin"}
admin_app.include_router(auth_router)
admin_app.include_router(dashboard_router)
admin_app.include_router(ops_stat_config_router)
admin_app.include_router(ops_marquee_seed_router)
admin_app.include_router(users_router)
admin_app.include_router(wallet_router)
admin_app.include_router(withdraw_router)
admin_app.include_router(price_report_router)
admin_app.include_router(feedback_router)
admin_app.include_router(admins_router)
admin_app.include_router(audit_router)
admin_app.include_router(config_router)
-1
View File
@@ -1 +0,0 @@
"""admin 专用数据访问层(跨用户查询 + admin 账号 + 审计 + 大盘聚合)。"""
-64
View File
@@ -1,64 +0,0 @@
"""admin_user 表 CRUD。"""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.security import hash_password
from app.models.admin import AdminUser
def get_by_id(db: Session, admin_id: int) -> AdminUser | None:
return db.get(AdminUser, admin_id)
def get_by_username(db: Session, username: str) -> AdminUser | None:
stmt = select(AdminUser).where(AdminUser.username == username)
return db.execute(stmt).scalar_one_or_none()
def create_admin(
db: Session, *, username: str, password: str, role: str = "operator"
) -> AdminUser:
admin = AdminUser(
username=username,
password_hash=hash_password(password),
role=role,
)
db.add(admin)
db.commit()
db.refresh(admin)
return admin
def update_last_login(db: Session, admin: AdminUser) -> None:
admin.last_login_at = datetime.now(timezone.utc)
db.commit()
def list_admins(db: Session) -> list[AdminUser]:
stmt = select(AdminUser).order_by(AdminUser.id)
return list(db.execute(stmt).scalars().all())
def set_role(db: Session, admin: AdminUser, *, role: str) -> AdminUser:
admin.role = role
db.commit()
db.refresh(admin)
return admin
def set_status(db: Session, admin: AdminUser, *, status: str) -> AdminUser:
admin.status = status
db.commit()
db.refresh(admin)
return admin
def set_password(db: Session, admin: AdminUser, *, password: str) -> AdminUser:
admin.password_hash = hash_password(password)
db.commit()
db.refresh(admin)
return admin
-70
View File
@@ -1,70 +0,0 @@
"""admin_audit_log 写入 + 查询。
审计日志只增不改不删——任何写操作经 app.admin.audit.write_audit 落一条。
"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.admin import AdminAuditLog
def add_audit_log(
db: Session,
*,
admin_id: int,
admin_username: str,
action: str,
target_type: str,
target_id: str | None = None,
detail: dict | None = None,
ip: str | None = None,
commit: bool = True,
) -> AdminAuditLog:
"""插一条审计。commit=False 时只 flush,让调用方把审计和业务写操作放同一事务。"""
log = AdminAuditLog(
admin_id=admin_id,
admin_username=admin_username,
action=action,
target_type=target_type,
target_id=target_id,
detail=detail,
ip=ip,
)
db.add(log)
if commit:
db.commit()
db.refresh(log)
else:
db.flush()
return log
def list_audit_logs(
db: Session,
*,
action: str | None = None,
target_type: str | None = None,
admin_id: int | None = None,
limit: int = 50,
cursor: int | None = None,
) -> tuple[list[AdminAuditLog], int | None]:
"""游标分页(id 倒序),与现有 list_* 约定一致。返回 (rows, next_cursor)。"""
stmt = select(AdminAuditLog)
if action:
stmt = stmt.where(AdminAuditLog.action == action)
if target_type:
stmt = stmt.where(AdminAuditLog.target_type == target_type)
if admin_id is not None:
stmt = stmt.where(AdminAuditLog.admin_id == admin_id)
if cursor is not None:
stmt = stmt.where(AdminAuditLog.id < cursor)
stmt = stmt.order_by(AdminAuditLog.id.desc())
rows = list(db.execute(stmt.limit(limit + 1)).scalars().all())
has_more = len(rows) > limit
items = rows[:limit]
# next_cursor 必须是"本页返回的最后一条"的 id(下一页查 id < 它),不能用 rows[limit]——
# rows[limit] 是探测下一页用的第 limit+1 条,它既不在本页也不在下页 → 每页边界丢一条。
next_cursor = items[-1].id if has_more else None
return items, next_cursor
-81
View File
@@ -1,81 +0,0 @@
"""admin 写操作 repo(状态改写)。
涉钱的金币/提现复用 app.repositories.wallet(grant_coins / refresh_withdraw_status /
reconcile_pending_withdraws),不在这里重写——重写涉钱逻辑就是给自己埋雷。
set_user_status / update_feedback_status 支持 commit=False,让 router 把"业务写 + 审计写"
放进同一事务一起 commit(原子:改了就有审计、有审计就真改了,见 plan 风险点 1)。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ
from app.models.feedback import Feedback
from app.models.price_report import PriceReport
from app.models.user import User
def set_user_status(db: Session, user: User, *, status: str, commit: bool = True) -> User:
user.status = status
if commit:
db.commit()
db.refresh(user)
else:
db.flush()
return user
def set_user_debug_trace(
db: Session, user: User, *, enabled: bool, commit: bool = True
) -> User:
"""开关用户「复制调试链接」权限。同 set_user_status:支持 commit=False 让 router 把
业务写 + 审计写放进同一事务。"""
user.debug_trace_enabled = enabled
if commit:
db.commit()
db.refresh(user)
else:
db.flush()
return user
def update_feedback_status(
db: Session, feedback: Feedback, *, status: str, commit: bool = True
) -> Feedback:
feedback.status = status
if commit:
db.commit()
db.refresh(feedback)
else:
db.flush()
return feedback
def review_price_report(
db: Session,
report: PriceReport,
*,
status: str,
reward_coins: int | None = None,
reject_reason: str | None = None,
commit: bool = True,
) -> PriceReport:
"""审核上报:置 approved/rejected + 记审核时间;通过填 reward_coins、拒绝填 reject_reason。
发金币(wallet.grant_coins)不在这里——由 router 在同一事务里调,涉钱逻辑不重写(见模块头注释)。
"""
report.status = status
if reward_coins is not None:
report.reward_coins = reward_coins
if reject_reason is not None:
report.reject_reason = reject_reason
report.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None) # 北京 wall-clock,同 created_at
if commit:
db.commit()
db.refresh(report)
else:
db.flush()
return report
-429
View File
@@ -1,429 +0,0 @@
"""admin 跨用户查询(去掉现有 repo 的 user_id 强制过滤)+ 通用游标分页 helper + 用户概览。
现有 app/repositories/ 的 list_* 都强绑单个 user_id(C 端只看自己);admin 要看全量、按条件筛,
所以在这里另起一套。游标约定与现有一致:id 倒序,cursor=上页最后一条 id,返回 (items, next_cursor)。
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from sqlalchemy import Select, asc, desc, func, or_, select
from sqlalchemy.orm import Session
from app.models.admin import AdminAuditLog
from app.models.comparison import ComparisonRecord
from app.models.feedback import Feedback
from app.models.price_report import PriceReport
from app.models.user import User
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
def cursor_paginate(
db: Session, stmt: Select, id_col, *, limit: int, cursor: int | None
) -> tuple[list, int | None]:
"""通用游标分页(id 倒序)。stmt 不要预先带 order_by/limit。
多取 1 条探测有没有下一页;next_cursor 取本页最后一条的 id(下一页查 id < 它),
绝不用第 limit+1 条的 id——那条既不在本页也不在下页,会每页边界丢一条(见 audit_log 同款修复)。
"""
if cursor is not None:
stmt = stmt.where(id_col < cursor)
stmt = stmt.order_by(id_col.desc()).limit(limit + 1)
rows = list(db.execute(stmt).scalars().all())
has_more = len(rows) > limit
items = rows[:limit]
next_cursor = items[-1].id if has_more else None
return items, next_cursor
def list_users(
db: Session,
*,
phone: str | None = None,
register_channel: str | None = None,
status: str | None = None,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[User], int | None]:
stmt = select(User)
if phone:
stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配
if register_channel:
stmt = stmt.where(User.register_channel == register_channel)
if status:
stmt = stmt.where(User.status == status)
return cursor_paginate(db, stmt, User.id, limit=limit, cursor=cursor)
def list_all_coin_transactions(
db: Session,
*,
user_id: int | None = None,
biz_type: str | None = None,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[CoinTransaction], int | None]:
stmt = select(CoinTransaction)
if user_id is not None:
stmt = stmt.where(CoinTransaction.user_id == user_id)
if biz_type:
stmt = stmt.where(CoinTransaction.biz_type == biz_type)
return cursor_paginate(db, stmt, CoinTransaction.id, limit=limit, cursor=cursor)
def list_all_cash_transactions(
db: Session,
*,
user_id: int | None = None,
biz_type: str | None = None,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[CashTransaction], int | None]:
stmt = select(CashTransaction)
if user_id is not None:
stmt = stmt.where(CashTransaction.user_id == user_id)
if biz_type:
stmt = stmt.where(CashTransaction.biz_type == biz_type)
return cursor_paginate(db, stmt, CashTransaction.id, limit=limit, cursor=cursor)
def list_all_withdraw_orders(
db: Session,
*,
user_id: int | None = None,
status: str | None = None,
keyword: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
date_field: str = "created_at",
sort_by: str = "created_at",
sort_order: str = "desc",
quick_filter: str | None = None,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[WithdrawOrder], int | None]:
stmt = select(WithdrawOrder)
needs_user_join = bool(keyword and keyword.strip()) or quick_filter == "high_risk"
if needs_user_join:
stmt = stmt.outerjoin(User, User.id == WithdrawOrder.user_id)
if user_id is not None:
stmt = stmt.where(WithdrawOrder.user_id == user_id)
if status:
stmt = stmt.where(WithdrawOrder.status == status)
kw = (keyword or "").strip()
if kw:
pattern = f"%{kw}%"
conditions = [
WithdrawOrder.out_bill_no.ilike(pattern),
WithdrawOrder.transfer_bill_no.ilike(pattern),
WithdrawOrder.user_name.ilike(pattern),
WithdrawOrder.wechat_state.ilike(pattern),
WithdrawOrder.fail_reason.ilike(pattern),
User.phone.ilike(pattern),
User.nickname.ilike(pattern),
User.wechat_nickname.ilike(pattern),
]
if kw.isdigit():
conditions.append(WithdrawOrder.user_id == int(kw))
stmt = stmt.where(or_(*conditions))
date_col = WithdrawOrder.updated_at if date_field == "updated_at" else WithdrawOrder.created_at
if date_from is not None:
stmt = stmt.where(date_col >= _as_utc_naive(date_from))
if date_to is not None:
stmt = stmt.where(date_col <= _as_utc_naive(date_to))
now = datetime.now(timezone.utc).replace(tzinfo=None)
today_start = (
datetime.now(ZoneInfo("Asia/Shanghai"))
.replace(hour=0, minute=0, second=0, microsecond=0)
.astimezone(timezone.utc)
.replace(tzinfo=None)
)
if quick_filter == "abnormal":
stmt = stmt.where(
or_(
WithdrawOrder.status.in_(("failed", "rejected")),
WithdrawOrder.fail_reason.is_not(None),
WithdrawOrder.wechat_state.in_(("FAIL", "CANCELLED", "CLOSED")),
)
)
elif quick_filter == "failed":
stmt = stmt.where(WithdrawOrder.status == "failed")
elif quick_filter == "overdue_reviewing":
stmt = stmt.where(
WithdrawOrder.status == "reviewing",
WithdrawOrder.created_at <= now - timedelta(minutes=30),
)
elif quick_filter == "pending_overdue":
stmt = stmt.where(
WithdrawOrder.status == "pending",
WithdrawOrder.updated_at <= now - timedelta(minutes=15),
)
elif quick_filter == "today":
stmt = stmt.where(WithdrawOrder.created_at >= today_start)
elif quick_filter == "high_risk":
stmt = stmt.where(
or_(
WithdrawOrder.user_name.is_(None),
WithdrawOrder.user_name == "",
User.status != "active",
User.created_at >= now - timedelta(hours=24),
WithdrawOrder.status.in_(("failed", "rejected")),
WithdrawOrder.fail_reason.is_not(None),
)
)
sort_cols = {
"id": WithdrawOrder.id,
"created_at": WithdrawOrder.created_at,
"updated_at": WithdrawOrder.updated_at,
"amount_cents": WithdrawOrder.amount_cents,
}
sort_col = sort_cols.get(sort_by, WithdrawOrder.created_at)
order_fn = asc if sort_order == "asc" else desc
id_order = asc(WithdrawOrder.id) if sort_order == "asc" else desc(WithdrawOrder.id)
stmt = stmt.order_by(order_fn(sort_col), id_order)
offset = max(cursor or 0, 0)
rows = list(db.execute(stmt.offset(offset).limit(limit + 1)).scalars().all())
has_more = len(rows) > limit
items = rows[:limit]
next_cursor = offset + limit if has_more else None
return items, next_cursor
def _as_utc_naive(value: datetime) -> datetime:
"""前端传 ISO 时间;DB 当前按 UTC naive 比较最稳(SQLite/本地开发一致)。"""
if value.tzinfo is None:
return value
return value.astimezone(timezone.utc).replace(tzinfo=None)
def list_feedbacks(
db: Session,
*,
status: str | None = None,
user_id: int | None = None,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[Feedback], int | None]:
stmt = select(Feedback)
if status:
stmt = stmt.where(Feedback.status == status)
if user_id is not None:
stmt = stmt.where(Feedback.user_id == user_id)
return cursor_paginate(db, stmt, Feedback.id, limit=limit, cursor=cursor)
def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder | None:
"""按商户单号查提现单(admin 重试打款先拿 user_id 用,M3)。"""
return db.execute(
select(WithdrawOrder).where(WithdrawOrder.out_bill_no == out_bill_no)
).scalar_one_or_none()
def withdraw_summary(db: Session) -> dict:
"""提现审核台顶部统计。金额单位:分。"""
rows = db.execute(
select(
WithdrawOrder.status,
func.count(WithdrawOrder.id),
func.coalesce(func.sum(WithdrawOrder.amount_cents), 0),
).group_by(WithdrawOrder.status)
).all()
by_status = {
status: {"count": int(count), "amount_cents": int(amount_cents)}
for status, count, amount_cents in rows
}
today_start = (
datetime.now(ZoneInfo("Asia/Shanghai"))
.replace(hour=0, minute=0, second=0, microsecond=0)
.astimezone(timezone.utc)
)
def _today_count(status: str) -> int:
return db.execute(
select(func.count(WithdrawOrder.id)).where(
WithdrawOrder.status == status,
WithdrawOrder.updated_at >= today_start,
)
).scalar_one()
today_success_amount = db.execute(
select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where(
WithdrawOrder.status == "success",
WithdrawOrder.updated_at >= today_start,
)
).scalar_one()
return {
"reviewing_count": by_status.get("reviewing", {}).get("count", 0),
"reviewing_amount_cents": by_status.get("reviewing", {}).get("amount_cents", 0),
"pending_count": by_status.get("pending", {}).get("count", 0),
"failed_count": by_status.get("failed", {}).get("count", 0),
"today_success_count": _today_count("success"),
"today_success_amount_cents": int(today_success_amount),
"today_rejected_count": _today_count("rejected"),
}
def list_withdraw_audit_logs(
db: Session, out_bill_no: str, *, limit: int = 20
) -> list[AdminAuditLog]:
return list(
db.execute(
select(AdminAuditLog)
.where(AdminAuditLog.target_type == "withdraw", AdminAuditLog.target_id == out_bill_no)
.order_by(AdminAuditLog.id.desc())
.limit(limit)
).scalars().all()
)
def withdraw_risk_flags(
order: WithdrawOrder,
user: User | None,
recent_withdraws: list[WithdrawOrder],
cash_balance_cents: int,
) -> tuple[list[str], int]:
flags: list[str] = []
if not order.user_name:
flags.append("缺少提现实名")
if user and user.status != "active":
flags.append(f"账号状态:{user.status}")
if user and user.created_at:
created_at = user.created_at.replace(tzinfo=timezone.utc) if user.created_at.tzinfo is None else user.created_at
if datetime.now(timezone.utc) - created_at < timedelta(hours=24):
flags.append("新注册用户")
failed_or_rejected = sum(1 for item in recent_withdraws if item.status in {"failed", "rejected"})
if failed_or_rejected:
flags.append(f"历史异常提现{failed_or_rejected}")
recent_reviewing = sum(1 for item in recent_withdraws if item.status == "reviewing")
if recent_reviewing >= 3:
flags.append(f"待审核提现偏多:{recent_reviewing}")
if cash_balance_cents < 0:
flags.append("现金余额为负")
score = min(100, len(flags) * 20 + failed_or_rejected * 10)
return flags, score
def withdraw_ledger_check(db: Session) -> dict:
cash_balance_total = int(
db.execute(select(func.coalesce(func.sum(CoinAccount.cash_balance_cents), 0))).scalar_one()
)
cash_txn_total = int(
db.execute(select(func.coalesce(func.sum(CashTransaction.amount_cents), 0))).scalar_one()
)
orders = list(db.execute(select(WithdrawOrder)).scalars().all())
cash_txns = list(
db.execute(
select(CashTransaction).where(
CashTransaction.biz_type.in_(("withdraw", "withdraw_refund"))
)
).scalars().all()
)
withdraw_refs = {txn.ref_id for txn in cash_txns if txn.biz_type == "withdraw"}
refund_counts: dict[str, int] = {}
for txn in cash_txns:
if txn.biz_type == "withdraw_refund" and txn.ref_id:
refund_counts[txn.ref_id] = refund_counts.get(txn.ref_id, 0) + 1
missing_withdraw = 0
missing_refund = 0
refund_on_non_terminal = 0
for order in orders:
if order.out_bill_no not in withdraw_refs:
missing_withdraw += 1
has_refund = refund_counts.get(order.out_bill_no, 0) > 0
if order.status in {"failed", "rejected"} and not has_refund:
missing_refund += 1
if has_refund and order.status not in {"failed", "rejected"}:
refund_on_non_terminal += 1
duplicate_refund = sum(1 for count in refund_counts.values() if count > 1)
diff = cash_balance_total - cash_txn_total
ok = (
diff == 0
and missing_withdraw == 0
and missing_refund == 0
and duplicate_refund == 0
and refund_on_non_terminal == 0
)
return {
"ok": ok,
"cash_balance_total_cents": cash_balance_total,
"cash_transaction_total_cents": cash_txn_total,
"balance_diff_cents": diff,
"missing_withdraw_txn_count": missing_withdraw,
"missing_refund_txn_count": missing_refund,
"duplicate_refund_txn_count": duplicate_refund,
"refund_txn_on_non_terminal_count": refund_on_non_terminal,
}
def get_user_overview(db: Session, user_id: int) -> dict | None:
"""用户 360 概览:基础资料 + 钱包余额 + 各项 count。历史明细走各自分页接口(带 user_id 过滤)。"""
user = db.get(User, user_id)
if user is None:
return None
acc = db.get(CoinAccount, user_id) # 可能为 None(从未发生过金币动作)
def _count(model, *conds) -> int:
return db.execute(select(func.count(model.id)).where(*conds)).scalar_one()
return {
"user": user,
"coin_balance": acc.coin_balance if acc else 0,
"cash_balance_cents": acc.cash_balance_cents if acc else 0,
"total_coin_earned": acc.total_coin_earned if acc else 0,
"comparison_total": _count(ComparisonRecord, ComparisonRecord.user_id == user_id),
"comparison_success": _count(
ComparisonRecord,
ComparisonRecord.user_id == user_id,
ComparisonRecord.status == "success",
),
"withdraw_total": _count(WithdrawOrder, WithdrawOrder.user_id == user_id),
"withdraw_success_cents": db.execute(
select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where(
WithdrawOrder.user_id == user_id, WithdrawOrder.status == "success"
)
).scalar_one(),
"feedback_total": _count(Feedback, Feedback.user_id == user_id),
}
def list_price_reports(
db: Session,
*,
status: str | None = None,
user_id: int | None = None,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[PriceReport], int | None]:
"""上报更低价列表(admin 全量,可按状态/用户筛)。游标同 feedback:id 倒序。"""
stmt = select(PriceReport)
if status:
stmt = stmt.where(PriceReport.status == status)
if user_id is not None:
stmt = stmt.where(PriceReport.user_id == user_id)
return cursor_paginate(db, stmt, PriceReport.id, limit=limit, cursor=cursor)
def price_report_summary(db: Session) -> dict:
"""上报审核台各状态计数(待审核/已通过/已拒绝/合计)。"""
rows = db.execute(
select(PriceReport.status, func.count(PriceReport.id)).group_by(PriceReport.status)
).all()
by_status = {status: int(count) for status, count in rows}
return {
"pending": by_status.get("pending", 0),
"approved": by_status.get("approved", 0),
"rejected": by_status.get("rejected", 0),
"total": sum(by_status.values()),
}
-125
View File
@@ -1,125 +0,0 @@
"""admin 大盘聚合查询(全局 count/sum/DAU/成功率)。全部只读、不改任何数据。
⚠️ 性能:这些是全表 count/sum,P0 数据量小够用;用户量上来后热点字段(user.created_at /
user.last_login_at / comparison_record.status / withdraw_order.status)要加索引,或改增量统计表。
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
from app.models.comparison import ComparisonRecord
from app.models.feedback import Feedback
from app.models.signin import SigninBoostRecord, SigninRecord
from app.models.user import User
from app.models.wallet import CoinTransaction, WithdrawOrder
_BEIJING = timezone(timedelta(hours=8))
def _beijing_today_start_utc() -> datetime:
"""北京时间今天 0 点对应的 UTC 时刻(DAU / 今日新增按北京时区切天)。"""
now_bj = datetime.now(_BEIJING)
start_bj = now_bj.replace(hour=0, minute=0, second=0, microsecond=0)
return start_bj.astimezone(timezone.utc)
def dashboard_overview(db: Session) -> dict:
today_start = _beijing_today_start_utc()
def _count(model, *conds) -> int:
stmt = select(func.count(model.id))
if conds:
stmt = stmt.where(*conds)
return db.execute(stmt).scalar_one()
def _sum(col, *conds) -> int:
stmt = select(func.coalesce(func.sum(col), 0))
if conds:
stmt = stmt.where(*conds)
return db.execute(stmt).scalar_one()
# ===== 用户 =====
by_status = dict(
db.execute(select(User.status, func.count(User.id)).group_by(User.status)).all()
)
# ===== 提现状态分布 =====
wd_by_status = dict(
db.execute(
select(WithdrawOrder.status, func.count(WithdrawOrder.id)).group_by(
WithdrawOrder.status
)
).all()
)
# ===== 比价 =====
comparison_total = _count(ComparisonRecord)
comparison_success = _count(ComparisonRecord, ComparisonRecord.status == "success")
success_rate = round(comparison_success / comparison_total, 4) if comparison_total else 0.0
return {
"users": {
"total": _count(User),
"active": by_status.get("active", 0),
"disabled": by_status.get("disabled", 0),
"deleted": by_status.get("deleted", 0),
"new_today": _count(User, User.created_at >= today_start),
"dau": _count(User, User.last_login_at >= today_start),
},
"coins": {
# 累计发放金币(coin_transaction 里所有 amount>0 之和;负数是兑换/扣减不计)
"granted_total": _sum(CoinTransaction.amount, CoinTransaction.amount > 0),
"reward_video_coin_total": _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
),
"reward_video_watch_count": _count(
AdRewardRecord,
AdRewardRecord.reward_scene == "reward_video",
AdRewardRecord.status == "granted",
),
"feed_ad_coin_total": _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type == "feed_ad_reward",
),
"feed_ad_watch_count": _count(
AdFeedRewardRecord,
AdFeedRewardRecord.status == "granted",
),
"signin_coin_total": _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type == "signin",
),
"signin_count": _count(SigninRecord),
"signin_boost_coin_total": _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type == "signin_boost",
),
"signin_boost_watch_count": _count(SigninBoostRecord),
},
"cash": {
"withdraw_success_cents": _sum(
WithdrawOrder.amount_cents, WithdrawOrder.status == "success"
),
"withdraw_pending_count": wd_by_status.get("pending", 0),
"withdraw_success_count": wd_by_status.get("success", 0),
"withdraw_failed_count": wd_by_status.get("failed", 0),
},
"comparison": {
"total": comparison_total,
"success": comparison_success,
"success_rate": success_rate,
},
"feedback": {"new": _count(Feedback, Feedback.status == "new")},
# CPS 收入数据源未接(referral-link 只换链接,转化/佣金未回收)→ 前端显示"待接入"。
"cps": {"available": False, "note": "CPS 转化数据未接入(P2)"},
}
-1
View File
@@ -1 +0,0 @@
"""admin 路由(前缀统一 /admin/api)。"""
-69
View File
@@ -1,69 +0,0 @@
"""admin 账号管理(仅 super_admin):列表 / 创建 / 改角色启停重置密码。均写审计。"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_role
from app.admin.repositories import admin_user as admin_repo
from app.admin.schemas.admin import AdminCreateRequest, AdminUpdateRequest
from app.admin.schemas.auth import AdminOut
from app.core.security import hash_password
router = APIRouter(
prefix="/admin/api/admins",
tags=["admin-accounts"],
dependencies=[Depends(require_role())], # require_role() 无参 = 仅 super_admin 通过
)
@router.get("", response_model=list[AdminOut], summary="管理员列表")
def list_admins(db: AdminDb) -> list[AdminOut]:
return [AdminOut.model_validate(a) for a in admin_repo.list_admins(db)]
@router.post("", response_model=AdminOut, summary="创建管理员")
def create_admin(
body: AdminCreateRequest, request: Request, admin: CurrentAdmin, db: AdminDb
) -> AdminOut:
if admin_repo.get_by_username(db, body.username) is not None:
raise HTTPException(status_code=409, detail="用户名已存在")
new = admin_repo.create_admin(
db, username=body.username, password=body.password, role=body.role
)
write_audit(
db, admin, action="admin.create", target_type="admin", target_id=new.id,
detail={"username": new.username, "role": new.role}, ip=get_client_ip(request), commit=True,
)
return AdminOut.model_validate(new)
@router.patch("/{admin_id}", response_model=AdminOut, summary="改角色/启停/重置密码")
def update_admin(
admin_id: int, body: AdminUpdateRequest, request: Request, admin: CurrentAdmin, db: AdminDb
) -> AdminOut:
target = admin_repo.get_by_id(db, admin_id)
if target is None:
raise HTTPException(status_code=404, detail="管理员不存在")
if admin_id == admin.id and body.status == "disabled":
raise HTTPException(status_code=400, detail="不能禁用自己")
changes: dict = {}
if body.role is not None:
target.role = body.role
changes["role"] = body.role
if body.status is not None:
target.status = body.status
changes["status"] = body.status
if body.password is not None:
target.password_hash = hash_password(body.password)
changes["password"] = "reset"
if not changes:
raise HTTPException(status_code=400, detail="无任何变更字段")
db.commit()
db.refresh(target)
write_audit(
db, admin, action="admin.update", target_type="admin", target_id=admin_id,
detail=changes, ip=get_client_ip(request), commit=True,
)
return AdminOut.model_validate(target)
-34
View File
@@ -1,34 +0,0 @@
"""admin 操作审计日志查询(所有 admin 可看:谁在何时对什么做了什么)。"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import audit_log as audit_repo
from app.admin.schemas.admin import AdminAuditLogOut
from app.admin.schemas.common import CursorPage
router = APIRouter(
prefix="/admin/api/audit-logs",
tags=["admin-audit"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=CursorPage[AdminAuditLogOut], summary="审计日志(谁改了什么)")
def list_audit_logs(
db: AdminDb,
action: Annotated[str | None, Query()] = None,
target_type: Annotated[str | None, Query()] = None,
admin_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 50,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[AdminAuditLogOut]:
items, next_cursor = audit_repo.list_audit_logs(
db, action=action, target_type=target_type, admin_id=admin_id, limit=limit, cursor=cursor,
)
return CursorPage(
items=[AdminAuditLogOut.model_validate(x) for x in items], next_cursor=next_cursor,
)
-48
View File
@@ -1,48 +0,0 @@
"""Admin 认证:账号密码登录 → admin JWT(独立 secret)。"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException
from app.admin.deps import AdminDb, CurrentAdmin
from app.admin.repositories import admin_user as admin_repo
from app.admin.schemas.auth import AdminLoginRequest, AdminLoginResponse, AdminOut
from app.admin.security import create_admin_token
from app.core.ratelimit import rate_limit
from app.core.security import verify_password
logger = logging.getLogger("shagua.admin.auth")
router = APIRouter(prefix="/admin/api/auth", tags=["admin-auth"])
@router.post(
"/login",
response_model=AdminLoginResponse,
summary="管理员登录",
dependencies=[Depends(rate_limit(10, 60, "admin-login"))], # 同 IP 每分钟≤10 次,防爆破
)
def login(req: AdminLoginRequest, db: AdminDb) -> AdminLoginResponse:
admin = admin_repo.get_by_username(db, req.username)
# 用户名不存在 / 密码错统一 401 同文案(防账号枚举)。
# disabled 账号单独 403"账号已禁用":admin 是内部少数已知账号、无枚举价值,
# 明确提示比防枚举更有运维价值(与 App 端 auth.py 对 disabled 用户的 403 一致)。
if admin is None or not verify_password(req.password, admin.password_hash):
raise HTTPException(status_code=401, detail="用户名或密码错误")
if admin.status != "active":
raise HTTPException(status_code=403, detail="账号已禁用")
token, expires_in = create_admin_token(admin_id=admin.id, role=admin.role)
admin_repo.update_last_login(db, admin)
logger.info("admin login ok id=%d username=%s role=%s", admin.id, admin.username, admin.role)
return AdminLoginResponse(
access_token=token,
expires_in=expires_in,
admin=AdminOut.model_validate(admin),
)
@router.get("/me", response_model=AdminOut, summary="当前管理员")
def me(admin: CurrentAdmin) -> AdminOut:
return AdminOut.model_validate(admin)
-81
View File
@@ -1,81 +0,0 @@
"""admin 运营配置:列出所有可配项 + 改某项(带审计 + 改值校验)。
配置项定义见 app.core.config_schema.CONFIG_DEFS;业务读配置 fallback 默认(见 app_config repo)。
权限:operator / finance 都可改(运营调奖励、财务调额度),super 恒可。
"""
from __future__ import annotations
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.schemas.config import ConfigItemOut, ConfigUpdateRequest
from app.core.config_schema import CONFIG_DEFS
from app.models.admin import AdminUser
from app.repositories import app_config
router = APIRouter(
prefix="/admin/api/config",
tags=["admin-config"],
dependencies=[Depends(get_current_admin)],
)
def _validate(key: str, value: Any) -> None:
"""按配置项 type 校验新值,不合法抛 ValueError(router 转 400)。"""
t = CONFIG_DEFS[key]["type"]
if t == "int":
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
raise ValueError("需为非负整数")
elif t == "int_list":
if not isinstance(value, list) or not value:
raise ValueError("需为非空整数列表")
if not all(isinstance(x, int) and not isinstance(x, bool) and x >= 0 for x in value):
raise ValueError("列表元素需为非负整数")
if key == "signin_rewards" and len(value) != 14:
raise ValueError("签到档位必须正好 14 个(对应 14 天循环)")
elif t == "dict_str_int":
if not isinstance(value, dict) or not all(
isinstance(k, str) and isinstance(v, int) and not isinstance(v, bool)
for k, v in value.items()
):
raise ValueError("需为 {字符串: 整数} 映射")
def _item(db, key: str) -> ConfigItemOut:
for item in app_config.list_all(db):
if item["key"] == key:
return ConfigItemOut(**item)
raise HTTPException(status_code=404, detail="未知配置项")
@router.get("", response_model=list[ConfigItemOut], summary="所有可配项 + 当前值")
def list_config(db: AdminDb) -> list[ConfigItemOut]:
return [ConfigItemOut(**item) for item in app_config.list_all(db)]
@router.patch("/{key}", response_model=ConfigItemOut, summary="改某项配置(带审计)")
def update_config(
key: str,
body: ConfigUpdateRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator", "finance"))],
db: AdminDb,
) -> ConfigItemOut:
if key not in CONFIG_DEFS:
raise HTTPException(status_code=404, detail="未知配置项")
try:
_validate(key, body.value)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
before = app_config.get_value(db, key)
app_config.set_value(db, key, body.value, admin_id=admin.id, commit=False)
write_audit(
db, admin, action="config.set", target_type="config", target_id=key,
detail={"before": before, "after": body.value}, ip=get_client_ip(request), commit=False,
)
db.commit()
return _item(db, key)
-19
View File
@@ -1,19 +0,0 @@
"""admin 数据大盘(只读聚合)。"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import stats
from app.admin.schemas.dashboard import DashboardOverview
router = APIRouter(
prefix="/admin/api/stats",
tags=["admin-stats"],
dependencies=[Depends(get_current_admin)],
)
@router.get("/overview", response_model=DashboardOverview, summary="大盘核心指标")
def overview(db: AdminDb) -> DashboardOverview:
return DashboardOverview.model_validate(stats.dashboard_overview(db))
-56
View File
@@ -1,56 +0,0 @@
"""admin 反馈工单:列表(读)+ 标记已处理(写,带审计)。"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.repositories import mutations, queries
from app.admin.schemas.common import CursorPage, OkResponse
from app.admin.schemas.feedback import FeedbackOut
from app.models.admin import AdminUser
from app.models.feedback import Feedback
router = APIRouter(
prefix="/admin/api/feedbacks",
tags=["admin-feedback"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=CursorPage[FeedbackOut], summary="反馈工单列表")
def list_feedbacks(
db: AdminDb,
status: Annotated[str | None, Query()] = None,
user_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[FeedbackOut]:
items, next_cursor = queries.list_feedbacks(
db, status=status, user_id=user_id, limit=limit, cursor=cursor,
)
return CursorPage(
items=[FeedbackOut.model_validate(f) for f in items], next_cursor=next_cursor,
)
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
def handle_feedback(
feedback_id: int,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
fb = db.get(Feedback, feedback_id)
if fb is None:
raise HTTPException(status_code=404, detail="反馈不存在")
before = fb.status
mutations.update_feedback_status(db, fb, status="handled", commit=False)
write_audit(
db, admin, action="feedback.handle", target_type="feedback", target_id=feedback_id,
detail={"before": before, "after": "handled"}, ip=get_client_ip(request), commit=False,
)
db.commit()
return OkResponse()
-160
View File
@@ -1,160 +0,0 @@
"""admin 首页轮播种子管理:列出 / 新增 / 改 / 删 / 批量生成 / 预览(带审计)。
种子用于首页轮播「真实+种子混播」的兜底(见 app/repositories/ops_marquee.py)。
权限:operator 可改,super 恒可;预览为只读(任意已登录 admin)。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.schemas.ops_marquee_seed import (
OpsMarqueeSeedBulkCreate,
OpsMarqueeSeedCreate,
OpsMarqueeSeedOut,
OpsMarqueeSeedUpdate,
OpsSavingsFeedPreviewOut,
)
from app.models.admin import AdminUser
from app.models.ops_marquee_seed import OpsMarqueeSeed
from app.repositories import ops_marquee
router = APIRouter(
prefix="/admin/api/marquee-seeds",
tags=["admin-marquee-seeds"],
dependencies=[Depends(get_current_admin)],
)
def _out(seed: OpsMarqueeSeed) -> OpsMarqueeSeedOut:
return OpsMarqueeSeedOut(
id=seed.id,
masked_user=seed.masked_user,
min_cents=seed.min_cents,
max_cents=seed.max_cents,
enabled=seed.enabled,
sort_order=seed.sort_order,
created_at=seed.created_at.isoformat() if seed.created_at else None,
)
@router.get("", response_model=list[OpsMarqueeSeedOut], summary="轮播种子列表")
def list_seeds(db: AdminDb) -> list[OpsMarqueeSeedOut]:
return [_out(s) for s in ops_marquee.list_seeds(db)]
@router.get("/preview", response_model=OpsSavingsFeedPreviewOut, summary="预览实际混播 feed")
def preview_feed(
db: AdminDb,
limit: Annotated[int, Query(ge=1, le=30)] = 8,
) -> OpsSavingsFeedPreviewOut:
"""返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。"""
return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit))
@router.post("", response_model=OpsMarqueeSeedOut, summary="新增轮播种子(带审计)")
def create_seed(
body: OpsMarqueeSeedCreate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OpsMarqueeSeedOut:
try:
seed = ops_marquee.create_seed(
db,
masked_user=body.masked_user,
min_cents=body.min_cents,
max_cents=body.max_cents,
enabled=body.enabled,
sort_order=body.sort_order,
commit=False,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
write_audit(
db, admin, action="ops_marquee_seed.create", target_type="ops_marquee_seed",
target_id=None, detail=body.model_dump(), ip=get_client_ip(request), commit=False,
)
db.commit()
db.refresh(seed)
return _out(seed)
@router.post("/bulk", response_model=list[OpsMarqueeSeedOut], summary="批量生成种子(带审计)")
def bulk_generate(
body: OpsMarqueeSeedBulkCreate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> list[OpsMarqueeSeedOut]:
try:
seeds = ops_marquee.bulk_generate(
db,
count=body.count,
min_cents=body.min_cents,
max_cents=body.max_cents,
enabled=body.enabled,
commit=False,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
write_audit(
db, admin, action="ops_marquee_seed.bulk_generate", target_type="ops_marquee_seed",
target_id=None, detail=body.model_dump(), ip=get_client_ip(request), commit=False,
)
db.commit()
for s in seeds:
db.refresh(s)
return [_out(s) for s in seeds]
@router.patch("/{seed_id}", response_model=OpsMarqueeSeedOut, summary="改轮播种子(带审计)")
def update_seed(
seed_id: int,
body: OpsMarqueeSeedUpdate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OpsMarqueeSeedOut:
try:
seed = ops_marquee.update_seed(
db,
seed_id,
masked_user=body.masked_user,
min_cents=body.min_cents,
max_cents=body.max_cents,
enabled=body.enabled,
sort_order=body.sort_order,
commit=False,
)
except ValueError as e:
raise HTTPException(status_code=404 if "不存在" in str(e) else 400, detail=str(e)) from e
write_audit(
db, admin, action="ops_marquee_seed.update", target_type="ops_marquee_seed",
target_id=seed_id, detail=body.model_dump(exclude_none=True),
ip=get_client_ip(request), commit=False,
)
db.commit()
db.refresh(seed)
return _out(seed)
@router.delete("/{seed_id}", summary="删轮播种子(带审计)")
def delete_seed(
seed_id: int,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> dict:
ok = ops_marquee.delete_seed(db, seed_id, commit=False)
if not ok:
raise HTTPException(status_code=404, detail="种子不存在")
write_audit(
db, admin, action="ops_marquee_seed.delete", target_type="ops_marquee_seed",
target_id=seed_id, detail=None, ip=get_client_ip(request), commit=False,
)
db.commit()
return {"deleted": True}
-74
View File
@@ -1,74 +0,0 @@
"""admin 首页三统计展示配置:列出三指标配置(+ 真实值预览)+ 改某指标(带审计 + 校验)。
每指标可独立选 real/manual/random。计算逻辑见 app/repositories/ops_stat.py。
权限:operator 可改(运营调门面数字),super 恒可。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.schemas.ops_stat_config import (
OpsStatItemOut,
OpsStatUpdateRequest,
)
from app.models.admin import AdminUser
from app.repositories import ops_stat
router = APIRouter(
prefix="/admin/api/dashboard-display",
tags=["admin-dashboard-display"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=list[OpsStatItemOut], summary="首页三统计配置 + 真实值预览")
def list_display(db: AdminDb) -> list[OpsStatItemOut]:
return [OpsStatItemOut(**item) for item in ops_stat.get_config(db)]
@router.patch(
"/{metric}", response_model=OpsStatItemOut, summary="改某指标展示配置(带审计)"
)
def update_display(
metric: str,
body: OpsStatUpdateRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OpsStatItemOut:
try:
before, after = ops_stat.update_config(
db,
metric,
mode=body.mode,
manual_value=body.manual_value,
random_mult_min=body.random_mult_min,
random_mult_max=body.random_mult_max,
random_tick_seconds=body.random_tick_seconds,
random_anchor_minutes=body.random_anchor_minutes,
random_kind=body.random_kind,
random_step_min=body.random_step_min,
random_step_max=body.random_step_max,
real_offset=body.real_offset,
allow_decrease=body.allow_decrease,
random_initial=body.random_initial,
apply_now=body.apply_now,
admin_id=admin.id,
commit=False,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
write_audit(
db, admin, action="ops_stat_config.set", target_type="ops_stat_config",
target_id=metric, detail={"before": before, "after": after},
ip=get_client_ip(request), commit=False,
)
db.commit()
item = next(it for it in ops_stat.get_config(db) if it["metric"] == metric)
return OpsStatItemOut(**item)
-103
View File
@@ -1,103 +0,0 @@
"""admin 上报更低价审核:列表 + 统计(读)+ 通过(发金币)/拒绝(写,带审计)。
数据由客户端 POST /api/v1/report 写入 price_report 表(提交即 pending);本路由是运营后台
对它的人工审核窗口。**通过** → 给上报用户钱包发固定金币(PRICE_REPORT_REWARD_COINS):
改状态 + 发金币(wallet.grant_coins)+ 审计同一事务一起 commit(原子,仿 users.grant_user_coins),
绝不只改状态不发钱或反之。客户端轮询 GET /api/v1/report/records 自动看到结果(无需推送)。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.repositories import mutations, queries
from app.admin.schemas.common import CursorPage, OkResponse
from app.admin.schemas.price_report import (
PriceReportOut,
PriceReportRejectRequest,
PriceReportSummary,
)
from app.core.rewards import PRICE_REPORT_REWARD_COINS
from app.models.admin import AdminUser
from app.models.price_report import PriceReport
from app.repositories import wallet as wallet_repo
router = APIRouter(
prefix="/admin/api/price-reports",
tags=["admin-price-report"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=CursorPage[PriceReportOut], summary="上报更低价列表(筛选+分页)")
def list_price_reports(
db: AdminDb,
status: Annotated[str | None, Query()] = None,
user_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[PriceReportOut]:
items, next_cursor = queries.list_price_reports(
db, status=status, user_id=user_id, limit=limit, cursor=cursor,
)
return CursorPage(
items=[PriceReportOut.model_validate(r) for r in items], next_cursor=next_cursor,
)
@router.get("/summary", response_model=PriceReportSummary, summary="上报审核统计(各状态计数)")
def price_report_summary(db: AdminDb) -> PriceReportSummary:
return PriceReportSummary.model_validate(queries.price_report_summary(db))
@router.post("/{report_id}/approve", response_model=OkResponse, summary="通过上报(发固定金币)")
def approve_price_report(
report_id: int,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
rep = db.get(PriceReport, report_id)
if rep is None:
raise HTTPException(status_code=404, detail="上报记录不存在")
if rep.status != "pending":
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
coins = PRICE_REPORT_REWARD_COINS
# 改状态 + 发金币 + 审计同一事务(commit=False),最后一起 commit:改了就有痕、发了就留账
mutations.review_price_report(db, rep, status="approved", reward_coins=coins, commit=False)
wallet_repo.grant_coins(
db, rep.user_id, coins,
biz_type="price_report_reward", ref_id=str(rep.id), remark="上报更低价审核通过",
)
write_audit(
db, admin, action="price_report.approve", target_type="price_report", target_id=report_id,
detail={"reward_coins": coins, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False,
)
db.commit()
return OkResponse()
@router.post("/{report_id}/reject", response_model=OkResponse, summary="拒绝上报")
def reject_price_report(
report_id: int,
body: PriceReportRejectRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
rep = db.get(PriceReport, report_id)
if rep is None:
raise HTTPException(status_code=404, detail="上报记录不存在")
if rep.status != "pending":
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
reason = body.reason.strip()
mutations.review_price_report(db, rep, status="rejected", reject_reason=reason, commit=False)
write_audit(
db, admin, action="price_report.reject", target_type="price_report", target_id=report_id,
detail={"reason": reason, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False,
)
db.commit()
return OkResponse()
-134
View File
@@ -1,134 +0,0 @@
"""admin 用户管理:列表 + 360 详情(读)+ 封禁/解封 + 手动调金币(写,带审计)。"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.repositories import mutations, queries
from app.admin.schemas.common import CursorPage, OkResponse
from app.admin.schemas.user import (
AdminUserListItem,
AdminUserOverview,
GrantCoinsRequest,
SetDebugTraceRequest,
SetUserStatusRequest,
)
from app.models.admin import AdminUser
from app.repositories import user as user_repo
from app.repositories import wallet as wallet_repo
router = APIRouter(
prefix="/admin/api/users",
tags=["admin-users"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=CursorPage[AdminUserListItem], summary="用户列表(筛选+分页)")
def list_users(
db: AdminDb,
phone: Annotated[str | None, Query()] = None,
register_channel: Annotated[str | None, Query()] = None,
status: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[AdminUserListItem]:
items, next_cursor = queries.list_users(
db, phone=phone, register_channel=register_channel, status=status,
limit=limit, cursor=cursor,
)
return CursorPage(
items=[AdminUserListItem.model_validate(u) for u in items],
next_cursor=next_cursor,
)
@router.get("/{user_id}", response_model=AdminUserOverview, summary="用户 360 详情")
def get_user(user_id: int, db: AdminDb) -> AdminUserOverview:
overview = queries.get_user_overview(db, user_id)
if overview is None:
raise HTTPException(status_code=404, detail="用户不存在")
return AdminUserOverview.model_validate(overview)
@router.post("/{user_id}/status", response_model=OkResponse, summary="封禁/解封用户")
def set_user_status(
user_id: int,
body: SetUserStatusRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
user = user_repo.get_user_by_id(db, user_id)
if user is None:
raise HTTPException(status_code=404, detail="用户不存在")
if user.status == "deleted":
raise HTTPException(status_code=400, detail="已注销账号不可改状态")
before = user.status
# 业务写 + 审计写同一事务(commit=False),最后一起 commit:改了就有痕、有痕就真改了
mutations.set_user_status(db, user, status=body.status, commit=False)
write_audit(
db, admin, action="user.status.set", target_type="user", target_id=user_id,
detail={"before": before, "after": body.status}, ip=get_client_ip(request), commit=False,
)
db.commit()
return OkResponse()
@router.post("/{user_id}/debug-trace", response_model=OkResponse, summary="开关调试链接权限")
def set_user_debug_trace(
user_id: int,
body: SetDebugTraceRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
user = user_repo.get_user_by_id(db, user_id)
if user is None:
raise HTTPException(status_code=404, detail="用户不存在")
before = user.debug_trace_enabled
# 业务写 + 审计写同一事务(commit=False),最后一起 commit(同 set_user_status)
mutations.set_user_debug_trace(db, user, enabled=body.enabled, commit=False)
write_audit(
db, admin, action="user.debug_trace.set", target_type="user", target_id=user_id,
detail={"before": before, "after": body.enabled}, ip=get_client_ip(request), commit=False,
)
db.commit()
return OkResponse()
@router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减金币(带审计)")
def grant_user_coins(
user_id: int,
body: GrantCoinsRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb,
) -> OkResponse:
if body.amount == 0:
raise HTTPException(status_code=400, detail="amount 不能为 0")
user = user_repo.get_user_by_id(db, user_id)
if user is None:
raise HTTPException(status_code=404, detail="用户不存在")
# 负数扣减时不允许扣成负余额(运营误操作保护)
if body.amount < 0:
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False)
if acc_now.coin_balance + body.amount < 0:
raise HTTPException(
status_code=400, detail=f"扣减后金币为负(当前余额 {acc_now.coin_balance})"
)
biz_type = "admin_grant" if body.amount > 0 else "admin_deduct"
# grant_coins 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
acc, _ = wallet_repo.grant_coins(
db, user_id, body.amount, biz_type=biz_type, remark=f"admin:{body.reason}"[:128],
)
write_audit(
db, admin, action="user.coins.grant", target_type="user", target_id=user_id,
detail={"amount": body.amount, "balance_after": acc.coin_balance, "reason": body.reason},
ip=get_client_ip(request), commit=False,
)
db.commit()
return OkResponse()
-49
View File
@@ -1,49 +0,0 @@
"""admin 钱包:金币流水 + 现金流水(跨用户,可按 user_id 过滤)。手动调金币见 M3。"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import queries
from app.admin.schemas.common import CursorPage
from app.admin.schemas.wallet import CashTxnOut, CoinTxnOut
router = APIRouter(
prefix="/admin/api/wallet",
tags=["admin-wallet"],
dependencies=[Depends(get_current_admin)],
)
@router.get("/coin-transactions", response_model=CursorPage[CoinTxnOut], summary="金币流水")
def coin_transactions(
db: AdminDb,
user_id: Annotated[int | None, Query()] = None,
biz_type: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[CoinTxnOut]:
items, next_cursor = queries.list_all_coin_transactions(
db, user_id=user_id, biz_type=biz_type, limit=limit, cursor=cursor,
)
return CursorPage(
items=[CoinTxnOut.model_validate(t) for t in items], next_cursor=next_cursor,
)
@router.get("/cash-transactions", response_model=CursorPage[CashTxnOut], summary="现金流水")
def cash_transactions(
db: AdminDb,
user_id: Annotated[int | None, Query()] = None,
biz_type: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[CashTxnOut]:
items, next_cursor = queries.list_all_cash_transactions(
db, user_id=user_id, biz_type=biz_type, limit=limit, cursor=cursor,
)
return CursorPage(
items=[CashTxnOut.model_validate(t) for t in items], next_cursor=next_cursor,
)
-440
View File
@@ -1,440 +0,0 @@
"""admin 提现:列表(读)+ 单笔重试查单 + 批量对账(写,带审计)。
提现的钱逻辑(查微信/退款/撤单/幂等)全部复用 app.repositories.wallet,admin 只触发 + 记审计。
这些 wallet 函数内部各自 commit(涉及微信调用),审计在其后单独 commit:操作本身幂等,
审计记录"谁触发的 + 结果",事务边界比"改金币"宽松是有意的(不能塞进 wallet 的自有事务)。
"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.repositories import queries
from app.admin.schemas.common import CursorPage
from app.admin.schemas.admin import AdminAuditLogOut
from app.admin.schemas.wallet import (
CashTxnOut,
ReconcileResult,
WithdrawBulkRejectRequest,
WithdrawBulkRequest,
WithdrawBulkResult,
WithdrawBulkItemResult,
WithdrawDetailOut,
WithdrawLedgerCheckOut,
WithdrawOrderOut,
WithdrawRejectRequest,
WithdrawSummaryOut,
WithdrawUserSnapshot,
WxpayHealthCheckOut,
)
from app.core.config import settings
from app.integrations import wxpay
from app.models.admin import AdminUser
from app.repositories import wallet as wallet_repo
router = APIRouter(
prefix="/admin/api/withdraws",
tags=["admin-withdraw"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=CursorPage[WithdrawOrderOut], summary="提现单列表")
def list_withdraws(
db: AdminDb,
user_id: Annotated[int | None, Query()] = None,
status: Annotated[str | None, Query()] = None,
keyword: Annotated[str | None, Query(max_length=100)] = None,
date_from: Annotated[datetime | None, Query()] = None,
date_to: Annotated[datetime | None, Query()] = None,
date_field: Annotated[str, Query(pattern="^(created_at|updated_at)$")] = "created_at",
sort_by: Annotated[
str, Query(pattern="^(id|created_at|updated_at|amount_cents)$")
] = "created_at",
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
quick_filter: Annotated[
str | None,
Query(pattern="^(abnormal|failed|overdue_reviewing|pending_overdue|today|high_risk)$"),
] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[WithdrawOrderOut]:
items, next_cursor = queries.list_all_withdraw_orders(
db,
user_id=user_id,
status=status,
keyword=keyword,
date_from=date_from,
date_to=date_to,
date_field=date_field,
sort_by=sort_by,
sort_order=sort_order,
quick_filter=quick_filter,
limit=limit,
cursor=cursor,
)
return CursorPage(
items=[WithdrawOrderOut.model_validate(o) for o in items], next_cursor=next_cursor,
)
@router.get("/summary", response_model=WithdrawSummaryOut, summary="提现审核台统计")
def withdraws_summary(db: AdminDb) -> WithdrawSummaryOut:
return WithdrawSummaryOut(**queries.withdraw_summary(db))
@router.get("/health-check", response_model=WxpayHealthCheckOut, summary="提现配置健康检查")
def withdraw_health_check() -> WxpayHealthCheckOut:
private_path = wxpay._resolve_config_path(settings.WXPAY_MCH_PRIVATE_KEY_PATH) # noqa: SLF001
public_path = wxpay._resolve_config_path(settings.WXPAY_PUBLIC_KEY_PATH) # noqa: SLF001
issues: list[str] = []
private_loadable = False
public_loadable = False
try:
wxpay._load_private_key() # noqa: SLF001
private_loadable = True
except Exception as e: # noqa: BLE001
issues.append(f"商户私钥不可用:{e}")
try:
wxpay._load_public_key() # noqa: SLF001
public_loadable = True
except Exception as e: # noqa: BLE001
issues.append(f"微信支付平台公钥不可用:{e}")
if not settings.wxpay_configured:
issues.append("微信支付基础配置不完整")
if not settings.WXPAY_AUTH_NOTIFY_URL:
issues.append("免确认授权回调地址未配置")
if not settings.WITHDRAW_AUTO_RECONCILE_ENABLED:
issues.append("自动对账未开启")
return WxpayHealthCheckOut(
ok=not issues,
wxpay_configured=settings.wxpay_configured,
wxpay_auth_configured=settings.wxpay_auth_configured,
private_key_path=str(private_path),
private_key_exists=private_path.exists(),
private_key_loadable=private_loadable,
public_key_path=str(public_path),
public_key_exists=public_path.exists(),
public_key_loadable=public_loadable,
auth_notify_url_configured=bool(settings.WXPAY_AUTH_NOTIFY_URL),
auto_reconcile_enabled=settings.WITHDRAW_AUTO_RECONCILE_ENABLED,
auto_reconcile_interval_sec=settings.WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC,
auto_reconcile_older_than_minutes=settings.WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES,
issues=issues,
)
@router.get("/ledger-check", response_model=WithdrawLedgerCheckOut, summary="提现资金账本校验")
def withdraw_ledger_check(db: AdminDb) -> WithdrawLedgerCheckOut:
return WithdrawLedgerCheckOut(**queries.withdraw_ledger_check(db))
@router.get("/{out_bill_no}", response_model=WithdrawDetailOut, summary="提现单详情")
def withdraw_detail(out_bill_no: str, db: AdminDb) -> WithdrawDetailOut:
order = queries.get_withdraw_by_out_bill_no(db, out_bill_no)
if order is None:
raise HTTPException(status_code=404, detail="提现单不存在")
overview = queries.get_user_overview(db, order.user_id)
user_snapshot = None
if overview is not None:
user = overview["user"]
user_snapshot = WithdrawUserSnapshot(
id=user.id,
phone=user.phone,
nickname=user.nickname,
status=user.status,
wechat_nickname=user.wechat_nickname,
wechat_avatar_url=user.wechat_avatar_url,
created_at=user.created_at,
last_login_at=user.last_login_at,
cash_balance_cents=overview["cash_balance_cents"],
withdraw_total=overview["withdraw_total"],
withdraw_success_cents=overview["withdraw_success_cents"],
)
recent_withdraws, _ = queries.list_all_withdraw_orders(
db, user_id=order.user_id, limit=5, cursor=None,
)
recent_cash_transactions, _ = queries.list_all_cash_transactions(
db, user_id=order.user_id, limit=8, cursor=None,
)
audit_logs = queries.list_withdraw_audit_logs(db, out_bill_no, limit=10)
risk_flags, risk_score = queries.withdraw_risk_flags(
order,
overview["user"] if overview else None,
recent_withdraws,
overview["cash_balance_cents"] if overview else 0,
)
return WithdrawDetailOut(
order=WithdrawOrderOut.model_validate(order),
user=user_snapshot,
risk_flags=risk_flags,
risk_score=risk_score,
recent_withdraws=[WithdrawOrderOut.model_validate(o) for o in recent_withdraws],
recent_cash_transactions=[CashTxnOut.model_validate(t) for t in recent_cash_transactions],
audit_logs=[AdminAuditLogOut.model_validate(log) for log in audit_logs],
)
# 注意:/reconcile 必须在 /{out_bill_no}/refresh 之前声明(静态路径优先于路径参数)
@router.post("/reconcile", response_model=ReconcileResult, summary="批量对账(扫超时 pending 单)")
def reconcile(
request: Request,
admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb,
older_than_minutes: Annotated[int, Query(ge=0)] = 15,
) -> ReconcileResult:
try:
result = wallet_repo.reconcile_pending_withdraws(db, older_than_minutes=older_than_minutes)
except wxpay.WxPayNotConfiguredError as e:
raise HTTPException(status_code=503, detail="微信支付未配置") from e
write_audit(
db, admin, action="withdraw.reconcile", target_type="withdraw", target_id=None,
detail=result, ip=get_client_ip(request), commit=True,
)
return ReconcileResult(**result)
def _bulk_result(items: list[WithdrawBulkItemResult]) -> WithdrawBulkResult:
success = sum(1 for item in items if item.ok)
return WithdrawBulkResult(
total=len(items),
success=success,
failed=len(items) - success,
items=items,
)
@router.post("/bulk/refresh", response_model=WithdrawBulkResult, summary="批量刷新查单")
def bulk_refresh_withdraws(
body: WithdrawBulkRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb,
) -> WithdrawBulkResult:
results: list[WithdrawBulkItemResult] = []
ip = get_client_ip(request)
for out_bill_no in body.out_bill_nos:
try:
order = queries.get_withdraw_by_out_bill_no(db, out_bill_no)
if order is None:
raise wallet_repo.WithdrawOrderNotFound
refreshed = wallet_repo.refresh_withdraw_status(
db, order.user_id, out_bill_no, cancel_if_unconfirmed=True,
)
write_audit(
db, admin, action="withdraw.refresh", target_type="withdraw",
target_id=out_bill_no,
detail={
"status": refreshed.status,
"wechat_state": refreshed.wechat_state,
"fail_reason": refreshed.fail_reason,
"bulk": True,
},
ip=ip, commit=True,
)
results.append(
WithdrawBulkItemResult(
out_bill_no=out_bill_no, ok=True, status=refreshed.status
)
)
except wallet_repo.WithdrawOrderNotFound:
db.rollback()
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error="提现单不存在")
)
except wxpay.WxPayNotConfiguredError:
db.rollback()
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error="微信支付未配置")
)
except Exception as e: # noqa: BLE001 - 批量操作单笔失败不打断整批
db.rollback()
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error=f"系统异常: {e}")
)
return _bulk_result(results)
@router.post("/bulk/approve", response_model=WithdrawBulkResult, summary="批量审核通过并打款")
def bulk_approve_withdraws(
body: WithdrawBulkRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb,
) -> WithdrawBulkResult:
results: list[WithdrawBulkItemResult] = []
ip = get_client_ip(request)
for out_bill_no in body.out_bill_nos:
try:
order = wallet_repo.approve_withdraw(db, out_bill_no)
write_audit(
db, admin, action="withdraw.approve", target_type="withdraw",
target_id=out_bill_no,
detail={
"status": order.status,
"wechat_state": order.wechat_state,
"amount_cents": order.amount_cents,
"bulk": True,
},
ip=ip, commit=True,
)
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=True, status=order.status)
)
except wallet_repo.WithdrawOrderNotFound:
db.rollback()
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error="提现单不存在")
)
except wallet_repo.WithdrawNotReviewable as e:
db.rollback()
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error=str(e))
)
except wxpay.WxPayNotConfiguredError:
db.rollback()
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error="微信支付未配置")
)
except Exception as e: # noqa: BLE001 - 批量操作单笔失败不打断整批
db.rollback()
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error=f"系统异常: {e}")
)
return _bulk_result(results)
@router.post("/bulk/reject", response_model=WithdrawBulkResult, summary="批量审核拒绝并退款")
def bulk_reject_withdraws(
body: WithdrawBulkRejectRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb,
) -> WithdrawBulkResult:
results: list[WithdrawBulkItemResult] = []
ip = get_client_ip(request)
for out_bill_no in body.out_bill_nos:
try:
order = wallet_repo.reject_withdraw(db, out_bill_no, body.reason)
write_audit(
db, admin, action="withdraw.reject", target_type="withdraw",
target_id=out_bill_no,
detail={
"reason": body.reason,
"amount_cents": order.amount_cents,
"bulk": True,
},
ip=ip, commit=True,
)
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=True, status=order.status)
)
except wallet_repo.WithdrawOrderNotFound:
db.rollback()
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error="提现单不存在")
)
except wallet_repo.WithdrawNotReviewable as e:
db.rollback()
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error=str(e))
)
except Exception as e: # noqa: BLE001 - 批量操作单笔失败不打断整批
db.rollback()
results.append(
WithdrawBulkItemResult(out_bill_no=out_bill_no, ok=False, error=f"系统异常: {e}")
)
return _bulk_result(results)
@router.post("/{out_bill_no}/refresh", response_model=WithdrawOrderOut, summary="单笔重试查单")
def refresh_withdraw(
out_bill_no: str,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb,
) -> WithdrawOrderOut:
order = queries.get_withdraw_by_out_bill_no(db, out_bill_no)
if order is None:
raise HTTPException(status_code=404, detail="提现单不存在")
try:
refreshed = wallet_repo.refresh_withdraw_status(
db, order.user_id, out_bill_no, cancel_if_unconfirmed=True,
)
except wxpay.WxPayNotConfiguredError as e:
raise HTTPException(status_code=503, detail="微信支付未配置") from e
write_audit(
db, admin, action="withdraw.refresh", target_type="withdraw", target_id=out_bill_no,
detail={
"status": refreshed.status,
"wechat_state": refreshed.wechat_state,
"fail_reason": refreshed.fail_reason,
},
ip=get_client_ip(request), commit=True,
)
return WithdrawOrderOut.model_validate(refreshed)
@router.post("/{out_bill_no}/approve", response_model=WithdrawOrderOut, summary="审核通过(发起微信打款)")
def approve_withdraw_order(
out_bill_no: str,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb,
) -> WithdrawOrderOut:
"""审核通过 → 调微信转账。仅 reviewing 单可通过(非 reviewing 返 409,防重复打款)。
打款的钱逻辑(转账/查单/退款/幂等)复用 wallet_repo.approve_withdraw → execute_withdraw_transfer;
返回的 order 可能是 pending(在途/待用户确认)/success/failed(打款失败已退),admin 前端按 status 展示。
"""
try:
order = wallet_repo.approve_withdraw(db, out_bill_no)
except wallet_repo.WithdrawOrderNotFound as e:
raise HTTPException(status_code=404, detail="提现单不存在") from e
except wallet_repo.WithdrawNotReviewable as e:
raise HTTPException(status_code=409, detail=str(e)) from e
except wxpay.WxPayNotConfiguredError as e:
raise HTTPException(status_code=503, detail="微信支付未配置") from e
write_audit(
db, admin, action="withdraw.approve", target_type="withdraw", target_id=out_bill_no,
detail={
"status": order.status,
"wechat_state": order.wechat_state,
"amount_cents": order.amount_cents,
},
ip=get_client_ip(request), commit=True,
)
return WithdrawOrderOut.model_validate(order)
@router.post("/{out_bill_no}/reject", response_model=WithdrawOrderOut, summary="审核拒绝(退回现金)")
def reject_withdraw_order(
out_bill_no: str,
body: WithdrawRejectRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("finance"))],
db: AdminDb,
) -> WithdrawOrderOut:
"""审核拒绝 → 退回用户现金 + 置 rejected,理由写入 fail_reason(用户可见)。仅 reviewing 单可拒。"""
try:
order = wallet_repo.reject_withdraw(db, out_bill_no, body.reason)
except wallet_repo.WithdrawOrderNotFound as e:
raise HTTPException(status_code=404, detail="提现单不存在") from e
except wallet_repo.WithdrawNotReviewable as e:
raise HTTPException(status_code=409, detail=str(e)) from e
write_audit(
db, admin, action="withdraw.reject", target_type="withdraw", target_id=out_bill_no,
detail={"reason": body.reason, "amount_cents": order.amount_cents},
ip=get_client_ip(request), commit=True,
)
return WithdrawOrderOut.model_validate(order)
-1
View File
@@ -1 +0,0 @@
"""admin 请求/响应 schemas(snake_case,与前端约定一致)。"""
-37
View File
@@ -1,37 +0,0 @@
"""admin 账号管理 + 审计日志 schemas。"""
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
_Role = Literal["super_admin", "finance", "operator"]
class AdminCreateRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=64)
password: str = Field(..., min_length=8, max_length=72) # bcrypt ≤72 字节
role: _Role = "operator"
class AdminUpdateRequest(BaseModel):
"""改角色 / 启用禁用 / 重置密码,字段都可选(只改传了的)。"""
role: _Role | None = None
status: Literal["active", "disabled"] | None = None
password: str | None = Field(None, min_length=8, max_length=72)
class AdminAuditLogOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
admin_id: int
admin_username: str
action: str
target_type: str
target_id: str | None = None
detail: dict | None = None
ip: str | None = None
created_at: datetime
-29
View File
@@ -1,29 +0,0 @@
"""Admin 认证 schemas。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class AdminLoginRequest(BaseModel):
username: str = Field(..., min_length=1, max_length=64)
password: str = Field(..., min_length=1, max_length=128)
class AdminOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
role: str
status: str
created_at: datetime
last_login_at: datetime | None = None
class AdminLoginResponse(BaseModel):
access_token: str
token_type: str = "Bearer"
expires_in: int = Field(..., description="access_token 剩余秒数(过期重新登录)")
admin: AdminOut
-19
View File
@@ -1,19 +0,0 @@
"""通用 schema:游标分页响应 + 通用 ok 响应。"""
from __future__ import annotations
from typing import Generic, TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class CursorPage(BaseModel, Generic[T]):
"""游标分页响应:items + 下一页游标(next_cursor=None 表示末页)。"""
items: list[T]
next_cursor: int | None = None
class OkResponse(BaseModel):
ok: bool = True
-22
View File
@@ -1,22 +0,0 @@
"""admin 运营配置 schemas。"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel
class ConfigItemOut(BaseModel):
key: str
label: str
group: str
type: str # int / int_list / dict_str_int
help: str | None = None
default: Any
value: Any
overridden: bool # 是否被运营覆盖过(false=用默认)
updated_at: str | None = None
class ConfigUpdateRequest(BaseModel):
value: Any
-56
View File
@@ -1,56 +0,0 @@
"""admin 大盘 schemas(对应 stats.dashboard_overview 的嵌套结构)。"""
from __future__ import annotations
from pydantic import BaseModel
class DashboardUsers(BaseModel):
total: int
active: int
disabled: int
deleted: int
new_today: int
dau: int
class DashboardCoins(BaseModel):
reward_video_coin_total: int = 0
reward_video_watch_count: int = 0
feed_ad_coin_total: int = 0
feed_ad_watch_count: int = 0
signin_coin_total: int = 0
signin_count: int = 0
signin_boost_coin_total: int = 0
signin_boost_watch_count: int = 0
granted_total: int
class DashboardCash(BaseModel):
withdraw_success_cents: int
withdraw_pending_count: int
withdraw_success_count: int
withdraw_failed_count: int
class DashboardComparison(BaseModel):
total: int
success: int
success_rate: float
class DashboardFeedback(BaseModel):
new: int
class DashboardCps(BaseModel):
available: bool
note: str
class DashboardOverview(BaseModel):
users: DashboardUsers
coins: DashboardCoins
cash: DashboardCash
comparison: DashboardComparison
feedback: DashboardFeedback
cps: DashboardCps
-18
View File
@@ -1,18 +0,0 @@
"""admin 反馈工单 schemas。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class FeedbackOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int
content: str
contact: str
images: list[str] | None = None
status: str
created_at: datetime
-53
View File
@@ -1,53 +0,0 @@
"""admin 首页轮播种子 schemas。金额单位:分(前端展示 ÷100 取元)。
种子 = 生成规则:masked_user 可空(空→feed 随机合成名)、金额是 [min_cents, max_cents] 区间。
"""
from __future__ import annotations
from pydantic import BaseModel
class OpsMarqueeSeedOut(BaseModel):
id: int
masked_user: str | None = None
min_cents: int
max_cents: int
enabled: bool
sort_order: int
created_at: str | None = None
class OpsMarqueeSeedCreate(BaseModel):
masked_user: str | None = None # 空 / 不传 → feed 随机合成脱敏名
min_cents: int
max_cents: int
enabled: bool = True
sort_order: int = 0
class OpsMarqueeSeedUpdate(BaseModel):
# masked_user:不传=不改;传空串=清空(改回随机合成);传值=固定该名
masked_user: str | None = None
min_cents: int | None = None
max_cents: int | None = None
enabled: bool | None = None
sort_order: int | None = None
class OpsMarqueeSeedBulkCreate(BaseModel):
"""批量生成 count 条自动合成名种子(冷启动铺量)。"""
count: int
min_cents: int
max_cents: int
enabled: bool = True
class OpsSavingsFeedPreviewItem(BaseModel):
masked_user: str
saved_amount_cents: int
time: str
class OpsSavingsFeedPreviewOut(BaseModel):
"""运营预览:实际混播出来的 feed(真实记录会插队,与客户端一致)。"""
items: list[OpsSavingsFeedPreviewItem]
-48
View File
@@ -1,48 +0,0 @@
"""admin 首页三统计展示配置 schemas。
倍率用千分比整数(1.000→1000);total_saved 的 manual_value / random_current 单位是分。
"""
from __future__ import annotations
from pydantic import BaseModel
class OpsStatItemOut(BaseModel):
metric: str # help_users / total_compares / total_saved
label: str # 帮助用户 / 完成比价 / 累计节省
unit: str # 人 / 次 / 分
mode: str # real / manual / random
manual_value: int | None = None
random_mult_min: int
random_mult_max: int
random_tick_seconds: int
random_anchor_minutes: int
random_kind: str # mult(×倍率) / add(+绝对增量)
random_step_min: int # 绝对增量区间(基础单位;total_saved 为分)
random_step_max: int
real_offset: int # 真实值模式基数偏移(基础单位)
allow_decrease: bool # 是否允许展示值下降(默认 false = 只增不减)
random_current: int | None = None
random_last_tick_at: str | None = None
real_value: int # 当前真实值(纯真实、不含偏移;给运营对比参考)
updated_at: str | None = None
class OpsStatUpdateRequest(BaseModel):
"""改某指标配置(均可选,只改传了的字段)。"""
mode: str | None = None
manual_value: int | None = None
random_mult_min: int | None = None
random_mult_max: int | None = None
random_tick_seconds: int | None = None
random_anchor_minutes: int | None = None
random_kind: str | None = None # mult / add
random_step_min: int | None = None
random_step_max: int | None = None
real_offset: int | None = None
allow_decrease: bool | None = None
# 切 random 时的初始基数;不传则用当前真实值播种
random_initial: int | None = None
# 立即更新:不等更新钟点,保存后马上把展示值刷新一次(real 快照/manual 生效/random 走一档)
apply_now: bool = False
-46
View File
@@ -1,46 +0,0 @@
"""admin 上报更低价审核 schemas。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class PriceReportOut(BaseModel):
"""admin 列表项:price_report 全字段(审核要看的快照 + 截图 + 状态 + 奖励)。"""
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int
comparison_record_id: int | None = None
# 选中比价记录的快照
store_name: str | None = None
dish_summary: str | None = None
original_platform_id: str | None = None
original_platform_name: str | None = None
original_price_cents: int | None = None
# 用户上报的更低价
reported_platform_id: str
reported_platform_name: str
reported_price_cents: int
images: list[str] = []
# 审核
status: str
reject_reason: str | None = None
reward_coins: int | None = None
reviewed_at: datetime | None = None
created_at: datetime
class PriceReportRejectRequest(BaseModel):
reason: str = Field(min_length=1, max_length=256, description="拒绝理由,用户端记录页会看到")
class PriceReportSummary(BaseModel):
"""审核台顶部各状态计数。"""
pending: int
approved: int
rejected: int
total: int
-52
View File
@@ -1,52 +0,0 @@
"""admin 用户管理 schemas。"""
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class AdminUserListItem(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
phone: str
nickname: str | None = None
register_channel: str
status: str
debug_trace_enabled: bool = False
wechat_openid: str | None = None
created_at: datetime
last_login_at: datetime
class AdminUserOverview(BaseModel):
"""用户 360 概览:基础资料 + 钱包余额 + 各项 count(历史明细走各自分页接口)。"""
model_config = ConfigDict(from_attributes=True)
user: AdminUserListItem
coin_balance: int
cash_balance_cents: int
total_coin_earned: int
comparison_total: int
comparison_success: int
withdraw_total: int
withdraw_success_cents: int
feedback_total: int
class GrantCoinsRequest(BaseModel):
amount: int = Field(..., description="金币变动:正=增加,负=扣减(不可为 0)")
reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)")
class SetUserStatusRequest(BaseModel):
status: Literal["active", "disabled"] = Field(
..., description="active=解封 / disabled=封禁(注销 deleted 不走此接口)"
)
class SetDebugTraceRequest(BaseModel):
enabled: bool = Field(..., description="是否给该用户开「复制调试链接」权限")
-149
View File
@@ -1,149 +0,0 @@
"""admin 钱包(金币/现金流水 + 提现单)schemas。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from app.admin.schemas.admin import AdminAuditLogOut
class CoinTxnOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int
amount: int
balance_after: int
biz_type: str
ref_id: str | None = None
remark: str | None = None
created_at: datetime
class CashTxnOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int
amount_cents: int
balance_after_cents: int
biz_type: str
ref_id: str | None = None
remark: str | None = None
created_at: datetime
class WithdrawOrderOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int
out_bill_no: str
amount_cents: int
user_name: str | None = None # 提现实名(审核核对 + 打款用)
status: str
wechat_state: str | None = None
transfer_bill_no: str | None = None
fail_reason: str | None = None
created_at: datetime
updated_at: datetime
class WithdrawSummaryOut(BaseModel):
reviewing_count: int
reviewing_amount_cents: int
pending_count: int
failed_count: int
today_success_count: int
today_success_amount_cents: int
today_rejected_count: int
class WithdrawUserSnapshot(BaseModel):
id: int
phone: str
nickname: str | None = None
status: str
wechat_nickname: str | None = None
wechat_avatar_url: str | None = None
created_at: datetime
last_login_at: datetime
cash_balance_cents: int
withdraw_total: int
withdraw_success_cents: int
class WithdrawDetailOut(BaseModel):
order: WithdrawOrderOut
user: WithdrawUserSnapshot | None = None
risk_flags: list[str]
risk_score: int
recent_withdraws: list[WithdrawOrderOut]
recent_cash_transactions: list[CashTxnOut]
audit_logs: list[AdminAuditLogOut]
class ReconcileResult(BaseModel):
checked: int
resolved: int
class WithdrawBulkRequest(BaseModel):
out_bill_nos: list[str] = Field(
..., min_length=1, max_length=50, description="提现商户单号列表"
)
class WithdrawBulkRejectRequest(WithdrawBulkRequest):
reason: str = Field(
..., min_length=1, max_length=200, description="批量拒绝理由(用户可见)"
)
class WithdrawBulkItemResult(BaseModel):
out_bill_no: str
ok: bool
status: str | None = None
error: str | None = None
class WithdrawBulkResult(BaseModel):
total: int
success: int
failed: int
items: list[WithdrawBulkItemResult]
class WithdrawLedgerCheckOut(BaseModel):
ok: bool
cash_balance_total_cents: int
cash_transaction_total_cents: int
balance_diff_cents: int
missing_withdraw_txn_count: int
missing_refund_txn_count: int
duplicate_refund_txn_count: int
refund_txn_on_non_terminal_count: int
class WxpayHealthCheckOut(BaseModel):
ok: bool
wxpay_configured: bool
wxpay_auth_configured: bool
private_key_path: str
private_key_exists: bool
private_key_loadable: bool
public_key_path: str
public_key_exists: bool
public_key_loadable: bool
auth_notify_url_configured: bool
auto_reconcile_enabled: bool
auto_reconcile_interval_sec: int
auto_reconcile_older_than_minutes: int
issues: list[str]
class WithdrawRejectRequest(BaseModel):
reason: str = Field(
..., min_length=1, max_length=200, description="拒绝理由(写入 fail_reason,用户可见)"
)
-55
View File
@@ -1,55 +0,0 @@
"""Admin JWT:与 App 用户 token 完全隔离。
用独立 secret(settings.ADMIN_JWT_SECRET ≠ JWT_SECRET_KEY)+ payload typ="admin",
双重保证 App 用户的 access_token 无法当 admin token 用(secret 不同直接验签失败)。
admin 无 refresh:过期(默认 12h)重新登录,简单。
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
import jwt
from app.core.config import settings
_ALGO = "HS256"
class AdminTokenError(Exception):
"""admin token 解析/校验失败,api 层捕获后返回 401。"""
def _now() -> datetime:
return datetime.now(timezone.utc)
def create_admin_token(*, admin_id: int, role: str) -> tuple[str, int]:
"""签发 admin access token,返回 (token, expires_in_seconds)。"""
now = _now()
expire = now + timedelta(minutes=settings.ADMIN_JWT_EXPIRE_MINUTES)
payload: dict[str, Any] = {
"sub": str(admin_id),
"role": role,
"typ": "admin",
"iat": int(now.timestamp()),
"exp": int(expire.timestamp()),
}
token = jwt.encode(payload, settings.ADMIN_JWT_SECRET, algorithm=_ALGO)
return token, int((expire - now).total_seconds())
def decode_admin_token(token: str) -> dict[str, Any]:
"""解析校验 admin token。签名错/过期/typ 非 admin → AdminTokenError。"""
try:
payload = jwt.decode(token, settings.ADMIN_JWT_SECRET, algorithms=[_ALGO])
except jwt.ExpiredSignatureError as e:
raise AdminTokenError("token expired") from e
except jwt.InvalidTokenError as e:
raise AdminTokenError(f"invalid token: {e}") from e
if payload.get("typ") != "admin":
raise AdminTokenError("not an admin token")
if "sub" not in payload:
raise AdminTokenError("token missing sub")
return payload
-1
View File
@@ -1 +0,0 @@
"""内部(server→server)端点。不对客户端开放,靠共享密钥头校验,不走用户 JWT。"""
-63
View File
@@ -1,63 +0,0 @@
"""价格观测内部上报端点(pricebot → app-server)。
pricebot 在比价 done 帧把整批价格事实 POST 到这里落库。**不是给客户端的接口**:
不走用户 JWT,靠 server 间共享密钥头 `X-Internal-Secret` 校验(== settings.INTERNAL_API_SECRET)。
密钥未配置(默认空)时直接 503,避免裸奔的写端点。
与不鉴权的比价透传(compare.py / coupon.py)无关:那两个是客户端→server 的透传壳,
这个是 server→server 的内部写库。
"""
from __future__ import annotations
import hmac
import logging
from typing import Annotated
from fastapi import APIRouter, Header, HTTPException, status
from app.api.deps import DbSession
from app.core.config import settings
from app.repositories import price_observation as repo
from app.schemas.price_observation import (
PriceObservationBatchIn,
PriceObservationBatchOut,
)
logger = logging.getLogger("shagua.internal.price")
router = APIRouter(prefix="/internal", tags=["internal"])
def _check_secret(x_internal_secret: str | None) -> None:
"""共享密钥校验。未配置 → 503(挡住裸奔写端点);不匹配 → 401(常量时间比较)。"""
configured = settings.INTERNAL_API_SECRET
if not configured:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="internal api not configured",
)
if not x_internal_secret or not hmac.compare_digest(x_internal_secret, configured):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid internal secret",
)
@router.post(
"/price-observation",
response_model=PriceObservationBatchOut,
summary="价格观测内部上报(pricebot→app-server,落 price_observation)",
)
def report_price_observation(
payload: PriceObservationBatchIn,
db: DbSession,
x_internal_secret: Annotated[str | None, Header()] = None,
) -> PriceObservationBatchOut:
_check_secret(x_internal_secret)
inserted, skipped = repo.insert_batch(db, payload)
logger.info(
"price_observation trace=%s inserted=%d skipped=%d device=%s user=%s",
payload.trace_id, inserted, skipped,
payload.source_device_id, payload.source_user_id,
)
return PriceObservationBatchOut(inserted=inserted, skipped=skipped)
-365
View File
@@ -1,365 +0,0 @@
"""看激励视频发奖 endpoint。
路由前缀 `/api/v1/ad`:
GET /pangle-callback 穿山甲 S2S 发奖回调(**无 JWT,靠验签**),穿山甲服务器调
GET /reward-status 客户端查今日看广告发奖进度(Bearer)
发奖走服务端:激励视频播完穿山甲回调本接口,验签通过后幂等发金币。客户端只负责
看完后刷新余额,不参与发奖,被破解也刷不到钱。
"""
from __future__ import annotations
import logging
import json
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status
from app.api.deps import CurrentUser, DbSession
from app.core import rewards
from app.core.config import settings
from app.integrations import pangle
from app.core.ratelimit import rate_limit
from app.repositories import ad_ecpm as crud_ecpm
from app.repositories import ad_feed_reward as crud_feed
from app.repositories import ad_reward as crud_ad
from app.repositories import ad_watch as crud_watch
from app.repositories import signin as crud_signin
from app.schemas.ad import (
AdRewardStatusOut,
EcpmReportIn,
EcpmReportOut,
FeedRewardIn,
FeedRewardOut,
PangleCallbackOut,
TestGrantIn,
TestGrantOut,
WatchReportIn,
WatchReportOut,
)
logger = logging.getLogger("shagua.ad")
router = APIRouter(prefix="/api/v1/ad", tags=["ad"])
# GroMore 回调 is_verify=false 时透传给客户端 SDK 的错误码(自定义,仅用于排查/客户端提示)
REASON_OK = 0
REASON_BAD_PARAMS = 1 # 验签过但缺 trans_id / user_id 非数字
REASON_UNKNOWN_USER = 2 # user_id 不存在(可能伪造)
REWARD_SCENE_REWARD_VIDEO = "reward_video"
REWARD_SCENE_SIGNIN_BOOST = "signin_boost"
SUPPORTED_REWARD_SCENES = {REWARD_SCENE_REWARD_VIDEO, REWARD_SCENE_SIGNIN_BOOST}
def _parse_extra(raw_extra: str | None) -> dict[str, str]:
"""解析客户端 setMediaExtra 透传的 JSON;旧格式/异常返回空 dict。"""
if not raw_extra:
return {}
try:
data = json.loads(raw_extra)
except (TypeError, ValueError):
return {}
if not isinstance(data, dict):
return {}
return {str(k): str(v) for k, v in data.items() if v is not None}
@router.get(
"/pangle-callback",
response_model=PangleCallbackOut,
summary="穿山甲激励视频发奖回调(S2S,验签)",
dependencies=[Depends(rate_limit(300, 60, "pangle-callback"))],
)
def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
"""穿山甲 GroMore 在激励视频播完后回调,带 user_id / trans_id / ecpm / sign 等 query 参数。
流程:开关/密钥就绪 → 验签(SHA256(m-key:trans_id)) → 解析 user_id/场景/eCPM → 幂等发金币。
验签失败 403(留给真请求重试);参数缺/坏或 user 不存在 → is_verify=false + reason(不发,不重试)。
granted / capped → is_verify=true + reason=0。
"""
if not settings.pangle_callback_configured:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
)
params = dict(request.query_params)
if not pangle.verify_callback_sign(params, settings.PANGLE_REWARD_SECRET):
logger.warning("pangle callback bad sign trans_id=%s", params.get("trans_id"))
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad sign")
trans_id = params.get("trans_id") or ""
raw_user_id = params.get("user_id") or ""
if not trans_id or not raw_user_id.isdigit():
# 验签过了但参数缺/坏:不发奖(is_verify=false),带错误码
logger.warning("pangle callback bad params: %s", params)
return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS)
user_id = int(raw_user_id)
raw = "&".join(f"{k}={v}" for k, v in sorted(params.items()) if k != "sign")
extra = {}
for extra_key in ("extra", "gromoreExtra", "gromore_extra"):
extra.update(_parse_extra(params.get(extra_key)))
reward_scene = extra.get("reward_scene") or REWARD_SCENE_REWARD_VIDEO
ad_session_id = extra.get("ad_session_id")
ecpm = params.get("ecpm")
existing = crud_ad.find_by_trans(db, trans_id)
if existing is not None:
logger.info(
"pangle callback idempotent user_id=%d trans_id=%s status=%s scene=%s",
user_id, trans_id, existing.status, existing.reward_scene,
)
return PangleCallbackOut(is_verify=True, reason=REASON_OK)
try:
if reward_scene not in SUPPORTED_REWARD_SCENES:
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=0, reward_scene=reward_scene[:32],
ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
status="unknown_scene",
)
logger.warning(
"pangle callback unknown scene user_id=%d trans_id=%s scene=%s",
user_id, trans_id, reward_scene,
)
return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS)
if reward_scene == REWARD_SCENE_SIGNIN_BOOST:
try:
boost, _balance = crud_signin.boost_today_signin(
db, user_id, ad_ref_id=trans_id, commit=False
)
except crud_signin.NotSignedTodayError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
status="not_signed",
)
except crud_signin.AlreadyBoostedError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
status="already_boosted",
)
except crud_signin.LastCycleDayBoostBlockedError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
status="last_day",
)
else:
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=boost.coin_awarded,
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
commit=False,
)
db.commit()
db.refresh(rec)
else:
rec = crud_ad.grant_ad_reward(
db, user_id, trans_id, ecpm=ecpm, ad_session_id=ad_session_id,
reward_scene=REWARD_SCENE_REWARD_VIDEO,
reward_name=params.get("reward_name"), raw=raw[:1024],
)
except crud_ad.UnknownUserError:
logger.warning("pangle callback unknown user_id=%d trans_id=%s", user_id, trans_id)
return PangleCallbackOut(is_verify=False, reason=REASON_UNKNOWN_USER)
logger.info(
"ad reward user_id=%d trans_id=%s scene=%s status=%s coin=%d",
user_id, trans_id, rec.reward_scene, rec.status, rec.coin,
)
# granted / capped 均算"已处理":is_verify=true 不让穿山甲重试(capped 只是没加币)
return PangleCallbackOut(is_verify=True, reason=REASON_OK)
@router.get("/reward-status", response_model=AdRewardStatusOut, summary="今日看广告发奖进度")
def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
(used, limit, coin_per, round_count, cooldown_until,
watched_sec, watch_limit) = crud_ad.today_status(db, user.id)
return AdRewardStatusOut(
used_today=used,
daily_limit=limit,
remaining=max(0, limit - used),
coin_per_ad=coin_per,
round_count=round_count,
cooldown_until=cooldown_until,
watched_seconds_today=watched_sec,
watch_seconds_limit=watch_limit,
watch_seconds_remaining=max(0, watch_limit - watched_sec),
)
@router.post(
"/watch-report",
response_model=WatchReportOut,
summary="上报激励视频观看时长(旧客户端兼容字段)",
dependencies=[Depends(rate_limit(120, 60, "ad-watch-report"))],
)
def watch_report(payload: WatchReportIn, user: CurrentUser, db: DbSession) -> WatchReportOut:
"""客户端在激励视频关闭(onAdClose)后上报本次实际观看秒数,服务端累计到当日总时长。
Bearer 鉴权,user_id 取自 JWT(不信 body)。seconds 服务端夹 [0, MAX_SINGLE_WATCH_SECONDS]。
当前产品只保留每日 500 次上限,DAILY_AD_WATCH_SECONDS_LIMIT=0 表示时长闸不启用;该接口
仍保留用于旧客户端兼容和排查观看时长。
"""
total = crud_watch.add_watch_seconds(db, user.id, payload.seconds)
limit = rewards.DAILY_AD_WATCH_SECONDS_LIMIT
logger.info(
"ad watch report user_id=%d +%ds total=%d/%d",
user.id, payload.seconds, total, limit,
)
return WatchReportOut(
watched_seconds_today=total,
watch_seconds_limit=limit,
watch_seconds_remaining=max(0, limit - total),
)
@router.post(
"/ecpm-report",
response_model=EcpmReportOut,
summary="上报本次广告展示的 eCPM(内部收益统计)",
dependencies=[Depends(rate_limit(120, 60, "ad-ecpm-report"))],
)
def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> EcpmReportOut:
"""客户端在广告展示后(onAdShow 读 getShowEcpm)上报 eCPM,落库做内部收益统计/对账。
Bearer 鉴权,user_id 取自 JWT(不信 body)。best-effort:落库即 ok,客户端 fire-and-forget,
丢一两条不影响业务(穿山甲后台报表是结算权威)。eCPM 与发奖(S2S)是两条独立流,不逐条关联。
"""
crud_ecpm.create_ecpm_record(
db, user.id,
ad_type=payload.ad_type, ecpm_raw=payload.ecpm,
ad_session_id=payload.ad_session_id,
adn=payload.adn, slot_id=payload.slot_id,
)
logger.info(
"ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s",
user.id, payload.ad_type, payload.ad_session_id, payload.ecpm, payload.adn, payload.slot_id,
)
return EcpmReportOut(ok=True)
@router.post(
"/test-grant",
response_model=TestGrantOut,
summary="[仅本地联调]模拟穿山甲回调发奖",
dependencies=[Depends(rate_limit(60, 60, "ad-test-grant"))],
)
def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = None) -> TestGrantOut:
"""⚠️ 仅本地联调用:没部署公网、穿山甲 S2S 回调打不到本地时,客户端(debug 包)看完广告后
调这个接口,直接走与回调相同的发奖逻辑(幂等 + 每日上限),验证"看广告→金币到账"全链路。
必须 settings.AD_REWARD_TEST_GRANT_ENABLED=true 才开放(默认 False),否则 404 当不存在。
它让已登录客户端能自助发奖 = 绕过反作弊,**生产必须关闭**。
"""
if not settings.AD_REWARD_TEST_GRANT_ENABLED:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not found")
reward_scene = (payload.reward_scene if payload is not None else REWARD_SCENE_REWARD_VIDEO)
if reward_scene not in SUPPORTED_REWARD_SCENES:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="bad reward_scene")
# 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限/今日膨胀一次)
trans_id = f"test-{user.id}-{uuid.uuid4().hex}"
if reward_scene == REWARD_SCENE_SIGNIN_BOOST:
try:
boost, _balance = crud_signin.boost_today_signin(
db, user.id, ad_ref_id=trans_id, commit=False
)
except crud_signin.NotSignedTodayError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
raw="client debug test-grant signin_boost", status="not_signed",
)
except crud_signin.AlreadyBoostedError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
raw="client debug test-grant signin_boost", status="already_boosted",
)
except crud_signin.LastCycleDayBoostBlockedError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
raw="client debug test-grant signin_boost", status="last_day",
)
else:
rec = crud_ad.record_external_reward(
db, user.id, trans_id, coin=boost.coin_awarded,
reward_scene=reward_scene, reward_name="测试签到膨胀",
raw="client debug test-grant signin_boost", commit=False,
)
db.commit()
db.refresh(rec)
else:
try:
rec = crud_ad.grant_ad_reward(
db, user.id, trans_id, ecpm="200", reward_name="测试发奖",
raw="client debug test-grant ecpm=200",
)
except crud_ad.UnknownUserError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
(used, limit, coin_per, round_count, cooldown_until,
_watched, _watch_limit) = crud_ad.today_status(db, user.id)
logger.info(
"ad TEST grant user_id=%d scene=%s status=%s coin=%d",
user.id, reward_scene, rec.status, rec.coin,
)
return TestGrantOut(
granted=(rec.status == "granted"),
status=rec.status,
coin=rec.coin,
used_today=used,
daily_limit=limit,
remaining=max(0, limit - used),
coin_per_ad=coin_per,
round_count=round_count,
cooldown_until=cooldown_until,
)
@router.post(
"/feed-reward",
response_model=FeedRewardOut,
summary="信息流广告完成后结算金币",
dependencies=[Depends(rate_limit(120, 60, "ad-feed-reward"))],
)
def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> FeedRewardOut:
"""点位 2:信息流广告每展示满 10 秒累计一份奖励,视频完成后一次性入账。
当前一期由客户端完成回调携带 eCPM / 展示秒数上报;client_event_id 做幂等键,避免重试重复发。
"""
rec = crud_feed.grant_feed_reward(
db,
user.id,
client_event_id=payload.client_event_id,
ecpm=payload.ecpm,
duration_seconds=payload.duration_seconds,
ad_session_id=payload.ad_session_id,
adn=payload.adn,
slot_id=payload.slot_id,
)
logger.info(
"feed ad reward user_id=%d event=%s status=%s units=%d coin=%d",
user.id, rec.client_event_id, rec.status, rec.unit_count, rec.coin,
)
return FeedRewardOut(
granted=(rec.status == "granted"),
status=rec.status,
coin=rec.coin,
unit_count=rec.unit_count,
daily_limit=rewards.get_ad_daily_limit(db),
)
+10 -21
View File
@@ -12,14 +12,13 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, HTTPException, status
from app.api.deps import CurrentUser, DbSession
from app.core.ratelimit import rate_limit
from app.core.jiguang import JiguangError, mask_phone, verify_and_get_phone
from app.core.security import TokenError, decode_token, issue_token_pair
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
from app.integrations.sms import SmsError, send_code, verify_code
from app.repositories import user as user_repo
from app.core.sms import SmsError, send_code, verify_code
from app.crud import user as crud_user
from app.schemas.auth import (
JverifyLoginRequest,
LogoutResponse,
@@ -62,7 +61,7 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
logger.error("[JG] verify+decrypt failed: %s", e, exc_info=True)
raise HTTPException(status_code=502, detail=f"jiguang verify failed: {e}") from e
user = user_repo.upsert_user_for_login(db, phone=phone, register_channel="jverify")
user = crud_user.upsert_user_for_login(db, phone=phone, register_channel="jverify")
if user.status != "active":
raise HTTPException(status_code=403, detail="account disabled")
@@ -72,34 +71,24 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
# ===================== 短信登录 =====================
@router.post(
"/sms/send",
response_model=SmsSendResponse,
summary="发送短信验证码",
dependencies=[Depends(rate_limit(10, 60, "sms-send"))], # 同 IP 每分钟≤10 次(防一 IP 刷不同号)
)
@router.post("/sms/send", response_model=SmsSendResponse, summary="发送短信验证码 (mock)")
def sms_send(req: SmsSendRequest) -> SmsSendResponse:
try:
cooldown = send_code(req.phone)
except SmsError as e:
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
raise HTTPException(status_code=429, detail=str(e)) from e
from app.core.config import settings # 局部 import 避免循环
return SmsSendResponse(sent=True, mock=settings.SMS_MOCK, cooldown_sec=cooldown)
@router.post(
"/sms/login",
response_model=TokenWithUser,
summary="手机号+验证码登录",
dependencies=[Depends(rate_limit(20, 60, "sms-login"))], # 防撞库爆破(另有单码失败次数上限)
)
@router.post("/sms/login", response_model=TokenWithUser, summary="手机号+验证码登录")
def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser:
if not verify_code(req.phone, req.code):
raise HTTPException(status_code=400, detail="invalid sms code")
user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
user = crud_user.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
if user.status != "active":
raise HTTPException(status_code=403, detail="account disabled")
@@ -117,7 +106,7 @@ def refresh(req: RefreshRequest, db: DbSession) -> TokenPair:
raise HTTPException(status_code=401, detail=str(e)) from e
user_id = int(payload["sub"])
user = user_repo.get_user_by_id(db, user_id)
user = crud_user.get_user_by_id(db, user_id)
if user is None or user.status != "active":
raise HTTPException(status_code=401, detail="user not found or disabled")
-107
View File
@@ -1,107 +0,0 @@
"""外卖比价业务透传端点。
把客户端的 POST /api/v1/intent/recognize 和 /api/v1/price/step 请求原样转发给
pricebot-backend 的 /api/intent/recognize 和 /api/price/step。MVP 阶段**不鉴权**
(同 coupon/step,见 docs/待办与技术债.md P1:device_id 透传区分设备,待补 JWT +
device_id↔user_id 绑定后才能做用户级画像)。
- Phase 1 /intent/recognize:从源平台(淘宝闪购/美团/京东外卖)购物车页识别店名+菜品+
价格,一次性。
- Phase 2 /price/step:多轮循环,在目标平台复现订单读到手价,直到 done。
真正的目标驱动比价逻辑在 pricebot-backend(GoalEngine,另一个 repo),本接口只是透传壳。
电商(ecom)那两个端点 food MVP 暂不需要,以后放开 scene 时再加 /ecom/intent/recognize
和 /ecom/step 两行即可。
pricebot 协议文档:
pricebot-backend/docs/main/02_api_protocol.md
"""
from __future__ import annotations
import json
import logging
from typing import Any
import httpx
from fastapi import APIRouter, HTTPException, Request, status
from app.core.config import settings
from app.core.pricebot_router import pick_pricebot
logger = logging.getLogger("shagua.compare")
router = APIRouter(prefix="/api/v1", tags=["compare"])
async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
"""把请求体原样转发给 pricebot-backend 的 {upstream_path}
跟 coupon_step 同款透传壳:不鉴权、不做 schema 校验,仅读 device_id/trace_id/step
打日志。比价单帧是大上下文 / 逐帧 LLM,超时用 PRICEBOT_COMPARE_TIMEOUT_SEC(60s,
比领券的 30s 长)。
"""
# 读原始字节,避免"反序列化→再序列化"的双重 JSON(省 ~一半透传 CPU,让 app-server
# 单 worker 也扛得住高并发)。只 json.loads 一次拿 trace_id 做亲和 + 打日志,转发时
# 直接发原始 bytes(content=raw),不重新 dumps。
raw = await request.body()
try:
meta = json.loads(raw)
except Exception as e:
raise HTTPException(status_code=400, detail=f"invalid json body: {e}") from e
if not isinstance(meta, dict):
meta = {}
# 按 trace_id 一致性 hash 选 pricebot 实例(同一比价的所有帧落同一进程,内存维护 state)
base = pick_pricebot(meta.get("trace_id"))
url = f"{base.rstrip('/')}{upstream_path}"
timeout = settings.PRICEBOT_COMPARE_TIMEOUT_SEC
logger.info(
"compare %s device_id=%s trace_id=%s step=%s",
upstream_path,
meta.get("device_id"),
meta.get("trace_id"),
meta.get("step"),
)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}
)
except httpx.RequestError as e:
logger.error("[pricebot] request failed: %s", e)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"pricebot upstream unreachable: {e}",
) from e
if resp.status_code >= 500:
logger.error(
"[pricebot] 5xx status=%d body=%s",
resp.status_code,
resp.text[:500],
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"pricebot upstream returned {resp.status_code}",
)
return resp.json()
@router.post("/intent/recognize", summary="外卖比价 Phase 1 意图识别 (透传到 pricebot)")
async def intent_recognize(request: Request) -> dict[str, Any]:
return await _passthrough(request, "/api/intent/recognize")
@router.post("/intent/step", summary="外卖比价 Phase 1 多帧意图识别 (透传到 pricebot, 仅淘宝源)")
async def intent_step(request: Request) -> dict[str, Any]:
# 多帧版意图识别(展开+滚动采集→提取): 循环调用直到 done(done 帧顶层带
# result+calibration)。目前仅淘宝源走这条, 其它源走上面单次 /intent/recognize。
return await _passthrough(request, "/api/intent/step")
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传到 pricebot)")
async def price_step(request: Request) -> dict[str, Any]:
return await _passthrough(request, "/api/price/step")
-76
View File
@@ -1,76 +0,0 @@
"""比价战绩里程碑 endpoint(福利页「记录比价战绩」)。
路由前缀 `/api/v1/compare`:
GET /milestones 进度与各档领取状态
POST /milestones/{milestone}/claim 领取某档奖励
**均需鉴权**。解锁进度 = 该用户 status='success' 的 comparison_record 条数;每档领一次。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, status
from app.api.deps import CurrentUser, DbSession
from app.repositories import comparison_milestone as crud_milestone
from app.schemas.compare_record import (
MilestoneClaimResultOut,
MilestoneStateOut,
MilestoneStatusOut,
)
logger = logging.getLogger("shagua.compare_milestone")
router = APIRouter(prefix="/api/v1/compare", tags=["compare-milestone"])
@router.get(
"/milestones",
response_model=MilestoneStatusOut,
summary="比价战绩里程碑进度",
)
def get_milestones(user: CurrentUser, db: DbSession) -> MilestoneStatusOut:
st = crud_milestone.get_status(db, user.id)
return MilestoneStatusOut(
success_count=st.success_count,
claimable_count=st.claimable_count,
milestones=[
MilestoneStateOut(milestone=m.milestone, coin=m.coin, state=m.state)
for m in st.milestones
],
)
@router.post(
"/milestones/{milestone}/claim",
response_model=MilestoneClaimResultOut,
summary="领取比价战绩里程碑奖励",
)
def claim_milestone(
milestone: int, user: CurrentUser, db: DbSession
) -> MilestoneClaimResultOut:
try:
coin, balance = crud_milestone.claim(db, user.id, milestone)
except crud_milestone.UnknownMilestoneError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="unknown milestone"
) from e
except crud_milestone.MilestoneLockedError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="milestone locked"
) from e
except crud_milestone.AlreadyClaimedError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="milestone already claimed"
) from e
logger.info(
"compare milestone claimed user_id=%d milestone=%d coin=%d",
user.id,
milestone,
coin,
)
return MilestoneClaimResultOut(
milestone=milestone, coin_awarded=coin, coin_balance=balance
)
-107
View File
@@ -1,107 +0,0 @@
"""比价记录 endpoint(「我的比价记录」数据源)。
路由前缀 `/api/v1/compare`:
POST /record 上报一次比价结果(幂等:同 user+trace_id 覆盖)
GET /records 比价记录列表(游标分页)
GET /records/{id} 单条详情(含 raw_payload 全量)
**均需鉴权**(CurrentUser)——与同文件无关的不鉴权透传 `compare.py` 分开:那个是
转发壳(MVP 不鉴权),这里是按用户维度落库的业务接口,必须有 user_id。
注:本轮只做 server 端,客户端(android 仓)在 done 帧后调 POST /record 上报的改动
另起一轮(见 app-server docs/待办与技术债.md P1)。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Query, status
from app.api.deps import CurrentUser, DbSession
from app.repositories import comparison as crud_compare
from app.schemas.compare_record import (
CompareStatsOut,
ComparisonRecordCreatedOut,
ComparisonRecordDetailOut,
ComparisonRecordIn,
ComparisonRecordPage,
ComparisonRecordOut,
)
logger = logging.getLogger("shagua.compare_record")
router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"])
@router.post(
"/record",
response_model=ComparisonRecordCreatedOut,
summary="上报一次比价结果(幂等)",
)
def report_record(
payload: ComparisonRecordIn, user: CurrentUser, db: DbSession
) -> ComparisonRecordCreatedOut:
rec = crud_compare.upsert_record(db, user_id=user.id, payload=payload)
logger.info(
"compare record user=%s trace=%s biz=%s status=%s saved=%s",
user.id,
rec.trace_id,
rec.business_type,
rec.status,
rec.saved_amount_cents,
)
return ComparisonRecordCreatedOut(id=rec.id)
@router.get(
"/stats",
response_model=CompareStatsOut,
summary="比价口径战绩(完成比价数 + 累计发现可省)",
)
def stats(user: CurrentUser, db: DbSession) -> CompareStatsOut:
count, saved = crud_compare.get_stats(db, user.id)
return CompareStatsOut(compare_count=count, discovered_saved_cents=saved)
@router.get(
"/records",
response_model=ComparisonRecordPage,
summary="比价记录列表(游标分页)",
)
def list_records(
user: CurrentUser,
db: DbSession,
limit: int = Query(20, ge=1, le=100),
cursor: int | None = Query(None, description="上一页末条 id"),
) -> ComparisonRecordPage:
items, next_cursor = crud_compare.list_records(
db, user.id, limit=limit, cursor=cursor
)
outs = [ComparisonRecordOut.model_validate(it) for it in items]
# 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它)
if not user.debug_trace_enabled:
for o in outs:
o.trace_url = None
return ComparisonRecordPage(items=outs, next_cursor=next_cursor)
@router.get(
"/records/{record_id}",
response_model=ComparisonRecordDetailOut,
summary="比价记录详情(含 raw_payload)",
)
def get_record(
record_id: int, user: CurrentUser, db: DbSession
) -> ComparisonRecordDetailOut:
rec = crud_compare.get_record(db, user.id, record_id)
if rec is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="record not found")
out = ComparisonRecordDetailOut.model_validate(rec)
# 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url。
# ⚠️ raw_payload 是上报体全量(model_dump),里面也藏着一份 trace_url,必须一并抹掉——
# 否则无权限用户从详情接口的 raw_payload 绕过权限闸拿到 trace_url。
if not user.debug_trace_enabled:
out.trace_url = None
if isinstance(out.raw_payload, dict):
out.raw_payload.pop("trace_url", None)
return out
-197
View File
@@ -1,197 +0,0 @@
"""领券业务透传端点。
把客户端的 POST /api/v1/coupon/step 请求原样转发给 pricebot-backend
的 /api/coupon/step。MVP 阶段**不鉴权**(见 docs/待办与技术债.md P1:
device_id 透传给 pricebot 区分设备,待补 JWT + device_id↔user_id 绑定后
才能做用户级画像)。
pricebot 协议文档:
pricebot-backend/docs/projects/领券-客户端对接协议.md
"""
from __future__ import annotations
import json
import logging
from typing import Any
import httpx
from fastapi import APIRouter, HTTPException, Request, status
from fastapi.concurrency import run_in_threadpool
from app.api.deps import DbSession
from app.core.config import settings
from app.core.pricebot_router import pick_pricebot
from app.db.session import SessionLocal
from app.repositories import coupon_state as coupon_repo
from app.schemas.coupon_state import CouponPromptDismissIn, CouponPromptShouldShowOut
logger = logging.getLogger("shagua.coupon")
router = APIRouter(prefix="/api/v1/coupon", tags=["coupon"])
def _to_int(v: object) -> int | None:
"""user_id 协议是字符串(登录态才带),转 int 存库;缺失/非法 → None。"""
if v is None:
return None
try:
return int(v) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
def _extract_coupon_results(resp_json: dict) -> list[dict]:
"""从 pricebot 响应抽本帧券结果,并**按 coupon_id 去重**。
⚠️ done/单券帧同时带 last_coupon_result(最后一张)+ action.params.coupon_results
(全量含最后一张)→ 最后一张出现两次。必须去重:同批里同 coupon_id 两次,
record_claims 在 autoflush=False 下两次 select 都查不到刚 add 的行 → 重复 add →
commit 撞唯一约束 IntegrityError 回滚整批(done/单券记录全丢)。
coupon_results(全量权威)覆盖 last_coupon_result。"""
by_id: dict[str, dict] = {}
last = resp_json.get("last_coupon_result")
if isinstance(last, dict) and last.get("coupon_id"):
by_id[last["coupon_id"]] = last
params = (resp_json.get("action") or {}).get("params") or {}
cr = params.get("coupon_results")
if isinstance(cr, list):
for x in cr:
if isinstance(x, dict) and x.get("coupon_id"):
by_id[x["coupon_id"]] = x
return list(by_id.values())
def _mark_engagement_blocking(
device_id: str, user_id: int | None, engage_type: str
) -> None:
"""独立 session 写 engagement(async 端点经 run_in_threadpool 调,不阻塞事件循环)。"""
with SessionLocal() as db:
coupon_repo.mark_engagement(db, device_id, user_id, engage_type)
def _record_claims_blocking(
device_id: str, user_id: int | None, trace_id: str | None, results: list[dict]
) -> None:
with SessionLocal() as db:
coupon_repo.record_claims(db, device_id, user_id, trace_id, results)
@router.post("/step", summary="领券任务步进 (透传到 pricebot)")
async def coupon_step(
request: Request,
) -> dict[str, Any]:
"""把请求体原样转发给 pricebot-backend 的 /api/coupon/step。
- 鉴权: MVP 阶段不鉴权(待补 JWT,见 docs/待办与技术债.md P1)
- 透传: 不做 schema 校验,pricebot 自己校验
- 失败: 网络不可达 / pricebot 5xx → 502 + 友好 message
"""
# 读原始字节,避免"反序列化→再序列化"的双重 JSON(省透传 CPU)。只 loads 一次拿
# trace_id 做亲和 + 打日志,转发时直接发原始 bytes,不重新 dumps。
raw = await request.body()
try:
meta = json.loads(raw)
except Exception as e:
raise HTTPException(status_code=400, detail=f"invalid json body: {e}") from e
if not isinstance(meta, dict):
meta = {}
device_id = meta.get("device_id")
user_id = _to_int(meta.get("user_id")) # 登录态才带;判断不靠它,资产留痕用
trace_id = meta.get("trace_id")
# 领券任务首帧(step=0)= 用户已发起领券 → 记一条今日 engagement(claim_started),
# 今天这台设备不再弹引导窗(对齐前台"点一键领取即 markEngaged")。写库失败绝不能
# 连累领券主流程,整段吞掉。
if device_id and meta.get("step") == 0:
try:
await run_in_threadpool(
_mark_engagement_blocking, device_id, user_id, "claim_started"
)
except Exception as e: # noqa: BLE001
logger.warning("coupon engagement write failed: %s", e)
# 按 trace_id 一致性 hash 选 pricebot 实例(同一领券任务的所有帧落同一进程)
base = pick_pricebot(meta.get("trace_id"))
url = f"{base.rstrip('/')}/api/coupon/step"
timeout = settings.PRICEBOT_REQUEST_TIMEOUT_SEC
logger.info(
"coupon_step device_id=%s trace_id=%s step=%s",
meta.get("device_id"),
meta.get("trace_id"),
meta.get("step"),
)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(
url, content=raw, headers={"Content-Type": "application/json"}
)
except httpx.RequestError as e:
logger.error("[pricebot] request failed: %s", e)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"pricebot upstream unreachable: {e}",
) from e
if resp.status_code >= 500:
logger.error(
"[pricebot] 5xx status=%d body=%s",
resp.status_code,
resp.text[:500],
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"pricebot upstream returned {resp.status_code}",
)
resp_json = resp.json()
# 领券结果沉淀(资产):中间帧 last_coupon_result + done 帧 coupon_results 幂等写库。
# 当前只记录、不参与"要不要领"判断(MVP 先不去重)。写库失败不影响返回。
if device_id:
results = _extract_coupon_results(resp_json)
if results:
try:
await run_in_threadpool(
_record_claims_blocking, device_id, user_id, trace_id, results
)
except Exception as e: # noqa: BLE001
logger.warning("coupon claim write failed: %s", e)
return resp_json
@router.post("/prompt/dismiss", summary="用户拒绝/关闭领券引导窗(记今日已 engage)")
def coupon_prompt_dismiss(payload: CouponPromptDismissIn, db: DbSession) -> dict[str, bool]:
"""客户端点关闭引导窗时调用 → 记一条今日 engagement(dismissed),今天不再弹。
server 在透传链路里看不到"用户拒绝"(拒绝不发起领券),故必须客户端通知。
MVP 不鉴权,按 device_id 记。
"""
coupon_repo.mark_engagement(db, payload.device_id, payload.user_id, "dismissed")
return {"ok": True}
@router.get(
"/prompt/should-show",
response_model=CouponPromptShouldShowOut,
summary="切到外卖 App 时是否还应弹领券引导窗",
)
def coupon_prompt_should_show(
device_id: str, db: DbSession
) -> CouponPromptShouldShowOut:
"""今天这台设备已 engage(领或拒)过 → should_show=false。客户端据此决定弹不弹
(纯后台判据,客户端不再做前台 SP 缓存判断)。"""
return CouponPromptShouldShowOut(
should_show=not coupon_repo.has_engaged_today(db, device_id)
)
@router.post("/prompt/reset", summary="重置今日领券引导窗 engagement(开发测频控用)")
def coupon_prompt_reset(payload: CouponPromptDismissIn, db: DbSession) -> dict[str, bool]:
"""删这台设备今天的 engagement → has_engaged_today 变 false,今天又能弹。
开发设置「重置今日领券弹窗状态」按钮调。MVP 不鉴权,按 device_id。"""
coupon_repo.reset_today_engagement(db, payload.device_id)
return {"ok": True}
-63
View File
@@ -1,63 +0,0 @@
"""帮助与反馈 endpoint。
路由前缀 `/api/v1/feedback`,需 Bearer 鉴权(反馈绑到登录用户,便于回访)。
POST / 提交反馈(multipart:content / contact 必填,images 可选 ≤4 张)
截图复用 [app.core.media] 落盘到 /media/feedback/。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from app.api.deps import CurrentUser, DbSession
from app.core import media
from app.repositories import feedback as feedback_repo
from app.schemas.feedback import FeedbackOut
logger = logging.getLogger("shagua.feedback")
router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
_MAX_IMAGES = 4
_CONTENT_MAX = 2000
_CONTACT_MAX = 128
@router.post("", response_model=FeedbackOut, summary="提交反馈")
async def submit_feedback(
user: CurrentUser,
db: DbSession,
content: str = Form(...),
contact: str = Form(...),
images: list[UploadFile] = File(default=[]),
) -> FeedbackOut:
content = content.strip()
contact = contact.strip()
if not content:
raise HTTPException(status_code=400, detail="反馈内容不能为空")
if len(content) > _CONTENT_MAX:
raise HTTPException(status_code=400, detail="反馈内容过长")
if not contact:
raise HTTPException(status_code=400, detail="联系方式不能为空")
if len(contact) > _CONTACT_MAX:
raise HTTPException(status_code=400, detail="联系方式过长")
files = [f for f in (images or []) if f is not None and f.filename]
if len(files) > _MAX_IMAGES:
raise HTTPException(status_code=400, detail=f"最多上传 {_MAX_IMAGES} 张图片")
urls: list[str] = []
for f in files:
data = await f.read()
try:
urls.append(media.save_feedback_image(user.id, data))
except media.MediaError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
fb = feedback_repo.create_feedback(
db, user_id=user.id, content=content, contact=contact, images=urls,
)
logger.info("feedback id=%d user_id=%d images=%d", fb.id, user.id, len(urls))
return FeedbackOut.model_validate(fb)
-217
View File
@@ -1,217 +0,0 @@
"""好友邀请 endpoint。
路由前缀 /api/v1/invite,需 Bearer 鉴权(用户级数据);唯一例外是 /landing-track
(落地页 dl.html 浏览器访问、无 token,详见任务 3 [[invite-three-tasks]]):
GET /me 我的邀请码 + 分享链接 + 已邀人数/已得金币(邀请页展示)
POST /bind 把当前登录用户(被邀请人)绑定到某邀请码;支持三种归因:
① clipboard:首启读剪贴板拿邀请码 → 上报码
② manual:用户在邀请页输入邀请码 → 上报码
③ fingerprint:剪贴板没拿到时上报指纹,后端反查
POST /landing-track 落地页 dl.html 访问时上报指纹(剪贴板兜底的服务端一端,无需鉴权)
绑定的真正逻辑(邀请码生成/反查、幂等、自邀屏蔽、发金币、指纹反查)在
repositories/invite.py。
"""
from __future__ import annotations
import logging
import re
from fastapi import APIRouter, Request
from app.api.deps import CurrentUser, DbSession
from app.core import rewards
from app.core.config import settings
from app.repositories import invite as invite_repo
from app.schemas.invite import (
BindInviteIn,
BindInviteOut,
InviteeItem,
InviteeListOut,
InviteInfoOut,
LandingTrackIn,
LandingTrackOut,
)
logger = logging.getLogger("shagua.invite")
router = APIRouter(prefix="/api/v1/invite", tags=["invite"])
_BIND_MESSAGES = {
"success": "邀请绑定成功,金币已到账",
"already_bound": "你已绑定过邀请人",
"invalid_code": "邀请码无效",
"self_invite": "不能填写自己的邀请码",
"not_eligible": "邀请仅对新注册用户生效",
"fp_not_found": "未匹配到邀请关系",
}
def _client_ip(request: Request) -> str:
"""从 HTTP 头拿真实 IP。nginx 反代时走 X-Forwarded-For;裸跑 uvicorn 走 request.client。"""
xff = request.headers.get("x-forwarded-for", "")
if xff:
# X-Forwarded-For 可能是 "client, proxy1, proxy2" 链;取第一个 = 真实客户端
return xff.split(",")[0].strip()
return request.client.host if request.client else ""
# Android UA 形如 '... ; <Model> Build/<id>) AppleWebKit/...';抓 ';' 后到 ' Build/' 前
# 的 token = Build.MODEL(跨端跟客户端 android.os.Build.MODEL 对齐)。user-agents 库
# 把 Android 设备归为 'Smartphone' 通用名,抓不到真实型号,只能正则。
_ANDROID_BUILD_MODEL_RE = re.compile(r";\s*([^;]+?)\s+Build/", re.IGNORECASE)
def _parse_device_model(ua: str) -> str:
"""解析浏览器 UA 拿手机型号(如 '24115RA8EC''PJF110')。
Android:正则抓 UA 里 ';...Build/' 前的 token(== Build.MODEL),跨端可严格匹配。
iOS / 解析不到:退到 user-agents 库的通用解析,失败/UA 空 → 返空字符串。
"""
if not ua:
return ""
m = _ANDROID_BUILD_MODEL_RE.search(ua)
if m:
return m.group(1).strip()
try:
from user_agents import parse
return (parse(ua).device.model or "").strip()
except Exception: # noqa: BLE001
return ""
@router.get("/me", response_model=InviteInfoOut, summary="我的邀请码 + 分享链接 + 战绩")
def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
code = invite_repo.ensure_code(db, user)
invited, coins = invite_repo.get_stats(db, user.id)
sep = "&" if "?" in settings.INVITE_LANDING_URL else "?"
share_url = f"{settings.INVITE_LANDING_URL}{sep}ref={code}"
return InviteInfoOut(
invite_code=code,
share_url=share_url,
invited_count=invited,
coins_earned=coins,
)
@router.get("/invitees", response_model=InviteeListOut, summary="我邀请的人列表(分页)")
def my_invitees(
user: CurrentUser, db: DbSession, limit: int = 20, offset: int = 0
) -> InviteeListOut:
"""邀请页小窗(取前几条) + 完整列表页(分页加载)共用。
名字/头像的降级兜底在 repositories/invite.get_invitees 算好,这里只组装响应。
limit 夹到 [1,50] 防滥用;offset 不小于 0。
"""
limit = max(1, min(limit, 50))
offset = max(0, offset)
items, total, has_more = invite_repo.get_invitees(
db, user.id, limit=limit, offset=offset,
)
return InviteeListOut(
items=[InviteeItem(**it) for it in items],
total=total,
has_more=has_more,
)
@router.post(
"/landing-track",
response_model=LandingTrackOut,
summary="落地页指纹采集(无需鉴权)",
)
def landing_track(
req: LandingTrackIn, request: Request, db: DbSession
) -> LandingTrackOut:
"""B 浏览器打开 dl.html?ref=xxx 时上报指纹。
服务端从 HTTP 头拿 IP/UA、解析 UA 拿 device_model,跟 req.screen 一起入库。
无需鉴权(浏览器没 token)。invalid_code / no_ip 走 silent 返回(不抛 5xx 影响下载流程)。
"""
inviter = invite_repo.resolve_inviter(db, req.ref)
if inviter is None or inviter.status != "active":
return LandingTrackOut(status="invalid_code")
ip = _client_ip(request)
if not ip:
return LandingTrackOut(status="no_ip")
ua_str = request.headers.get("user-agent", "")
device_model = _parse_device_model(ua_str)
invite_repo.record_fingerprint(
db,
inviter_user_id=inviter.id,
ip=ip,
screen=req.screen,
device_model=device_model,
user_agent=ua_str,
)
logger.info(
"invite landing-track inviter=%d ip=%s screen=%s model=%s",
inviter.id, ip, req.screen, device_model,
)
return LandingTrackOut(status="ok")
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,双方发金币)")
def bind_invite(
req: BindInviteIn, user: CurrentUser, db: DbSession, request: Request
) -> BindInviteOut:
code = (req.invite_code or "").strip()
# 路径 1:有邀请码 → 走原路径(clipboard / manual)
if code:
result = invite_repo.bind(
db, invitee=user, invite_code=code, channel=req.channel,
)
logger.info(
"invite bind invitee=%d code=%s channel=%s -> %s",
user.id, code, req.channel, result.status,
)
return BindInviteOut(
status=result.status,
coins_awarded=result.invitee_coin,
message=_BIND_MESSAGES.get(result.status, result.status),
)
# 路径 2:无邀请码 + 有指纹 → 指纹兜底反查
if req.fingerprint is not None:
ip = _client_ip(request)
inviter = invite_repo.resolve_inviter_by_fingerprint(
db,
ip=ip,
screen=req.fingerprint.screen,
device_model=req.fingerprint.device_model,
window_days=rewards.INVITE_FP_WINDOW_DAYS,
)
if inviter is None:
logger.info(
"invite bind invitee=%d fingerprint not_found ip=%s screen=%s model=%s",
user.id, ip, req.fingerprint.screen, req.fingerprint.device_model,
)
return BindInviteOut(
status="fp_not_found",
coins_awarded=0,
message=_BIND_MESSAGES["fp_not_found"],
)
# 用反查到的 inviter.invite_code 走原 bind 流程(走原幂等/自邀/新人闸/发币逻辑)
result = invite_repo.bind(
db, invitee=user, invite_code=inviter.invite_code, channel="fingerprint",
)
logger.info(
"invite bind invitee=%d fingerprint -> inviter=%d code=%s -> %s",
user.id, inviter.id, inviter.invite_code, result.status,
)
return BindInviteOut(
status=result.status,
coins_awarded=result.invitee_coin,
message=_BIND_MESSAGES.get(result.status, result.status),
)
# 路径 3:啥都没传 → 无效请求
return BindInviteOut(
status="invalid_code",
coins_awarded=0,
message=_BIND_MESSAGES["invalid_code"],
)
+5 -83
View File
@@ -7,14 +7,9 @@ from __future__ import annotations
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from fastapi import APIRouter, HTTPException
from app.core.config import settings
from app.db.session import get_db
from app.integrations.meituan import MeituanCpsError, get_referral_link, query_coupon
from app.models.meituan_coupon import MeituanCoupon
from app.core.meituan import MeituanCpsError, get_referral_link, query_coupon
from app.schemas.meituan import (
CouponCard,
CouponListRequest,
@@ -23,7 +18,6 @@ from app.schemas.meituan import (
FeedResponse,
ReferralLinkRequest,
ReferralLinkResponse,
TopSalesRequest,
)
logger = logging.getLogger("shagua.meituan")
@@ -33,8 +27,6 @@ router = APIRouter(prefix="/api/v1/meituan", tags=["meituan-cps"])
@router.post("/coupons", response_model=CouponListResponse, summary="券列表(为您推荐)")
def list_coupons(req: CouponListRequest) -> CouponListResponse:
if not settings.mt_cps_configured:
return CouponListResponse(items=[], has_next=False, search_id=None)
logger.info("[coupons] lon=%.6f lat=%.6f topic=%s", req.longitude, req.latitude, req.list_topic_id)
try:
raw = query_coupon(
@@ -84,21 +76,11 @@ def _interleave(waimai: list[dict], daodian: list[dict]) -> list[CouponCard]:
return items
def _commission_pct(card: CouponCard) -> float:
"""'1.4%' → 1.4;解析失败按 0(会被智能推荐过滤掉)。"""
try:
return float(card.commission_rate.rstrip("%"))
except (ValueError, AttributeError):
return 0.0
@router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉);tab=rec智能推荐/distance距离最近")
@router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉, 无限流)")
def feed(req: FeedRequest) -> FeedResponse:
if not settings.mt_cps_configured:
return FeedResponse(items=[], has_next=False, page=req.page)
page_idx = req.page - 1
lon, lat = req.longitude, req.latitude
tab = (req.tab or "").strip()
logger.info("[feed] tab=%s page=%s lon=%.6f lat=%.6f", tab or "(default)", req.page, lon, lat)
logger.info("[feed] page=%s lon=%.6f lat=%.6f", req.page, lon, lat)
def _fetch_topic(platform: int, biz_line: int | None, topic: int) -> list[dict]:
try:
@@ -110,28 +92,6 @@ def feed(req: FeedRequest) -> FeedResponse:
except MeituanCpsError:
return []
# 距离最近:拉齐全部榜单轮次,合并去重,后端在【完整池子】上全局按距离由近及远排,一次性返回。
# (距离排序必须在完整池上做、不能逐页排——这正是之前放前端不合理的根因。)
if tab == "distance":
with ThreadPoolExecutor(max_workers=len(_TOPIC_ROUNDS) * 2) as pool:
futs = []
for wm_topic, dd_topic in _TOPIC_ROUNDS:
futs.append(pool.submit(_fetch_topic, 1, None, wm_topic))
futs.append(pool.submit(_fetch_topic, 2, 1, dd_topic))
raws = [f.result() for f in futs]
seen: set[str] = set()
cards: list[CouponCard] = []
for raw_list in raws:
for it in raw_list:
card = CouponCard.from_raw(it)
if card.product_view_sign and card.product_view_sign not in seen:
seen.add(card.product_view_sign)
cards.append(card)
cards.sort(key=lambda c: c.distance_meters if c.distance_meters is not None else float("inf"))
return FeedResponse(items=cards, has_next=False, page=1)
# 智能推荐(rec,默认):沿用逐轮分页的混合 feed,后端过滤掉佣金率 < 3%。
page_idx = req.page - 1
if page_idx >= len(_TOPIC_ROUNDS):
return FeedResponse(items=[], has_next=False, page=req.page)
@@ -142,16 +102,12 @@ def feed(req: FeedRequest) -> FeedResponse:
waimai, daodian = f_wm.result(), f_dd.result()
items = _interleave(waimai, daodian)
if tab == "rec":
items = [c for c in items if _commission_pct(c) >= 3.0]
has_next = page_idx + 1 < len(_TOPIC_ROUNDS)
return FeedResponse(items=items, has_next=has_next, page=req.page)
@router.post("/referral-link", response_model=ReferralLinkResponse, summary="换取推广链接(点抢时调)")
def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse:
if not settings.mt_cps_configured:
return ReferralLinkResponse(link="", link_map={})
try:
raw = get_referral_link(
product_view_sign=req.product_view_sign,
@@ -167,37 +123,3 @@ def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse:
link_map = raw.get("referralLinkMap") or {}
link = raw.get("data") or link_map.get("1") or link_map.get("3") or next(iter(link_map.values()), "")
return ReferralLinkResponse(link=link, link_map=link_map)
@router.post("/top-sales", response_model=CouponListResponse,
summary="销量最高(从离线库 meituan_coupon 按销量降序 + 跨源去重,不实时打美团)")
def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponListResponse:
# 只取有销量档的(美团很多券没销量,排不了);按销量降序、同销量再按佣金降序
stmt = select(MeituanCoupon).where(MeituanCoupon.sale_volume_num.isnot(None))
if req.platform is not None:
stmt = stmt.where(MeituanCoupon.platform == req.platform)
stmt = stmt.order_by(
MeituanCoupon.sale_volume_num.desc(),
MeituanCoupon.commission_percent.desc(),
)
rows = db.execute(stmt).scalars().all()
# 跨源去重(dedup_key = 品牌|名|价):按销量降序遍历,每个 dedup_key 只留第一条(=销量最高那条)。
# 用 raw(整条原始返回)重建 CouponCard,字段与实时接口完全一致,前端无需改渲染。
seen: set[str] = set()
cards: list[CouponCard] = []
for row in rows:
if row.dedup_key in seen:
continue
seen.add(row.dedup_key)
try:
card = CouponCard.from_raw(row.raw or {})
except Exception: # noqa: BLE001
continue
if card.product_view_sign:
cards.append(card)
start = (req.page - 1) * req.page_size
page_items = cards[start:start + req.page_size]
has_next = start + req.page_size < len(cards)
return CouponListResponse(items=page_items, has_next=has_next, search_id=None)
-25
View File
@@ -1,25 +0,0 @@
from fastapi import APIRouter
from app.api.deps import CurrentUser, DbSession
from app.repositories import savings as crud_savings
from app.schemas.order import OrderReportOut, OrderReportRequest
router = APIRouter(prefix="/api/v1/order", tags=["order"])
@router.post(
"/report",
response_model=OrderReportOut,
summary="上报归因订单(比价后5分钟内点链接 + 支付金额与比价价相差≤1元)",
)
def report_order(req: OrderReportRequest, user: CurrentUser, db: DbSession) -> OrderReportOut:
# 记账唯一真相表是 savings_record(source='compare')。
rec, duplicated = crud_savings.create_from_report(db, user.id, req)
return OrderReportOut(
id=rec.id,
platform=rec.platform or req.platform,
pay_channel=rec.pay_channel or req.pay_channel,
compared_price_cents=rec.compared_price_cents or 0,
paid_amount_cents=rec.order_amount_cents,
duplicated=duplicated,
)
-37
View File
@@ -1,37 +0,0 @@
"""首页平台级展示数据 endpoint(全平台门面数字,**不鉴权**——登录前首页也要展示)。
路由前缀 `/api/v1/platform`:
GET /stats 首页三统计(帮助用户 / 完成比价 / 累计节省),按运营后台配的模式算。
展示模式(real/manual/random,每指标独立)与计算逻辑见 app/repositories/ops_stat.py。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Query
from app.api.deps import DbSession
from app.repositories import ops_marquee as marquee_crud
from app.repositories import ops_stat as crud
from app.schemas.platform import PlatformStatsOut, SavingsFeedItem, SavingsFeedOut
logger = logging.getLogger("shagua.platform")
router = APIRouter(prefix="/api/v1/platform", tags=["platform"])
@router.get("/stats", response_model=PlatformStatsOut, summary="首页三统计(全平台门面数字)")
def stats(db: DbSession) -> PlatformStatsOut:
v = crud.get_display_values(db)
return PlatformStatsOut(
help_users=v["help_users"],
total_compares=v["total_compares"],
total_saved_cents=v["total_saved"],
)
@router.get("/savings-feed", response_model=SavingsFeedOut, summary="首页轮播 feed(真实+种子混播)")
def savings_feed(db: DbSession, limit: int = Query(8, ge=1, le=30)) -> SavingsFeedOut:
items = marquee_crud.get_feed(db, limit=limit)
return SavingsFeedOut(items=[SavingsFeedItem(**it) for it in items])
-145
View File
@@ -1,145 +0,0 @@
"""上报更低价 endpoint。
路由前缀 `/api/v1/report`,需 Bearer 鉴权(上报绑登录用户)。
POST / 提交上报(multipart:comparison_record_id / reported_platform_id /
reported_price(元) + images 1~4 张)
GET /records 上报记录列表(?status= pending/approved/rejected 可选筛选)
要点:
- 截图复用 [app.core.media] 落盘到 /media/price_report/。
- 原最低价由 comparison_record_id 反查比价记录 best_*(不信任客户端传的快照)。
- 校验(D):上报价必须 < 原最低价,否则 400。
- 提交一律 status=pending;通过发奖励是人工审核后台动作,不在此。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from app.api.deps import CurrentUser, DbSession
from app.core import media
from app.models.comparison import ComparisonRecord
from app.repositories import report as report_repo
from app.schemas.report import (
ReportRecordCounts,
ReportRecordOut,
ReportRecordsOut,
ReportSubmitOut,
)
logger = logging.getLogger("shagua.report")
router = APIRouter(prefix="/api/v1/report", tags=["report"])
_MAX_IMAGES = 4
_VALID_STATUS = ("pending", "approved", "rejected")
# 上报平台(canonical id → 展示名),与原型 fb-platform-chip data-pf 一致
_PLATFORM_NAMES = {
"meituan-waimai": "美团外卖",
"jd-waimai": "京东外卖",
"taobao-shanguang": "淘宝闪购",
}
def _dish_summary(items: list | None) -> str | None:
"""把比价记录 items[{name,qty,...}] 拼成菜品摘要文案。"""
if not items:
return None
names = [str(it.get("name", "")).strip() for it in items if isinstance(it, dict)]
names = [n for n in names if n]
return "".join(names) if names else None
@router.post("", response_model=ReportSubmitOut, summary="提交上报更低价")
async def submit_report(
user: CurrentUser,
db: DbSession,
comparison_record_id: int = Form(...),
reported_platform_id: str = Form(...),
reported_price: str = Form(..., description="用户填的更低价(元)"),
images: list[UploadFile] = File(default=[]),
) -> ReportSubmitOut:
# 平台
reported_platform_id = reported_platform_id.strip()
platform_name = _PLATFORM_NAMES.get(reported_platform_id)
if platform_name is None:
raise HTTPException(status_code=400, detail="不支持的上报平台")
# 价格(元 → 分)
try:
price_yuan = float(reported_price)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="价格格式不正确") from None
if price_yuan <= 0:
raise HTTPException(status_code=400, detail="价格必须大于 0")
reported_price_cents = round(price_yuan * 100)
# 反查比价记录(必须属于本人)→ 取原最低价快照
rec = db.get(ComparisonRecord, comparison_record_id)
if rec is None or rec.user_id != user.id:
raise HTTPException(status_code=404, detail="比价记录不存在")
original_price_cents = rec.best_price_cents
# D:上报价必须低于原最低价
if original_price_cents is not None and reported_price_cents >= original_price_cents:
raise HTTPException(
status_code=400,
detail=f"上报价需低于原最低价 ¥{original_price_cents / 100:.2f}",
)
# 截图(至少 1 张,最多 4 张)
files = [f for f in (images or []) if f is not None and f.filename]
if not files:
raise HTTPException(status_code=400, detail="请至少上传一张截图证明")
if len(files) > _MAX_IMAGES:
raise HTTPException(status_code=400, detail=f"最多上传 {_MAX_IMAGES} 张图片")
urls: list[str] = []
for f in files:
data = await f.read()
try:
urls.append(media.save_report_image(user.id, data))
except media.MediaError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
rep = report_repo.create_report(
db,
user_id=user.id,
comparison_record_id=comparison_record_id,
store_name=rec.store_name,
dish_summary=_dish_summary(rec.items),
original_platform_id=rec.best_platform_id,
original_platform_name=rec.best_platform_name,
original_price_cents=original_price_cents,
reported_platform_id=reported_platform_id,
reported_platform_name=platform_name,
reported_price_cents=reported_price_cents,
images=urls,
)
logger.info("price_report id=%d user_id=%d images=%d", rep.id, user.id, len(urls))
return ReportSubmitOut.model_validate(rep)
@router.get("/records", response_model=ReportRecordsOut, summary="上报记录列表")
def list_report_records(
user: CurrentUser,
db: DbSession,
status: str | None = None,
) -> ReportRecordsOut:
status = (status or "").strip() or None
if status and status not in _VALID_STATUS:
raise HTTPException(status_code=400, detail="无效的状态筛选")
rows = report_repo.list_reports(db, user.id, status)
# counts 始终基于全量(不受 status 筛选影响),供前端 chip 计数
all_rows = rows if status is None else report_repo.list_reports(db, user.id, None)
counts = ReportRecordCounts(
all=len(all_rows),
pending=sum(1 for r in all_rows if r.status == "pending"),
approved=sum(1 for r in all_rows if r.status == "approved"),
rejected=sum(1 for r in all_rows if r.status == "rejected"),
)
return ReportRecordsOut(
records=[ReportRecordOut.model_validate(r) for r in rows],
counts=counts,
)
-60
View File
@@ -1,60 +0,0 @@
"""省钱 endpoint(profile 的「累计帮你省了」「省钱战绩」「省钱明细」)。
路由前缀 `/api/v1/savings`:
GET /summary 累计省下 + 订单数 + 平均每单省
GET /battle 本周已省 + 超过百分比 + 连续省钱天数
GET /records 省钱明细(游标分页)
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Query
from app.api.deps import CurrentUser, DbSession
from app.repositories import savings as crud_savings
from app.schemas.welfare import (
SavingsBattleOut,
SavingsRecordOut,
SavingsRecordPage,
SavingsSummaryOut,
)
logger = logging.getLogger("shagua.savings")
router = APIRouter(prefix="/api/v1/savings", tags=["savings"])
@router.get("/summary", response_model=SavingsSummaryOut, summary="累计帮你省了")
def summary(user: CurrentUser, db: DbSession) -> SavingsSummaryOut:
s = crud_savings.get_summary(db, user.id)
return SavingsSummaryOut(
total_saved_cents=s.total_saved_cents,
order_count=s.order_count,
avg_saved_cents=s.avg_saved_cents,
)
@router.get("/battle", response_model=SavingsBattleOut, summary="省钱战绩")
def battle(user: CurrentUser, db: DbSession) -> SavingsBattleOut:
b = crud_savings.get_battle(db, user.id)
return SavingsBattleOut(
week_saved_cents=b.week_saved_cents,
beat_percent=b.beat_percent,
streak_days=b.streak_days,
compare_count=b.compare_count,
)
@router.get("/records", response_model=SavingsRecordPage, summary="省钱明细(游标分页)")
def records(
user: CurrentUser,
db: DbSession,
limit: int = Query(20, ge=1, le=100),
cursor: int | None = Query(None, description="上一页末条 id"),
) -> SavingsRecordPage:
items, next_cursor = crud_savings.list_records(db, user.id, limit=limit, cursor=cursor)
return SavingsRecordPage(
items=[SavingsRecordOut.model_validate(it) for it in items],
next_cursor=next_cursor,
)

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