diff --git a/src/lib/curseforge/game.test.ts b/src/lib/curseforge/game.test.ts index 20a258c..83002de 100644 --- a/src/lib/curseforge/game.test.ts +++ b/src/lib/curseforge/game.test.ts @@ -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+/); + } + }); +}); diff --git a/src/lib/curseforge/game.ts b/src/lib/curseforge/game.ts index e1ca387..4f5f112 100644 --- a/src/lib/curseforge/game.ts +++ b/src/lib/curseforge/game.ts @@ -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 => { + const data = await client.get("/v1/minecraft/version"); + const parsed = z.object({ data: MinecraftGameVersionSchema.array() }).parse(data); + return parsed.data; + }, + TTL, +); diff --git a/src/lib/curseforge/index.ts b/src/lib/curseforge/index.ts index cdda887..051cbba 100644 --- a/src/lib/curseforge/index.ts +++ b/src/lib/curseforge/index.ts @@ -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"; diff --git a/src/lib/utils/format.ts b/src/lib/utils/format.ts index db53517..0a65d06 100644 --- a/src/lib/utils/format.ts +++ b/src/lib/utils/format.ts @@ -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 | undefined, - values: Record, + 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); } diff --git a/src/services/meta.ts b/src/services/meta.ts index 2befa1d..8fee3f4 100644 --- a/src/services/meta.ts +++ b/src/services/meta.ts @@ -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 = { @@ -27,6 +28,17 @@ export async function getModrinthLoaders(): Promise { return loaders.map((l) => l.name); } +/** + * 获取 Modrinth 支持的 Minecraft 版本号列表。 + * 用于 ConfigModal 中 game_versions 多选下拉框的选项。 + */ +export async function getModrinthMcVersions(): Promise { + 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 { + const versions = await getMinecraftVersions(curseforgeClient); + return versions + .filter((v) => { + const sv = SemVer.parse(v.versionString); + return sv.preRelease === null; + }) + .map((v) => v.versionString); +} diff --git a/src/services/platforms/curseforge.ts b/src/services/platforms/curseforge.ts index cb1b7e5..194ba94 100644 --- a/src/services/platforms/curseforge.ts +++ b/src/services/platforms/curseforge.ts @@ -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 { +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, ); } diff --git a/src/services/platforms/modrinth.ts b/src/services/platforms/modrinth.ts index e22987d..8b2e086 100644 --- a/src/services/platforms/modrinth.ts +++ b/src/services/platforms/modrinth.ts @@ -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 { +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, }); } diff --git a/src/services/platforms/types.ts b/src/services/platforms/types.ts deleted file mode 100644 index 96f314d..0000000 --- a/src/services/platforms/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Config } from "@/types"; -import type { PublishInput } from "../publish.schemas"; - -export interface PlatformContext { - input: PublishInput; - config: Config; -} diff --git a/src/services/publish.schemas.ts b/src/services/publish.schemas.ts index 243fc40..d8734dd 100644 --- a/src/services/publish.schemas.ts +++ b/src/services/publish.schemas.ts @@ -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; +export type PlatformGetGameVersionsFn = () => Promise; + +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"]), diff --git a/src/services/publish.ts b/src/services/publish.ts index 08439db..69811f2 100644 --- a/src/services/publish.ts +++ b/src/services/publish.ts @@ -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 { @@ -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 diff --git a/src/types/curseforge/game.ts b/src/types/curseforge/game.ts index 3e7bbbf..986a948 100644 --- a/src/types/curseforge/game.ts +++ b/src/types/curseforge/game.ts @@ -27,3 +27,29 @@ export const GameVersionSchema = z.object({ }); export type GameVersion = z.infer; // 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; +// endregion diff --git a/src/types/curseforge/index.ts b/src/types/curseforge/index.ts index 2c65e2a..45521ff 100644 --- a/src/types/curseforge/index.ts +++ b/src/types/curseforge/index.ts @@ -30,8 +30,10 @@ export { export { GameVersionTypeSchema, GameVersionSchema, + MinecraftGameVersionSchema, type GameVersionType, type GameVersion, + type MinecraftGameVersion, } from "./game"; export { diff --git a/src/types/modrinth/tags.ts b/src/types/modrinth/tags.ts index b2677fe..e19b54c 100644 --- a/src/types/modrinth/tags.ts +++ b/src/types/modrinth/tags.ts @@ -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(), });