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
119 changes: 86 additions & 33 deletions db-server/src/domain/economy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] } {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR · DRY:本地 sql() 应落到 lib/sql-helpers 作为唯一权威入口。同目录已有 raw(),但 SQL\...${raw(table)}...`实测仍生成FROM ?(脚枪,与本 PR 要修的 bug 同类)。follow-up PR 已提升 sql()并加回归测试钉死 raw 只能.append`。

return { sql: text, values };
}

const TABLE_ACCOUNTS = "sfmc_economy_accounts";
const TABLE_TRANSACTIONS = "sfmc_economy_transactions";
const TABLE_IDEMPOTENCY = "sfmc_economy_idempotency";
Expand Down Expand Up @@ -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];
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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]
)
);
}

Expand All @@ -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,
]
)
);
}

Expand All @@ -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]
)
);
}

Expand Down Expand Up @@ -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<Record<string, unknown>>) : [];
}
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<Record<string, unknown>>) : [];
}
Expand All @@ -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<Record<string, unknown>>;
const task = rows[0];
if (!task) {
Expand All @@ -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,
Expand Down Expand Up @@ -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<string, unknown>;
return {
Expand All @@ -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<string, unknown>) : {};
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<string, unknown>).total ?? 0) : 0;
const destroyed = Array.isArray(destroyedRows)
Expand Down
16 changes: 13 additions & 3 deletions db-server/src/service-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
35 changes: 22 additions & 13 deletions db-server/src/services/economy-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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));
});
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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) => {
Expand Down
4 changes: 3 additions & 1 deletion db-server/src/tx-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,8 +382,10 @@ export class TxRunner {
mod: ModuleManifestV2,
step: TxStepService
): Promise<TxStepResult> {
// 与 ServiceRegistry.dispatch 对齐:提供方自调用跳过 requires(DRY/LSP)
const providerId = this.deps.serviceRegistry.getProvider(step.name);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR · DRY / LSP:这里用 getProvider() 再预检 requires,与 ServiceRegistry.dispatch 重复。未注册 service 时本路径会先抛 not_in_requires,而 HTTP/dispatchno_such_service。follow-up 已删除预检,requires/自调用只以 dispatch 为准;权限门仍保留。

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;
Expand Down
Loading