Compare commits

..

1 Commits

Author SHA1 Message Date
guke badd23b07d feat(compare): 新增只读当日比价额度查询 GET /compare/quota
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 18:23:38 +08:00
5 changed files with 180 additions and 305 deletions
+20
View File
@@ -21,6 +21,7 @@ from app.core.trace_ids import new_trace_id
from app.repositories import comparison as crud_compare
from app.repositories import risk as risk_repo
from app.schemas.compare_record import (
CompareQuotaOut,
CompareStartReserveIn,
CompareStartReserveOut,
CompareStatsOut,
@@ -155,6 +156,25 @@ def stats(user: CurrentUser, db: DbSession) -> CompareStatsOut:
return CompareStatsOut(compare_count=count, discovered_saved_cents=saved)
@router.get(
"/quota",
response_model=CompareQuotaOut,
summary="查询今天的比价次数配额(只读,不预占)",
)
def get_compare_quota(
user: CurrentUser,
db: DbSession,
device_id: str | None = Query(default=None),
) -> CompareQuotaOut:
"""按登录用户查今日比价配额,口径与 /compare/start 同源。
used 按 user_id 计数;limit/reset_at 按 phone+device 解析(与 /start 一致,device 白名单能命中)。
exhausted=true → 已达今日上限。客户端①④入口点击时前置查此,超限就地 toast 不跳转。"""
policy = limit_policy.resolve(db, "compare.start.daily", phone=user.phone, device=device_id)
used = crud_compare.get_daily_compare_used(db, user.id, reset_at=policy.reset_at)
exhausted = policy.limit is not None and used >= policy.limit
return CompareQuotaOut(exhausted=exhausted, used=used, limit=policy.limit)
@router.get(
"/records",
response_model=ComparisonRecordPage,
+27
View File
@@ -548,6 +548,33 @@ def reserve_daily_start(
return rec, int(used) + 1
def get_daily_compare_used(
db: Session,
user_id: int,
reset_at: datetime | None = None,
) -> int:
"""今日(北京时间自然日)该用户已发起的比价次数。只读,不改任何数据。
口径必须与 reserve_daily_start 完全一致(同 day_start/day_end/reset_at)。"""
current = datetime.now(CN_TZ)
if current.tzinfo is not None:
current = current.astimezone(CN_TZ).replace(tzinfo=None)
day_start = current.replace(hour=0, minute=0, second=0, microsecond=0)
if reset_at is not None:
reset_start = reset_at
if reset_start.tzinfo is not None:
reset_start = reset_start.astimezone(CN_TZ).replace(tzinfo=None)
day_start = max(day_start, reset_start)
day_end = day_start + timedelta(days=1)
used = db.scalar(
select(func.count(ComparisonRecord.id)).where(
ComparisonRecord.user_id == user_id,
ComparisonRecord.created_at >= day_start,
ComparisonRecord.created_at < day_end,
)
) or 0
return int(used)
def harvest_running(
db: Session,
*,
+8
View File
@@ -260,3 +260,11 @@ class MilestoneClaimResultOut(BaseModel):
milestone: int = Field(..., description="本次领取的档位序号")
coin_awarded: int = Field(..., description="本次发放金币")
coin_balance: int = Field(..., description="领奖后金币余额")
class CompareQuotaOut(BaseModel):
"""今天的比价次数配额状态(只读)。"""
exhausted: bool = Field(..., description="是否已达今日上限,无法再比价")
used: int = Field(..., description="今天已用次数")
limit: int | None = Field(..., description="今天的配额上限(None=无限制)")
@@ -1,304 +0,0 @@
# 线上 PostgreSQL 数据库备份 / 恢复方案 设计
- 日期:2026-08-03
- 状态:设计已评审,待写实现计划
- 相关代码(复用的现成范式):
- [deploy/daily-exchange.service](../../../deploy/daily-exchange.service) / [deploy/daily-exchange.timer](../../../deploy/daily-exchange.timer)systemd oneshot + timer 定时任务范式:`TZ=Asia/Shanghai``Persistent=true` 补跑、`ProtectSystem=strict` 加固、文件锁)
- [app/core/heartbeat_monitor_worker.py](../../../app/core/heartbeat_monitor_worker.py)(单实例文件锁 + 优雅退出范式)
- [app/integrations/notifier.py](../../../app/integrations/notifier.py)(可插拔通知器:`LogNotifier` 占位、协议不变后续替换)
- [app/admin/deps.py](../../../app/admin/deps.py)`require_role("super_admin")` 权限守卫、审计 IP
- [app/admin/routers/withdraw.py](../../../app/admin/routers/withdraw.py)admin router 风格范式)
- [scripts/init_postgres.py](../../../scripts/init_postgres.py)PG 运维脚本范式)
## 1. 背景与目标
线上业务库 `shaguabijia`PostgreSQL 16,当前数据量 1–10GB)目前没有系统化的备份/恢复方案。本方案提供覆盖「定时 + 手动 + 平台恢复 + 平台不可用兜底」四条路径的逻辑备份体系。
四条明确需求:
1. **每日定时备份** —— 无人值守,不依赖平台进程。
2. **平台手动备份** —— admin 后台一键触发。
3. **平台指定备份文件快速恢复** —— 在 admin 后台选一份备份,恢复到**旁库**供核对。
4. **平台不可用时手动恢复** —— 登录服务器,交互式选择备份恢复,与平台走**同一套脚本**。
数据规模 110GB → **逻辑备份(`pg_dump`)足够**,不引入物理备份 / PITR 的复杂度(见 §2 预留路径)。
## 2. 非目标(本期不做 / 预留升级路径)
- **物理备份 + PITR**`pg_basebackup` + WAL 归档):数据量涨到「凌晨全量 dump 也影响业务」时再上。§3 的架构不阻碍后续叠加。
- **异地对象存储(S3 / 阿里云 OSS)**:本期只落**本地磁盘**。备份脚本预留 `upload_to_remote()` 可插拔 hook`BACKUP_S3_ENABLED=false` 时直接返回),等 S3 服务确认后填充,主流程不改。
- **从只读副本 dump**:搭流复制 standby、改从副本备份以对主库零影响,属后续升级;那个副本还能顺带承载 PITR。
- **平台「一键覆盖生产」按钮**:刻意不做。扶正生产(旁库→主库)永远是人工 SSH 执行的高危脚本(§6)。
- **平台下载备份文件**:整库备份含手机号 / 微信 / 提现等敏感数据,经浏览器下载放大泄露面且文件大。取文件走 SSH。
- **真实推送告警渠道**:项目当前无真实推送能力(`notifier.py``LogNotifier` 占位、心跳 worker 也只打印)。本期告警走「日志 + openobserve + 平台新鲜度徽标」,推送做占位 hook(§8)。
## 3. 核心设计原则与整体架构
### 3.1 两条铁律
1. **执行逻辑沉到自包含脚本,平台与定时器都只是调用者。** —— 需求 4(平台不可用时手动恢复)因此不是另写一套,而是复用同一套脚本,逻辑不重复、兜底路径永远可用。
2. **备份清单的权威来源是文件系统(备份目录 + 每目录内的 `manifest.json`),不是数据库任务表。** —— 任务表只记录「平台发起的操作过程」用于展示进度与审计;删掉任务表不影响任何一份备份的可用性与可恢复性。最需要恢复的时刻(DB 崩了)恰恰是任务表也读不到的时刻,而恢复根本不读任务表。
### 3.2 分层架构
```
┌─────────────────────────────────────────┐
│ 核心执行层(自包含,不依赖平台/DB 任务表) │
│ scripts/db_backup.sh ← 生成备份 │
│ scripts/db_restore.sh ← 从备份恢复到旁库 │
│ scripts/db_promote.sh ← 旁库扶正生产(高危)│
└─────────────────────────────────────────┘
▲ ▲ ▲
┌─────────────┘ │ └──────────────┐
① 每日定时 ② 平台手动备份/恢复 ④ 平台挂了,人 SSH
systemd timer admin API → setsid 起脚本 跑 db_restore.sh
(不经过平台) (任务表记状态,web 不阻塞) (交互式选备份)
```
### 3.3 平台执行方式:方案 B(轻量版)
平台触发的「几分钟重操作」不放进 web 请求,采用**任务表解耦 + 独立进程执行**:
| | 采用 | 说明 |
|---|---|---|
| 谁拉起脚本 | admin API 插任务后 `setsid` 起一个**脱离 web 进程组**的 subprocess 跑脚本 | 不新增常驻 worker 服务,最贴合项目「脚本 + systemd」风格;web 重启不影响已起的脚本 |
| 并发控制 | `db_backup.sh``flock` 文件锁天然串行 | 防定时与手动同时跑、上一轮未完下一轮又起 |
| 升级路径 | 任务量大、需排队/限流/重试时,升级为常驻 worker 轮询任务表 | 本期低频操作用不上 |
**被否决的方案 A**admin API 里同步 `subprocess` 跑 dump/restore,请求一直挂着。否决原因:几分钟操作阻塞 web 进程、易请求超时;admin 进程需持有 DB 高权限;平台一挂手动备份路径也没了(解耦度反而更差)。
## 4. 备份设计
### 4.1 备份产物:一个自描述目录
```
$BACKUP_DIR/20260803_030000_daily/
├── shaguabijia.dump # pg_dump -Fc(自定义格式,内建压缩)
├── globals.sql # pg_dumpall --globals-only --no-role-passwords
├── manifest.json # 元信息(见下)
└── SHA256SUMS # 上述两个文件的校验和
```
目录名(= `backup_id`)格式:`YYYYMMDD_HHMMSS_<reason>`(北京时 `Asia/Shanghai`,纯数字 + 下划线),如 `20260803_030000_daily``reason ∈ {daily, manual}`。刻意不用连字符/冒号/字母,使其**同时是合法文件名与合法 PG 库名**——旁库名由它直接拼接(见 §5.1)。
`manifest.json` 字段:
| 字段 | 说明 |
|---|---|
| `backup_id` | = 目录名,全局唯一标识 |
| `created_at` | ISO8601 北京时 |
| `reason` | `daily` / `manual` |
| `triggered_by` | `systemd-timer` / admin 用户名 / `ssh-manual` |
| `pg_version` | 备份时 PG 版本 |
| `database` | `shaguabijia` |
| `format` | `custom` |
| `alembic_version` | 备份时的 schema 版本(恢复时对齐迁移用;旁库 `alembic_version` 表亦可查) |
| `files` | 每个文件的 `name` / `size_bytes` / `sha256` |
| `dump_duration_sec` | 导出耗时 |
| `status` | `success` / `failed`**仅自检通过才写 success** |
### 4.2 `db_backup.sh` 关键行为
| 环节 | 做法 | 理由 |
|---|---|---|
| 导出业务库 | `pg_dump -Fc``shaguabijia.dump` | 单文件、压缩,`pg_restore` 支持并行(`-j`)与按表选择性恢复 |
| 导出全局角色 | `pg_dumpall --globals-only --no-role-passwords``globals.sql` | 恢复到新机器时角色/权限齐全;`--no-role-passwords` 免超级用户读 `pg_authid`、旁库核对也不需要密码 |
| 资源降级 | `nice -n 19 ionice -c3` 包裹 pg_dump | 让业务优先,压制备份对 CPU/IO 的抢占 |
| **备份后自检** | dump 完立刻 `pg_restore --list *.dump >/dev/null` 解析 TOC + 校验 sha256 | 不验证的备份是薛定谔的备份;能抓到文件截断/损坏,只有通过才写 `status=success` |
| 并发锁 | `flock` 独占锁(仿 daily-exchange 30min 锁) | 串行化,防重入 |
| 保留清理 | **仅自检通过后**执行(§4.4) | 保证先有新备份、再删旧的 |
| 远程上传 | 末尾 `upload_to_remote()``BACKUP_S3_ENABLED=false` 时 return 0 | S3 可插拔扩展点 |
| 通知 | 末尾 `notify()`(§8),成功/失败都打结构化日志 | 贴合现状、可插拔 |
| 退出码 | 失败非 0 退出 + `SyslogIdentifier=pg-backup` | systemd 与 openobserve 可感知 |
| 触发标签 | `--reason daily|manual`,写进 manifest | 定时与手动共用一个脚本 |
### 4.3 对线上读写的影响与缓解
`pg_dump` 基于 MVCC 一致性快照,**不阻塞正常增删改查**,导出的是某一时刻的一致性视图。要点:
- **唯一会互斥的例外是 DDL**:dump 持 `ACCESS SHARE` 锁,与 `ALTER TABLE` / `DROP` / `TRUNCATE` / `VACUUM FULL` / 非并发建索引冲突。规避:alembic 迁移仅在手动部署时跑,与凌晨备份天然错开。
- **真正的影响是资源争抢**(1–10GB 下的关注点):磁盘 I/O(整库顺序读)、CPU(zlib 压缩)、缓存冲刷(热数据被挤出 shared_buffers,备份后短时命中率下降)、长事务期间 VACUUM 暂时回收不了死元组。
- **缓解**:定时放凌晨低谷(03:00,与 0 点 daily-exchange 错开)+ `nice`/`ionice` 降级 + 控制 dump 时长在几分钟内。
### 4.4 保留策略
清理只在**备份成功且自检通过后**执行,且**只删本地、不碰 S3**(S3 用自身生命周期策略):
- `BACKUP_KEEP_DAILY_DAYS=14` —— 保留最近 14 天每日备份。
- `BACKUP_KEEP_MONTHLY_COUNT=6` —— 更早的备份中,每月 1 号那份额外保留 6 个月(防「问题两周前就埋下」)。
- `BACKUP_MIN_KEEP=3` —— **硬底线:无论配置如何,永远至少保留最近 3 份**,防时间跳变/配置错误把备份删光。
- 清理前检查磁盘剩余空间,不足则打告警日志而非静默继续。
## 5. 恢复设计(恢复到旁库)
**铁律:平台与默认脚本永远只恢复到旁库,绝不自动碰生产。**
### 5.1 `db_restore.sh` —— 恢复到旁库(常规路径)
输入一个 `backup_id`,产出可供核对的旁库 `shaguabijia_restore_<backup_id>`(如 `shaguabijia_restore_20260803_030000_daily`):
| 步 | 动作 | 护栏 |
|---|---|---|
| 1 | **恢复前先验完整性**:校验 `SHA256SUMS` + `pg_restore --list` 确认可解析 | 不把损坏备份恢复到一半才发现 |
| 2 | 创建旁库 `shaguabijia_restore_<backup_id>`;已存在则提示换名或显式 `--force` 重建 | 不撞库、不误删 |
| 3 | 灌 `globals.sql`(角色已存在则跳过,幂等) | |
| 4 | `pg_restore -j <并行度,默认 2>` 到旁库(custom 格式自动识别,无需 `-Fc`),`nice`/`ionice` 降级 | 往旁库写,**不锁生产表**,对生产仅轻微资源争抢 |
| 5 | **恢复后自检报告**:表数量、关键业务表(user / wallet / withdraw 等)行数、`alembic_version`、最新记录时间戳 | 人凭报告判断「数据对不对、新不新」 |
**磁盘峰值提醒**:旁库与生产库共存于同一实例,恢复期间磁盘占用约为 2×(生产 + 旁库),恢复前脚本检查磁盘余量。
### 5.2 兜底:交互式选择(需求 4)
`db_restore.sh` **不带参数**运行时 → 扫描 `$BACKUP_DIR`,列表打印所有备份(`backup_id` / 时间 / 大小 / `status`),提示输入序号选一个恢复到旁库。这就是「平台挂了 SSH 登录手动选备份恢复」——与平台调用的是同一个脚本,零额外学习成本。
### 5.3 深度核对的边界
平台展示 §5.1 第 5 步的自检报告即可支撑「数据对不对」的判断。**逐行深度核对不在本方案范围**——需要时 DBA 直接连旁库跑只读 SQL,旁库为此存在。
## 6. 扶正生产:`db_promote.sh`(高危、人工、不进平台)
核对无误后把旁库扶正为生产。**故意不做成平台按钮**,必须人工 SSH 执行,因为不可逆且涉及短暂停机:
1. **二次确认**:要求手输生产库名 `shaguabijia` 才继续(仿 GitHub 删仓库确认)。
2. **先自动备份当前生产库**(调 `db_backup.sh --reason manual`)—— 最关键的兜底,扶错了能退回来。
3. **断开生产连接**:停 app 服务或 `pg_terminate_backend` 清连接(执行者自身不能连在待改名的库上)。**此步有短暂停机。**
4. **rename 切换**(不用 drop):`shaguabijia``shaguabijia_old_<ts>``shaguabijia_restore_xxx``shaguabijia`。出错可立即换回,比删库重建安全得多。
5. 重启 app 服务,人工验证。
**为何用脚本而非裸敲 SQL**:切换不是「只有 rename 一句」,而是一串不能漏的动作,其中「先备份当前生产库」裸敲最易漏、漏了就没退路。脚本把这串封装成带护栏的原子操作。极端情况下连脚本都不可用时,rename 本质是两条 `ALTER DATABASE` SQL,DBA 亦可手工执行,但会失去自动兜底备份保护,属下策。
## 7. 平台 API + 数据模型 + 权限
### 7.1 数据模型(两张任务表,均新建 + alembic 迁移)
`db_backup_job`
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | PK | |
| `triggered_by_admin_id` | FK admin_user, nullable | 手动才有 |
| `status` | str | `pending` / `running` / `success` / `failed` |
| `backup_id` | str, nullable | 成功后填产出目录名 |
| `error_msg` | text, nullable | |
| `created_at` / `started_at` / `finished_at` | datetime | 后两者 nullable |
| `duration_sec` | int, nullable | |
`db_restore_job`
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | PK | |
| `backup_id` | str | 恢复哪个备份 |
| `target_db` | str | 旁库名 |
| `triggered_by_admin_id` | FK admin_user | |
| `status` | str | `pending` / `running` / `success` / `failed` |
| `sanity_report` | json, nullable | §5.1 第 5 步的自检报告 |
| `error_msg` | text, nullable | |
| `created_at` / `started_at` / `finished_at` | datetime | 后两者 nullable |
模型需在 [app/models/__init__.py](../../../app/models/__init__.py) 导入以便 Alembic 发现。
**备份清单不查任务表**:定时备份不写任务表(不经过平台),但其产出的备份**出现在备份列表**(列表来自扫盘)。平台因此有两个视图:备份文件列表(扫盘,含所有来源)、操作记录(任务表 + 审计)。
### 7.2 API 端点(新建 `app/admin/routers/db_backup.py`,注册进 `app/admin/main.py`
| 方法 | 路径 | 作用 | 返回 |
|---|---|---|---|
| GET | `/admin/db-backup/backups` | 列所有备份(扫盘读 manifest) | `list[BackupItem]` |
| GET | `/admin/db-backup/health` | 新鲜度:最近成功备份时间与年龄 | `{last_success_at, age_hours, healthy}` |
| POST | `/admin/db-backup/backups` | 手动触发备份 | `202 {job_id}` |
| GET | `/admin/db-backup/jobs/{id}` | 轮询备份任务状态 | `BackupJobStatus` |
| POST | `/admin/db-backup/restores` | 恢复 `backup_id` 到旁库(body: `{backup_id}` | `202 {restore_job_id}` |
| GET | `/admin/db-backup/restores/{id}` | 轮询恢复状态 + 自检报告 | `RestoreJobStatus` |
`BackupItem``backup_id / created_at / reason / triggered_by / size_bytes / database / pg_version / alembic_version / status`
契约放 `app/schemas/db_backup.py`Pydantic)。POST 端点为**异步**语义:插任务表 → `setsid` 起脚本 → 立即 `202` 返回 `job_id`,前端轮询对应 GET 端点。
### 7.3 权限与审计
- **权限**:备份与恢复端点**均限 `super_admin`**(复用 `require_role("super_admin")`)。恢复能触及全库数据,不宜下放;将来给运维岗再引入 `require_page("db_backup")` 细分。
- **审计**:每次备份 / 恢复写现有审计日志(触发人、`backup_id`、结果),与项目其它高危操作一致。
- **不提供下载**(见 §2)。
## 8. 监控告警(贴合「日志 + openobserve + 平台徽标」现状)
三层,从被动到主动:
1. **结构化日志**:脚本成功/失败均打 `SyslogIdentifier=pg-backup` 日志,`journalctl` 可查、openobserve 可抓,失败为 ERROR 级 → 若已配 openobserve 告警规则即命中。
2. **平台新鲜度徽标**(最直观、不依赖推送):admin 备份页顶部显示「最近成功备份:X 小时前」,超 `BACKUP_FRESH_MAX_HOURS`(默认 26h)红色高亮。数据源为 `GET /admin/db-backup/health`
3. **可插拔 `notify()` hook**:脚本内通知点,现在只打日志(照搬 `notifier.py``LogNotifier` 模式),将来推送能力(飞书/短信)就绪再填,主流程不改。
**新鲜度自检兜底**`deploy/pg-backup-check.timer`+service)每天 09:00 检查最新成功备份是否在 26h 内,过期打 ERROR 日志 + 调 `notify()`。这覆盖 `OnFailure` 抓不到的盲区(timer 被禁 / 宕机没补跑)——因为那种情况脚本根本没运行,靠「有没有新备份」反向判断。
## 9. 脚本连库身份与权限
备份/恢复脚本**以本机 `postgres` 超级用户走 Unix socket(peer 认证,无需密码)**执行,而非业务的 `DATABASE_URL`TCP + 密码)。理由:
- `pg_dumpall --globals-only``CREATE DATABASE`(建旁库)、`ALTER DATABASE ... RENAME``pg_terminate_backend` 都需高权限;
- 本机 socket peer 认证是运维脚本标准做法,权限最省心,且不用把超级用户密码写进任何配置文件。
脚本以 `root``postgres` 系统用户运行(systemd service 内 `User=postgres` 或经 `sudo -u postgres`)。SSH 手动执行时同理。
## 10. 配置项
新增到 [app/core/config.py](../../../app/core/config.py) 的 `Settings`(供平台侧 §8 新鲜度等读取),shell 脚本经 systemd `EnvironmentFile=.env` 注入、SSH 手动跑时脚本内有默认值兜底:
| 配置 | 默认 | 说明 |
|---|---|---|
| `BACKUP_DIR` | `/opt/pg_backups` | 备份根目录(建议独立数据盘,与 PG 数据文件不同物理盘) |
| `BACKUP_KEEP_DAILY_DAYS` | `14` | 每日备份保留天数 |
| `BACKUP_KEEP_MONTHLY_COUNT` | `6` | 月度长留份数 |
| `BACKUP_MIN_KEEP` | `3` | 硬底线,永远至少保留份数 |
| `BACKUP_FRESH_MAX_HOURS` | `26` | 新鲜度阈值 |
| `BACKUP_PG_SUPERUSER` | `postgres` | 脚本连库超级用户 |
| `BACKUP_PGHOST` | `/var/run/postgresql` | socket 目录(peer 认证) |
| `BACKUP_S3_ENABLED` | `false` | 远程上传总开关(预留) |
| `BACKUP_S3_*` | 空 | bucket / endpoint / 凭证(预留,S3 确认后填) |
同步更新 `.env.example`
## 11. 完整文件清单
### 本仓(后端 + 脚本)
**新增:**
| 文件 | 作用 |
|---|---|
| `scripts/db_backup.sh` | 备份核心(dump + 自检 + 保留清理 + upload hook + notify |
| `scripts/db_restore.sh` | 恢复到旁库(含无参交互式选择 = 需求 4) |
| `scripts/db_promote.sh` | 旁库扶正生产(高危、人工、二次确认) |
| `app/models/db_backup_job.py` | `db_backup_job` + `db_restore_job` 模型 |
| `app/admin/repositories/db_backup_job.py` | 任务表数据访问 + 扫盘读 manifest |
| `app/admin/routers/db_backup.py` | 平台 API6 个端点) |
| `app/schemas/db_backup.py` | API 契约 |
| `alembic/versions/xxxx_add_db_backup_jobs.py` | 建两张任务表 |
| `deploy/pg-backup.service` / `pg-backup.timer` | 每日定时备份 |
| `deploy/pg-backup-check.service` / `pg-backup-check.timer` | 新鲜度自检 |
| `deploy/pg-backup.md` | 部署文档(仿 [deploy/daily-exchange.md](../../../deploy/daily-exchange.md) |
**改动:** `app/models/__init__.py`(导模型)、`app/core/config.py`(§10 配置项)、`app/admin/main.py`(注册 router)、`.env.example`
### 另一仓(`shaguabijia-admin-web`)配套
备份管理前端页:备份列表 + 一键备份 + 选备份恢复到旁库 + 恢复进度/自检报告展示 + 新鲜度徽标。本 spec 定义 API 契约(§7.2),前端据此实现,单独走该仓的开发流程。
## 12. 需求覆盖对照
| 需求 | 落地 |
|---|---|
| 每日定时备份 | `pg-backup.timer``db_backup.sh --reason daily`,凌晨 3 点,不依赖平台 |
| 平台手动备份 | `POST /admin/db-backup/backups` → 插任务 + `setsid``db_backup.sh --reason manual` |
| 平台指定备份快速恢复 | `POST /admin/db-backup/restores``db_restore.sh` 恢复到旁库 + 自检报告 |
| 平台不可用手动恢复 | SSH 跑 `db_restore.sh`(交互式选备份),同一套脚本 |
| (延伸)扶正生产 | 人工 `db_promote.sh`,二次确认 + 先备份当前 + rename 切换 |
## 13. 部署时需确认的运维参数
以下取决于服务器实际情况,部署或本 spec 复审时确认,不阻碍设计:
1. **备份目录位置**:默认 `/opt/pg_backups`;若有独立数据盘,建议放数据盘且与 PG 数据文件不同物理盘(避免一盘挂掉数据与备份同亡)。
2. **保留量匹配磁盘余量**:1–10GB 压缩后每份约几百 MB~2GB,14 天约 3–30GB;核对与磁盘余量是否匹配。
3. **备份时间**:默认凌晨 03:00(与 0 点 daily-exchange 错开);确认无其它凌晨任务撞车。
4. **脚本运行用户**:确认以 `postgres`(或可 `sudo -u postgres`)运行、socket peer 认证可用。
+125 -1
View File
@@ -1,14 +1,16 @@
from __future__ import annotations
import time
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from sqlalchemy import func, select
from app.core.limit_policy import MODE_UNLIMITED
from app.core.rewards import CN_TZ
from app.core.security import decode_token
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
from app.models.limit_policy import LimitPolicyOverride
def _login(client) -> tuple[str, int]:
@@ -145,3 +147,125 @@ def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None:
ComparisonRecord.trace_id == rejected_trace
)
) == 0
def test_compare_quota_fresh_user(client) -> None:
"""新用户今天没有比价记录 → exhausted=False, used=0, limit=100。"""
token, _user_id = _login(client)
response = client.get("/api/v1/compare/quota", headers=_headers(token))
assert response.status_code == 200, response.text
assert response.json() == {"exhausted": False, "used": 0, "limit": 100}
def test_compare_quota_exhausted(client) -> None:
"""今日已有 100 条记录 → exhausted=True, used=100, limit=100。"""
token, user_id = _login(client)
now = datetime.now(CN_TZ).replace(tzinfo=None)
with SessionLocal() as db:
db.add_all(
[
ComparisonRecord(
user_id=user_id,
trace_id=f"quota-exhausted-{user_id}-{i}",
status="failed",
created_at=now,
)
for i in range(100)
]
)
db.commit()
response = client.get("/api/v1/compare/quota", headers=_headers(token))
assert response.status_code == 200, response.text
assert response.json() == {"exhausted": True, "used": 100, "limit": 100}
def test_compare_quota_yesterday_rows_not_counted(client) -> None:
"""昨天的记录不计入今日配额 → exhausted=False, used=0。"""
token, user_id = _login(client)
yesterday = datetime.now(CN_TZ).replace(tzinfo=None) - timedelta(days=1)
with SessionLocal() as db:
db.add_all(
[
ComparisonRecord(
user_id=user_id,
trace_id=f"quota-yesterday-window-{user_id}-{i}",
status="success",
created_at=yesterday,
)
for i in range(100)
]
)
db.commit()
response = client.get("/api/v1/compare/quota", headers=_headers(token))
assert response.status_code == 200, response.text
assert response.json() == {"exhausted": False, "used": 0, "limit": 100}
def test_compare_quota_device_whitelist_parity(client) -> None:
"""device 白名单下 /quota?device_id=X 与 /start 的 policy 完全一致。
场景:
- 设备 whitelisted-device-001 unlimited 覆盖 /quota?device_id= 应报
exhausted=False, limit=null,哪怕同用户今天已发起 100
- 不带 device_id(或带非白名单设备) 同用户走全局 100 上限,exhausted=True
"""
token, user_id = _login(client)
device_id = f"whitelisted-device-{user_id}"
# 种 100 条今日记录:此时不带白名单设备 /quota 应报 exhausted=True
now = datetime.now(CN_TZ).replace(tzinfo=None)
with SessionLocal() as db:
db.add_all(
[
ComparisonRecord(
user_id=user_id,
trace_id=f"quota-parity-{user_id}-{i}",
status="failed",
created_at=now,
)
for i in range(100)
]
)
# 种 device 白名单覆盖:unlimited、无失效时间(永久白名单用 expires_at=None)
# 注意:validate_override 要求 unlimited+有 expires_at,但这里直接写 ORM 跳过
# 该验证——测试意图是覆盖"设备白名单已存在"的生产状态,expires_at=None 代表永久。
db.add(
LimitPolicyOverride(
subject_type="device",
subject_value=device_id,
rule_code="compare.start.daily",
mode=MODE_UNLIMITED,
enabled=True,
expires_at=None,
)
)
db.commit()
# 带白名单 device_id → unlimited,不受 100 条记录限制
resp_with_device = client.get(
f"/api/v1/compare/quota?device_id={device_id}",
headers=_headers(token),
)
assert resp_with_device.status_code == 200, resp_with_device.text
body_with = resp_with_device.json()
assert body_with["exhausted"] is False, f"whitelisted device should not be exhausted: {body_with}"
assert body_with["limit"] is None, f"whitelisted device should have null limit: {body_with}"
assert body_with["used"] == 100
# 不带 device_id → 走全局 100 上限,已有 100 条 → exhausted=True
resp_no_device = client.get("/api/v1/compare/quota", headers=_headers(token))
assert resp_no_device.status_code == 200, resp_no_device.text
body_no = resp_no_device.json()
assert body_no["exhausted"] is True, f"without device should be exhausted: {body_no}"
assert body_no["limit"] == 100
assert body_no["used"] == 100
# 带非白名单 device_id → 同样走全局 100 上限
resp_other_device = client.get(
"/api/v1/compare/quota?device_id=unknown-device-xyz",
headers=_headers(token),
)
assert resp_other_device.status_code == 200, resp_other_device.text
body_other = resp_other_device.json()
assert body_other["exhausted"] is True, f"non-whitelisted device should be exhausted: {body_other}"
assert body_other["limit"] == 100