diff --git a/db-server/src/index.ts b/db-server/src/index.ts index 4cf4470d..d050cfb1 100644 --- a/db-server/src/index.ts +++ b/db-server/src/index.ts @@ -36,7 +36,7 @@ import { ServiceRegistry } from "./service-registry.js"; import { TxRunner } from "./tx-runner.js"; import { registerEnabledBuiltinServices } from "./services/builtin-handlers.js"; -import { readJson, writeJson } from "@sfmc-bds/sdk/node/config"; +import { readJson } from "@sfmc-bds/sdk/node/config"; import { createModuleConfigRoutes } from "./routes/module-config-routes.js"; import { createDbRoutes } from "./routes/db-routes.js"; @@ -49,7 +49,7 @@ import { createMessagesRoutes } from "./routes/messages.js"; import { createModuleRoutes } from "./routes/modules.js"; import { forwardToQQBridge, makeLLBotConfig } from "./domain/bridge.js"; -import { isEnabled, loadModuleLock, updateModuleState } from "./lib/module-state.js"; +import { isEnabled, loadModuleLock, saveModuleLock, updateModuleState } from "./lib/module-state.js"; import { body as sharedBody, json as sharedJson } from "./lib/http.js"; if (!assertNodeVersion(22, 13)) { @@ -153,12 +153,15 @@ function buildModuleList() { id, module_id: id, name: configKey, + configKey, config_key: configKey, display_name: String((raw as Record).name || configKey), type: String((raw as Record).type || "feature"), description: String((raw as Record).description || ""), default_enabled: (raw as Record).enabledByDefault !== false, can_disable: (raw as Record).canDisable !== false, + // ConfigManager 认 installed!==false;已装包默认 true + installed: true, requires: Array.isArray((raw as Record).requires) ? ((raw as Record).requires as unknown[]).filter(Boolean).map(String) : [], @@ -194,7 +197,8 @@ function setModuleEnabled(mod: { id: string; canDisable: boolean }, enabled: boo // 直接更新启动时缓存的 lockFile(而非另读一份新副本), // 否则 buildModuleList() 读的仍是旧缓存,导致启停后 enabled 状态不翻转。 updateModuleState(lockFile, mod.id, { enabled: !!enabled }); - writeJson(env.MODULE_LOCK_PATH, lockFile); + // DRY:与 loadModuleLock 对称走 saveModuleLock,勿散落 writeJson + saveModuleLock(env.MODULE_LOCK_PATH, lockFile); } // ── 平台路由(非模块业务) ─────────────────────────────────── @@ -217,7 +221,13 @@ const messagesRoutes = createMessagesRoutes({ fromId ), }); -const configRoutes = createConfigRoutes({ json, projectRoot: env.PROJECT_ROOT }); +const configRoutes = createConfigRoutes({ + json, + projectRoot: env.PROJECT_ROOT, + // DIP:路由不读 lock/catalog/token 文件,由入口注入与 /modules 同源数据 + listModules: () => buildModuleList() as Array>, + getModuleTokens: () => ({ ...moduleAuth.tokens }), +}); const moduleRoutesInstance = createModuleRoutes({ loadModuleCatalog, buildModuleList: buildModuleList as unknown as () => Array>, diff --git a/db-server/src/routes/_shared.ts b/db-server/src/routes/_shared.ts index b94848e4..5a7fcc5d 100644 --- a/db-server/src/routes/_shared.ts +++ b/db-server/src/routes/_shared.ts @@ -74,5 +74,17 @@ export function jsonV2Fail( sharedJson(res, payload, status); } +/** + * v2 成功信封权威形态(DRY + LSP): + * 一律 `{ ok:true, ...fields }`,与 jsonV2Fail 对称;客户端已双认 ok/success。 + */ +export function jsonV2Ok( + res: ServerResponse, + fields: Record = {}, + status = 200 +): void { + sharedJson(res, { ...fields, ok: true }, status); +} + export {}; diff --git a/db-server/src/routes/config.ts b/db-server/src/routes/config.ts index b022c9a8..792b74e5 100644 --- a/db-server/src/routes/config.ts +++ b/db-server/src/routes/config.ts @@ -19,6 +19,16 @@ import { configPath, readJson, type ConfigName } from "@sfmc-bds/sdk/node/config interface Deps { json: (res: import("http").ServerResponse, data: Record, status?: number) => void; projectRoot: string; + /** + * 注入模块列表(与 GET /api/sfmc/modules 同源 — DRY)。 + * 勿在本路由再读 catalog/lock(DIP:高层装配,路由只消费抽象)。 + */ + listModules?: () => Array>; + /** + * 注入模块 HMAC token 表(仅 loopback 可达;供 SAPI host-bootstrap 注入 + * set*ModuleContext,因 SAPI 不能读 data/module-tokens.json)。 + */ + getModuleTokens?: () => Record; } function isMetaKey(k: string): boolean { @@ -54,13 +64,18 @@ function arrayOrEmpty(v: unknown): unknown[] { return Array.isArray(v) ? v : []; } -function createConfigRoutes({ json, projectRoot }: Deps) { +function createConfigRoutes({ json, projectRoot, listModules, getModuleTokens }: Deps) { /** 仓顶服务 config 读取统一走 SDK;闭包捕获 projectRoot。 */ const readCfg = (name: ConfigName): unknown => readJson(configPath(projectRoot, name)); function getAllConfigs(): Record { + const modules = typeof listModules === "function" ? listModules() : []; + const module_tokens = typeof getModuleTokens === "function" ? getModuleTokens() : {}; return { - modules: [], + // 与 /api/sfmc/modules 同源;ConfigManager.init 一次拉齐启停态(DRY) + modules, + // loopback-only 下发;SAPI 无 fs,靠此注入模块身份(DIP) + module_tokens, settings: stripMeta(readJson(configPath(projectRoot, "settings.json")) as Record | null), areas: (arrayOrEmpty(readJson(configPath(projectRoot, "areas.json"))) as Array>) .filter((r) => r && r.module && r.dimension != null) diff --git a/db-server/src/routes/db-routes.ts b/db-server/src/routes/db-routes.ts index 7184bf40..65a453ec 100644 --- a/db-server/src/routes/db-routes.ts +++ b/db-server/src/routes/db-routes.ts @@ -18,7 +18,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { json as defaultJson, type Method } from "../lib/http.js"; -import { jsonV2Fail, type ModuleAuth } from "./_shared.js"; +import { jsonV2Fail, jsonV2Ok, type ModuleAuth } from "./_shared.js"; import type { DefineTableRequest, SchemaRegistry, @@ -71,7 +71,7 @@ export function createDbRoutes(depsIn: Partial) { const req2 = body as unknown as DefineTableRequest & { moduleId?: string }; delete (req2 as { moduleId?: string }).moduleId; const result = deps.schemaRegistry!.define(moduleId, req2); - json(res, { success: true, table: result.table, created: result.created }, 200); + jsonV2Ok(res, { table: result.table, created: result.created }); } catch (e) { jsonV2Fail(res, (e as Error).message, 400); } @@ -87,6 +87,7 @@ export function createDbRoutes(depsIn: Partial) { const steps = Array.isArray(txReq.steps) ? txReq.steps : []; const result = await deps.txRunner!.run({ moduleId, steps }); const status = result.ok ? 200 : 400; + // TxResponse/TxError 自身已带 ok 字段;原样回传保持契约 json(res, result as unknown as Record, status); } catch (e) { jsonV2Fail(res, (e as Error).message, 500); @@ -113,7 +114,7 @@ export function createDbRoutes(depsIn: Partial) { return true; } const first = result.results[0]; - json(res, { success: true, ...unwrapResult(first, cand.op) }, 200); + jsonV2Ok(res, unwrapResult(first, cand.op) as Record); } catch (e) { if (e instanceof PermissionDeniedError) { jsonV2Fail(res, e.message, 403, "permission_denied"); @@ -139,7 +140,7 @@ export function createDbRoutes(depsIn: Partial) { jsonV2Fail(res, result.error, 400, result.code); return true; } - json(res, { success: true }); + jsonV2Ok(res); } catch (e) { jsonV2Fail(res, (e as Error).message, 500); } @@ -163,7 +164,7 @@ export function createDbRoutes(depsIn: Partial) { String(body.key), body.value ); - json(res, { success: r.ok }); + jsonV2Ok(res, { committed: r.ok }); } } catch (e) { jsonV2Fail(res, (e as Error).message, 500); diff --git a/db-server/src/routes/service-routes.ts b/db-server/src/routes/service-routes.ts index bc10261c..2178fc07 100644 --- a/db-server/src/routes/service-routes.ts +++ b/db-server/src/routes/service-routes.ts @@ -59,11 +59,7 @@ export function createServiceRoutes(depsIn: Partial) { try { payload = JSON.parse(rawInput); } catch (e) { - json( - res, - { ok: false, error: `invalid input json: ${(e as Error).message}`, code: "bad_request" }, - 400 - ); + jsonV2Fail(res, `invalid input json: ${(e as Error).message}`, 400, "bad_request"); return true; } } @@ -80,15 +76,11 @@ export function createServiceRoutes(depsIn: Partial) { json(res, { ok: true, result: out.result }); } catch (e) { if (e instanceof PermissionDeniedError) { - json(res, { ok: false, error: e.message, code: "permission_denied" }, 403); + jsonV2Fail(res, e.message, 403, "permission_denied"); return true; } const err = e as { status?: number; code?: string; message: string }; - json( - res, - { ok: false, error: err.message, code: err.code ?? "internal" }, - err.status ?? 500 - ); + jsonV2Fail(res, err.message, err.status ?? 500, err.code ?? "internal"); } return true; } diff --git a/db-server/src/runtime.test.ts b/db-server/src/runtime.test.ts index cf165026..54315c78 100644 --- a/db-server/src/runtime.test.ts +++ b/db-server/src/runtime.test.ts @@ -97,6 +97,26 @@ test("jsonV2Fail: ok 方言 + extra(step) 合并(LSP/DRY)", async () => { deepEqual(payload, { ok: false, error: "boom", code: "bad_step", step: 2 }); }); +test("jsonV2Ok: 与 Fail 对称 ok 方言(LSP/DRY)", async () => { + const { jsonV2Ok } = 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; + + jsonV2Ok(res, { rows: [1] }); + const payload = JSON.parse(Buffer.concat(chunks).toString("utf8")); + equal(statusCode, 200); + deepEqual(payload, { ok: true, rows: [1] }); +}); + test("normalizeOrderBy: SDK field 与遗留 col / 数组互通(LSP)", async () => { const { normalizeOrderBy } = await import("./lib/order-by.js"); deepEqual(normalizeOrderBy(undefined), []); diff --git a/modules/packages/afk/sapi/src/index.ts b/modules/packages/afk/sapi/src/index.ts index b37e23d4..c963c753 100644 --- a/modules/packages/afk/sapi/src/index.ts +++ b/modules/packages/afk/sapi/src/index.ts @@ -138,13 +138,17 @@ ModuleRegistry.register({ Permission.register("afk.clear.other", Permission.OP); }, async init() { - const cfg = await config.get("afk"); - const safe: AfkConfig = cfg ?? { afk_time: 120, step_time: 15 }; - if (!cfg) { + // getAll 取整份 afk.json(afk_time/step_time);勿用 get("afk")(文件无此键) + const cfg = await config.getAll(); + const hasTime = typeof cfg?.afk_time === "number"; + const safe: AfkConfig = hasTime + ? { afk_time: cfg.afk_time, step_time: typeof cfg.step_time === "number" ? cfg.step_time : 15 } + : { afk_time: 120, step_time: 15 }; + if (!hasTime) { debug.e("AFK", "configs/afk.json missing — using built-in defaults {afk_time:120, step_time:15}"); } - config.onChange("afk", (key, value) => { + config.onChange((key, value) => { debug.i("AFK", `config.<${key}> changed: ${JSON.stringify(value)}`); }); diff --git a/modules/sdk/@sfmc-sdk/src/module-loader/internal/config-manager.ts b/modules/sdk/@sfmc-sdk/src/module-loader/internal/config-manager.ts index 96466ad5..9aa1b79a 100644 --- a/modules/sdk/@sfmc-sdk/src/module-loader/internal/config-manager.ts +++ b/modules/sdk/@sfmc-sdk/src/module-loader/internal/config-manager.ts @@ -35,7 +35,12 @@ export type ConfigKey = | "questions"; type ConfigCache = { + /** 启停态:同时按 catalog id 与 configKey 索引(OCP/LSP) */ modules: Map; + /** catalog id → configKey */ + moduleConfigKeys: Map; + /** catalog id → HMAC module token(来自 configs/all.module_tokens) */ + moduleTokens: Map; settings: Map; areas: any[]; permissions: Record; @@ -47,7 +52,17 @@ type ConfigCache = { }; type AllConfigs = { - modules: Array<{ config_key?: string; configKey?: string; name?: string; enabled?: boolean; installed?: boolean }>; + modules: Array<{ + id?: string; + module_id?: string; + config_key?: string; + configKey?: string; + name?: string; + enabled?: boolean; + installed?: boolean; + }>; + /** loopback 下发的模块 HMAC token;SAPI 无 fs,靠此注入身份(DIP) */ + module_tokens?: Record; settings: Record; areas: Array<{ name?: string; @@ -78,6 +93,8 @@ type AllConfigs = { export class ConfigManager { private static cache: ConfigCache = { modules: new Map(), + moduleConfigKeys: new Map(), + moduleTokens: new Map(), settings: new Map(), areas: [], permissions: {}, @@ -119,11 +136,25 @@ export class ConfigManager { return ConfigManager._ready; } + /** + * 模块是否启用。key 可为 catalog id(feature-afk)或 configKey(afk); + * populate 时双写索引,避免 ModuleRegistry / 旧 Modules 枚举键不一致(LSP)。 + */ static isEnabled(module: string): boolean { if (!ConfigManager._ready) return false; return ConfigManager.cache.modules.get(module) ?? false; } + /** 取模块 HMAC token(来自 configs/all.module_tokens)。 */ + static getModuleToken(moduleId: string): string { + return ConfigManager.cache.moduleTokens.get(moduleId) ?? ""; + } + + /** 取模块 configKey;无则空串。 */ + static getModuleConfigKey(moduleId: string): string { + return ConfigManager.cache.moduleConfigKeys.get(moduleId) ?? ""; + } + static getSetting(key: string, defaultVal?: T): T { const val = ConfigManager.cache.settings.get(key); if (val === undefined) return defaultVal as T; @@ -182,9 +213,9 @@ export class ConfigManager { try { const { modules } = JSON.parse(body); ConfigManager.cache.modules.clear(); + ConfigManager.cache.moduleConfigKeys.clear(); for (const m of modules) { - const key = m.config_key || m.configKey || m.name; - if (key) ConfigManager.cache.modules.set(key, !!m.enabled && m.installed !== false); + ConfigManager._indexModuleEntry(m); } ConfigManager._notifyModuleChanges(); } catch (e) { @@ -194,11 +225,36 @@ export class ConfigManager { // ── Internal ── + private static _indexModuleEntry(m: { + id?: string; + module_id?: string; + config_key?: string; + configKey?: string; + name?: string; + enabled?: boolean; + installed?: boolean; + }): void { + const id = String(m.id || m.module_id || "").trim(); + const key = String(m.config_key || m.configKey || m.name || "").trim(); + const enabled = !!m.enabled && m.installed !== false; + if (id) { + ConfigManager.cache.modules.set(id, enabled); + if (key) ConfigManager.cache.moduleConfigKeys.set(id, key); + } + if (key) ConfigManager.cache.modules.set(key, enabled); + } + private static populate(all: AllConfigs): void { ConfigManager.cache.modules.clear(); + ConfigManager.cache.moduleConfigKeys.clear(); + ConfigManager.cache.moduleTokens.clear(); for (const m of all.modules || []) { - const key = m.config_key || m.configKey || m.name; - if (key) ConfigManager.cache.modules.set(key, !!m.enabled && m.installed !== false); + ConfigManager._indexModuleEntry(m); + } + for (const [id, token] of Object.entries(all.module_tokens || {})) { + if (id && typeof token === "string" && token) { + ConfigManager.cache.moduleTokens.set(id, token); + } } ConfigManager.cache.settings.clear(); diff --git a/modules/sdk/@sfmc-sdk/src/module-loader/internal/module-keys.ts b/modules/sdk/@sfmc-sdk/src/module-loader/internal/module-keys.ts index 0412816b..2684e354 100644 --- a/modules/sdk/@sfmc-sdk/src/module-loader/internal/module-keys.ts +++ b/modules/sdk/@sfmc-sdk/src/module-loader/internal/module-keys.ts @@ -29,4 +29,8 @@ export const Modules = { } as const; export type ModuleKey = (typeof Modules)[keyof typeof Modules]; -export type ModuleId = keyof typeof Modules; +/** + * OCP:模块 id 为开放字符串(catalog/manifest id,如 feature-afk); + * Modules 枚举仅作旧别名,新模块不必改此表。 + */ +export type ModuleId = string; diff --git a/modules/sdk/@sfmc-sdk/src/module-loader/runtime.ts b/modules/sdk/@sfmc-sdk/src/module-loader/runtime.ts index 92bee075..696775ae 100644 --- a/modules/sdk/@sfmc-sdk/src/module-loader/runtime.ts +++ b/modules/sdk/@sfmc-sdk/src/module-loader/runtime.ts @@ -1,6 +1,13 @@ import { system } from "@minecraft/server"; import { ConfigManager } from "./internal/config-manager.js"; import { ModuleId, Modules } from "./internal/module-keys.js"; +import { setConfigModuleContext, clearConfigModuleContext } from "../sapi/config/client.js"; +import { + setDbModuleContext, + clearDbModuleContext, + isDbTxRecording, +} from "../sapi/db/client.js"; +import { setServiceModuleContext, clearServiceModuleContext } from "../sapi/service/client.js"; // Command 类尚未迁入 @sfmc-bds/sdk (Stage F 之后实装)。本批 (Stage A+B) 把所有 // Command.unregister / Command.unregisterByModule 调用换成 stub,行为等价 noop; // 实际命令注销由 modules 自己在 cleanup() 中调各自的 unregister 接口(已存在)。 @@ -17,6 +24,10 @@ export type ModuleLifecycle = { }; export type ModuleDescriptor = { + /** + * 模块身份:优先用 catalog/manifest id(如 feature-afk)。 + * OCP:新模块不必改 Modules 枚举;旧键(afk)仍可通过 Modules 别名解析启停。 + */ id: ModuleId; afterWorldLoad?: boolean; lifecycle: ModuleLifecycle; @@ -25,11 +36,38 @@ export type ModuleDescriptor = { type CleanUpFn = () => void; const descriptors: ModuleDescriptor[] = []; -const cleanups = new Map(); -const booted = new Set(); +const cleanups = new Map(); +const booted = new Set(); const lastEnabled = new Map(); let worldLoaded = false; +/** 启停查询键:catalog id 本身 + 旧 Modules 枚举映射的 configKey。 */ +function enableKeysFor(id: ModuleId): string[] { + const keys = [id]; + const legacy = (Modules as Record)[id]; + if (legacy && legacy !== id) keys.push(legacy); + const configKey = ConfigManager.getModuleConfigKey(id); + if (configKey && !keys.includes(configKey)) keys.push(configKey); + return keys; +} + +/** 启动前注入 db/config/service 模块身份(DIP:token 来自 configs/all,非 fs)。 */ +function applyModuleAuthContext(id: ModuleId): void { + const token = ConfigManager.getModuleToken(id); + const configKey = ConfigManager.getModuleConfigKey(id) || (Modules as Record)[id] || ""; + if (!token) { + console.warn( + `[Module:${id}] 无 module token(configs/all.module_tokens 缺失);` + + ` v2 db/config/service 调用将 401` + ); + } + setDbModuleContext(id, token); + setServiceModuleContext(id, token, isDbTxRecording); + if (configKey) { + setConfigModuleContext(id, configKey, token); + } +} + export class ModuleRegistry { static register(descriptor: ModuleDescriptor): void { descriptors.push(descriptor); @@ -44,7 +82,8 @@ export class ModuleRegistry { } static isActive(id: ModuleId): boolean { - return ConfigManager.isEnabled(Modules[id]); + // 任一索引键启用即视为 active(id / legacy Modules / configKey) + return enableKeysFor(id).some((k) => ConfigManager.isEnabled(k)); } static trackCleanup(modId: ModuleId, fn: CleanUpFn): void { @@ -70,7 +109,7 @@ export class ModuleRegistry { static snapshotEnabled(): void { for (const d of descriptors) { - lastEnabled.set(Modules[d.id], ModuleRegistry.isActive(d.id)); + lastEnabled.set(d.id, ModuleRegistry.isActive(d.id)); } } @@ -82,9 +121,8 @@ export class ModuleRegistry { if (!ConfigManager.isReady()) return []; const changes: Array<{ id: ModuleId; action: "disable" | "enable" }> = []; for (const d of descriptors) { - const key = Modules[d.id]; const cur = ModuleRegistry.isActive(d.id); - const prev = lastEnabled.has(key) ? lastEnabled.get(key)! : cur; + const prev = lastEnabled.has(d.id) ? lastEnabled.get(d.id)! : cur; if (prev === cur) continue; if (prev && !cur) { try { @@ -101,7 +139,7 @@ export class ModuleRegistry { } changes.push({ id: d.id, action: "enable" }); } - lastEnabled.set(key, cur); + lastEnabled.set(d.id, cur); } return changes; } @@ -121,6 +159,7 @@ export class ModuleRegistry { if (!d.afterWorldLoad) continue; if (!ModuleRegistry.isActive(d.id)) continue; try { + applyModuleAuthContext(d.id); d.lifecycle.init?.(); } catch (e) { console.warn(`[Module:${d.id}] init failed: ${(e as Error).message || e}`); @@ -134,6 +173,7 @@ export class ModuleRegistry { if (d.afterWorldLoad) continue; if (!ModuleRegistry.isActive(d.id)) continue; try { + applyModuleAuthContext(d.id); d.lifecycle.init?.(); } catch (e) { console.warn(`[Module:${d.id}] task start failed: ${(e as Error).message || e}`); @@ -147,6 +187,7 @@ export class ModuleRegistry { if (!ModuleRegistry.isActive(id)) return; if (booted.has(id)) return; try { + applyModuleAuthContext(id); d.lifecycle.registerPermissions?.(); d.lifecycle.registerCommands?.(); d.lifecycle.registerEvents?.(); @@ -168,9 +209,11 @@ export class ModuleRegistry { } catch (e) { console.warn(`[Module:${id}] cleanup hook failed: ${(e as Error).message || e}`); } - // 2. 注销模块持有的命令 + // 2. 注销模块持有的命令(用 catalog id;旧 Modules 别名作次选) try { - _cmdUnregisterByModule(Modules[id]); + _cmdUnregisterByModule(id); + const legacy = (Modules as Record)[id]; + if (legacy) _cmdUnregisterByModule(legacy); } catch {} // 3. 注销模块注册的事件订阅 / runInterval const fns = cleanups.get(id); @@ -185,6 +228,10 @@ export class ModuleRegistry { cleanups.set(id, []); } booted.delete(id); + // 清身份避免禁用后仍带旧 token 调用(Demeter:只清本模块上下文) + clearDbModuleContext(); + clearConfigModuleContext(); + clearServiceModuleContext(); } static teardown(): void { diff --git a/modules/sdk/@sfmc-sdk/src/sapi/config/client.ts b/modules/sdk/@sfmc-sdk/src/sapi/config/client.ts index 1de788bb..4c281ba5 100644 --- a/modules/sdk/@sfmc-sdk/src/sapi/config/client.ts +++ b/modules/sdk/@sfmc-sdk/src/sapi/config/client.ts @@ -3,37 +3,66 @@ * * 设计: * - 首次访问时,SDK 发 GET /api/sfmc/configs/ 拉全,缓存在 _cache - * - get(key):同步从 _cache 读 + * - get(key):从 _cache 读单字段 + * - getAll():整份配置对象 * - set(key, value):写 _cache + 发 POST /api/sfmc/configs//set 持久化 * - onChange:订阅 set 触发的内存变更 * * 为什么不放 ConfigManager:ConfigManager 现有缓存只展平 settings.json; * 模块私有 configKey(land.json / economy.json)按 configKey 隔离,需要新机制。 + * + * 多模块:按 configKey 分桶缓存(OCP),bootModule 注入时不互相清空。 */ -import { HttpDB } from "../runtime/httpdb.js"; +import { HttpDB, type HttpRequestAuthOpts } from "../runtime/httpdb.js"; import { HttpRequestMethod } from "@minecraft/server-net"; -let _moduleId = ""; -let _configKey = ""; -let _authToken = ""; -const _cache = new Map(); -let _loadPromise: Promise | null = null; +type ConfigBucket = { + moduleId: string; + authToken: string; + cache: Map; + loadPromise: Promise | null; +}; + +/** configKey → 桶;支持多模块并存(OCP)。 */ +const _buckets = new Map(); +/** 最近一次 setConfigModuleContext 的 configKey(兼容无参 get/set)。 */ +let _activeConfigKey = ""; export function setConfigModuleContext(moduleId: string, configKey: string, token: string): void { - _moduleId = moduleId; - _configKey = configKey; - _authToken = token; - _cache.clear(); - _loadPromise = null; + const existing = _buckets.get(configKey); + if (existing && existing.moduleId === moduleId && existing.authToken === token) { + _activeConfigKey = configKey; + return; + } + _buckets.set(configKey, { + moduleId, + authToken: token, + cache: new Map(), + loadPromise: null, + }); + _activeConfigKey = configKey; } export function clearConfigModuleContext(): void { - _moduleId = ""; - _configKey = ""; - _authToken = ""; - _cache.clear(); - _loadPromise = null; + _buckets.clear(); + _activeConfigKey = ""; +} + +function activeBucket(): ConfigBucket { + if (!_activeConfigKey) { + throw new Error("[config] 模块上下文未初始化,setConfigModuleContext 未调用"); + } + const b = _buckets.get(_activeConfigKey); + if (!b) { + throw new Error(`[config] 找不到 configKey=${_activeConfigKey} 的上下文`); + } + return b; +} + +function authOpts(token: string): HttpRequestAuthOpts | undefined { + const t = (token || "").trim(); + return t ? { authToken: t } : undefined; } /** @@ -41,51 +70,57 @@ export function clearConfigModuleContext(): void { * verifyModuleAuth 只认 query 上的 moduleId,body 里的 moduleId 不算数; * 之前 config 漏带 query → 即便注入了 token 也会 401(LSP/DRY 违规)。 */ -function withModuleId(path: string): string { - return HttpDB.withModuleId(path, _moduleId); +function withModuleId(path: string, moduleId: string): string { + return HttpDB.withModuleId(path, moduleId); } -async function ensureLoaded(): Promise { - if (!_configKey) { - throw new Error("[config] 模块上下文未初始化,setConfigModuleContext 未调用"); - } - if (_loadPromise) return _loadPromise; - _loadPromise = (async () => { +async function ensureLoaded(bucket: ConfigBucket, configKey: string): Promise { + if (bucket.loadPromise) return bucket.loadPromise; + bucket.loadPromise = (async () => { const res = await HttpDB.typedRequest<{ config: Record }>( HttpRequestMethod.GET, - withModuleId(`/api/sfmc/configs/${encodeURIComponent(_configKey)}`), + withModuleId(`/api/sfmc/configs/${encodeURIComponent(configKey)}`, bucket.moduleId), undefined, - { authToken: _authToken } + authOpts(bucket.authToken) ); if (res.ok && res.data) { for (const [k, v] of Object.entries(res.data.config ?? {})) { - _cache.set(k, v); + bucket.cache.set(k, v); } } })(); - return _loadPromise; + return bucket.loadPromise; } const _changeHandlers = new Set<(key: string, value: unknown) => void>(); export const config = { async get(key: string): Promise { - await ensureLoaded(); - return _cache.get(key) as T | undefined; + const bucket = activeBucket(); + await ensureLoaded(bucket, _activeConfigKey); + return bucket.cache.get(key) as T | undefined; + }, + + /** 整份模块配置(afk.json 顶层字段一次取齐)。 */ + async getAll>(): Promise { + const bucket = activeBucket(); + await ensureLoaded(bucket, _activeConfigKey); + const out: Record = {}; + for (const [k, v] of bucket.cache.entries()) out[k] = v; + return out as T; }, async set(key: string, value: T): Promise { - if (!_configKey) { - throw new Error("[config] 模块上下文未初始化"); - } + const bucket = activeBucket(); + const configKey = _activeConfigKey; const res = await HttpDB.typedRequest<{ ok: true }>( HttpRequestMethod.POST, - withModuleId(`/api/sfmc/configs/${encodeURIComponent(_configKey)}/set`), + withModuleId(`/api/sfmc/configs/${encodeURIComponent(configKey)}/set`, bucket.moduleId), { key, value }, - { authToken: _authToken } + authOpts(bucket.authToken) ); if (res.ok) { - _cache.set(key, value); + bucket.cache.set(key, value); for (const h of _changeHandlers) h(key, value); } else { throw new Error(`[config] set 失败: ${res.error ?? "unknown"}`); diff --git a/modules/sdk/@sfmc-sdk/src/sapi/db/client.ts b/modules/sdk/@sfmc-sdk/src/sapi/db/client.ts index e3a5e4ff..f654d412 100644 --- a/modules/sdk/@sfmc-sdk/src/sapi/db/client.ts +++ b/modules/sdk/@sfmc-sdk/src/sapi/db/client.ts @@ -10,20 +10,19 @@ * (避免与 ConfigManager / 其它模块互相覆盖 — DIP) */ -import { HttpDB } from "../runtime/httpdb.js"; +import { HttpDB, type HttpRequestAuthOpts } from "../runtime/httpdb.js"; import { HttpRequestMethod } from "@minecraft/server-net"; import type { ColumnDef, DeleteResult, InsertResult, QueryOptions, - TxError, TxResponse, TxStep, UpdateResult, } from "./types.js"; -/* ── 模块身份(由 installHostBootstrap 注入) ─────────────────────── */ +/* ── 模块身份(由 ModuleRegistry.bootModule → setDbModuleContext 注入) ── */ let _moduleId = ""; let _authToken = ""; @@ -40,6 +39,11 @@ export function clearDbModuleContext(): void { _currentTxId = null; } +/** 供 service 客户端判断是否处于 db.tx 录制期(LSP:与 service.get 互斥)。 */ +export function isDbTxRecording(): boolean { + return _currentTxId != null; +} + /* ── HTTP 辅助 ──────────────────────────────────────────────────── */ export class DbError extends Error { @@ -56,15 +60,38 @@ function withModuleId(path: string): string { return HttpDB.withModuleId(path, _moduleId); } +/** 空串不传 authToken,避免 ?? 被 "" 挡住 ConfigManager 默认回落(DIP)。 */ +function authOpts(): HttpRequestAuthOpts | undefined { + const t = (_authToken || "").trim(); + return t ? { authToken: t } : undefined; +} + +function requireModuleContext(op: string): void { + if (!_moduleId) { + throw new DbError( + `[db.${op}] 模块上下文未初始化:setDbModuleContext 未调用(host-bootstrap/ModuleRegistry)`, + "unauthorized", + 0 + ); + } +} + async function post(path: string, body: unknown): Promise { const res = await HttpDB.typedRequest( HttpRequestMethod.POST, withModuleId(path), body as Record, - { authToken: _authToken } + authOpts() ); if (!res.ok) { - throw new DbError(res.error ?? "db_server_error", "internal", res.status); + // LSP:保留服务端 code/step(尤其 /db/tx),勿一律打成 internal + const data = res.data as { error?: string; code?: string; step?: number } | undefined; + const code = data?.code || "internal"; + const msg = + data?.step != null + ? `事务在 step ${data.step} 失败: ${data.error ?? res.error ?? "db_server_error"}` + : data?.error || res.error || "db_server_error"; + throw new DbError(msg, code, res.status); } return res.data as T; } @@ -84,6 +111,7 @@ function txReadNotSupported(op: string): never { export const db = { /** 模块 init 时调,声明自己要哪些表。db-server schema-registry 收集后建表。 */ async defineTable(name: string, columns: Record, opts?: { softDelete?: boolean }): Promise { + requireModuleContext("defineTable"); if (_currentTxId) throw new DbError("defineTable 不可在事务内调用", "forbidden", 0); await post("/api/sfmc/db/define-table", { name, @@ -96,6 +124,7 @@ export const db = { table: string, opts?: QueryOptions ): Promise { + requireModuleContext("query"); if (_currentTxId) throw new DbError("query 不可直接调,请用 db.tx", "use_tx", 0); const res = await post<{ rows: T[] }>("/api/sfmc/db/query", { table, opts: opts ?? {} }); return res.rows; @@ -105,12 +134,14 @@ export const db = { table: string, id: string | number ): Promise { + requireModuleContext("get"); if (_currentTxId) throw new DbError("get 不可直接调,请用 db.tx", "use_tx", 0); const res = await post<{ row: T | null }>("/api/sfmc/db/get", { table, id: String(id) }); return res.row; }, async insert>(table: string, row: T): Promise { + requireModuleContext("insert"); if (_currentTxId) throw new DbError("insert 不可直接调,请用 db.tx", "use_tx", 0); const res = await post("/api/sfmc/db/insert", { table, row }); return res.row as T; @@ -121,12 +152,14 @@ export const db = { id: string | number, patch: Partial ): Promise { + requireModuleContext("update"); if (_currentTxId) throw new DbError("update 不可直接调,请用 db.tx", "use_tx", 0); const res = await post("/api/sfmc/db/update", { table, id: String(id), patch }); return res.row as T; }, async delete(table: string, id: string | number, opts?: { hard?: boolean }): Promise { + requireModuleContext("delete"); if (_currentTxId) throw new DbError("delete 不可直接调,请用 db.tx", "use_tx", 0); await post("/api/sfmc/db/delete", { table, @@ -138,9 +171,11 @@ export const db = { /** * 事务:边界在 db-server 进程。失败自动回滚,成功提交。 * 录制期:写操作(insert/update/delete/audit/call)可入队;query/get 显式抛错, - * 避免返回 []/null 假数据破坏 LSP。call 可入队但返回值不可用(两阶段协议另议)。 + * 避免返回 []/null 假数据破坏 LSP。call 为 fire-and-forget(Promise), + * 完整两阶段读回协议另议。 */ async tx(fn: (tx: TxContext) => Promise): Promise { + requireModuleContext("tx"); if (_currentTxId) throw new DbError("嵌套事务暂不支持", "nested_tx", 0); const steps: TxStep[] = []; _currentTxId = "pending"; @@ -162,9 +197,9 @@ export const db = { table: string, id: string | number, patch: Partial - ): Promise => { + ): Promise => { + // LSP:录制期无完整行可读回;勿把 patch 假扮成 U push({ op: "update", table, id: String(id), patch }); - return patch as U; }, delete: async (table: string, id: string | number, opts?: { hard?: boolean }) => { push({ op: "delete", table, id: String(id), hard: opts?.hard ?? false }); @@ -173,10 +208,9 @@ export const db = { if (data) push({ op: "audit", table, rowId: String(rowId), action, data }); else push({ op: "audit", table, rowId: String(rowId), action }); }, - call: async (name: string, input: Record): Promise => { + call: async (name: string, input: Record): Promise => { push({ op: "service", name, input }); - // 录制期无服务端 result 可回放;勿依赖返回值 - return undefined as unknown as U; + // 录制期无服务端 result 可回放;契约为 void(与 service.get 区分 — LSP) }, }; @@ -188,16 +222,15 @@ export const db = { throw e; } - const res = await post("/api/sfmc/db/tx", { steps }); + // post 在 !ok 时已抛带 step/code 的 DbError;此处只需成功路径 + await post("/api/sfmc/db/tx", { steps }); _currentTxId = null; - if (!res.ok) { - throw new DbError(`事务在 step ${res.step} 失败: ${res.error}`, res.code, 0); - } return userResult; }, /** 平台预置:审计日志(自动写 _audit 表) */ async audit(table: string, rowId: string | number, action: string, data?: Record): Promise { + requireModuleContext("audit"); if (_currentTxId) throw new DbError("audit 不可直接调,请用 db.tx", "use_tx", 0); if (data) await post("/api/sfmc/db/audit", { table, rowId: String(rowId), action, data }); else await post("/api/sfmc/db/audit", { table, rowId: String(rowId), action }); @@ -205,6 +238,7 @@ export const db = { /** 平台预置:幂等执行(同 action+key 不会重复) */ async idempotent(action: string, key: string, fn: () => Promise): Promise { + requireModuleContext("idempotent"); const probe = await post<{ replayed: boolean }>("/api/sfmc/db/idempotent/probe", { action, key }); if (probe.replayed) { return undefined as unknown as T; @@ -225,12 +259,14 @@ export interface TxContext { id: string | number ): Promise; insert>(table: string, row: T): Promise; + /** 录制期无行读回,返回 void(与事务外 update→T 区分)。 */ update>( table: string, id: string | number, patch: Partial - ): Promise; + ): Promise; delete(table: string, id: string | number, opts?: { hard?: boolean }): Promise; audit(table: string, rowId: string | number, action: string, data?: Record): Promise; - call(name: string, input: Record): Promise; + /** fire-and-forget:入队 service step,不返回 result。 */ + call(name: string, input: Record): Promise; } diff --git a/modules/sdk/@sfmc-sdk/src/sapi/db/index.ts b/modules/sdk/@sfmc-sdk/src/sapi/db/index.ts index 7e296763..d6178259 100644 --- a/modules/sdk/@sfmc-sdk/src/sapi/db/index.ts +++ b/modules/sdk/@sfmc-sdk/src/sapi/db/index.ts @@ -12,7 +12,7 @@ * - 模块不能 require("fs");只能走这里 */ -export { db, setDbModuleContext, clearDbModuleContext, DbError } from "./client.js"; +export { db, setDbModuleContext, clearDbModuleContext, isDbTxRecording, DbError } from "./client.js"; export type { TxContext } from "./client.js"; export type { ColumnDef, diff --git a/modules/sdk/@sfmc-sdk/src/sapi/runtime/httpdb.ts b/modules/sdk/@sfmc-sdk/src/sapi/runtime/httpdb.ts index 1cdaeff6..9d8b6913 100644 --- a/modules/sdk/@sfmc-sdk/src/sapi/runtime/httpdb.ts +++ b/modules/sdk/@sfmc-sdk/src/sapi/runtime/httpdb.ts @@ -39,6 +39,15 @@ export class HttpDB { this.authToken = token.trim(); } + /** + * 解析本次请求 Bearer:请求级非空 token 优先;空串视为未传,回落进程默认(DIP)。 + * 勿用 `opts?.authToken ?? default` — `""` 会挡住回落。 + */ + static resolveAuthToken(opts?: HttpRequestAuthOpts): string { + const fromOpts = typeof opts?.authToken === "string" ? opts.authToken.trim() : ""; + return fromOpts || this.authToken.trim(); + } + /** * 给路径附上 ?moduleId= / &moduleId=(db/config/service 客户端共用,DRY)。 * verifyModuleAuth 只认 query 上的 moduleId。 @@ -112,8 +121,8 @@ export class HttpDB { req.body = JSON.stringify(bodyData); req.addHeader("Content-Type", "application/json"); } - // 请求级 token 优先,否则回落 DataAdapter 默认(ConfigManager) - const token = (opts?.authToken ?? this.authToken).trim(); + // 请求级非空 token 优先;空串视为未传,回落 DataAdapter 默认(DIP) + const token = HttpDB.resolveAuthToken(opts); if (token) req.addHeader("Authorization", `Bearer ${token}`); const res = await http.request(req); diff --git a/modules/sdk/@sfmc-sdk/src/sapi/service/client.ts b/modules/sdk/@sfmc-sdk/src/sapi/service/client.ts index 31208681..f852d2d4 100644 --- a/modules/sdk/@sfmc-sdk/src/sapi/service/client.ts +++ b/modules/sdk/@sfmc-sdk/src/sapi/service/client.ts @@ -10,7 +10,7 @@ * - service.list:列所有 enabled 模块 provides 的 service */ -import { HttpDB } from "../runtime/httpdb.js"; +import { HttpDB, type HttpRequestAuthOpts } from "../runtime/httpdb.js"; import { HttpRequestMethod } from "@minecraft/server-net"; let _moduleId = ""; @@ -48,8 +48,24 @@ function withModuleId(path: string): string { return HttpDB.withModuleId(path, _moduleId); } +function authOpts(): HttpRequestAuthOpts | undefined { + const t = (_authToken || "").trim(); + return t ? { authToken: t } : undefined; +} + +function requireModuleContext(op: string): void { + if (!_moduleId) { + throw new ServiceError( + `[service.${op}] 模块上下文未初始化:setServiceModuleContext 未调用`, + "unauthorized", + 0 + ); + } +} + export const service = { async get(name: string, input: Record = {}): Promise { + requireModuleContext("get"); if (_isInTx()) { throw new ServiceError( "事务内调 service 必须用 db.tx 的 tx.call(name, input),不能用 service.get", @@ -62,7 +78,7 @@ export const service = { HttpRequestMethod.GET, withModuleId(`/api/sfmc/services/${encodeURIComponent(name)}?${qs}`), undefined, - { authToken: _authToken } + authOpts() ); if (!res.ok) { throw new ServiceError(res.error ?? "service_error", "internal", res.status); @@ -71,11 +87,12 @@ export const service = { }, async list(): Promise { + requireModuleContext("list"); const res = await HttpDB.typedRequest<{ services: ServiceInfo[] }>( HttpRequestMethod.GET, withModuleId("/api/sfmc/services"), undefined, - { authToken: _authToken } + authOpts() ); if (res.ok && res.data) return res.data.services; return []; diff --git a/package-lock.json b/package-lock.json index a5cdac5c..1ecd5825 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3165,7 +3165,7 @@ }, "tools": { "name": "@sfmc-bds/tools", - "version": "0.1.0", + "version": "0.1.1", "license": "AGPL-3.0-only", "bin": { "sfmc-catalog-sync": "catalog-sync.mjs", diff --git a/tools/check-ootb.mjs b/tools/check-ootb.mjs index a04ad526..b71242a7 100755 --- a/tools/check-ootb.mjs +++ b/tools/check-ootb.mjs @@ -45,7 +45,7 @@ async function main() { else fail("Node 版本", `需要 ≥22.13,当前 ${process.versions.node}`); } - // 1) 仓库必需文件 + // 1) 仓库必需文件(发布包 package.json 从 NPM_PUBLISH_PACKAGES 派生 — DRY) { const required = [ ".gitignore", @@ -54,13 +54,10 @@ async function main() { "README.md", "modules/catalog.json", "modules/module-lock.json", - "db-server/package.json", - "qq-bridge/package.json", - "sfmc/package.json", - "bds-tools/package.json", "tools/fetch-module.mjs", "tools/catalog-sync.mjs", "tools/check-modules.mjs", + ...Object.values(NPM_PUBLISH_PACKAGES), ]; const missing = required.filter((f) => !exists(path.join(ROOT, f))); if (missing.length === 0) pass("必备仓库文件齐全"); @@ -131,6 +128,14 @@ async function main() { if (mods.status !== 200 || !Array.isArray(mods.body.modules)) { throw new Error(`modules 接口异常 ${mods.status}`); } + // configs/all 须含 modules(与 /modules 同源) + module_tokens(SAPI 鉴权注入 — DIP) + const all = await requestJson({ port: 3001, method: "GET", path: "/api/sfmc/configs/all" }); + if (all.status !== 200 || !Array.isArray(all.body.modules)) { + throw new Error(`configs/all.modules 异常 ${all.status}`); + } + if (!all.body.module_tokens || typeof all.body.module_tokens !== "object") { + throw new Error("configs/all 缺少 module_tokens"); + } pass(`db-server 启动 + 平台 API (modules=${mods.body.modules.length})`); } catch (e) { fail("db-server 启动 + 平台 API", e.message); diff --git a/tools/lib/npm-publish-packages.mjs b/tools/lib/npm-publish-packages.mjs index 121991bb..00b6bc94 100644 --- a/tools/lib/npm-publish-packages.mjs +++ b/tools/lib/npm-publish-packages.mjs @@ -59,6 +59,17 @@ export function assertPublishPackageInWorkspaces(pkgName, repoRoot = process.cwd throw new Error(`Unknown publish package: ${pkgName}`); } const pkgPath = NPM_PUBLISH_PACKAGES[resolved]; + const absPkg = path.join(repoRoot, pkgPath); + // DRY:清单路径须真实存在且 name 与键一致(防路径漂移 / 漏建包) + if (!fs.existsSync(absPkg)) { + throw new Error(`${resolved} 清单路径不存在: ${pkgPath}`); + } + const pkgJson = JSON.parse(fs.readFileSync(absPkg, "utf8")); + if (pkgJson.name !== resolved) { + throw new Error( + `${resolved} 清单路径 ${pkgPath} 的 name 为 ${JSON.stringify(pkgJson.name)},不一致(DRY)` + ); + } const workspaceDir = path.posix.dirname(pkgPath.replace(/\\/g, "/")); const rootPkgPath = path.join(repoRoot, "package.json"); const rootPkg = JSON.parse(fs.readFileSync(rootPkgPath, "utf8")); @@ -70,5 +81,5 @@ export function assertPublishPackageInWorkspaces(pkgName, repoRoot = process.cwd ` npm -w 会失败。请把该目录写入 package.json#workspaces(DRY)。` ); } - return { workspaceDir, workspaces }; + return { workspaceDir, workspaces, version: pkgJson.version }; } diff --git a/tools/package.json b/tools/package.json index a7fc45b9..f36048bd 100644 --- a/tools/package.json +++ b/tools/package.json @@ -1,6 +1,6 @@ { "name": "@sfmc-bds/tools", - "version": "0.1.0", + "version": "0.1.1", "description": "SFMC dev tools - testing before build", "type": "module", "license": "AGPL-3.0-only", diff --git a/tools/resolve-npm-publish-tag.mjs b/tools/resolve-npm-publish-tag.mjs index 2a982a7d..b0c52623 100644 --- a/tools/resolve-npm-publish-tag.mjs +++ b/tools/resolve-npm-publish-tag.mjs @@ -52,6 +52,10 @@ const pkgPath = NPM_PUBLISH_PACKAGES[resolved]; const actual = JSON.parse(fs.readFileSync(pkgPath, "utf8")).version; if (actual !== ver) { console.error(`Tag version ${ver} != package.json version ${actual} (${pkgPath})`); + console.error( + `修复:先把 ${pkgPath} bump 到 ${ver} 并合入默认分支,再 workflow_dispatch 本 tag` + + `(checkout 默认分支),或删除后重打指向含新版本 commit 的 tag。` + ); process.exit(1); }