Skip to content
19 changes: 13 additions & 6 deletions db-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,10 @@ function resolveModuleByKey(key: string) {
}

function setModuleEnabled(mod: { id: string; canDisable: boolean }, enabled: boolean) {
const lock = loadModuleLock(env.MODULE_LOCK_PATH);
updateModuleState(lock, mod.id, { enabled: !!enabled });
writeJsonFile(env.MODULE_LOCK_PATH, lock);
// 直接更新启动时缓存的 lockFile(而非另读一份新副本),
// 否则 buildModuleList() 读的仍是旧缓存,导致启停后 enabled 状态不翻转。
updateModuleState(lockFile, mod.id, { enabled: !!enabled });
writeJsonFile(env.MODULE_LOCK_PATH, lockFile);
}

// ── 路由工厂实例 (旧业务路径 — 保留直到各模块迁 v2) ────────────────
Expand Down Expand Up @@ -313,10 +314,14 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom
await body(req);

// ── v2 模块身份校验:写 req.moduleAuth ────────────────────
// 注意:`/api/sfmc/configs/all` 是旧的一次性配置快照端点(SAPI ConfigManager.init
// 启动必用),不属于 v2 模块配置命名空间(configs/<模块 configKey>),必须豁免,
// 否则会被模块鉴权网关拦成 401,导致插件端起不来。
const isLegacyConfigAll = path === "/api/sfmc/configs/all";
const needsModuleAuth =
path.startsWith("/api/sfmc/db/") ||
path.startsWith("/api/sfmc/services") ||
/^\/api\/sfmc\/configs\/[A-Za-z0-9_-]+(?:\/(?:set|notify))?$/.test(path);
(/^\/api\/sfmc\/configs\/[A-Za-z0-9_-]+(?:\/(?:set|notify))?$/.test(path) && !isLegacyConfigAll);
if (needsModuleAuth) {
const id = verifyModuleAuth({
headers: req.headers,
Expand Down Expand Up @@ -353,15 +358,17 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom
try {
// ── v2 路由(优先匹配) ─────────────────────────────
const reqWithAuth = req as http.IncomingMessage & { moduleAuth?: { id: string; permissions: string[] } };
if (reqWithAuth.moduleAuth && (path.startsWith("/api/sfmc/db/") || path.startsWith("/api/sfmc/services") || /^\/api\/sfmc\/configs\/[A-Za-z0-9_-]+/.test(path))) {
if (reqWithAuth.moduleAuth && (path.startsWith("/api/sfmc/db/") || path.startsWith("/api/sfmc/services") || (/^\/api\/sfmc\/configs\/[A-Za-z0-9_-]+/.test(path) && !isLegacyConfigAll))) {
const ctx: Record<string, unknown> = {
path,
method,
params,
req,
res,
};
ctx["body"] = (req as http.IncomingMessage & { _body?: Record<string, unknown> })._body ?? {};
// body 已在上面 `await body(req)` 预读并缓存到 req._bodyPromise;
// 这里复用缓存(原实现读的 req._body 从未被赋值,导致所有 v2 路由 body 恒为空)。
ctx["body"] = await body(req);
if (path.startsWith("/api/sfmc/db/")) {
if (await dbRoutes(ctx)) return;
} else if (path.startsWith("/api/sfmc/services")) {
Expand Down
6 changes: 5 additions & 1 deletion db-server/src/routes/db-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,11 @@ export function createDbRoutes(depsIn: Partial<DbRoutesDeps>) {
if (path === "/api/sfmc/db/tx") {
try {
const txReq = body as unknown as TxRequest;
const result = await deps.txRunner!.run(txReq);
// 强制以「鉴权身份」执行事务:忽略 body 里自带的 moduleId,
// 既防止持 A 的 token 冒用 B 的权限越权,也修复客户端只发 {steps} 时
// moduleId 缺失导致的事务恒被拒。
const steps = Array.isArray(txReq.steps) ? txReq.steps : [];
const result = await deps.txRunner!.run({ moduleId, steps });
const status = result.ok ? 200 : 400;
json(res, result as unknown as Record<string, unknown>, status);
} catch (e) {
Expand Down
49 changes: 38 additions & 11 deletions db-server/src/schema-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ function assertValid(req: DefineTableRequest, moduleId: string): void {
* 例如 {id:{type:"text",primary:true},name:{type:"text",notNull:true}}
* → "id" TEXT PRIMARY KEY, "name" TEXT NOT NULL
*/
function buildColumnClause(name: string, def: ColumnDef, withDeletedAt: boolean): string {
function buildColumnClause(name: string, def: ColumnDef): string {
const parts: string[] = [`"${name}"`, def.type.toUpperCase()];
if (def.primary) parts.push("PRIMARY KEY");
if (def.notNull && !def.primary) parts.push("NOT NULL");
Expand All @@ -95,13 +95,25 @@ function buildColumnClause(name: string, def: ColumnDef, withDeletedAt: boolean)
else parts.push(`DEFAULT ${def.default}`);
}
if (def.ref) parts.push(`REFERENCES ${def.ref}`);
// 隐式 _deleted_at / _version 在列尾追加,不参与 columns 字段
if (withDeletedAt) {
parts.push('"_deleted_at" INTEGER', '"_version" INTEGER DEFAULT 0');
}
return parts.join(" ");
}

/**
* 把一张表的所有列(含 softDelete 隐式列)拼成 CREATE TABLE 的列定义数组。
* 隐式 _deleted_at / _version 作为独立列在末尾追加「一次」(此前误在每个列
* 子句里各追加一次,且无逗号分隔,导致 softDelete 表建表 SQL 语法错误)。
*/
function buildColumnList(columns: Record<string, ColumnDef>, softDelete: boolean): string[] {
const cols: string[] = [];
for (const [n, def] of Object.entries(columns)) {
cols.push(buildColumnClause(n, def));
}
if (softDelete) {
cols.push('"_deleted_at" INTEGER', '"_version" INTEGER DEFAULT 0');
}
return cols;
}

export class SchemaRegistry {
private readonly db: DatabaseSync;
private readonly tables = new Map<string, DefinedTable>();
Expand Down Expand Up @@ -137,15 +149,33 @@ export class SchemaRegistry {
}

const softDelete = req.softDelete ?? true;
this.tables.set(req.name, {
const defined: DefinedTable = {
moduleId,
name: req.name,
columns: req.columns,
softDelete,
});
};
this.tables.set(req.name, defined);
// 立即物理建表(幂等 CREATE TABLE IF NOT EXISTS)。
// 原设计是「define 收集 → finalize 统一建表」,但 finalize() 从未被调用,
// 导致模块表永远不会落地、后续 insert/query 全部 no such table。
// 采用 define 即建表:碰撞检测已在上方按内存表完成,不影响单一 owner 保证。
this.createPhysical(defined);
return { table: req.name, created: true, indices: [] };
}

/** 幂等地物理建表 + 建索引(供 define / finalize 共用)。 */
private createPhysical(t: DefinedTable): void {
const cols = buildColumnList(t.columns, t.softDelete);
this.db.exec(`CREATE TABLE IF NOT EXISTS "${t.name}" (${cols.join(", ")})`);
for (const [n, def] of Object.entries(t.columns)) {
if (def.index) {
const idxName = `idx_${t.name}_${n}`.slice(0, 60);
this.db.exec(`CREATE INDEX IF NOT EXISTS "${idxName}" ON "${t.name}"("${n}")`);
}
}
}

/**
* 真正建表的时刻。所有 enabled 模块 init 完成(或明确调)后再 finalize。
* CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS,幂等。
Expand All @@ -156,10 +186,7 @@ export class SchemaRegistry {
const created: string[] = [];

for (const t of this.tables.values()) {
const cols: string[] = [];
for (const [n, def] of Object.entries(t.columns)) {
cols.push(buildColumnClause(n, def, t.softDelete));
}
const cols = buildColumnList(t.columns, t.softDelete);
const ddl = `CREATE TABLE IF NOT EXISTS "${t.name}" (${cols.join(", ")})`;
this.db.exec(ddl);

Expand Down
5 changes: 4 additions & 1 deletion db-server/src/tx-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,10 @@ export class TxRunner {
const { moduleId, steps } = req;
const manifest = this.deps.enabled.get(moduleId);
if (!manifest) return { ok: false, step: -1, error: "模块未 enabled", code: "forbidden" };
assertModulePermission(moduleId, manifest.permissions, Perm.dbWrite("*"));
// 注意:不在此处做「整体 db:write:*」断言。
// 通配 `db:write:*` 无法通过 permission-gate 的 validPermissionKey 校验(模块声明它会启动失败),
// 因此该断言会让所有事务(含只读事务)恒被拒。真正的读写权限由每个 step 的
// requireTableRead / requireTableWrite 按具体表精确 gate。

const traceId = randomUUID().slice(0, 8);
const results: TxStepResult[] = [];
Expand Down
Loading
Loading