387 lines
15 KiB
Markdown
387 lines
15 KiB
Markdown
# 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-SHA256(16B 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)
|