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
14 changes: 11 additions & 3 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
18 changes: 12 additions & 6 deletions db-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions db-server/src/routes/_shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,21 @@ export type RouteFactory = (deps: Partial<RouteDeps>) => 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<string, unknown> = { ok: false, error };
if (code) payload.code = code;
sharedJson(res, payload, status);
}

export {};

4 changes: 2 additions & 2 deletions db-server/src/routes/db-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -59,7 +59,7 @@ export function createDbRoutes(depsIn: Partial<DbRoutesDeps>) {

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;
}

Expand Down
11 changes: 6 additions & 5 deletions db-server/src/routes/module-config-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -79,12 +79,12 @@ export function createModuleConfigRoutes(depsIn: Partial<ModuleConfigRoutesDeps>
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;
}

Expand All @@ -97,7 +97,8 @@ export function createModuleConfigRoutes(depsIn: Partial<ModuleConfigRoutesDeps>
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;
}
Expand All @@ -116,7 +117,7 @@ export function createModuleConfigRoutes(depsIn: Partial<ModuleConfigRoutesDeps>
} 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;
}
Expand Down
4 changes: 2 additions & 2 deletions db-server/src/routes/service-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -41,7 +41,7 @@ export function createServiceRoutes(depsIn: Partial<ServiceRoutesDeps>) {
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;
}

Expand Down
38 changes: 38 additions & 0 deletions db-server/src/services/builtin-handlers.ts
Original file line number Diff line number Diff line change
@@ -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<string>
): 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;
}
3 changes: 2 additions & 1 deletion modules/sdk/@sfmc-sdk/src/sapi/db/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
61 changes: 39 additions & 22 deletions modules/sdk/@sfmc-sdk/src/sapi/runtime/economy.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<EconomyAccount | null> {
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<AccountView>("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<EconomyTransactionResult> {
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<EconomyTransactionResult & { balance?: number }>(
"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<MutateView>(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" };
}
}

Expand Down Expand Up @@ -155,4 +172,4 @@ export class Money {
static initScoreboard() {
// Economy is persisted by db-server. The legacy scoreboard is no longer authoritative.
}
}
}
27 changes: 24 additions & 3 deletions tools/resolve-npm-publish-tag.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -40,16 +43,34 @@ if (actual !== ver) {
process.exit(1);
}

/** @returns {Promise<boolean>} */
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})`);
Loading