feat: 重构服务支持双模式并接入 MongoDB 数据库
- 新增 MongoDB 存储层实现英雄、预设和版本数据的持久化 - 新增 push-meta 和 migrate-to-mongo 脚本支持数据同步迁移 - 支持开发版和线上设计版双模式运行 - 新增版本闸门机制,线上设计版落后时进入只读模式 - 优化前端 API 配置适配不同部署环境 - 新增线上设计版静态资源托管和路由回退
This commit is contained in:
@@ -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}`)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user