Skip to content

Commit f4cfe6b

Browse files
committed
merge: 数据导入支持合并其他账号的备份
2 parents 6716761 + 42a79b0 commit f4cfe6b

12 files changed

Lines changed: 92 additions & 19 deletions

File tree

‎README.en.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ Practice statistics show total practice, accuracy (correct / attempted) and acti
178178
- **🔍 Filters**: filter by mastery, date range, knowledge point, question type, grade and term, paper level and more; supports bulk actions and comparing the similarity of two questions. Selected questions can be moved to another notebook (tags stay unchanged), or exported as JSON, Excel or Word (the Excel columns match the question bank import template, so the file can be re-imported directly).
179179
- **📊 Statistics**: mastery rate, trends and subject distribution for both mistakes and practice, with time-range and subject filters.
180180
- **⏳ Background tasks**: the button at the bottom right shows the progress of long-running jobs such as recognition, AI generation, animation voice, bulk tagging and exam generation. Jobs keep running on the server after you leave the page. Photo recognition results are also kept in **Recognition**.
181-
- **💾 Import and export**: export or import data backups in **Settings**, including notebooks, questions, system and custom tags, practice and answer records, exams, personal preferences and synthesized cloud voice. Tags are restored by tag system and parent path, keeping identically named chapters and knowledge points under different grades separate and reconnecting questions to their specific tag nodes. Account credentials and AI settings are not included.
181+
- **💾 Import and export**: export or import data backups in **Settings**, including notebooks, questions, system and custom tags, practice and answer records, exams, personal preferences and synthesized cloud voice. Tags are restored by tag system and parent path, keeping identically named chapters and knowledge points under different grades separate and reconnecting questions to their specific tag nodes. A backup from another account can be merged into the current account after confirmation; all data belongs to the current account and the other account's preferences are not restored. Account credentials and AI settings are not included.
182182
- **🌗 Dark mode**: the theme button in the top navigation bar switches between Auto (follow system), Light and Dark; Auto is the default. The choice is saved per account, so it carries over when you sign in on another device.
183183
- **🕒 Application time zone**: Beijing time by default and changeable by administrators; dates and statistics follow the application time zone. See [TIME_ZONE.md](doc/TIME_ZONE.md) (Chinese).
184184
- **🔑 One-time setup link**: fresh installs no longer ship an admin with a fixed password. On first start the server generates a setup link that works only once; open it to choose the admin email and password. The desktop and standalone Android apps open it automatically; on fnOS you enter the admin email and password in the install wizard instead; Docker, npm and source runs print it in the startup log (the token is also in `config/setup-token` in the data directory). Instances upgraded from earlier versions keep their accounts.

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@
178178
- **🔍 多维度筛选**:按掌握状态、时间范围、知识点、题型、年级学期、试卷等级等条件筛选,支持批量操作和两题相似度比较;选中的错题可移动到其他错题本(标签保持不变),也可导出为 JSON、Excel 或 Word(Excel 列与题库导入模板一致,可往返导入)。
179179
- **📊 数据统计**:错题与练习的掌握率、趋势和学科分布,支持时间范围与学科筛选。错题列表支持用地址参数预设掌握状态(`?mastery=new|reviewing|mastered`)和知识点标签(`?tag=标签名`)筛选。
180180
- **⏳ 后台任务**:页面右下角的按钮可查看识别、AI 生成、动画语音生成、批量标注、组卷等耗时事务的进度;离开页面后事务仍在服务器上继续。拍照识别的结果另在「识别任务」中持久保存。
181-
- **💾 数据导入导出**:在「设置」中导出或导入数据备份,包含错题本、错题、系统及自定义标签、练习与作答记录、试卷、个人偏好和已合成的云端语音;标签按体系和父子路径还原,不同年级下的同名章节与知识点分别保留,题目恢复到具体标签节点。账户凭据和 AI 配置不在备份中。
181+
- **💾 数据导入导出**:在「设置」中导出或导入数据备份,包含错题本、错题、系统及自定义标签、练习与作答记录、试卷、个人偏好和已合成的云端语音;标签按体系和父子路径还原,不同年级下的同名章节与知识点分别保留,题目恢复到具体标签节点。备份来自其他账号时可在确认后合并到当前账号,数据全部归当前账号,不还原对方的偏好设置。账户凭据和 AI 配置不在备份中。
182182
- **🌗 深色模式**:顶部导航栏的主题按钮可在「自动(跟随系统)」「浅色」「深色」之间切换,默认自动。偏好按用户保存在账号中,换设备登录也保持一致。
183183
- **🕒 应用时区**:默认北京时间,管理员可修改,日期显示与统计都按应用时区计算,详见 [时区说明](doc/TIME_ZONE.md)。
184184
- **🔑 一次性初始化链接**:全新安装不再预置固定密码的管理员。服务首次启动时生成只能使用一次的初始化链接,打开后自行设置管理员邮箱和密码:桌面版、安卓单机版自动打开;飞牛OS 直接在安装向导中填写管理员邮箱和密码;Docker、npm、源码运行打印在启动日志中(也可在数据目录的 `config/setup-token` 中找到令牌)。从旧版本升级的实例保留原有账号。

‎doc/wiki/Backup-and-Restore.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
## 网页导出与导入
1616

17-
在设置的数据管理入口导出 JSON,并妥善保存。普通用户导入时,导出文件中的用户邮箱必须与当前账户一致;管理员可以使用管理范围的全部数据导入导出。
17+
在设置的数据管理入口导出 JSON,并妥善保存。普通用户导入时,导出文件中的用户邮箱通常必须与当前账户一致;备份来自其他账号时,可在确认后合并到当前账号,错题本、标签、题目和试卷全部归当前账号,不还原对方的偏好设置。管理员可以使用管理范围的全部数据导入导出。
1818

1919
### 导出 JSON(题目交换格式)
2020

‎doc/wiki/Settings-and-Users.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656

5757
| 角色 | 主要权限 |
5858
| --- | --- |
59-
| 普通用户 | 管理自己的错题、练习、试卷和个人偏好,导入导出本人数据 |
59+
| 普通用户 | 管理自己的错题、练习、试卷和个人偏好,导入导出本人数据;可把其他账号的备份合并到当前账号 |
6060
| 管理员 | 使用全局 AI 和提示词设置、时区设置、注册开关、用户管理及管理范围的数据导入导出 |
6161

6262
管理员从“设置 → 用户管理”进入后台,可查看使用统计,启用、禁用或删除用户。删除前应确认对象并[备份数据](Backup-and-Restore)。

‎doc/wiki/en/Backup-and-Restore-en.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ The JSON export does not include full user accounts with passwords, AI data shar
1414

1515
## Web export and import
1616

17-
Export JSON from the data management section in the settings and keep it safe. When a regular user imports, the email in the export file must match the current account; admins can import and export data for all users.
17+
Export JSON from the data management section in the settings and keep it safe. When a regular user imports, the email in the export file normally must match the current account; a backup from another account can be merged into the current account after confirmation, with notebooks, tags, questions and exam papers all assigned to the current account and the other account's preferences not restored. Admins can import and export data for all users.
1818

1919
### Export JSON (Question Exchange Format)
2020

‎doc/wiki/en/Settings-and-Users-en.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ AI features send the question text, images, answers, reference answers and expla
5656

5757
| Role | Main permissions |
5858
| --- | --- |
59-
| Regular user | Manage their own questions, practice, exam papers and preferences; import and export their own data |
59+
| Regular user | Manage their own questions, practice, exam papers and preferences; import and export their own data, and merge another account's backup into the current account |
6060
| Admin | Global AI and prompt settings, time zone, registration switch, user management and site-wide data import/export |
6161

6262
Admins open the dashboard from "Settings → User Management" to see usage statistics and enable, disable or delete users. Confirm the target and [back up your data](Backup-and-Restore-en) before deleting.

‎src/__tests__/integration/data-backup.test.ts‎

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ async function exportBackup() {
4444
return response.json();
4545
}
4646

47-
async function importBackup(body: object) {
48-
const response = await IMPORT(new Request('http://localhost/api/import', {
47+
async function importBackup(body: object, query = '') {
48+
const response = await IMPORT(new Request(`http://localhost/api/import${query}`, {
4949
method: 'POST',
5050
body: JSON.stringify(body),
5151
headers: { 'Content-Type': 'application/json' },
@@ -805,3 +805,53 @@ describe('数据导出后再导入(真实 SQLite)', () => {
805805
});
806806
});
807807
});
808+
809+
describe('跨账号合并导入', () => {
810+
const originalSession = { user: { id: 'user-1', email: 'backup-test@example.com', role: 'user' }, expires: '2099-01-01' };
811+
812+
/** 创建另一个账号的备份(含该账号的偏好设置和题目) */
813+
async function exportOtherAccountBackup() {
814+
await database.user.create({
815+
data: { id: 'user-2', email: 'parent@example.com', password: 'test-only', confirmBeforeImport: true },
816+
});
817+
await database.subject.create({
818+
data: { id: 'parent-math', name: '家长数学', stage: 'junior_high', subjectKey: 'math', userId: 'user-2' },
819+
});
820+
await database.errorItem.create({
821+
data: { id: 'parent-item', userId: 'user-2', subjectId: 'parent-math', originalImageUrl: '', questionText: '家长录入的题目', answerText: '答案', analysis: '解析' },
822+
});
823+
mocks.session = { user: { id: 'user-2', email: 'parent@example.com', role: 'user' }, expires: '2099-01-01' };
824+
const backup = await exportBackup();
825+
mocks.session = originalSession;
826+
return backup;
827+
}
828+
829+
it('merge=true 时把其他账号的备份合并到当前账号,且不还原对方偏好', async () => {
830+
const backup = await exportOtherAccountBackup();
831+
expect(backup.user.email).toBe('parent@example.com');
832+
await clearUserData();
833+
834+
const { stats } = await importBackup(backup, '?merge=true');
835+
836+
expect(stats.preferencesRestored).toBe(false);
837+
expect((await database.user.findUniqueOrThrow({ where: { id: 'user-1' } })).confirmBeforeImport).toBe(false);
838+
expect((await database.user.findUniqueOrThrow({ where: { id: 'user-2' } })).confirmBeforeImport).toBe(true);
839+
const importedItem = await database.errorItem.findFirstOrThrow({ where: { questionText: '家长录入的题目', userId: 'user-1' } });
840+
expect(importedItem.userId).toBe('user-1');
841+
const importedNotebook = await database.subject.findUniqueOrThrow({ where: { id: importedItem.subjectId! } });
842+
expect(importedNotebook.userId).toBe('user-1');
843+
});
844+
845+
it('未指定 merge=true 时仍拒绝其他账号的备份', async () => {
846+
const backup = await exportOtherAccountBackup();
847+
await clearUserData();
848+
const response = await IMPORT(new Request('http://localhost/api/import', {
849+
method: 'POST',
850+
body: JSON.stringify(backup),
851+
headers: { 'Content-Type': 'application/json' },
852+
}));
853+
854+
expect(response.status).toBe(400);
855+
expect(await database.errorItem.count({ where: { userId: 'user-1' } })).toBe(0);
856+
});
857+
});

‎src/app/api/import/route.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export async function POST(req: Request) {
3131

3232
const { searchParams } = new URL(req.url);
3333
const importAll = searchParams.get('all') === 'true';
34+
// 合并其他账号的备份:非本人邮箱时也允许导入,数据全部归到当前账号,偏好设置不还原
35+
const merge = searchParams.get('merge') === 'true';
3436

3537
// 只有管理员可以导入全部数据
3638
if (importAll && session.user.role !== 'admin') {
@@ -50,8 +52,8 @@ export async function POST(req: Request) {
5052
}
5153
const body = parsed.data;
5254

53-
// 非管理员模式:验证导出数据属于当前用户
54-
if (!importAll && body.user.email !== user.email) {
55+
// 非管理员模式:导出数据属于当前用户,或显式要求合并其他账号的备份
56+
if (!importAll && body.user.email !== user.email && !merge) {
5557
return badRequest("Import data does not belong to current user");
5658
}
5759

@@ -85,7 +87,7 @@ export async function POST(req: Request) {
8587

8688
logger.info({
8789
userId: user.id,
88-
scope: importAll ? 'all' : 'user',
90+
scope: importAll ? 'all' : merge && body.user.email !== user.email ? 'merge' : 'user',
8991
...stats,
9092
}, 'Data import completed');
9193

‎src/components/settings-dialog.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export function SettingsDialog() {
6161
const { data: session } = useSession();
6262
const isAdmin = session?.user?.role === 'admin';
6363
const userId = session?.user?.id ?? '';
64+
const userEmail = session?.user?.email ?? '';
6465
const { t } = useLanguage();
6566
const router = useRouter();
6667
const [open, setOpen] = useState(false);
@@ -208,7 +209,7 @@ export function SettingsDialog() {
208209

209210
<TabsContent value="danger" className="space-y-4 py-4">
210211
<div className="space-y-3">
211-
<DataBackupSettings isAdmin={isAdmin} userId={userId} />
212+
<DataBackupSettings isAdmin={isAdmin} userId={userId} userEmail={userEmail} />
212213
<DangerZoneSettings isAdmin={isAdmin} />
213214
<DeleteAccountSettings isAdmin={isAdmin} />
214215
</div>

‎src/components/settings/data-backup-settings.tsx‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ type ImportStats = Record<string, number | boolean | undefined>;
1717
interface DataBackupSettingsProps {
1818
isAdmin: boolean;
1919
userId: string;
20+
/** 当前登录账号邮箱,用于识别备份是否来自其他账号 */
21+
userEmail: string;
2022
}
2123

2224
function formatImportResult(stats: ImportStats, t: Translations): string {
@@ -32,7 +34,7 @@ function formatImportResult(stats: ImportStats, t: Translations): string {
3234
}
3335

3436
/** 数据备份:导出、导入 JSON;管理员可导出、导入全部用户的数据 */
35-
export function DataBackupSettings({ isAdmin, userId }: DataBackupSettingsProps) {
37+
export function DataBackupSettings({ isAdmin, userId, userEmail }: DataBackupSettingsProps) {
3638
const { t } = useLanguage();
3739
const [exporting, setExporting] = useState(false);
3840
const [importing, setImporting] = useState(false);
@@ -71,17 +73,33 @@ export function DataBackupSettings({ isAdmin, userId }: DataBackupSettingsProps)
7173

7274
const handleImport = async (all: boolean) => {
7375
if (!selectedFile) return;
76+
let data: unknown;
77+
try {
78+
data = JSON.parse(await selectedFile.text());
79+
} catch (error) {
80+
frontendLogger.error('[DataBackupSettings]', 'Import file is not valid JSON', { error });
81+
toast.error(t.settings?.importFailed || "Import failed");
82+
return;
83+
}
84+
85+
const backupUser = typeof data === 'object' && data !== null && 'user' in data ? data.user : undefined;
86+
const sourceEmail = typeof backupUser === 'object' && backupUser !== null && 'email' in backupUser && typeof backupUser.email === 'string'
87+
? backupUser.email
88+
: null;
89+
const merging = !all && !!sourceEmail && !!userEmail && sourceEmail !== userEmail;
7490
const confirmText = all
7591
? (t.settings?.importAllConfirm || "Import all users' data? This will restore data for all users from the export file.")
76-
: (t.settings?.importConfirm || "Are you sure you want to import?");
92+
: merging
93+
? (t.settings?.importOtherAccountConfirm || "This backup belongs to {email}. Importing merges it into the current account without restoring that account's preferences. Continue?").replace("{email}", sourceEmail ?? "")
94+
: (t.settings?.importConfirm || "Are you sure you want to import?");
7795
if (!(await confirmDialog(confirmText))) {
7896
return;
7997
}
8098

8199
setImporting(true);
82100
try {
83-
const data = JSON.parse(await selectedFile.text());
84-
const { stats } = await apiClient.post<{ stats: ImportStats }>(all ? '/api/import?all=true' : '/api/import', data);
101+
const url = all ? '/api/import?all=true' : merging ? '/api/import?merge=true' : '/api/import';
102+
const { stats } = await apiClient.post<{ stats: ImportStats }>(url, data);
85103
toast.success(formatImportResult(stats, t));
86104
setSelectedFile(null);
87105
window.location.reload();
@@ -90,7 +108,7 @@ export function DataBackupSettings({ isAdmin, userId }: DataBackupSettingsProps)
90108
toast.info(t.errors.TASK_ALREADY_RUNNING);
91109
await refreshImportTask();
92110
} else {
93-
frontendLogger.error('[DataBackupSettings]', all ? 'Import all failed' : 'Import failed', { error });
111+
frontendLogger.error('[DataBackupSettings]', all ? 'Import all failed' : merging ? 'Merge import failed' : 'Import failed', { error });
94112
toast.error(t.settings?.importFailed || "Import failed");
95113
}
96114
} finally {

0 commit comments

Comments
 (0)