From 9bcbc1fc3bc0b087e450c8089109601b95b1b273 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 22 Jul 2026 05:47:56 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(economy):=20=E8=A1=A8=E5=90=8D=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=B5=B0=20=3F=20=E7=BB=91=E5=AE=9A,=E5=B9=B6?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E9=A2=86=E5=9F=9F=E9=94=99=E8=AF=AF=E4=BF=A1?= =?UTF-8?q?=E5=B0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sql-template-strings 插值表名会变成 FROM ?,触发 near "?": syntax error, 导致新注册的 economy.* handlers 全部不可用。表名改为信任常量嵌入 SQL, 值继续参数化。 同时修正 dailyTasks.list/submit 与消费方契约(LSP),以及 DispatchError 透传 domain status,避免 insufficient_funds 等被压成 500 internal。 Co-authored-by: Shiroha --- db-server/src/domain/economy.ts | 119 +++++++++++++++------ db-server/src/service-registry.ts | 16 ++- db-server/src/services/economy-handlers.ts | 35 +++--- 3 files changed, 121 insertions(+), 49 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) => { From 0ecc43099a07eabad15bba76002241607a5df73e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 22 Jul 2026 05:48:05 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(tx):=20tx.call=20=E8=87=AA=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E8=B7=B3=E8=BF=87=20requires,=E4=B8=8E=20ServiceRegis?= =?UTF-8?q?try=20=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dispatch 已允许提供方调自己的 service 免 requires,但 TxRunner.doService 在进 dispatch 前硬拦,导致 HTTP service.get 与 tx.call 契约不一致(DRY/LSP)。 通过 getProvider() 共用提供方身份判断。 Co-authored-by: Shiroha --- db-server/src/tx-runner.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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; From b40f705ebbb89873f444c9be74cdd7bde93e5185 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 22 Jul 2026 05:48:05 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(modules):=20=E4=BF=AE=E5=A4=8D=E6=9C=AC?= =?UTF-8?q?=E8=BD=AE=20chat/land/gui=20=E7=9A=84=20LSP/DIP=20=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E6=BC=82=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feature-chat:@sfmc/types → @sfmc/sdk/contracts(权威类型源 DRY) - claimRedPacket/land-transfer:不再在 db.tx 录制器回调内按 get 结果分支 - core-gui:声明 requires feature-land-gui(与 MainMenu 实际 import 对齐) Co-authored-by: Shiroha --- modules/catalog.json | 3 +- .../feature-chat/sapi/src/chat-api.ts | 50 +++++++++---------- .../feature-chat/sapi/src/chat-gui.ts | 2 +- .../feature-chat/sapi/src/doge-chat.ts | 2 +- modules/packages/gui/sapi/manifest.json | 4 +- .../packages/land/sapi/src/land-transfer.ts | 47 +++++++++-------- 6 files changed, 57 insertions(+), 51 deletions(-) diff --git a/modules/catalog.json b/modules/catalog.json index 56f4471..d5b8348 100644 --- a/modules/catalog.json +++ b/modules/catalog.json @@ -27,7 +27,8 @@ "requires": [ "feature-economy", "feature-chat", - "feature-land" + "feature-land", + "feature-land-gui" ], "entry": { "kind": "sapi", diff --git a/modules/packages/feature-chat/sapi/src/chat-api.ts b/modules/packages/feature-chat/sapi/src/chat-api.ts index 2503ffa..fdc5e2c 100644 --- a/modules/packages/feature-chat/sapi/src/chat-api.ts +++ b/modules/packages/feature-chat/sapi/src/chat-api.ts @@ -349,42 +349,42 @@ export async function claimRedPacket( actorName: string ): Promise<{ ok: boolean; amount?: number; account?: { balance?: number; version?: number }; error?: string }> { try { - const result = await db.tx<{ amount: number; balance: number; version: number }>(async (tx) => { - const row = await tx.get("sfmc_chat_redpackets", redpacketId); - if (!row) throw new Error("redpacket_not_found"); - const rp = rowToRedPacket(row as unknown as RedPacketRow); - if (rp.remainingCount <= 0) throw new Error("redpacket_empty"); - const receivers = new Set(rp.receivers); - if (receivers.has(actorId)) throw new Error("already_claimed"); - if (Date.now() > rp.expiresAt) throw new Error("redpacket_expired"); - let amount: number; - if (rp.remainingCount === 1) { - amount = rp.remainingAmount; - } else { - const max = Math.floor((rp.remainingAmount / rp.remainingCount) * 2); - amount = Math.max(1, Math.floor(Math.random() * (max + 1))); - amount = Math.min(amount, rp.remainingAmount - (rp.remainingCount - 1)); - } - receivers.add(actorId); + // db.tx 录制器对 get/call 返回占位值,不可在事务回调内按结果分支(LSP)。 + // 先用 db.get 做读校验与金额计算,再把写操作录进同一事务提交。 + const existing = await db.get("sfmc_chat_redpackets", redpacketId); + if (!existing) return { ok: false, error: "redpacket_not_found" }; + const rp = rowToRedPacket(existing); + if (rp.remainingCount <= 0) return { ok: false, error: "redpacket_empty" }; + const receivers = new Set(rp.receivers); + if (receivers.has(actorId)) return { ok: false, error: "already_claimed" }; + if (Date.now() > rp.expiresAt) return { ok: false, error: "redpacket_expired" }; + + let amount: number; + if (rp.remainingCount === 1) { + amount = rp.remainingAmount; + } else { + const max = Math.floor((rp.remainingAmount / rp.remainingCount) * 2); + amount = Math.max(1, Math.floor(Math.random() * (max + 1))); + amount = Math.min(amount, rp.remainingAmount - (rp.remainingCount - 1)); + } + receivers.add(actorId); + + await db.tx(async (tx) => { await tx.update("sfmc_chat_redpackets", redpacketId, { remaining_amount: rp.remainingAmount - amount, remaining_count: rp.remainingCount - 1, receivers: JSON.stringify([...receivers]), }); - const credited = (await tx.call("economy.account.credit", { + await tx.call("economy.account.credit", { playerId: actorId, playerName: actorName, amount, reason: "redpacket_claim", meta: { redpacketId }, - })) as { balance?: number; version?: number } | null; - return { - amount, - balance: Number(credited?.balance ?? 0), - version: Number(credited?.version ?? 0), - }; + }); }); - return { ok: true, amount: result.amount, account: { balance: result.balance, version: result.version } }; + // tx.call 在客户端同样拿不到真实返回;余额由调用方后续 Money.load / service 刷新 + return { ok: true, amount }; } catch (err) { return { ok: false, error: (err as Error).message }; } diff --git a/modules/packages/feature-chat/sapi/src/chat-gui.ts b/modules/packages/feature-chat/sapi/src/chat-gui.ts index b0b01b4..02feea0 100644 --- a/modules/packages/feature-chat/sapi/src/chat-gui.ts +++ b/modules/packages/feature-chat/sapi/src/chat-gui.ts @@ -1,5 +1,5 @@ import { Player, world } from "@minecraft/server"; -import type { Channel, RedPacket } from "@sfmc/types"; +import type { Channel, RedPacket } from "@sfmc/sdk/contracts"; import { DogeChat } from "./doge-chat.js"; import { Money } from "@sfmc/sdk/sapi/runtime"; import { FormStatus, MenuNavigator, obsNum, obsStr } from "@sfmc/sdk/sapi/runtime"; diff --git a/modules/packages/feature-chat/sapi/src/doge-chat.ts b/modules/packages/feature-chat/sapi/src/doge-chat.ts index 84d1b20..9a0bbfe 100644 --- a/modules/packages/feature-chat/sapi/src/doge-chat.ts +++ b/modules/packages/feature-chat/sapi/src/doge-chat.ts @@ -5,7 +5,7 @@ * Author : Shiroha7z * \* ---------------------------------------- */ import { Player, system, world } from "@minecraft/server"; -import type { Channel, ChannelConfig, ChatMessage, MessageType, RedPacket } from "@sfmc/types"; +import type { Channel, ChannelConfig, ChatMessage, MessageType, RedPacket } from "@sfmc/sdk/contracts"; import { db } from "@sfmc/sdk/sapi/db"; import { debug } from "@sfmc/sdk/sapi/runtime"; import { Money } from "@sfmc/sdk/sapi/runtime"; diff --git a/modules/packages/gui/sapi/manifest.json b/modules/packages/gui/sapi/manifest.json index ebea564..fca94fb 100644 --- a/modules/packages/gui/sapi/manifest.json +++ b/modules/packages/gui/sapi/manifest.json @@ -4,7 +4,7 @@ "name": "主菜单GUI", "type": "core", "configKey": "gui", - "requires": ["feature-economy", "feature-chat", "feature-land"], + "requires": ["feature-economy", "feature-chat", "feature-land", "feature-land-gui"], "permissions": [ "service:economy.account.transfer" @@ -17,5 +17,5 @@ ] }, - "notes": "core-gui:主菜单/管理面板入口。土地/聊天 GUI 通过 npm 依赖 @sfmc/module-land-gui 与 @sfmc/module-feature-chat 调用;转账走 service.get(economy.account.transfer)。合作社图形 UI 已并入 feature-coop 命令面。" + "notes": "core-gui:主菜单/管理面板入口。依赖 feature-land-gui(LandGUI)与 feature-chat(ChatGUI);转账走 service.get(economy.account.transfer)。合作社图形 UI 已并入 feature-coop 命令面。" } diff --git a/modules/packages/land/sapi/src/land-transfer.ts b/modules/packages/land/sapi/src/land-transfer.ts index f819542..53a3a3a 100644 --- a/modules/packages/land/sapi/src/land-transfer.ts +++ b/modules/packages/land/sapi/src/land-transfer.ts @@ -37,9 +37,25 @@ export async function transferLand(input: TransferInput): Promise("lands", input.landId); + if (!cur) { + return { ok: false, landId: input.landId, newOwnerId: input.newOwnerId, error: "land_not_found", message: "领地不存在。" }; + } + if (cur.owner_player_id !== input.currentOwnerId) { + return { ok: false, landId: input.landId, newOwnerId: input.newOwnerId, error: "not_owner", message: "你不是该领地主人。" }; + } + if (cur.status && cur.status !== "active") { + return { ok: false, landId: input.landId, newOwnerId: input.newOwnerId, error: "inactive", message: "领地不可转让。" }; + } + return await db.tx(async (tx) => { - const snapshot = await runTransferSteps(tx, input); - return snapshot; + return runTransferSteps(tx, input, Number(cur.version ?? 0)); }); } catch (e) { if (e instanceof DbError) { @@ -49,26 +65,15 @@ export async function transferLand(input: TransferInput): Promise { +async function runTransferSteps(tx: TxContext, input: TransferInput, landVersion: number): Promise { const now = Date.now(); const oldMemberId = synthMemberId(input.landId, input.currentOwnerId); const newMemberId = synthMemberId(input.landId, input.newOwnerId); - // 1. 拿当前 land(版本号) - const cur = await tx.get<{ - id: string; - owner_player_id: string; - version: number; - status: string; - }>("lands", input.landId); - // 注意:tx.get 在 SDK 端只是 step recorder,这里拿不到真实值 — 用闭包假设 - // 真实执行发生在 db-server 内,client 端假设步骤会被原子执行。 - void cur; - - // 2. 删原 owner 成员行 + // 1. 删原 owner 成员行(读校验已在 tx 外完成;录制器 get 不可用于分支) await tx.delete("land_members", oldMemberId); - // 3. 写新 owner 成员行 + // 2. 写新 owner 成员行 await tx.insert("land_members", { id: newMemberId, land_id: input.landId, @@ -78,15 +83,15 @@ async function runTransferSteps(tx: TxContext, input: TransferInput): Promise 0) { await tx.call("economy.account.debit", { playerId: input.currentOwnerId,