Refactor platform publishing

Introduce a new `getMinecraftVersions` function to fetch Minecraft
versions from
CurseForge with caching, along with its Zod schema and exports. Refactor
the
publishing system to use a unified `PlatformAdaptor` interface, replace
the old
`PlatformContext` type, and update the publish flow to compute version
ranges
and pass files as structured objects. Improve `resolveVersionName` to
accept a
full config object and rework `buildJarPath` to use a Template instance.
Add
server functions to retrieve filtered MC version lists for both Modrinth
and
CurseForge.
This commit is contained in:
CPTProgrammer 2026-07-11 09:23:12 +08:00
parent e8bcd18c76
commit ffcfa9c4f5
No known key found for this signature in database
13 changed files with 191 additions and 102 deletions

View File

@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { CurseForgeClient } from "./client";
import { getVersionTypes, getGameVersions } from "./game";
import { getVersionTypes, getGameVersions, getMinecraftVersions } from "./game";
import secrets from "../../../secrets.json";
const client = new CurseForgeClient(secrets.curseforge);
@ -18,3 +18,14 @@ describe("getGameVersions", () => {
expect(versions.length).toBeGreaterThan(0);
});
});
describe("getMinecraftVersions", () => {
test("GET /v1/minecraft/version", async () => {
const versions = await getMinecraftVersions(client);
expect(versions.length).toBeGreaterThan(0);
for (const v of versions) {
expect(v.versionString).toBeTruthy();
expect(v.versionString).toMatch(/^\d+\.\d+/);
}
});
});

View File

@ -1,9 +1,12 @@
import { z } from "zod";
import { CurseForgeClient } from "./client";
import {
GameVersionTypeSchema,
GameVersionSchema,
MinecraftGameVersionSchema,
type GameVersionType,
type GameVersion,
type MinecraftGameVersion,
} from "@/types";
import { createTtlCache } from "@/lib/utils/cache";
@ -38,3 +41,16 @@ export const getGameVersions = createTtlCache(
},
TTL,
);
/**
* CurseForge Minecraft 1h
* GET /v1/minecraft/version
*/
export const getMinecraftVersions = createTtlCache(
async (client: CurseForgeClient): Promise<MinecraftGameVersion[]> => {
const data = await client.get<unknown>("/v1/minecraft/version");
const parsed = z.object({ data: MinecraftGameVersionSchema.array() }).parse(data);
return parsed.data;
},
TTL,
);

View File

@ -1,3 +1,3 @@
export { CurseForgeClient } from "./client";
export { getVersionTypes, getGameVersions } from "./game";
export { getVersionTypes, getGameVersions, getMinecraftVersions } from "./game";
export { getFiles, uploadFile, updateFile } from "./file";

View File

@ -1,22 +1,30 @@
import path from "node:path";
import { Template } from "@/lib/utils/template";
import type { Config } from "@/types";
import type { McVersionEntry } from "@/lib/utils/mcVersion";
export function resolveVersionName(
template: string,
templates: Record<string, string> | undefined,
values: Record<string, string>,
config: Config,
version: string,
entry: McVersionEntry,
): string {
const tmpl = new Template(template, templates);
return tmpl.format(values);
const tmpl = new Template(template, {
filename_format: config.filename_format,
});
return tmpl.format({
version,
mc_version: entry.version,
mc_version_range: entry.explicit,
});
}
export function buildJarPath(
projectDir: string,
format: string,
template: Template,
version: string,
mcVersion: string,
): string {
const tmpl = new Template(format);
const filename = tmpl.format({ version, mc_version: mcVersion });
const filename = template.format({ version, mc_version: mcVersion });
return path.join(projectDir, "build", "libs", filename);
}

View File

@ -1,8 +1,9 @@
"use server";
import { modrinthClient, curseforgeClient } from "./clients";
import { getLoaders } from "@/lib/modrinth";
import { getVersionTypes, getGameVersions } from "@/lib/curseforge";
import { getLoaders, getGameVersions as getModrinthGameVersions } from "@/lib/modrinth";
import { getVersionTypes, getGameVersions, getMinecraftVersions } from "@/lib/curseforge";
import { SemVer } from "@/lib/utils/templates/semver";
import { EnvironmentSchema } from "@/types";
const ENVIRONMENT_LABELS: Record<string, string> = {
@ -27,6 +28,17 @@ export async function getModrinthLoaders(): Promise<string[]> {
return loaders.map((l) => l.name);
}
/**
* Modrinth Minecraft
* ConfigModal game_versions
*/
export async function getModrinthMcVersions(): Promise<string[]> {
const versions = await getModrinthGameVersions(modrinthClient);
return versions
.filter((v) => v.version_type === "release")
.map((v) => v.version);
}
/**
* Modrinth Ant Design Select
* 使 OptGroup Client / Server / Both / Other
@ -77,3 +89,18 @@ export async function getCurseForgeMeta(): Promise<{
return { environments, loaders };
}
/**
* CurseForge Minecraft
* /v1/minecraft/version
* versionString "preRelease"
*/
export async function getCurseForgeMcVersions(): Promise<string[]> {
const versions = await getMinecraftVersions(curseforgeClient);
return versions
.filter((v) => {
const sv = SemVer.parse(v.versionString);
return sv.preRelease === null;
})
.map((v) => v.versionString);
}

View File

@ -1,30 +1,13 @@
import { curseforgeClient } from "@/services/clients";
import { uploadFile, getGameVersions } from "@/lib/curseforge";
import { resolveVersionName, buildJarPath } from "@/lib/utils/format";
import { readAsFile } from "@/lib/utils/file";
import { resolveVersionName } from "@/lib/utils/format";
import type { UploadMetadata } from "@/types";
import type { PlatformContext } from "./types";
import { PlatformPublishFn } from "../publish.schemas";
export async function publishCurseForgeVersion(
ctx: PlatformContext,
mcVersion: string,
): Promise<void> {
export const publishCurseForgeVersion: PlatformPublishFn = async (ctx, mcVersion, files) => {
const { input, config } = ctx;
const displayName = resolveVersionName(
config.curseforge.version_name,
{ filename_format: config.filename_format },
{ version: input.version, mc_version: mcVersion },
);
const primaryPath = buildJarPath(
config.project_dir,
config.filename_format,
input.version,
mcVersion,
);
const primaryFile = await readAsFile(primaryPath);
const displayName = resolveVersionName(config.curseforge.version_name, config, input.version, mcVersion);
const gameVersions = await getGameVersions(curseforgeClient);
@ -40,9 +23,9 @@ export async function publishCurseForgeVersion(
displayName,
releaseType: input.versionType,
gameVersions: [
resolveId(mcVersion),
...config.curseforge.loaders.map(resolveId),
...config.curseforge.environment.map(resolveId),
...mcVersion.range.map(resolveId),
],
relations: config.curseforge.relations,
};
@ -51,6 +34,6 @@ export async function publishCurseForgeVersion(
curseforgeClient,
config.curseforge.project_id,
metadata,
primaryFile,
files.primary,
);
}

View File

@ -1,54 +1,21 @@
import { modrinthClient } from "@/services/clients";
import { createVersion } from "@/lib/modrinth";
import { resolveVersionName, buildJarPath } from "@/lib/utils/format";
import { readAsFile } from "@/lib/utils/file";
import { resolveVersionName } from "@/lib/utils/format";
import type { CreatableVersion } from "@/types";
import type { PlatformContext } from "./types";
import { PlatformPublishFn } from "../publish.schemas";
export async function publishModrinthVersion(
ctx: PlatformContext,
mcVersion: string,
): Promise<void> {
export const publishModrinthVersion: PlatformPublishFn = async (ctx, mcVersion, files) => {
const { input, config } = ctx;
const versionNumber = resolveVersionName(
config.modrinth.version,
undefined,
{ version: input.version, mc_version: mcVersion },
);
const versionName = resolveVersionName(
config.modrinth.version_name,
undefined,
{
version: input.version,
mc_version: mcVersion,
mc_version_range: mcVersion,
},
);
const primaryPath = buildJarPath(
config.project_dir,
config.filename_format,
input.version,
mcVersion,
);
const sourcePath = buildJarPath(
config.project_dir,
config.source_filename_format,
input.version,
mcVersion,
);
const primaryFile = await readAsFile(primaryPath);
const sourceFile = await readAsFile(sourcePath);
const versionNumber = resolveVersionName(config.modrinth.version, ctx.config, input.version, mcVersion);
const versionName = resolveVersionName(config.modrinth.version_name, ctx.config, input.version, mcVersion);
const data: CreatableVersion = {
project_id: config.modrinth.project_id,
name: versionName,
version_number: versionNumber,
changelog: input.changelog,
game_versions: [mcVersion],
game_versions: mcVersion.range,
version_type: input.versionType,
loaders: config.modrinth.loaders,
featured: false,
@ -65,7 +32,7 @@ export async function publishModrinthVersion(
};
await createVersion(modrinthClient, data, {
primary: primaryFile,
sources: sourceFile,
primary: files.primary,
sources: files.source,
});
}

View File

@ -1,7 +0,0 @@
import { Config } from "@/types";
import type { PublishInput } from "../publish.schemas";
export interface PlatformContext {
input: PublishInput;
config: Config;
}

View File

@ -1,20 +1,44 @@
import { McVersionEntry } from "@/lib/utils/mcVersion";
import { Config } from "@/types";
import { z } from "zod";
export const platforms = ["modrinth", "curseforge"] as const;
export interface PlatformContext {
input: PublishInput;
config: Config;
}
export type PlatformPublishFn = (
ctx: PlatformContext,
mcVersion: McVersionEntry,
files: {
primary: File,
source: File,
}
) => Promise<void>;
export type PlatformGetGameVersionsFn = () => Promise<string[]>;
export type PlatformAdaptor = {
publish: PlatformPublishFn,
getGameVersions: PlatformGetGameVersionsFn,
}
// ── Schemas ────────────────────────────────────────────
export const PublishInputSchema = z.object({
configName: z.string(),
version: z.string(),
mcVersions: z.object({
modrinth: z.array(z.object({ mc_version: z.string() })),
curseforge: z.array(z.object({ mc_version: z.string() })),
}),
mcVersions: z.object(Object.fromEntries(platforms.map(platform => [
platform,
z.array(z.object({ mc_version: z.string() }))
]))),
cutoffMcVersion: z.string(), // 最后一个版本的截止 MC 版本,两个平台共用
changelog: z.string(),
versionType: z.enum(["release", "beta", "alpha"]),
});
export const ProgressEventSchema = z.object({
platform: z.enum(["modrinth", "curseforge"]),
platform: z.enum(platforms),
current: z.number().int().min(0),
total: z.number().int().min(0),
status: z.enum(["running", "completed", "failed"]),

View File

@ -7,10 +7,30 @@ import {
type ProgressEvent,
type PlatformResult,
type PublishResult,
platforms,
type PlatformContext,
type PlatformAdaptor,
} from "./publish.schemas";
import { publishModrinthVersion } from "./platforms/modrinth";
import { publishCurseForgeVersion } from "./platforms/curseforge";
import type { PlatformContext } from "./platforms/types";
import { computeVersionRanges } from "@/lib/utils/mcVersion";
import { getCurseForgeMcVersions, getModrinthMcVersions } from "./meta";
import { readAsFile } from "@/lib/utils/file";
import { Template } from "@/lib/utils/template";
import { buildJarPath } from "@/lib/utils/format";
export const platformAdaptors: {
[key in typeof platforms[number]]: PlatformAdaptor
} = {
modrinth: {
publish: publishModrinthVersion,
getGameVersions: getModrinthMcVersions,
},
curseforge: {
publish: publishCurseForgeVersion,
getGameVersions: getCurseForgeMcVersions,
},
};
// ── tRPC init ──────────────────────────────────────────
@ -23,7 +43,7 @@ const ee = new EventEmitter();
// ── Platform orchestrator ──────────────────────────────
async function runPlatform(
platform: "modrinth" | "curseforge",
platform: typeof platforms[number],
mcVersions: { mc_version: string }[],
ctx: PlatformContext,
): Promise<PlatformResult> {
@ -50,16 +70,29 @@ async function runPlatform(
errors: [],
} satisfies ProgressEvent);
const publishFn =
platform === "modrinth"
? publishModrinthVersion
: publishCurseForgeVersion;
const publishFn = platformAdaptors[platform].publish;
const getGameVersionsFn = platformAdaptors[platform].getGameVersions;
for (let i = 0; i < mcVersions.length; i++) {
const { mc_version } = mcVersions[i];
const mcVersionEntries = computeVersionRanges(
mcVersions.map(v => v.mc_version),
await getGameVersionsFn(),
ctx.input.cutoffMcVersion
);
const primaryFileTemplate = new Template(ctx.config.filename_format);
const sourceFileTemplate = new Template(ctx.config.source_filename_format);
for (let i = 0; i < mcVersionEntries.length; i++) {
const mcVersionEntry = mcVersionEntries[i];
try {
await publishFn(ctx, mc_version);
const primaryFile = await readAsFile(buildJarPath(ctx.config.project_dir, primaryFileTemplate, ctx.input.version, mcVersionEntry.version));
const sourceFile = await readAsFile(buildJarPath(ctx.config.project_dir, sourceFileTemplate, ctx.input.version, mcVersionEntry.version));
await publishFn(ctx, mcVersionEntry, {
primary: primaryFile,
source: sourceFile,
});
result.success++;
const isLast = i + 1 === total;
@ -72,7 +105,7 @@ async function runPlatform(
} satisfies ProgressEvent);
} catch (err) {
result.fail++;
const msg = `${mc_version}: ${errorMessage(err)}`;
const msg = `${mcVersionEntry.version}: ${errorMessage(err)}`;
result.errors.push(msg);
ee.emit("progress", {
@ -101,10 +134,9 @@ export const appRouter = t.router({
const ctx: PlatformContext = { input, config };
const [modrinthSettled, curseforgeSettled] = await Promise.allSettled([
runPlatform("modrinth", input.mcVersions.modrinth, ctx),
runPlatform("curseforge", input.mcVersions.curseforge, ctx),
]);
const [modrinthSettled, curseforgeSettled] = await Promise.allSettled(
platforms.map(platform => runPlatform(platform, input.mcVersions[platform], ctx))
);
const modrinth: PlatformResult = modrinthSettled.status === "fulfilled"
? modrinthSettled.value

View File

@ -27,3 +27,29 @@ export const GameVersionSchema = z.object({
});
export type GameVersion = z.infer<typeof GameVersionSchema>;
// endregion
// region MinecraftGameVersion — GET /v1/minecraft/version 响应元素
export const MinecraftGameVersionSchema = z.object({
/** 版本记录 ID */
id: z.number().int(),
/** Game Version ID */
gameVersionId: z.number().int(),
/** 版本号字符串,如 "1.21.5" */
versionString: z.string(),
/** JAR 文件下载地址 */
jarDownloadUrl: z.string(),
/** JSON 文件下载地址 */
jsonDownloadUrl: z.string(),
/** 是否已审核通过 */
approved: z.boolean(),
/** 最后修改时间ISO 8601 */
dateModified: z.string(),
/** Game Version Type ID */
gameVersionTypeId: z.number().int(),
/** 版本状态1=Approved 2=Deleted 3=New */
gameVersionStatus: z.union([z.literal(1), z.literal(2), z.literal(3)]),
/** 版本类型状态1=Normal 2=Deleted */
gameVersionTypeStatus: z.union([z.literal(1), z.literal(2)]),
});
export type MinecraftGameVersion = z.infer<typeof MinecraftGameVersionSchema>;
// endregion

View File

@ -30,8 +30,10 @@ export {
export {
GameVersionTypeSchema,
GameVersionSchema,
MinecraftGameVersionSchema,
type GameVersionType,
type GameVersion,
type MinecraftGameVersion,
} from "./game";
export {

View File

@ -19,7 +19,7 @@ export const GameVersionTagSchema = z.object({
/** 版本类型release | snapshot | alpha | beta */
version_type: z.enum(["release", "snapshot", "alpha", "beta"]),
/** 版本发布日期ISO-8601 */
date: z.string().datetime(),
date: z.string(),
/** 是否为主要版本,用于 Featured Versions 标记 */
major: z.boolean(),
});