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="今日看广告发奖进度")
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(
used_today=used,
daily_limit=limit,
remaining=max(0, limit - used),
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:
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)
return TestGrantOut(
granted=(rec.status == "granted"),
@@ -128,4 +130,6 @@ def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut:
daily_limit=limit,
remaining=max(0, limit - used),
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 服务端回调发奖)=====
# 看完一个激励视频发的金币(≈¥0.01,汇率 10000 金币=1 元)。
# 作用:① 回调缺/坏 reward_amount 时的回退值;② 客户端进度接口展示的"单次预告金币"
# 看完一个激励视频发的金币(100 金币 ≈¥0.01,汇率 10000 金币=1 元)。
# 作用:① 回调缺/坏 reward_amount 时的回退值;② 客户端进度接口展示的"单次预告金币";
# ③ test-grant 本地联调的发奖额。
# 真实发放以穿山甲回调带回的 reward_amount 为准(见 resolve_ad_reward_coin),后台应把
# 代码位"奖励数量"配成与本值一致(100),保证"广告内展示 / 进度预告 / 实际到账"三者一致。
# 代码位"奖励数量"配成与本值一致(=100),保证"广告内展示 / 进度预告 / 实际到账"三者一致。
AD_REWARD_COIN: int = 100
# 单次发奖金币上限:夹紧穿山甲回调里异常的 reward_amount(如后台多打一个 0),防刷爆余额。
MAX_AD_REWARD_COIN: int = 1000
# 每用户每日发奖次数上限,防刷 + 控成本。
# ⚠️ 占位值:正式上线前按产品策略 + 广告 eCPM 实测收益重新核定,改这里即可。
DAILY_AD_REWARD_LIMIT: int = 10
# ⚠️ 测试阶段值=20:正式上线前按产品策略 + 广告 eCPM 实测收益重新核定,改这里即可。
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:
+55 -5
View File
@@ -10,11 +10,19 @@
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
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.models.ad_reward import AdRewardRecord
from app.models.user import User
@@ -102,7 +110,49 @@ def _commit_record(db: Session, rec: AdRewardRecord, trans_id: str) -> AdRewardR
return rec
def today_status(db: Session, user_id: int) -> tuple[int, int, int]:
"""客户端查"今日看广告发奖"进度:返回 (今日已发次数, 每日上限, 单次金币)。"""
used = _granted_today(db, user_id, cn_today().isoformat())
return used, DAILY_AD_REWARD_LIMIT, AD_REWARD_COIN
def _last_completed_round_end_at(
db: Session, user_id: int, reward_date: str, round_count: int
) -> datetime | None:
"""当日 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 datetime import datetime
from pydantic import BaseModel, Field
@@ -27,6 +29,14 @@ class AdRewardStatusOut(BaseModel):
daily_limit: int = Field(..., description="每日发奖次数上限")
remaining: 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):
@@ -39,3 +49,7 @@ class TestGrantOut(BaseModel):
daily_limit: int = Field(..., description="每日发奖次数上限")
remaining: 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 | 每日发奖次数上限 |
| `remaining` | 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.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.models.ad_reward import AdRewardRecord
from app.models.user import User
@@ -148,6 +155,81 @@ def test_reward_status_requires_auth(client) -> None:
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:
"""未配置回调(开关关)时 → 503。"""
monkeypatch.setattr(settings, "PANGLE_CALLBACK_ENABLED", False)