Add update feature for existing platform versions

Extend existing releases' title and compatible MC versions without
re-uploading files, via Modrinth PATCH /version/{id} and CurseForge
update-file. Adds an UpdateModal with range/title diff preview, an
update tRPC mutation sharing the progress subscription with publish, and
dry-run interception for Modrinth PATCH requests.
This commit is contained in:
CPTProgrammer
2026-09-19 00:02:58 +08:00
parent 90b1ceddb6
commit 3894c0c599
12 changed files with 1108 additions and 58 deletions
+183 -26
View File
@@ -4,6 +4,7 @@ import { getConfig } from "./config";
import { errorMessage } from "@/lib/utils/error";
import {
PublishInputSchema,
UpdateInputSchema,
type ProgressEvent,
type PlatformResult,
type PublishResult,
@@ -11,11 +12,23 @@ import {
type PlatformContext,
type PlatformAdaptor,
type PublishInput,
type UpdateInput,
type ExistingRelease,
PublishResultSchema,
ProgressEventSchema,
} from "./publish.schemas";
import { publishModrinthVersion } from "./platforms/modrinth";
import { publishCurseForgeVersion } from "./platforms/curseforge";
import {
publishModrinthVersion,
updateModrinthVersion,
listExistingModrinth,
modrinthMatchKey,
} from "./platforms/modrinth";
import {
publishCurseForgeVersion,
updateCurseForgeVersion,
listExistingCurseForge,
curseForgeMatchKey,
} from "./platforms/curseforge";
import { computeVersionRanges, type McVersionEntry } from "@/lib/utils/mcVersion";
import { getCurseForgeMcVersions, getModrinthMcVersions } from "./meta";
import { readAsFile } from "@/lib/utils/file";
@@ -27,10 +40,16 @@ export const platformAdaptors: {
} = {
modrinth: {
publish: publishModrinthVersion,
update: updateModrinthVersion,
listExisting: listExistingModrinth,
matchKey: modrinthMatchKey,
getGameVersions: getModrinthMcVersions,
},
curseforge: {
publish: publishCurseForgeVersion,
update: updateCurseForgeVersion,
listExisting: listExistingCurseForge,
matchKey: curseForgeMatchKey,
getGameVersions: getCurseForgeMcVersions,
},
};
@@ -45,6 +64,49 @@ const ee = new EventEmitter();
// ── Platform orchestrator ──────────────────────────────
/**
* 计算勾选版本的兼容范围条目(发布与更新流程共用)。
* 范围必须基于完整项目版本列表计算,勾选只决定处理哪些条目。
*/
async function computeSelectedEntries(
platform: typeof platforms[number],
selectedMcVersions: string[],
ctx: PlatformContext<UpdateInput>,
): Promise<McVersionEntry[]> {
const selected = new Set(selectedMcVersions);
const entries = computeVersionRanges(
ctx.input.allMcVersions,
await platformAdaptors[platform].getGameVersions(),
ctx.input.cutoffMcVersion
).filter((e) => selected.has(e.version));
if (entries.length !== selected.size) {
throw new Error("勾选版本与项目版本列表不一致,请刷新后重试");
}
return entries;
}
/** 平台准备阶段失败的兜底:记为该平台全部失败并发出 failed 事件,
* 否则前端进度条会停在 running 假象里,错误也被吞掉。 */
function failPlatformPreparation(
platform: typeof platforms[number],
total: number,
result: PlatformResult,
err: unknown,
logPrefix: string,
): void {
const msg = errorMessage(err);
console.error(`[${logPrefix}] ${platform} 准备阶段失败:`, err);
result.fail = total;
result.errors.push(msg);
ee.emit("progress", {
platform,
current: 0,
total,
status: "failed",
errors: result.errors,
} satisfies ProgressEvent);
}
async function runPlatform(
platform: typeof platforms[number],
mcVersions: PublishInput["mcVersions"][keyof PublishInput["mcVersions"]],
@@ -74,35 +136,18 @@ async function runPlatform(
} satisfies ProgressEvent);
const publishFn = platformAdaptors[platform].publish;
const getGameVersionsFn = platformAdaptors[platform].getGameVersions;
// 平台准备阶段(元数据拉取 + 版本范围计算)。此处异常不在逐版本的
// try/catch 内,必须单独兜底:记为该平台全部失败并发出 failed 事件,
// 否则前端进度条会停在 running 假象里,错误也被吞掉。
// try/catch 内,必须单独兜底
let mcVersionEntries: McVersionEntry[];
try {
// 范围必须基于完整项目版本列表计算,勾选只决定发布哪些条目
const selected = new Set(mcVersions.map((v) => v.mc_version));
mcVersionEntries = computeVersionRanges(
ctx.input.allMcVersions,
await getGameVersionsFn(),
ctx.input.cutoffMcVersion
).filter((e) => selected.has(e.version));
if (mcVersionEntries.length !== selected.size) {
throw new Error("勾选版本与项目版本列表不一致,请刷新后重试");
}
} catch (err) {
const msg = errorMessage(err);
console.error(`[publish] ${platform} 准备阶段失败:`, err);
result.fail = total;
result.errors.push(msg);
ee.emit("progress", {
mcVersionEntries = await computeSelectedEntries(
platform,
current: 0,
total,
status: "failed",
errors: result.errors,
} satisfies ProgressEvent);
mcVersions.map((v) => v.mc_version),
ctx,
);
} catch (err) {
failPlatformPreparation(platform, total, result, err, "publish");
return result;
}
@@ -151,6 +196,95 @@ async function runPlatform(
return result;
}
// ── Update orchestrator ────────────────────────────────
async function runPlatformUpdate(
platform: typeof platforms[number],
mcVersions: UpdateInput["mcVersions"][keyof UpdateInput["mcVersions"]],
ctx: PlatformContext<UpdateInput>,
): Promise<PlatformResult> {
const result: PlatformResult = { success: 0, fail: 0, errors: [] };
const total = mcVersions.length;
if (total === 0) {
ee.emit("progress", {
platform,
current: 0,
total: 0,
status: "completed",
errors: [],
} satisfies ProgressEvent);
return result;
}
ee.emit("progress", {
platform,
current: 0,
total,
status: "running",
errors: [],
} satisfies ProgressEvent);
const adaptor = platformAdaptors[platform];
// 准备阶段(范围计算 + 已有版本列表拉取),异常处理同 runPlatform
let mcVersionEntries: McVersionEntry[];
let existingReleases: ExistingRelease[];
try {
mcVersionEntries = await computeSelectedEntries(
platform,
mcVersions.map((v) => v.mc_version),
ctx,
);
existingReleases = await adaptor.listExisting(ctx.config);
} catch (err) {
failPlatformPreparation(platform, total, result, err, "update");
return result;
}
for (let i = 0; i < mcVersionEntries.length; i++) {
const mcVersionEntry = mcVersionEntries[i];
try {
// 服务端按模板重新匹配已有版本,不信任前端传入的任何 ID
const key = adaptor.matchKey(ctx.config, ctx.input.version, mcVersionEntry.version);
const existing = existingReleases.find((e) => e.matchKey === key);
if (!existing) {
throw new Error(`平台上未找到版本 "${key}",请改用发布流程`);
}
await adaptor.update(ctx, mcVersionEntry, existing);
result.success++;
const isLast = i + 1 === total;
ee.emit("progress", {
platform,
current: i + 1,
total,
status: isLast ? "completed" : "running",
errors: result.errors,
} satisfies ProgressEvent);
} catch (err) {
result.fail++;
const msg = `${mcVersionEntry.version}: ${errorMessage(err)}`;
result.errors.push(msg);
ee.emit("progress", {
platform,
current: i,
total,
status: "failed",
errors: result.errors,
} satisfies ProgressEvent);
// Cancel remaining for this platform only
break;
}
}
return result;
}
// ── Router ─────────────────────────────────────────────
export const appRouter = t.router({
@@ -177,6 +311,29 @@ export const appRouter = t.router({
return PublishResultSchema.parse(Object.fromEntries(results.map(result => [result.platform, result.result])));
}),
update: t.procedure
.input(UpdateInputSchema)
.mutation(async ({ input }): Promise<PublishResult> => {
const config = await getConfig(input.configFile);
const ctx: PlatformContext<UpdateInput> = { input, config };
const results = await Promise.all(platforms.map(async (platform) => {
try {
const result = await runPlatformUpdate(platform, input.mcVersions[platform], ctx);
return {platform, result};
} catch (err) {
console.error(`[update] ${platform} 执行异常:`, err);
return {
platform,
result: { success: 0, fail: 0, errors: [errorMessage(err)] }
}
}
}));
return PublishResultSchema.parse(Object.fromEntries(results.map(result => [result.platform, result.result])));
}),
progress: t.procedure.subscription(async function* (opts) {
for await (const [data] of on(ee, "progress", {
signal: opts.signal,