短链服务的主路径只有一次查表和一个 3xx 响应,难点在三个容易被忽略的边界:两个创建者同时申请同一个别名时谁的目标留下;所有者撤销一条短链之后,旧目标还会被返回多久;统计库变慢或不可用时,跳转是否跟着变慢。实现是一个 Java 21 + PostgreSQL 16 的最小服务,三个问题各自对应一组可执行的断言。

需求与范围

操作只有五种:创建随机码短链、创建自定义别名、撤销、按码访问、记录点击。过期时间在创建时写定,之后不改;目标地址创建后不可修改,需要改目标就撤销旧码再建新码。

业务不变量:

  • I1 码唯一且不覆盖。一个码最多对应一个目标;任何被告知“创建成功”的调用方,其提交的目标就是库中该码的目标。
  • I2 撤销有界生效。撤销事务提交后,返回旧目标的 302 只会在一个可计算的窗口内出现,窗口上界由缓存参数决定,并且可以测量。
  • I3 统计不阻塞跳转。统计库连接失败、表锁等待或写入超时,不改变重定向的状态码,请求线程也不等待统计侧的连接或锁。
  • I4 点击至多计一次。同一次点击因重试被写入多次时,统计表里只有一行。点击可以因队列满而丢弃,但丢弃数必须被计数。

明确排除:账号与权限、多实例部署、CDN 与浏览器缓存的实际行为、目标网站内容审查、按访客去重的独立访客统计、限流。

教学规模假设

以下数字是教学假设,用于选参数,不来自任何测量。随机码为 8 位,字母表 57 个字符(去掉 0、O、1、l、I 五个易混字符),取值空间 57⁸ ≈ 1.1×10¹⁴。假设库中已有 10⁸ 条短链,一次随机插入撞上已有码的概率约为 10⁸ / 1.1×10¹⁴ ≈ 9×10⁻⁷,连续 5 次都撞上的概率可以忽略,所以重试上限定为 5 次,超过即报错,不做无界重试。读写比假设远大于 100:1,跳转路径需要缓存;撤销假设很少发生(每秒个位数),因此允许撤销之间串行化。撤销生效窗口的目标:轮询正常时接近一个轮询周期,轮询失效时不超过缓存条目 TTL。

接口与数据模型

表 34-1:接口与错误语义

操作 成功结果 拒绝或失败
createRandom(owner, target, ttl) 新码与冲突重试次数 InvalidTarget;连续 5 次冲突抛 code space exhausted
createAlias(owner, alias, target) 别名本身 AliasTaken(SQLSTATE 23505);InvalidTarget
revoke(code) 新版本号 已撤销或不存在时返回 -1,不产生变更记录
GET /{code}、HEAD /{code} 302 + Location 404 未知码;410 已撤销或已过期;503 主库查询异常(未做实验)
其他方法 无 405

所有响应都带 Cache-Control: no-store。数据分两个库:主库存短链与变更日志,统计库只有点击表。分库是为了让 S2、S3 能单独让统计侧失效,而不碰主路径的连接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
CREATE TABLE link (
code text PRIMARY KEY CHECK (code ~ '^[A-Za-z0-9_-]{3,32}$'),
target text NOT NULL,
owner text NOT NULL,
kind text NOT NULL CHECK (kind IN ('RANDOM', 'ALIAS')),
state text NOT NULL CHECK (state IN ('ACTIVE', 'REVOKED')),
version bigint NOT NULL DEFAULT 1,
expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
revoked_at timestamptz
);
CREATE TABLE link_change (
seq bigserial PRIMARY KEY,
code text NOT NULL REFERENCES link(code),
version bigint NOT NULL,
changed_at timestamptz NOT NULL DEFAULT clock_timestamp()
);

点击表 click(click_id uuid PRIMARY KEY, code, clicked_at, ingested_at) 在统计库中。click_id 在处理重定向请求时生成,之后所有重试都携带同一个值。

状态只存 ACTIVE 与 REVOKED 两种。过期不落成状态,而是每次访问时用 expires_at 与当前时间比较得出。这一区分决定了缓存能否出错:过期时间创建后不变,缓存里的副本永远正确(过期判断用重定向进程的本地时钟,expires_at 由数据库 now() 写入,两个时钟的偏差会直接加到过期边界上;本实验是单机);撤销改变的是可变状态,缓存副本会落后于库。

图 34-1:短链状态与访问结果(只画实验覆盖的转移)

stateDiagram-v2
    [*] --> ACTIVE: INSERT 成功(主键无冲突)
    [*] --> 拒绝: 23505 别名已占用 / 目标校验失败
    ACTIVE --> REVOKED: revoke,前置条件 state='ACTIVE',version+1 并写 link_change
    REVOKED --> REVOKED: 再次 revoke,影响 0 行,返回 -1
    ACTIVE --> ACTIVE: GET 且未过期,返回 302
    ACTIVE --> ACTIVE: GET 且 now >= expires_at,返回 410
    REVOKED --> REVOKED: GET 返回 410(缓存条目被淘汰或超过 TTL 之后)

选哪个重定向状态码

RFC 9110 第 15.4 节定义了 3xx。301 与 308 表示目标资源“已被分配新的永久 URI”,302 与 307 表示“暂时位于另一个 URI,客户端应继续使用原 URI”。对短链更关键的是缓存语义:第 15.1 节列出可启发式缓存的状态码,包含 301、308、404、410,不含 302、307。RFC 9111 第 4.2.2 节允许缓存在响应没有显式过期时间时自行估算新鲜期,规范只给出约束而不规定算法;第 3 节规定,没有 max-age、Expires 等显式信息的响应,只有状态码属于可启发式缓存时才可以被存储。

推论是:返回 301 或 308 且不带缓存指令,浏览器或中间缓存可以在服务端毫不知情的情况下长期复用旧跳转,服务端撤销对这些副本无效。302 默认不可启发式缓存,但只要响应里带了 max-age,同样会被存下来。404、410 也可启发式缓存,若一个未知码先被缓存为 404,之后再被创建,客户端会继续看到 404。

表 34-2:重定向状态码比较

状态码 语义(RFC 9110) 默认可启发式缓存 撤销后旧副本 方法改写
301 永久移动 是 可能长期残留 允许 POST 改 GET
302 暂时位于别处 否 无显式指令时不残留 允许 POST 改 GET
307 暂时,保持方法 否 同 302 禁止改写
308 永久,保持方法 是 可能长期残留 禁止改写

本实现选 302,并在所有响应上发送 no-store。RFC 9111 第 5.2.2.5 节规定 no-store 对私有缓存和共享缓存都适用,缓存不得存储该响应;同一节也写明它对恶意或被攻破的缓存不构成可靠保证。因此 I2 的成立条件包括“客户端与中间节点遵守 HTTP 缓存规范”。R1 只断言服务端发出了 no-store,没有验证任何浏览器或 CDN 的行为。服务只接受 GET 与 HEAD,POST 返回 405 并按 RFC 9110 第 15.5.6 节带 Allow: GET, HEAD(R1 断言了这个头),避免 302 对 POST 的方法改写问题。

创建:唯一性来自哪一步检查

唯一性只由 link.code 主键保证。应用层不先查是否存在,因为查询与插入之间没有任何东西阻止另一个事务插入同一个码。别名创建直接插入,冲突时由数据库返回 23505:

1
2
3
4
5
6
7
8
9
10
11
12
public static Created createAlias(Connection c, String owner, String alias, String target)
throws SQLException, AliasTaken, InvalidTarget {
String t = validateTarget(target);
try {
Db.update(c, "INSERT INTO link(code, target, owner, kind, state) VALUES (?, ?, ?, 'ALIAS', 'ACTIVE')",
alias, t, owner);
return new Created(alias, 0);
} catch (SQLException e) {
if ("23505".equals(e.getSQLState())) throw new AliasTaken(alias);
throw e;
}
}

随机码用 ON CONFLICT (code) DO NOTHING,并以受影响行数判断成败:

1
2
3
4
5
6
7
8
9
10
for (int i = 0; i < MAX_TRIES; i++) {
String code = gen.get();
int n = Db.update(c, """
INSERT INTO link(code, target, owner, kind, state, expires_at)
VALUES (?, ?, ?, 'RANDOM', 'ACTIVE',
CASE WHEN ?::int IS NULL THEN NULL ELSE now() + make_interval(secs => ?::int) END)
ON CONFLICT (code) DO NOTHING""", code, t, owner, ttlSeconds, ttlSeconds);
if (n == 1) return new Created(code, collisions);
collisions++;
}

I1 的保证来自两处:主键约束在插入时检查冲突;调用方只在 n == 1 时才把码返回给用户。若把 n 的检查去掉,冲突时调用方会拿到一个属于别人的码。若把 DO NOTHING 换成 DO UPDATE,冲突时会改写别人的目标。

负向对照 NC1 构造的就是后一种(对照组使用结构相同的 link_naive 表)。16 个线程各自先查询 promo-q4 是否存在,在屏障处等齐,然后都执行 INSERT ... ON CONFLICT (code) DO UPDATE SET target = EXCLUDED.target。PostgreSQL 16 的 INSERT 文档说明 ON CONFLICT DO UPDATE 保证原子的“插入或更新”结果,这一点没有被违反,每条语句都成功了;错的是语义。事件序列如下:

图 34-2:NC1 先查后写加覆盖式 upsert 的事件序列

1
2
3
4
5
t0  线程 0..15 各自 SELECT count(*) WHERE code='promo-q4'   -> 全部读到 0
t1 CyclicBarrier 放行
t2 线程 k 执行 upsert(target=variant=k);先到者插入,后到者在冲突行上 UPDATE
t3 16 个调用全部返回成功
t4 库中只剩最后一次 UPDATE 写入的目标;另外 15 个调用方拿到的目标已被覆盖

输出原文:

1
2
CASE NC1 :: 负向对照:先查后写 + ON CONFLICT DO UPDATE(覆盖式 upsert)
note 报告成功的调用数 16,其目标已被覆盖的调用数 15

同样的 16 线程换成 createAlias(A1),结果是 1 个成功、15 个收到 AliasTaken,库中目标等于唯一成功者提交的目标。A2 用脚本化的码序列让前两次插入分别撞上已有的 Abc23456 与 promo-q4,第三次成功,冲突重试次数为 2,已有行的目标不变;码序列固定为同一个值时,第 5 次冲突后抛出 code space exhausted。A3 用 16 线程各创建 50 个随机码,返回 800 个互不相同的码,库中 800 行。

I1 的失败边界:主键必须是唯一性的唯一来源。如果为了分库而把码拆到多个没有全局唯一约束的库里,或者别名比较不区分大小写而约束区分,这条保证就失效。

重定向主路径与撤销窗口

主路径只依赖主库的 link 表,经过一个进程内缓存。条目记录目标、状态、版本、过期时刻和装入时间;条目年龄小于 TTL 时直接使用,否则回源。撤销通过两条途径进入缓存:

  1. 撤销事务把版本加一,并在同一事务里向 link_change 追加一行。重定向服务的轮询线程按 seq 递增读取变更,淘汰版本更旧的缓存条目。
  2. 轮询失效时,条目 TTL 是兜底上界:最迟一个 TTL 之后必然回源,读到 REVOKED。

于是撤销生效窗口 W 满足:轮询正常时 W ≈ 轮询周期 + 一次查询;轮询停止时 W ≤ TTL。另外有一类请求无法被任何缓存机制拦截:撤销提交之前已经读到 ACTIVE 的在途请求,它们仍会返回 302。W 的定义因此是“提交之后才开始的请求还可能拿到 302 的时间范围”。

图 34-3:撤销经轮询淘汰缓存的时序(R2 的参数)

sequenceDiagram
    participant O as 所有者
    participant DB as 主库 link / link_change
    participant P as 轮询线程(每 100ms)
    participant C as 进程内缓存(TTL 2000ms)
    participant U as 访问者
    U->>C: GET 码(条目 v1 ACTIVE)
    C-->>U: 302
    O->>DB: UPDATE state=REVOKED, version=2;INSERT link_change(seq, v2);COMMIT
    U->>C: GET 码(条目仍是 v1)
    C-->>U: 302(窗口内的旧值)
    P->>DB: SELECT ... WHERE seq > lastSeq
    DB-->>P: (seq, 码, v2)
    P->>C: computeIfPresent:v1 < v2,淘汰
    U->>C: GET 码(未命中)
    C->>DB: 回源读到 REVOKED
    C-->>U: 410

R2 与 R3 用真实 HTTP 测量这个窗口:先访问一次把条目装进缓存,提交撤销,然后每隔约 5ms 发起一次请求,直到第一次拿到 410。每个 302 响应都带一个观测用的 X-Entry-Age-Ms 头,记录所依据条目的年龄。断言有两条:撤销后每个 302 依据的条目年龄都小于 TTL;首次 410 距提交的时间不超过 TTL 加最大采样间隔加 100ms 余量。

1
2
3
4
CASE R2 :: 撤销生效窗口,真实 HTTP 测量:轮询开启(poll=100ms, TTL=2000ms)
note 撤销提交后仍返回 302 的请求数 1,首次 410 响应距提交 29 ms,相邻两次请求发起间隔最大 14 ms(本机观察值)
CASE R3 :: 撤销生效窗口,真实 HTTP 测量:轮询关闭(只剩 TTL=1500ms 上界)
note 撤销提交后仍返回 302 的请求数 69,首次 410 响应距提交 1533 ms,相邻两次请求发起间隔最大 49 ms(本机观察值)

这些毫秒数是本机单次观察值,只说明窗口落在模型给出的范围内,不能当作性能结论。R2 中窗口小于一个 100ms 的轮询周期,具体值取决于提交时刻落在轮询周期的哪个位置,多次运行会不同;R3 关闭轮询后窗口接近 TTL,撤销后 302 所依据条目的最大年龄为 1483ms,小于 1500ms。I2 的观察位置是 X-Entry-Age-Ms 与首次 410 的时间,失败边界是下面两个反例。

两个让撤销漏掉的交错

回源读在撤销之前,填充在淘汰之后

请求 A 未命中缓存,回源读到 v1 ACTIVE;此时撤销提交,轮询线程读到 v2,去缓存里淘汰,但缓存里还没有这个码,淘汰什么也没做;随后 A 把 v1 写进缓存。这条旧条目没有任何后续变更来淘汰它,只能等 TTL 过期。

R4 与 NC3 用测试钩子构造这个交错:钩子在回源读到行之后、写入缓存之前阻塞请求 A,主线程提交撤销并等到轮询线程的“已见版本”变为 2,再放行 A。修复是让轮询线程先登记每个码的已见版本,回源结果版本更旧时不进缓存:

1
2
cache.compute(code, (k, old) ->
cfg.versionGuard() && fresh.version() < seenVersion.getOrDefault(k, 0L) ? null : fresh);

轮询一侧先执行 seenVersion.merge(code, v, Math::max),再对同一个键调用 computeIfPresent。ConcurrentHashMap 的 Javadoc 写明 compute 整个调用原子执行,同一个键上的这两个操作互斥。若回源一侧先完成 compute,旧条目已在缓存里,随后的淘汰把它删掉;若轮询先登记版本,回源一侧的比较会拒绝旧读。两种次序都不会留下旧条目。

1
2
3
4
5
CASE R4 :: 回源读在撤销提交前、缓存填充在轮询淘汰后:版本守卫拒绝旧读
ok 请求 A 依据撤销前读到的行返回 302 (expected=302, actual=302)
note 请求 A 完成后的下一次访问状态 410,回源加载次数 2,轮询淘汰次数 0
CASE NC3 :: 负向对照:去掉版本守卫,同一交错让旧条目留在缓存直到 TTL
note 请求 A 完成后的下一次访问状态 302,回源加载次数 1,轮询淘汰次数 0

请求 A 本身返回 302,这属于前面说的在途请求,守卫只保证它的旧读不会延续到后续请求。

小序号晚提交,轮询游标越过它

link_change.seq 来自序列,序列值在插入时分配,与提交顺序无关。两个撤销并发时,A 先拿到 seq=7 但迟迟不提交,B 拿到 seq=8 先提交;轮询读到 8,把游标推到 8;A 随后提交,seq=7 小于游标,永远不会被读到。

NC4 用 beforeCommit 钩子让撤销 A 写完变更记录后停住,再让撤销 B 完成:

1
2
3
CASE NC4 :: 负向对照:不取写入锁,小序号晚提交,轮询游标越过它
note A 未提交时 B 是否已提交: true,此时轮询已见 Y 版本 2
note seq(X)=7 seq(Y)=8,提交 500ms 后轮询已见版本 X=0 Y=2,访问状态 X=302 Y=410

修复是让变更记录的写入串行:撤销事务开头先取一个事务级咨询锁,持有到提交。

1
2
3
4
5
6
if (serialize) {
try (PreparedStatement ps = c.prepareStatement("SELECT pg_advisory_xact_lock(?)")) {
ps.setLong(1, CHANGE_LOG_LOCK);
ps.execute();
}
}

后一个撤销在前一个提交之后才能执行插入、拿到序列值,所以任何能看到较大序号的快照也一定能看到较小序号。成立条件:所有写入 link_change 的事务都先取这把锁(本章只有撤销写这张表),且序列 CACHE 为 1(bigserial 的默认值);CACHE 大于 1 时各会话预取号段,串行提交也不能保证序号递增。R5 中 B 在 A 提交前确实在锁上等待,seq(X)=5 < seq(Y)=6,两个码都返回 410。代价是全部撤销串行执行,这依赖“撤销很少”的规模假设;撤销频繁时需要换成按时间回扫的轮询,或改用数据库的逻辑复制流,本篇没有实现这两种方案。

去掉这两处修复时,两个反例都不违反 TTL 上界,只是把窗口从一个轮询周期拉长到一个 TTL。若 TTL 被设得很长甚至无限,它们就会让撤销完全失效。

统计:故障留在主路径之外

处理请求时,服务先完成存在、状态、过期三项检查,然后生成一个点击记录,用不阻塞的 offer 放进有界队列,队列满则丢弃并计数,最后返回 302:

1
2
3
4
5
6
7
8
Click click = new Click(UUID.randomUUID(), code, Instant.now());
if (cfg.statsMode() == StatsMode.SYNC) {
writer.insertNow(click); // 负向对照:统计写入阻塞在重定向路径上
} else if (queue.offer(click)) {
enqueued.incrementAndGet();
} else {
dropped.incrementAndGet();
}

后台写入器每批最多取 100 条,在一个事务里执行 INSERT ... ON CONFLICT (click_id) DO NOTHING;失败时保留整个批次,稍后重试;连接设置 lock_timeout = 200ms,锁等待不会无限挂住写入器。PostgreSQL 16 文档对 lock_timeout 的说明是:语句等待锁超过该时长即被中止,每次加锁尝试分别计时。

I3 的保证来自 offer 不等待这一步,以及写入器与请求线程不共享连接。I4 的保证来自 click_id 主键和 DO NOTHING:批次提交成功但确认丢失、写入器重发整批时,已提交的行被跳过。

表 34-3:统计侧故障用例

用例 注入方式 跳转结果 统计结果
S1 无 20 次 302 20 行,丢弃 0
S2 统计库地址指向不可达端口,队列容量 8 30 次 302 失败批次 7,丢弃 21;恢复地址后补写 9 行,等于入队数
S3 另一会话持有 click 表的 ACCESS EXCLUSIVE 锁 20 次 302(客户端超时 1000ms) 写入器 2 次因 55P03 失败;释放锁后 20 行
S4 第一个批次提交后模拟确认丢失 10 次 302 批次尝试 6、失败 1,插入 10 行,主键去重 1 行
NC2 统计同步写入,表被锁 3 次请求全部客户端超时 不适用

ACCESS EXCLUSIVE 与所有锁模式冲突,INSERT 需要的 ROW EXCLUSIVE 也在其中(PostgreSQL 16 显式锁定一章)。NC2 把统计写入放回请求线程,且该连接没有 lock_timeout,于是 3 次请求全部在客户端 1000ms 超时:

1
2
CASE NC2 :: 负向对照:统计同步写在重定向路径上,统计表被锁时重定向超时
note 3 次请求中 302=0,客户端超时=3

S4 的去重行数等于确认丢失那一批的大小。10 次请求是串行发出的,写入器取第一批时队列里只有第一次点击,所以本次运行去重 1 行,其余 9 次点击作为后续批次正常插入。批次如何切分取决于调度,断言只要求“重试批次中有行被去重”和“最终 10 行”。

统计的失败边界写在契约里:点击可以丢,丢的数量在 dropped 计数器上;队列在进程内存中,进程崩溃时未入库的点击全部丢失,本篇没有做崩溃实验。I4 只说明同一个 click_id 不会计两次,不说明同一访客的重复点击会被合并。

开放重定向

RFC 6749 第 10.15 节把开放重定向器定义为:用参数指定跳转位置、且不做任何校验就把用户代理重定向过去的端点,并指出它可被用于钓鱼,借用可信域名的外观。短链服务按定义就是重定向器,差别只在“参数”换成了预先登记的码。能在语法层面做的校验有四项,V1 覆盖了每一项:

  • 只接受 http 与 https,拒绝 javascript:、data:、ftp:,拒绝没有协议的相对地址与协议相对地址 //host/x。
  • 拒绝 userinfo。RFC 9110 第 4.2.4 节建议(SHOULD)接收方把来自不可信来源的 http(s) URI 中的 userinfo 视为错误,因为它常被用来掩盖真实主机;RFC 3986 第 7.6 节给出了 @ 前伪装主机名的例子。
  • 拒绝空白与控制字符。目标会原样进入 Location 头,回车换行进入响应头就是头部注入。
  • 要求非空主机名,拒绝 https:///nohost。

语法校验管不了目标站点本身是否恶意。对已登记的恶意目标,可用的补救手段就是撤销,这也是 I2 需要一个可计算上界的原因。另有一处跨站泄露:RFC 9110 第 10.2.2 节规定,Location 不带片段时,用户代理沿用原请求 URI 的片段,第 17.11 节提醒这可能把一个站点的片段带到另一个站点。本实现不处理片段继承,列为限制。

恢复流程

  • 轮询连接断开:轮询线程捕获 SQLException,睡眠一个周期后重连,游标 lastSeq 保留在内存中,重连后从原位置继续。断开期间撤销窗口退化为 TTL。R3 通过关闭轮询模拟了这一退化情形;断线重连与游标续读的代码路径没有做实验。
  • 重定向进程重启:缓存为空,游标从 max(seq) 开始。启动前的撤销已经反映在 link 行里,回源即可读到,不需要回放。
  • 撤销事务失败:变更记录与状态更新在同一事务,回滚后两者都不存在,缓存保持旧值是正确的。重复撤销影响 0 行,返回 -1,不追加变更记录。
  • 统计库不可用:写入器保留当前批次并按 100ms 间隔重试,队列满后丢弃并计数;恢复后补写已入队部分(S2)。
  • 统计提交结果未知:整批重发,click_id 去重(S4)。核对口径是统计库 count(*) 等于写入器的 rowsInserted,且等于入队数。

证据与限制

run.sh 一条命令完成启动集群(端口 55434)、重建 shortlink34 与 shortlink34_stats 两个库、编译、运行全部用例并停集群。最后一次运行退出码为 0,汇总行:

1
SUMMARY passed=86 failed=0

表 34-4:实验清单

编号 类别 内容 状态
V1 模型回放(纯函数) 目标地址校验 PASS
A1、A2、A3 真实组件 别名并发、随机码冲突重试、并发随机创建 PASS
NC1 真实组件,负向对照 先查后写加覆盖式 upsert PASS(暴露覆盖)
R1 真实组件 + 回环 HTTP 302/404/410/405 与 no-store PASS
R2、R3 真实组件 + 回环 HTTP 撤销窗口,轮询开启与关闭 PASS
R4、NC3 真实组件 + 确定性钩子 回源旧读与版本守卫 PASS
R5、NC4 真实组件 + 确定性钩子 变更日志序号与提交顺序 PASS
S1–S4、NC2 真实组件 + 回环 HTTP 统计故障隔离、丢弃计数、去重 PASS

限制逐条列在 evidence/34/LIMITS.md,要点如下。服务端与客户端在同一个 JVM 内经回环地址通信,没有浏览器、代理或 CDN,no-store 是否被遵守未验证。重定向服务只有一个实例,多实例时每个实例各自轮询,窗口取各实例中最慢的一个,本篇没有测。主库故障时主路径返回 503 或阻塞在连接池上,没有做主库故障实验。统计队列在内存中,进程崩溃会丢失未入库点击,没有做 kill -9 实验。所有毫秒数都是本机单次观察值。

本批导航

  • 上一篇:33 通知渠道的结果未知
  • 下一篇:35 文件上传与异步处理
  • 本批索引:仓库 batch-3/INDEX.md(阅读顺序与运行入口),batch-3/STATUS.md、batch-3/SOURCES.md
  • 运行入口:batch-3/examples/shortlink/run.sh
  • 证据目录:batch-3/evidence/34/
  • 本篇工程与证据快照:backend-systems-34.tar.gz(SHA-256 见同目录 backend-systems-34.tar.gz.sha256)。正文中的 examples/、evidence/、SOURCES.md 路径都相对仓库的 batch-3/ 目录,与快照包内布局一致。