Skip to content

Commit 267ec96

Browse files
committed
feat: Add hot-reloading functionality
1 parent f2e760c commit 267ec96

5 files changed

Lines changed: 91 additions & 66 deletions

File tree

backend/src/module/api/program.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ async def check_for_update(include_prerelease: bool = False):
161161
)
162162

163163

164-
@router.post("/update", response_model=APIResponse, dependencies=[Depends(get_current_user)])
164+
@router.post("program/update", response_model=APIResponse, dependencies=[Depends(get_current_user)])
165165
async def update_program(download_url: str):
166166
"""Docker 环境更新接口
167167
@@ -223,7 +223,6 @@ async def perform_update():
223223
status_code=200,
224224
msg_en="Update started successfully. Container will restart shortly.",
225225
msg_zh="更新已开始。容器将很快重启。",
226-
data={"download_url": download_url},
227226
)
228227
)
229228

backend/src/module/update/docker_updater.py

Lines changed: 72 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import tempfile
1313
import zipfile
1414
from pathlib import Path
15-
from typing import Optional
1615
from urllib.parse import urlparse
1716

1817
from module.network.request_url import RequestURL
@@ -25,17 +24,17 @@
2524
# 允许的下载域名白名单
2625
ALLOWED_DOMAINS = ["github.com", "codeload.github.com"] # GitHub 的文件下载域名
2726

28-
# 最大下载文件大小 (100MB)
29-
MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024
27+
# 最大下载文件大小 (10MB)
28+
MAX_DOWNLOAD_SIZE = 10 * 1024 * 1024
3029

3130

3231
class DockerUpdater:
3332
"""Docker 环境更新器"""
3433

3534
def __init__(self):
36-
self.temp_dir: Optional[Path] = None
37-
self.backup_dir = Path("/app.bak")
38-
self.app_dir = Path("/app")
35+
self.temp_dir: Path | None = None
36+
self.backup_dir: Path = Path("/app/backup")
37+
self.app_dir: Path = Path("/app")
3938

4039
def _is_url_allowed(self, url: str) -> bool:
4140
"""检查 URL 是否在允许的白名单中
@@ -114,23 +113,23 @@ async def _download_file(self, url: str) -> Path:
114113
# 设置用户代理
115114
client.header["User-Agent"] = "Auto_Bangumi Docker Updater"
116115

117-
response = await client.get_url(url, stream=True)
116+
response = await client.get_url(url)
118117

119118
# 检查文件大小
120119
content_length = response.headers.get("content-length")
121120
if content_length and int(content_length) > MAX_DOWNLOAD_SIZE:
122121
raise Exception(f"File too large: {content_length} bytes")
123122

124-
# 下载文件
125-
downloaded_size = 0
123+
# 直接获取文件内容
124+
content = response.content
125+
if len(content) > MAX_DOWNLOAD_SIZE:
126+
raise Exception("Downloaded file exceeds size limit")
127+
128+
# 写入文件
126129
with open(download_path, "wb") as f:
127-
async for chunk in response.aiter_bytes(8192):
128-
downloaded_size += len(chunk)
129-
if downloaded_size > MAX_DOWNLOAD_SIZE:
130-
raise Exception("Downloaded file exceeds size limit")
131-
f.write(chunk)
130+
f.write(content)
132131

133-
logger.info(f"[DockerUpdater] Downloaded {downloaded_size} bytes to {download_path}")
132+
logger.info(f"[DockerUpdater] Downloaded {len(content)} bytes to {download_path}")
134133
return download_path
135134

136135
except Exception as e:
@@ -163,6 +162,7 @@ def _extract_zip(self, zip_path: Path) -> Path:
163162

164163
# 找到实际的程序目录(通常是解压后的第一个子目录)
165164
extracted_items = list(extract_path.iterdir())
165+
logger.debug(f"[DockerUpdater] Extracted items: {extracted_items}")
166166
if len(extracted_items) == 1 and extracted_items[0].is_dir():
167167
return extracted_items[0]
168168
else:
@@ -173,61 +173,61 @@ def _extract_zip(self, zip_path: Path) -> Path:
173173
raise
174174

175175
def _backup_current_app(self):
176-
"""备份当前应用"""
176+
"""备份当前应用的 src 和 dist 目录"""
177177
try:
178178
# 删除旧的备份
179179
if self.backup_dir.exists():
180180
shutil.rmtree(self.backup_dir)
181181

182-
# 创建新备份
183-
shutil.move(str(self.app_dir), str(self.backup_dir))
184-
logger.info(f"[DockerUpdater] Backed up current app to {self.backup_dir}")
182+
# 创建备份目录
183+
self.backup_dir.mkdir(exist_ok=True)
184+
185+
# 只备份 src 和 dist 目录
186+
src_dir = self.app_dir / "src"
187+
dist_dir = self.app_dir / "dist"
188+
189+
if src_dir.exists():
190+
shutil.copytree(src_dir, self.backup_dir / "src")
191+
logger.info(f"[DockerUpdater] Backed up src directory to {self.backup_dir / 'src'}")
192+
193+
if dist_dir.exists():
194+
shutil.copytree(dist_dir, self.backup_dir / "dist")
195+
logger.info(f"[DockerUpdater] Backed up dist directory to {self.backup_dir / 'dist'}")
185196

186197
except Exception as e:
187198
logger.error(f"[DockerUpdater] Backup failed: {e}")
188199
raise
189200

190201
def _install_new_app(self, source_dir: Path):
191-
"""安装新应用
202+
"""安装新应用的 src 和 dist 目录
192203
193204
Args:
194205
source_dir: 新应用源目录
195206
"""
196207
try:
197-
# 移动新应用到目标位置
198-
shutil.move(str(source_dir), str(self.app_dir))
199-
logger.info(f"[DockerUpdater] Installed new app from {source_dir}")
208+
# 更新 src 目录
209+
new_src = source_dir / "src"
210+
if new_src.exists():
211+
target_src = self.app_dir / "src"
212+
if target_src.exists():
213+
shutil.rmtree(target_src)
214+
shutil.copytree(new_src, target_src)
215+
logger.info(f"[DockerUpdater] Installed new src directory from {new_src}")
216+
217+
# 更新 dist 目录
218+
new_dist = source_dir / "dist"
219+
if new_dist.exists():
220+
target_dist = self.app_dir / "dist"
221+
if target_dist.exists():
222+
shutil.rmtree(target_dist)
223+
shutil.copytree(new_dist, target_dist)
224+
logger.info(f"[DockerUpdater] Installed new dist directory from {new_dist}")
200225

201226
except Exception as e:
202227
logger.error(f"[DockerUpdater] Installation failed: {e}")
203228
raise
204229

205-
def _restore_user_data(self):
206-
"""恢复用户配置和数据"""
207-
try:
208-
# 恢复 config 目录
209-
backup_config = self.backup_dir / "config"
210-
new_config = self.app_dir / "config"
211-
212-
if backup_config.exists():
213-
if new_config.exists():
214-
shutil.rmtree(new_config)
215-
shutil.copytree(backup_config, new_config)
216-
logger.info("[DockerUpdater] Restored config directory")
217-
218-
# 恢复 data 目录
219-
backup_data = self.backup_dir / "data"
220-
new_data = self.app_dir / "data"
221-
222-
if backup_data.exists():
223-
if new_data.exists():
224-
shutil.rmtree(new_data)
225-
shutil.copytree(backup_data, new_data)
226-
logger.info("[DockerUpdater] Restored data directory")
227-
228-
except Exception as e:
229-
logger.error(f"[DockerUpdater] Failed to restore user data: {e}")
230-
raise
230+
231231

232232
def _fix_permissions(self):
233233
"""修复文件权限"""
@@ -242,13 +242,29 @@ def _fix_permissions(self):
242242
def _rollback(self):
243243
"""回滚到备份版本"""
244244
try:
245-
if self.backup_dir.exists():
246-
if self.app_dir.exists():
247-
shutil.rmtree(self.app_dir)
248-
shutil.move(str(self.backup_dir), str(self.app_dir))
249-
logger.info("[DockerUpdater] Rolled back to backup version")
250-
else:
245+
if not self.backup_dir.exists():
251246
logger.error("[DockerUpdater] No backup found for rollback")
247+
return
248+
249+
# 回滚 src 目录
250+
backup_src = self.backup_dir / "src"
251+
if backup_src.exists():
252+
target_src = self.app_dir / "src"
253+
if target_src.exists():
254+
shutil.rmtree(target_src)
255+
shutil.copytree(backup_src, target_src)
256+
logger.info("[DockerUpdater] Rolled back src directory")
257+
258+
# 回滚 dist 目录
259+
backup_dist = self.backup_dir / "dist"
260+
if backup_dist.exists():
261+
target_dist = self.app_dir / "dist"
262+
if target_dist.exists():
263+
shutil.rmtree(target_dist)
264+
shutil.copytree(backup_dist, target_dist)
265+
logger.info("[DockerUpdater] Rolled back dist directory")
266+
267+
logger.info("[DockerUpdater] Rollback completed")
252268

253269
except Exception as e:
254270
logger.error(f"[DockerUpdater] Rollback failed: {e}")
@@ -278,7 +294,6 @@ async def update(self, download_url: str) -> dict:
278294
# 创建更新锁
279295
if not self._create_update_lock():
280296
raise Exception("Another update is already in progress")
281-
282297
try:
283298
logger.info("[DockerUpdater] Starting Docker update process")
284299

@@ -294,9 +309,6 @@ async def update(self, download_url: str) -> dict:
294309
# 4. 安装新应用
295310
self._install_new_app(source_dir)
296311

297-
# 5. 恢复用户数据
298-
self._restore_user_data()
299-
300312
# 6. 修复权限
301313
self._fix_permissions()
302314

@@ -325,9 +337,9 @@ def force_restart(self):
325337
logger.info("[DockerUpdater] Forcing container restart")
326338
# 在 Docker 环境中,.sh 有监控进程会自动重启
327339
import sys
340+
328341
sys.exit(0)
329342

330343

331344
# 全局实例
332345
docker_updater = DockerUpdater()
333-

backend/src/module/update/release_checker.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class ReleaseInfo:
2323
published_at: str
2424
prerelease: bool
2525
draft: bool
26+
download_url: str | None = None # GitHub releases assets 下载链接
2627

2728

2829
class ReleaseChecker:
@@ -73,7 +74,7 @@ async def get_all_releases(self, include_prerelease: bool = False) -> list[Relea
7374
continue
7475

7576
href = link_elem.get("href", "")
76-
if f"/releases/tag/" not in href:
77+
if "/releases/tag/" not in href:
7778
continue
7879

7980
tag_name = href.split("/releases/tag/")[-1]
@@ -104,6 +105,9 @@ async def get_all_releases(self, include_prerelease: bool = False) -> list[Relea
104105
published_elem.text.strip() if published_elem is not None and published_elem.text else ""
105106
)
106107

108+
# 构建 GitHub releases assets 下载链接
109+
download_url = f"https://github.com/{self.repo_owner}/{self.repo_name}/releases/download/{tag_name}/app-v{tag_name}.zip"
110+
107111
release_info = ReleaseInfo(
108112
tag_name=tag_name,
109113
name=title,
@@ -116,6 +120,7 @@ async def get_all_releases(self, include_prerelease: bool = False) -> list[Relea
116120
keyword in tag_name.lower() for keyword in ["alpha", "beta", "rc", "pre", "dev"]
117121
),
118122
draft=False,
123+
download_url=download_url,
119124
)
120125
releases.append(release_info)
121126

@@ -175,6 +180,7 @@ async def check_for_update(self, include_prerelease: bool = False) -> dict:
175180
"html_url": latest_release.html_url,
176181
"published_at": latest_release.published_at,
177182
"prerelease": latest_release.prerelease,
183+
"download_url": latest_release.download_url,
178184
},
179185
}
180186

webui/src/api/program.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export const apiProgram = {
5757
html_url: string;
5858
published_at: string;
5959
prerelease: boolean;
60+
download_url: string;
6061
};
6162
}>(`api/v1/check/update?include_prerelease=${includePrerelease}`);
6263
return data;
@@ -65,8 +66,10 @@ export const apiProgram = {
6566
/**
6667
* 执行更新
6768
*/
68-
async update() {
69-
const { data } = await axios.get<ApiSuccess>('api/v1/update');
69+
async update(downloadUrl: string) {
70+
const { data } = await axios.post<ApiSuccess>('api/v1/program/update', {
71+
download_url: downloadUrl,
72+
});
7073
return data;
7174
},
7275
};

webui/src/components/ab-check-update.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const updateInfo = ref<{
1616
html_url: string;
1717
published_at: string;
1818
prerelease: boolean;
19+
download_url: string;
1920
};
2021
} | null>(null);
2122
@@ -58,7 +59,11 @@ async function checkUpdate() {
5859
5960
// 执行更新
6061
async function performUpdate() {
61-
await executeUpdate();
62+
if (!updateInfo.value?.release_info.download_url) {
63+
message.error(t('notify.update_failed'));
64+
return;
65+
}
66+
await executeUpdate(updateInfo.value.release_info.download_url);
6267
}
6368
6469
// 确认更新

0 commit comments

Comments
 (0)