Compare commits

...

4 Commits

Author SHA1 Message Date
marco d46a5cd80f chore: alembic merge revision + PG 迁移指南文档
PostgreSQL 迁移 (#6) 之后两个分支引入了独立 heads (ef96beb47b1e +
d1e2f3a4b5c6), 必须合并成单一 head 才能 alembic upgrade head 顺利跑通。
- alembic/versions/f01db5d77dac_merge_pg_jsonb_and_feedback_heads.py:
  空 op 的 merge revision, 把两个 heads 收敛为 f01db5d77dac
- docs/postgres-migration.md: SQLite → PostgreSQL 迁移指南
  (背景/动机/操作步骤/无真实用户数据假设, 上线前补 pgloader 演练等)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:08:51 +08:00
marco e69e244de7 Merge pull request 'feat(ad-reward): reward-status 加本轮 3 次冷却字段 (round_count + cooldown_until)' (#5) from feat/welfare-video-round-cooldown into main
Reviewed-on: #5
2026-05-29 15:45:51 +08:00
marco 2ebde935f9 Merge pull request 'feat: SQLite → PostgreSQL 迁移代码改动' (#6) from feat/postgres-migration-and-bootstrap-script into main
Reviewed-on: #6
2026-05-29 15:44:40 +08:00
OuYingJun1024 56f4548654 feat(ad-reward): reward-status 加本轮 3 次冷却字段 (round_count + cooldown_until)
让客户端 4 态任务行 CTA (Normal/Loading/Capped/CoolingDown) 与 ExitReward
弹窗 limit note (本轮看完/今日看完 二选一) 都由后端权威派生, 跨设备 / 重装 /
杀进程都一致, 客户端不再依赖 in-memory 本地存储。

- core/rewards.py: 加 VIDEO_ROUND_REQUIRED_COUNT=3 / VIDEO_ROUND_COOLDOWN_SECONDS=600
- repositories/ad_reward.py: today_status 从 3-tuple 扩成 5-tuple,
  从已有 created_at 字段按 ORDER BY DESC + OFFSET round_count 派生最近一个
  已完成轮末尾时间; 零新字段零新表
- schemas/ad.py: AdRewardStatusOut + TestGrantOut 加 round_count + cooldown_until
- api/v1/ad.py: reward_status / test_grant 接 5-tuple
- tests: 加 4 个覆盖 (初始 / 未达一轮 / 刚达一轮进冷却 / 冷却已过)
- docs/api/ad-reward-status.md: 补字段说明 + 客户端 4 态 CTA 推导规则 +
  "冷却仅 UX, 发奖不受影响" 注解

冷却是 UX 约束: pangle-callback 发奖只看 daily_limit, 冷却期间穿山甲
回调照常发奖, 不另设拒发分支。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 13:27:56 +08:00
8 changed files with 484 additions and 14 deletions
@@ -0,0 +1,26 @@
"""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
+6 -2
View File
@@ -84,12 +84,14 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
@router.get("/reward-status", response_model=AdRewardStatusOut, summary="今日看广告发奖进度") @router.get("/reward-status", response_model=AdRewardStatusOut, summary="今日看广告发奖进度")
def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut: def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
used, limit, coin_per = crud_ad.today_status(db, user.id) used, limit, coin_per, round_count, cooldown_until = crud_ad.today_status(db, user.id)
return AdRewardStatusOut( return AdRewardStatusOut(
used_today=used, used_today=used,
daily_limit=limit, daily_limit=limit,
remaining=max(0, limit - used), remaining=max(0, limit - used),
coin_per_ad=coin_per, coin_per_ad=coin_per,
round_count=round_count,
cooldown_until=cooldown_until,
) )
@@ -118,7 +120,7 @@ def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut:
except crud_ad.UnknownUserError as e: except crud_ad.UnknownUserError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
used, limit, coin_per = crud_ad.today_status(db, user.id) used, limit, coin_per, round_count, cooldown_until = crud_ad.today_status(db, user.id)
logger.info("ad TEST grant user_id=%d status=%s coin=%d", user.id, rec.status, rec.coin) logger.info("ad TEST grant user_id=%d status=%s coin=%d", user.id, rec.status, rec.coin)
return TestGrantOut( return TestGrantOut(
granted=(rec.status == "granted"), granted=(rec.status == "granted"),
@@ -128,4 +130,6 @@ def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut:
daily_limit=limit, daily_limit=limit,
remaining=max(0, limit - used), remaining=max(0, limit - used),
coin_per_ad=coin_per, coin_per_ad=coin_per,
round_count=round_count,
cooldown_until=cooldown_until,
) )
+11 -5
View File
@@ -59,16 +59,22 @@ TASK_REWARDS: dict[str, int] = {
# ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)===== # ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)=====
# 看完一个激励视频发的金币(≈¥0.01,汇率 10000 金币=1 元)。 # 看完一个激励视频发的金币(100 金币 ≈¥0.01,汇率 10000 金币=1 元)。
# 作用:① 回调缺/坏 reward_amount 时的回退值;② 客户端进度接口展示的"单次预告金币" # 作用:① 回调缺/坏 reward_amount 时的回退值;② 客户端进度接口展示的"单次预告金币";
# ③ test-grant 本地联调的发奖额。
# 真实发放以穿山甲回调带回的 reward_amount 为准(见 resolve_ad_reward_coin),后台应把 # 真实发放以穿山甲回调带回的 reward_amount 为准(见 resolve_ad_reward_coin),后台应把
# 代码位"奖励数量"配成与本值一致(100),保证"广告内展示 / 进度预告 / 实际到账"三者一致。 # 代码位"奖励数量"配成与本值一致(=100),保证"广告内展示 / 进度预告 / 实际到账"三者一致。
AD_REWARD_COIN: int = 100 AD_REWARD_COIN: int = 100
# 单次发奖金币上限:夹紧穿山甲回调里异常的 reward_amount(如后台多打一个 0),防刷爆余额。 # 单次发奖金币上限:夹紧穿山甲回调里异常的 reward_amount(如后台多打一个 0),防刷爆余额。
MAX_AD_REWARD_COIN: int = 1000 MAX_AD_REWARD_COIN: int = 1000
# 每用户每日发奖次数上限,防刷 + 控成本。 # 每用户每日发奖次数上限,防刷 + 控成本。
# ⚠️ 占位值:正式上线前按产品策略 + 广告 eCPM 实测收益重新核定,改这里即可。 # ⚠️ 测试阶段值=20:正式上线前按产品策略 + 广告 eCPM 实测收益重新核定,改这里即可。
DAILY_AD_REWARD_LIMIT: int = 10 DAILY_AD_REWARD_LIMIT: int = 20
# 一轮看 N 次激励视频,看完一轮强制 10 分钟冷却(reward-status 派生 cooldown_until 给客户端,
# 客户端任务行 CTA 在冷却期间显示"MM:SS"且不可点)。冷却是 UX 约束,后端发奖只看 daily_limit,
# 冷却期间穿山甲回调仍照常发奖,不另设拒发分支。
VIDEO_ROUND_REQUIRED_COUNT: int = 3
VIDEO_ROUND_COOLDOWN_SECONDS: int = 600
def resolve_ad_reward_coin(reward_amount: str | int | None) -> int: def resolve_ad_reward_coin(reward_amount: str | int | None) -> int:
+55 -5
View File
@@ -10,11 +10,19 @@
""" """
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.rewards import AD_REWARD_COIN, DAILY_AD_REWARD_LIMIT, cn_today from app.core.rewards import (
AD_REWARD_COIN,
DAILY_AD_REWARD_LIMIT,
VIDEO_ROUND_COOLDOWN_SECONDS,
VIDEO_ROUND_REQUIRED_COUNT,
cn_today,
)
from app.repositories import wallet as crud_wallet from app.repositories import wallet as crud_wallet
from app.models.ad_reward import AdRewardRecord from app.models.ad_reward import AdRewardRecord
from app.models.user import User from app.models.user import User
@@ -102,7 +110,49 @@ def _commit_record(db: Session, rec: AdRewardRecord, trans_id: str) -> AdRewardR
return rec return rec
def today_status(db: Session, user_id: int) -> tuple[int, int, int]: def _last_completed_round_end_at(
"""客户端查"今日看广告发奖"进度:返回 (今日已发次数, 每日上限, 单次金币)。""" db: Session, user_id: int, reward_date: str, round_count: int
used = _granted_today(db, user_id, cn_today().isoformat()) ) -> datetime | None:
return used, DAILY_AD_REWARD_LIMIT, AD_REWARD_COIN """当日 granted 记录中**最近一个已完成轮**末尾那次的 created_at。
思路:把当日 granted 按时间倒序排,跳过当前未完成轮的 round_count 条,下一条
"上一轮最后一次"。round_count==0 且 used>=N 时跳 0 条直接取最近一条。
used<N(还没完成第一轮)调用方应直接判 None,不进这里。
"""
return db.execute(
select(AdRewardRecord.created_at)
.where(
AdRewardRecord.user_id == user_id,
AdRewardRecord.reward_date == reward_date,
AdRewardRecord.status == "granted",
)
.order_by(AdRewardRecord.created_at.desc())
.offset(round_count)
.limit(1)
).scalar_one_or_none()
def today_status(
db: Session, user_id: int
) -> tuple[int, int, int, int, datetime | None]:
"""客户端查"今日看广告发奖"进度。
返回 (今日已发次数, 每日上限, 单次金币, 本轮已看次数, 本轮冷却结束时间(UTC))。
- round_count = used % VIDEO_ROUND_REQUIRED_COUNT,展示用(0..N-1)
- cooldown_until 计算:取最近一个已完成轮末尾的 created_at + 10 min;若仍 > now 则返回,
否则返回 None。冷却 = UX 约束(客户端 CTA 倒计时不可点),后端发奖逻辑不受影响。
"""
today = cn_today().isoformat()
used = _granted_today(db, user_id, today)
round_count = used % VIDEO_ROUND_REQUIRED_COUNT
cooldown_until: datetime | None = None
if used >= VIDEO_ROUND_REQUIRED_COUNT:
last_end = _last_completed_round_end_at(db, user_id, today, round_count)
if last_end is not None:
# SQLAlchemy 在 SQLite 上拿到的 created_at 可能是 naive,统一按 UTC 解读再比较
if last_end.tzinfo is None:
last_end = last_end.replace(tzinfo=timezone.utc)
cd_end = last_end + timedelta(seconds=VIDEO_ROUND_COOLDOWN_SECONDS)
if cd_end > datetime.now(timezone.utc):
cooldown_until = cd_end
return used, DAILY_AD_REWARD_LIMIT, AD_REWARD_COIN, round_count, cooldown_until
+14
View File
@@ -4,6 +4,8 @@
""" """
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -27,6 +29,14 @@ class AdRewardStatusOut(BaseModel):
daily_limit: int = Field(..., description="每日发奖次数上限") daily_limit: int = Field(..., description="每日发奖次数上限")
remaining: int = Field(..., description="今日剩余可领次数") remaining: int = Field(..., description="今日剩余可领次数")
coin_per_ad: int = Field(..., description="看完一个激励视频发的金币") coin_per_ad: int = Field(..., description="看完一个激励视频发的金币")
round_count: int = Field(
..., description="本轮(3 次一组)已看次数,0..N-1。客户端可由它判断'刚刚看完一轮'"
)
cooldown_until: datetime | None = Field(
None,
description="本轮看完后 10 分钟冷却结束时间(UTC ISO),null 表示不在冷却。"
"冷却是 UX 约束,后端发奖不受影响,客户端 CTA 据此显示 MM:SS 倒计时且不可点",
)
class TestGrantOut(BaseModel): class TestGrantOut(BaseModel):
@@ -39,3 +49,7 @@ class TestGrantOut(BaseModel):
daily_limit: int = Field(..., description="每日发奖次数上限") daily_limit: int = Field(..., description="每日发奖次数上限")
remaining: int = Field(..., description="今日剩余可领次数") remaining: int = Field(..., description="今日剩余可领次数")
coin_per_ad: int = Field(..., description="看完一个激励视频发的金币") coin_per_ad: int = Field(..., description="看完一个激励视频发的金币")
round_count: int = Field(..., description="本轮已看次数,详见 AdRewardStatusOut")
cooldown_until: datetime | None = Field(
None, description="本轮冷却结束时间(UTC),详见 AdRewardStatusOut"
)
+10 -1
View File
@@ -14,6 +14,15 @@
| `daily_limit` | int | 每日发奖次数上限 | | `daily_limit` | int | 每日发奖次数上限 |
| `remaining` | int | 今日剩余可领次数 | | `remaining` | int | 今日剩余可领次数 |
| `coin_per_ad` | int | 看完一个激励视频发的金币 | | `coin_per_ad` | int | 看完一个激励视频发的金币 |
| `round_count` | int | 本轮(3 次一组)已看次数,0..N-1。客户端可据「拿到 round_count==0 && cooldown_until!=null」判定刚刚看完一轮 |
| `cooldown_until` | datetime\|null | 本轮 10 分钟冷却结束时间(UTC ISO);null 表示不在冷却 |
## 说明 ## 说明
福利页「看视频赚金币」用:展示「今日还能看 N 次」「看一次得 M 金币」。真正发奖走 [ad-pangle-callback](./ad-pangle-callback.md)(穿山甲 S2S)。 福利页「看视频赚金币」用:
- 展示「今日还能看 N 次」「看一次得 M 金币」(`remaining`/`coin_per_ad`)
- 任务行 CTA 4 态推导:`adLoading` → Loading;`remaining==0` → Capped("明天再来");`cooldown_until` 在未来 → CoolingDown(显示 MM:SS 倒计时,不可点);否则 Normal("去赚取")
- 弹窗 limit note:`used_today >= daily_limit` 显示「今日视频已到限额,明天再来」;`round_count==0 && cooldown_until!=null` 显示「本轮视频已看完,10分钟后再来」
冷却派生算法:取当日 `status='granted'` 记录里**最近一个已完成轮**(每 N=3 条算一轮)末尾那次的 `created_at`,加 10 分钟。若仍 > now → 返回;否则返回 null。**冷却是 UX 约束**,后端发奖(`/api/v1/ad/pangle-callback`)只看 daily_limit,冷却期间穿山甲回调照常发奖。
真正发奖走 [ad-pangle-callback](./ad-pangle-callback.md)(穿山甲 S2S)。
+279
View File
@@ -0,0 +1,279 @@
# SQLite → PostgreSQL 迁移指南
> 上线前把数据库从 SQLite 切到 PostgreSQL。本服务自始就为这一步预留了路径(`db/session.py` 的 dialect 特判 / `alembic/env.py` 的 batch mode 开关 / SQLAlchemy 2.0 + Alembic),代码改动很小。
>
> **背景假设**:当前生产**无真实用户数据**(MVP 阶段),所以整个迁移本质是"切引擎",不涉及数据搬迁。如果将来已有真实数据,本文档不适用,需补充 `pgloader` 演练 + 钱表金额逐行核对 + 停服窗口 + 回滚预案。
>
> 最后更新:2026-05-29
---
## 0. 为什么切
SQLite 三个硬伤决定它不能上线:
- **并发写锁库**:整个文件一把锁,多请求并发写会排队,极易超时。当前 uvicorn `--workers 1` 勉强能跑,扩 worker 立刻撞墙。
- **类型宽松**:整数列能塞字符串、`CHECK` 约束当装饰品。钱表(`wallet` / `withdraw_order` / `cash_transaction`)用 SQLite 等于裸奔。
- **无 JSONB / 无并发索引 / 无分区**:PG 的核心能力一个都享受不到。`savings.dishes` 现在用 SQLite 的 `JSON`(实际存 TEXT),无索引、无操作符,纯展示用。
PG 默认上 16 版(工具链最齐),驱动用 **psycopg3**(SQLAlchemy 2.0 时代默认,不要再装 psycopg2)。
---
## 1. 本地起 PG + 跑通空库(半天)
### 1.1 装 PG
macOS:
```bash
brew install postgresql@16
brew services start postgresql@16
```
Linux(Ubuntu/Debian):
```bash
sudo apt install postgresql-16
sudo systemctl enable --now postgresql
```
### 1.2 建库 + 用户 + 授权
```bash
psql postgres <<'EOF'
CREATE USER shaguabijia_app WITH PASSWORD 'change-me-strong-random';
CREATE DATABASE shaguabijia OWNER shaguabijia_app ENCODING 'UTF8';
GRANT ALL PRIVILEGES ON DATABASE shaguabijia TO shaguabijia_app;
EOF
```
密码用 `python -c "import secrets; print(secrets.token_urlsafe(32))"` 生成,**不要复用 JWT_SECRET_KEY**。
### 1.3 装驱动
```bash
pip install "psycopg[binary]>=3.1"
```
`psycopg[binary]` 是预编译版,免装 libpq 头文件。生产同款,无需源码编译版。
### 1.4 改 `.env`
```ini
DATABASE_URL=postgresql+psycopg://shaguabijia_app:<password>@localhost:5432/shaguabijia
```
> ⚠️ URL scheme 必须是 `postgresql+psycopg://`(显式声明 psycopg3),不能写成 `postgresql://`——SQLAlchemy 默认会去找 psycopg2,装的不一致就报 ModuleNotFoundError。
### 1.5 建表
```bash
alembic upgrade head
```
### 1.6 验证
```bash
psql -U shaguabijia_app -d shaguabijia -c "\dt"
```
应该列出全部 9 张表 + `alembic_version`:
```
user, coin_account, coin_transaction, cash_transaction,
withdraw_order, signin_record, task_claim_record,
savings_record, ad_reward_record, alembic_version
```
再手动 INSERT 一行 user,确认 `created_at` 默认值正常落:
```sql
INSERT INTO "user" (phone, register_channel, status) VALUES ('13800138000', 'sms', 'active');
SELECT id, phone, created_at, last_login_at FROM "user";
```
`created_at` 应该是当前时间戳带时区,不是 NULL。`user` 表名是 PG 关键字,**必须用双引号**包裹。
---
## 2. 测试套件全绿(1 小时)
### 2.1 测试连 PG
测试默认连 SQLite。临时让 `tests/conftest.py``DATABASE_URL` 也指到本地 PG(可以建个 `shaguabijia_test` 库专门跑测试,免得污染 dev 库)。
```bash
createdb -O shaguabijia_app shaguabijia_test
DATABASE_URL=postgresql+psycopg://shaguabijia_app:xxx@localhost:5432/shaguabijia_test pytest
```
### 2.2 处理失败用例
**所有红色测试都不能放过**——SQLite 类型/约束都很宽松,PG 严格起来暴露的就是真 bug。常见问题:
- **字符串和整数比较**:SQLite 允许 `WHERE phone = 13800138000`(自动转字符串),PG 直接报错。改成 `:phone` 参数 + 类型严格
- **timezone 处理**:SQLite 存 naive datetime,PG 的 `TIMESTAMPTZ` 必须带时区。检查所有 `datetime.utcnow()`,改成 `datetime.now(timezone.utc)`
- **隐式 commit**:SQLite 某些情况下隐式 commit,PG 严格事务边界。如果某用例报 `current transaction is aborted`,说明业务代码缺 commit/rollback
### 2.3 验证
`pytest` 全绿,**且本地起 `./run.sh` 后用 Apifox 调通登录 / 美团 feed / 领券透传三个真接口**。
---
## 3. 代码扫雷(1 天)
### 3.1 必改清单
| 文件 / 位置 | 改动 | 原因 |
|---|---|---|
| `app/models/savings.py` | `from sqlalchemy import JSON``from sqlalchemy.dialects.postgresql import JSONB`;`mapped_column(JSON, ...)``mapped_column(JSONB, ...)` | JSONB 在 PG 上有 GIN 索引和操作符支持,是 PG 核心优势之一。SQLite 上 `JSON` 实际是 TEXT,切到 PG 后用 `JSON` 类型存的是 `json` 不是 `jsonb`,查询/索引能力差一大截 |
| `app/db/session.py``create_engine` 调用 | 增加连接池参数:`pool_size=10, max_overflow=20, pool_recycle=3600` | SQLite 单文件不需要池,PG 必须显式池化。`pool_recycle=3600` 防 PG 主动断开的 idle 连接 |
| `alembic/versions/` 下旧迁移里的 `sa.text('(CURRENT_TIMESTAMP)')` | **不要改** | PG 能接受这个语法。改了反而搞乱迁移历史 + 让 alembic 觉得 schema drift。新写的迁移统一用 `sa.func.now()` |
### 3.2 保留的 dialect 特判
下面这些**保留原样**,它们的 SQLite 分支在 PG 下自动绕过、不影响新部署,但本地开发想用 SQLite 临时跑还能用:
- `app/db/session.py` 里的 `_ensure_sqlite_dir``check_same_thread` 特判
- `alembic/env.py` 里的 `render_as_batch` 特判
### 3.3 顺便清掉
`app/main.py` 启动日志里如果打 `DATABASE_URL`,确认密码不会进日志(只打 scheme + host,不打完整 URL)。
### 3.4 验证
本地 PG 起 `./run.sh`,跑一遍下面的烟测脚本:
```bash
# 登录
curl -X POST http://localhost:8770/api/v1/auth/sms/send -d '{"phone":"13800138000"}'
curl -X POST http://localhost:8770/api/v1/auth/sms/login -d '{"phone":"13800138000","code":"123456"}'
# 拿到 access_token 后调 me
curl -H "Authorization: Bearer <token>" http://localhost:8770/api/v1/auth/me
# 美团 feed
curl -X POST http://localhost:8770/api/v1/meituan/feed -d '{"longitude":116.4,"latitude":39.9,"page":0}'
```
三条都 200 即通过。`psql``user` 表多了一条 `13800138000`
---
## 4. 生产切换(半小时,无需停服窗口)
**前提**:生产无真实数据。如已有数据,本节不适用。
### 4.1 装 PG
```bash
ssh server
sudo apt install postgresql-16
sudo systemctl enable --now postgresql
```
### 4.2 建库
```bash
sudo -u postgres psql <<'EOF'
CREATE USER shaguabijia_app WITH PASSWORD '<strong-random-password>';
CREATE DATABASE shaguabijia OWNER shaguabijia_app ENCODING 'UTF8';
GRANT ALL PRIVILEGES ON DATABASE shaguabijia TO shaguabijia_app;
EOF
```
生产密码**单独生成**,不要复用本地的。
### 4.3 装驱动到生产 venv
```bash
cd /opt/shaguabijia-app-server
.venv/bin/pip install "psycopg[binary]>=3.1"
```
### 4.4 改生产 `.env`
```ini
DATABASE_URL=postgresql+psycopg://shaguabijia_app:<password>@localhost:5432/shaguabijia
```
### 4.5 建表 + 重启
```bash
.venv/bin/alembic upgrade head
sudo systemctl restart shaguabijia-app-server
sudo journalctl -u shaguabijia-app-server -f
```
启动日志里看到 `Application startup complete` + 没有 5xx 即通过。
### 4.6 验证
- 用真账号(手机号)走一遍极光登录或短信登录
-`GET /api/v1/auth/me`,确认返回当前用户
-`POST /api/v1/meituan/feed`,确认券列表正常
- `psql -U shaguabijia_app -d shaguabijia -c 'SELECT id, phone, created_at FROM "user"'` 看到刚登录的用户
### 4.7 老 SQLite 文件归档
```bash
cd /opt/shaguabijia-app-server
mv data/app.db data/app.db.legacy-$(date +%Y%m%d)
```
不要删——万一要回查测试期残留数据(开发期可能塞了一些手工记录),保留几个月。
---
## 5. 兜底:切换失败如何回滚
万一改完起不来:
```bash
# 1. .env 改回 SQLite
sed -i 's|^DATABASE_URL=postgresql.*|DATABASE_URL=sqlite:///./data/app.db|' /opt/shaguabijia-app-server/.env
# 2. 把归档的 sqlite 文件还原(如果已经改名)
mv data/app.db.legacy-* data/app.db
# 3. 重启
sudo systemctl restart shaguabijia-app-server
```
由于 SQLite 文件没动过,无数据丢失,损失只是切换过程中的几分钟 5xx。
---
## 6. 顺便建议:同时装上 Redis
PG 上线后,Redis 是下一个明确要补的基础设施(已在 [待办与技术债.md](./待办与技术债.md) 里列了用途)。**建议本次维护窗口顺手把 Redis 也装上**,不开始用、只装服务,免得下次还要单独申请部署:
```bash
sudo apt install redis-server
sudo systemctl enable --now redis-server
# 改 /etc/redis/redis.conf:bind 127.0.0.1(只本机访问)+ requirepass <strong-password>
sudo systemctl restart redis-server
```
下面三个用途等接的时候再写代码,**本次只装服务、不接代码**:
| 用途 | 当前临时方案 | 何时必须接 Redis |
|---|---|---|
| 短信验证码冷却 | 进程内存 dict(`integrations/sms.py`) | `SMS_MOCK=false` 上线时 / 扩 worker 时 |
| pricebot trace_id session | 进程内存 dict(在 pricebot-backend) | pricebot 扩 worker 时 |
| JWT 黑名单(logout 真失效) | 当前 logout 占位无失效 | P1 鉴权改造时 |
---
## 7. 后续优化(不阻塞上线,一周内补)
- **每天凌晨 `pg_dump` 备份**:用 systemd timer 或 cron,保留 7 天。备份文件 `pg_dump -Fc shaguabijia > /backup/shaguabijia-$(date +%Y%m%d).dump`
- **金钱表加 CHECK 约束**:新迁移给 `coin_account.balance``withdraw_order.amount_yuan_x100``CHECK (... >= 0)`。PG 严格执行,SQLite 形同虚设
- **timezone 统一**:生产 PG 配 `timezone='Asia/Shanghai'` 或维持 `UTC`,**团队定一个**写进本文档,业务代码统一处理。当前模型混用 `func.now()`(server-side)和 `_utcnow()`(`models/user.py``last_login_at`,Python-side),后者依赖应用层时区,迁完 PG 后建议统一改 `func.now()`
- **慢查询观察**:`shared_preload_libraries = 'pg_stat_statements'` 打开扩展,每周看一次 Top 10 慢查询,提前优化
---
## 8. 不要做的事
- ❌ 不要搞主从复制 / 读写分离——量级远没到,先用单实例
- ❌ 不要装 PgBouncer / 连接池中间件——SQLAlchemy 自带池够用
- ❌ 不要 squash 旧迁移文件——9 个增量保持原样,让 alembic 一次跑过空库
- ❌ 不要在生产 PG 上裸跑测试套件——用单独的 `shaguabijia_test`
+83 -1
View File
@@ -9,8 +9,15 @@ from sqlalchemy import select
from app.core.config import settings from app.core.config import settings
from app.integrations import pangle from app.integrations import pangle
from app.core.rewards import AD_REWARD_COIN, DAILY_AD_REWARD_LIMIT, MAX_AD_REWARD_COIN from app.core.rewards import (
AD_REWARD_COIN,
DAILY_AD_REWARD_LIMIT,
MAX_AD_REWARD_COIN,
VIDEO_ROUND_COOLDOWN_SECONDS,
VIDEO_ROUND_REQUIRED_COUNT,
)
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.models.ad_reward import AdRewardRecord
from app.models.user import User from app.models.user import User
@@ -148,6 +155,81 @@ def test_reward_status_requires_auth(client) -> None:
assert r.status_code == 401 assert r.status_code == 401
def test_reward_status_initial_round_state(client) -> None:
"""新用户没看广告 → round_count=0, cooldown_until=None。"""
phone = "13800003101"
token = _login(client, phone)
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
assert st["used_today"] == 0
assert st["round_count"] == 0
assert st["cooldown_until"] is None
def test_reward_status_mid_round(client) -> None:
"""看 1 次(未到一轮)→ round_count=1, cooldown_until 仍 None。"""
phone = "13800003102"
token = _login(client, phone)
uid = _user_id(phone)
_callback(client, _signed(uid, "trans_mid_1"))
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
assert st["used_today"] == 1
assert st["round_count"] == 1
assert st["cooldown_until"] is None
def test_reward_status_round_complete_enters_cooldown(client) -> None:
"""刚看完一轮 N 次 → round_count=0(下一轮起点) + cooldown_until 是未来 ~10 分钟。"""
from datetime import datetime, timezone
phone = "13800003103"
token = _login(client, phone)
uid = _user_id(phone)
for i in range(VIDEO_ROUND_REQUIRED_COUNT):
_callback(client, _signed(uid, f"trans_rc_{i}"))
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
assert st["used_today"] == VIDEO_ROUND_REQUIRED_COUNT
assert st["round_count"] == 0
assert st["cooldown_until"] is not None
cd = datetime.fromisoformat(st["cooldown_until"].replace("Z", "+00:00"))
if cd.tzinfo is None:
cd = cd.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
delta = (cd - now).total_seconds()
# 配置 600s,允许 ±30s 容差(本机 / CI 慢 IO)
assert 0 < delta <= VIDEO_ROUND_COOLDOWN_SECONDS + 30
def test_reward_status_cooldown_expired(client) -> None:
"""看完一轮后冷却已过(手动改 created_at 到 11 分钟前)→ cooldown_until 回到 None。"""
from datetime import datetime, timedelta, timezone
from sqlalchemy import select, update
phone = "13800003104"
token = _login(client, phone)
uid = _user_id(phone)
for i in range(VIDEO_ROUND_REQUIRED_COUNT):
_callback(client, _signed(uid, f"trans_exp_{i}"))
# 把当日所有 granted 记录的 created_at 推到 11 分钟前(覆盖冷却末尾那条)
db = SessionLocal()
try:
eleven_min_ago = datetime.now(timezone.utc) - timedelta(seconds=VIDEO_ROUND_COOLDOWN_SECONDS + 60)
db.execute(
update(AdRewardRecord)
.where(AdRewardRecord.user_id == uid)
.values(created_at=eleven_min_ago)
)
db.commit()
finally:
db.close()
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
assert st["used_today"] == VIDEO_ROUND_REQUIRED_COUNT
assert st["round_count"] == 0
assert st["cooldown_until"] is None
def test_callback_disabled_returns_503(client, monkeypatch) -> None: def test_callback_disabled_returns_503(client, monkeypatch) -> None:
"""未配置回调(开关关)时 → 503。""" """未配置回调(开关关)时 → 503。"""
monkeypatch.setattr(settings, "PANGLE_CALLBACK_ENABLED", False) monkeypatch.setattr(settings, "PANGLE_CALLBACK_ENABLED", False)