diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index e4cd5b82..b162d62a 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -14,7 +14,9 @@ on: inputs: tag: description: "要发布的 tag(如 @sfmc-bds/sdk@v0.1.0)" - required: true + # 勿 required:true:改本 workflow 的 branch push 会被 GHA 误评估, + # 缺 inputs 时出现空 jobs + "workflow file issue" failure(见 run 29922880511)。 + required: false type: string jobs: diff --git a/db-server/src/routes/_shared.ts b/db-server/src/routes/_shared.ts index 5e9bb322..b94848e4 100644 --- a/db-server/src/routes/_shared.ts +++ b/db-server/src/routes/_shared.ts @@ -59,16 +59,17 @@ export const body = sharedBody; /** * v2 失败信封权威形态(DRY + LSP): - * 一律 `{ ok:false, error, code? }`,勿再混用 `{ success:false }`。 + * 一律 `{ ok:false, error, code? }`(+ 可选 extra),勿再混用 `{ success:false }`。 * service-routes / db / module-config / index 模块鉴权门应走此助手。 */ export function jsonV2Fail( res: ServerResponse, error: string, status: number, - code?: string + code?: string, + extra?: Record ): void { - const payload: Record = { ok: false, error }; + const payload: Record = { ...(extra ?? {}), ok: false, error }; if (code) payload.code = code; sharedJson(res, payload, status); } diff --git a/db-server/src/routes/db-routes.ts b/db-server/src/routes/db-routes.ts index 8eb1fe68..7184bf40 100644 --- a/db-server/src/routes/db-routes.ts +++ b/db-server/src/routes/db-routes.ts @@ -73,7 +73,7 @@ export function createDbRoutes(depsIn: Partial) { const result = deps.schemaRegistry!.define(moduleId, req2); json(res, { success: true, table: result.table, created: result.created }, 200); } catch (e) { - json(res, { success: false, error: (e as Error).message }, 400); + jsonV2Fail(res, (e as Error).message, 400); } return true; } @@ -89,7 +89,7 @@ export function createDbRoutes(depsIn: Partial) { const status = result.ok ? 200 : 400; json(res, result as unknown as Record, status); } catch (e) { - json(res, { success: false, error: (e as Error).message }, 500); + jsonV2Fail(res, (e as Error).message, 500); } return true; } @@ -109,17 +109,17 @@ export function createDbRoutes(depsIn: Partial) { const result = await deps.txRunner!.run({ moduleId, steps: [step] }); if (!result.ok) { const code = result.code === "permission_denied" ? 403 : 400; - json(res, { success: false, error: result.error, step: result.step, code: result.code }, code); + jsonV2Fail(res, result.error, code, result.code, { step: result.step }); return true; } const first = result.results[0]; json(res, { success: true, ...unwrapResult(first, cand.op) }, 200); } catch (e) { if (e instanceof PermissionDeniedError) { - json(res, { success: false, error: e.message, code: "permission_denied" }, 403); + jsonV2Fail(res, e.message, 403, "permission_denied"); return true; } - json(res, { success: false, error: (e as Error).message }, 500); + jsonV2Fail(res, (e as Error).message, 500); } return true; } @@ -136,12 +136,12 @@ export function createDbRoutes(depsIn: Partial) { }; const result = await deps.txRunner!.run({ moduleId, steps: [step] }); if (!result.ok) { - json(res, { success: false, error: result.error }, 400); + jsonV2Fail(res, result.error, 400, result.code); return true; } json(res, { success: true }); } catch (e) { - json(res, { success: false, error: (e as Error).message }, 500); + jsonV2Fail(res, (e as Error).message, 500); } return true; } @@ -166,7 +166,7 @@ export function createDbRoutes(depsIn: Partial) { json(res, { success: r.ok }); } } catch (e) { - json(res, { success: false, error: (e as Error).message }, 500); + jsonV2Fail(res, (e as Error).message, 500); } return true; } diff --git a/db-server/src/runtime.test.ts b/db-server/src/runtime.test.ts index 41000dfb..3d3c3fac 100644 --- a/db-server/src/runtime.test.ts +++ b/db-server/src/runtime.test.ts @@ -76,3 +76,23 @@ test("ServiceRegistry: missing service → no_such_service (not not_in_requires) (e: unknown) => e instanceof DispatchError && e.code === "no_such_service" ); }); + +test("jsonV2Fail: ok 方言 + extra(step) 合并(LSP/DRY)", async () => { + const { jsonV2Fail } = await import("./routes/_shared.js"); + const chunks: Buffer[] = []; + let statusCode = 0; + const res = { + writeHead(code: number) { + statusCode = code; + }, + end(body?: string) { + if (body) chunks.push(Buffer.from(body)); + }, + setHeader() {}, + } as unknown as import("node:http").ServerResponse; + + jsonV2Fail(res, "boom", 400, "bad_step", { step: 2 }); + const payload = JSON.parse(Buffer.concat(chunks).toString("utf8")); + equal(statusCode, 400); + deepEqual(payload, { ok: false, error: "boom", code: "bad_step", step: 2 }); +}); diff --git a/modules/sdk/@sfmc-sdk/src/module-loader/http-data-adapter.ts b/modules/sdk/@sfmc-sdk/src/module-loader/http-data-adapter.ts new file mode 100644 index 00000000..89891e4d --- /dev/null +++ b/modules/sdk/@sfmc-sdk/src/module-loader/http-data-adapter.ts @@ -0,0 +1,22 @@ +/** + * HttpDB → ConfigManager.DataAdapter 适配器(DIP)。 + * ConfigManager 只依赖 DataAdapter 抽象;本文件是 module-loader 侧唯一 HttpDB 装配点。 + */ + +import { HttpDB } from "../sapi/runtime/httpdb.js"; +import type { DataAdapter } from "./internal/config-manager.js"; + +/** 用 HttpDB 实现 DataAdapter;可选覆盖 db-server 基址。 */ +export function createHttpDataAdapter(opts?: { baseUrl?: string }): DataAdapter { + if (opts?.baseUrl) HttpDB.configure({ baseUrl: opts.baseUrl }); + return { + checkHealth: async () => { + await HttpDB.checkHealth(); + }, + getAllConfigs: async () => HttpDB.get("/api/sfmc/configs/all"), + getModules: async () => HttpDB.get("/api/sfmc/modules"), + setAuthToken: (token: string) => { + HttpDB.setAuthToken(token); + }, + }; +} diff --git a/modules/sdk/@sfmc-sdk/src/module-loader/index.ts b/modules/sdk/@sfmc-sdk/src/module-loader/index.ts index b9b186ca..76e66ff4 100644 --- a/modules/sdk/@sfmc-sdk/src/module-loader/index.ts +++ b/modules/sdk/@sfmc-sdk/src/module-loader/index.ts @@ -14,4 +14,5 @@ export type { ModuleLifecycle, ModuleDescriptor } from "./runtime.js"; export { guardEvent, announceLoaded } from "./runtime.js"; export { installHostBootstrap } from "./install.js"; export type { InstallOptions, HostBackend, ModuleSurface } from "./install.js"; +export { createHttpDataAdapter } from "./http-data-adapter.js"; export const SFMC_MODULE_LOADER_VERSION = "0.1.0" as const; diff --git a/modules/sdk/@sfmc-sdk/src/module-loader/install.ts b/modules/sdk/@sfmc-sdk/src/module-loader/install.ts index c87af554..c6e4a731 100644 --- a/modules/sdk/@sfmc-sdk/src/module-loader/install.ts +++ b/modules/sdk/@sfmc-sdk/src/module-loader/install.ts @@ -9,20 +9,19 @@ * 1) system.beforeEvents.startup.subscribe:ConfigManager.init() + bootAll + snapshot * 2) world.afterEvents.worldLoad.subscribe:bootAfterWorldLoad * 3) system.beforeEvents.shutdown.subscribe:teardown - * 4) bindDataAdapter():注入 db-server HTTP 适配器 + * 4) bindDataAdapter():注入 db-server HTTP 适配器(DIP:经 DataAdapter) * 5) 注册 setModuleGuard 给 Command.trigger 使用 - * - * 本批 (Stage A+B):占位实现,host adapters (Command/Permission/HttpDB 等) 在 Stage F - * (core-* 模块迁移)完成后实装。 */ import { system, world } from "@minecraft/server"; +import { createHttpDataAdapter } from "./http-data-adapter.js"; +import type { DataAdapter } from "./internal/config-manager.js"; import { ConfigManager } from "./internal/config-manager.js"; import { ModuleRegistry } from "./runtime.js"; export interface HostBackend { /** 注入 db-server 数据适配器 */ - bindDataAdapter(adapter: unknown): void; + bindDataAdapter(adapter: DataAdapter): void; /** 关闭 db-server HTTP 客户端 */ dispose(): void; } @@ -38,6 +37,11 @@ export interface InstallOptions { dbServerUrl?: string; /** 可选注入自定义 HostBackend(测试用) */ hostBackend?: HostBackend; + /** + * 测试/离线可注入自定义 DataAdapter;默认走 HttpDB 实现。 + * 高层只依赖 DataAdapter 抽象(DIP),不直接依赖 HttpDB。 + */ + dataAdapter?: DataAdapter; /** 模块 id 列表(默认 undefined = 全部装载,从 catalog 读取) */ enabledModuleIds?: readonly string[]; } @@ -45,16 +49,16 @@ export interface InstallOptions { let _installed = false; export function installHostBootstrap(options: InstallOptions = {}): HostBackend { - if (_installed) return _bootstrapStubBackend(); + if (_installed) return _bootstrapBackend(); _installed = true; - // 1) 占位 data adapter:Stage F 之前 ConsoleData 把 readAll 抛 NOT_IMPLEMENTED; - // installHostBootstrap 仍然注册 hooks,只有 ConfigManager.init() 会失败 — - // 这是允许的中间形态。modules/src/index.ts 还可以装运行。 - ConfigManager.bindDataAdapter(stubDataAdapter()); - if (options.hostBackend) options.hostBackend.bindDataAdapter(undefined); + // DIP:ConfigManager ← DataAdapter;默认 HttpDB,可被 options 替换(测试/自定义 host) + const adapter = + options.dataAdapter ?? createHttpDataAdapter(options.dbServerUrl ? { baseUrl: options.dbServerUrl } : undefined); + ConfigManager.bindDataAdapter(adapter); + if (options.hostBackend) options.hostBackend.bindDataAdapter(adapter); - // 2) 装配 system.events + // 装配 system.events system.beforeEvents.startup.subscribe(async () => { try { await ConfigManager.init(); @@ -77,25 +81,18 @@ export function installHostBootstrap(options: InstallOptions = {}): HostBackend } catch {} }); - return _bootstrapStubBackend(); + return _bootstrapBackend(); } -function _bootstrapStubBackend(): HostBackend { +function _bootstrapBackend(): HostBackend { return { - bindDataAdapter: () => undefined, + bindDataAdapter: (adapter: DataAdapter) => { + ConfigManager.bindDataAdapter(adapter); + }, dispose: () => undefined, }; } -function stubDataAdapter() { - return { - checkHealth: async () => undefined, - getAllConfigs: async () => null, - getModules: async () => null, - setAuthToken: () => undefined, - }; -} - function announceLoaded() { // runtime.ts 内的同名 export;此处仅为了不让 TS 报 unused 警告 const _ref = announceLoadedExported; diff --git a/modules/sdk/@sfmc-sdk/src/sapi/runtime/httpdb.ts b/modules/sdk/@sfmc-sdk/src/sapi/runtime/httpdb.ts index 30bbf4db..330d3a12 100644 --- a/modules/sdk/@sfmc-sdk/src/sapi/runtime/httpdb.ts +++ b/modules/sdk/@sfmc-sdk/src/sapi/runtime/httpdb.ts @@ -11,9 +11,8 @@ import { system } from "@minecraft/server"; import { http, HttpRequest, HttpRequestMethod } from "@minecraft/server-net"; import { isSuccessfulHttpEnvelope } from "./http-envelope.js"; -const HOST = "127.0.0.1"; -const PORT = 3001; -const BASE_URL = `http://${HOST}:${PORT}`; +/** 默认连本机 db-server;可通过 configure / InstallOptions.dbServerUrl 覆盖(DIP)。 */ +let baseUrl = "http://127.0.0.1:3001"; const TIMEOUT = 3; export class HttpDB { @@ -21,6 +20,17 @@ export class HttpDB { private static _lastErrorLog = 0; private static authToken = ""; + /** 注入 db-server 基址(如 http://127.0.0.1:4000),去掉末尾 /。 */ + static configure(opts: { baseUrl?: string }): void { + if (opts.baseUrl) { + baseUrl = opts.baseUrl.replace(/\/+$/, ""); + } + } + + static getBaseUrl(): string { + return baseUrl; + } + static setAuthToken(token: string): void { this.authToken = token.trim(); } @@ -51,10 +61,10 @@ export class HttpDB { static async checkHealth(): Promise { for (let i = 0; i < 5; i++) { try { - const res = await http.get(`${BASE_URL}/api/health`); + const res = await http.get(`${baseUrl}/api/health`); this.available = res.status === 200; if (this.available) { - console.info(`[HttpDB] 数据库服务连接成功 (${BASE_URL}/api/health)`); + console.info(`[HttpDB] 数据库服务连接成功 (${baseUrl}/api/health)`); return true; } console.error(`[HttpDB] 数据库服务返回异常状态 ${res.status}`); @@ -64,7 +74,7 @@ export class HttpDB { console.info(`[HttpDB] 连接失败,2s 后重试 (${i + 1}/5)...`); await system.waitTicks(40); } else { - console.error(`[HttpDB] 连接失败 (${BASE_URL}): ${err}`); + console.error(`[HttpDB] 连接失败 (${baseUrl}): ${err}`); } } } @@ -89,7 +99,7 @@ export class HttpDB { bodyData?: Record ): Promise<{ status: number; body: string }> { try { - const req = new HttpRequest(`${BASE_URL}${path}`); + const req = new HttpRequest(`${baseUrl}${path}`); req.timeout = TIMEOUT; req.method = method;