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

15 KiB
Raw Blame History

T7: 分享功能 MVP — 实施计划

版本: v1.0 状态: 已完成 (T7.1/T7.2/T7.3/T7.4/T7.5 已完成) 关联文档:


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)

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

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

# 创建
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 框架

# 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 -q3 个单测通过
  • python3 -m pytest tests/test_poster_cli.py -q2 个 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

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 -q34 passed
  • python3 -m pytest data/share/tests/ -q61 passed
  • python3 -m pytest data/share/ data/orders/ -q231 passed
  • python3 -m ruff check data/share/permission.py data/share/tests/test_permission.py data/share/short_link.pyAll 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 现已补齐并完成回归验证。


验收

  • 短码生成 (base62, 6位, 加密随机)
  • SQLite 持久化 (WAL 模式, 自动建表)
  • CRUD: create / get / resolve / revoke
  • 访问控制: 权限 / 有效期 / 密码
  • 访问统计: 次数 / 时间
  • CLI: create / resolve / revoke / list / stats / purge
  • 路由辅助: route_short_link(code, ...) 可挂载到任意 Web 框架
  • 测试: 短链 / 权限 / 分享页 / 正式分享入口回归通过
  • 海报生成 (T7.2)
  • UI / Web 路由 (T7.5)