Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 30 additions & 52 deletions db-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -169,7 +160,7 @@ function buildModuleList() {
type: String((raw as Record<string, unknown>).type || "feature"),
description: String((raw as Record<string, unknown>).description || ""),
default_enabled: (raw as Record<string, unknown>).enabledByDefault !== false,
can_disable: (raw as Record<string, unknown>).canDisable !== false,
can_disable: moduleCanDisable(raw as Record<string, unknown>),
// ConfigManager 认 installed!==false;已装包默认 true
installed: true,
requires: Array.isArray((raw as Record<string, unknown>).requires)
Expand All @@ -193,14 +184,25 @@ function buildModuleList() {
.filter(Boolean);
}

/** catalog 省略 canDisable 时默认允许禁用(与 buildModuleList.can_disable 同源)。 */
function moduleCanDisable(raw: Record<string, unknown>): 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<string, unknown>).id || "") === k ||
String((m as Record<string, unknown>).configKey || (m as Record<string, unknown>).config_key || "") === k
) as { id: string; configKey: string; canDisable: boolean } | null;
) as Record<string, unknown> | 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) {
Expand All @@ -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 },
});
}

// ── 平台路由(非模块业务) ───────────────────────────────────
Expand Down
90 changes: 90 additions & 0 deletions db-server/src/module-runtime-sync.ts
Original file line number Diff line number Diff line change
@@ -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<string, ModuleManifestV2 | undefined>;
};

export type SyncModuleRuntimeOpts = {
moduleId: string;
enabled: boolean;
projectRoot: string;
/** 与 buildModuleAuth 一致:无 AUTH_TOKEN 时 secretGenerated=true */
envAuthToken: string;
enabledSet: Set<string>;
enabledManifests: Map<string, ModuleManifestV2>;
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`);
}
}
62 changes: 18 additions & 44 deletions db-server/src/routes/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,50 +71,24 @@ function createConfigRoutes({ json, projectRoot, listModules, getModuleTokens }:
function getAllConfigs(): Record<string, unknown> {
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<string, unknown> | null),
areas: (arrayOrEmpty(readJson(configPath(projectRoot, "areas.json"))) as Array<Record<string, unknown>>)
.filter((r) => r && r.module && r.dimension != null)
.map((r) => stripMetaDeep(r)),
permissions: (
arrayOrEmpty(readJson(configPath(projectRoot, "permissions.json"))) as Array<Record<string, unknown>>
)
.filter((r) => r && r.player_name)
.map((r) => stripMetaDeep(r)),
banned_items: (arrayOrEmpty(readJson(configPath(projectRoot, "banned_items.json"))) as Array<string>)
.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<Record<string, unknown>>)
.filter((r) => r && r.name)
.map((r) => stripMetaDeep(r)),
peace_filters: (
arrayOrEmpty(readJson(configPath(projectRoot, "peace_filters.json"))) as Array<Record<string, unknown>>
)
.filter((r) => r && r.family)
.map((r) => stripMetaDeep(r)),
questions: (arrayOrEmpty(readJson(configPath(projectRoot, "questions.json"))) as Array<Record<string, unknown>>)
.filter((r) => r && r.question)
.map((r, idx: number) => {
const clean = stripMetaDeep(r) as Record<string, unknown>;
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<string, unknown> | null),
areas: getAreas(),
permissions: getPermissions(),
banned_items: getBannedItems(),
clean: getClean(),
grids: getGrids(),
peace_filters: getPeaceFilters(),
questions: getQA(),
};
}

Expand Down Expand Up @@ -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<string>)
.filter((i) => typeof i === "string" && i && !i.startsWith("_"))
.map((id) => ({ item_id: id }));
function getBannedItems(): string[] {
return (arrayOrEmpty(readCfg("banned_items.json")) as Array<string>).filter(
(i) => typeof i === "string" && i && !i.startsWith("_")
);
}

function getClean(): { item_max: number; poll_interval: number } {
Expand Down Expand Up @@ -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;
}
}
Expand Down
64 changes: 64 additions & 0 deletions db-server/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(["feature-a"]);
const enabledManifests = new Map();
const moduleAuth = { tokens: { "feature-a": "old" } as Record<string, string>, 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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | { item_id?: string }>;
clean: { item_max?: number; poll_interval?: number };
grids: Array<{
name: string;
Expand Down Expand Up @@ -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 = {
Expand Down
Loading
Loading