feat: 重构服务支持双模式并接入 MongoDB 数据库

- 新增 MongoDB 存储层实现英雄、预设和版本数据的持久化
- 新增 push-meta 和 migrate-to-mongo 脚本支持数据同步迁移
- 支持开发版和线上设计版双模式运行
- 新增版本闸门机制,线上设计版落后时进入只读模式
- 优化前端 API 配置适配不同部署环境
- 新增线上设计版静态资源托管和路由回退
This commit is contained in:
panFD
2026-10-03 12:02:47 +08:00
parent 1bb275607a
commit 9c9cc99e64
14 changed files with 656 additions and 48 deletions

22
put/server/.env.example Normal file
View File

@@ -0,0 +1,22 @@
# ---- 运行模式 ----
# local = 项目开发版(本地,全功能:写 heroSet.ts / 导出 CSV / push-meta)
# remote = 线上设计版(VPS 部署,仅英雄/预设编辑,版本落后时只读)
MODE=local
# ---- MongoDB(两边都要配置,连同一个库)----
# 本地 MongoDB 示例:mongodb://127.0.0.1:27017
# 带账号密码示例: mongodb://user:password@your-vps-ip:27017
# Atlas 示例: mongodb+srv://user:password@cluster0.xxxx.mongodb.net
MONGO_URL=mongodb://user:password@your-vps-ip:27017
MONGO_DB=heroconfig
# ---- 服务端口 ----
PORT=3001
# ---- 数据结构版本 ----
# 英雄/底座结构有破坏性变更时 +1,并执行 npm run push-meta 推送到库中
# 线上设计版低于库中版本时将进入只读,需更新部署
SCHEMA_VERSION=1
# ---- 前端(web/.env,仅静态部署时需要;server 托管 dist 时不用配)----
# VITE_API_BASE=http://your-vps-ip:3001

75
put/server/db.js Normal file
View File

@@ -0,0 +1,75 @@
/**
* 英雄/预设存储层:开发版与线上设计版统一读写线上 MongoDB。
* - heroes 集合:每个英雄一条文档,uuid 唯一索引
* - templates 集合:预设库(_id='presets' 单文档)
* - meta 集合:版本闸门(_id='version' 单文档,schemaVersion 由开发版推送 meta 时写入)
* 技能/Buff 底座不进数据库,开发版走 TS 编译,线上版走 meta.json 快照。
*/
import { MongoClient } from 'mongodb'
const MONGO_URL = process.env.MONGO_URL || ''
const DB_NAME = process.env.MONGO_DB || 'heroconfig'
let client = null
let db = null
/** 惰性连接 MongoDB(单例复用) */
async function mongo() {
if (db) return db
if (!MONGO_URL) throw new Error('未配置 MONGO_URL 环境变量')
client = new MongoClient(MONGO_URL)
await client.connect()
db = client.db(DB_NAME)
// uuid 唯一索引:保证英雄按 uuid 幂等覆盖
await db.collection('heroes').createIndex({ uuid: 1 }, { unique: true })
return db
}
/** 读取全部英雄(按 uuid 升序,剥离 Mongo 内部 _id) */
export async function getHeroes() {
const docs = await (await mongo()).collection('heroes').find().sort({ uuid: 1 }).toArray()
return docs.map(({ _id, ...h }) => h)
}
/** 全量覆盖:upsert 传入英雄,并删除库中多余的 uuid */
export async function saveHeroes(heroes) {
const col = (await mongo()).collection('heroes')
for (const h of heroes) {
await col.replaceOne({ uuid: h.uuid }, h, { upsert: true })
}
await col.deleteMany({ uuid: { $nin: heroes.map((h) => h.uuid) } })
}
/** 读取预设库(无文档时返回空池) */
export async function getTemplates() {
const doc = await (await mongo()).collection('templates').findOne({ _id: 'presets' })
return {
triggerPresets: Array.isArray(doc?.triggerPresets) ? doc.triggerPresets : [],
fieldPresets: Array.isArray(doc?.fieldPresets) ? doc.fieldPresets : [],
}
}
/** 全量覆盖预设库 */
export async function saveTemplates(triggerPresets, fieldPresets) {
await (await mongo())
.collection('templates')
.replaceOne({ _id: 'presets' }, { _id: 'presets', triggerPresets, fieldPresets }, { upsert: true })
}
/** 读取库中数据结构版本(首次为 0) */
export async function getSchemaVersion() {
const doc = await (await mongo()).collection('meta').findOne({ _id: 'version' })
return doc?.schemaVersion ?? 0
}
/**
* 推送数据结构版本:只允许升不允许降,避免旧部署误把版本刷低导致线上版解锁写入。
* @returns {Promise<number>} 写入后的实际版本
*/
export async function bumpSchemaVersion(version) {
const col = (await mongo()).collection('meta')
const cur = await getSchemaVersion()
const next = Math.max(cur, version)
await col.replaceOne({ _id: 'version' }, { _id: 'version', schemaVersion: next, updatedAt: new Date() }, { upsert: true })
return next
}

View File

@@ -1,9 +1,17 @@
/**
* 英雄配置工具服务(双模式)
* - 项目开发版(MODE=local):全功能,meta 从游戏工程 TS 编译,可写 heroSet.ts / 导出 CSV / 推送快照
* - 线上设计版(MODE=remote):meta 从 data/meta.json 快照加载,仅英雄/预设的增删改查;
* 库中 schemaVersion 高于本服务内置版本时进入只读(保存接口返回 409),需更新部署后才能修改
*/
import 'dotenv/config'
import express from 'express'
import cors from 'cors'
import esbuild from 'esbuild'
import fs from 'node:fs'
import path from 'node:path'
import url from 'node:url'
import * as store from './db.js'
const __dirname = path.dirname(url.fileURLToPath(import.meta.url))
const PROJECT_ROOT = path.resolve(__dirname, '..', '..')
@@ -14,7 +22,11 @@ const CSV_PATH = path.join(CONFIG_DIR, '英雄设计表.csv')
const HEROES_BEGIN = '// @hero-setup:heroes-begin'
const HEROES_END = '// @hero-setup:heroes-end'
const DATA_DIR = path.join(__dirname, 'data')
const HEROES_PATH = path.join(DATA_DIR, 'heroes.json')
const META_SNAPSHOT_PATH = path.join(DATA_DIR, 'meta.json')
const MODE = process.env.MODE || 'local'
/** 数据结构版本:英雄/底座结构有破坏性变更时 +1,并随 push-meta 推送到库中 */
const SCHEMA_VERSION = Number(process.env.SCHEMA_VERSION || 1)
const app = express()
app.use(cors())
@@ -43,6 +55,14 @@ async function loadConfigModule(file) {
let cache = null
async function buildCache() {
// 线上设计版:从快照文件加载(快照由开发版 scripts/push-meta.js 生成并随部署发布)
if (MODE === 'remote') {
if (!fs.existsSync(META_SNAPSHOT_PATH)) {
throw new Error('缺少 data/meta.json 快照,请先在开发版执行 npm run push-meta 并重新部署')
}
cache = JSON.parse(fs.readFileSync(META_SNAPSHOT_PATH, 'utf8'))
return cache
}
const [skill, buff, hero] = await Promise.all([
loadConfigModule('SkillSet.ts'),
loadConfigModule('BuffSet.ts'),
@@ -63,7 +83,12 @@ async function buildCache() {
app.get('/api/meta', async (_req, res) => {
try {
if (!cache) await buildCache()
res.json(cache)
res.json({
...cache,
mode: MODE,
schemaVersion: SCHEMA_VERSION,
dbSchemaVersion: await store.getSchemaVersion(),
})
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
@@ -78,51 +103,56 @@ app.post('/api/refresh', async (_req, res) => {
}
})
/** 英雄数据持久化:GET 读取(无文件时返回空列表) */
app.get('/api/heroes', (_req, res) => {
/** 版本闸门:线上设计版落后于库中结构版本时,写操作返回 409 强制只读 */
async function guardVersion(res) {
if (MODE !== 'remote') return true
const dbVer = await store.getSchemaVersion()
if (dbVer > SCHEMA_VERSION) {
res.status(409).json({
error: `线上设计版结构版本(v${SCHEMA_VERSION})低于数据库(v${dbVer}),请更新部署后再修改`,
dbSchemaVersion: dbVer,
schemaVersion: SCHEMA_VERSION,
})
return false
}
return true
}
/** 英雄数据:GET 从线上库读取 */
app.get('/api/heroes', async (_req, res) => {
try {
if (!fs.existsSync(HEROES_PATH)) return res.json({ heroes: [] })
res.json(JSON.parse(fs.readFileSync(HEROES_PATH, 'utf8')))
res.json({ heroes: await store.getHeroes() })
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
/** 英雄数据持久化:POST 全量覆盖保存 */
app.post('/api/heroes', (req, res) => {
/** 英雄数据:POST 全量覆盖写入线上库 */
app.post('/api/heroes', async (req, res) => {
const heroes = req.body?.heroes
if (!Array.isArray(heroes)) {
return res.status(400).json({ error: 'heroes 必须为数组' })
}
try {
fs.mkdirSync(DATA_DIR, { recursive: true })
fs.writeFileSync(HEROES_PATH, JSON.stringify({ heroes }, null, 2), 'utf8')
res.json({ ok: true, path: HEROES_PATH, count: heroes.length })
if (!(await guardVersion(res))) return
await store.saveHeroes(heroes)
res.json({ ok: true, count: heroes.length })
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
const TEMPLATES_PATH = path.join(DATA_DIR, 'templates.json')
/** 预设库持久化:GET 读取(无文件时返回空池) */
app.get('/api/templates', (_req, res) => {
/** 预设库:GET 从线上库读取 */
app.get('/api/templates', async (_req, res) => {
try {
if (!fs.existsSync(TEMPLATES_PATH)) return res.json({ triggerPresets: [], fieldPresets: [] })
const data = JSON.parse(fs.readFileSync(TEMPLATES_PATH, 'utf8'))
// 兼容旧格式:若根上是 templates 数组,返回空池让前端走默认
if (Array.isArray(data.templates)) return res.json({ triggerPresets: [], fieldPresets: [] })
res.json({
triggerPresets: Array.isArray(data.triggerPresets) ? data.triggerPresets : [],
fieldPresets: Array.isArray(data.fieldPresets) ? data.fieldPresets : [],
})
res.json(await store.getTemplates())
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
/** 职业模板持久化:POST 全量覆盖保存(结构:{ triggerPresets: [], fieldPresets: [] }) */
app.post('/api/templates', (req, res) => {
/** 预设库:POST 全量覆盖写入线上库(结构:{ triggerPresets: [], fieldPresets: [] }) */
app.post('/api/templates', async (req, res) => {
const body = req.body || {}
const triggerPresets = body.triggerPresets
const fieldPresets = body.fieldPresets
@@ -136,9 +166,9 @@ app.post('/api/templates', (req, res) => {
return res.status(400).json({ error: `触发预设 ${bad.id || bad.label} 的 group 非法:${bad.group}` })
}
try {
fs.mkdirSync(DATA_DIR, { recursive: true })
fs.writeFileSync(TEMPLATES_PATH, JSON.stringify({ triggerPresets, fieldPresets }, null, 2), 'utf8')
res.json({ ok: true, path: TEMPLATES_PATH, triggerCount: triggerPresets.length, fieldCount: fieldPresets.length })
if (!(await guardVersion(res))) return
await store.saveTemplates(triggerPresets, fieldPresets)
res.json({ ok: true, triggerCount: triggerPresets.length, fieldCount: fieldPresets.length })
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
@@ -425,8 +455,16 @@ function renderHeroCsv(heroes) {
return rows.map((r) => r.map(csvCell).join(',')).join('\n') + '\n'
}
/** 仅开发版可用的本地文件写操作(heroSet.ts / CSV),线上设计版调用返回 403 */
function localOnly(res) {
if (MODE === 'local') return true
res.status(403).json({ error: '线上设计版不支持此操作(写 heroSet.ts / 导出 CSV 请在项目开发版执行)' })
return false
}
app.post('/api/export-csv', async (req, res) => {
try {
if (!localOnly(res)) return
const heroes = req.body?.heroes
if (!Array.isArray(heroes) || !heroes.length) {
return res.status(400).json({ error: 'heroes 不能为空' })
@@ -440,12 +478,25 @@ app.post('/api/export-csv', async (req, res) => {
}
})
app.get('/api/export-csv', (_req, res) => {
app.get('/api/export-csv', (req, res) => {
if (!localOnly(res)) return
if (!fs.existsSync(CSV_PATH)) return res.status(404).json({ error: '尚未生成' })
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.send(fs.readFileSync(CSV_PATH, 'utf8'))
})
/** 导出 meta 快照(开发版专用):供 push-meta 脚本发布到线上设计版 */
app.get('/api/meta-snapshot', async (_req, res) => {
try {
if (!localOnly(res)) return
if (!cache) await buildCache()
const { mode: _m, schemaVersion: _v, dbSchemaVersion: _d, ...snapshot } = cache
res.json(snapshot)
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
/** HeroList 定义匹配(用于导出时同步图鉴/卡池数据源) */
const HERO_LIST_RE = /export const HeroList: number\[\] = \[[\s\S]*?\n\];/
@@ -470,6 +521,7 @@ function syncHeroList(src, heroes) {
app.post('/api/apply', (req, res) => {
try {
if (!localOnly(res)) return
const heroes = req.body?.heroes
if (!Array.isArray(heroes) || !heroes.length) {
return res.status(400).json({ error: 'heroes 不能为空' })
@@ -491,7 +543,17 @@ app.post('/api/apply', (req, res) => {
}
})
const PORT = 3001
const PORT = Number(process.env.PORT || 3001)
// 线上设计版:托管前端构建产物(web/dist),单个服务即可对外提供完整站点
if (MODE === 'remote') {
const DIST_DIR = path.resolve(__dirname, '..', 'web', 'dist')
if (fs.existsSync(DIST_DIR)) {
app.use(express.static(DIST_DIR))
app.get(/^\/(?!api\/).*/, (_req, res) => res.sendFile(path.join(DIST_DIR, 'index.html')))
}
}
app.listen(PORT, () => {
console.log(`[hero-config-tool] server listening http://localhost:${PORT}`)
console.log(`[hero-config-tool] mode=${MODE} schemaVersion=${SCHEMA_VERSION} listening http://localhost:${PORT}`)
})

View File

@@ -4,11 +4,15 @@
"private": true,
"type": "module",
"scripts": {
"dev": "node --watch index.js"
"dev": "node --watch index.js",
"start": "node index.js",
"push-meta": "node scripts/push-meta.js"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^18.0.5",
"esbuild": "^0.24.0",
"express": "^4.21.2"
"express": "^4.21.2",
"mongodb": "^6.12.0"
}
}

View File

@@ -0,0 +1,35 @@
/**
* 一次性迁移:把本地 data/heroes.json、data/templates.json 导入线上 MongoDB
* 用法:配置 MONGO_URL 后执行 node scripts/migrate-to-mongo.js
* 已在库中的数据会按 uuid 幂等覆盖,可重复执行。
*/
import fs from 'node:fs'
import path from 'node:path'
import url from 'node:url'
import { saveHeroes, saveTemplates, getHeroes } from '../db.js'
const __dirname = path.dirname(url.fileURLToPath(import.meta.url))
const DATA_DIR = path.join(__dirname, '..', 'data')
const readJson = (file, fallback) => {
const p = path.join(DATA_DIR, file)
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : fallback
}
const heroes = readJson('heroes.json', { heroes: [] }).heroes || []
const tpl = readJson('templates.json', { triggerPresets: [], fieldPresets: [] })
if (heroes.length) {
await saveHeroes(heroes)
console.log(`[migrate] heroes 导入 ${heroes.length} 条`)
} else {
console.log('[migrate] 无本地 heroes.json 或为空,跳过英雄导入')
}
if ((tpl.triggerPresets?.length || 0) + (tpl.fieldPresets?.length || 0) > 0) {
await saveTemplates(tpl.triggerPresets || [], tpl.fieldPresets || [])
console.log(`[migrate] templates 导入 trigger=${tpl.triggerPresets?.length || 0} field=${tpl.fieldPresets?.length || 0}`)
} else {
console.log('[migrate] 无本地 templates.json 或为空,跳过预设导入')
}
console.log(`[migrate] 库中现有英雄 ${(await getHeroes()).length} 条`)
process.exit(0)

View File

@@ -0,0 +1,29 @@
/**
* 推送 meta 快照 + 结构版本到线上(项目开发版使用)
* 用法:先启动本地 dev 服务,再执行 npm run push-meta
* 产物:server/data/meta.json —— 需随线上设计版一起部署(git 提交或拷贝到 VPS)
*/
import fs from 'node:fs'
import path from 'node:path'
import url from 'node:url'
import { bumpSchemaVersion } from '../db.js'
const __dirname = path.dirname(url.fileURLToPath(import.meta.url))
const SNAPSHOT_PATH = path.join(__dirname, '..', 'data', 'meta.json')
const LOCAL_API = process.env.LOCAL_API || 'http://localhost:3001'
const SCHEMA_VERSION = Number(process.env.SCHEMA_VERSION || 1)
const res = await fetch(`${LOCAL_API}/api/meta-snapshot`)
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`拉取 meta 快照失败(请确认本地 dev 服务已启动): ${res.status} ${body}`)
}
const snapshot = await res.json()
fs.mkdirSync(path.dirname(SNAPSHOT_PATH), { recursive: true })
fs.writeFileSync(SNAPSHOT_PATH, JSON.stringify(snapshot), 'utf8')
const dbVersion = await bumpSchemaVersion(SCHEMA_VERSION)
console.log(`[push-meta] meta.json 已生成(${Buffer.byteLength(JSON.stringify(snapshot))} bytes)`)
console.log(`[push-meta] 数据库结构版本: v${dbVersion}(本服务 v${SCHEMA_VERSION})`)
console.log('[push-meta] 请将 meta.json 与最新代码一起部署到线上设计版')
process.exit(0)