Skip to content

Commit 75922b5

Browse files
authored
Merge pull request #158 from lcomplete/codex/add-database-backup
feat: add database backup settings
2 parents 370beef + 0e86260 commit 75922b5

9 files changed

Lines changed: 286 additions & 1 deletion

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,6 @@ db.sqlite*
5151
.agent/
5252
.shared/
5353
.codex/
54+
.antigravitycli/
5455

5556
.mcp.json

app/client/src/api/api.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1361,6 +1361,18 @@ export interface GlobalSetting {
13611361
* @memberof GlobalSetting
13621362
*/
13631363
'changedOpenApiKey'?: boolean;
1364+
/**
1365+
*
1366+
* @type {string}
1367+
* @memberof GlobalSetting
1368+
*/
1369+
'backupPath'?: string;
1370+
/**
1371+
*
1372+
* @type {number}
1373+
* @memberof GlobalSetting
1374+
*/
1375+
'backupKeepDays'?: number;
13641376
/**
13651377
*
13661378
* @type {number}

app/client/src/components/SettingModal/GeneralSetting.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import RouterIcon from '@mui/icons-material/Router';
2525
import AutoModeIcon from '@mui/icons-material/AutoMode';
2626
import BlockIcon from '@mui/icons-material/Block';
2727
import TranslateIcon from '@mui/icons-material/Translate';
28+
import BackupIcon from '@mui/icons-material/Backup';
2829
import SettingSectionTitle from "./SettingSectionTitle";
2930

3031
export default function GeneralSetting() {
@@ -52,7 +53,9 @@ export default function GeneralSetting() {
5253
openApiKey: yup.string().nullable(),
5354
openApiBaseUrl: yup.string().nullable(),
5455
openApiModel: yup.string().nullable(),
55-
autoSaveSiteBlacklists: yup.string().nullable()
56+
autoSaveSiteBlacklists: yup.string().nullable(),
57+
backupPath: yup.string().nullable(),
58+
backupKeepDays: yup.number().nullable().min(1, t('backupKeepDaysMin'))
5659
}),
5760
onSubmit: async (values) => {
5861
// 只有当 API 密钥真正改变时才设置 changedOpenApiKey 为 true
@@ -228,6 +231,42 @@ export default function GeneralSetting() {
228231
</Tooltip>
229232
</div>
230233

234+
<SettingSectionTitle icon={BackupIcon}>{t('databaseBackup')}</SettingSectionTitle>
235+
<div className="flex flex-wrap items-center gap-3 mt-1">
236+
<TextField
237+
margin="dense"
238+
size="small"
239+
className="w-full sm:w-[320px]"
240+
id="backupPath"
241+
label={t('backupPath')}
242+
value={formikGeneral.values.backupPath || ""}
243+
onChange={formikGeneral.handleChange}
244+
error={formikGeneral.touched.backupPath && Boolean(formikGeneral.errors.backupPath)}
245+
helperText={formikGeneral.touched.backupPath && formikGeneral.errors.backupPath}
246+
type="text"
247+
variant="outlined"
248+
/>
249+
<TextField
250+
margin="dense"
251+
size="small"
252+
className="w-full sm:w-[180px]"
253+
id="backupKeepDays"
254+
label={t('backupKeepDays')}
255+
placeholder={t('backupKeepDaysPlaceholder') || "30"}
256+
value={formikGeneral.values.backupKeepDays === undefined || formikGeneral.values.backupKeepDays === null ? "" : formikGeneral.values.backupKeepDays}
257+
onChange={formikGeneral.handleChange}
258+
error={formikGeneral.touched.backupKeepDays && Boolean(formikGeneral.errors.backupKeepDays)}
259+
helperText={formikGeneral.touched.backupKeepDays && formikGeneral.errors.backupKeepDays}
260+
type="number"
261+
variant="outlined"
262+
/>
263+
<Tooltip title={t('backupPathHint')} placement="right">
264+
<IconButton size="small" sx={{ color: '#94a3b8' }}>
265+
<HelpOutlineIcon />
266+
</IconButton>
267+
</Tooltip>
268+
</div>
269+
231270
<SettingSectionTitle icon={BlockIcon}>{t('websiteBlacklist')}</SettingSectionTitle>
232271

233272
<div className="mt-1">

app/client/src/i18n/locales/en/settings.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
"coldDataRetentionDays": "Cold data retention days",
1717
"coldDataRetentionMin": "Cold data retention days can't less than 1",
1818
"coldDataHint": "Cold data: Unsaved browser history articles.",
19+
"databaseBackup": "Database Backup",
20+
"backupPath": "Backup directory path",
21+
"backupKeepDays": "Backup retention days",
22+
"backupKeepDaysMin": "Backup retention days can't be less than 1",
23+
"backupKeepDaysPlaceholder": "Default 30 days",
24+
"backupPathHint": "Specify the directory where SQLite database backups will be saved. Leaving it empty disables backup. If retention days is empty, defaults to 30 days.",
1925
"websiteBlacklist": "Website Blacklist",
2026
"blacklistLabel": "Blacklist (one per line, supports regex)",
2127
"saveSuccess": "General setting save success.",

app/client/src/i18n/locales/zh-CN/settings.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
"coldDataRetentionDays": "冷数据保留天数",
1717
"coldDataRetentionMin": "冷数据保留天数不能小于 1",
1818
"coldDataHint": "冷数据:未保存的浏览器历史文章。",
19+
"databaseBackup": "数据库备份",
20+
"backupPath": "备份目录路径",
21+
"backupKeepDays": "备份保存天数",
22+
"backupKeepDaysMin": "备份保留天数不能小于 1",
23+
"backupKeepDaysPlaceholder": "默认 30 天",
24+
"backupPathHint": "指定 SQLite 数据库备份保存的目录。留空则不开启备份。保留天数留空默认保存 30 天。",
1925
"websiteBlacklist": "网站黑名单",
2026
"blacklistLabel": "黑名单(每行一个,支持正则表达式)",
2127
"saveSuccess": "常规设置保存成功。",

app/server/huntly-server/src/main/java/com/huntly/server/domain/entity/GlobalSetting.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ public class GlobalSetting implements Serializable {
6161
@Column(name = "auto_save_tweet_min_likes")
6262
private Integer autoSaveTweetMinLikes;
6363

64+
@Column(name = "backup_path")
65+
private String backupPath;
66+
67+
@Column(name = "backup_keep_days")
68+
private Integer backupKeepDays;
69+
6470
@Column(name = "created_at")
6571
private Instant createdAt;
6672

app/server/huntly-server/src/main/java/com/huntly/server/service/GlobalSettingService.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,19 @@ public GlobalSetting saveGlobalSetting(GlobalSetting globalSetting) {
140140
}
141141
dbSetting.setMcpToken(globalSetting.getMcpToken());
142142
dbSetting.setAutoSaveTweetMinLikes(globalSetting.getAutoSaveTweetMinLikes());
143+
if (StringUtils.isNotBlank(globalSetting.getBackupPath())) {
144+
java.nio.file.Path normalized = java.nio.file.Paths.get(globalSetting.getBackupPath()).normalize();
145+
if (!normalized.isAbsolute()) {
146+
throw new IllegalArgumentException("backup path must be an absolute path");
147+
}
148+
if (normalized.toString().contains("..")) {
149+
throw new IllegalArgumentException("backup path must not contain '..'");
150+
}
151+
dbSetting.setBackupPath(normalized.toString());
152+
} else {
153+
dbSetting.setBackupPath(null);
154+
}
155+
dbSetting.setBackupKeepDays(globalSetting.getBackupKeepDays());
143156
dbSetting.setUpdatedAt(globalSetting.getUpdatedAt());
144157

145158
// Clear cache when setting is updated
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package com.huntly.server.task;
2+
3+
import com.huntly.server.domain.entity.GlobalSetting;
4+
import com.huntly.server.service.GlobalSettingService;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.apache.commons.lang3.StringUtils;
7+
import org.springframework.scheduling.annotation.Scheduled;
8+
import org.springframework.stereotype.Component;
9+
10+
import javax.sql.DataSource;
11+
import java.io.File;
12+
import java.sql.Connection;
13+
import java.sql.PreparedStatement;
14+
import java.sql.SQLException;
15+
import java.text.SimpleDateFormat;
16+
import java.time.Instant;
17+
import java.time.temporal.ChronoUnit;
18+
import java.util.Date;
19+
20+
/**
21+
* @author lcomplete
22+
*/
23+
@Component
24+
@Slf4j
25+
public class DatabaseBackupTask {
26+
private final GlobalSettingService settingService;
27+
private final DataSource dataSource;
28+
29+
public DatabaseBackupTask(GlobalSettingService settingService, DataSource dataSource) {
30+
this.settingService = settingService;
31+
this.dataSource = dataSource;
32+
}
33+
34+
/**
35+
* Automatically backup database every day at 2:00 AM.
36+
*/
37+
@Scheduled(cron = "0 0 2 * * ?")
38+
public void autoBackupDatabase() {
39+
log.info("start auto backup database");
40+
GlobalSetting setting = settingService.getGlobalSetting();
41+
if (setting == null || StringUtils.isBlank(setting.getBackupPath())) {
42+
log.info("database backup path is not configured, skip backup");
43+
return;
44+
}
45+
46+
String backupPath = setting.getBackupPath();
47+
File backupDir = new File(backupPath);
48+
if (!backupDir.exists()) {
49+
if (!backupDir.mkdirs()) {
50+
log.error("failed to create backup directory: {}", backupPath);
51+
return;
52+
}
53+
}
54+
55+
// Perform backup using SQLite VACUUM INTO for data consistency
56+
String timestamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
57+
File backupFile = new File(backupDir, "db_backup_" + timestamp + ".sqlite");
58+
try (Connection conn = dataSource.getConnection();
59+
PreparedStatement stmt = conn.prepareStatement("VACUUM INTO ?")) {
60+
stmt.setString(1, backupFile.getAbsolutePath());
61+
stmt.execute();
62+
log.info("database backup successfully saved to: {}", backupFile.getAbsolutePath());
63+
} catch (SQLException e) {
64+
log.error("failed to backup database via VACUUM INTO", e);
65+
return;
66+
}
67+
68+
// Clean expired backups
69+
int keepDays = setting.getBackupKeepDays() != null && setting.getBackupKeepDays() > 0
70+
? setting.getBackupKeepDays()
71+
: 30; // default keep for 30 days
72+
73+
cleanOldBackups(backupDir, keepDays);
74+
}
75+
76+
private void cleanOldBackups(File backupDir, int keepDays) {
77+
File[] files = backupDir.listFiles((dir, name) -> name.startsWith("db_backup_") && name.endsWith(".sqlite"));
78+
if (files == null || files.length == 0) {
79+
return;
80+
}
81+
82+
Instant limitTime = Instant.now().minus(keepDays, ChronoUnit.DAYS);
83+
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd_HHmmss");
84+
85+
for (File file : files) {
86+
String name = file.getName();
87+
try {
88+
String timestampStr = name.substring("db_backup_".length(), name.length() - ".sqlite".length());
89+
Date date = sdf.parse(timestampStr);
90+
if (date.toInstant().isBefore(limitTime)) {
91+
if (file.delete()) {
92+
log.info("deleted old database backup: {}", file.getName());
93+
} else {
94+
log.warn("failed to delete old backup: {}", file.getName());
95+
}
96+
}
97+
} catch (Exception e) {
98+
log.warn("failed to parse backup file name timestamp: {}", name);
99+
}
100+
}
101+
}
102+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package com.huntly.server.task;
2+
3+
import com.huntly.server.domain.entity.GlobalSetting;
4+
import com.huntly.server.service.GlobalSettingService;
5+
import org.junit.jupiter.api.BeforeEach;
6+
import org.junit.jupiter.api.Test;
7+
import org.junit.jupiter.api.io.TempDir;
8+
import org.mockito.Mock;
9+
import org.mockito.MockitoAnnotations;
10+
11+
import javax.sql.DataSource;
12+
import java.io.File;
13+
import java.io.IOException;
14+
import java.nio.file.Files;
15+
import java.nio.file.Path;
16+
import java.sql.Connection;
17+
import java.sql.PreparedStatement;
18+
import java.sql.SQLException;
19+
import java.text.SimpleDateFormat;
20+
import java.time.Instant;
21+
import java.time.temporal.ChronoUnit;
22+
import java.util.Date;
23+
24+
import static org.assertj.core.api.Assertions.assertThat;
25+
import static org.mockito.ArgumentMatchers.anyString;
26+
import static org.mockito.ArgumentMatchers.eq;
27+
import static org.mockito.Mockito.*;
28+
29+
class DatabaseBackupTaskTest {
30+
31+
@Mock
32+
private GlobalSettingService settingService;
33+
34+
@Mock
35+
private DataSource dataSource;
36+
37+
@Mock
38+
private Connection connection;
39+
40+
@Mock
41+
private PreparedStatement preparedStatement;
42+
43+
private DatabaseBackupTask backupTask;
44+
45+
@BeforeEach
46+
void setUp() throws SQLException {
47+
MockitoAnnotations.openMocks(this);
48+
when(dataSource.getConnection()).thenReturn(connection);
49+
when(connection.prepareStatement(anyString())).thenReturn(preparedStatement);
50+
backupTask = new DatabaseBackupTask(settingService, dataSource);
51+
}
52+
53+
@Test
54+
void autoBackupDatabase_whenBackupPathNotConfigured_shouldSkip() throws SQLException {
55+
GlobalSetting setting = new GlobalSetting();
56+
setting.setBackupPath("");
57+
when(settingService.getGlobalSetting()).thenReturn(setting);
58+
59+
backupTask.autoBackupDatabase();
60+
61+
verify(dataSource, never()).getConnection();
62+
}
63+
64+
@Test
65+
void autoBackupDatabase_shouldExecuteVacuumIntoAndCleanExpired(@TempDir Path backupDir) throws IOException, SQLException {
66+
// Mock settings
67+
GlobalSetting setting = new GlobalSetting();
68+
setting.setBackupPath(backupDir.toString());
69+
setting.setBackupKeepDays(2);
70+
when(settingService.getGlobalSetting()).thenReturn(setting);
71+
72+
// Pre-create some mock backup files
73+
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd_HHmmss");
74+
75+
// 1. A very old backup (e.g. 5 days ago) - should be deleted
76+
Instant fiveDaysAgo = Instant.now().minus(5, ChronoUnit.DAYS);
77+
String oldTimestamp = sdf.format(Date.from(fiveDaysAgo));
78+
Path oldBackupFile = backupDir.resolve("db_backup_" + oldTimestamp + ".sqlite");
79+
Files.writeString(oldBackupFile, "old-content");
80+
81+
// 2. A recent backup (e.g. 1 day ago) - should be kept
82+
Instant oneDayAgo = Instant.now().minus(1, ChronoUnit.DAYS);
83+
String recentTimestamp = sdf.format(Date.from(oneDayAgo));
84+
Path recentBackupFile = backupDir.resolve("db_backup_" + recentTimestamp + ".sqlite");
85+
Files.writeString(recentBackupFile, "recent-content");
86+
87+
// Run backup
88+
backupTask.autoBackupDatabase();
89+
90+
// Verify VACUUM INTO was called
91+
verify(connection).prepareStatement(eq("VACUUM INTO ?"));
92+
verify(preparedStatement).setString(eq(1), anyString());
93+
verify(preparedStatement).execute();
94+
95+
// Verify old backup was deleted
96+
assertThat(Files.exists(oldBackupFile)).isFalse();
97+
// Verify recent backup is kept
98+
assertThat(Files.exists(recentBackupFile)).isTrue();
99+
}
100+
}

0 commit comments

Comments
 (0)