feat(server,web,desktop,shared,deploy,plugin): 数据库管理模块 + 客户端深色模式/设置拆子路由/首启引导,版本统一 1.0.0
- 服务端新增 database 模块(/api/database/*,仅 ADMIN):全库 JSON 快照(备份 / 恢复 / 导入导出 / 定时备份
共用同一格式,14 张表按拓扑序导出、恢复时关外键检查)、连接测试与目标库校验、一键 SQLite ⇄ MySQL 切换
(备份 → 导出 → 测试连接 → 校验空库 → 改 schema → 子进程 generate / tsc / db push → 写数据,全部成功才改写
.env,中途失败回滚 schema);XML 快照与 SQLite 数据文件均可下载;打包形态随包带上 prisma CLI 与 typescript
(prisma-client 生成物是 .ts,需再编进 dist/generated,安装包因之变大)
- Web 管理端新增「数据库管理」页(仅管理员),并加首次登录新手引导(el-tour,可跳过、可随时重看,版本号写
localStorage,改版后老用户会再收到一次)
- 客户端设置改版:侧边栏改为分组折叠菜单,设置拆成 6 个子路由
(/settings/{general,appearance,island,reminder,account,about}),新增深色模式(主题持久化到主进程配置,
主窗口底色跟主题走,避免"先白后黑"闪一下),新增「关于」页(应用信息 / 诊断信息)
- 客户端新增首启六步引导(OnboardingWelcome,状态存主进程配置 onboardingDone)
- 客户端主窗口初始尺寸与 ClassIsland 对齐(1242x582 / minHeight 540 / 居中)
- 版本号统一提升到 1.0.0:5 个 package.json、部署配置与镜像标签(.env.example / docker-compose / Dockerfile)、
服务端 /health 与首页、插件 manifest.yml 与 BridgeService.PluginVersion、文档里的安装包名
- 文档与验收同步:AGENTS.md 补第 37~51 条约定(深色主题实现边界、设置子路由、数据库管理三件事、侧栏与窗口
尺寸、引导在自动化里的判据等),README 补「数据库管理 / 新手引导 / 设置改版」三节;verify:e2e 增补 13 条
数据库管理用例,客户端冒烟与 Web UI 冒烟跟进子路由、深色主题、侧栏与引导
验证:pnpm lint 0 error;pnpm typecheck 全过;verify:e2e 186/186;verify:classisland-plugin 68/68;
build:desktop 通过(含产物凭据门禁);verify:desktop 99/102(3 项为上课时段下的"真实通知链路"用例,
客户端按产品规则暂存普通通知所致,见下);插件 dotnet 编译 0 错误。
注:verify:desktop 的「真实通知链路 / 标记已读」三项要求当前不在上课时段——本次运行时种子课表恰有
「周五 10:55-11:40 生物」正在进行,客户端正确地暂存了普通通知(AGENTS.md 第 18 条),属时间相关用例,
与本次改动无关。
This commit is contained in:
1 parent
391e2c1591
commit
572295c297
66 files changed
+5131
-1027
No files matched your search
@@ -3,27 +3,33 @@ import { PrismaClient } from '../generated/prisma/client.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/** 本项目支持的主数据库类型(Redis 等键值库不是 Prisma 支持的主库,不在范围内) */
|
||||
export type DatabaseProvider = 'sqlite' | 'mysql';
|
||||
|
||||
/**
|
||||
* Prisma 7 要求所有数据库都通过 driver adapter 接入:
|
||||
* - SQLite -> @prisma/adapter-libsql(libSQL 内嵌模式,npm 预编译二进制,无需本地编译)
|
||||
* - MySQL -> @prisma/adapter-mariadb(切换数据库时安装,见 docs/mysql.md)
|
||||
* - MySQL -> @prisma/adapter-mariadb(依赖里常驻,切换数据库时直接可用)
|
||||
*
|
||||
* MySQL 分支使用「非字面量模块名」动态导入,这样未安装 mariadb 适配器时
|
||||
* 也不会影响类型检查与 SQLite 模式的运行。
|
||||
*
|
||||
* 该工厂独立于进程级单例:数据库管理模块的「连接测试」与跨库迁移的工具进程
|
||||
* 都需要按任意 provider/url 构建临时客户端,而不是读 env 单例。
|
||||
*/
|
||||
async function createAdapter(): Promise<unknown> {
|
||||
if (env.databaseProvider === 'mysql') {
|
||||
export async function createPrismaClient(
|
||||
provider: DatabaseProvider,
|
||||
url: string,
|
||||
): Promise<PrismaClient> {
|
||||
if (provider === 'mysql') {
|
||||
const adapterModule = '@prisma/adapter-mariadb';
|
||||
const mod = (await import(adapterModule)) as { PrismaMariaDb: new (url: string) => unknown };
|
||||
logger.info('已加载 MySQL/MariaDB 驱动适配器');
|
||||
return new mod.PrismaMariaDb(env.databaseUrl);
|
||||
return new PrismaClient({ adapter: new mod.PrismaMariaDb(url) as never });
|
||||
}
|
||||
return new PrismaLibSql({ url: env.databaseUrl });
|
||||
return new PrismaClient({ adapter: new PrismaLibSql({ url }) as never });
|
||||
}
|
||||
|
||||
const adapter = await createAdapter();
|
||||
|
||||
export const prisma = new PrismaClient({ adapter: adapter as never });
|
||||
export const prisma = await createPrismaClient(env.databaseProvider, env.databaseUrl);
|
||||
|
||||
logger.info(`Prisma 客户端已初始化(provider=${env.databaseProvider})`);
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { PrismaClient } from '../generated/prisma/client.js';
|
||||
import type { DatabaseProvider } from './db.js';
|
||||
|
||||
/**
|
||||
* 全库 JSON 快照:备份 / 恢复 / 导入导出 / 跨库迁移共用这一种格式。
|
||||
*
|
||||
* 为什么不用「复制数据库文件」或 mysqldump:
|
||||
* - 文件级备份只对 SQLite 有意义,跨库迁移根本用不上;
|
||||
* - JSON 快照与 provider 无关,一份实现同时服务备份、导入导出和 SQLite⇄MySQL 迁移。
|
||||
*
|
||||
* 外键注意:schema 里 User.classId → Class 且 Class.teacherId → User(互相引用),
|
||||
* 恢复时必须**临时关闭外键检查**再按序写入,否则任何排序都会被其中一侧卡死。
|
||||
* - SQLite:PRAGMA foreign_keys=OFF(libsql 连接级,进程内安全)
|
||||
* - MySQL:SET FOREIGN_KEY_CHECKS=0/1(会话级,连接池下同一客户端实例内生效)
|
||||
*/
|
||||
|
||||
export const SNAPSHOT_FORMAT = 'classhelper-snapshot';
|
||||
export const SNAPSHOT_VERSION = 1;
|
||||
|
||||
/** 导出与恢复顺序(外键关闭后顺序不再影响写入,但保持拓扑序便于人工阅读与排查) */
|
||||
export const SNAPSHOT_TABLES = [
|
||||
'User',
|
||||
'Class',
|
||||
'Course',
|
||||
'ClassTeacher',
|
||||
'Enrollment',
|
||||
'Schedule',
|
||||
'TimeLayout',
|
||||
'Homework',
|
||||
'HomeworkStatus',
|
||||
'Notification',
|
||||
'NotificationRead',
|
||||
'Grade',
|
||||
'IntegrationDevice',
|
||||
'ClassIslandPush',
|
||||
] as const;
|
||||
|
||||
export type SnapshotTable = (typeof SNAPSHOT_TABLES)[number];
|
||||
|
||||
export interface DatabaseSnapshot {
|
||||
format: string;
|
||||
version: number;
|
||||
createdAt: string;
|
||||
source: { provider: string };
|
||||
/** 每张表的行数(导出时统计,恢复时校验总量用) */
|
||||
counts: Record<string, number>;
|
||||
/** 表名 → 行数组(DateTime 已是 ISO 字符串,Prisma 接受 ISO 字符串作为 DateTime 输入) */
|
||||
data: Record<SnapshotTable, Record<string, unknown>[]>;
|
||||
}
|
||||
|
||||
/** 模型代理的最小接口(避免 any:Prisma 各模型的 delegate 形状一致) */
|
||||
interface ModelDelegate {
|
||||
findMany(): Promise<Record<string, unknown>[]>;
|
||||
count(): Promise<number>;
|
||||
createMany(args: { data: Record<string, unknown>[] }): Promise<unknown>;
|
||||
deleteMany(): Promise<unknown>;
|
||||
}
|
||||
|
||||
function delegatesOf(prisma: PrismaClient): Record<SnapshotTable, ModelDelegate> {
|
||||
return prisma as unknown as Record<SnapshotTable, ModelDelegate>;
|
||||
}
|
||||
|
||||
/** 单次 createMany 的批量大小(班级规模数据量小,小批次足够且省内存) */
|
||||
const CHUNK_SIZE = 200;
|
||||
|
||||
/** 导出全库快照(行内 DateTime 为 Date 对象,JSON.stringify 时自动转 ISO) */
|
||||
export async function exportSnapshot(
|
||||
prisma: PrismaClient,
|
||||
provider: DatabaseProvider,
|
||||
): Promise<DatabaseSnapshot> {
|
||||
const delegates = delegatesOf(prisma);
|
||||
const data = {} as Record<SnapshotTable, Record<string, unknown>[]>;
|
||||
const counts: Record<string, number> = {};
|
||||
for (const table of SNAPSHOT_TABLES) {
|
||||
const rows = await delegates[table].findMany();
|
||||
data[table] = rows;
|
||||
counts[table] = rows.length;
|
||||
}
|
||||
return {
|
||||
format: SNAPSHOT_FORMAT,
|
||||
version: SNAPSHOT_VERSION,
|
||||
createdAt: new Date().toISOString(),
|
||||
source: { provider },
|
||||
counts,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
/** 快照结构校验:给出可读的失败原因("为什么恢复失败"比失败本身重要) */
|
||||
export function assertValidSnapshot(value: unknown): asserts value is DatabaseSnapshot {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new Error('快照内容不是合法的 JSON 对象');
|
||||
}
|
||||
const snapshot = value as Partial<DatabaseSnapshot>;
|
||||
if (snapshot.format !== SNAPSHOT_FORMAT) {
|
||||
throw new Error(`快照格式不正确(期望 ${SNAPSHOT_FORMAT},收到 ${String(snapshot.format ?? '未知')})`);
|
||||
}
|
||||
if (snapshot.version !== SNAPSHOT_VERSION) {
|
||||
throw new Error(`快照版本不兼容(期望 v${SNAPSHOT_VERSION},收到 v${String(snapshot.version)})`);
|
||||
}
|
||||
if (typeof snapshot.data !== 'object' || snapshot.data === null) {
|
||||
throw new Error('快照缺少 data 字段');
|
||||
}
|
||||
for (const table of SNAPSHOT_TABLES) {
|
||||
const rows = (snapshot.data as Record<string, unknown>)[table];
|
||||
if (!Array.isArray(rows)) {
|
||||
throw new Error(`快照缺少表 ${table} 的数据`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setForeignKeys(prisma: PrismaClient, provider: DatabaseProvider, enabled: boolean): Promise<void> {
|
||||
const sql =
|
||||
provider === 'mysql'
|
||||
? `SET FOREIGN_KEY_CHECKS=${enabled ? 1 : 0}`
|
||||
: `PRAGMA foreign_keys=${enabled ? 'ON' : 'OFF'}`;
|
||||
await prisma.$executeRawUnsafe(sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把快照写入目标库(先清空再写入)。
|
||||
* 用于:备份恢复、快照导入、跨库迁移的子进程。
|
||||
*/
|
||||
export async function restoreSnapshot(
|
||||
prisma: PrismaClient,
|
||||
provider: DatabaseProvider,
|
||||
snapshot: DatabaseSnapshot,
|
||||
options: { clean?: boolean } = {},
|
||||
): Promise<{ counts: Record<string, number> }> {
|
||||
assertValidSnapshot(snapshot);
|
||||
const delegates = delegatesOf(prisma);
|
||||
const clean = options.clean !== false;
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
await setForeignKeys(prisma, provider, false);
|
||||
try {
|
||||
if (clean) {
|
||||
// 反向清空,保证被引用的表最后清(外键关闭后只是防御性的,保持确定性)
|
||||
for (const table of [...SNAPSHOT_TABLES].reverse()) {
|
||||
await delegates[table].deleteMany();
|
||||
}
|
||||
}
|
||||
for (const table of SNAPSHOT_TABLES) {
|
||||
const rows = snapshot.data[table];
|
||||
for (let index = 0; index < rows.length; index += CHUNK_SIZE) {
|
||||
await delegates[table].createMany({ data: rows.slice(index, index + CHUNK_SIZE) });
|
||||
}
|
||||
counts[table] = rows.length;
|
||||
}
|
||||
} finally {
|
||||
await setForeignKeys(prisma, provider, true).catch(() => undefined);
|
||||
}
|
||||
return { counts };
|
||||
}
|
||||
|
||||
/** 汇总快照总行数(列表展示 / 切换任务结果用) */
|
||||
export function snapshotTotalRows(snapshot: DatabaseSnapshot): number {
|
||||
return Object.values(snapshot.counts).reduce((sum, count) => sum + count, 0);
|
||||
}
|
||||
Reference in new issue
Block a user