feat(权限): 收敛普通账号写权限至本人凭证
将调度时刻、采集参数等实例级配置收归管理员,普通账号仅可维护本人 Cookie 与 User-Agent。 新增 config.writable_by 作为唯一写权限入口,set_setting 强制全局键落到 user_id=0, 消除「管理员改了只有自己生效」的静默缺陷。新增 tools/check_docs.py 文档自检, smoke 断言扩至 215 项、check_live 扩至 122 项并支持普通账号越权验收, 忽略 backups/、data/*.bak* 与 legacy-v1/,版本升至 v1.3.0。
这个提交包含在:
@@ -0,0 +1,91 @@
|
||||
# backups/ — 数据库备份存放处
|
||||
|
||||
人工或脚本产生的数据库快照统一放这里。**不要放进 `data/`**,原因见下。
|
||||
|
||||
## 为什么备份不放在 `data/`
|
||||
|
||||
`data/` 是运行时数据目录,在 Docker 部署下会被挂载成卷(`workbuddy-portal_wb_data`)。
|
||||
|
||||
- 备份放进 `data/` → 执行 `docker compose down -v` 或重建卷时,**备份会跟着正本一起被删掉**,
|
||||
这正好是最需要备份的那一刻。
|
||||
- 备份放在仓库目录下的 `backups/` → 卷重建不影响它,同时离源码够近、搬家不丢。
|
||||
|
||||
`backups/` 下的一切都被 `.gitignore` 忽略(见仓库根 `.gitignore` 的「数据库备份」段),
|
||||
**绝不要提交**:快照里含 `settings` 表的加密凭证密文与 `users` 表的密码哈希。
|
||||
|
||||
## 目录内容
|
||||
|
||||
| 文件 | 说明 |
|
||||
| --- | --- |
|
||||
| `usage.sqlite.bak-pre-v13` | v1.2.0 → v1.3.0 迁移(`DB_SCHEMA_VERSION` 2 → 3)前的快照。保留用意:迁移同时做了「配置作用域收敛」(把调度与采集参数从个人级提升到实例级 `user_id=0`),万一收敛结果不符合预期,可回滚到这份 uv=2 的库重来。 |
|
||||
|
||||
### 校验记录(2026-09-16)
|
||||
|
||||
```
|
||||
integrity_check : ok
|
||||
user_version : 2
|
||||
usage_records : 1665 条
|
||||
SUM(credits) : 8513.36
|
||||
```
|
||||
|
||||
迁移后正本 `data/usage.sqlite`(uv=3)同样是 **1665 条 / 8513.36 积分**,零丢失。
|
||||
|
||||
> **快照本身是自包含的**:全部数据都在主文件里(`-wal` 为 0 字节,没有未落盘的提交帧)。
|
||||
>
|
||||
> 但要注意一个会反复出现的现象:**只要有人以 WAL 模式打开过这份快照,
|
||||
> SQLite 就会就地重建 `…-wal` / `…-shm` 两个侧车文件** —— 连只读打开也会
|
||||
> (SQLite 需要 `-shm` 做锁表)。所以「移走一次」不是长久之计,
|
||||
> 校验命令里加 `immutable=1` 才是根治(告诉 SQLite 这个文件不会变,不必建锁表)。
|
||||
>
|
||||
> 侧车只是运行时缓存,`backups/` 已在 `.gitignore` 里被整体忽略,
|
||||
> 不会误入库。归档或搬运快照前把两个侧车移走即可,前提是先确认 `-wal` 是 0 字节。
|
||||
|
||||
## 怎么用
|
||||
|
||||
**校验一份备份是否可用**(`immutable=1` = 只读且不建锁表,**不会改动也不会污染快照**):
|
||||
|
||||
```bash
|
||||
python -c "
|
||||
import sqlite3
|
||||
c = sqlite3.connect('file:backups/usage.sqlite.bak-pre-v13?immutable=1', uri=True)
|
||||
print('integrity:', c.execute('PRAGMA integrity_check').fetchone()[0])
|
||||
print('user_version:', c.execute('PRAGMA user_version').fetchone()[0])
|
||||
print('records:', c.execute('SELECT COUNT(*) FROM usage_records').fetchone()[0])
|
||||
"
|
||||
```
|
||||
|
||||
> 备份文件的**唯一权威判据**是 `integrity_check` 与行数,别拿 `sha256` 当验签 ——
|
||||
> 快照落盘后再被 SQLite 干净关闭过一次,主文件字节可能变化而内容完全一致。
|
||||
> 校验只需 `sqlite3` 标准库,用你跑服务的**同一个**解释器即可。
|
||||
|
||||
**回滚一份备份**:停掉服务 → 备份当前正本 → 把快照覆盖回 `data/usage.sqlite`
|
||||
→ **同时删除 `data/usage.sqlite-wal` 与 `data/usage.sqlite-shm`**(残留的 WAL 会让 SQLite 读到旧状态)
|
||||
→ 启动服务 → 跑 `python manage.py status` 与 `python manage.py stats` 确认账号与存档条数。
|
||||
|
||||
> 回滚前务必确认目标库的 `user_version` 与服务端 `workbuddy_portal/db.py` 的
|
||||
> `DB_SCHEMA_VERSION` 兼容:回滚到更老的版本号时,服务会在下次启动时重跑迁移。
|
||||
|
||||
## 自动备份(可选)
|
||||
|
||||
容器部署下推荐用宿主机的 cron 做,**先落盘再压缩**,避免 SQLite 在线拷贝产生撕裂快照:
|
||||
|
||||
```bash
|
||||
# 每天 03:30,用 SQLite 自带的一致性备份命令(不是 cp!)
|
||||
docker compose exec -T portal python -c "
|
||||
import sqlite3
|
||||
src = sqlite3.connect('/app/data/usage.sqlite')
|
||||
dst = sqlite3.connect('/tmp/wb-backup.sqlite')
|
||||
src.backup(dst); dst.close(); src.close()
|
||||
"
|
||||
docker compose cp portal:/tmp/wb-backup.sqlite \
|
||||
"./backups/usage-$(date +%Y%m%d).sqlite"
|
||||
docker compose exec -T portal rm -f /tmp/wb-backup.sqlite
|
||||
```
|
||||
|
||||
`cp` 一个正在被写入的 SQLite 文件可能拿到半截事务;`sqlite3.Connection.backup()`
|
||||
走的是官方的在线备份 API,能保证快照一致。手工离线拷贝时才可以直接 `cp`。
|
||||
|
||||
## 清理策略
|
||||
|
||||
保留最近 7 份日备 + 每份迁移前快照。旧的直接删,别在这里堆积——
|
||||
`backups/` 是安全网,不是归档;数据正本永远只有 `data/usage.sqlite` 一个。
|
||||
在新工单中引用
屏蔽一个用户