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 { describe, expect, test } from "vitest";
import { CurseForgeClient } from "./client"; import { CurseForgeClient } from "./client";
import { getVersionTypes, getGameVersions } from "./game"; import { getVersionTypes, getGameVersions, getMinecraftVersions } from "./game";
import secrets from "../../../secrets.json"; import secrets from "../../../secrets.json";
const client = new CurseForgeClient(secrets.curseforge); const client = new CurseForgeClient(secrets.curseforge);
@ -18,3 +18,14 @@ describe("getGameVersions", () => {
expect(versions.length).toBeGreaterThan(0); 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 { CurseForgeClient } from "./client";
import { import {
GameVersionTypeSchema, GameVersionTypeSchema,
GameVersionSchema, GameVersionSchema,
MinecraftGameVersionSchema,
type GameVersionType, type GameVersionType,
type GameVersion, type GameVersion,
type MinecraftGameVersion,
} from "@/types"; } from "@/types";
import { createTtlCache } from "@/lib/utils/cache"; import { createTtlCache } from "@/lib/utils/cache";
@ -38,3 +41,16 @@ export const getGameVersions = createTtlCache(
}, },
TTL, 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 { CurseForgeClient } from "./client";
export { getVersionTypes, getGameVersions } from "./game"; export { getVersionTypes, getGameVersions, getMinecraftVersions } from "./game";
export { getFiles, uploadFile, updateFile } from "./file"; export { getFiles, uploadFile, updateFile } from "./file";

View File

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

View File

@ -1,8 +1,9 @@
"use server"; "use server";
import { modrinthClient, curseforgeClient } from "./clients"; import { modrinthClient, curseforgeClient } from "./clients";
import { getLoaders } from "@/lib/modrinth"; import { getLoaders, getGameVersions as getModrinthGameVersions } from "@/lib/modrinth";
import { getVersionTypes, getGameVersions } from "@/lib/curseforge"; import { getVersionTypes, getGameVersions, getMinecraftVersions } from "@/lib/curseforge";
import { SemVer } from "@/lib/utils/templates/semver";
import { EnvironmentSchema } from "@/types"; import { EnvironmentSchema } from "@/types";
const ENVIRONMENT_LABELS: Record<string, string> = { const ENVIRONMENT_LABELS: Record<string, string> = {
@ -27,6 +28,17 @@ export async function getModrinthLoaders(): Promise<string[]> {
return loaders.map((l) => l.name); 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 * Modrinth Ant Design Select
* 使 OptGroup Client / Server / Both / Other * 使 OptGroup Client / Server / Both / Other
@ -77,3 +89,18 @@ export async function getCurseForgeMeta(): Promise<{
return { environments, loaders }; 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 { curseforgeClient } from "@/services/clients";
import { uploadFile, getGameVersions } from "@/lib/curseforge"; import { uploadFile, getGameVersions } from "@/lib/curseforge";
import { resolveVersionName, buildJarPath } from "@/lib/utils/format"; import { resolveVersionName } from "@/lib/utils/format";
import { readAsFile } from "@/lib/utils/file";
import type { UploadMetadata } from "@/types"; import type { UploadMetadata } from "@/types";
import type { PlatformContext } from "./types"; import { PlatformPublishFn } from "../publish.schemas";
export async function publishCurseForgeVersion( export const publishCurseForgeVersion: PlatformPublishFn = async (ctx, mcVersion, files) => {
ctx: PlatformContext,
mcVersion: string,
): Promise<void> {
const { input, config } = ctx; const { input, config } = ctx;
const displayName = resolveVersionName( const displayName = resolveVersionName(config.curseforge.version_name, config, input.version, mcVersion);
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 gameVersions = await getGameVersions(curseforgeClient); const gameVersions = await getGameVersions(curseforgeClient);
@ -40,9 +23,9 @@ export async function publishCurseForgeVersion(
displayName, displayName,
releaseType: input.versionType, releaseType: input.versionType,
gameVersions: [ gameVersions: [
resolveId(mcVersion),
...config.curseforge.loaders.map(resolveId), ...config.curseforge.loaders.map(resolveId),
...config.curseforge.environment.map(resolveId), ...config.curseforge.environment.map(resolveId),
...mcVersion.range.map(resolveId),
], ],
relations: config.curseforge.relations, relations: config.curseforge.relations,
}; };
@ -51,6 +34,6 @@ export async function publishCurseForgeVersion(
curseforgeClient, curseforgeClient,
config.curseforge.project_id, config.curseforge.project_id,
metadata, metadata,
primaryFile, files.primary,
); );
} }

View File

@ -1,54 +1,21 @@
import { modrinthClient } from "@/services/clients"; import { modrinthClient } from "@/services/clients";
import { createVersion } from "@/lib/modrinth"; import { createVersion } from "@/lib/modrinth";
import { resolveVersionName, buildJarPath } from "@/lib/utils/format"; import { resolveVersionName } from "@/lib/utils/format";
import { readAsFile } from "@/lib/utils/file";
import type { CreatableVersion } from "@/types"; import type { CreatableVersion } from "@/types";
import type { PlatformContext } from "./types"; import { PlatformPublishFn } from "../publish.schemas";
export async function publishModrinthVersion( export const publishModrinthVersion: PlatformPublishFn = async (ctx, mcVersion, files) => {
ctx: PlatformContext,
mcVersion: string,
): Promise<void> {
const { input, config } = ctx; const { input, config } = ctx;
const versionNumber = resolveVersionName( const versionNumber = resolveVersionName(config.modrinth.version, ctx.config, input.version, mcVersion);
config.modrinth.version, const versionName = resolveVersionName(config.modrinth.version_name, ctx.config, input.version, mcVersion);
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 data: CreatableVersion = { const data: CreatableVersion = {
project_id: config.modrinth.project_id, project_id: config.modrinth.project_id,
name: versionName, name: versionName,
version_number: versionNumber, version_number: versionNumber,
changelog: input.changelog, changelog: input.changelog,
game_versions: [mcVersion], game_versions: mcVersion.range,
version_type: input.versionType, version_type: input.versionType,
loaders: config.modrinth.loaders, loaders: config.modrinth.loaders,
featured: false, featured: false,
@ -65,7 +32,7 @@ export async function publishModrinthVersion(
}; };
await createVersion(modrinthClient, data, { await createVersion(modrinthClient, data, {
primary: primaryFile, primary: files.primary,
sources: sourceFile, 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"; 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 ──────────────────────────────────────────── // ── Schemas ────────────────────────────────────────────
export const PublishInputSchema = z.object({ export const PublishInputSchema = z.object({
configName: z.string(), configName: z.string(),
version: z.string(), version: z.string(),
mcVersions: z.object({ mcVersions: z.object(Object.fromEntries(platforms.map(platform => [
modrinth: z.array(z.object({ mc_version: z.string() })), platform,
curseforge: z.array(z.object({ mc_version: z.string() })), z.array(z.object({ mc_version: z.string() }))
}), ]))),
cutoffMcVersion: z.string(), // 最后一个版本的截止 MC 版本,两个平台共用
changelog: z.string(), changelog: z.string(),
versionType: z.enum(["release", "beta", "alpha"]), versionType: z.enum(["release", "beta", "alpha"]),
}); });
export const ProgressEventSchema = z.object({ export const ProgressEventSchema = z.object({
platform: z.enum(["modrinth", "curseforge"]), platform: z.enum(platforms),
current: z.number().int().min(0), current: z.number().int().min(0),
total: z.number().int().min(0), total: z.number().int().min(0),
status: z.enum(["running", "completed", "failed"]), status: z.enum(["running", "completed", "failed"]),

View File

@ -7,10 +7,30 @@ import {
type ProgressEvent, type ProgressEvent,
type PlatformResult, type PlatformResult,
type PublishResult, type PublishResult,
platforms,
type PlatformContext,
type PlatformAdaptor,
} from "./publish.schemas"; } from "./publish.schemas";
import { publishModrinthVersion } from "./platforms/modrinth"; import { publishModrinthVersion } from "./platforms/modrinth";
import { publishCurseForgeVersion } from "./platforms/curseforge"; 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 ────────────────────────────────────────── // ── tRPC init ──────────────────────────────────────────
@ -23,7 +43,7 @@ const ee = new EventEmitter();
// ── Platform orchestrator ────────────────────────────── // ── Platform orchestrator ──────────────────────────────
async function runPlatform( async function runPlatform(
platform: "modrinth" | "curseforge", platform: typeof platforms[number],
mcVersions: { mc_version: string }[], mcVersions: { mc_version: string }[],
ctx: PlatformContext, ctx: PlatformContext,
): Promise<PlatformResult> { ): Promise<PlatformResult> {
@ -50,16 +70,29 @@ async function runPlatform(
errors: [], errors: [],
} satisfies ProgressEvent); } satisfies ProgressEvent);
const publishFn = const publishFn = platformAdaptors[platform].publish;
platform === "modrinth" const getGameVersionsFn = platformAdaptors[platform].getGameVersions;
? publishModrinthVersion
: publishCurseForgeVersion;
for (let i = 0; i < mcVersions.length; i++) { const mcVersionEntries = computeVersionRanges(
const { mc_version } = mcVersions[i]; 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 { 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++; result.success++;
const isLast = i + 1 === total; const isLast = i + 1 === total;
@ -72,7 +105,7 @@ async function runPlatform(
} satisfies ProgressEvent); } satisfies ProgressEvent);
} catch (err) { } catch (err) {
result.fail++; result.fail++;
const msg = `${mc_version}: ${errorMessage(err)}`; const msg = `${mcVersionEntry.version}: ${errorMessage(err)}`;
result.errors.push(msg); result.errors.push(msg);
ee.emit("progress", { ee.emit("progress", {
@ -101,10 +134,9 @@ export const appRouter = t.router({
const ctx: PlatformContext = { input, config }; const ctx: PlatformContext = { input, config };
const [modrinthSettled, curseforgeSettled] = await Promise.allSettled([ const [modrinthSettled, curseforgeSettled] = await Promise.allSettled(
runPlatform("modrinth", input.mcVersions.modrinth, ctx), platforms.map(platform => runPlatform(platform, input.mcVersions[platform], ctx))
runPlatform("curseforge", input.mcVersions.curseforge, ctx), );
]);
const modrinth: PlatformResult = modrinthSettled.status === "fulfilled" const modrinth: PlatformResult = modrinthSettled.status === "fulfilled"
? modrinthSettled.value ? modrinthSettled.value

View File

@ -27,3 +27,29 @@ export const GameVersionSchema = z.object({
}); });
export type GameVersion = z.infer<typeof GameVersionSchema>; export type GameVersion = z.infer<typeof GameVersionSchema>;
// endregion // 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 { export {
GameVersionTypeSchema, GameVersionTypeSchema,
GameVersionSchema, GameVersionSchema,
MinecraftGameVersionSchema,
type GameVersionType, type GameVersionType,
type GameVersion, type GameVersion,
type MinecraftGameVersion,
} from "./game"; } from "./game";
export { export {

View File

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