Files
gaokao-volunteer-system/scripts/gaokao-shortlink
Hermes Agent 3f46811e49
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
release: cut v2.1
2026-06-13 14:49:58 +08:00

311 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
"""
gaokao-shortlink — T7.1 短链接生成器命令行工具
子命令:
create 创建短链接
resolve 解析短链接 (查询/校验)
revoke 撤销单条短链接
revoke-report 按 report 批量撤销短链接
list 列出某报告 / 某用户的所有链接
stats 查看某链接的访问统计
stats-report 查看某报告的访问统计
purge 清理过期记录 (维护)
示例:
# 创建: 关联报告 R-2026-001, 30 天有效, 只读权限
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 --permission comment \\
--password s3cr3t --ttl-days 7
# 解析
python scripts/gaokao-shortlink resolve ABC123
python scripts/gaokao-shortlink resolve ABC123 --password s3cr3t
# 撤销
python scripts/gaokao-shortlink revoke ABC123 --owner alice
python scripts/gaokao-shortlink revoke-report --report-id R-2026-001 --owner alice
# 列表
python scripts/gaokao-shortlink list --report R-2026-001
python scripts/gaokao-shortlink list --owner alice
# 统计
python scripts/gaokao-shortlink stats ABC123 --days 7
python scripts/gaokao-shortlink stats-report --report-id R-2026-001 --days 7
"""
import argparse
import json
import os
import sys
from pathlib import Path
# 把项目根目录加入 sys.path
PROJ_ROOT = Path(__file__).resolve().parent.parent
if str(PROJ_ROOT) not in sys.path:
sys.path.insert(0, str(PROJ_ROOT))
from data.share.short_link import ( # noqa: E402
DEFAULT_DB_PATH,
PERM_ADMIN,
PERM_COMMENT,
PERM_EDIT,
PERM_READ,
ShortLinkService,
STATUS_OK,
STATUS_NOT_FOUND,
STATUS_PASSWORD_REQUIRED,
STATUS_PASSWORD_WRONG,
STATUS_REVOKED,
STATUS_EXPIRED,
VALID_PERMISSIONS,
build_url,
)
def _out(data, as_json: bool = True) -> None:
"""统一输出"""
if as_json:
print(json.dumps(data, ensure_ascii=False, indent=2, default=str))
else:
# 人类可读
if isinstance(data, dict):
for k, v in data.items():
print(f"{k}: {v}")
else:
print(data)
def cmd_create(args) -> int:
"""创建短链接"""
svc = ShortLinkService(db_path=args.db)
try:
link = svc.create(
report_id=args.report_id,
owner_id=args.owner,
permission=args.permission,
password=args.password,
ttl_seconds=args.ttl_seconds,
ttl_days=args.ttl_days,
code_length=args.length,
note=args.note,
)
except (ValueError, RuntimeError) as e:
print(f"创建失败: {e}", file=sys.stderr)
return 2
out = {
"code": link.code,
"url": build_url(link.code, base=args.base_url),
"report_id": link.report_id,
"owner_id": link.owner_id,
"permission": link.permission,
"expires_at_iso": (
link.to_dict()["expires_at_iso"] if link.expires_at else None
),
"created_at_iso": link.to_dict()["created_at_iso"],
"has_password": link.password_hash is not None,
}
_out(out, as_json=not args.human)
return 0
def cmd_resolve(args) -> int:
"""解析短链接"""
svc = ShortLinkService(db_path=args.db)
res = svc.resolve(args.code, password=args.password, record_access=not args.dry_run)
payload = {
"code": res.code,
"status": res.status,
"reason": res.reason,
"url": build_url(res.code, base=args.base_url),
}
if res.link is not None:
payload["report_id"] = res.link.report_id
payload["owner_id"] = res.link.owner_id
payload["permission"] = res.link.permission
payload["access_count"] = res.link.access_count
payload["last_access_at_iso"] = (
res.link.to_dict()["last_access_at_iso"]
if res.link.last_access_at else None
)
payload["expires_at_iso"] = (
res.link.to_dict()["expires_at_iso"]
if res.link.expires_at else None
)
_out(payload, as_json=not args.human)
return 0 if res.status == STATUS_OK else 1
def cmd_revoke(args) -> int:
"""撤销短链接"""
svc = ShortLinkService(db_path=args.db)
ok = svc.revoke(args.code, owner_id=args.owner)
if not ok:
print(
f"撤销失败: code={args.code} 不存在或 owner 不匹配",
file=sys.stderr,
)
return 1
_out({"code": args.code, "revoked": True, "owner": args.owner},
as_json=not args.human)
return 0
def cmd_revoke_report(args) -> int:
"""按 report 批量撤销短链接"""
svc = ShortLinkService(db_path=args.db)
revoked = svc.revoke_by_report(args.report_id, owner_id=args.owner)
payload = {
"report_id": args.report_id,
"owner_id": args.owner,
"revoked_count": revoked,
}
_out(payload, as_json=not args.human)
return 0 if revoked > 0 else 1
def cmd_list(args) -> int:
"""列出某报告 / 某用户的链接"""
if not args.report and not args.owner:
print("必须指定 --report 或 --owner", file=sys.stderr)
return 2
svc = ShortLinkService(db_path=args.db)
if args.report:
links = svc.list_by_report(args.report)
else:
links = svc.list_by_owner(args.owner, limit=args.limit)
rows = []
for link in links:
d = link.to_dict()
d["active"] = link.is_active()
rows.append(d)
_out({"count": len(rows), "links": rows}, as_json=not args.human)
return 0
def cmd_stats(args) -> int:
"""查看某链接访问统计"""
svc = ShortLinkService(db_path=args.db)
stats = svc.get_stats(args.code, days=args.days)
if stats is None:
print(f"未找到 code={args.code}", file=sys.stderr)
return 1
_out(stats, as_json=not args.human)
return 0
def cmd_stats_report(args) -> int:
"""查看某报告的分享访问统计"""
svc = ShortLinkService(db_path=args.db)
stats = svc.get_report_stats(args.report_id, owner_id=args.owner, days=args.days)
_out(stats, as_json=not args.human)
return 0
def cmd_purge(args) -> int:
"""清理过期记录"""
svc = ShortLinkService(db_path=args.db)
n = svc.purge_expired()
_out({"purged": n}, as_json=not args.human)
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="gaokao-shortlink",
description="T7.1 短链接生成器 (base62 + SQLite)",
)
p.add_argument(
"--db",
default=str(DEFAULT_DB_PATH),
help=f"SQLite 数据库路径 (默认: {DEFAULT_DB_PATH})",
)
p.add_argument(
"--base-url",
default="http://localhost:8000",
help="用于构造 /s/{code} 完整 URL (默认 http://localhost:8000)",
)
p.add_argument(
"--human", action="store_true",
help="人类可读输出 (默认 JSON)",
)
sub = p.add_subparsers(dest="cmd", required=True)
# ---- create ----
pc = sub.add_parser("create", help="创建一条短链接")
pc.add_argument("--report-id", required=True, help="关联报告 ID")
pc.add_argument("--owner", default="anonymous", help="创建者 ID")
pc.add_argument(
"--permission", default=PERM_COMMENT, choices=sorted(VALID_PERMISSIONS),
help=f"权限 (默认 {PERM_COMMENT}; {', '.join(sorted(VALID_PERMISSIONS))})",
)
pc.add_argument("--password", help="访问密码 (可选)")
pc.add_argument("--ttl-seconds", type=int, help="有效期(秒), 与 --ttl-days 互斥")
pc.add_argument("--ttl-days", type=int, help="有效期(天), 与 --ttl-seconds 互斥")
pc.add_argument("--length", type=int, default=6, help="短码长度 (4-16, 默认 6)")
pc.add_argument("--note", help="备注")
pc.set_defaults(func=cmd_create)
# ---- resolve ----
pr = sub.add_parser("resolve", help="解析短链接")
pr.add_argument("code", help="短码, 如 ABC123")
pr.add_argument("--password", help="访问密码")
pr.add_argument(
"--dry-run", action="store_true",
help="不记录访问 (查询但不计数)",
)
pr.set_defaults(func=cmd_resolve)
# ---- revoke ----
px = sub.add_parser("revoke", help="撤销短链接")
px.add_argument("code", help="短码")
px.add_argument("--owner", help="owner 校验 (可选, 不传则不强校)")
px.set_defaults(func=cmd_revoke)
# ---- revoke-report ----
pxx = sub.add_parser("revoke-report", help="按 report 批量撤销短链接")
pxx.add_argument("--report-id", required=True, help="关联报告 ID")
pxx.add_argument("--owner", help="owner 校验 (可选)")
pxx.set_defaults(func=cmd_revoke_report)
# ---- list ----
pl = sub.add_parser("list", help="列出某报告/某用户的链接")
pl.add_argument("--report", help="按 report_id 过滤")
pl.add_argument("--owner", help="按 owner_id 过滤")
pl.add_argument("--limit", type=int, default=100, help="最多返回条数")
pl.set_defaults(func=cmd_list)
# ---- stats ----
ps = sub.add_parser("stats", help="查看短链接统计")
ps.add_argument("code", help="短码")
ps.add_argument("--days", type=int, default=7, help="返回最近 N 天趋势 (默认 7)")
ps.set_defaults(func=cmd_stats)
# ---- stats-report ----
psr = sub.add_parser("stats-report", help="查看报告级分享统计")
psr.add_argument("--report-id", required=True, help="关联报告 ID")
psr.add_argument("--owner", help="按 owner_id 过滤 (可选)")
psr.add_argument("--days", type=int, default=7, help="返回最近 N 天趋势 (默认 7)")
psr.set_defaults(func=cmd_stats_report)
# ---- purge ----
pp = sub.add_parser("purge", help="清理过期记录")
pp.set_defaults(func=cmd_purge)
return p
def main(argv=None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())