宗门迁移
This commit is contained in:
1 parent
76d6ae654f
commit
f3f88c309c
15 files changed
+1511
-1
No files matched your search
@@ -0,0 +1,40 @@
|
||||
# 临时宗门迁移工具
|
||||
|
||||
入口:`/admin/sect-migration`。工具只调用已有认证、角色锁、事务、背包和资源接口;不修改游戏启动、全局中间件、定时任务、修炼或丹药。未发布的 `0044` 在删除旧表前自动保留迁移来源;实际转化与补偿只在本工具执行,迁移结束可以整组删除工具。
|
||||
|
||||
## 停机与执行
|
||||
|
||||
停机由部署侧负责。先备份数据库、完成旧版清账,停止游戏实例、消息消费者与外部定时任务。先执行包含来源保留步骤的正常数据库升级,再使用含迁移页面的前端,临时把 API 进程入口换为本分支的:
|
||||
|
||||
```bash
|
||||
bun --env-file=你的环境文件 src/server/routes/api/admin/sect-migration/serve.ts
|
||||
```
|
||||
|
||||
此入口沿用原来的 `HOST`、`PORT`、认证及管理员配置,只服务登录、验证码、管理员会话和迁移接口,不执行 `src/index.ts`,不会启动游戏消费者或定时任务。需要可运行 Bun 的源码维护环境;无需修改正式启动配置或构建链。前端仍使用包含迁移页面的正常 SPA,API 域名及反向代理保持原有配置。
|
||||
|
||||
1. 停机后执行项目正常数据库迁移。`0044` 的第一步会锁定旧宗门来源表,将每个 active 成员的身份、心法等级、双流派层数、当前流派和原节点配置保存到同库 `app_settings` 的 `sect-migration:v1:pending:<membershipId>`,并保存 `sect-migration:v1:manifest` 清单。不依赖新版服务或提前点击后台按钮。
|
||||
2. 来源保留与后续旧表删除、清空、结构升级处于 Drizzle 的同一事务,任一步失败都回滚。若发现已有 `sect-migration:v1:` 记录,直接中止,须核查记录来源,不覆盖、不自动删除。没有 active 成员时仍保存空清单。
|
||||
3. 启动上述临时管理服务,打开迁移页面,勾选已停机,查看目标等级、经脉深度、退款与异常,点击“迁移下一批”,每批最多 20 人。工具检测到旧数据库时不会允许执行新版写入。
|
||||
4. 每位玩家迁移成功后,在同一个事务内删除其 pending 记录;失败保留原记录。进度、退款、道具发放、资源事件及迁移凭证一起提交,重复执行不重复退款。
|
||||
5. 刷新核对到所有成员均通过,下载凭证留档,关闭临时 API 服务,恢复正常游戏入口。核对依赖当前余额/进度,应在开服前完成。
|
||||
|
||||
勾选停机仅记录管理员的操作前提,不声称检查或停止了其他进程。已有新版培养数据的成员禁止覆盖。已经执行过未含保留步骤的旧 `0044` 的数据库,不会因修改文件自动重跑;缺失来源只能从升级前备份找回旧记录,不能强行重跑 `0044`;工具不会用新版测试进度推断旧资产。
|
||||
|
||||
## 迁移与补偿
|
||||
|
||||
- 五个宗门的固定映射在 `src/shared/sect-migration/mapping.json`。六本心法按槽位继承等级,不匹配名字或技能效果。未知 ID、越界、分支超过主心法等情况阻止该成员迁移,不静默裁剪。
|
||||
- 旧六层经脉包含 `ultimate`。新版共享深度取双流派最大值,第 7 层不赠送;节点清空让玩家重选。旧当前流派按固定映射继承,未选择流派的成员保留待选择状态。
|
||||
- 另一流派的投入按旧标准累计返还:第 n 层修为 `5000 × 4^(n-1)`,灵石为修为的 5 倍,感悟每层 100。退款公式冻结。
|
||||
- 修为、灵石直接返还,超过既有余额上限时整笔失败;大额灵石在同一事务内分段调用现有接口,不被单次增量上限截断。
|
||||
- **感悟改发宗门感悟果**,50 点补偿对应 1 颗,每颗增加 50 点感悟(每层补偿 2 颗,最多 12 颗)。它是已有 `consumable.v1` 的完整灵果实例,复用灵果服用逻辑,感悟已满时拒绝消耗。建议感悟不高于 50 时服用,超出 100 上限的部分按现有规则不保留;玄品适用于当前所有境界。没有新增物品定义、专属使用入口或对主流程的例外规则。删除工具后依然可用。
|
||||
- 道具自动堆叠,背包不足时按现有规则进入新版仓库。核对包括道具数量增量与退款前后余额。
|
||||
|
||||
## 删除工具
|
||||
|
||||
迁移完成且凭证已下载后:
|
||||
|
||||
- 删除 `src/server/routes/api/admin/sect-migration/`、`src/react-app/routes/admin/sect-migration/`、`src/shared/sect-migration/`。
|
||||
- 删除 `src/shared/contracts/sectMigration.ts` 和本文档。
|
||||
- 移除 `src/server/routes/api/admin/index.ts` 的对应 import/route,以及 `src/react-app/router.tsx`、`src/react-app/routes/admin/_config/nav.ts` 的页面入口。
|
||||
|
||||
除此之外没有需要恢复的主流程改动,也没有新增数据库表、字段或迁移编号。已执行的 `0044` 作为迁移历史保留,不随工具删除或回退;它只依赖当时的旧表和 `app_settings`,不依赖工具代码。`app_settings` 中的迁移凭证可作为审计记录保留;如需清理,先归档再仅清理 `sect-migration:v1:` 前缀。玩家的新宗门进度、资源和补偿道具均不依赖这些凭证或工具代码。
|
||||
@@ -1,3 +1,35 @@
|
||||
-- Preserve V5 sect migration inputs before any destructive cutover statement.
|
||||
-- Drizzle runs preservation and schema changes in the same transaction.
|
||||
-- Compensation remains an explicit action in the temporary admin tool.
|
||||
DO $$
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(74612001);
|
||||
LOCK TABLE "wanjiedaoyou_sect_memberships", "wanjiedaoyou_sect_method_progress",
|
||||
"wanjiedaoyou_sect_path_progress", "wanjiedaoyou_sect_meridian_loadouts" IN SHARE MODE;
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM "wanjiedaoyou_app_settings" WHERE "key" LIKE 'sect-migration:v1:%'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Sect migration records already exist; inspect them before cutover. Existing sources must not be overwritten.';
|
||||
END IF;
|
||||
|
||||
INSERT INTO "wanjiedaoyou_app_settings" ("key", "value")
|
||||
SELECT 'sect-migration:v1:pending:' || m.id, jsonb_build_object(
|
||||
'membershipId', m.id, 'cultivatorId', m.cultivator_id, 'sectId', m.sect_id,
|
||||
'activePathId', m.active_path_id,
|
||||
'methods', COALESCE((SELECT jsonb_agg(jsonb_build_object('methodId', p.method_id, 'level', p.level) ORDER BY p.method_id)
|
||||
FROM wanjiedaoyou_sect_method_progress p WHERE p.membership_id = m.id), '[]'::jsonb),
|
||||
'paths', COALESCE((SELECT jsonb_agg(jsonb_build_object('pathId', p.path_id, 'unlockedLayerIds', p.unlocked_layer_ids) ORDER BY p.path_id)
|
||||
FROM wanjiedaoyou_sect_path_progress p WHERE p.membership_id = m.id), '[]'::jsonb),
|
||||
'meridianLoadouts', COALESCE((SELECT jsonb_agg(jsonb_build_object('pathId', p.path_id, 'slot', p.slot, 'nodeIds', p.node_ids) ORDER BY p.path_id, p.slot)
|
||||
FROM wanjiedaoyou_sect_meridian_loadouts p WHERE p.membership_id = m.id), '[]'::jsonb)
|
||||
)::text FROM wanjiedaoyou_sect_memberships m WHERE m.status = 'active';
|
||||
|
||||
INSERT INTO "wanjiedaoyou_app_settings" ("key", "value")
|
||||
SELECT 'sect-migration:v1:manifest', jsonb_build_object(
|
||||
'stagedAt', now(), 'membershipIds', COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb)
|
||||
)::text FROM wanjiedaoyou_sect_memberships WHERE status = 'active';
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "wanjiedaoyou_combat_replay_archives" (
|
||||
"battle_id" uuid PRIMARY KEY NOT NULL,
|
||||
"metadata_version" integer NOT NULL,
|
||||
@@ -75,7 +107,7 @@ CREATE TABLE "wanjiedaoyou_sect_meridian_nodes" (
|
||||
"layer" integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
-- Unreleased V6 hard cutover: discard replaced state, without migrating old payloads.
|
||||
-- Unreleased V6 hard cutover: V5 sect inputs are preserved above for admin migration.
|
||||
DROP TABLE "wanjiedaoyou_combat_v6_manual_slots";--> statement-breakpoint
|
||||
DROP TABLE "wanjiedaoyou_combat_v6_manual_states";--> statement-breakpoint
|
||||
DROP TABLE "wanjiedaoyou_combat_v6_equipment_loadouts";--> statement-breakpoint
|
||||
|
||||
@@ -1255,6 +1255,11 @@ export const router = createBrowserRouter(
|
||||
lazy={lazyRoute(() => import('@app/routes/admin/feedback/route'))}
|
||||
handle={title('用户反馈')}
|
||||
/>
|
||||
<Route
|
||||
path="sect-migration"
|
||||
lazy={lazyRoute(() => import('@app/routes/admin/sect-migration/route'))}
|
||||
handle={title('宗门迁移')}
|
||||
/>
|
||||
<Route
|
||||
path="accounts"
|
||||
lazy={lazyRoute(() => import('@app/routes/admin/accounts/route'))}
|
||||
|
||||
@@ -5,6 +5,11 @@ export interface AdminNavItem {
|
||||
}
|
||||
|
||||
export const adminNavItems: AdminNavItem[] = [
|
||||
{
|
||||
title: '宗门迁移',
|
||||
description: '旧心法、经脉继承与退款核对',
|
||||
href: '/admin/sect-migration',
|
||||
},
|
||||
{
|
||||
title: '总览',
|
||||
description: '后台入口与能力地图',
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { InkButton } from '@app/components/ui/InkButton';
|
||||
import type { SectMigrationReport } from '@shared/contracts/sectMigration';
|
||||
import {
|
||||
MIGRATION_INSIGHT_PER_FRUIT,
|
||||
migrationInsightQuantity,
|
||||
} from '@shared/sect-migration/insightItem';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { AdminPageHeader } from '../_components/AdminPage';
|
||||
|
||||
const labels = { ready: '待迁移', completed: '已核对', blocked: '需处理' };
|
||||
export default function SectMigrationPage() {
|
||||
const [report, setReport] = useState<SectMigrationReport | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [results, setResults] = useState<string[]>([]);
|
||||
const [page, setPage] = useState(0);
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [maintenanceConfirmed, setMaintenanceConfirmed] = useState(false);
|
||||
const load = useCallback(async () => {
|
||||
const response = await fetch('/api/admin/sect-migration');
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error ?? '加载失败');
|
||||
setReport(data as SectMigrationReport);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetch('/api/admin/sect-migration')
|
||||
.then(async (response) => {
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error ?? '加载失败');
|
||||
if (!cancelled) setReport(data as SectMigrationReport);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : '加载失败');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
const refresh = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '核对失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const execute = async () => {
|
||||
if (!report?.enabled || !maintenanceConfirmed) return;
|
||||
const membershipIds = report.rows
|
||||
.filter((r) => r.status === 'ready')
|
||||
.slice(0, 20)
|
||||
.map((r) => r.membershipId);
|
||||
if (!membershipIds.length) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setResults([]);
|
||||
try {
|
||||
const response = await fetch('/api/admin/sect-migration/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ membershipIds, maintenanceConfirmed }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error ?? '迁移失败');
|
||||
setResults(
|
||||
data.results.map(
|
||||
(r: { membershipId: string; status: string; error?: string }) =>
|
||||
`${r.membershipId}:${r.error ?? (r.status === 'completed' ? '迁移完成' : '已处理,跳过')}`,
|
||||
),
|
||||
);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '执行失败;请刷新核对后重试');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const rows =
|
||||
report?.rows.filter((r) => filter === 'all' || r.status === filter) ?? [];
|
||||
const pageCount = Math.max(1, Math.ceil(rows.length / 50));
|
||||
const currentPage = Math.min(page, pageCount - 1);
|
||||
const ready = report?.rows.filter((r) => r.status === 'ready').length ?? 0;
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<AdminPageHeader
|
||||
title="宗门迁移"
|
||||
description="心法按槽位继承等级;经脉继承最高层数,退还另一流派投入。成功后删除待迁移记录,保留核对凭证。"
|
||||
/>
|
||||
{error ? (
|
||||
<p role="alert" className="text-crimson break-words">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{!report ? (
|
||||
<p>正在读取迁移记录…</p>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
本工具不控制游戏停机。操作前请在部署侧停止游戏实例、消息消费者和定时任务,仅保留临时管理服务。
|
||||
</p>
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={maintenanceConfirmed}
|
||||
onChange={(e) => setMaintenanceConfirmed(e.target.checked)}
|
||||
disabled={busy}
|
||||
/>
|
||||
已完成停机,并确认旧数据保留后到迁移结束期间没有其他写入
|
||||
</label>
|
||||
<p>
|
||||
旧数据保留时间:{report.stagedAt ?? '未保留'}
|
||||
。感悟补偿发放为宗门感悟果,每颗增加 {MIGRATION_INSIGHT_PER_FRUIT}
|
||||
点,可分次服用。建议感悟不高于 50 时服用,超出 100
|
||||
上限的部分不保留;感悟已满时不消耗。
|
||||
</p>
|
||||
{report.phase === 'legacy' ? (
|
||||
<p>
|
||||
当前为旧数据库。停机后执行包含来源保留步骤的 0044
|
||||
数据库升级,再返回这里迁移。升级会自动保存旧宗门来源。
|
||||
</p>
|
||||
) : null}
|
||||
{report.issues.map((issue) => (
|
||||
<p key={issue} role="alert" className="text-crimson break-words">
|
||||
{issue}
|
||||
</p>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-5 font-mono text-sm">
|
||||
<span>总人数 {report.rows.length}</span>
|
||||
<span>待迁移 {ready}</span>
|
||||
<span>
|
||||
已核对{' '}
|
||||
{report.rows.filter((r) => r.status === 'completed').length}
|
||||
</span>
|
||||
<span>
|
||||
需处理 {report.rows.filter((r) => r.status === 'blocked').length}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
预计退款总额(含已完成):修为{' '}
|
||||
<span className="font-mono">
|
||||
{report.totals.cultivationExp.toLocaleString()}
|
||||
</span>
|
||||
,灵石{' '}
|
||||
<span className="font-mono">
|
||||
{report.totals.spiritStones.toLocaleString()}
|
||||
</span>
|
||||
,宗门感悟果{' '}
|
||||
<span className="font-mono">
|
||||
{(
|
||||
report.totals.comprehensionInsight / MIGRATION_INSIGHT_PER_FRUIT
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<InkButton
|
||||
disabled={
|
||||
busy ||
|
||||
!maintenanceConfirmed ||
|
||||
!report.enabled ||
|
||||
!ready ||
|
||||
report.issues.length > 0
|
||||
}
|
||||
onClick={() => void execute()}
|
||||
>
|
||||
迁移下一批(最多 20 人)
|
||||
</InkButton>
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
disabled={busy}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
刷新并核对
|
||||
</InkButton>
|
||||
<a
|
||||
className="underline"
|
||||
href="/api/admin/sect-migration/audit"
|
||||
download
|
||||
>
|
||||
下载迁移凭证
|
||||
</a>
|
||||
</div>
|
||||
{report.complete ? (
|
||||
<p role="status">所有清单成员迁移及退款核对通过。</p>
|
||||
) : null}
|
||||
{results.length ? (
|
||||
<details open>
|
||||
<summary>本批结果</summary>
|
||||
<ul className="text-sm break-all">
|
||||
{results.map((r) => (
|
||||
<li key={r}>{r}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : null}
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
筛选
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => {
|
||||
setFilter(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
className="border-ink/20 border p-2"
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
{Object.entries(labels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
{[
|
||||
'玩家 / 宗门',
|
||||
'心法等级(槽位顺序)',
|
||||
'经脉层数',
|
||||
'补偿:修为 / 灵石 / 感悟果',
|
||||
'状态',
|
||||
].map((label) => (
|
||||
<th
|
||||
key={label}
|
||||
className="border-ink/20 border-b p-2 whitespace-nowrap"
|
||||
>
|
||||
{label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows
|
||||
.slice(currentPage * 50, currentPage * 50 + 50)
|
||||
.map((row) => (
|
||||
<tr key={row.membershipId}>
|
||||
<td className="border-ink/10 border-b p-2">
|
||||
<p>
|
||||
{row.name} / {row.sectId}
|
||||
</p>
|
||||
<small className="text-ink-secondary break-all">
|
||||
{row.membershipId}
|
||||
</small>
|
||||
</td>
|
||||
<td className="p-2 font-mono whitespace-nowrap">
|
||||
{row.plan?.methods.map((m) => m.level).join(' / ') ??
|
||||
'—'}
|
||||
</td>
|
||||
<td className="p-2 font-mono">
|
||||
{row.plan?.meridianDepth ?? '—'}
|
||||
</td>
|
||||
<td className="p-2 font-mono whitespace-nowrap">
|
||||
{row.plan
|
||||
? [
|
||||
row.plan.refund.cultivationExp,
|
||||
row.plan.refund.spiritStones,
|
||||
migrationInsightQuantity(
|
||||
row.plan.refund.comprehensionInsight,
|
||||
),
|
||||
]
|
||||
.map((n) => n.toLocaleString())
|
||||
.join(' / ')
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="min-w-40 p-2">
|
||||
{labels[row.status]}
|
||||
{row.error ? (
|
||||
<p className="text-crimson">{row.error}</p>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
disabled={currentPage === 0}
|
||||
onClick={() => setPage(currentPage - 1)}
|
||||
>
|
||||
上一页
|
||||
</InkButton>
|
||||
<span className="font-mono">
|
||||
{currentPage + 1} / {pageCount}
|
||||
</span>
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
disabled={currentPage + 1 >= pageCount}
|
||||
onClick={() => setPage(currentPage + 1)}
|
||||
>
|
||||
下一页
|
||||
</InkButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import systemMailsRouter from '@server/routes/api/admin/system-mails.router';
|
||||
import towerEnemySetsRouter from '@server/routes/api/admin/tower-enemy-sets.router';
|
||||
import { Hono } from 'hono';
|
||||
import rewardItemsRouter from './reward-items.router';
|
||||
import sectMigrationRouter from './sect-migration';
|
||||
|
||||
const router = new Hono<AppEnv>();
|
||||
|
||||
@@ -36,6 +37,7 @@ router.route('/reward-items', rewardItemsRouter);
|
||||
router.route('/redeem-codes', redeemCodesRouter);
|
||||
router.route('/reputation-shop', reputationShopRouter);
|
||||
router.route('/sect-shop', sectShopRouter);
|
||||
router.route('/sect-migration', sectMigrationRouter);
|
||||
router.route('/sponsorship', sponsorshipRouter);
|
||||
router.route('/community-group', communityGroupRouter);
|
||||
router.route('/llm-metrics', llmMetricsRouter);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
getValidatedJson,
|
||||
requireAdmin,
|
||||
validateJson,
|
||||
} from '@server/lib/hono/middleware';
|
||||
import type { AppEnv } from '@server/lib/hono/types';
|
||||
import { SectMigrationBatchSchema } from '@shared/contracts/sectMigration';
|
||||
import { Hono } from 'hono';
|
||||
import type { z } from 'zod';
|
||||
import {
|
||||
executeSectMigration,
|
||||
exportSectMigrationAudit,
|
||||
sectMigrationReport,
|
||||
} from './service';
|
||||
|
||||
const router = new Hono<AppEnv>();
|
||||
router.use('*', requireAdmin());
|
||||
router.use('*', async (c, next) => {
|
||||
c.header('Cache-Control', 'no-store');
|
||||
await next();
|
||||
});
|
||||
router.get('/', async (c) => c.json(await sectMigrationReport()));
|
||||
router.get('/audit', async (c) => {
|
||||
c.header(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="sect-migration-audit.json"',
|
||||
);
|
||||
return c.json(await exportSectMigrationAudit());
|
||||
});
|
||||
router.post('/batch', validateJson(SectMigrationBatchSchema), async (c) => {
|
||||
const input = getValidatedJson<z.infer<typeof SectMigrationBatchSchema>>(c);
|
||||
try {
|
||||
return c.json({
|
||||
results: await executeSectMigration(
|
||||
input.membershipIds,
|
||||
c.get('user')!.id,
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
{ error: error instanceof Error ? error.message : '执行失败' },
|
||||
409,
|
||||
);
|
||||
}
|
||||
});
|
||||
export default router;
|
||||
@@ -0,0 +1,24 @@
|
||||
// Optional temporary entrypoint for the maintenance window. Importing app does
|
||||
// not run src/index.ts, so no game scheduler or message consumers are started.
|
||||
// The usual app entrypoint and middleware are untouched.
|
||||
import app from '@server/app';
|
||||
|
||||
export default {
|
||||
hostname: process.env.HOST ?? '127.0.0.1',
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
fetch(request: Request) {
|
||||
const path = new URL(request.url).pathname;
|
||||
if (
|
||||
path === '/api/admin/session' ||
|
||||
path === '/api/admin/sect-migration' ||
|
||||
path.startsWith('/api/admin/sect-migration/') ||
|
||||
path.startsWith('/api/auth/') ||
|
||||
path.startsWith('/api/captcha/')
|
||||
)
|
||||
return app.fetch(request);
|
||||
return Response.json(
|
||||
{ error: '临时宗门迁移服务,仅开放管理工具' },
|
||||
{ status: 503 },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,654 @@
|
||||
import {
|
||||
db,
|
||||
type DbExecutor,
|
||||
type DbTransaction,
|
||||
} from '@server/lib/drizzle/db';
|
||||
import {
|
||||
appSettings,
|
||||
cultivators,
|
||||
inventoryItems,
|
||||
sectCombatStates,
|
||||
sectMemberships,
|
||||
sectMeridianLoadouts,
|
||||
sectMeridianNodes,
|
||||
sectMethodProgress,
|
||||
} from '@server/lib/drizzle/schema';
|
||||
import { redisLockKeys, withRedisLock } from '@server/lib/redis/lock';
|
||||
import { lockCultivatorForStateMutation } from '@server/lib/repositories/playerStateRepository';
|
||||
import {
|
||||
assertInventoryIdle,
|
||||
grantInventory,
|
||||
} from '@server/lib/services/InventoryService';
|
||||
import { ResourceEventCommitter } from '@server/lib/services/ResourceEventCommitter';
|
||||
import {
|
||||
updateCultivationExp,
|
||||
updateSpiritStones,
|
||||
} from '@server/lib/services/cultivator/CultivatorStateRepository';
|
||||
import { inventoryStackKey } from '@server/lib/services/inventoryStackKey';
|
||||
import {
|
||||
LegacySectMemberSchema,
|
||||
type LegacySectMember,
|
||||
type SectMigrationPlan,
|
||||
type SectMigrationRefund,
|
||||
type SectMigrationReport,
|
||||
type SectMigrationRow,
|
||||
} from '@shared/contracts/sectMigration';
|
||||
import {
|
||||
COMBAT_V6_SECT_DEFINITIONS,
|
||||
compileCurrentSectCombatV6,
|
||||
type CombatV6SectId,
|
||||
type SectCombatProgressV6,
|
||||
} from '@shared/engine/combat-v6/content';
|
||||
import { combatCharacterLevel } from '@shared/engine/combat-v6/projection/character-level';
|
||||
import { methodLevelCap } from '@shared/engine/combat-v6/sect-progression';
|
||||
import {
|
||||
migrationInsightFacts,
|
||||
migrationInsightGrants,
|
||||
migrationInsightQuantity,
|
||||
} from '@shared/sect-migration/insightItem';
|
||||
import { planLegacySectMigration } from '@shared/sect-migration/plan';
|
||||
import type { RealmStage, RealmType } from '@shared/types/constants';
|
||||
import { and, eq, inArray, like, sql } from 'drizzle-orm';
|
||||
|
||||
const MANIFEST_KEY = 'sect-migration:v1:manifest';
|
||||
const QUEUE_PREFIX = 'sect-migration:v1:pending:';
|
||||
const RECEIPT_PREFIX = 'sect-migration:v1:member:';
|
||||
const zeroRefund = (): SectMigrationRefund => ({
|
||||
cultivationExp: 0,
|
||||
spiritStones: 0,
|
||||
comprehensionInsight: 0,
|
||||
});
|
||||
type Manifest = { stagedAt: string; membershipIds: string[] };
|
||||
type Receipt = {
|
||||
source: LegacySectMember;
|
||||
plan: SectMigrationPlan;
|
||||
before: SectMigrationRefund;
|
||||
after: SectMigrationRefund;
|
||||
insightItems: {
|
||||
before: number;
|
||||
after: number;
|
||||
facts: typeof migrationInsightFacts;
|
||||
};
|
||||
completedAt: string;
|
||||
operatorId: string;
|
||||
};
|
||||
type Character = typeof cultivators.$inferSelect;
|
||||
|
||||
function balances(character: Character): SectMigrationRefund {
|
||||
const p = character.cultivation_progress as Record<string, unknown> | null;
|
||||
const result = {
|
||||
spiritStones: character.spirit_stones,
|
||||
cultivationExp: p?.cultivation_exp ?? 0,
|
||||
comprehensionInsight: p?.comprehension_insight ?? 0,
|
||||
};
|
||||
if (
|
||||
Object.values(result).some(
|
||||
(v) => typeof v !== 'number' || !Number.isSafeInteger(v) || v < 0,
|
||||
)
|
||||
)
|
||||
throw new Error('角色资源余额异常');
|
||||
return result as SectMigrationRefund;
|
||||
}
|
||||
|
||||
function expectedBalances(
|
||||
before: SectMigrationRefund,
|
||||
refund: SectMigrationRefund,
|
||||
) {
|
||||
const after = {
|
||||
spiritStones: before.spiritStones + refund.spiritStones,
|
||||
cultivationExp: before.cultivationExp + refund.cultivationExp,
|
||||
comprehensionInsight: before.comprehensionInsight,
|
||||
};
|
||||
if (
|
||||
after.spiritStones > 1_000_000_000 ||
|
||||
after.cultivationExp > 1_000_000_000
|
||||
)
|
||||
throw new Error('退款超过灵石或修为余额上限,需处理后重试');
|
||||
if (!Number.isSafeInteger(after.comprehensionInsight))
|
||||
throw new Error('感悟余额异常');
|
||||
return after;
|
||||
}
|
||||
|
||||
function validatePlan(
|
||||
source: LegacySectMember,
|
||||
character: Character,
|
||||
plan: SectMigrationPlan,
|
||||
) {
|
||||
const level = combatCharacterLevel(
|
||||
character.realm as RealmType,
|
||||
character.realm_stage as RealmStage,
|
||||
);
|
||||
if (plan.methods.some((m) => m.level > methodLevelCap(level)))
|
||||
throw new Error('旧心法超过当前角色等级允许范围,禁止裁剪');
|
||||
const definition =
|
||||
COMBAT_V6_SECT_DEFINITIONS[source.sectId as CombatV6SectId];
|
||||
if (
|
||||
!definition ||
|
||||
plan.methods.some(
|
||||
(m) => !definition.methods.some((t) => t.id === m.methodId),
|
||||
)
|
||||
)
|
||||
throw new Error('目标心法定义已变化');
|
||||
if (plan.pathIds.some((id) => !definition.paths.some((p) => p.id === id)))
|
||||
throw new Error('目标流派定义已变化');
|
||||
if (plan.activePathId) {
|
||||
const compiled = compileCurrentSectCombatV6({
|
||||
characterLevel: level,
|
||||
progress: {
|
||||
version: 1,
|
||||
sectId: source.sectId as CombatV6SectId,
|
||||
methods: Object.fromEntries(
|
||||
plan.methods.map((m) => [m.methodId, m.level]),
|
||||
),
|
||||
meridianDepth:
|
||||
plan.meridianDepth as SectCombatProgressV6['meridianDepth'],
|
||||
activePathId: plan.activePathId,
|
||||
meridianLoadouts: [
|
||||
{ pathId: plan.pathIds[0], nodeIds: [], revision: 0 },
|
||||
{ pathId: plan.pathIds[1], nodeIds: [], revision: 0 },
|
||||
],
|
||||
},
|
||||
});
|
||||
if (!compiled.ok)
|
||||
throw new Error(compiled.diagnostics.map((d) => d.message).join(';'));
|
||||
}
|
||||
}
|
||||
|
||||
async function readTarget(membershipIds: string[], q: DbExecutor) {
|
||||
if (!membershipIds.length)
|
||||
return { states: [], methods: [], loadouts: [], nodes: [] };
|
||||
const states = await q
|
||||
.select()
|
||||
.from(sectCombatStates)
|
||||
.where(inArray(sectCombatStates.membershipId, membershipIds));
|
||||
const methods = await q
|
||||
.select()
|
||||
.from(sectMethodProgress)
|
||||
.where(inArray(sectMethodProgress.membershipId, membershipIds));
|
||||
const loadouts = await q
|
||||
.select()
|
||||
.from(sectMeridianLoadouts)
|
||||
.where(inArray(sectMeridianLoadouts.membershipId, membershipIds));
|
||||
const nodes = loadouts.length
|
||||
? await q
|
||||
.select()
|
||||
.from(sectMeridianNodes)
|
||||
.where(
|
||||
inArray(
|
||||
sectMeridianNodes.loadoutId,
|
||||
loadouts.map((l) => l.id),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
return { states, methods, loadouts, nodes };
|
||||
}
|
||||
|
||||
async function verifyCompleted(
|
||||
id: string,
|
||||
target: Awaited<ReturnType<typeof readTarget>>,
|
||||
receipt: Receipt,
|
||||
character: Character,
|
||||
q: DbExecutor,
|
||||
) {
|
||||
const plan = receipt.plan;
|
||||
const state = target.states.find((s) => s.membershipId === id);
|
||||
const methods = target.methods.filter((m) => m.membershipId === id);
|
||||
const loadouts = target.loadouts.filter((l) => l.membershipId === id);
|
||||
const expectedPaths = plan.activePathId ? plan.pathIds : [];
|
||||
if (
|
||||
!state ||
|
||||
state.revision !== 1 ||
|
||||
state.meridianDepth !== plan.meridianDepth ||
|
||||
state.activePathId !== plan.activePathId ||
|
||||
methods.length !== 6 ||
|
||||
plan.methods.some(
|
||||
(m) =>
|
||||
!methods.some((t) => t.methodId === m.methodId && t.level === m.level),
|
||||
) ||
|
||||
loadouts.length !== expectedPaths.length ||
|
||||
expectedPaths.some(
|
||||
(id) => !loadouts.some((l) => l.pathId === id && l.revision === 0),
|
||||
) ||
|
||||
target.nodes.some((n) => loadouts.some((l) => l.id === n.loadoutId))
|
||||
)
|
||||
throw new Error('迁移凭证与新版宗门进度不一致');
|
||||
const current = balances(character);
|
||||
if (
|
||||
(Object.keys(current) as (keyof SectMigrationRefund)[]).some(
|
||||
(k) =>
|
||||
current[k] !== receipt.after[k] ||
|
||||
receipt.after[k] - receipt.before[k] !==
|
||||
(k === 'comprehensionInsight' ? 0 : plan.refund[k]),
|
||||
)
|
||||
)
|
||||
throw new Error('迁移凭证与实际退款余额不一致');
|
||||
if (
|
||||
receipt.insightItems.after - receipt.insightItems.before !==
|
||||
migrationInsightQuantity(plan.refund.comprehensionInsight) ||
|
||||
(await insightItemCount(character.id, q)) !== receipt.insightItems.after
|
||||
)
|
||||
throw new Error('感悟补偿道具数量与迁移凭证不一致');
|
||||
}
|
||||
|
||||
export async function sectMigrationReport(): Promise<SectMigrationReport> {
|
||||
return db.transaction(
|
||||
async (tx) => {
|
||||
const [rawManifest] = await tx
|
||||
.select()
|
||||
.from(appSettings)
|
||||
.where(eq(appSettings.key, MANIFEST_KEY));
|
||||
const manifest = rawManifest
|
||||
? (JSON.parse(rawManifest.value) as Manifest)
|
||||
: null;
|
||||
const report: SectMigrationReport = {
|
||||
enabled: false,
|
||||
phase: 'unknown',
|
||||
stagedAt: manifest?.stagedAt ?? null,
|
||||
issues: [],
|
||||
rows: [],
|
||||
totals: zeroRefund(),
|
||||
complete: false,
|
||||
};
|
||||
const schema = await tx.execute(sql`SELECT
|
||||
to_regclass('public.wanjiedaoyou_sect_path_progress') IS NOT NULL AS legacy,
|
||||
to_regclass('public.wanjiedaoyou_sect_combat_states') IS NOT NULL AS v6`);
|
||||
report.phase =
|
||||
schema.rows[0]?.legacy && !schema.rows[0]?.v6
|
||||
? 'legacy'
|
||||
: schema.rows[0]?.v6 && !schema.rows[0]?.legacy
|
||||
? 'v6'
|
||||
: 'unknown';
|
||||
report.enabled = report.phase === 'v6';
|
||||
if (!manifest) {
|
||||
if (report.phase === 'legacy') return report;
|
||||
report.issues.push(
|
||||
'缺少升级前旧宗门记录。若已执行旧版 0044,需从升级前备份恢复来源,不能用新版数据推断。',
|
||||
);
|
||||
return report;
|
||||
}
|
||||
const queue = await tx
|
||||
.select()
|
||||
.from(appSettings)
|
||||
.where(like(appSettings.key, `${QUEUE_PREFIX}%`));
|
||||
const receipts = await tx
|
||||
.select()
|
||||
.from(appSettings)
|
||||
.where(like(appSettings.key, `${RECEIPT_PREFIX}%`));
|
||||
const sources = new Map<string, unknown>();
|
||||
for (const row of queue)
|
||||
sources.set(row.key.slice(QUEUE_PREFIX.length), JSON.parse(row.value));
|
||||
for (const row of receipts) {
|
||||
const id = row.key.slice(RECEIPT_PREFIX.length);
|
||||
if (sources.has(id))
|
||||
report.issues.push(`成员同时存在待处理记录和凭证 ${id}`);
|
||||
sources.set(id, (JSON.parse(row.value) as Receipt).source);
|
||||
}
|
||||
const members: LegacySectMember[] = [];
|
||||
for (const id of manifest.membershipIds) {
|
||||
const parsed = LegacySectMemberSchema.safeParse(sources.get(id));
|
||||
if (!parsed.success || parsed.data.membershipId !== id)
|
||||
report.issues.push(`缺少或损坏旧宗门记录 ${id}`);
|
||||
else members.push(parsed.data);
|
||||
}
|
||||
for (const id of sources.keys())
|
||||
if (!manifest.membershipIds.includes(id))
|
||||
report.issues.push(`发现清单外记录 ${id}`);
|
||||
if (report.phase !== 'v6') {
|
||||
if (report.phase === 'unknown')
|
||||
report.issues.push('数据库结构不完整,暂不能执行迁移');
|
||||
for (const source of members) {
|
||||
try {
|
||||
const plan = planLegacySectMigration(source);
|
||||
for (const key of Object.keys(
|
||||
report.totals,
|
||||
) as (keyof SectMigrationRefund)[])
|
||||
report.totals[key] += plan.refund[key];
|
||||
report.rows.push({
|
||||
membershipId: source.membershipId,
|
||||
cultivatorId: source.cultivatorId,
|
||||
name: source.cultivatorId,
|
||||
sectId: source.sectId,
|
||||
status: 'ready',
|
||||
plan,
|
||||
});
|
||||
} catch (error) {
|
||||
report.rows.push({
|
||||
membershipId: source.membershipId,
|
||||
cultivatorId: source.cultivatorId,
|
||||
name: source.cultivatorId,
|
||||
sectId: source.sectId,
|
||||
status: 'blocked',
|
||||
error: error instanceof Error ? error.message : '旧进度异常',
|
||||
});
|
||||
}
|
||||
}
|
||||
return report;
|
||||
}
|
||||
const memberships = await tx
|
||||
.select()
|
||||
.from(sectMemberships)
|
||||
.where(eq(sectMemberships.status, 'active'));
|
||||
const ids = members.map((m) => m.membershipId);
|
||||
const characters = members.length
|
||||
? await tx
|
||||
.select()
|
||||
.from(cultivators)
|
||||
.where(
|
||||
inArray(
|
||||
cultivators.id,
|
||||
members.map((m) => m.cultivatorId),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
const target = await readTarget(ids, tx);
|
||||
for (const membership of memberships) {
|
||||
if (!ids.includes(membership.id))
|
||||
report.issues.push(`升级清单缺少当前宗门成员 ${membership.id}`);
|
||||
}
|
||||
for (const source of members) {
|
||||
const character = characters.find((c) => c.id === source.cultivatorId);
|
||||
const row: SectMigrationRow = {
|
||||
membershipId: source.membershipId,
|
||||
cultivatorId: source.cultivatorId,
|
||||
name: character?.name ?? source.cultivatorId,
|
||||
sectId: source.sectId,
|
||||
status: 'blocked',
|
||||
};
|
||||
try {
|
||||
const membership = memberships.find(
|
||||
(m) => m.id === source.membershipId,
|
||||
);
|
||||
if (
|
||||
!character ||
|
||||
character.status !== 'active' ||
|
||||
!membership ||
|
||||
membership.cultivatorId !== source.cultivatorId ||
|
||||
membership.sectId !== source.sectId
|
||||
)
|
||||
throw new Error('角色或宗门成员归属与升级清单不一致');
|
||||
row.plan = planLegacySectMigration(source);
|
||||
validatePlan(source, character, row.plan);
|
||||
for (const key of Object.keys(
|
||||
report.totals,
|
||||
) as (keyof SectMigrationRefund)[])
|
||||
report.totals[key] += row.plan.refund[key];
|
||||
const raw = receipts.find(
|
||||
(r) => r.key === RECEIPT_PREFIX + source.membershipId,
|
||||
);
|
||||
if (raw) {
|
||||
const receipt = JSON.parse(raw.value) as Receipt;
|
||||
await verifyCompleted(
|
||||
source.membershipId,
|
||||
target,
|
||||
receipt,
|
||||
character,
|
||||
tx,
|
||||
);
|
||||
row.status = 'completed';
|
||||
} else {
|
||||
if (
|
||||
target.states.some(
|
||||
(s) => s.membershipId === source.membershipId,
|
||||
) ||
|
||||
target.methods.some(
|
||||
(m) => m.membershipId === source.membershipId,
|
||||
) ||
|
||||
target.loadouts.some(
|
||||
(l) => l.membershipId === source.membershipId,
|
||||
)
|
||||
)
|
||||
throw new Error('已有新版宗门数据,禁止覆盖');
|
||||
expectedBalances(balances(character), row.plan.refund);
|
||||
row.status = 'ready';
|
||||
}
|
||||
} catch (error) {
|
||||
row.error = error instanceof Error ? error.message : '核对失败';
|
||||
}
|
||||
report.rows.push(row);
|
||||
}
|
||||
report.complete =
|
||||
report.issues.length === 0 &&
|
||||
report.rows.every((r) => r.status === 'completed');
|
||||
return report;
|
||||
},
|
||||
{ isolationLevel: 'repeatable read', accessMode: 'read only' },
|
||||
);
|
||||
}
|
||||
|
||||
async function migrateMember(source: LegacySectMember, operatorId: string) {
|
||||
return withRedisLock(
|
||||
{
|
||||
key: redisLockKeys.cultivatorMutation(source.cultivatorId),
|
||||
context: 'sect-migration',
|
||||
timeoutMs: 30000,
|
||||
retries: 0,
|
||||
},
|
||||
async (lease) =>
|
||||
db.transaction(async (tx) => {
|
||||
await lockCultivatorForStateMutation(tx, source.cultivatorId);
|
||||
const [raw] = await tx
|
||||
.select()
|
||||
.from(appSettings)
|
||||
.where(eq(appSettings.key, RECEIPT_PREFIX + source.membershipId));
|
||||
if (raw) {
|
||||
return 'skipped' as const;
|
||||
}
|
||||
const [pending] = await tx
|
||||
.select()
|
||||
.from(appSettings)
|
||||
.where(eq(appSettings.key, QUEUE_PREFIX + source.membershipId))
|
||||
.for('update');
|
||||
if (
|
||||
!pending ||
|
||||
JSON.stringify(
|
||||
LegacySectMemberSchema.parse(JSON.parse(pending.value)),
|
||||
) !== JSON.stringify(source)
|
||||
)
|
||||
throw new Error('旧宗门待处理记录已变化');
|
||||
await assertInventoryIdle(source.cultivatorId);
|
||||
const [membership] = await tx
|
||||
.select()
|
||||
.from(sectMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(sectMemberships.id, source.membershipId),
|
||||
eq(sectMemberships.status, 'active'),
|
||||
),
|
||||
)
|
||||
.for('update');
|
||||
const [character] = await tx
|
||||
.select()
|
||||
.from(cultivators)
|
||||
.where(eq(cultivators.id, source.cultivatorId));
|
||||
if (
|
||||
!character ||
|
||||
character.status !== 'active' ||
|
||||
!membership ||
|
||||
membership.cultivatorId !== source.cultivatorId ||
|
||||
membership.sectId !== source.sectId
|
||||
)
|
||||
throw new Error('角色或宗门成员归属已变化');
|
||||
const target = await readTarget([source.membershipId], tx);
|
||||
if (
|
||||
target.states.length ||
|
||||
target.methods.length ||
|
||||
target.loadouts.length
|
||||
)
|
||||
throw new Error('已有新版宗门数据,禁止覆盖');
|
||||
const plan = planLegacySectMigration(source);
|
||||
validatePlan(source, character, plan);
|
||||
const before = balances(character);
|
||||
const insightBefore = await insightItemCount(character.id, tx);
|
||||
const after = expectedBalances(before, plan.refund);
|
||||
await tx.insert(sectCombatStates).values({
|
||||
membershipId: source.membershipId,
|
||||
activePathId: plan.activePathId,
|
||||
meridianDepth: plan.meridianDepth,
|
||||
revision: 1,
|
||||
});
|
||||
await tx.insert(sectMethodProgress).values(
|
||||
plan.methods.map((m) => ({
|
||||
...m,
|
||||
membershipId: source.membershipId,
|
||||
})),
|
||||
);
|
||||
if (plan.activePathId)
|
||||
await tx.insert(sectMeridianLoadouts).values(
|
||||
plan.pathIds.map((pathId) => ({
|
||||
membershipId: source.membershipId,
|
||||
pathId,
|
||||
revision: 0,
|
||||
})),
|
||||
);
|
||||
await refundResources(character, plan.refund, tx);
|
||||
await new ResourceEventCommitter().commit(tx, {
|
||||
actor: { userId: character.userId, cultivatorId: character.id },
|
||||
source: 'sect-migration-v1',
|
||||
requestId: source.membershipId,
|
||||
scopeDefaults: { cultivatorId: character.id },
|
||||
changes: (
|
||||
[
|
||||
'player.sect-combat',
|
||||
'player.currency',
|
||||
'player.progress',
|
||||
'inventory.bag',
|
||||
] as const
|
||||
).map((resourceTopic) => ({
|
||||
resourceTopic,
|
||||
operation: 'invalidate',
|
||||
eventType: 'sect.migrated',
|
||||
})),
|
||||
});
|
||||
const receipt: Receipt = {
|
||||
source,
|
||||
plan,
|
||||
before,
|
||||
after,
|
||||
insightItems: {
|
||||
before: insightBefore,
|
||||
after:
|
||||
insightBefore +
|
||||
migrationInsightQuantity(plan.refund.comprehensionInsight),
|
||||
facts: migrationInsightFacts,
|
||||
},
|
||||
completedAt: new Date().toISOString(),
|
||||
operatorId,
|
||||
};
|
||||
const [updated] = await tx
|
||||
.select()
|
||||
.from(cultivators)
|
||||
.where(eq(cultivators.id, character.id));
|
||||
await verifyCompleted(
|
||||
source.membershipId,
|
||||
await readTarget([source.membershipId], tx),
|
||||
receipt,
|
||||
updated,
|
||||
tx,
|
||||
);
|
||||
await tx.insert(appSettings).values({
|
||||
key: RECEIPT_PREFIX + source.membershipId,
|
||||
value: JSON.stringify(receipt),
|
||||
updatedBy: operatorId,
|
||||
});
|
||||
await tx
|
||||
.delete(appSettings)
|
||||
.where(eq(appSettings.key, QUEUE_PREFIX + source.membershipId));
|
||||
lease.assertHeld();
|
||||
return 'completed' as const;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function insightItemCount(owner: string, q: DbExecutor) {
|
||||
const rows = await q
|
||||
.select({ quantity: inventoryItems.quantity })
|
||||
.from(inventoryItems)
|
||||
.where(
|
||||
and(
|
||||
eq(inventoryItems.cultivatorId, owner),
|
||||
eq(inventoryItems.definitionId, 'consumable.v1'),
|
||||
eq(
|
||||
inventoryItems.stackKey,
|
||||
inventoryStackKey('consumable.v1', migrationInsightFacts)!,
|
||||
),
|
||||
),
|
||||
);
|
||||
return rows.reduce((sum, row) => sum + row.quantity, 0);
|
||||
}
|
||||
|
||||
async function refundResources(
|
||||
character: Character,
|
||||
refund: SectMigrationRefund,
|
||||
tx: DbTransaction,
|
||||
) {
|
||||
// Use ordinary resource writes in bounded chunks; never lose a large refund to maxDelta.
|
||||
for (let remaining = refund.spiritStones; remaining > 0;) {
|
||||
const amount = Math.min(remaining, 10_000_000);
|
||||
await updateSpiritStones(character.userId, character.id, amount, tx);
|
||||
remaining -= amount;
|
||||
}
|
||||
if (refund.cultivationExp)
|
||||
await updateCultivationExp(
|
||||
character.userId,
|
||||
character.id,
|
||||
refund.cultivationExp,
|
||||
undefined,
|
||||
tx,
|
||||
);
|
||||
await grantInventory(
|
||||
character.id,
|
||||
migrationInsightGrants(refund.comprehensionInsight),
|
||||
tx,
|
||||
);
|
||||
}
|
||||
|
||||
export async function executeSectMigration(ids: string[], operatorId: string) {
|
||||
const preflight = await sectMigrationReport();
|
||||
if (!preflight.enabled) throw new Error('请先完成数据库结构升级');
|
||||
if (preflight.issues.length) throw new Error(preflight.issues.join(';'));
|
||||
const results: { membershipId: string; status: string; error?: string }[] =
|
||||
[];
|
||||
for (const id of new Set(ids)) {
|
||||
try {
|
||||
const [raw] = await db
|
||||
.select()
|
||||
.from(appSettings)
|
||||
.where(eq(appSettings.key, QUEUE_PREFIX + id));
|
||||
if (!raw) {
|
||||
const [receipt] = await db
|
||||
.select()
|
||||
.from(appSettings)
|
||||
.where(eq(appSettings.key, RECEIPT_PREFIX + id));
|
||||
if (!receipt) throw new Error('没有待处理记录或迁移凭证');
|
||||
results.push({ membershipId: id, status: 'skipped' });
|
||||
continue;
|
||||
}
|
||||
const source = LegacySectMemberSchema.parse(JSON.parse(raw.value));
|
||||
if (source.membershipId !== id) throw new Error('来源成员 ID 不一致');
|
||||
results.push({
|
||||
membershipId: id,
|
||||
status: await migrateMember(source, operatorId),
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
membershipId: id,
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : '迁移失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function exportSectMigrationAudit() {
|
||||
const report = await sectMigrationReport();
|
||||
const receipts = await db
|
||||
.select()
|
||||
.from(appSettings)
|
||||
.where(like(appSettings.key, `${RECEIPT_PREFIX}%`));
|
||||
return {
|
||||
exportedAt: new Date().toISOString(),
|
||||
report,
|
||||
receipts: receipts.map((r) => JSON.parse(r.value) as Receipt),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const LegacySectMemberSchema = z.object({
|
||||
membershipId: z.uuid(),
|
||||
cultivatorId: z.uuid(),
|
||||
sectId: z.string().min(1),
|
||||
activePathId: z.string().nullable(),
|
||||
methods: z
|
||||
.array(z.object({ methodId: z.string(), level: z.number().int() }))
|
||||
.max(100),
|
||||
paths: z
|
||||
.array(
|
||||
z.object({
|
||||
pathId: z.string(),
|
||||
unlockedLayerIds: z.array(z.string()).max(20),
|
||||
}),
|
||||
)
|
||||
.max(20),
|
||||
// Audit only; legacy nodes never become V6 selections.
|
||||
meridianLoadouts: z.array(
|
||||
z.object({
|
||||
pathId: z.string(),
|
||||
slot: z.number().int(),
|
||||
nodeIds: z.array(z.string()),
|
||||
}),
|
||||
),
|
||||
});
|
||||
export type LegacySectMember = z.infer<typeof LegacySectMemberSchema>;
|
||||
export type SectMigrationRefund = {
|
||||
cultivationExp: number;
|
||||
spiritStones: number;
|
||||
comprehensionInsight: number;
|
||||
};
|
||||
export type SectMigrationPlan = {
|
||||
methods: { methodId: string; level: number }[];
|
||||
meridianDepth: number;
|
||||
activePathId: string | null;
|
||||
pathIds: string[];
|
||||
refund: SectMigrationRefund;
|
||||
};
|
||||
export type SectMigrationRow = {
|
||||
membershipId: string;
|
||||
cultivatorId: string;
|
||||
name: string;
|
||||
sectId: string;
|
||||
status: 'ready' | 'completed' | 'blocked';
|
||||
error?: string;
|
||||
plan?: SectMigrationPlan;
|
||||
};
|
||||
export type SectMigrationReport = {
|
||||
enabled: boolean;
|
||||
phase: 'legacy' | 'v6' | 'unknown';
|
||||
stagedAt: string | null;
|
||||
issues: string[];
|
||||
rows: SectMigrationRow[];
|
||||
totals: SectMigrationRefund;
|
||||
complete: boolean;
|
||||
};
|
||||
export const SectMigrationBatchSchema = z.object({
|
||||
maintenanceConfirmed: z.literal(true),
|
||||
membershipIds: z.array(z.uuid()).min(1).max(20),
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import {
|
||||
CULTIVATION_PILL_MAX_QUALITY_BY_REALM,
|
||||
CULTIVATION_PILL_MIN_QUALITY_BY_REALM,
|
||||
} from '../config/consumableSystem';
|
||||
import { ItemGrantSchema } from '../inventory';
|
||||
import { QUALITY_ORDER, type RealmType } from '../types/constants';
|
||||
import {
|
||||
migrationInsightFacts,
|
||||
migrationInsightGrants,
|
||||
migrationInsightQuantity,
|
||||
} from './insightItem';
|
||||
|
||||
it('delivers all 600 refunded points as valid existing consumables without adding a definition', () => {
|
||||
const grants = migrationInsightGrants(600);
|
||||
expect(grants.reduce((n, g) => n + g.quantity, 0)).toBe(12);
|
||||
expect(migrationInsightQuantity(100)).toBe(2);
|
||||
expect(migrationInsightQuantity(200)).toBe(4);
|
||||
expect(migrationInsightFacts.spec).toMatchObject({
|
||||
operations: [
|
||||
{ type: 'gain_progress', target: 'comprehension_insight', value: 50 },
|
||||
],
|
||||
});
|
||||
expect(grants.every((g) => ItemGrantSchema.safeParse(g).success)).toBe(true);
|
||||
expect(migrationInsightGrants(0)).toEqual([]);
|
||||
expect(() => migrationInsightGrants(601)).toThrow();
|
||||
expect(() => migrationInsightGrants(51)).toThrow();
|
||||
});
|
||||
it('uses a quality available throughout every existing realm', () => {
|
||||
const quality = QUALITY_ORDER[migrationInsightFacts.quality];
|
||||
for (const realm of Object.keys(
|
||||
CULTIVATION_PILL_MAX_QUALITY_BY_REALM,
|
||||
) as RealmType[]) {
|
||||
expect(quality).toBeGreaterThanOrEqual(
|
||||
QUALITY_ORDER[CULTIVATION_PILL_MIN_QUALITY_BY_REALM[realm]],
|
||||
);
|
||||
expect(quality).toBeLessThanOrEqual(
|
||||
QUALITY_ORDER[CULTIVATION_PILL_MAX_QUALITY_BY_REALM[realm]],
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ItemGrant } from '../inventory';
|
||||
import { ConsumableFactsSchema } from '../items/definitions/consumables';
|
||||
|
||||
export const MIGRATION_INSIGHT_PER_FRUIT = 50;
|
||||
|
||||
// Self-contained facts: consumable.v1 remains usable after this tool is removed.
|
||||
// 玄品 is inside the existing allowed quality range for every character realm.
|
||||
export const migrationInsightFacts = ConsumableFactsSchema.parse({
|
||||
name: '宗门感悟果',
|
||||
type: '灵果',
|
||||
quality: '玄品',
|
||||
description:
|
||||
'宗门传承改版的经脉投入补偿。每颗增加 50 点道心感悟,可按需分次服用。建议感悟不高于 50 时服用,超过 100 上限的部分不保留;感悟已满时无法服用。',
|
||||
spec: {
|
||||
kind: 'spirit_fruit',
|
||||
family: 'insight',
|
||||
operations: [
|
||||
{
|
||||
type: 'gain_progress',
|
||||
target: 'comprehension_insight',
|
||||
value: MIGRATION_INSIGHT_PER_FRUIT,
|
||||
},
|
||||
],
|
||||
consumeRules: { scene: 'out_of_battle_only', quotaCategory: 'none' },
|
||||
source: { kind: 'spirit_field', version: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
export function migrationInsightQuantity(points: number): number {
|
||||
if (
|
||||
!Number.isInteger(points) ||
|
||||
points < 0 ||
|
||||
points > 600 ||
|
||||
points % MIGRATION_INSIGHT_PER_FRUIT !== 0
|
||||
)
|
||||
throw new Error('经脉感悟退款数量无效');
|
||||
return points / MIGRATION_INSIGHT_PER_FRUIT;
|
||||
}
|
||||
|
||||
export function migrationInsightGrants(points: number): ItemGrant[] {
|
||||
const quantity = migrationInsightQuantity(points);
|
||||
return quantity === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
definitionId: 'consumable.v1',
|
||||
quantity,
|
||||
instanceData: migrationInsightFacts,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"lingxiao": {
|
||||
"methods": [
|
||||
["lingxiao-canon", "lingxiao.method.canon"],
|
||||
["edge-cleansing", "lingxiao.method.sword_aura"],
|
||||
["sword-guidance", "lingxiao.method.waiting"],
|
||||
["void-step", "lingxiao.method.shadow"],
|
||||
["origin-returning", "lingxiao.method.formation"],
|
||||
["sword-nurturing", "lingxiao.method.clarity"]
|
||||
],
|
||||
"paths": [
|
||||
["swift-sword", "lingxiao.path.zhanchen"],
|
||||
["heavy-sword", "lingxiao.path.guiyi"]
|
||||
]
|
||||
},
|
||||
"youdu": {
|
||||
"methods": [
|
||||
["youdu-canon", "youdu.method.canon"],
|
||||
["three-souls-separation", "youdu.method.judge"],
|
||||
["forgetful-river-record", "youdu.method.wither"],
|
||||
["seven-souls-seizure", "youdu.method.shadow"],
|
||||
["soul-pinning-ironbook", "youdu.method.asura"],
|
||||
["dead-heart-living-spirit", "youdu.method.insight"]
|
||||
],
|
||||
"paths": [
|
||||
["tide", "youdu.path.soul_judge"],
|
||||
["decree", "youdu.path.poison_master"]
|
||||
]
|
||||
},
|
||||
"wuxiang": {
|
||||
"methods": [
|
||||
["wuxiang-canon", "wuxiang.method.canon"],
|
||||
["blood-lotus", "wuxiang.method.compassion"],
|
||||
["white-bone", "wuxiang.method.guardian"],
|
||||
["wrathful-ming", "wuxiang.method.wrath"],
|
||||
["six-senses", "wuxiang.method.purity"],
|
||||
["reed-crossing-method", "wuxiang.method.crossing"]
|
||||
],
|
||||
"paths": [
|
||||
["mirror-karma", "wuxiang.path.compassion"],
|
||||
["demon-crossing", "wuxiang.path.wrath"]
|
||||
]
|
||||
},
|
||||
"tianyan": {
|
||||
"methods": [
|
||||
["tianyan-canon", "tianyan.method.canon"],
|
||||
["wood-vitality", "tianyan.method.wood"],
|
||||
["fire-illumination", "tianyan.method.fire"],
|
||||
["earth-bearing", "tianyan.method.earth"],
|
||||
["metal-severing", "tianyan.method.metal"],
|
||||
["water-flowing", "tianyan.method.water"]
|
||||
],
|
||||
"paths": [
|
||||
["hetu-evolution", "tianyan.path.hetu"],
|
||||
["luoshu-control", "tianyan.path.luoshu"]
|
||||
]
|
||||
},
|
||||
"jiujie": {
|
||||
"methods": [
|
||||
["jiujie-canon", "jiujie.method.canon"],
|
||||
["calamity-eye", "jiujie.method.seal"],
|
||||
["heavenly-record", "jiujie.method.thunder"],
|
||||
["thunder-prison", "jiujie.method.guardian"],
|
||||
["cause-judgment", "jiujie.method.pride"],
|
||||
["crossing-calamity", "jiujie.method.cloud"]
|
||||
],
|
||||
"paths": [
|
||||
["calamity-eye", "jiujie.path.law"],
|
||||
["heavenly-condemnation", "jiujie.path.thunder"]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { LegacySectMember } from '../contracts/sectMigration';
|
||||
import { COMBAT_V6_SECT_DEFINITIONS } from '../engine/combat-v6/content';
|
||||
import mapping from './mapping.json';
|
||||
import { planLegacySectMigration } from './plan';
|
||||
|
||||
function member(
|
||||
sectId: keyof typeof mapping = 'lingxiao',
|
||||
depths = [4, 2],
|
||||
): LegacySectMember {
|
||||
const rule = mapping[sectId];
|
||||
return {
|
||||
membershipId: '00000000-0000-4000-8000-000000000001',
|
||||
cultivatorId: '00000000-0000-4000-8000-000000000002',
|
||||
sectId,
|
||||
activePathId: depths[0] ? rule.paths[0][0] : null,
|
||||
methods: rule.methods.map(([methodId], i) => ({
|
||||
methodId,
|
||||
level: 100 - i * 20,
|
||||
})),
|
||||
paths: rule.paths.map(([pathId], i) => ({
|
||||
pathId,
|
||||
unlockedLayerIds: ['1', '2', '3', '4', '5', 'ultimate'].slice(
|
||||
0,
|
||||
depths[i],
|
||||
),
|
||||
})),
|
||||
meridianLoadouts: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('legacy sect migration', () => {
|
||||
it.each(Object.keys(mapping) as (keyof typeof mapping)[])(
|
||||
'preserves six slot levels and valid target IDs: %s',
|
||||
(sectId) => {
|
||||
const source = member(sectId);
|
||||
source.methods.reverse();
|
||||
const plan = planLegacySectMigration(source);
|
||||
const target = COMBAT_V6_SECT_DEFINITIONS[sectId];
|
||||
expect(plan.methods.map((m) => m.level)).toEqual([
|
||||
100, 80, 60, 40, 20, 0,
|
||||
]);
|
||||
expect(plan.methods.map((m) => m.methodId)).toEqual(
|
||||
[...target.methods].sort((a, b) => a.slot - b.slot).map((m) => m.id),
|
||||
);
|
||||
expect(plan.pathIds).toEqual(target.paths.map((p) => p.id));
|
||||
expect(plan.meridianDepth).toBe(4);
|
||||
expect(plan.refund).toEqual({
|
||||
cultivationExp: 25000,
|
||||
spiritStones: 125000,
|
||||
comprehensionInsight: 200,
|
||||
});
|
||||
},
|
||||
);
|
||||
it('refunds one complete path when both are maxed, without granting layer seven', () => {
|
||||
const plan = planLegacySectMigration(member('lingxiao', [6, 6]));
|
||||
expect(plan.meridianDepth).toBe(6);
|
||||
expect(plan.refund).toEqual({
|
||||
cultivationExp: 6825000,
|
||||
spiritStones: 34125000,
|
||||
comprehensionInsight: 600,
|
||||
});
|
||||
});
|
||||
it('preserves the second active path and ignores node selections', () => {
|
||||
const source = member('lingxiao', [2, 4]);
|
||||
source.activePathId = source.paths[1].pathId;
|
||||
source.meridianLoadouts = [
|
||||
{ pathId: source.activePathId, slot: 1, nodeIds: ['obsolete-node'] },
|
||||
];
|
||||
const plan = planLegacySectMigration(source);
|
||||
expect(plan.activePathId).toBe(mapping.lingxiao.paths[1][1]);
|
||||
expect(plan.meridianDepth).toBe(4);
|
||||
});
|
||||
it('keeps members without a learned path pending and missing untrained methods at zero', () => {
|
||||
const source = member('lingxiao', [0, 0]);
|
||||
source.methods = [];
|
||||
source.paths = [];
|
||||
const plan = planLegacySectMigration(source);
|
||||
expect(plan.activePathId).toBeNull();
|
||||
expect(plan.methods.every((m) => m.level === 0)).toBe(true);
|
||||
expect(plan.refund.cultivationExp).toBe(0);
|
||||
});
|
||||
it('does not refund a single learned path', () => {
|
||||
const plan = planLegacySectMigration(member('lingxiao', [6, 0]));
|
||||
expect(plan.refund.spiritStones).toBe(0);
|
||||
});
|
||||
it('rejects lost primary progress, unknown methods, duplicates and noncontiguous layers', () => {
|
||||
const source = member();
|
||||
source.methods.shift();
|
||||
expect(() => planLegacySectMigration(source)).toThrow('主心法');
|
||||
const unknown = member();
|
||||
unknown.methods.push({ methodId: 'unknown', level: 1 });
|
||||
expect(() => planLegacySectMigration(unknown)).toThrow('未知旧心法');
|
||||
const duplicate = member();
|
||||
duplicate.methods.push(duplicate.methods[0]);
|
||||
expect(() => planLegacySectMigration(duplicate)).toThrow('重复');
|
||||
const gap = member();
|
||||
gap.paths[0].unlockedLayerIds = ['1', '3'];
|
||||
expect(() => planLegacySectMigration(gap)).toThrow('连续');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
LegacySectMember,
|
||||
SectMigrationPlan,
|
||||
} from '../contracts/sectMigration';
|
||||
import mapping from './mapping.json';
|
||||
|
||||
/** Frozen V5 release mapping and prices. Do not follow future V6 balance changes. */
|
||||
export function planLegacySectMigration(
|
||||
member: LegacySectMember,
|
||||
): SectMigrationPlan {
|
||||
if (!Object.prototype.hasOwnProperty.call(mapping, member.sectId))
|
||||
throw new Error('未知旧宗门');
|
||||
const rule = mapping[member.sectId as keyof typeof mapping];
|
||||
const methods = new Map<string, number>();
|
||||
for (const method of member.methods) {
|
||||
if (!rule.methods.some(([id]) => id === method.methodId))
|
||||
throw new Error('未知旧心法');
|
||||
if (methods.has(method.methodId)) throw new Error('旧心法重复');
|
||||
if (
|
||||
!Number.isInteger(method.level) ||
|
||||
method.level < 0 ||
|
||||
method.level > 180
|
||||
)
|
||||
throw new Error('旧心法等级越界');
|
||||
methods.set(method.methodId, method.level);
|
||||
}
|
||||
const levels = rule.methods.map(([id]) => methods.get(id) ?? 0);
|
||||
if (levels.slice(1).some((level) => level > levels[0]))
|
||||
throw new Error('旧分支心法超过主心法或缺失主心法');
|
||||
const depths = new Map<string, number>();
|
||||
const layers = ['1', '2', '3', '4', '5', 'ultimate'];
|
||||
for (const path of member.paths) {
|
||||
if (!rule.paths.some(([id]) => id === path.pathId))
|
||||
throw new Error('未知旧流派');
|
||||
if (depths.has(path.pathId)) throw new Error('旧流派重复');
|
||||
if (
|
||||
path.unlockedLayerIds.length > 6 ||
|
||||
path.unlockedLayerIds.some((id, i) => id !== layers[i])
|
||||
)
|
||||
throw new Error('旧经脉层数必须从第一层连续解锁');
|
||||
depths.set(path.pathId, path.unlockedLayerIds.length);
|
||||
}
|
||||
const depthValues = rule.paths.map(([id]) => depths.get(id) ?? 0);
|
||||
const depth = Math.max(...depthValues);
|
||||
const active = rule.paths.find(([id]) => id === member.activePathId);
|
||||
if (
|
||||
member.activePathId !== null &&
|
||||
(!active || !depths.get(member.activePathId))
|
||||
)
|
||||
throw new Error('当前旧流派不存在或尚未解锁');
|
||||
if (depth > 0 && !active) throw new Error('已解锁经脉但缺失当前流派');
|
||||
const refundDepth = Math.min(...depthValues);
|
||||
let cultivationExp = 0;
|
||||
for (let layer = 1; layer <= refundDepth; layer++)
|
||||
cultivationExp += 5000 * 4 ** (layer - 1);
|
||||
return {
|
||||
methods: rule.methods.map(([, methodId], i) => ({
|
||||
methodId,
|
||||
level: levels[i],
|
||||
})),
|
||||
meridianDepth: depth,
|
||||
activePathId: active?.[1] ?? null,
|
||||
pathIds: rule.paths.map(([, id]) => id),
|
||||
refund: {
|
||||
cultivationExp,
|
||||
spiritStones: cultivationExp * 5,
|
||||
comprehensionInsight: refundDepth * 100,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user