Files
gaokao-volunteer-system/data/share/short_link.py
Hermes Agent ca480ebf84
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
chore(quality): close T5.5 coverage gate
2026-06-13 22:31:40 +08:00

1003 lines
33 KiB
Python
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.1)
提供:
- 短码生成 (base62, 默认 6 位)
- SQLite 映射表 (短码 → 报告元数据)
- 访问控制 (有效期 / 密码 / 权限)
- 访问统计 (次数 / 时间)
URL 模式:
/s/{code} → 分享短链接 (由 Web 路由 /s/<code> 调用 resolve())
/s/ABC123 → 短码示例
依赖:
仅 Python 3.8+ 标准库 (sqlite3, hashlib, secrets)
"""
import hashlib
import hmac
import os
import secrets
import sqlite3
import string
import time
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, List, Optional
# ---------------------------------------------------------------------------
# 常量
# ---------------------------------------------------------------------------
# base62 字母表 (0-9, A-Z, a-z) — URL-safe, 无需 urlencode
BASE62_ALPHABET = string.digits + string.ascii_uppercase + string.ascii_lowercase
BASE62_LEN = len(BASE62_ALPHABET) # 62
# 短码默认长度 (6 位 = 56B 空间, 实际使用远小于该值, 碰撞概率极低)
DEFAULT_CODE_LEN = 6
PBKDF2_SALT_BYTES = 16
PBKDF2_HASH_BYTES = 32
PBKDF2_ITERATIONS = 200_000
PBKDF2_SEPARATOR = "$"
# 默认数据库路径
DEFAULT_DB_PATH = Path(__file__).resolve().parent / "short_links.db"
# 权限级别
PERM_READ = "read"
PERM_COMMENT = "comment"
PERM_EDIT = "edit"
PERM_ADMIN = "admin"
VALID_PERMISSIONS = {PERM_READ, PERM_COMMENT, PERM_EDIT, PERM_ADMIN}
# 状态常量
STATUS_OK = "ok"
STATUS_NOT_FOUND = "not_found"
STATUS_REVOKED = "revoked"
STATUS_EXPIRED = "expired"
STATUS_PASSWORD_REQUIRED = "password_required"
STATUS_PASSWORD_WRONG = "password_wrong"
# ---------------------------------------------------------------------------
# 数据类
# ---------------------------------------------------------------------------
@dataclass
class ShareLink:
"""分享链接记录"""
code: str
report_id: str
owner_id: str = "anonymous"
permission: str = PERM_COMMENT
password_hash: Optional[str] = (
None # pbkdf2: salt_hex$digest_hex兼容历史 sha256 hex
)
expires_at: Optional[float] = None # unix timestamp
revoked: int = 0
access_count: int = 0
last_access_at: Optional[float] = None
created_at: float = field(default_factory=time.time)
note: Optional[str] = None
def is_expired(self, now: Optional[float] = None) -> bool:
if self.expires_at is None:
return False
return (now or time.time()) >= self.expires_at
def is_active(self) -> bool:
return self.revoked == 0 and not self.is_expired()
def to_dict(self) -> dict:
d = asdict(self)
d["created_at_iso"] = _iso(self.created_at)
if self.expires_at is not None:
d["expires_at_iso"] = _iso(self.expires_at)
else:
d["expires_at_iso"] = None
if self.last_access_at is not None:
d["last_access_at_iso"] = _iso(self.last_access_at)
else:
d["last_access_at_iso"] = None
return d
@dataclass
class ResolveResult:
"""resolve() 的返回结果"""
status: str
code: str
link: Optional[ShareLink] = None
reason: str = ""
@property
def ok(self) -> bool:
return self.status == STATUS_OK
def to_dict(self) -> dict[str, Any]:
d: dict[str, Any] = {
"status": self.status,
"code": self.code,
"reason": self.reason,
}
if self.link is not None:
d["link"] = self.link.to_dict()
return d
# ---------------------------------------------------------------------------
# base62 编解码
# ---------------------------------------------------------------------------
def base62_encode(num: int) -> str:
"""
把非负整数编码为 base62 字符串
例: 0 -> "0", 61 -> "Z", 62 -> "10", 1234567 -> "3Gtv"
"""
if num < 0:
raise ValueError("num must be >= 0")
if num == 0:
return BASE62_ALPHABET[0]
parts = []
while num > 0:
num, rem = divmod(num, BASE62_LEN)
parts.append(BASE62_ALPHABET[rem])
return "".join(reversed(parts))
def base62_decode(s: str) -> int:
"""把 base62 字符串解码为整数"""
if not s:
raise ValueError("empty string")
n = 0
for ch in s:
idx = BASE62_ALPHABET.find(ch)
if idx < 0:
raise ValueError(f"invalid base62 char: {ch!r}")
n = n * BASE62_LEN + idx
return n
def generate_code(length: int = DEFAULT_CODE_LEN) -> str:
"""
用加密安全的随机数生成短码
注: 大批量时实际碰撞概率可用生日悖论估算
(N=10M, length=6, p≈4e-7), 失败后由调用方重试
"""
if length < 4 or length > 16:
raise ValueError("length must be in [4, 16]")
return "".join(secrets.choice(BASE62_ALPHABET) for _ in range(length))
# ---------------------------------------------------------------------------
# 内部工具
# ---------------------------------------------------------------------------
def _iso(ts: float) -> str:
"""unix timestamp -> ISO8601 (UTC)"""
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
def _now() -> float:
return time.time()
def _hash_password(password: str) -> str:
"""密码哈希: pbkdf2_hmac(sha256) + 16-byte salt格式为 salt_hex$digest_hex。"""
if not password:
raise ValueError("password must be non-empty")
salt = secrets.token_bytes(PBKDF2_SALT_BYTES)
digest = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt,
PBKDF2_ITERATIONS,
dklen=PBKDF2_HASH_BYTES,
)
return f"{salt.hex()}{PBKDF2_SEPARATOR}{digest.hex()}"
def _is_legacy_sha256_hash(password_hash: str) -> bool:
return len(password_hash) == 64 and PBKDF2_SEPARATOR not in password_hash
def _verify_password(password: str, password_hash: str) -> bool:
if not password:
return False
if _is_legacy_sha256_hash(password_hash):
legacy = hashlib.sha256(password.encode("utf-8")).hexdigest()
return hmac.compare_digest(legacy, password_hash)
salt_hex, _, digest_hex = password_hash.partition(PBKDF2_SEPARATOR)
if not salt_hex or not digest_hex:
return False
try:
salt = bytes.fromhex(salt_hex)
expected = bytes.fromhex(digest_hex)
except ValueError:
return False
candidate = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt,
PBKDF2_ITERATIONS,
dklen=PBKDF2_HASH_BYTES,
)
return hmac.compare_digest(candidate, expected)
def _row_to_link(row: sqlite3.Row) -> ShareLink:
return ShareLink(
code=row["code"],
report_id=row["report_id"],
owner_id=row["owner_id"],
permission=row["permission"],
password_hash=row["password_hash"],
expires_at=row["expires_at"],
revoked=row["revoked"],
access_count=row["access_count"],
last_access_at=row["last_access_at"],
created_at=row["created_at"],
note=row["note"],
)
# ---------------------------------------------------------------------------
# 短链接服务
# ---------------------------------------------------------------------------
class ShortLinkService:
"""
短链接服务, 对应 /s/{code} 路由
用法:
svc = ShortLinkService() # 默认 DB 路径
link = svc.create(report_id="R-2026-001",
permission="read",
ttl_days=7)
print(link.code) # "aB3xY7"
print(svc.url(link.code, base="https://gk.example.com"))
# -> "https://gk.example.com/s/aB3xY7"
result = svc.resolve(link.code)
assert result.ok
assert result.link.report_id == "R-2026-001"
"""
SCHEMA = """
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,
expires_at REAL,
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);
CREATE TABLE IF NOT EXISTS share_link_access_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL,
accessed_at REAL NOT NULL,
visitor_token TEXT,
ip TEXT,
user_agent TEXT,
FOREIGN KEY(code) REFERENCES share_links(code) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_share_link_access_events_code_time
ON share_link_access_events(code, accessed_at);
CREATE INDEX IF NOT EXISTS idx_share_link_access_events_visitor
ON share_link_access_events(visitor_token);
"""
def __init__(self, db_path: str | os.PathLike[str] | None = None):
self.db_path = Path(db_path) if db_path else DEFAULT_DB_PATH
self.db_path.parent.mkdir(parents=True, exist_ok=True)
# 第一次实例化即建表 (亦可显式调用 init_schema)
self.init_schema()
# ---- 生命周期 ----
def init_schema(self) -> None:
with self._connect() as conn:
conn.executescript(self.SCHEMA)
conn.commit()
def _connect(self) -> sqlite3.Connection:
# in-memory DB 需要 shared cache 才能在多次 connect 中共享
if str(self.db_path) == ":memory:":
conn = sqlite3.connect(
"file::memory:?cache=shared",
timeout=10.0,
isolation_level=None,
uri=True,
)
else:
conn = sqlite3.connect(
str(self.db_path), timeout=10.0, isolation_level=None
)
conn.row_factory = sqlite3.Row
if str(self.db_path) != ":memory:":
conn.execute("PRAGMA journal_mode = WAL;")
conn.execute("PRAGMA foreign_keys = ON;")
return conn
# ---- 创建 ----
def create(
self,
report_id: str,
owner_id: str = "anonymous",
permission: str = PERM_COMMENT,
password: Optional[str] = None,
ttl_seconds: Optional[int] = None,
ttl_days: Optional[int] = None,
code_length: int = DEFAULT_CODE_LEN,
max_retries: int = 8,
note: Optional[str] = None,
) -> ShareLink:
"""
创建一条分享链接
参数:
report_id 关联的报告 ID (T1 / T2 / T3 报告)
owner_id 创建者 ID
permission read / comment / edit / admin
password 可选访问密码
ttl_seconds / ttl_days 二选一, None 表示永不过期
code_length 短码长度 (4-16)
max_retries 碰撞重试次数
note 备注
"""
if not report_id:
raise ValueError("report_id is required")
if permission not in VALID_PERMISSIONS:
raise ValueError(f"permission must be one of {sorted(VALID_PERMISSIONS)}")
if ttl_seconds is not None and ttl_days is not None:
raise ValueError("ttl_seconds 与 ttl_days 不能同时指定")
if ttl_seconds is not None and ttl_seconds <= 0:
raise ValueError("ttl_seconds must be > 0")
if ttl_days is not None and ttl_days <= 0:
raise ValueError("ttl_days must be > 0")
expires_at = None
if ttl_seconds is not None:
expires_at = _now() + ttl_seconds
elif ttl_days is not None:
expires_at = _now() + ttl_days * 86400
password_hash = _hash_password(password) if password else None
last_err: Optional[Exception] = None
for _ in range(max_retries):
code = generate_code(code_length)
try:
with self._connect() as conn:
conn.execute(
"""
INSERT INTO share_links(
code, report_id, owner_id, permission,
password_hash, expires_at, revoked,
access_count, last_access_at, created_at, note
) VALUES (?, ?, ?, ?, ?, ?, 0, 0, NULL, ?, ?)
""",
(
code,
report_id,
owner_id,
permission,
password_hash,
expires_at,
_now(),
note,
),
)
created = self.get(code)
if created is None:
raise RuntimeError(f"short link created but lookup failed: {code}")
return created
except sqlite3.IntegrityError as e:
# 唯一冲突: 碰撞, 重试
last_err = e
continue
raise RuntimeError(
f"无法生成唯一短码 (length={code_length}, retries={max_retries}): {last_err}"
)
# ---- 查询 ----
def get(self, code: str) -> Optional[ShareLink]:
if not code:
return None
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM share_links WHERE code = ?", (code,)
).fetchone()
return _row_to_link(row) if row else None
def resolve(
self,
code: str,
password: Optional[str] = None,
record_access: bool = True,
visitor_token: Optional[str] = None,
ip: Optional[str] = None,
user_agent: Optional[str] = None,
) -> ResolveResult:
"""
解析短码 -> (status, link, reason)
状态:
ok / not_found / revoked / expired /
password_required / password_wrong
校验顺序 (按 HTTP 语义):
1. 存在?
2. 已撤销?
3. 已过期?
4. 需要密码?
5. 密码正确?
"""
link = self.get(code)
if link is None:
return ResolveResult(
status=STATUS_NOT_FOUND, code=code, reason="code not found"
)
if link.revoked != 0:
return ResolveResult(
status=STATUS_REVOKED, code=code, link=link, reason="revoked"
)
if link.is_expired():
return ResolveResult(
status=STATUS_EXPIRED, code=code, link=link, reason="expired"
)
if link.password_hash is not None:
if not password:
return ResolveResult(
status=STATUS_PASSWORD_REQUIRED,
code=code,
link=link,
reason="password required",
)
if not _verify_password(password, link.password_hash):
return ResolveResult(
status=STATUS_PASSWORD_WRONG,
code=code,
link=link,
reason="wrong password",
)
if _is_legacy_sha256_hash(link.password_hash):
upgraded_hash = _hash_password(password)
with self._connect() as conn:
conn.execute(
"UPDATE share_links SET password_hash = ? WHERE code = ?",
(upgraded_hash, link.code),
)
link.password_hash = upgraded_hash
if record_access:
self._bump_access(
link.code,
visitor_token=visitor_token,
ip=ip,
user_agent=user_agent,
)
link.access_count += 1
link.last_access_at = _now()
return ResolveResult(status=STATUS_OK, code=code, link=link, reason="ok")
def _bump_access(
self,
code: str,
*,
visitor_token: Optional[str] = None,
ip: Optional[str] = None,
user_agent: Optional[str] = None,
) -> None:
accessed_at = _now()
with self._connect() as conn:
conn.execute(
"""
UPDATE share_links
SET access_count = access_count + 1,
last_access_at = ?
WHERE code = ?
""",
(accessed_at, code),
)
conn.execute(
"""
INSERT INTO share_link_access_events(
code, accessed_at, visitor_token, ip, user_agent
) VALUES (?, ?, ?, ?, ?)
""",
(code, accessed_at, visitor_token, ip, user_agent),
)
# ---- 撤销 ----
def revoke(self, code: str, owner_id: Optional[str] = None) -> bool:
"""
撤销一条链接
如果指定 owner_id, 只有 owner 匹配时才能撤销 (防止越权)
返回: 是否从「未撤销」变为「已撤销」(True/False),
重复撤销 / 不存在的 code 均返回 False
"""
with self._connect() as conn:
if owner_id is not None:
cur = conn.execute(
"UPDATE share_links SET revoked = 1 "
"WHERE code = ? AND owner_id = ? AND revoked = 0",
(code, owner_id),
)
else:
cur = conn.execute(
"UPDATE share_links SET revoked = 1 WHERE code = ? AND revoked = 0",
(code,),
)
return cur.rowcount > 0
def revoke_by_report(self, report_id: str, owner_id: Optional[str] = None) -> int:
"""按 report 批量撤销分享链接;可选 owner 约束避免越权。"""
if not report_id:
return 0
with self._connect() as conn:
if owner_id is not None:
cur = conn.execute(
"UPDATE share_links SET revoked = 1 "
"WHERE report_id = ? AND owner_id = ? AND revoked = 0",
(report_id, owner_id),
)
else:
cur = conn.execute(
"UPDATE share_links SET revoked = 1 "
"WHERE report_id = ? AND revoked = 0",
(report_id,),
)
return cur.rowcount
# ---- 列表 / 统计 ----
def list_by_report(self, report_id: str) -> List[ShareLink]:
with self._connect() as conn:
rows = conn.execute(
"SELECT * FROM share_links WHERE report_id = ? ORDER BY created_at DESC",
(report_id,),
).fetchall()
return [_row_to_link(r) for r in rows]
def list_by_owner(self, owner_id: str, limit: int = 100) -> List[ShareLink]:
with self._connect() as conn:
rows = conn.execute(
"SELECT * FROM share_links WHERE owner_id = ? "
"ORDER BY created_at DESC LIMIT ?",
(owner_id, limit),
).fetchall()
return [_row_to_link(r) for r in rows]
def get_stats(self, code: str, days: int = 7) -> Optional[dict]:
link = self.get(code)
if link is None:
return None
return {
"code": link.code,
"report_id": link.report_id,
"access_count": link.access_count,
"last_access_at": link.last_access_at,
"last_access_at_iso": _iso(link.last_access_at)
if link.last_access_at
else None,
"revoked": bool(link.revoked),
"expired": link.is_expired(),
"created_at_iso": _iso(link.created_at),
"expires_at_iso": _iso(link.expires_at) if link.expires_at else None,
"unique_visitors": self._count_unique_visitors(code=link.code),
"daily_accesses": self._daily_access_stats(code=link.code, days=days),
}
def _count_unique_visitors(
self,
*,
code: Optional[str] = None,
report_id: Optional[str] = None,
owner_id: Optional[str] = None,
) -> int:
clauses = ["e.visitor_token IS NOT NULL", "e.visitor_token != ''"]
params: list = []
join = ""
if code is not None:
clauses.append("e.code = ?")
params.append(code)
else:
join = " JOIN share_links l ON l.code = e.code"
if report_id is not None:
clauses.append("l.report_id = ?")
params.append(report_id)
if owner_id is not None:
clauses.append("l.owner_id = ?")
params.append(owner_id)
query = (
"SELECT COUNT(DISTINCT e.visitor_token) AS n "
"FROM share_link_access_events e"
f"{join} WHERE {' AND '.join(clauses)}"
)
with self._connect() as conn:
row = conn.execute(query, params).fetchone()
return int(row["n"] if row else 0)
def _daily_access_stats(
self,
*,
code: Optional[str] = None,
report_id: Optional[str] = None,
owner_id: Optional[str] = None,
days: int = 7,
) -> List[dict]:
if days <= 0:
raise ValueError("days must be > 0")
params: list = []
if code is not None:
query = """
SELECT
DATE(accessed_at, 'unixepoch') AS date,
COUNT(*) AS access_count,
COUNT(DISTINCT CASE
WHEN visitor_token IS NOT NULL AND visitor_token != ''
THEN visitor_token
END) AS unique_visitors
FROM share_link_access_events
WHERE code = ?
GROUP BY DATE(accessed_at, 'unixepoch')
ORDER BY DATE(accessed_at, 'unixepoch') ASC
"""
params.append(code)
else:
query = """
SELECT
DATE(e.accessed_at, 'unixepoch') AS date,
COUNT(*) AS access_count,
COUNT(DISTINCT CASE
WHEN e.visitor_token IS NOT NULL AND e.visitor_token != ''
THEN e.visitor_token
END) AS unique_visitors
FROM share_link_access_events e
JOIN share_links l ON l.code = e.code
WHERE l.report_id = ?
"""
params.append(report_id)
if owner_id is not None:
query += " AND l.owner_id = ?"
params.append(owner_id)
query += (
" GROUP BY DATE(e.accessed_at, 'unixepoch')"
" ORDER BY DATE(e.accessed_at, 'unixepoch') ASC"
)
with self._connect() as conn:
rows = conn.execute(query, params).fetchall()
daily = [
{
"date": row["date"],
"access_count": int(row["access_count"]),
"unique_visitors": int(row["unique_visitors"]),
}
for row in rows
]
return daily[-days:]
def get_report_stats(
self,
report_id: str,
owner_id: Optional[str] = None,
days: int = 7,
) -> dict:
if not report_id:
raise ValueError("report_id is required")
if owner_id is None:
links = self.list_by_report(report_id)
else:
links = [
link
for link in self.list_by_report(report_id)
if link.owner_id == owner_id
]
last_access_at = max(
(link.last_access_at for link in links if link.last_access_at is not None),
default=None,
)
return {
"report_id": report_id,
"owner_id": owner_id,
"total_links": len(links),
"active_links": sum(1 for link in links if link.is_active()),
"revoked_links": sum(1 for link in links if link.revoked != 0),
"expired_links": sum(1 for link in links if link.is_expired()),
"total_access_count": sum(link.access_count for link in links),
"unique_visitors": self._count_unique_visitors(
report_id=report_id,
owner_id=owner_id,
),
"last_access_at": last_access_at,
"last_access_at_iso": _iso(last_access_at) if last_access_at else None,
"daily_accesses": self._daily_access_stats(
report_id=report_id,
owner_id=owner_id,
days=days,
),
}
# ---- 维护 ----
def purge_expired(self) -> int:
"""清理过期记录 (T7.1 不强制, 仅供维护)"""
with self._connect() as conn:
cur = conn.execute(
"DELETE FROM share_links WHERE expires_at IS NOT NULL AND expires_at < ?",
(_now(),),
)
return cur.rowcount
def count(self, owner_id: Optional[str] = None) -> int:
with self._connect() as conn:
if owner_id is None:
row = conn.execute("SELECT COUNT(*) AS n FROM share_links").fetchone()
else:
row = conn.execute(
"SELECT COUNT(*) AS n FROM share_links WHERE owner_id = ?",
(owner_id,),
).fetchone()
return int(row["n"])
# ---------------------------------------------------------------------------
# 路由辅助 (供 Web 框架 / T7.5 分享页接入)
# ---------------------------------------------------------------------------
def _route_with_svc(
svc: ShortLinkService,
code: str,
password: Optional[str] = None,
base_url: str = "http://localhost:8000",
) -> dict:
"""route_short_link 的核心逻辑, 接受外部 svc (用于 in-memory 测试)"""
res = svc.resolve(code, password=password, record_access=True)
out: dict = {
"code": code,
"status": res.status,
"reason": res.reason,
"url": f"{base_url.rstrip('/')}/s/{code}",
}
if res.ok and res.link is not None:
out["report_id"] = res.link.report_id
out["permission"] = res.link.permission
out["owner_id"] = res.link.owner_id
out["access_count"] = res.link.access_count
return out
def route_short_link(
code: str,
password: Optional[str] = None,
base_url: str = "http://localhost:8000",
db_path: Optional[os.PathLike] = None,
) -> dict:
"""
模拟 /s/{code} 路由的入口
返回 JSON-friendly dict, 包含 redirect URL 或错误信息
用法 (Flask 示意):
@app.route("/s/<code>")
def short_link(code):
return jsonify(route_short_link(code, request.args.get("pwd")))
参数:
code 短码 (URL 路径变量)
password 访问密码 (query/body)
base_url 用于构造 /s/{code} 完整 URL
db_path 数据库路径, 默认 DEFAULT_DB_PATH; 测试可覆盖
"""
svc = ShortLinkService(db_path=db_path)
return _route_with_svc(svc, code, password=password, base_url=base_url)
def build_url(code: str, base: str = "http://localhost:8000") -> str:
"""生成 /s/{code} 完整 URL"""
return f"{base.rstrip('/')}/s/{code}"
# ---------------------------------------------------------------------------
# T7.3 权限感知路由: 在 resolve() 之上叠加 report payload 渲染
# ---------------------------------------------------------------------------
# 之所以不放进 route_short_link, 是为了:
# 1. 不破坏 T7.1 / T7.2 已落地的 CLI 脚本与单测签名;
# 2. 报告获取通常需要读盘 / 调 T2/T3 服务, 不在短链接服务职责内,
# 通过 report_loader 回调注入, 让本层保持零业务耦合。
# ---------------------------------------------------------------------------
def route_short_link_with_report(
code: str,
password: Optional[str] = None,
base_url: str = "http://localhost:8000",
db_path: Optional[os.PathLike] = None,
*,
report_loader=None,
report: Optional[dict] = None,
include_url: bool = True,
) -> dict:
"""
在 route_short_link 之上叠加 T7.3 权限感知渲染。
用法 (Web 路由示意):
@app.route("/s/<code>")
def short_link(code):
def loader(report_id):
return load_report_from_storage(report_id)
return jsonify(route_short_link_with_report(
code,
password=request.args.get("pwd"),
base_url=request.host_url.rstrip("/"),
report_loader=loader,
))
参数:
code 短码
password 访问密码 (query/body)
base_url 构造 /s/{code} 完整 URL
db_path SQLite 路径, 默认 DEFAULT_DB_PATH
report_loader 可选 callable(report_id) -> dict | None
用于按 report_id 拉取原始报告数据
report 可选直接传入报告 dict (优先级高于 report_loader)
include_url 是否在 payload 中附 share_url
返回 dict (兼容 T7.1 shape + 扩展字段):
{
...route_short_link() 原样字段 (status, code, reason, url, ...)...
"rendered": { # T7.3 新增
"permission": "read",
"policy": {...},
"visible_fields": [...] | None,
"payload": {...}, # 字段裁剪 + 姓名脱敏后
"masked_fields": [...]
}
}
当 resolve() 失败 (not_found / revoked / expired / password_*) 时,
rendered 不会被计算 — 让 Web 层直接根据 status 返回 401/403/404/410。
"""
# 延迟 import 避免循环 (permission 模块会 import orders.masking)
from data.share.permission import render_report_payload
base = route_short_link(
code,
password=password,
base_url=base_url,
db_path=db_path,
)
# resolve 失败 -> 不渲染 payload, 行为对齐"未授权禁止"原则
if base.get("status") != STATUS_OK:
return base
report_id = base.get("report_id")
perm = base.get("permission", "read")
if report is not None:
report_data = report
elif report_loader is not None and report_id is not None:
try:
report_data = report_loader(report_id)
except Exception:
report_data = None
else:
report_data = None
share_url = base.get("url") if include_url else None
base["rendered"] = render_report_payload(perm, report_data, share_url=share_url)
return base
# ---------------------------------------------------------------------------
# CLI 直接调用 (python -m data.share.short_link ...) 用作冒烟
# ---------------------------------------------------------------------------
def _self_test() -> None: # pragma: no cover - 仅 CLI 触发
svc = ShortLinkService(db_path=":memory:")
print("=== short_link self test ===")
# 1. create
link = svc.create(
report_id="R-2026-001",
owner_id="alice",
permission="read",
ttl_days=7,
)
print(f"created: code={link.code} report_id={link.report_id}")
assert len(link.code) == DEFAULT_CODE_LEN
assert all(c in BASE62_ALPHABET for c in link.code)
# 2. resolve (ok)
res = svc.resolve(link.code)
assert res.ok, f"unexpected status: {res.status}"
assert res.link is not None
print(f"resolve ok: access_count={res.link.access_count}")
# 3. revoke
assert svc.revoke(link.code, owner_id="alice") is True
res = svc.resolve(link.code)
assert res.status == STATUS_REVOKED
print("revoke works")
# 4. password
link2 = svc.create(
report_id="R-2026-002",
owner_id="bob",
permission="edit",
password="s3cr3t",
)
res = svc.resolve(link2.code, password=None)
assert res.status == STATUS_PASSWORD_REQUIRED, res.status
res = svc.resolve(link2.code, password="wrong")
assert res.status == STATUS_PASSWORD_WRONG
res = svc.resolve(link2.code, password="s3cr3t")
assert res.ok
print("password works")
# 5. expire
link3 = svc.create(
report_id="R-2026-003",
owner_id="carol",
permission="read",
ttl_seconds=1,
)
time.sleep(1.2)
res = svc.resolve(link3.code)
assert res.status == STATUS_EXPIRED
print("expire works")
# 6. base62 codec sanity
for n in [0, 1, 61, 62, 62**6 - 1, 62**6]:
s = base62_encode(n)
assert base62_decode(s) == n, (n, s)
print("base62 codec works")
# 7. route helper (复用同一 in-memory svc, 避免创建新连接)
out = _route_with_svc(
svc, link2.code, password="s3cr3t", base_url="https://gk.example.com"
)
assert out["status"] == "ok", out
assert out["url"] == "https://gk.example.com/s/" + link2.code
print(f"route helper works: {out['url']}")
print("=== all self tests passed ===")
if __name__ == "__main__": # pragma: no cover
_self_test()