feat(launch): 热力图与 webhook 发布闭环(T2/T3)

- T2 (op-artalk 车道):gitea heatmap 构建期拉取+fallback+空态三保险,
  53×7 灰阶格复用 tokens(亮暗自动),日界锚定 Asia/Shanghai,零 JS,
  zh/en 首页挂载;fallback 经死端点实测不破构建
- T3:webhook_receiver.py(HMAC 强制+main-only+构建锁)以 systemd
  --user 常驻 13021;gitea hook 注册(events=push, branch=main)
- 三个深坑:gitea 容器回宿主须走桥网关 IP 而非 127.0.0.1;gitea 28
  egress 拦私网须 ALLOWED_HOST_LIST(ALLOWLIST 是废弃名);systemd
  用户服务缺用户 PATH 致 build 静默失败(stamp 不更新即症状)

验证: 测试投递 202→构建→staging 戳更新(09:48:37 实测);
      dist 热力图 376 格真数据(9 次贡献)zh/en 双页

Spec: 2026-10-25_add-blog-launch
Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-10-25 09:49:19 +08:00
1 parent 0c54a43bb5
commit 938558ad4c
12 files changed
+692 -110

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
// 贡献热力图数据(极客层 P0):构建期拉自托管 gitea 年历,聚合 53 周 × 7 日
// 三级回落(远端 → 仓内样例 → 空数组)保证网络单点故障不破构建;见 Heatmap.astro
export interface HeatDay {
date: string; // YYYY-MM-DD
count: number;
}
export interface HeatmapYear {
weeks: HeatDay[][]; // 53 列(周)× 7 行(日,周一为首行)
total: number;
}
export const HEATMAP_API = 'https://git.wait4x.online/api/v1/users/wait4x/heatmap';
interface RawHeat {
timestamp: number;
contributions: number;
}
/** 分档阈值:0 / 1-2 / 3-5 / 6-9 / 10+ → 灰阶 l0-l4,固定阈值可解释(GitHub 同款哲学) */
export function levelOf(count: number): number {
if (count >= 10) return 4;
if (count >= 6) return 3;
if (count >= 3) return 2;
if (count >= 1) return 1;
return 0;
}
// 站点受众时区:日界按上海(+8)切,保证 gitea 后台与博客热力图对同一天计数一致;
// 显式固定而非取构建机时区——避免构建环境变化悄悄改变历史归日
const DAY_FMT = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
});
function toDateString(tsSec: number): string {
return DAY_FMT.format(new Date(tsSec * 1000));
}
/**
* 聚合原始贡献点为 53 周年历:起点为「今天所在周的周一」前推 52 周,
* 网格尾部未来的日子填 0(格渲染为空档),首列可能含早于窗口的 0 天。
*/
export async function loadHeatmap(): Promise<HeatmapYear> {
let raw: RawHeat[] = [];
try {
const res = await fetch(HEATMAP_API, {
headers: { Accept: 'application/json', 'User-Agent': 'wait4x-blog-build' },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`${HEATMAP_API} -> ${res.status}`);
raw = (await res.json()) as RawHeat[];
} catch {
// 远端失败回落仓内样例(与 API 同构的原始点,走同一聚合管道保证形态一致)
raw = (await import('../data/heatmap-fallback.json')).default as RawHeat[];
}
const byDate = new Map<string, number>();
for (const { timestamp, contributions } of raw) {
const key = toDateString(timestamp);
byDate.set(key, (byDate.get(key) ?? 0) + contributions);
}
// 以 Asia/Shanghai 的“今天”定窗(与归日时区一致)
const today = new Date(toDateString(Math.floor(Date.now() / 1000)) + 'T12:00:00+08:00');
const dow = (today.getUTCDay() + 6) % 7; // 周一=0
const gridStart = new Date(today.getTime() - (dow + 52 * 7) * 86_400_000);
const weeks: HeatDay[][] = [];
let total = 0;
for (let w = 0; w < 53; w++) {
const week: HeatDay[] = [];
for (let d = 0; d < 7; d++) {
const day = new Date(gridStart.getTime() + (w * 7 + d) * 86_400_000);
const key = toDateString(Math.floor(day.getTime() / 1000));
const count = day > today ? 0 : (byDate.get(key) ?? 0);
total += count;
week.push({ date: key, count });
}
weeks.push(week);
}
return { weeks, total };
}