Files
gaokao-volunteer-system/docs/plans/T7-sharing-mvp.md
Hermes Agent a8b49276e8
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
feat(share): complete poster generation and unify share CLI surface
2026-06-30 17:48:58 +08:00

387 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# T7: 分享功能 MVP — 实施计划
**版本**: v1.0
**状态**: ✅ 已完成 (T7.1/T7.2/T7.3/T7.4/T7.5 已完成)
**关联文档**:
- [产品设计 v1 - 分享](../PRODUCT_DESIGN_v1.md)
- [分享功能设计 SHARING_DESIGN.md](../SHARING_DESIGN.md)
- [实施计划 v2 - T7 节](../IMPLEMENTATION_PLAN_v2.md#t7-分享功能mvp-)
---
## 0. 范围与现状
T7 包含 5 个子任务 (来自 IMPLEMENTATION_PLAN_v2.md §T7):
| ID | 任务 | 优先级 | 状态 | 工时 |
| ---- | -------------- | :----: | --------- | :--: |
| T7.1 | 短链接生成 | P1 | ✅ 已完成 | 1d |
| T7.2 | 海报生成 (PIL) | P1 | ✅ 已完成 | 1.5d |
| T7.3 | 权限控制 (3级) | P1 | ✅ 已完成 | 1d |
| T7.4 | 撤销与统计 | P1 | ✅ 已完成 | 1d |
| T7.5 | 分享页 WebUI | P1 | ✅ 已完成 | 0.5d |
**本文档记录 T7 全部 5 个子任务的最终落地状态与验证结论。**
---
## T7.1 短链接生成 ✅
### 目标
为每条志愿方案生成形如 `/s/ABC123` 的短链接, 满足:
- URL 简短, 便于微信/短信/朋友圈传播
- 持久映射 (SQLite)
- 访问可控 (有效期 / 密码 / 权限)
- 访问可统计 (次数 / 时间)
### 现状
- **数据存储**: `data/share/short_links.db` (SQLite, WAL 模式)
- **核心模块**: `data/share/short_link.py`
- **CLI 入口**: `scripts/gaokao-shortlink`
- **路由辅助**: `data.share.short_link.route_short_link(code, ...)` 供 Web 框架挂载 `/s/<code>` 时调用
- **测试**: `data/share/tests/test_short_link.py` (25 个 pytest 用例, 137 个断言)
### 短码生成
```
字母表: 0-9, A-Z, a-z (62 字符, URL-safe, 无需 urlencode)
长度: 默认 6 位, 范围 4-16 位
空间: 62^6 = 56,800,235,584 ≈ 568 亿
随机源: secrets.choice (加密安全)
冲突处理: 创建时捕获 IntegrityError 重试, 默认 8 次
```
**碰撞估算** (生日悖论):
- N = 1000 万条已存在, 短码 6 位
- p(碰撞) ≈ 1 - exp(-N^2 / (2 \* 62^6)) ≈ 8.8e-5, 仍然极低
- 短码 7 位: 空间 3.5e10, 实际不会撞
### 数据模型 (SQLite)
```sql
CREATE TABLE IF NOT EXISTS share_links (
code TEXT PRIMARY KEY,
report_id TEXT NOT NULL,
owner_id TEXT NOT NULL DEFAULT 'anonymous',
permission TEXT NOT NULL DEFAULT 'comment',
password_hash TEXT, -- pbkdf2 salt_hex$digest_hex兼容历史 sha256 迁移)
expires_at REAL, -- unix timestamp, NULL=永久
revoked INTEGER NOT NULL DEFAULT 0,
access_count INTEGER NOT NULL DEFAULT 0,
last_access_at REAL,
created_at REAL NOT NULL,
note TEXT
);
CREATE INDEX IF NOT EXISTS idx_share_links_report ON share_links(report_id);
CREATE INDEX IF NOT EXISTS idx_share_links_owner ON share_links(owner_id);
CREATE INDEX IF NOT EXISTS idx_share_links_expires ON share_links(expires_at);
```
### Python API
```python
from data.share.short_link import ShortLinkService
svc = ShortLinkService() # 默认 db = data/share/short_links.db
link = svc.create(
report_id="R-2026-001",
owner_id="alice",
permission="read", # read / comment / edit / admin
password="s3cr3t", # 可选
ttl_days=7, # 有效期(7d), None=永久
code_length=6, # 默认 6
)
print(link.code) # e.g. "aB3xY7"
print(build_url(link.code)) # "http://localhost:8000/s/aB3xY7"
# 解析 (校验状态 + 计入访问次数)
res = svc.resolve(link.code, password="s3cr3t")
# res.status in {ok, not_found, revoked, expired, password_required, password_wrong}
# 撤销
svc.revoke(link.code, owner_id="alice") # False if already revoked / wrong owner
# 列表 / 统计
svc.list_by_report("R-2026-001")
svc.list_by_owner("alice")
svc.get_stats(link.code) # {access_count, last_access_at, ...}
```
### CLI
```bash
# 创建
python scripts/gaokao-shortlink create \
--report-id R-2026-001 --owner alice \
--permission read --ttl-days 30
# 创建带密码
python scripts/gaokao-shortlink create \
--report-id R-2026-001 --owner alice \
--password s3cr3t --ttl-days 7
# 解析
python scripts/gaokao-shortlink resolve ABC123
python scripts/gaokao-shortlink resolve ABC123 --password s3cr3t
# 撤销 (可选 owner 校验)
python scripts/gaokao-shortlink revoke ABC123 --owner alice
# 列表 / 统计 / 清理
python scripts/gaokao-shortlink list --report R-2026-001
python scripts/gaokao-shortlink list --owner alice
python scripts/gaokao-shortlink stats ABC123
python scripts/gaokao-shortlink purge
```
### 接入 Web 框架
```python
# Flask 示意 (项目当前无 Web 框架, 由 T7.5 引入)
from data.share.short_link import route_short_link
from flask import jsonify, request
@app.route("/s/<code>")
def short_link(code):
return jsonify(route_short_link(
code,
password=request.args.get("pwd"),
base_url=request.host_url.rstrip("/"),
))
```
### 验证
- 单元测试: `python3 -m pytest data/share/tests/test_short_link.py -v` → 25 passed
- E2E CLI: 已通过 24 项断言 (含 create/resolve/revoke/list/stats/purge 全部子命令)
- 短码唯一性: 50 次连续 create 无冲突
- 访问控制: 撤销 / 过期 / 密码错误三种状态正确返回
### 已知限制 (P2)
- 当前使用 PBKDF2-HMAC-SHA25616B salt + 200k iterations历史无盐 sha256 会在成功校验后自动迁移
- DB 单文件, 不支持水平扩展; 高 QPS 时建议迁 Redis
- 暂未做 IP 限流 / 访问频率限制 (T7.4 之后再补)
---
## T7.2 海报生成 (PIL) ✅
### 目标
把方案生成 1080×1920 PNG/JPG 海报, 含:
- 标题 / 关键信息 (考生脱敏版本可选)
- 推荐院校 TOP3
- 二维码 (内容 = /s/{code})
- 底部品牌区 (Powered by 龙老师)
### 设计草案
```
依赖: Pillow (PIL), qrcode
入口: scripts/gaokao-poster
数据源: 报告 JSON + 短码
输出: data/share/posters/{report_id}_{code}.png
```
### 本次落地
- **海报模块**`data/share/poster.py`
- `build_poster_payload(...)`:从报告 JSON 提取标题 / 考生 / 分数省份年份 / 推荐院校 TOP3 / 分享链接
- `render_poster(...)`:生成 1080×1920 海报,包含标题卡、考生摘要、推荐院校卡片、二维码区、底部品牌区
- `save_poster(...)`:按输出扩展名保存 PNG / JPG并返回渲染结果元数据
- **CLI 入口**`scripts/gaokao-poster`
- 输入:`--report-json --code --base-url --output`
- 默认姓名脱敏;`--unmask-name` 可关闭脱敏
- **运行依赖**`requirements-admin.txt` 新增 `qrcode>=8.2,<9.0`
### 验证
- `python3 -m pytest data/share/tests/test_poster.py -q`**3 个单测通过**
- `python3 -m pytest tests/test_poster_cli.py -q`**2 个 CLI 测试通过**
- 海报能力已纳入分享回归:`data/share/tests/test_poster.py + tests/test_poster_cli.py + 既有分享测试`**93 passed**
---
## T7.3 权限控制 (3级) ✅
### 目标
把短链接层的 `permission` 字段真正翻译成分享页可执行的 UI 能力与字段可见性策略:
| 级别 | 字段值 | UI 行为 | 姓名展示 |
| ---- | --------- | -------------------------- | --------------------- |
| 只读 | `read` | 隐藏所有编辑/评论入口 | 脱敏显示(如 `张**` |
| 评论 | `comment` | 显示「提建议」入口,禁编辑 | 脱敏显示(如 `张**` |
| 编辑 | `edit` | 显示完整编辑权限 | 原样展示 |
### 本次落地
- **策略模块**`data/share/permission.py`
- `PermissionPolicy.for_permission(permission)`:把 `read/comment/edit/admin` 归一化成前端能力策略
- `allows_field(field)` / `can("view|comment|edit")`:统一权限判断
- 未知 permission 一律回退到最严格的 `read` 拒止策略(防止越权)
- **姓名脱敏复用**:复用 `data/orders/masking.py::mask_name`
- 仅策略层决定 "该不该脱敏"
- 基础脱敏算法仍由订单模块统一维护公开分享场景再做保守收敛3 字及以上中文名统一为 `姓+**`,非中文名统一为 `**`
- **路由辅助扩展**`data.share.short_link.route_short_link_with_report(...)`
- 在 T7.1 的 `route_short_link()` 之上叠加报告 payload 渲染
- 支持 `report=` 直接注入,或 `report_loader(report_id)` 回调懒加载
- resolve 失败not_found / revoked / expired / password_required / password_wrong时不下发 `rendered`,避免泄露元数据
- **字段裁剪规则**
- `read`:回传最小元信息 + 脱敏姓名(公开场景采用更严格的 `张**` / `**` 收敛规则)
- `comment`:回传 `title / summary / recommendations / volunteers / score / rank / year / province` + 脱敏姓名,但不暴露手机号/身份证/内部备注/哈希
- `edit`:开放完整编辑所需业务字段,但仍强制隐藏 `password_hash / internal_note / note / debug_info / raw_payload` 等内部字段
### Python API
```python
from data.share.permission import PermissionPolicy, render_report_payload
from data.share.short_link import route_short_link_with_report
policy = PermissionPolicy.for_permission("comment")
assert policy.can_view is True
assert policy.can_comment is True
assert policy.can_edit is False
rendered = render_report_payload("comment", report_dict, share_url="https://gk.example.com/s/ABC123")
# rendered = {
# "permission": "comment",
# "policy": {"can_view": True, "can_comment": True, "can_edit": False, "mask_name": True},
# "visible_fields": [...],
# "payload": {...},
# "masked_fields": [...],
# }
out = route_short_link_with_report(
"ABC123",
password="s3cr3t",
base_url="https://gk.example.com",
report_loader=lambda report_id: load_report(report_id),
)
```
### 验证
- `python3 -m pytest data/share/tests/test_permission.py -q`**34 passed**
- `python3 -m pytest data/share/tests/ -q`**61 passed**
- `python3 -m pytest data/share/ data/orders/ -q`**231 passed**
- `python3 -m ruff check data/share/permission.py data/share/tests/test_permission.py data/share/short_link.py`**All checks passed**
### 设计取舍 / 已知限制
- `admin` 作为 T7.1 历史兼容值,当前按 `edit` alias 处理T7.3 的业务语义仍是 3 级权限模型
- `edit` 允许手机号/身份证等编辑所需业务字段,但 `password_hash / internal_note / note / debug_info / raw_payload` 这类内部字段仍被策略层强制隐藏
- 当前 `read/comment` 采用"最小字段白名单",优先防泄露;如果 T7.5 UI 需要额外字段,应在策略表中显式增补,而不是默认放开
---
## T7.4 撤销与统计 ✅
### 本次落地
- 批量撤销: `ShortLinkService.revoke_by_report(report_id, owner_id=None)`
- 同一 report 下可一次性撤销全部分享;传 `owner_id` 时只撤销该 owner 创建的链接,避免越权
- CLI 新增 `python scripts/gaokao-shortlink revoke-report --report-id R-2026-001 --owner alice`
- 访问统计升级: 基于 `share_link_access_events` 访问事件表记录每次成功 resolve
- 保留 T7.1 的 `access_count / last_access_at`
- 新增 `unique_visitors`(按 `visitor_token` 去重)
- 新增 `daily_accesses`(按 UTC 日聚合 `access_count / unique_visitors`
- 报告级统计: `ShortLinkService.get_report_stats(report_id, owner_id=None, days=7)`
- 返回 `total_links / active_links / revoked_links / expired_links / total_access_count / unique_visitors / daily_accesses`
- CLI 新增 `python scripts/gaokao-shortlink stats-report --report-id R-2026-001 --days 7`
### 设计取舍
- 本期先做访问事件表,支撑按日趋势和访客去重;地域统计仍保持 P2待后续接入 IP 反查/风控链路时再补
- `visitor_token` 由调用方透传(如 openid / session id / cookie 指纹);未传时仍统计访问次数,但 `unique_visitors` 不会虚构数据
- 每次成功 `resolve(record_access=True)` 同时更新汇总字段与事件表,兼顾现有接口兼容性和后续统计扩展
### 验证
- `python3 -m pytest data/share/tests/test_short_link.py -q` → 27 passed
- 覆盖新增用例:`revoke_by_report` owner 隔离、报告级批量撤销、按日趋势聚合、访客去重、报告级汇总统计
---
## T7.5 分享页 WebUI ✅
### 路由
```
GET /s/{code} 公开分享页 (权限受控)
GET /s/{code}?pwd=xxx 带密码参数
```
### UI 草案 (响应式)
```
┌─────────────────────────────────┐
│ 高考志愿填报方案 [龙老师] │
├─────────────────────────────────┤
│ 考生: 张** (脱敏 by permission)│
│ 分数: 578 位次: 12,345 │
├─────────────────────────────────┤
│ 推荐院校 TOP3 │
│ 1. 江西财经大学 - 会计学 35% │
│ 2. ... │
├─────────────────────────────────┤
│ 扫码查看完整方案 │
│ [二维码] │
└─────────────────────────────────┘
```
### 本次落地
- **公开分享页路由**`admin/routes/ui.py::share_page`
- `GET /s/{code}` / `GET /s/{code}?pwd=...`
- 通过 `route_short_link_with_report(...)` 统一接入 T7.1/T7.3/T7.4 短链、权限、密码、撤销与统计体系
- **分享页渲染**`admin/share_page.py`
- `render_share_page(...)`:按 `ok/not_found/revoked/expired/password_required/password_wrong` 渲染不同页面
- `ok` 页展示标题、考生信息、分数/位次、推荐院校 TOP3、分享说明与复制链接按钮
- 移动端优先,带 viewport meta 与响应式布局
- **正式分享入口联动**
- 后台 `POST /api/share-link` / `GET /api/share-link/latest` / `POST /api/share-link/{code}/revoke`
- Portal 分享状态区与正式短链治理链路已统一接入
### 验证
- `python3 -m pytest admin/tests/test_share_ui.py -q` → 分享页 UI / 密码流 / 状态页覆盖通过
- `python3 -m pytest admin/tests/test_share_link_api.py admin/tests/test_share_link_frontend.py admin/tests/test_share_status_panel.py -q` → 正式分享链接 API、前端入口、状态区回归通过
- 分享相关总回归(含 T7.2 新增测试)→ **93 passed**
---
## 时间表 (来自 IMPLEMENTATION_PLAN_v2.md §6)
| 日期 | 任务 |
| --------- | -------------- |
| 7/10 (五) | T7.1 ✅ + T7.2 |
| 7/11 (六) | T7.3 + T7.4 |
| 7/12 (日) | T7.5 |
T7.1 已于 2026-06-12 提前完成T7.2/T7.5 现已补齐并完成回归验证。
---
## 验收
- [x] 短码生成 (base62, 6位, 加密随机)
- [x] SQLite 持久化 (WAL 模式, 自动建表)
- [x] CRUD: create / get / resolve / revoke
- [x] 访问控制: 权限 / 有效期 / 密码
- [x] 访问统计: 次数 / 时间
- [x] CLI: create / resolve / revoke / list / stats / purge
- [x] 路由辅助: `route_short_link(code, ...)` 可挂载到任意 Web 框架
- [x] 测试: 短链 / 权限 / 分享页 / 正式分享入口回归通过
- [x] 海报生成 (T7.2)
- [x] UI / Web 路由 (T7.5)