diff --git a/db-server/src/index.ts b/db-server/src/index.ts index 3085a505..3ed6ff04 100644 --- a/db-server/src/index.ts +++ b/db-server/src/index.ts @@ -23,13 +23,7 @@ import http from "node:http"; import { createIdempotencyStore } from "./lib/idempotency-store.js"; import { loadEnv } from "./env.js"; -import { - buildModuleAuth, - ensureModuleToken, - persistModuleAuth, - revokeModuleToken, - verifyModuleAuth, -} from "./module-auth.js"; +import { buildModuleAuth, verifyModuleAuth } from "./module-auth.js"; import { loadManifestV2 } from "./manifest-loader.js"; import { log } from "./lib/log.js"; import { assertNodeVersion } from "./lib/runtime.js"; @@ -40,11 +34,8 @@ import { initSchema } from "./domain/schema.js"; import { SchemaRegistry } from "./schema-registry.js"; import { ServiceRegistry } from "./service-registry.js"; import { TxRunner } from "./tx-runner.js"; -import { - registerBuiltinPluginForModule, - registerEnabledBuiltinServices, - unregisterBuiltinPluginForModule, -} from "./services/builtin-handlers.js"; +import { registerEnabledBuiltinServices } from "./services/builtin-handlers.js"; +import { syncModuleRuntimeState } from "./module-runtime-sync.js"; import { readJson } from "@sfmc-bds/sdk/node/config"; @@ -169,7 +160,7 @@ function buildModuleList() { 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, + can_disable: moduleCanDisable(raw as Record), // ConfigManager 认 installed!==false;已装包默认 true installed: true, requires: Array.isArray((raw as Record).requires) @@ -193,14 +184,25 @@ function buildModuleList() { .filter(Boolean); } +/** catalog 省略 canDisable 时默认允许禁用(与 buildModuleList.can_disable 同源)。 */ +function moduleCanDisable(raw: Record): boolean { + return raw.canDisable !== false; +} + function resolveModuleByKey(key: string) { const k = String(key || "").trim(); const catalog = loadModuleCatalog(); - return catalog.find( + const raw = catalog.find( (m) => String((m as Record).id || "") === k || String((m as Record).configKey || (m as Record).config_key || "") === k - ) as { id: string; configKey: string; canDisable: boolean } | null; + ) as Record | undefined; + if (!raw) return null; + const id = String(raw.id || "").trim(); + const configKey = String(raw.configKey || raw.config_key || "").trim(); + if (!id || !configKey) return null; + /* LSP:与 buildModuleList.can_disable 同源 — 省略字段视为可禁用 */ + return { id, configKey, canDisable: moduleCanDisable(raw) }; } function setModuleEnabled(mod: { id: string; canDisable: boolean }, enabled: boolean) { @@ -210,43 +212,19 @@ function setModuleEnabled(mod: { id: string; canDisable: boolean }, enabled: boo // DRY:与 loadModuleLock 对称走 saveModuleLock,勿散落 writeJson saveModuleLock(env.MODULE_LOCK_PATH, lockFile); - // 热更新运行时图(PR #31 未完成项):enabledSet / tokens / manifests / builtin handlers - // 与启动期同源,避免「lock 已开但鉴权仍 401 / handler 未注册」。 - syncRuntimeEnabled(mod.id, !!enabled); -} - -/** - * 启停后同步 db-server 进程内运行时状态(不重启)。 - * TxRunner / serviceRoutes 持有 enabledManifests 引用,Map 就地改即可。 - */ -function syncRuntimeEnabled(moduleId: string, enabled: boolean): void { - if (enabled) { - const manifest = loadedManifest.modules[moduleId]; - if (!manifest) { - log.warn(`[modules] 热启用 ${moduleId}: manifest 缺失(未安装?),仅写 lock`); - return; - } - enabledSet.add(moduleId); - enabledManifests.set(moduleId, manifest); - if (ensureModuleToken(moduleAuth, moduleId)) { - log.info(`[modules] 热启用 ${moduleId}: 派生 module token`); - } - persistModuleAuth(env.PROJECT_ROOT, moduleAuth, env.AUTH_TOKEN); - if (registerBuiltinPluginForModule(serviceRegistry, { query, db }, moduleId)) { - log.success(`[modules] 热启用 ${moduleId}: 已注册内置 service handlers`); - } - return; - } - - enabledSet.delete(moduleId); - enabledManifests.delete(moduleId); - if (revokeModuleToken(moduleAuth, moduleId)) { - persistModuleAuth(env.PROJECT_ROOT, moduleAuth, env.AUTH_TOKEN); - } - const n = unregisterBuiltinPluginForModule(serviceRegistry, moduleId); - if (n > 0) { - log.info(`[modules] 热禁用 ${moduleId}: 卸下 ${n} 个内置 handlers`); - } + // DIP:热同步 enabledSet / tokens / manifests / builtin handlers(不重启 db-server) + syncModuleRuntimeState({ + moduleId: mod.id, + enabled: !!enabled, + projectRoot: env.PROJECT_ROOT, + envAuthToken: env.AUTH_TOKEN, + enabledSet, + enabledManifests, + loadedManifest, + moduleAuth, + serviceRegistry, + builtinDeps: { query, db }, + }); } // ── 平台路由(非模块业务) ─────────────────────────────────── diff --git a/db-server/src/module-runtime-sync.ts b/db-server/src/module-runtime-sync.ts new file mode 100644 index 00000000..c7f3ed62 --- /dev/null +++ b/db-server/src/module-runtime-sync.ts @@ -0,0 +1,90 @@ +/** + * module-runtime-sync.ts — enable/disable 时同步进程内运行态 + * + * lock 落盘后,内存态(enabledSet / enabledManifests / moduleAuth.tokens / + * builtin service handlers)必须与之一致,否则「只重启 BDS」拿不到新 token + * (DIP:路由只依赖注入的集合,不感知启停实现)。 + * + * DRY:token 落盘 / 内置 handler 启停复用 module-auth 与 builtin-handlers, + * 不在此处再写一份派生与卸载逻辑。 + */ + +import type { ModuleManifestV2 } from "./manifest-loader.js"; +import { + ensureModuleToken, + persistModuleAuth, + revokeModuleToken, + type ModuleAuthMap, +} from "./module-auth.js"; +import { + registerBuiltinPluginForModule, + unregisterBuiltinPluginForModule, + type BuiltinServiceDeps, +} from "./services/builtin-handlers.js"; +import type { ServiceRegistry } from "./service-registry.js"; +import { log } from "./lib/log.js"; + +export type LoadedModules = { + modules: Record; +}; + +export type SyncModuleRuntimeOpts = { + moduleId: string; + enabled: boolean; + projectRoot: string; + /** 与 buildModuleAuth 一致:无 AUTH_TOKEN 时 secretGenerated=true */ + envAuthToken: string; + enabledSet: Set; + enabledManifests: Map; + loadedManifest: LoadedModules; + moduleAuth: ModuleAuthMap; + serviceRegistry: ServiceRegistry; + builtinDeps: BuiltinServiceDeps; +}; + +/** + * 同步 enable/disable 后的进程内集合与 token 文件。 + * 调用方负责先写 lock;本函数不碰磁盘 lock。 + */ +export function syncModuleRuntimeState(opts: SyncModuleRuntimeOpts): void { + const { + moduleId, + enabled, + projectRoot, + envAuthToken, + enabledSet, + enabledManifests, + loadedManifest, + moduleAuth, + serviceRegistry, + builtinDeps, + } = opts; + + if (enabled) { + const manifest = loadedManifest.modules[moduleId]; + if (!manifest) { + log.warn(`[modules] 热启用 ${moduleId}: manifest 缺失(未安装?),仅写 lock`); + return; + } + enabledSet.add(moduleId); + enabledManifests.set(moduleId, manifest); + if (ensureModuleToken(moduleAuth, moduleId)) { + log.info(`[modules] 热启用 ${moduleId}: 派生 module token`); + } + persistModuleAuth(projectRoot, moduleAuth, envAuthToken); + if (registerBuiltinPluginForModule(serviceRegistry, builtinDeps, moduleId)) { + log.success(`[modules] 热启用 ${moduleId}: 已注册内置 service handlers`); + } + return; + } + + enabledSet.delete(moduleId); + enabledManifests.delete(moduleId); + if (revokeModuleToken(moduleAuth, moduleId)) { + persistModuleAuth(projectRoot, moduleAuth, envAuthToken); + } + const n = unregisterBuiltinPluginForModule(serviceRegistry, moduleId); + if (n > 0) { + log.info(`[modules] 热禁用 ${moduleId}: 卸下 ${n} 个内置 handlers`); + } +} diff --git a/db-server/src/routes/config.ts b/db-server/src/routes/config.ts index 792b74e5..95d93fad 100644 --- a/db-server/src/routes/config.ts +++ b/db-server/src/routes/config.ts @@ -71,50 +71,24 @@ function createConfigRoutes({ json, projectRoot, listModules, getModuleTokens }: function getAllConfigs(): Record { const modules = typeof listModules === "function" ? listModules() : []; const module_tokens = typeof getModuleTokens === "function" ? getModuleTokens() : {}; + /** + * SAPI ConfigManager 契约:banned_items 为 string[](与 GET /banned_items 同源)。 + * 勿再映射成 {item_id} — 否则 filter(typeof s === "string") 会得到空缓存(LSP)。 + * 其余资源复用单资源 helpers,避免 getAllConfigs 与专用路由双写(DRY)。 + */ return { // 与 /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) - .map((r) => stripMetaDeep(r)), - permissions: ( - arrayOrEmpty(readJson(configPath(projectRoot, "permissions.json"))) as Array> - ) - .filter((r) => r && r.player_name) - .map((r) => stripMetaDeep(r)), - banned_items: (arrayOrEmpty(readJson(configPath(projectRoot, "banned_items.json"))) as Array) - .filter((i) => typeof i === "string" && i && !i.startsWith("_")) - .map((id) => ({ item_id: id })), - clean: stripMetaDeep(readJson(configPath(projectRoot, "clean.json")) ?? {}), - grids: (arrayOrEmpty(readJson(configPath(projectRoot, "grids.json"))) as Array>) - .filter((r) => r && r.name) - .map((r) => stripMetaDeep(r)), - peace_filters: ( - arrayOrEmpty(readJson(configPath(projectRoot, "peace_filters.json"))) as Array> - ) - .filter((r) => r && r.family) - .map((r) => stripMetaDeep(r)), - questions: (arrayOrEmpty(readJson(configPath(projectRoot, "questions.json"))) as Array>) - .filter((r) => r && r.question) - .map((r, idx: number) => { - const clean = stripMetaDeep(r) as Record; - return { - id: idx + 1, - weight: clean.weight ?? 1, - question: clean.question, - answers: clean.answers ?? [], - msg_right: clean.msg_right ?? "", - msg_wrong: clean.msg_wrong ?? "", - explanation: clean.explanation ?? "", - min_rank: clean.min_rank ?? null, - max_rank: clean.max_rank ?? null, - rewards: clean.rewards ?? [], - punishments: clean.punishments ?? [], - }; - }), + settings: stripMeta(readCfg("settings.json") as Record | null), + areas: getAreas(), + permissions: getPermissions(), + banned_items: getBannedItems(), + clean: getClean(), + grids: getGrids(), + peace_filters: getPeaceFilters(), + questions: getQA(), }; } @@ -153,10 +127,10 @@ function createConfigRoutes({ json, projectRoot, listModules, getModuleTokens }: .map((r) => stripMetaDeep(r)); } - function getBannedItems(): Array<{ item_id: string }> { - return (arrayOrEmpty(readCfg("banned_items.json")) as Array) - .filter((i) => typeof i === "string" && i && !i.startsWith("_")) - .map((id) => ({ item_id: id })); + function getBannedItems(): string[] { + return (arrayOrEmpty(readCfg("banned_items.json")) as Array).filter( + (i) => typeof i === "string" && i && !i.startsWith("_") + ); } function getClean(): { item_max: number; poll_interval: number } { @@ -248,7 +222,7 @@ function createConfigRoutes({ json, projectRoot, listModules, getModuleTokens }: } if (requestPath === "/api/sfmc/banned_items") { if (method === "GET") { - json(res, { items: getBannedItems().map((x) => x.item_id) }); + json(res, { items: getBannedItems() }); return true; } } diff --git a/db-server/src/runtime.test.ts b/db-server/src/runtime.test.ts index e51a85e2..6e9fa3d9 100644 --- a/db-server/src/runtime.test.ts +++ b/db-server/src/runtime.test.ts @@ -131,6 +131,70 @@ test("normalizeOrderBy: SDK field 与遗留 col / 数组互通(LSP)", async () = throws(() => normalizeOrderBy({ dir: "asc" }), /field\/col/); }); +test("syncModuleRuntimeState: enable/disable 热更新 token+enabledSet(DIP)", async () => { + const { mkdtempSync, rmSync, readFileSync } = await import("node:fs"); + const { join } = await import("node:path"); + const { tmpdir } = await import("node:os"); + const { syncModuleRuntimeState } = await import("./module-runtime-sync.js"); + const { deriveToken } = await import("./module-auth.js"); + const { unregisterBuiltinPluginForModule } = await import("./services/builtin-handlers.js"); + + const root = mkdtempSync(join(tmpdir(), "sfmc-runtime-sync-")); + try { + const enabledSet = new Set(["feature-a"]); + const enabledManifests = new Map(); + const moduleAuth = { tokens: { "feature-a": "old" } as Record, secret: "test-secret" }; + const registry = new ServiceRegistry(); + const fakeManifest = { + id: "feature-b", + version: "1.0.0", + permissions: [] as string[], + services: { provides: [], requires: [] }, + db: { tables: [] }, + config: { key: "b" }, + } as unknown as import("./manifest-loader.js").ModuleManifestV2; + + syncModuleRuntimeState({ + moduleId: "feature-b", + enabled: true, + projectRoot: root, + envAuthToken: "fixed-auth", + enabledSet, + enabledManifests, + loadedManifest: { modules: { "feature-b": fakeManifest } }, + moduleAuth, + serviceRegistry: registry, + builtinDeps: { query: (() => []) as never, db: {} as never }, + }); + + equal(enabledSet.has("feature-b"), true); + equal(enabledManifests.has("feature-b"), true); + equal(moduleAuth.tokens["feature-b"], deriveToken("feature-b", "test-secret")); + const store = JSON.parse(readFileSync(join(root, "data", "module-tokens.json"), "utf8")); + equal(store.tokens["feature-b"], moduleAuth.tokens["feature-b"]); + + syncModuleRuntimeState({ + moduleId: "feature-b", + enabled: false, + projectRoot: root, + envAuthToken: "fixed-auth", + enabledSet, + enabledManifests, + loadedManifest: { modules: { "feature-b": fakeManifest } }, + moduleAuth, + serviceRegistry: registry, + builtinDeps: { query: (() => []) as never, db: {} as never }, + }); + + equal(enabledSet.has("feature-b"), false); + equal(enabledManifests.has("feature-b"), false); + equal(moduleAuth.tokens["feature-b"], undefined); + equal(unregisterBuiltinPluginForModule(registry, "feature-b"), 0); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("module-auth: ensureModuleToken / revoke 复用 secret(DRY)", async () => { const { deriveToken, ensureModuleToken, revokeModuleToken } = await import("./module-auth.js"); const secret = "test-secret"; 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 9aa1b79a..7b340e0d 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 @@ -74,7 +74,8 @@ type AllConfigs = { end_z: number; }>; permissions: Array<{ player_name: string; level: number }>; - banned_items: string[]; + /** 权威为 string[];兼容历史 {item_id} */ + banned_items: Array; clean: { item_max?: number; poll_interval?: number }; grids: Array<{ name: string; @@ -275,7 +276,10 @@ export class ConfigManager { ConfigManager.cache.permissions[p.player_name] = p.level; } - ConfigManager.cache.bannedItems = (all.banned_items || []).filter((s) => typeof s === "string"); + // 权威契约为 string[];兼容历史 {item_id} 以免旧服务端把缓存清空(LSP 防御) + ConfigManager.cache.bannedItems = (all.banned_items || []) + .map((s) => (typeof s === "string" ? s : s && typeof s === "object" ? String((s as { item_id?: unknown }).item_id || "") : "")) + .filter((id) => !!id); if (all.clean) { ConfigManager.cache.clean = { diff --git a/modules/sdk/@sfmc-sdk/src/sapi/service/client.ts b/modules/sdk/@sfmc-sdk/src/sapi/service/client.ts index 70fa0d80..a51a98a6 100644 --- a/modules/sdk/@sfmc-sdk/src/sapi/service/client.ts +++ b/modules/sdk/@sfmc-sdk/src/sapi/service/client.ts @@ -81,7 +81,9 @@ export const service = { authOpts() ); if (!res.ok) { - throw new ServiceError(res.error ?? "service_error", "internal", res.status); + // LSP:与 db 客户端一致,保留服务端 code,勿一律打成 internal + const data = res.data as { error?: string; code?: string } | undefined; + throw new ServiceError(data?.error ?? res.error ?? "service_error", data?.code || "internal", res.status); } return (res.data as { ok: true; result: T }).result; }, diff --git a/sfmc/src/module-commands.ts b/sfmc/src/module-commands.ts index 778f4612..6a8ea6e5 100644 --- a/sfmc/src/module-commands.ts +++ b/sfmc/src/module-commands.ts @@ -30,9 +30,6 @@ export const MODULE_CMD_NAMES = ["module", "mod"] as const; export type ModuleCmdName = (typeof MODULE_CMD_NAMES)[number]; -/** HELP 中主名+别名展示串(权威来源 MODULE_CMD_NAMES),如 "module/mod"。 */ -export const MODULE_CMD_ALIAS_LABEL = MODULE_CMD_NAMES.join("/"); - /** 判断是否为 module 顶层命令(含别名);避免 main/repl 再硬编码 case。 */ export function isModuleCommand(cmd: string | undefined): cmd is ModuleCmdName { return !!cmd && (MODULE_CMD_NAMES as readonly string[]).includes(cmd); @@ -56,6 +53,7 @@ export const MODULE_SUBCOMMANDS = [ "disable", ] as const; +/** Usage 行主名|别名(与 MODULE_CMD_NAMES 同源,避免与 HELP 漂移)。 */ export const MODULE_USAGE = `Usage: sfmc ${MODULE_CMD_NAMES.join("|")} <${MODULE_SUBCOMMANDS.join("|")}> [args]`; diff --git a/sfmc/src/repl.ts b/sfmc/src/repl.ts index dffc1c7d..e602f302 100644 --- a/sfmc/src/repl.ts +++ b/sfmc/src/repl.ts @@ -21,8 +21,8 @@ function setRaw(v: boolean): void { } catch {} } -/** HELP 行首:把 MODULE_CMD_NAMES 着色后用 / 拼接(如 module/mod)。 */ -const MODULE_HELP_LABEL = MODULE_CMD_NAMES.map((n) => c.green(n)).join("/"); +/** HELP 行首:染色后的 module 别名标签(权威来源 paintModuleCmdAlias / MODULE_CMD_NAMES)。 */ +const MODULE_HELP_LABEL = paintModuleCmdAlias(c.green); const welcome = `\n ${c.text(`⠪⡁⡯⠁`)} @@ -53,19 +53,19 @@ ${c.bold("Commands")} ${c.green("remote enroll")} [name] Enroll this supervisor with a controller ${c.green("remote disable")} Disable + disconnect remote agent - ${paintModuleCmdAlias(c.green)} list + ${MODULE_HELP_LABEL} list List installed modules - ${paintModuleCmdAlias(c.green)} search [id] + ${MODULE_HELP_LABEL} search [id] Fetch registry list / show one module's registry info - ${paintModuleCmdAlias(c.green)} install [--from ] + ${MODULE_HELP_LABEL} install [--from ] Fetch + install a module - ${paintModuleCmdAlias(c.green)} uninstall + ${MODULE_HELP_LABEL} uninstall Remove an installed module - ${paintModuleCmdAlias(c.green)} verify [id] + ${MODULE_HELP_LABEL} verify [id] Verify installed modules (SHA-256) - ${paintModuleCmdAlias(c.green)} info + ${MODULE_HELP_LABEL} info Show one installed module's details - ${paintModuleCmdAlias(c.green)} enable|disable + ${MODULE_HELP_LABEL} enable|disable Toggle module (needs db-server) ${c.green("version")} Show version ${c.green("help")} Show this diff --git a/tools/check-ootb.mjs b/tools/check-ootb.mjs index 05589522..7cbba2dc 100755 --- a/tools/check-ootb.mjs +++ b/tools/check-ootb.mjs @@ -136,6 +136,13 @@ async function main() { if (!all.body.module_tokens || typeof all.body.module_tokens !== "object") { throw new Error("configs/all 缺少 module_tokens"); } + // LSP:configs/all.banned_items 须为 string[](与 /banned_items、ConfigManager 同源) + if (Array.isArray(all.body.banned_items)) { + const bad = all.body.banned_items.find((x) => typeof x !== "string"); + if (bad !== undefined) { + throw new Error(`configs/all.banned_items 须为 string[],收到 ${typeof bad}`); + } + } pass(`db-server 启动 + 平台 API (modules=${mods.body.modules.length})`); } catch (e) { fail("db-server 启动 + 平台 API", e.message);