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
52 changes: 50 additions & 2 deletions db-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ import http from "node:http";

import { createIdempotencyStore } from "./lib/idempotency-store.js";
import { loadEnv } from "./env.js";
import { buildModuleAuth, verifyModuleAuth } from "./module-auth.js";
import {
buildModuleAuth,
ensureModuleToken,
persistModuleAuth,
revokeModuleToken,
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 @@ -34,7 +40,11 @@ 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 { registerEnabledBuiltinServices } from "./services/builtin-handlers.js";
import {
registerBuiltinPluginForModule,
registerEnabledBuiltinServices,
unregisterBuiltinPluginForModule,
} from "./services/builtin-handlers.js";

import { readJson } from "@sfmc-bds/sdk/node/config";

Expand Down Expand Up @@ -199,6 +209,44 @@ function setModuleEnabled(mod: { id: string; canDisable: boolean }, enabled: boo
updateModuleState(lockFile, mod.id, { enabled: !!enabled });
// 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`);
}
}

// ── 平台路由(非模块业务) ───────────────────────────────────
Expand Down
28 changes: 28 additions & 0 deletions db-server/src/module-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,34 @@ export function loadModuleAuth(projectRoot: string): ModuleAuthMap | null {
return { tokens: parsed.tokens, secret: parsed.secret };
}

/** 把当前 auth map 写回 data/module-tokens.json(热更新 token 时复用) */
export function persistModuleAuth(projectRoot: string, auth: ModuleAuthMap, envAuthToken: string): void {
const outFile = join(projectRoot, "data", "module-tokens.json");
writeJson(outFile, {
tokens: auth.tokens,
secret: auth.secret,
generatedAt: new Date().toISOString(),
secretGenerated: !envAuthToken,
} satisfies TokenStore);
}

/**
* 确保 moduleId 在 auth.tokens 中有派生值(复用现有 secret,勿重建随机 secret)。
* @returns 是否新写入了 token
*/
export function ensureModuleToken(auth: ModuleAuthMap, moduleId: string): boolean {
if (auth.tokens[moduleId]) return false;
auth.tokens[moduleId] = deriveToken(moduleId, auth.secret);
return true;
}

/** 禁用时撤销内存中的 token(磁盘由 persistModuleAuth 同步) */
export function revokeModuleToken(auth: ModuleAuthMap, moduleId: string): boolean {
if (!(moduleId in auth.tokens)) return false;
delete auth.tokens[moduleId];
return true;
}

/**
* 校验请求方身份:
* - Bearer 头 / X-Module-Token 二选一
Expand Down
70 changes: 60 additions & 10 deletions db-server/src/routes/db-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,13 @@
*
* 端点:
* POST /api/sfmc/db/define-table body {name, columns, softDelete?} → {table, created}
* POST /api/sfmc/db/tx body {steps[]} → TxResponse | TxError
* POST /api/sfmc/db/tx body {steps[]} → TxResponse | TxError (批量)
* POST /api/sfmc/db/tx/begin body {} → {txId}
* POST /api/sfmc/db/tx/step body {txId, step} → {result}
* POST /api/sfmc/db/tx/commit body {txId} → TxResponse
* POST /api/sfmc/db/tx/rollback body {txId} → {ok}
* POST /api/sfmc/db/query body {table, opts?} → {rows}
* POST /api/sfmc/db/get body {table, id} → {row}
* POST /api/sfmc/db/insert body {table, row} → {row}
* POST /api/sfmc/db/update body {table, id, patch} → {row}
* POST /api/sfmc/db/delete body {table, id, hard?} → {changes}
* POST /api/sfmc/db/audit body {table, rowId, action, data?} → {ok}
* POST /api/sfmc/db/idempotent/probe body {action, key} → {replayed, cached?}
* POST /api/sfmc/db/idempotent/commit body {action, key, value?} → {ok}
*
* 鉴权:handle() 校验后把 {id, permissions} 写到 ctx.moduleAuth(不挂 req)。
* ...
*/

import type { IncomingMessage, ServerResponse } from "node:http";
Expand Down Expand Up @@ -95,6 +91,60 @@ export function createDbRoutes(depsIn: Partial<DbRoutesDeps>) {
return true;
}

// 交互式事务会话 — 供 SDK 在回调内读回 query/get/call 结果
if (path === "/api/sfmc/db/tx/begin") {
try {
const result = deps.txRunner!.beginSession(moduleId);
json(res, result as unknown as Record<string, unknown>, result.ok ? 200 : 400);
} catch (e) {
jsonV2Fail(res, (e as Error).message, 500);
}
return true;
}
if (path === "/api/sfmc/db/tx/step") {
try {
const txId = String(body.txId || "");
const step = body.step as TxStep | undefined;
if (!txId || !step || typeof step !== "object" || !("op" in step)) {
jsonV2Fail(res, "tx/step 需要 { txId, step }", 400);
return true;
}
const result = await deps.txRunner!.stepSession(txId, moduleId, step);
json(res, result as unknown as Record<string, unknown>, result.ok ? 200 : 400);
} catch (e) {
jsonV2Fail(res, (e as Error).message, 500);
}
return true;
}
if (path === "/api/sfmc/db/tx/commit") {
try {
const txId = String(body.txId || "");
if (!txId) {
jsonV2Fail(res, "tx/commit 需要 { txId }", 400);
return true;
}
const result = deps.txRunner!.commitSession(txId, moduleId);
json(res, result as unknown as Record<string, unknown>, result.ok ? 200 : 400);
} catch (e) {
jsonV2Fail(res, (e as Error).message, 500);
}
return true;
}
if (path === "/api/sfmc/db/tx/rollback") {
try {
const txId = String(body.txId || "");
if (!txId) {
jsonV2Fail(res, "tx/rollback 需要 { txId }", 400);
return true;
}
const result = deps.txRunner!.rollbackSession(txId, moduleId);
json(res, result as unknown as Record<string, unknown>, result.ok ? 200 : 400);
} catch (e) {
jsonV2Fail(res, (e as Error).message, 500);
}
return true;
}

type SingleOp = "query" | "get" | "insert" | "update" | "delete";
const singles: { match: RegExp; op: SingleOp }[] = [
{ match: /^\/api\/sfmc\/db\/query$/, op: "query" },
Expand Down
98 changes: 98 additions & 0 deletions db-server/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,101 @@ test("normalizeOrderBy: SDK field 与遗留 col / 数组互通(LSP)", async () =
]);
throws(() => normalizeOrderBy({ dir: "asc" }), /field\/col/);
});

test("module-auth: ensureModuleToken / revoke 复用 secret(DRY)", async () => {
const { deriveToken, ensureModuleToken, revokeModuleToken } = await import("./module-auth.js");
const secret = "test-secret";
const auth = { secret, tokens: {} as Record<string, string> };
equal(ensureModuleToken(auth, "feature-afk"), true);
equal(auth.tokens["feature-afk"], deriveToken("feature-afk", secret));
equal(ensureModuleToken(auth, "feature-afk"), false);
equal(revokeModuleToken(auth, "feature-afk"), true);
equal(auth.tokens["feature-afk"], undefined);
equal(revokeModuleToken(auth, "feature-afk"), false);
});

test("builtin-handlers: 热禁用按 serviceNames 卸载(OCP)", async () => {
const { BUILTIN_SERVICE_PLUGINS, unregisterBuiltinPluginForModule } = await import(
"./services/builtin-handlers.js"
);
const economy = BUILTIN_SERVICE_PLUGINS.find((p) => p.moduleId === "feature-economy");
equal(!!economy?.serviceNames?.includes("economy.account.get"), true);

const reg = new ServiceRegistry();
reg.registerHandler("feature-economy", "economy.account.get", async () => ({}));
reg.registerHandler("feature-economy", "economy.account.debit", async () => ({}));
equal(unregisterBuiltinPluginForModule(reg, "feature-economy"), 2);
equal(reg.list().length, 0);
equal(unregisterBuiltinPluginForModule(reg, "feature-unknown"), 0);
});

test("TxRunner 交互会话: step 中途读回 insert/get(PR #31 leftover)", async () => {
const { DatabaseSync } = await import("node:sqlite");
const { createQuery } = await import("./lib/sqlite.js");
const { SchemaRegistry } = await import("./schema-registry.js");
const { TxRunner } = await import("./tx-runner.js");

const db = new DatabaseSync(":memory:");
const query = createQuery(db);
const schema = new SchemaRegistry(db);
schema.define("feature-demo", {
name: "demo_items",
columns: {
id: { type: "text", primary: true },
name: { type: "text", notNull: true },
},
softDelete: false,
});

const enabled = new Map([
[
"feature-demo",
{
id: "feature-demo",
version: "1.0.0",
permissions: ["db:read:demo_items", "db:write:demo_items"],
services: { provides: [], requires: [] },
db: { tables: [] },
config: { key: "demo" },
} as unknown as import("./manifest-loader.js").ModuleManifestV2,
],
]);

const runner = new TxRunner({
db,
query,
schema,
serviceRegistry: new ServiceRegistry(),
enabled,
});

const begin = runner.beginSession("feature-demo");
equal(begin.ok, true);
if (!begin.ok) return;
const { txId } = begin;

const ins = await runner.stepSession(txId, "feature-demo", {
op: "insert",
table: "demo_items",
row: { id: "a1", name: "alpha" },
});
equal(ins.ok, true);
if (!ins.ok) return;
equal((ins.result as { op: string; row: { id: string } }).row.id, "a1");

const got = await runner.stepSession(txId, "feature-demo", {
op: "get",
table: "demo_items",
id: "a1",
});
equal(got.ok, true);
if (!got.ok) return;
equal((got.result as { row: { name: string } | null }).row?.name, "alpha");

const committed = runner.commitSession(txId, "feature-demo");
equal(committed.ok, true);

const rows = db.prepare("SELECT name FROM demo_items WHERE id = ?").all("a1") as Array<{ name: string }>;
equal(rows[0]?.name, "alpha");
db.close();
});
44 changes: 43 additions & 1 deletion db-server/src/services/builtin-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,25 @@ export type BuiltinServiceDeps = { query: QueryFn; db: DatabaseSync };
export type BuiltinServicePlugin = {
moduleId: string;
register: (registry: ServiceRegistry, deps: BuiltinServiceDeps) => void;
/** 与 register 写入的 handler 名对齐,供热禁用 unregister */
serviceNames?: string[];
};

/** 内置 service 插件清单 — 唯一扩展点 */
export const BUILTIN_SERVICE_PLUGINS: BuiltinServicePlugin[] = [
{ moduleId: "feature-economy", register: registerEconomyHandlers },
{
moduleId: "feature-economy",
register: registerEconomyHandlers,
serviceNames: [
"economy.account.get",
"economy.account.debit",
"economy.account.credit",
"economy.account.transfer",
"economy.dailyTasks.list",
"economy.dailyTasks.submit",
"economy.stats.monthly",
],
},
];

/** 按 enabledSet 注册内置插件,返回已注册插件数 */
Expand All @@ -36,3 +50,31 @@ export function registerEnabledBuiltinServices(
}
return n;
}

/** 热启用:只注册单个内置插件(若尚未注册) */
export function registerBuiltinPluginForModule(
registry: ServiceRegistry,
deps: BuiltinServiceDeps,
moduleId: string
): boolean {
const plugin = BUILTIN_SERVICE_PLUGINS.find((p) => p.moduleId === moduleId);
if (!plugin) return false;
const already = (plugin.serviceNames ?? []).some((n) => registry.list().some((h) => h.name === n));
if (already) return false;
plugin.register(registry, deps);
return true;
}

/** 热禁用:卸掉该模块的内置 handler */
export function unregisterBuiltinPluginForModule(registry: ServiceRegistry, moduleId: string): number {
const plugin = BUILTIN_SERVICE_PLUGINS.find((p) => p.moduleId === moduleId);
if (!plugin?.serviceNames) return 0;
let n = 0;
for (const name of plugin.serviceNames) {
if (registry.list().some((h) => h.name === name)) {
registry.unregisterHandler(name);
n += 1;
}
}
return n;
}
Loading
Loading