From 9c7fd31a691f3b319e1e460422149cc0f348b36b Mon Sep 17 00:00:00 2001 From: CPTProgrammer <46586216+CPTProgrammer@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:00:05 +0800 Subject: [PATCH] Add publish dry-run mode with request logging --- docs/workflow.md | 11 +++++ src/lib/curseforge/client.ts | 7 ++- src/lib/curseforge/file.test.ts | 56 ++++++++++++++++++++++++ src/lib/modrinth/client.ts | 7 ++- src/lib/modrinth/version.test.ts | 73 ++++++++++++++++++++++++++++++++ src/lib/utils/dryRun.ts | 55 ++++++++++++++++++++++++ 6 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 src/lib/curseforge/file.test.ts create mode 100644 src/lib/modrinth/version.test.ts create mode 100644 src/lib/utils/dryRun.ts diff --git a/docs/workflow.md b/docs/workflow.md index 8b20a3e..a9f3d42 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -138,3 +138,14 @@ - 通过 tRPC subscription 推送进度更新 6. **任一请求失败则取消当前平台的后续所有任务**,不影响另一个平台 7. 全部完成或失败后,弹出结果 Modal 显示最终状态(成功数 / 失败数) + +## Dry-run(模拟发布) + +在 `.env.local` 中设置 `PUBLISH_DRY_RUN=1` 后重启开发服务器,发布流程进入模拟模式: + +- 配置解析、版本范围计算、文件读取、multipart 请求组装全部照常执行; +- 两个平台的上传请求在 `ModrinthClient.post` / `CurseForgeClient.uploadPost` 内被拦截,**不会发生任何实际上传**; +- 每次拦截会把完整请求摘要(metadata JSON、各文件字段名/大小)打印到终端,并追加写入日志文件供人工核对,路径见终端输出;可用环境变量 `PUBLISH_DRY_RUN_LOG_DIR` 指定日志目录,默认为系统临时目录; +- 平台返回伪造响应以维持调用链类型校验(Modrinth 为 `{ name: "(dry-run)", ... }`,CurseForge 为 `{ id: 0 }`),并带 1.5 秒模拟延迟,让前端进度条逐个推进。 + +实现位置:`src/lib/utils/dryRun.ts`。 diff --git a/src/lib/curseforge/client.ts b/src/lib/curseforge/client.ts index fa8737e..8c4a7dc 100644 --- a/src/lib/curseforge/client.ts +++ b/src/lib/curseforge/client.ts @@ -1,3 +1,4 @@ +import { isPublishDryRun, interceptUpload } from "../utils/dryRun"; import { readErrorBody, summarizeFormData } from "../utils/fetch"; /** CurseForge API 客户端,封装两套 API 的鉴权与 User-Agent。 */ @@ -74,7 +75,11 @@ export class CurseForgeClient { * 不手动设置 Content-Type,让 fetch 自动生成含 boundary 的头。 */ async uploadPost(path: string, body: FormData): Promise { - const res = await fetch(`${this.uploadBase}${path}`, { + const url = `${this.uploadBase}${path}`; + if (isPublishDryRun()) { + return interceptUpload("CurseForge", "POST", url, body) as Promise; + } + const res = await fetch(url, { method: "POST", headers: this.uploadHeaders(), body, diff --git a/src/lib/curseforge/file.test.ts b/src/lib/curseforge/file.test.ts new file mode 100644 index 0000000..840b099 --- /dev/null +++ b/src/lib/curseforge/file.test.ts @@ -0,0 +1,56 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CurseForgeClient } from "./client"; +import { uploadFile } from "./file"; +import type { UploadMetadata } from "@/types"; + +describe("uploadFile (dry-run)", () => { + const OLD_ENV = process.env; + let logDir: string; + let logSpy: ReturnType; + + beforeEach(async () => { + process.env = { + ...OLD_ENV, + PUBLISH_DRY_RUN: "1", + }; + logDir = await mkdtemp(join(tmpdir(), "curseforge-dryrun-")); + process.env.PUBLISH_DRY_RUN_LOG_DIR = logDir; + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(async () => { + process.env = OLD_ENV; + logSpy.mockRestore(); + await rm(logDir, { recursive: true, force: true }); + }); + + const client = new CurseForgeClient("fake-api-token", "fake-legacy-token"); + + const metadata: UploadMetadata = { + changelog: "test changelog", + changelogType: "markdown", + displayName: "mod-1.0.0-mc1.21.jar", + releaseType: "release", + gameVersions: [10157, 7499], + relations: { projects: [] }, + }; + + it("不上传,返回可通过 UploadResponseSchema 校验的结果,并打印请求摘要", async () => { + const file = new File(["jar-content"], "mod-1.0.0-mc1.21.jar"); + + const result = await uploadFile(client, 123456, metadata, file); + + // uploadFile 内部对 mock 响应执行了 UploadResponseSchema.parse + expect(result.id).toBe(0); + + expect(logSpy).toHaveBeenCalledOnce(); + const output = logSpy.mock.calls[0][0] as string; + expect(output).toContain("[DRY-RUN] CurseForge POST"); + expect(output).toContain("/api/projects/123456/upload-file"); + expect(output).toContain('"displayName":"mod-1.0.0-mc1.21.jar"'); + expect(output).toContain("mod-1.0.0-mc1.21.jar"); + }); +}); diff --git a/src/lib/modrinth/client.ts b/src/lib/modrinth/client.ts index 69f9424..8d5bd02 100644 --- a/src/lib/modrinth/client.ts +++ b/src/lib/modrinth/client.ts @@ -1,3 +1,4 @@ +import { isPublishDryRun, interceptUpload } from "../utils/dryRun"; import { readErrorBody, summarizeFormData } from "../utils/fetch"; /** Modrinth API v2 的基础请求客户端,封装鉴权与 User-Agent。 */ @@ -41,7 +42,11 @@ export class ModrinthClient { * 不手动设置 Content-Type,让 fetch 自动生成含 boundary 的头。 */ async post(path: string, body: FormData): Promise { - const res = await fetch(`${this.base}${path}`, { + const url = `${this.base}${path}`; + if (isPublishDryRun()) { + return interceptUpload("Modrinth", "POST", url, body) as Promise; + } + const res = await fetch(url, { method: "POST", headers: this.headers(), body, diff --git a/src/lib/modrinth/version.test.ts b/src/lib/modrinth/version.test.ts new file mode 100644 index 0000000..4916205 --- /dev/null +++ b/src/lib/modrinth/version.test.ts @@ -0,0 +1,73 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ModrinthClient } from "./client"; +import { createVersion } from "./version"; +import type { CreatableVersion } from "@/types"; + +describe("createVersion (dry-run)", () => { + const OLD_ENV = process.env; + let logDir: string; + let logSpy: ReturnType; + + beforeEach(async () => { + process.env = { + ...OLD_ENV, + PUBLISH_DRY_RUN: "1", + }; + logDir = await mkdtemp(join(tmpdir(), "modrinth-dryrun-")); + process.env.PUBLISH_DRY_RUN_LOG_DIR = logDir; + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(async () => { + process.env = OLD_ENV; + logSpy.mockRestore(); + await rm(logDir, { recursive: true, force: true }); + }); + + const client = new ModrinthClient("fake-token", "test-agent"); + + const baseData: CreatableVersion = { + project_id: "AABBCCDD", + name: "Test v1.0.0", + version_number: "1.0.0-mc1.21", + changelog: "test changelog", + game_versions: ["1.21"], + version_type: "release", + loaders: ["fabric"], + featured: false, + dependencies: [], + file_parts: ["primary", "sources"], + primary_file: "primary", + file_types: { sources: "sources-jar" }, + environment: "client_and_server", + }; + + it("不上传,返回可通过 VersionSchema 校验的结果,并打印请求摘要", async () => { + const primary = new File(["jar-content"], "mod-1.0.0-mc1.21.jar"); + const sources = new File(["src-content"], "mod-1.0.0-mc1.21-sources.jar"); + + const result = await createVersion(client, baseData, { primary, sources }); + + // createVersion 内部对 mock 响应执行了 VersionSchema.parse,能返回即说明结构合法 + expect(result.name).toBe("(dry-run)"); + expect(result.version_number).toBe("(dry-run)"); + + expect(logSpy).toHaveBeenCalledOnce(); + const output = logSpy.mock.calls[0][0] as string; + expect(output).toContain("[DRY-RUN] Modrinth POST"); + expect(output).toContain("/v2/version"); + expect(output).toContain('"project_id":"AABBCCDD"'); + expect(output).toContain("mod-1.0.0-mc1.21.jar"); + expect(output).toContain("mod-1.0.0-mc1.21-sources.jar"); + }); + + it("file_parts 中声明但 files 中缺失时,仍抛出原有的组装期校验错误", async () => { + const primary = new File(["jar"], "mod.jar"); + await expect( + createVersion(client, baseData, { primary } as Record), + ).rejects.toThrow('file field "sources" declared in file_parts'); + }); +}); diff --git a/src/lib/utils/dryRun.ts b/src/lib/utils/dryRun.ts new file mode 100644 index 0000000..2717687 --- /dev/null +++ b/src/lib/utils/dryRun.ts @@ -0,0 +1,55 @@ +import { appendFile, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { summarizeFormData } from "./fetch"; + +/** 模拟上传耗时的延迟(毫秒),让前端进度条有真实的推进感。 */ +const DRY_RUN_DELAY_MS = 1500; + +/** + * 是否处于发布 dry-run(模拟)模式。 + * 通过环境变量 `PUBLISH_DRY_RUN=1` 开启(通常写在 `.env.local`,已被 gitignore)。 + */ +export function isPublishDryRun(): boolean { + return process.env.PUBLISH_DRY_RUN === "1"; +} + +/** + * dry-run 模式下拦截一次上传请求: + * 将完整的 multipart 请求摘要(metadata JSON + 各文件字段)打印到控制台, + * 同时追加写入日志文件,便于人工核对实际会发送给平台的参数。 + * + * @returns 伪造的平台响应体(结构满足上游 zod 校验) + */ +export async function interceptUpload( + platform: string, + method: string, + url: string, + body: FormData, +): Promise { + const summary = summarizeFormData(body); + const logDir = process.env.PUBLISH_DRY_RUN_LOG_DIR ?? tmpdir(); + const logFile = join(logDir, `dry-run-${Date.now()}-${platform}.log`); + + try { + await mkdir(logDir, { recursive: true }); + await appendFile( + logFile, + `[${new Date().toISOString()}] ${method} ${url}\n${summary}\n${"─".repeat(60)}\n`, + "utf-8", + ); + } catch { + // 日志文件写不进去不阻断主流程,至少控制台还有输出 + } + + console.log( + `\n[DRY-RUN] ${platform} ${method} ${url}\n${summary}\n→ 完整请求已写入: ${logFile}\n`, + ); + + // 模拟网络上传耗时,让进度条逐个推进而不是瞬间跳完 + await new Promise((resolve) => setTimeout(resolve, DRY_RUN_DELAY_MS)); + + return platform === "Modrinth" + ? { name: "(dry-run)", version_number: "(dry-run)" } + : { id: 0 }; +}