diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 7e11dc9d..b9f807aa 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -5,10 +5,11 @@ name: npm-publish # 可发包清单唯一来源: tools/lib/npm-publish-packages.mjs on: - # 临时用 ** 验证;scoped tag(含 /)需跨段匹配。发布门禁在 resolve 脚本。 + # scoped tag 含 /(@sfmc-bds/sdk@v0.1.0)须用 ** 跨段;勿再用笼统 "**"(会误触 SEA v*)。 + # 包合法性 + 已发布探测由 tools/resolve-npm-publish-tag.mjs 校验。 push: tags: - - "**" + - "**sfmc-bds/**" workflow_dispatch: inputs: tag: @@ -39,16 +40,23 @@ jobs: PUBLISH_TAG: ${{ github.event.inputs.tag || github.ref_name }} run: node tools/resolve-npm-publish-tag.mjs + - name: skip if already published + if: steps.meta.outputs.already_published == 'true' + run: echo "Already on npm: ${{ steps.meta.outputs.workspace }}@${{ steps.meta.outputs.ver }} — skip" + - run: npm ci --no-audit --no-fund + if: steps.meta.outputs.already_published != 'true' - name: build SDK (dependency) - if: steps.meta.outputs.workspace != '@sfmc-bds/sdk' + if: steps.meta.outputs.already_published != 'true' && steps.meta.outputs.workspace != '@sfmc-bds/sdk' run: npm run build --workspace @sfmc-bds/sdk - name: build target workspace + if: steps.meta.outputs.already_published != 'true' run: npm run build --workspace "${{ steps.meta.outputs.workspace }}" - name: publish to npm + if: steps.meta.outputs.already_published != 'true' env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: npm publish --workspace "${{ steps.meta.outputs.workspace }}" --access public --provenance diff --git a/db-server/src/index.ts b/db-server/src/index.ts index b7d40fc2..4cf4470d 100644 --- a/db-server/src/index.ts +++ b/db-server/src/index.ts @@ -34,13 +34,14 @@ 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 { registerEconomyHandlers } from "./services/economy-handlers.js"; +import { registerEnabledBuiltinServices } from "./services/builtin-handlers.js"; import { readJson, writeJson } from "@sfmc-bds/sdk/node/config"; import { createModuleConfigRoutes } from "./routes/module-config-routes.js"; import { createDbRoutes } from "./routes/db-routes.js"; import { createServiceRoutes } from "./routes/service-routes.js"; +import { jsonV2Fail } from "./routes/_shared.js"; import { createConfigRoutes } from "./routes/config.js"; import { createHealthRoutes } from "./routes/health.js"; @@ -115,10 +116,14 @@ const txRunner = new TxRunner({ enabled: enabledManifests, }); -// ── 进程内 service handler(第一刀:economy.*) ─────────────── -if (enabledSet.has("feature-economy")) { - registerEconomyHandlers(serviceRegistry, { query, db }); - log.success(`[service] registered ${serviceRegistry.list().length} economy handlers`); +// ── 进程内置 service handler(扩展点:BUILTIN_SERVICE_PLUGINS) ── +{ + const plugins = registerEnabledBuiltinServices(serviceRegistry, { query, db }, enabledSet); + if (plugins > 0) { + log.success( + `[service] registered ${serviceRegistry.list().length} handlers from ${plugins} builtin plugin(s)` + ); + } } const json = sharedJson; @@ -272,7 +277,8 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom enabledModuleIds: enabledSet, }); if (!id) { - json(res, { success: false, error: "unauthorized: module identity invalid" }, 401); + // LSP: v2 模块门与 service-routes 同用 ok 方言 + jsonV2Fail(res, "unauthorized: module identity invalid", 401, "unauthorized"); return; } const manifest = enabledManifests.get(id); diff --git a/db-server/src/routes/_shared.ts b/db-server/src/routes/_shared.ts index 056445e2..5e9bb322 100644 --- a/db-server/src/routes/_shared.ts +++ b/db-server/src/routes/_shared.ts @@ -57,5 +57,21 @@ export type RouteFactory = (deps: Partial) => RouteHandler; export const json = sharedJson; export const body = sharedBody; +/** + * v2 失败信封权威形态(DRY + LSP): + * 一律 `{ ok:false, error, code? }`,勿再混用 `{ success:false }`。 + * service-routes / db / module-config / index 模块鉴权门应走此助手。 + */ +export function jsonV2Fail( + res: ServerResponse, + error: string, + status: number, + code?: string +): void { + const payload: Record = { ok: false, error }; + if (code) payload.code = code; + sharedJson(res, payload, status); +} + export {}; diff --git a/db-server/src/routes/db-routes.ts b/db-server/src/routes/db-routes.ts index dd9f38fd..8eb1fe68 100644 --- a/db-server/src/routes/db-routes.ts +++ b/db-server/src/routes/db-routes.ts @@ -18,7 +18,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { json as defaultJson, type Method } from "../lib/http.js"; -import type { ModuleAuth } from "./_shared.js"; +import { jsonV2Fail, type ModuleAuth } from "./_shared.js"; import type { DefineTableRequest, SchemaRegistry, @@ -59,7 +59,7 @@ export function createDbRoutes(depsIn: Partial) { const auth = ctx.moduleAuth ?? null; if (!auth) { - json(res, { success: false, error: "unauthorized: module identity missing" }, 401); + jsonV2Fail(res, "unauthorized: module identity missing", 401, "unauthorized"); return true; } diff --git a/db-server/src/routes/module-config-routes.ts b/db-server/src/routes/module-config-routes.ts index 7bcb3674..75ebd143 100644 --- a/db-server/src/routes/module-config-routes.ts +++ b/db-server/src/routes/module-config-routes.ts @@ -18,7 +18,7 @@ import { readJson, writeJson } from "@sfmc-bds/sdk/node/config"; import { json as defaultJson, type Method } from "../lib/http.js"; import { assertModulePermission, Perm } from "../permission-gate.js"; import type { ModuleManifestV2 } from "../manifest-loader.js"; -import type { ModuleAuth } from "./_shared.js"; +import { jsonV2Fail, type ModuleAuth } from "./_shared.js"; export interface ModuleConfigRoutesDeps { projectRoot: string; @@ -79,12 +79,12 @@ export function createModuleConfigRoutes(depsIn: Partial const tail = m[2]; const auth = ctx.moduleAuth; if (!auth) { - json(res, { success: false, error: "unauthorized" }, 401); + jsonV2Fail(res, "unauthorized", 401, "unauthorized"); return true; } const manifest = enabled.get(auth.id); if (!manifest || manifest.configKey !== configKey) { - json(res, { success: false, error: `模块 ${auth.id} 不持有 configKey "${configKey}"` }, 403); + jsonV2Fail(res, `模块 ${auth.id} 不持有 configKey "${configKey}"`, 403, "forbidden"); return true; } @@ -97,7 +97,8 @@ export function createModuleConfigRoutes(depsIn: Partial json(res, { config: cfg }); } catch (e) { const code = (e as { name?: string }).name === "PermissionDeniedError" ? 403 : 500; - json(res, { success: false, error: (e as Error).message }, code); + // LSP: GET 失败与 /set 同用 ok 方言 + jsonV2Fail(res, (e as Error).message, code); } return true; } @@ -116,7 +117,7 @@ export function createModuleConfigRoutes(depsIn: Partial } catch (e) { // LSP: /set 成功用 ok:true,失败统一 ok:false(勿混用 success) const code = (e as { name?: string }).name === "PermissionDeniedError" ? 403 : 500; - json(res, { ok: false, error: (e as Error).message }, code); + jsonV2Fail(res, (e as Error).message, code); } return true; } diff --git a/db-server/src/routes/service-routes.ts b/db-server/src/routes/service-routes.ts index 3f0226c5..bc10261c 100644 --- a/db-server/src/routes/service-routes.ts +++ b/db-server/src/routes/service-routes.ts @@ -11,7 +11,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { json as defaultJson, type Method } from "../lib/http.js"; import { assertModulePermission, Perm, PermissionDeniedError } from "../permission-gate.js"; import type { ServiceRegistry } from "../service-registry.js"; -import type { ModuleAuth } from "./_shared.js"; +import { jsonV2Fail, type ModuleAuth } from "./_shared.js"; export interface ServiceRoutesDeps { serviceRegistry: ServiceRegistry; @@ -41,7 +41,7 @@ export function createServiceRoutes(depsIn: Partial) { const auth = ctx.moduleAuth ?? null; if (!auth) { // LSP: 与成功路径同一 ok 方言(文档约定 { ok:false, error, code }) - json(res, { ok: false, error: "unauthorized: module identity missing", code: "unauthorized" }, 401); + jsonV2Fail(res, "unauthorized: module identity missing", 401, "unauthorized"); return true; } diff --git a/db-server/src/services/builtin-handlers.ts b/db-server/src/services/builtin-handlers.ts new file mode 100644 index 00000000..14450ea8 --- /dev/null +++ b/db-server/src/services/builtin-handlers.ts @@ -0,0 +1,38 @@ +/** + * services/builtin-handlers.ts — 进程内置 service 插件注册表 + * + * 新增内置 handler 只追加 BUILTIN_SERVICE_PLUGINS(OCP), + * 勿在 index.ts 再写 if (enabledSet.has(...)) registerXxx 链。 + */ + +import type { DatabaseSync } from "node:sqlite"; +import type { QueryFn } from "../lib/sqlite.js"; +import type { ServiceRegistry } from "../service-registry.js"; +import { registerEconomyHandlers } from "./economy-handlers.js"; + +export type BuiltinServiceDeps = { query: QueryFn; db: DatabaseSync }; + +export type BuiltinServicePlugin = { + moduleId: string; + register: (registry: ServiceRegistry, deps: BuiltinServiceDeps) => void; +}; + +/** 内置 service 插件清单 — 唯一扩展点 */ +export const BUILTIN_SERVICE_PLUGINS: BuiltinServicePlugin[] = [ + { moduleId: "feature-economy", register: registerEconomyHandlers }, +]; + +/** 按 enabledSet 注册内置插件,返回已注册插件数 */ +export function registerEnabledBuiltinServices( + registry: ServiceRegistry, + deps: BuiltinServiceDeps, + enabledSet: Set +): number { + let n = 0; + for (const plugin of BUILTIN_SERVICE_PLUGINS) { + if (!enabledSet.has(plugin.moduleId)) continue; + plugin.register(registry, deps); + n += 1; + } + return n; +} diff --git a/modules/sdk/@sfmc-sdk/src/sapi/db/types.ts b/modules/sdk/@sfmc-sdk/src/sapi/db/types.ts index 5c942558..ef041844 100644 --- a/modules/sdk/@sfmc-sdk/src/sapi/db/types.ts +++ b/modules/sdk/@sfmc-sdk/src/sapi/db/types.ts @@ -81,7 +81,8 @@ export interface TxStepResult { export interface TxResponse { ok: true; - steps: TxStepResult[]; + /** 与 db-server TxResponse.results 对齐(LSP);勿再命名为 steps */ + results: TxStepResult[]; } export interface TxError { diff --git a/modules/sdk/@sfmc-sdk/src/sapi/runtime/economy.ts b/modules/sdk/@sfmc-sdk/src/sapi/runtime/economy.ts index f96ab4de..871e4ed3 100644 --- a/modules/sdk/@sfmc-sdk/src/sapi/runtime/economy.ts +++ b/modules/sdk/@sfmc-sdk/src/sapi/runtime/economy.ts @@ -1,5 +1,5 @@ import { Player } from "@minecraft/server"; -import { HttpDB } from "./httpdb.js"; +import { service, ServiceError } from "../service/client.js"; import { debug } from "./debug-log.js"; export interface EconomyAccount { @@ -24,37 +24,54 @@ export interface EconomyTransactionResult { error?: string; } +type AccountView = { balance?: number; version?: number } | null; + +type MutateView = { + balance?: number; + version?: number; + transactionId?: string; +}; + +/** + * 经 service registry 读账户(LSP:已无 /api/sfmc/economy/* REST)。 + * 调用方须已 setServiceModuleContext,且持有 service:economy.account.get。 + */ async function getEconomyAccount(playerId: string, playerName: string): Promise { try { - const body = await HttpDB.get(`/api/sfmc/economy/account/${encodeURIComponent(playerId)}`); - if (!body) return null; - const parsed = JSON.parse(body); - if (!parsed || typeof parsed.balance !== "number") return null; - return { balance: parsed.balance, version: parsed.version ?? 0 }; + const account = await service.get("economy.account.get", { + playerId, + playerName, + }); + if (!account || typeof account.balance !== "number") return null; + return { balance: account.balance, version: account.version ?? 0 }; } catch (e) { debug.w("MNY", `getEconomyAccount failed for ${playerName}: ${(e as Error).message}`); return null; } } +/** + * credit/debit 走同名 service;保留旧 EconomyTransactionRequest 形状给调用方。 + */ async function applyEconomyTransaction(req: EconomyTransactionRequest): Promise { + const playerId = + req.type === "debit" ? (req.sourcePlayerId ?? req.actorId) : (req.targetPlayerId ?? req.actorId); + const name = req.type === "debit" ? "economy.account.debit" : "economy.account.credit"; try { - const res = await HttpDB.typedRequest( - "POST" as any, - "/api/sfmc/economy/transaction", - { - actorId: req.actorId, - sourcePlayerId: req.sourcePlayerId, - targetPlayerId: req.targetPlayerId, - amount: req.amount, - type: req.type, - note: req.note, - } - ); - if (res.ok && res.data) return res.data; - return { ok: false, error: res.error || "request_failed" }; + const data = await service.get(name, { + playerId, + actorId: req.actorId, + amount: req.amount, + reason: req.note ?? "", + }); + const out: EconomyTransactionResult = { ok: true }; + if (typeof data?.balance === "number") out.balance = data.balance; + if (typeof data?.version === "number") out.version = data.version; + if (typeof data?.transactionId === "string") out.transactionId = data.transactionId; + return out; } catch (e) { - return { ok: false, error: (e as Error).message }; + const err = e as ServiceError; + return { ok: false, error: err.message || "request_failed" }; } } @@ -155,4 +172,4 @@ export class Money { static initScoreboard() { // Economy is persisted by db-server. The legacy scoreboard is no longer authoritative. } -} \ No newline at end of file +} diff --git a/tools/resolve-npm-publish-tag.mjs b/tools/resolve-npm-publish-tag.mjs index 44410400..72f46e06 100644 --- a/tools/resolve-npm-publish-tag.mjs +++ b/tools/resolve-npm-publish-tag.mjs @@ -5,7 +5,10 @@ * * 用法(workflow): * node tools/resolve-npm-publish-tag.mjs - * 依赖 env: GITHUB_REF_NAME, GITHUB_OUTPUT + * 依赖 env: PUBLISH_TAG 或 GITHUB_REF_NAME,GITHUB_OUTPUT + * + * 另:探测 registry 是否已有同版本,写 already_published=true, + * 避免 workflow_dispatch 重跑对已发布版本 E403(与 publish 步骤幂等)。 */ import fs from "node:fs"; import { NPM_PUBLISH_PACKAGES, resolvePublishPackage } from "./lib/npm-publish-packages.mjs"; @@ -40,16 +43,34 @@ if (actual !== ver) { process.exit(1); } +/** @returns {Promise} */ +async function isAlreadyPublished(name, version) { + const url = `https://registry.npmjs.org/${encodeURIComponent(name)}/${encodeURIComponent(version)}`; + try { + const res = await fetch(url); + return res.status === 200; + } catch (e) { + console.warn(`[resolve-npm-publish-tag] registry probe failed: ${e?.message || e}`); + return false; + } +} + +const already = await isAlreadyPublished(resolved, ver); +if (already) { + console.log(`Already published on npm: ${resolved}@${ver}`); +} else { + console.log(`Version OK: ${resolved}@${ver} (${pkgPath})`); +} + const lines = [ `tag=${tag}`, `pkg=${resolved}`, `workspace=${resolved}`, `ver=${ver}`, `pkg_path=${pkgPath}`, + `already_published=${already ? "true" : "false"}`, ]; if (outFile) { fs.appendFileSync(outFile, lines.join("\n") + "\n"); } - -console.log(`Version OK: ${resolved}@${ver} (${pkgPath})`);