From 89bc23e7e7eb142e68a5e6f964fb88d8b17abac2 Mon Sep 17 00:00:00 2001 From: SepComet <202308010230@stu.csust.edu.cn> Date: Wed, 6 May 2026 10:11:16 +0800 Subject: [PATCH] Add structured rebuild entrypoint for AstrBot --- TODO.md | 67 +++++++------ docs/rebuild-trigger-spec.md | 109 ++++++++++++++++++++ package.json | 2 +- scripts/rebuild-codes.ts | 19 ++++ scripts/rebuild.ts | 186 +++++++++++++++++++++++++++++++++++ scripts/sync-content.ts | 25 ++++- 6 files changed, 376 insertions(+), 32 deletions(-) create mode 100644 docs/rebuild-trigger-spec.md create mode 100644 scripts/rebuild-codes.ts create mode 100644 scripts/rebuild.ts diff --git a/TODO.md b/TODO.md index 6583c77..310df9d 100644 --- a/TODO.md +++ b/TODO.md @@ -78,59 +78,66 @@ - [x] 生成 `itemCount`(activity) - [x] 前端更明确地显示“数据更新时间” -### 3. 增加变更检测 +### 3. 增加日志与通知 -- [ ] 数据无变化时跳过部署 -- [ ] 可通过 hash / diff / 序列化比较实现 - -### 4. 增加日志与通知 - -- [ ] AstrBot 每日 00:00 触发同步与重建 +- [ ] AstrBot 或 cron 每日 00:00 触发同步与重建 +- [x] 封装统一重建指令供 AstrBot 调用(如 `npm run rebuild` 或独立 shell 入口) +- [x] 约定结构化执行结果与错误码,便于 AstrBot 判断成功/失败阶段 - [ ] 成功时发送通知 - [ ] 失败时发送告警 -- [ ] 支持手动补跑一次 +- [x] 支持手动补跑一次 + +### 4. 补部署文件与部署说明 + +- [ ] 增加 `Dockerfile` +- [ ] 增加 `docker-compose.yml` +- [ ] 明确静态产物部署方式 +- [ ] 写清部署步骤与重建入口 + +### 5. 补运行文档 + +- [ ] 在 README 或单独文档里写清: + - [x] AstrBot / cron 如何触发 + - [x] AstrBot 调用的统一构建指令与返回约定 + - [x] 错误码含义(sync/build/deploy/unknown) + - [ ] 环境变量如何配置 + - [ ] Gitea / Seafile 映射如何填写 + - [ ] 同步脚本职责 + - [ ] 构建失败如何排查 ## P2:后续完善项 -### 5. 项目与资源映射收口 +### 6. 项目与资源映射收口 - [ ] 明确“哪些仓库进入 projects” - [ ] 明确“哪些仓库只用于 activity 不进入 projects” - [ ] 决定项目封面图是继续本地维护,还是允许远程字段补充 - [ ] 决定 `download_link` 是否最终完全被 `downloads[]` 取代 -### 6. Seafile 下载策略收口 +### 7. Seafile 下载策略收口 - [x] 首版支持只展示元数据 + 下载链接 - [ ] 评估是否需要构建时同步特定打包文件到 `public/downloads/` - [ ] 如同步文件本体,补充大小限制、覆盖策略、清理策略 - [ ] 评估是否需要“自动扫描目录”而不是完全依赖映射文件 -### 7. 首页与页面体验完善 +### 8. 首页与页面体验完善 - [ ] 首页增加 shares preview / recent resources 模块 - [ ] `/shares` 页增加更明确的类型 / 所属项目 / 链接状态展示优化 - [ ] 项目卡片下载区样式优化 - [ ] 前端更明确显示同步时间与数据来源 -### 8. 补部署文件 +### 9. 变更检测(当前不是必须项) -- [ ] 增加 `Dockerfile` -- [ ] 增加 `docker-compose.yml` -- [ ] 明确静态产物部署方式 - -### 9. 补文档 - -- [ ] 在 README 或单独文档里写清: - - AstrBot / cron 如何触发 - - 环境变量如何配置 - - Gitea / Seafile 映射如何填写 - - 同步脚本职责 - - 构建失败如何排查 +- [ ] 数据无变化时跳过部署 +- [ ] 可通过 hash / diff / 序列化比较实现 +- [ ] 当前数据量较小,可在部署资源或通知噪音变高时再做 ## 推荐最小闭环 -- [ ] AstrBot 每天 00:00 触发 +- [ ] AstrBot 或 cron 每天 00:00 触发 +- [ ] 调用统一重建指令并读取错误码 / 执行结果 - [x] 执行 `npm run content:sync` - [x] 执行 `npm run build` - [ ] 部署静态产物 @@ -138,8 +145,8 @@ ## 实施顺序建议 -1. 先补 projects / shares / activity schema 校验 -2. 再整理并提交 `src/config.ts` / `src/content/projects/index.json` -3. 再补首页 shares preview -4. 再补 AstrBot / cron 触发与通知 -5. 最后补 Docker / README / 部署说明 +1. 先补 AstrBot / cron 触发与通知 +2. 再补 Docker / docker-compose / 静态产物部署说明 +3. 再补 README / 运维文档,形成可维护闭环 +4. 然后回头做首页 shares preview 和列表体验优化 +5. 变更检测放到最后,需要时再做 diff --git a/docs/rebuild-trigger-spec.md b/docs/rebuild-trigger-spec.md new file mode 100644 index 0000000..26955cc --- /dev/null +++ b/docs/rebuild-trigger-spec.md @@ -0,0 +1,109 @@ +# 重建触发与错误码约定 + +本文档定义仓库对外提供的统一重建入口,供 AstrBot、cron 或手工执行复用。 + +## 统一入口 + +```bash +npm run rebuild +``` + +该命令会顺序执行: + +1. `npm run content:sync` +2. `npm run build` + +当前还不包含部署步骤;后续如接入部署,可继续扩展到同一入口里。 + +## 适合 AstrBot 的调用方式 + +AstrBot 只需要做三件事: + +1. 定时触发 `npm run rebuild` +2. 读取进程退出码 +3. 从输出中提取 `REBUILD_RESULT ...` 这一行,作为结构化结果 + +推荐通知内容: + +- 成功:开始时间、结束时间、总耗时 +- 失败:失败阶段、错误码、错误符号、最后几行日志 + +## 结构化输出 + +命令结束前会输出一行 JSON: + +```text +REBUILD_RESULT {"ok":true,"stage":"build","code":0,"symbol":"SUCCESS",...} +``` + +字段说明: + +- `ok`: 是否成功 +- `stage`: 失败或结束所在阶段,当前可能为 `sync` / `build` / `bootstrap` +- `code`: 退出码 +- `symbol`: 退出码对应的文本符号 +- `startedAt`: 整体开始时间 +- `finishedAt`: 整体结束时间 +- `durationMs`: 整体耗时 +- `failedStepDurationMs`: 当前失败步骤耗时;成功时为最后一步耗时 +- `logTail`: 最后若干行日志,便于 AstrBot 通知时直接带上排障信息 + +## 错误码 + +### 成功 + +| Code | Symbol | 含义 | +|------|--------|------| +| 0 | `SUCCESS` | 全部成功 | + +### 同步阶段 + +| Code | Symbol | 含义 | +|------|--------|------| +| 10 | `SYNC_FAILED` | 通用同步失败 | +| 11 | `GITEA_SYNC_FAILED` | Gitea 同步失败 | +| 12 | `SEAFILE_SYNC_FAILED` | Seafile 同步失败 | +| 13 | `SCHEMA_VALIDATION_FAILED` | JSON/Schema 校验失败 | + +### 构建阶段 + +| Code | Symbol | 含义 | +|------|--------|------| +| 20 | `BUILD_FAILED` | Astro 构建失败 | + +### 部署阶段(预留) + +| Code | Symbol | 含义 | +|------|--------|------| +| 30 | `DEPLOY_FAILED` | 静态产物部署失败 | + +### 运行阶段 + +| Code | Symbol | 含义 | +|------|--------|------| +| 40 | `CONFIG_INVALID` | 运行环境缺少必要命令或配置异常 | +| 50 | `UNKNOWN_ERROR` | 未归类异常 | + +## 手动补跑 + +本地或服务器上都可以直接执行: + +```bash +npm run rebuild +``` + +如果只是想单独排查同步阶段,也可以先运行: + +```bash +npm run content:sync +``` + +## 建议的 AstrBot 判定逻辑 + +- 退出码 `0`:发送成功通知 +- 非 `0`:发送失败告警 +- 优先展示: + - `symbol` + - `stage` + - `code` + - `logTail` diff --git a/package.json b/package.json index c41a5c6..acd74fa 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "dev": "astro dev", "content:sync": "node --env-file-if-exists=.env --experimental-strip-types ./scripts/sync-content.ts", "build": "astro build", - "rebuild": "npm run content:sync && npm run build", + "rebuild": "node --env-file-if-exists=.env --experimental-strip-types ./scripts/rebuild.ts", "preview": "astro preview", "astro": "astro" }, diff --git a/scripts/rebuild-codes.ts b/scripts/rebuild-codes.ts new file mode 100644 index 0000000..ecf751a --- /dev/null +++ b/scripts/rebuild-codes.ts @@ -0,0 +1,19 @@ +export const REBUILD_EXIT_CODES = { + SUCCESS: 0, + SYNC_FAILED: 10, + GITEA_SYNC_FAILED: 11, + SEAFILE_SYNC_FAILED: 12, + SCHEMA_VALIDATION_FAILED: 13, + BUILD_FAILED: 20, + DEPLOY_FAILED: 30, + CONFIG_INVALID: 40, + UNKNOWN_ERROR: 50, +} as const; + +export type RebuildExitCode = + (typeof REBUILD_EXIT_CODES)[keyof typeof REBUILD_EXIT_CODES]; + +export function getRebuildExitSymbol(code: number) { + const entry = Object.entries(REBUILD_EXIT_CODES).find(([, value]) => value === code); + return entry?.[0] ?? 'UNRECOGNIZED_EXIT_CODE'; +} diff --git a/scripts/rebuild.ts b/scripts/rebuild.ts new file mode 100644 index 0000000..57174ef --- /dev/null +++ b/scripts/rebuild.ts @@ -0,0 +1,186 @@ +import { spawn } from 'node:child_process'; +import process from 'node:process'; +import { REBUILD_EXIT_CODES, getRebuildExitSymbol } from './rebuild-codes.ts'; + +type RebuildStage = 'sync' | 'build' | 'deploy' | 'bootstrap'; + +type StepResult = { + ok: boolean; + exitCode: number; + stage: RebuildStage; + durationMs: number; + tail: string[]; +}; + +async function main() { + const startedAt = new Date(); + console.log(`[rebuild] start ${startedAt.toISOString()}`); + + const syncResult = await runNpmStep({ + stage: 'sync', + script: 'content:sync', + failureCode: REBUILD_EXIT_CODES.SYNC_FAILED, + }); + + if (!syncResult.ok) { + return finish(syncResult, startedAt); + } + + const buildResult = await runNpmStep({ + stage: 'build', + script: 'build', + failureCode: REBUILD_EXIT_CODES.BUILD_FAILED, + }); + + return finish(buildResult, startedAt); +} + +async function runNpmStep(input: { + stage: RebuildStage; + script: string; + failureCode: number; +}): Promise { + console.log(`[rebuild] stage=${input.stage} command="npm run ${input.script}"`); + + const command = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const startedAt = Date.now(); + const tailBuffer = createTailBuffer(40); + + return new Promise((resolve) => { + const child = spawn(command, ['run', input.script], { + cwd: process.cwd(), + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + child.stdout.on('data', (chunk) => { + const text = chunk.toString(); + process.stdout.write(text); + tailBuffer.push(text); + }); + + child.stderr.on('data', (chunk) => { + const text = chunk.toString(); + process.stderr.write(text); + tailBuffer.push(text); + }); + + child.on('error', (error) => { + const durationMs = Date.now() - startedAt; + const exitCode = classifySpawnError(error); + tailBuffer.push(`[rebuild] failed to spawn command: ${error.message}\n`); + + resolve({ + ok: false, + exitCode, + stage: input.stage, + durationMs, + tail: tailBuffer.read(), + }); + }); + + child.on('close', (code) => { + const durationMs = Date.now() - startedAt; + const normalizedExitCode = + code === 0 ? REBUILD_EXIT_CODES.SUCCESS : normalizeStageExitCode(input.failureCode, code); + + resolve({ + ok: normalizedExitCode === REBUILD_EXIT_CODES.SUCCESS, + exitCode: normalizedExitCode, + stage: input.stage, + durationMs, + tail: tailBuffer.read(), + }); + }); + }); +} + +function finish(result: StepResult, startedAt: Date) { + const finishedAt = new Date(); + const payload = { + ok: result.ok, + stage: result.stage, + code: result.exitCode, + symbol: getRebuildExitSymbol(result.exitCode), + startedAt: startedAt.toISOString(), + finishedAt: finishedAt.toISOString(), + durationMs: finishedAt.getTime() - startedAt.getTime(), + failedStepDurationMs: result.durationMs, + logTail: result.tail, + }; + + console.log(`REBUILD_RESULT ${JSON.stringify(payload)}`); + + if (!result.ok) { + console.error( + `[rebuild] failed stage=${result.stage} code=${result.exitCode} symbol=${payload.symbol}`, + ); + } else { + console.log(`[rebuild] success code=0 symbol=${payload.symbol}`); + } + + process.exitCode = result.exitCode; +} + +function normalizeStageExitCode(fallbackCode: number, childCode: number | null) { + if (childCode == null) { + return fallbackCode; + } + + if (Object.values(REBUILD_EXIT_CODES).includes(childCode as never)) { + return childCode; + } + + return fallbackCode; +} + +function classifySpawnError(error: NodeJS.ErrnoException) { + if (error.code === 'ENOENT') { + return REBUILD_EXIT_CODES.CONFIG_INVALID; + } + + return REBUILD_EXIT_CODES.UNKNOWN_ERROR; +} + +function createTailBuffer(maxLines: number) { + const lines: string[] = []; + + return { + push(chunk: string) { + const normalized = chunk.replace(/\r\n/g, '\n'); + for (const line of normalized.split('\n')) { + if (!line.trim()) { + continue; + } + lines.push(line); + } + + while (lines.length > maxLines) { + lines.shift(); + } + }, + read() { + return [...lines]; + }, + }; +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error('[rebuild] unhandled failure'); + console.error(message); + console.log( + `REBUILD_RESULT ${JSON.stringify({ + ok: false, + stage: 'bootstrap', + code: REBUILD_EXIT_CODES.UNKNOWN_ERROR, + symbol: getRebuildExitSymbol(REBUILD_EXIT_CODES.UNKNOWN_ERROR), + startedAt: new Date().toISOString(), + finishedAt: new Date().toISOString(), + durationMs: 0, + failedStepDurationMs: 0, + logTail: [message], + })}`, + ); + process.exitCode = REBUILD_EXIT_CODES.UNKNOWN_ERROR; +}); diff --git a/scripts/sync-content.ts b/scripts/sync-content.ts index d017970..f003ed1 100644 --- a/scripts/sync-content.ts +++ b/scripts/sync-content.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import process from 'node:process'; import { fetchGiteaSnapshot, type GeneratedGiteaActivity } from './fetch-gitea.ts'; import { fetchSeafileSnapshot } from './fetch-seafile.ts'; +import { REBUILD_EXIT_CODES, getRebuildExitSymbol } from './rebuild-codes.ts'; import { generatedProjectListSchema, generatedShareListSchema, @@ -203,7 +204,29 @@ async function writeJson(filePath: string, data: unknown) { } main().catch((error) => { + const exitCode = classifySyncExitCode(error); console.error('[sync-content] failed'); console.error(error); - process.exitCode = 1; + console.error( + `[sync-content] exit code ${exitCode} (${getRebuildExitSymbol(exitCode)})`, + ); + process.exitCode = exitCode; }); + +function classifySyncExitCode(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + + if (error instanceof SyntaxError || message.includes('[data-schema]')) { + return REBUILD_EXIT_CODES.SCHEMA_VALIDATION_FAILED; + } + + if (message.includes('[fetch-gitea]')) { + return REBUILD_EXIT_CODES.GITEA_SYNC_FAILED; + } + + if (message.includes('[fetch-seafile]')) { + return REBUILD_EXIT_CODES.SEAFILE_SYNC_FAILED; + } + + return REBUILD_EXIT_CODES.SYNC_FAILED; +}