From 1060a178dc0d9d00a899d840b02f7b5b8a024c22 Mon Sep 17 00:00:00 2001 From: Shiroha7z Date: Wed, 22 Jul 2026 14:49:45 +0800 Subject: [PATCH] fix(economy): stop table-name ? binding and align tx.call self-call Cherry-picked db-server fixes from closed PR #14 onto main@522d799. Modules already externalized; only platform handlers/domain. Co-authored-by: Cursor --- db-server/src/domain/economy.ts | 119 +++++++++++++++------ db-server/src/service-registry.ts | 16 ++- db-server/src/services/economy-handlers.ts | 35 +++--- db-server/src/tx-runner.ts | 4 +- 4 files changed, 124 insertions(+), 50 deletions(-) diff --git a/db-server/src/domain/economy.ts b/db-server/src/domain/economy.ts index b29642a..a365cf7 100644 --- a/db-server/src/domain/economy.ts +++ b/db-server/src/domain/economy.ts @@ -12,11 +12,19 @@ import type { EconomyAccountRow, EconomyTransactionRow } from "@sfmc/sdk/contracts"; import type { DatabaseSync } from "node:sqlite"; -import { SQL, type SQLStatement } from "sql-template-strings"; +import type { SQLStatement } from "sql-template-strings"; import { isValidIdempotencyKey } from "../lib/idempotency.js"; import type { TxResult } from "./transaction.js"; export type { TxResult }; +/** + * 表名是信任常量,必须嵌入 SQL 文本;值走 ? 绑定。 + * 不可对 sql-template-strings 插值表名(会变成 FROM ? → near "?": syntax error)。 + */ +function sql(text: string, values: unknown[] = []): { sql: string; values: unknown[] } { + return { sql: text, values }; +} + const TABLE_ACCOUNTS = "sfmc_economy_accounts"; const TABLE_TRANSACTIONS = "sfmc_economy_transactions"; const TABLE_IDEMPOTENCY = "sfmc_economy_idempotency"; @@ -58,13 +66,16 @@ export function ensureEconomyAccount( ): EconomyAccountRow | undefined { const now = Date.now(); query( - SQL`INSERT INTO ${TABLE_ACCOUNTS} (player_id, player_name_snapshot, balance, version, created_at, updated_at) - VALUES (${String(playerId)}, ${String(playerName)}, 0, 1, ${now}, ${now}) + sql( + `INSERT INTO ${TABLE_ACCOUNTS} (player_id, player_name_snapshot, balance, version, created_at, updated_at) + VALUES (?, ?, 0, 1, ?, ?) ON CONFLICT(player_id) DO UPDATE SET player_name_snapshot = excluded.player_name_snapshot, - updated_at = excluded.updated_at` + updated_at = excluded.updated_at`, + [String(playerId), String(playerName), now, now] + ) ); - const rows = query(SQL`SELECT * FROM ${TABLE_ACCOUNTS} WHERE player_id = ${String(playerId)}`); + const rows = query(sql(`SELECT * FROM ${TABLE_ACCOUNTS} WHERE player_id = ?`, [String(playerId)])); if (Array.isArray(rows)) { return (rows as EconomyAccountRow[])[0]; } @@ -168,8 +179,11 @@ export function applyEconomySteps(query: AnyQuery, data: ApplyEconomyInput): App // 幂等回放 if (idempotencyKey) { const rows = query( - SQL`SELECT response_json FROM ${TABLE_IDEMPOTENCY} - WHERE actor_id = ${actorId} AND idempotency_key = ${idempotencyKey}` + sql( + `SELECT response_json FROM ${TABLE_IDEMPOTENCY} + WHERE actor_id = ? AND idempotency_key = ?`, + [actorId, idempotencyKey] + ) ); const previous = Array.isArray(rows) ? (rows as Array<{ response_json: string }>)[0] : undefined; if (previous) { @@ -193,16 +207,22 @@ export function applyEconomySteps(query: AnyQuery, data: ApplyEconomyInput): App const now = Date.now(); if (source) { query( - SQL`UPDATE ${TABLE_ACCOUNTS} - SET balance = balance - ${amount}, version = version + 1, updated_at = ${now} - WHERE player_id = ${source.player_id} AND balance >= ${amount}` + sql( + `UPDATE ${TABLE_ACCOUNTS} + SET balance = balance - ?, version = version + 1, updated_at = ? + WHERE player_id = ? AND balance >= ?`, + [amount, now, source.player_id, amount] + ) ); } if (target) { query( - SQL`UPDATE ${TABLE_ACCOUNTS} - SET balance = balance + ${amount}, version = version + 1, updated_at = ${now} - WHERE player_id = ${target.player_id}` + sql( + `UPDATE ${TABLE_ACCOUNTS} + SET balance = balance + ?, version = version + 1, updated_at = ? + WHERE player_id = ?`, + [amount, now, target.player_id] + ) ); } @@ -214,24 +234,50 @@ export function applyEconomySteps(query: AnyQuery, data: ApplyEconomyInput): App if (source) { query( - SQL`INSERT INTO ${TABLE_TRANSACTIONS} + sql( + `INSERT INTO ${TABLE_TRANSACTIONS} (id, transaction_type, actor_id, source_player_id, target_player_id, amount, balance_before, balance_after, reference_type, reference_id, reason, created_at) - VALUES (${id + "-dr"}, ${transactionType + ".dr"}, ${actorId}, ${sourceId}, ${null}, - ${amount}, ${source.balance}, ${source.balance - amount}, - ${referenceType}, ${referenceId}, ${reason}, ${now})` + VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)`, + [ + id + "-dr", + transactionType + ".dr", + actorId, + sourceId, + amount, + source.balance, + source.balance - amount, + referenceType, + referenceId, + reason, + now, + ] + ) ); } if (target) { query( - SQL`INSERT INTO ${TABLE_TRANSACTIONS} + sql( + `INSERT INTO ${TABLE_TRANSACTIONS} (id, transaction_type, actor_id, source_player_id, target_player_id, amount, balance_before, balance_after, reference_type, reference_id, reason, created_at) - VALUES (${id + "-cr"}, ${transactionType + ".cr"}, ${actorId}, ${null}, ${targetId}, - ${amount}, ${target.balance}, ${target.balance + amount}, - ${referenceType}, ${referenceId}, ${reason}, ${now})` + VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + id + "-cr", + transactionType + ".cr", + actorId, + targetId, + amount, + target.balance, + target.balance + amount, + referenceType, + referenceId, + reason, + now, + ] + ) ); } @@ -251,9 +297,12 @@ export function applyEconomySteps(query: AnyQuery, data: ApplyEconomyInput): App if (idempotencyKey) { query( - SQL`INSERT INTO ${TABLE_IDEMPOTENCY} + sql( + `INSERT INTO ${TABLE_IDEMPOTENCY} (actor_id, idempotency_key, transaction_id, response_json, created_at) - VALUES (${actorId}, ${idempotencyKey}, ${id}, ${JSON.stringify(response)}, ${now})` + VALUES (?, ?, ?, ?, ?)`, + [actorId, idempotencyKey, id, JSON.stringify(response), now] + ) ); } @@ -305,12 +354,11 @@ export function listDailyTasks( const status = filter?.status ?? "active"; const now = Date.now(); if (filter?.includeExpired) { - const rows = query(SQL`SELECT * FROM ${TABLE_DAILY} WHERE status = ${status}`); + const rows = query(sql(`SELECT * FROM ${TABLE_DAILY} WHERE status = ?`, [status])); return Array.isArray(rows) ? (rows as Array>) : []; } const rows = query( - SQL`SELECT * FROM ${TABLE_DAILY} - WHERE status = ${status} AND expires_at > ${now}` + sql(`SELECT * FROM ${TABLE_DAILY} WHERE status = ? AND expires_at > ?`, [status, now]) ); return Array.isArray(rows) ? (rows as Array>) : []; } @@ -336,8 +384,11 @@ export function submitDailyTaskSteps( const quantity = data.quantity; const rows = query( - SQL`SELECT * FROM ${TABLE_DAILY} - WHERE id = ${taskId} AND status = 'active' AND expires_at > ${Date.now()}` + sql( + `SELECT * FROM ${TABLE_DAILY} + WHERE id = ? AND status = 'active' AND expires_at > ?`, + [taskId, Date.now()] + ) ) as Array>; const task = rows[0]; if (!task) { @@ -351,7 +402,7 @@ export function submitDailyTaskSteps( } const reward = Number(task.unit_reward) * quantity; - query(SQL`UPDATE ${TABLE_DAILY} SET filled_qty = filled_qty + ${quantity} WHERE id = ${taskId}`); + query(sql(`UPDATE ${TABLE_DAILY} SET filled_qty = filled_qty + ? WHERE id = ?`, [quantity, taskId])); const result = applyEconomySteps(query, { actor_id: actorId, @@ -426,7 +477,7 @@ export function monthlyEconomyStats(query: AnyQuery): { const now = new Date(); const id = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; - const cached = query(SQL`SELECT * FROM ${TABLE_STATS} WHERE id = ${"global"} OR id = ${id}`); + const cached = query(sql(`SELECT * FROM ${TABLE_STATS} WHERE id = ? OR id = ?`, ["global", id])); if (Array.isArray(cached) && cached.length > 0) { const row = cached[0] as Record; return { @@ -438,13 +489,15 @@ export function monthlyEconomyStats(query: AnyQuery): { }; } - const supplyRows = query(SQL`SELECT COALESCE(SUM(balance),0) AS total_supply, COUNT(*) AS active_accounts FROM ${TABLE_ACCOUNTS}`); + const supplyRows = query( + sql(`SELECT COALESCE(SUM(balance),0) AS total_supply, COUNT(*) AS active_accounts FROM ${TABLE_ACCOUNTS}`) + ); const supply = Array.isArray(supplyRows) ? (supplyRows[0] as Record) : {}; const issuedRows = query( - SQL`SELECT COALESCE(SUM(amount),0) AS total FROM ${TABLE_TRANSACTIONS} WHERE target_player_id IS NOT NULL` + sql(`SELECT COALESCE(SUM(amount),0) AS total FROM ${TABLE_TRANSACTIONS} WHERE target_player_id IS NOT NULL`) ); const destroyedRows = query( - SQL`SELECT COALESCE(SUM(amount),0) AS total FROM ${TABLE_TRANSACTIONS} WHERE source_player_id IS NOT NULL` + sql(`SELECT COALESCE(SUM(amount),0) AS total FROM ${TABLE_TRANSACTIONS} WHERE source_player_id IS NOT NULL`) ); const issued = Array.isArray(issuedRows) ? Number((issuedRows[0] as Record).total ?? 0) : 0; const destroyed = Array.isArray(destroyedRows) diff --git a/db-server/src/service-registry.ts b/db-server/src/service-registry.ts index b16934a..8ef79af 100644 --- a/db-server/src/service-registry.ts +++ b/db-server/src/service-registry.ts @@ -92,18 +92,28 @@ export class ServiceRegistry { }); return { ok: true, result }; } catch (e) { - const err = e as Error; + // 保留 handler 抛出的领域/鉴权错误契约,勿压成 500 internal(LSP) + if (e instanceof DispatchError) throw e; + const err = e as Error & { code?: string; status?: number }; + if (typeof err.status === "number" && err.code) { + throw new DispatchError(err.message, err.code as DispatchError["code"], err.status); + } throw new DispatchError(`handler 抛错: ${err.message}`, "internal", 500); } } + + /** 查询 service 提供方 moduleId(供 tx.call 与 HTTP 共用鉴权策略) */ + getProvider(name: string): string | undefined { + return this.handlers.get(name)?.moduleId; + } } export class DispatchError extends Error { - code: "no_such_service" | "not_in_requires" | "forbidden" | "internal"; + code: "no_such_service" | "not_in_requires" | "forbidden" | "internal" | "domain_error"; status: number; constructor( message: string, - code: "no_such_service" | "not_in_requires" | "forbidden" | "internal", + code: "no_such_service" | "not_in_requires" | "forbidden" | "internal" | "domain_error", status: number ) { super(message); diff --git a/db-server/src/services/economy-handlers.ts b/db-server/src/services/economy-handlers.ts index eafcd22..f3e1349 100644 --- a/db-server/src/services/economy-handlers.ts +++ b/db-server/src/services/economy-handlers.ts @@ -7,7 +7,7 @@ import type { DatabaseSync } from "node:sqlite"; import type { QueryFn } from "../lib/sqlite.js"; -import type { ServiceRegistry } from "../service-registry.js"; +import { DispatchError, type ServiceRegistry } from "../service-registry.js"; import { applyEconomyTransaction, getEconomyAccount, @@ -32,9 +32,10 @@ function num(v: unknown): number { return Number(v); } +/** 领域失败保留 status/code,避免被 registry 压成 500 internal(LSP) */ function unwrapOk(result: ApplyEconomyResult): unknown { if (!result.ok) { - throw new Error(result.error); + throw new DispatchError(result.error, "domain_error", result.status); } return { transactionId: result.transactionId, @@ -56,7 +57,7 @@ export function registerEconomyHandlers( registry.registerHandler(MODULE_ID, "economy.account.get", async (ctx) => { const p = asObj(ctx.payload); const playerId = str(p.playerId ?? p.player_id); - if (!playerId) throw new Error("missing_playerId"); + if (!playerId) throw new DispatchError("missing_playerId", "domain_error", 400); const q = (ctx.tx?.query as unknown as AnyQuery) ?? standaloneQuery; return getEconomyAccount(q, playerId, str(p.playerName ?? p.player_name)); }); @@ -65,7 +66,7 @@ export function registerEconomyHandlers( const p = asObj(ctx.payload); const playerId = str(p.playerId ?? p.player_id ?? p.actorId); const amount = num(p.amount); - if (!playerId) throw new Error("missing_playerId"); + if (!playerId) throw new DispatchError("missing_playerId", "domain_error", 400); const q = (ctx.tx?.query as unknown as AnyQuery) ?? standaloneQuery; const db = ctx.tx?.db ?? deps.db; const idem = str(p.idempotencyKey ?? p.idempotency_key); @@ -92,7 +93,7 @@ export function registerEconomyHandlers( const p = asObj(ctx.payload); const playerId = str(p.playerId ?? p.player_id ?? p.actorId); const amount = num(p.amount); - if (!playerId) throw new Error("missing_playerId"); + if (!playerId) throw new DispatchError("missing_playerId", "domain_error", 400); const q = (ctx.tx?.query as unknown as AnyQuery) ?? standaloneQuery; const db = ctx.tx?.db ?? deps.db; const idem = str(p.idempotencyKey ?? p.idempotency_key); @@ -121,7 +122,7 @@ export function registerEconomyHandlers( str(p.fromPlayerId ?? p.sourcePlayerId ?? p.source_player_id ?? p.actorId ?? p.actor_id) || ""; const to = str(p.toPlayerId ?? p.targetPlayerId ?? p.target_player_id) || ""; const amount = num(p.amount); - if (!from || !to) throw new Error("missing_from_or_to"); + if (!from || !to) throw new DispatchError("missing_from_or_to", "domain_error", 400); const q = (ctx.tx?.query as unknown as AnyQuery) ?? standaloneQuery; const db = ctx.tx?.db ?? deps.db; const idem = str(p.idempotencyKey ?? p.idempotency_key); @@ -144,26 +145,34 @@ export function registerEconomyHandlers( return unwrapOk(result); }); + // 信封与 daily-task 消费方对齐:{ tasks: [...] } registry.registerHandler(MODULE_ID, "economy.dailyTasks.list", async (ctx) => { const p = asObj(ctx.payload); const q = (ctx.tx?.query as unknown as AnyQuery) ?? standaloneQuery; - return listDailyTasks(q, { - status: str(p.status) || "active", - includeExpired: Boolean(p.includeExpired), - }); + return { + tasks: listDailyTasks(q, { + status: str(p.status) || "active", + includeExpired: Boolean(p.includeExpired), + }), + }; }); + // 信封与 daily-task 消费方对齐:成功/失败都带 ok,业务失败不抛(避免物品已扣却无法走 !ok 退还分支) registry.registerHandler(MODULE_ID, "economy.dailyTasks.submit", async (ctx) => { const p = asObj(ctx.payload); const actorId = str(p.actorId ?? p.playerId ?? p.player_id); const taskId = str(p.taskId ?? p.task_id); const quantity = num(p.quantity ?? 1); - if (!actorId || !taskId) throw new Error("missing_actor_or_task"); + if (!actorId || !taskId) { + return { ok: false, error: "missing_actor_or_task", status: 400 }; + } const q = (ctx.tx?.query as unknown as AnyQuery) ?? standaloneQuery; const db = ctx.tx?.db ?? deps.db; const result = submitDailyTaskTx(q, db, { actorId, taskId, quantity }, { alreadyInTx: !!ctx.tx }); - if (!result.ok) throw new Error(result.error); - return result.data; + if (!result.ok) { + return { ok: false, error: result.error, status: result.status }; + } + return { ok: true, ...result.data }; }); registry.registerHandler(MODULE_ID, "economy.stats.monthly", async (ctx) => { diff --git a/db-server/src/tx-runner.ts b/db-server/src/tx-runner.ts index cf85bae..1497ca2 100644 --- a/db-server/src/tx-runner.ts +++ b/db-server/src/tx-runner.ts @@ -382,8 +382,10 @@ export class TxRunner { mod: ModuleManifestV2, step: TxStepService ): Promise { + // 与 ServiceRegistry.dispatch 对齐:提供方自调用跳过 requires(DRY/LSP) + const providerId = this.deps.serviceRegistry.getProvider(step.name); const declared = mod.services.requires.find((r) => r.name === step.name); - if (!declared) { + if (!declared && providerId !== mod.id) { const err = new Error(`模块 ${mod.id} 未声明 service.requires ${step.name}`); (err as { code?: string }).code = "not_in_requires"; throw err;