Compare commits

...

4 Commits

Author SHA1 Message Date
9700X_PC 7afc97b8f8 fix: 상세 조회 확인시간 즉시 갱신 및 미확인 알림 중복 방지 2026-02-19 20:23:30 +09:00
9700X_PC 504dd61b6f feat: 상세 재수집 주기에 5시간 옵션 추가
Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-19 20:09:27 +09:00
9700X_PC 4fd45a3364 크롤링 새로고침 강건성 개선: 목록 재확인 + 상세 재시도 로직 + 설정 가능한 재확인 시간
Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-19 20:07:10 +09:00
9700X_PC 272a1e5f53 업데이터 정책 및 오류 처리 고도화 2026-02-19 16:24:13 +09:00
18 changed files with 1184 additions and 97 deletions

View File

@ -0,0 +1,78 @@
## 2026-02-19 Initialization
- Decision log initialized.
## 2026-02-19 Expose crawling.recheck_hours in Settings UI
### Decision
Added UI control and persistence for `recheck_hours` in SettingsDialog to expose previously invisible setting.
### Changes Made
1. **settings_dialog.py:_init_crawl_tab()** (lines 142-147)
- Added ComboBox for "상세 재수집 주기 (시간)" with options [1,2,3,6,12,24]
- Loads from `settings['crawling']['recheck_hours']` with default 3
- Positioned after unchecked_interval control for logical UI flow
2. **settings_dialog.py:save_all()** (line 417)
- Added persistence: `settings['crawling']['recheck_hours'] = int(self.combo_recheck_hours.get())`
- Placed with other crawling settings (after interval_minutes)
3. **test_settings_dialog_update_policy.py**
- Added `recheck_hours: 3` to initial controller.settings
- Added `combo_recheck_hours = _DummyField("6")` test field
- Added assertion: `self.assertEqual(crawling_settings["recheck_hours"], 6)`
### Why This Approach
- **Default fallback (3 hours)** ensures backward compatibility
- **ComboBox values** match expected scheduler time scales
- **Test verifies persistence** through save_all() flow
- **No changes to controller/scheduler** - they already use the key
- **Consistent with existing pattern** (interval_minutes control)
### Verified
- Unit test passes: `test_save_all_persists_update_policy_fields`
- Value correctly loaded from settings
- Value correctly saved on save_all()
## 2026-02-19 Add 5-hour option to recheck-hours ComboBox
### Decision
Added "5" to the recheck_hours ComboBox values to enable explicit user selection of 5-hour interval.
### Change
- **settings_dialog.py:line 143**
- Updated values: ["1", "2", "3", "5", "6", "12", "24"] (inserted "5" between "3" and "6")
- Maintains numeric ordering
- No logic changes to save() or load flow
## 2026-02-19 Prevent duplicate unchecked notification dialogs
### Decision
Implement singleton behavior for unchecked notification dialogs (title prefix `⚠️ 미확인 VOC`).
Allow regular new notifications to open multiple dialogs as before.
### Implementation
- **notification_dialog.py (lines 4-6, 8-15, 20, 22-24, 59-62, 86, 88-101)**:
1. Added class variable `_unchecked_dialog = None` to track open unchecked dialog
2. In `__init__`: Check if incoming title is unchecked notification (startswith `⚠️ 미확인 VOC`)
3. If unchecked AND one already open → lift() and focus_force() existing dialog, return early (no new instance)
4. If unchecked AND none open → store reference in `_unchecked_dialog`
5. Modified close button (line 61) to call `self._on_close()` instead of `self.destroy()`
6. Added `_on_close()` method (lines 97-101): Cleans up `_unchecked_dialog` reference before destroying
7. Updated `_on_view()` and `_on_open_list()` to call `_on_close()` instead of direct `destroy()`
### Behavior
- **Unchecked dialogs (⚠️ 미확인 VOC)**: Maximum one open at a time; new attempts bring existing to front
- **Regular notifications (신규 알림)**: Unchanged; multiple can open (checked in title prefix)
- **Cleanup**: Reference cleared when dialog closes via any path (button, protocol handler, or methods)
### Why This Approach
- **Minimal scope**: Changes only notification_dialog.py, no controller/scheduler modifications
- **Non-intrusive**: Regular new notifications unaffected; only unchecked notifications deduplicated
- **Safe cleanup**: `_on_close()` ensures `_unchecked_dialog` ref cleared regardless of close method
- **Semantic check**: title.startswith("⚠️ 미확인 VOC") is the exact prefix used by notifier
### Verified
- Syntax: python -m py_compile passed
- No regressions: All button callbacks still work (updated to use _on_close)
- LSP: Only pre-existing MAIN_COLOR hasattr error (unrelated)

View File

@ -0,0 +1,94 @@
## 2026-02-19 Initialization
- Issue log initialized.
## 2026-02-19 Implement Meaningful recheck_hours Behavior
### Issue
`get_posts_needing_detail(recheck_hours)` received parameter but ignored it—only checking for empty content.
Scheduler unconditionally skipped `is_public=0` posts, preventing detail collection for relevant non-public items.
### Implementation Details
#### 1. database.py: get_posts_needing_detail()
**Lines 258-288**
- **Before**: Only selected posts with empty content, ordered by is_public DESC (hardcoded)
- **After**:
- SELECT posts where: (content IS NULL/empty) OR (updated_at < threshold_time)
- threshold_time = now - recheck_hours interval
- ORDER BY is_related DESC, id DESC (prioritizes related + latest, not public status)
- Respects recheck_hours setting from crawling.recheck_hours (default 3 hours)
**SQL Logic**:
```sql
WHERE (content IS NULL OR content = '')
OR (updated_at IS NOT NULL AND updated_at < ?)
ORDER BY is_related DESC, id DESC LIMIT 10
```
#### 2. scheduler_manager.py: _collect_detail_content()
**Lines 305-368**
- **Before**: Hard-skip all `is_public=0` with unconditional continue
- **After**: Decision tree:
- **Related posts** (is_related=1): Attempt detail regardless of is_public (차량 context matters)
- **Public posts** (is_public=1): Attempt detail
- **Non-public + non-related**: Skip (prevents pointless permission errors)
- Failures from fetch_detail_content (timeout, permission denied, etc.) logged at DEBUG level (not treated as errors)
- Maintains 1.2s delay between requests for server load protection
**Decision Logic**:
```python
if is_related:
should_attempt_detail = True # Always try
elif is_public == 1:
should_attempt_detail = True # Try public
else:
skip_reason = "비공개 비관심글" # Skip non-public non-related
```
### Backward Compatibility
✓ Method signature unchanged: `get_posts_needing_detail(recheck_hours=3)`
✓ Default recheck_hours=3 hours maintained
✓ Safe defaults: Respects crawling.recheck_hours setting
✓ Graceful failure handling (None returns from fetch logged, not crashed)
✓ No new dependencies or breaking changes
### Verification Checklist
- [x] Python syntax valid (py_compile)
- [x] recheck_hours parameter actually used in SQL query
- [x] Threshold calculation correct (now - N hours)
- [x] Scheduler respects is_related context for non-public posts
- [x] Failure handling graceful (debug logs, not exceptions)
- [x] Korean logging messages consistent with codebase
## 2026-02-19 Fix: HistoryDialog Title Click Checked Timestamp Refresh
### Issue
When clicking a title in HistoryDialog to open detail view, `controller.mark_as_read(voc_id)` is called which refreshes `controller.list_window`. However, UIManager independently manages `self.list_window`, causing a mismatch—the refresh affects the wrong instance.
### Root Cause
Two separate list_window references:
- `controller.list_window` (checked in mark_as_read)
- `UIManager.list_window` (actual active window displayed)
These never synchronized, so refresh refreshed a stale reference.
### Solution
**File**: `app/controllers/ui_manager.py` (line 97)
**Change**: After creating HistoryDialog instance in UIManager, synchronize it to controller:
```python
self.list_window = HistoryDialog(self.controller, formatted)
# UIManager의 list_window를 controller가 접근하도록 동기화
self.controller.list_window = self.list_window
```
### Impact
- Title click → detail open → `mark_as_read()``controller.list_window.refresh_data_only()` now refreshes the visible list
- Checked timestamp visibility updates immediately (same behavior as pressing refresh button)
- No code changes needed in controller.py; list refresh flow remains unchanged
- Minimal 1-line addition with clear Korean comment explaining synchronization
### Verification
✓ Syntax valid (py_compile)
✓ No LSP diagnostics
✓ Controller mark_as_read() logic preserved
✓ Existing list open behavior unaffected

View File

@ -0,0 +1,34 @@
## 2026-02-19 Initialization
- Notepad initialized for crawling fixes orchestration.
## 2026-02-19 Scraper Service Fixes
### Fix 1: Unconditional List Refresh (Lines 168-172)
**Before**: Conditional check skipped navigation if URL contained "act=searchList" and data existed
**After**: Always navigate to refresh list via _navigate_to_list_via_physical_click()
**Reason**: Cached pages cause new posts to be missed. Unconditional refresh ensures latest state.
**Code**: Removed nested if checking "act=searchList" and _check_data_exists()
### Fix 2: Preserve Non-Public Rows (Lines 231-236)
**Before**: Skip (continue) non-public rows entirely with `if is_public == 0: continue`
**After**: Keep all rows in metadata with is_public=0 flag, log discovery
**Reason**: Metadata completeness required; detail access already has timeout handling
**Code**: Changed `continue` to debug log "비공개 게시글 수집: {voc_id} (상세 조회 스킵 예정)"
### Fix 3: Detail Attempt Policy (Lines 256-269)
**Before**: Verbose comments, explicit "관심 대상인 경우 상세까지 긁어서 정확도 높임"
**After**: Clear policy: "관심 게시글: 공개/비공개 구분 없이 상세 조회 시도"
**Reason**: Support related 차량 cases; non-fatal errors in fetch_detail_content (timeout, permissions)
**Code**: Updated comments to explain that fetch_detail_content handles errors for both public/private
### Non-Functional Changes
- All modifications are isolated to fetch_list_pages() method
- No dependency additions
- Failures remain non-fatal (try-except in fetch_detail_content catches all)
- Architecture preserved (same control flow, same data structure)
### Testing Notes
- List refresh now runs on every fetch_list_pages() call
- Metadata includes is_public=0 rows (complete state capture)
- Detail attempts continue for is_target rows regardless of is_public value
- Timeout/permission errors in fetch_detail_content are silently caught (returns None)

View File

@ -224,6 +224,11 @@ class AppController:
self.settings['update'].setdefault('version_table', 'program_versions')
self.settings['update'].setdefault('ssl_verify', True)
self.settings['update'].setdefault('ca_bundle_path', '')
self.settings['update'].setdefault('cleanup_backup_on_success', True)
self.settings['update'].setdefault('clean_install_before_copy', True)
self.settings['update'].setdefault('preserve_globs', ['data/*.sqlite', 'data/*.json', 'data/*.db'])
self.settings['update'].setdefault('allowed_update_levels', ['patch', 'minor', 'major'])
self.settings['update'].setdefault('clean_install_levels', ['minor', 'major'])
self.logger.info("설정 파일 로드 완료")
except FileNotFoundError:
# 기본 설정 생성
@ -251,7 +256,12 @@ class AppController:
"program_id": "voc_monitor",
"version_table": "program_versions",
"ssl_verify": True,
"ca_bundle_path": ""
"ca_bundle_path": "",
"cleanup_backup_on_success": True,
"clean_install_before_copy": True,
"preserve_globs": ["data/*.sqlite", "data/*.json", "data/*.db"],
"allowed_update_levels": ["patch", "minor", "major"],
"clean_install_levels": ["minor", "major"]
},
"report": {
"output_path": str(get_data_dir() / "reports")
@ -415,11 +425,13 @@ class AppController:
self.logger.info(f"수동 업데이트 확인 결과: 새 버전 {version_info.version}")
self._show_update_prompt(version_info)
except UpdaterConfigError as e:
self.logger.warning(f"업데이트 확인 실패(설정): {e}")
messagebox.showerror("업데이트 설정 오류", str(e))
code = getattr(e, "code", "ERR_CONFIG")
self.logger.warning(f"업데이트 확인 실패(설정)[{code}]: {e}")
messagebox.showerror("업데이트 설정 오류", self._format_update_error(code, str(e)))
except UpdaterNetworkError as e:
self.logger.warning(f"업데이트 확인 실패(네트워크): {e}")
messagebox.showerror("업데이트 네트워크 오류", str(e))
code = getattr(e, "code", "ERR_NETWORK")
self.logger.warning(f"업데이트 확인 실패(네트워크)[{code}]: {e}")
messagebox.showerror("업데이트 네트워크 오류", self._format_update_error(code, str(e)))
except Exception as e:
self.logger.error(f"업데이트 수동 확인 실패: {e}", exc_info=True)
messagebox.showerror("업데이트 오류", f"업데이트 확인 중 오류가 발생했습니다.\n{e}")
@ -430,7 +442,24 @@ class AppController:
def _on_update_check_error(self, error: Exception):
"""백그라운드 업데이트 확인 실패 콜백"""
self.logger.warning(f"백그라운드 업데이트 확인 실패: {error}")
code = getattr(error, "code", "ERR_UPDATE_CHECK")
self.logger.warning(f"백그라운드 업데이트 확인 실패[{code}]: {error}")
@staticmethod
def _format_update_error(code: str, detail: str) -> str:
mapping = {
"ERR_UPDATER_EXE_NOT_FOUND": "업데이터 실행 파일이 없어 업데이트를 진행할 수 없습니다.",
"ERR_UPDATER_TEMP_NOT_FOUND": "임시 업데이터 파일 생성에 실패했습니다.",
"ERR_UPDATER_LAUNCH": "업데이터 실행에 실패했습니다.",
"ERR_NO_DOWNLOAD_URL": "다운로드 주소가 없어 업데이트를 시작할 수 없습니다.",
"ERR_PERMISSION": "파일 권한 문제로 업데이트를 진행할 수 없습니다.",
"ERR_SSL_VERIFY": "SSL 인증서 검증에 실패했습니다.",
"ERR_TIMEOUT": "업데이트 서버 연결 시간이 초과되었습니다.",
"ERR_NETWORK_REQUEST": "업데이트 서버 통신에 실패했습니다.",
"ERR_PREPARE_UNKNOWN": "업데이트 준비 중 알 수 없는 오류가 발생했습니다.",
}
base = mapping.get(code, "업데이트 처리 중 오류가 발생했습니다.")
return f"[{code}] {base}\n{detail}" if detail else f"[{code}] {base}"
def _show_update_prompt(self, version_info):
"""업데이트 설치 여부를 사용자에게 확인"""
@ -458,10 +487,14 @@ class AppController:
release_note = (version_info.release_note or "-").strip()
release_note_preview = release_note[:200] + ("..." if len(release_note) > 200 else "")
current_version = manager.current_version
update_level = getattr(version_info, "update_level", "patch")
level_label = {"patch": "PATCH", "minor": "MINOR", "major": "MAJOR"}.get(update_level, str(update_level).upper())
major_notice = "\n※ MAJOR 업데이트: 클린 설치 정책이 적용될 수 있습니다.\n" if update_level == "major" else ""
msg = (
f"새 버전이 있습니다.\n\n"
f"현재: {current_version}\n"
f"최신: {version_info.version}\n\n"
f"업데이트 등급: {level_label}{major_notice}\n"
f"배포 노트:\n{release_note_preview}\n\n"
f"지금 업데이트를 진행할까요?"
)
@ -478,13 +511,16 @@ class AppController:
ok, prep_msg = manager.prepare_update(version_info)
if not ok:
self.logger.error(f"업데이트 준비 실패: {prep_msg}")
messagebox.showerror("업데이트 준비 실패", prep_msg)
code = getattr(manager, "last_error_code", "ERR_PREPARE_UNKNOWN")
self.logger.error(f"업데이트 준비 실패[{code}]: {prep_msg}")
messagebox.showerror("업데이트 준비 실패", self._format_update_error(code, prep_msg))
return
if not manager.launch_updater():
self.logger.error("updater.exe 실행 실패")
messagebox.showerror("업데이트 실행 실패", "updater.exe 실행에 실패했습니다.")
code = getattr(manager, "last_error_code", "ERR_UPDATER_LAUNCH")
detail = getattr(manager, "last_error_message", "updater.exe 실행에 실패했습니다.")
self.logger.error(f"updater.exe 실행 실패[{code}]: {detail}")
messagebox.showerror("업데이트 실행 실패", self._format_update_error(code, detail))
return
self.logger.info("업데이트 프로세스 시작, 메인 앱 종료")

View File

@ -28,7 +28,6 @@ from datetime import datetime, timedelta
from typing import Callable, Optional
from core.exceptions import (
ScraperError,
LoginFailedError,
SessionExpiredError,
DatabaseError
@ -97,6 +96,7 @@ class SchedulerManager:
# 콜백 함수 (Controller에서 주입)
self.is_related_callback: Optional[Callable] = None
self.notify_callback: Optional[Callable] = None
self.sync_callback: Optional[Callable] = None
def set_callbacks(self, is_related_func: Callable, notify_func: Callable):
"""
@ -110,6 +110,10 @@ class SchedulerManager:
"""
self.is_related_callback = is_related_func
self.notify_callback = notify_func
def set_sync_callback(self, sync_func: Callable):
"""Supabase 동기화 콜백 설정"""
self.sync_callback = sync_func
def start(self):
"""
@ -128,8 +132,11 @@ class SchedulerManager:
# 백그라운드 스레드에서 스케줄러 루프 실행
threading.Thread(target=self._scheduler_loop, daemon=True).start()
# 첫 크롤링 즉시 실행
threading.Thread(target=self.run_crawling_cycle, daemon=True).start()
# 첫 작업 즉시 실행
if self.settings.get('sync', {}).get('enabled', False):
threading.Thread(target=self.run_sync_cycle, daemon=True).start()
else:
threading.Thread(target=self.run_crawling_cycle, daemon=True).start()
self.logger.info("스케줄러 시작됨")
@ -148,9 +155,15 @@ class SchedulerManager:
"""
schedule.clear()
# 크롤링 주기 (기본: 10분)
sync_enabled = self.settings.get('sync', {}).get('enabled', False)
# 크롤링/동기화 주기
crawl_interval = self.settings.get('crawling', {}).get('interval_minutes', 10)
schedule.every(crawl_interval).minutes.do(self.run_crawling_cycle)
sync_interval = self.settings.get('sync', {}).get('pull_interval_minutes', crawl_interval)
if sync_enabled:
schedule.every(sync_interval).minutes.do(self.run_sync_cycle)
else:
schedule.every(crawl_interval).minutes.do(self.run_crawling_cycle)
# DB 체크 주기 (설정값, 기본: 5분)
noti_interval = self.settings.get('noti', {}).get('db_check_interval_minutes', 5)
@ -160,8 +173,10 @@ class SchedulerManager:
unchecked_interval = self.settings.get('noti', {}).get('unchecked_check_interval_minutes', 30)
schedule.every(unchecked_interval).minutes.do(self.run_unchecked_check_cycle)
mode_text = '동기화' if sync_enabled else '크롤링'
mode_interval = sync_interval if sync_enabled else crawl_interval
self.logger.info(
f"스케줄 업데이트됨: 크롤링 {crawl_interval}분 / 신규 알림 {noti_interval}분 / 미확인 체크 {unchecked_interval}"
f"스케줄 업데이트됨: {mode_text} {mode_interval}분 / 신규 알림 {noti_interval}분 / 미확인 체크 {unchecked_interval}"
)
def _scheduler_loop(self):
@ -188,6 +203,10 @@ class SchedulerManager:
- DatabaseError: DB 오류 로그 기록 계속
"""
self.logger.info("크롤링 사이클 시작...")
if self.settings.get('sync', {}).get('enabled', False):
self.logger.info("동기화 모드 활성화: 크롤링 사이클 건너뜀")
return
try:
# 1. 로그인 상태 확인 및 재로그인
@ -232,6 +251,22 @@ class SchedulerManager:
self.logger.error(f"DB 오류: {e.message}")
except Exception as e:
self.logger.error(f"크롤링 중 예상치 못한 오류: {e}", exc_info=True)
def run_sync_cycle(self):
"""Supabase 동기화 사이클 실행"""
if not self.settings.get('sync', {}).get('enabled', False):
return
if not self.sync_callback:
self.logger.warning("동기화 콜백 미설정으로 동기화를 건너뜁니다.")
return
self.logger.info("Supabase 동기화 사이클 시작...")
try:
self.sync_callback()
self.logger.info("Supabase 동기화 사이클 완료.")
except Exception as e:
self.logger.error(f"Supabase 동기화 중 오류: {e}", exc_info=True)
def _process_collected_posts(self, posts_data: list):
"""
@ -271,7 +306,14 @@ class SchedulerManager:
"""
상세 내용 수집 (내부 메서드)
내용이 없거나 최근 글인 경우 상세 파싱을 시도합니다.
recheck_hours 설정에 따라 재수집이 필요한 게시글의 상세 내용을 수집합니다.
관심글(is_related=1)이거나 내용이 없는 경우를 우선처리합니다.
정책:
- 내용이 없거나 마지막 업데이트가 recheck_hours 이상 경과: 상세 시도
- 관심글(차량 관련 ): 공개/비공개 구분 없이 상세 조회 시도
- 비관심 & 비공개: 상세 조회 스킵 (불필요한 권한 오류 방지)
- fetch_detail_content의 실패(타임아웃, 권한거부 ) 자동 처리(로그만 기록)
"""
recheck_hours = self.settings['crawling']['recheck_hours']
targets = self.db.get_posts_needing_detail(recheck_hours)
@ -279,21 +321,49 @@ class SchedulerManager:
for tgt in targets:
voc_id, title, is_related = tgt
# 비공개 글 체크
# 게시글 상세 정보 조회
post_check = self.db.get_post_by_id(voc_id)
if post_check and post_check['is_public'] == 0:
if not post_check:
continue
# 상세 내용 수집
is_public = post_check['is_public']
# 결정 로직: 상세 조회 시도 여부
should_attempt_detail = False
skip_reason = None
if is_related:
# 관심글: 항상 시도 (차량 관련 등 가치있는 정보)
should_attempt_detail = True
elif is_public == 1:
# 공개글: 시도
should_attempt_detail = True
else:
# 비공개 + 비관심글: 스킵 (불필요한 권한 오류 방지)
skip_reason = "비공개 비관심글"
if not should_attempt_detail:
self.logger.debug(
f"상세 조회 스킵: {voc_id} ({skip_reason})"
)
continue
# 상세 내용 수집 시도 (실패는 자동 처리)
detail_data = self.model.fetch_detail_content(voc_id)
if detail_data:
try:
self.db.update_detail(voc_id, detail_data)
if is_related:
self.logger.info(f"상세 내용 업데이트됨: {title}")
log_suffix = " [관심글]" if is_related else ""
self.logger.info(f"상세 내용 업데이트됨: {title}{log_suffix}")
except DatabaseError as e:
self.logger.error(f"상세 내용 저장 실패 (ID: {voc_id}): {e.message}")
else:
# fetch_detail_content에서 None 반환: 타임아웃, 권한거부, 기타 오류
# 이는 정상적인 상황이므로 로그만 기록
self.logger.debug(
f"상세 내용 수집 실패 또는 스킵: {voc_id} (타임아웃/권한/기타)"
)
time.sleep(1.2) # 서버 부하 방지용 딜레이

View File

@ -93,6 +93,8 @@ class UIManager:
from view.dialogs.history_dialog import HistoryDialog
self.list_window = HistoryDialog(self.controller, formatted)
# UIManager의 list_window를 controller가 접근하도록 동기화
self.controller.list_window = self.list_window
else:
# 기존 창 재사용
self.list_window.refresh_data_only()

View File

@ -117,30 +117,12 @@ HWP 보고서 생성 프로세스:
import os
import re
import winreg
import importlib
import importlib.util
from pathlib import Path
from datetime import datetime
import json
# Windows API 모듈
try:
import win32print
import win32ui
import win32con
import win32com.client
HAS_WIN32 = True
except ImportError:
HAS_WIN32 = False
# ReportLab (PDF)
try:
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
HAS_REPORTLAB = True
except ImportError:
HAS_REPORTLAB = False
from utils.path_utils import get_base_dir
from utils.logger import get_logger
from utils.voc_parser import VOCParser
@ -148,6 +130,17 @@ from utils.train_analyzer import TrainAnalyzer
from utils.date_utils import DateScheduleUtils
from services.timetable_service import TimetableService
# 외부 모듈 가용성 체크
HAS_WIN32 = (
importlib.util.find_spec("win32print") is not None
and importlib.util.find_spec("win32ui") is not None
and importlib.util.find_spec("win32com.client") is not None
)
HAS_REPORTLAB = (
importlib.util.find_spec("reportlab") is not None
and importlib.util.find_spec("reportlab.pdfgen") is not None
)
BASE_DIR = get_base_dir()
CONFIG_FILE = BASE_DIR / "data" / "settings.json"
@ -284,6 +277,46 @@ class ReportService:
except Exception as e:
self.logger.warning(f"보안 모듈 등록 실패: {e}")
return None
def _normalize_report_date(self, raw_date: str) -> tuple[str, str]:
"""보고서 날짜를 표준 포맷으로 정규화합니다."""
text = str(raw_date or "").strip()
dt_obj = None
for fmt in (
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d",
"%Y.%m.%d %H:%M:%S",
"%Y.%m.%d",
"%Y/%m/%d %H:%M:%S",
"%Y/%m/%d",
):
try:
dt_obj = datetime.strptime(text, fmt)
break
except ValueError:
continue
if dt_obj is None:
m = re.search(r"(\d{4})[./-](\d{1,2})[./-](\d{1,2})", text)
if m:
y, mm, dd = m.groups()
try:
dt_obj = datetime(int(y), int(mm), int(dd))
except ValueError:
dt_obj = None
if dt_obj is None:
dt_obj = datetime.now()
return dt_obj.strftime("%Y.%m.%d"), dt_obj.strftime("%Y%m%d")
def _ensure_file_extension(self, filename: str, extension: str) -> str:
"""파일명에 확장자가 없거나 다른 경우 지정 확장자를 강제합니다."""
ext = extension if extension.startswith(".") else f".{extension}"
if not filename.lower().endswith(ext.lower()):
filename = f"{filename}{ext}"
return filename
def _parse_voc_info(self, title, content, doc_date):
"""
@ -378,6 +411,9 @@ class ReportService:
"""VOC 상세 내용 인쇄"""
if not HAS_WIN32:
return False, "윈도우 인쇄 모듈(pywin32)이 로드되지 않았습니다.\n(pip install pywin32)"
win32print = importlib.import_module("win32print")
win32ui = importlib.import_module("win32ui")
try:
hDC = win32ui.CreateDC()
@ -430,13 +466,19 @@ class ReportService:
"""HWP 보고서 생성"""
if not HAS_WIN32:
return False, "윈도우 자동화 모듈(pywin32)이 로드되지 않았습니다."
win32_client = importlib.import_module("win32com.client")
try:
# 1. 한글 실행
try:
hwp = win32com.client.gencache.EnsureDispatch("HWPFrame.HwpObject")
except:
hwp = win32com.client.Dispatch("HWPFrame.HwpObject")
gencache = getattr(win32_client, "gencache", None)
if gencache is not None:
hwp = gencache.EnsureDispatch("HWPFrame.HwpObject")
else:
hwp = win32_client.Dispatch("HWPFrame.HwpObject")
except Exception:
hwp = win32_client.Dispatch("HWPFrame.HwpObject")
# 2. 보안 모듈 등록
dll_path = BASE_DIR / "assets" / "FilePathCheckerModule.dll"
@ -445,7 +487,7 @@ class ReportService:
if reg_module_name:
res = hwp.RegisterModule("FilePathCheckDLL", reg_module_name)
if not res:
self.logger.warning(f"보안 모듈 적용 실패")
self.logger.warning("보안 모듈 적용 실패")
hwp.XHwpWindows.Item(0).Visible = True
except Exception as e:
@ -463,7 +505,7 @@ class ReportService:
# 4. 데이터 파싱
raw_title = data.get('title', '')
raw_content = data.get('content', '')
doc_date = str(data.get('date', '')).replace('-', '.')
doc_date, date_clean = self._normalize_report_date(data.get('date', ''))
formatted_content = self.parser.format_voc_content(raw_title, raw_content)
parsed = self._parse_voc_info(raw_title, raw_content, doc_date)
@ -549,11 +591,12 @@ class ReportService:
# 10. 파일명 생성 및 저장
safe_title = re.sub(r'[\\/:*?"<>|\r\n]', "", parsed['pure_title']).strip()
date_clean = doc_date.replace(".", "")
f_train = f"{train_num}열차" if train_num else ""
base_name = f"{line_str_file} {f_train} {set_str} {car_str} {safe_title} 관련".strip()
base_name = re.sub(r'\s+', ' ', base_name)
final_filename = f"{base_name}({date_clean})(VOC).hwp"
final_filename = f"{base_name}({date_clean})(VOC)"
final_filename = re.sub(r'[\\/:*?"<>|\r\n]', "", final_filename).strip(" .")
final_filename = self._ensure_file_extension(final_filename, ".hwp")
# 저장 경로
output_dir = self.config.get('report', {}).get('output_path', '')
@ -582,14 +625,21 @@ class ReportService:
"""PDF 보고서 생성"""
if not HAS_REPORTLAB:
return False, "PDF 생성 모듈(reportlab)이 설치되지 않았습니다.\n(pip install reportlab)"
canvas = importlib.import_module("reportlab.pdfgen.canvas")
page_sizes = importlib.import_module("reportlab.lib.pagesizes")
pdfmetrics = importlib.import_module("reportlab.pdfbase.pdfmetrics")
ttfonts = importlib.import_module("reportlab.pdfbase.ttfonts")
A4 = page_sizes.A4
TTFont = ttfonts.TTFont
try:
date_str = str(data.get('date') or '').replace('-', '')[:8]
if not date_str:
date_str = datetime.now().strftime("%Y%m%d")
_, date_str = self._normalize_report_date(data.get('date') or '')
safe_title = "".join([c for c in data['title'] if c.isalnum() or c in (' ', '(', ')', '[', ']')]).strip()
filename = f"{safe_title}({date_str})(VOC).pdf"
filename = f"{safe_title}({date_str})(VOC)"
filename = re.sub(r'[\\/:*?"<>|\r\n]', "", filename).strip(" .")
filename = self._ensure_file_extension(filename, ".pdf")
# 저장 경로
output_dir = self.config.get('report', {}).get('output_path', '')

View File

@ -165,11 +165,11 @@ class VOCScraper:
target_depts = target_depts if target_depts else self.target_depts
try:
# 0. 목록 페이지인지 확인하고 아니면 이동
if "act=searchList" not in self.page.url or not self._check_data_exists():
if not self._navigate_to_list_via_physical_click():
self.logger.error("목록 페이지 접근 실패. 수집을 중단합니다.")
return {"status": "error", "msg": "목록 페이지 접근 실패"}
# 0. 항상 목록 페이지로 이동하여 최신 상태 확보
# 캐시된 페이지 사용 시 신규 게시글 누락 가능하므로 조건부 체크 제거
if not self._navigate_to_list_via_physical_click():
self.logger.error("목록 페이지 접근 실패. 수집을 중단합니다.")
return {"status": "error", "msg": "목록 페이지 접근 실패"}
# 1. 페이지네이션 정보 확인
total_cnt_ele = self.page.ele("text:page )", timeout=2)
@ -231,9 +231,10 @@ class VOCScraper:
is_public_txt = cols[5].text(strip=True)
is_public = 1 if is_public_txt == '' or is_public_txt == 'O' else 0
# 비공개 게시글도 메타데이터에 포함 (is_public=0으로 마킹)
# 상세 페이지 접근 시 권한 문제 발생 가능하므로 로깅만 수행
if is_public == 0:
self.logger.debug(f"비공개 게시글 생략: {voc_id}")
continue
self.logger.debug(f"비공개 게시글 수집: {voc_id} (상세 조회 스킵 예정)")
status = cols[6].text(strip=True)
@ -253,14 +254,13 @@ class VOCScraper:
post_info["is_related"] = 1 if is_target else 0
# 상세 수집 대상이면 (관심 게시글이거나 공개글이면)
# *여기서는 '상세 수집'을 별도로 하지 않고 목록 정보만 우선 저장*
# 상세 정보는 update_detail 로직이나 필요시점에 호출
# 단, 본문 내용 검색이 필요하다면 여기서 들어가야 함.
# 현재 요구사항은 알림이 중요하므로, 관련성이 있으면 상세를 긁는게 맞음.
# 상세 수집 전략: 관련성 있는 게시글만 상세 조회 시도
# 비공개 게시글은 권한 오류 가능하지만, try-catch로 처리됨
# 차량 등 관련 부서의 경우 is_public 상관없이 시도 (update_detail은 이미 오류처리)
# 리스트 수집 시 상세 내용도 긁고 싶다면:
if is_target: # 관심 대상인 경우 상세까지 긁어서 정확도 높임
if is_target:
# 관심 게시글: 공개/비공개 구분 없이 상세 조회 시도
# 비공개면 fetch_detail_content에서 타임아웃으로 처리됨
detail = self.fetch_detail_content(voc_id)
if detail:
# 상세에서 가져온 더 정확한 정보로 덮어쓰기

View File

@ -1,6 +1,24 @@
{
"config_url": "https://jwt.m1tcloud.cc/config",
"default_environment": "main",
"update_policy": {
"preserve_globs": [
"data/*.sqlite",
"data/*.json",
"data/*.db"
],
"cleanup_backup_on_success": true,
"clean_install_before_copy": true,
"allowed_update_levels": [
"patch",
"minor",
"major"
],
"clean_install_levels": [
"minor",
"major"
]
},
"fallback": {
"supabase_url": "https://kong.m1tcloud.cc",
"anon_key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJvbGUiOiJhbm9uIn0.S_qrtc9owXm1gIrP41rhpmcDMMdXvbufahyObsTFpv0"

View File

@ -10,6 +10,7 @@ Supabase에서 버전 정보를 조회하고 업데이트를 준비합니다.
import json
import logging
import copy
import shutil
import subprocess
import sys
@ -49,6 +50,7 @@ class VersionInfo:
release_note: Optional[str] = None
download_url: Optional[str] = None
min_required_version: Optional[str] = None
update_level: str = "patch"
@dataclass
@ -66,6 +68,10 @@ class UpdateConfig:
target_path: str
version: str
restart_exe: str = "voc_noti.exe"
update_level: str = "patch"
preserve_globs: list[str] | None = None
cleanup_backup_on_success: bool = True
clean_install_before_copy: bool = False
# ============================================================================
@ -74,17 +80,22 @@ class UpdateConfig:
class UpdateError(Exception):
"""업데이트 관련 기본 예외"""
pass
def __init__(self, message: str, code: str = "ERR_UPDATE_UNKNOWN"):
self.message = message
self.code = code
super().__init__(message)
class NetworkError(UpdateError):
"""네트워크 연결 실패"""
pass
def __init__(self, message: str, code: str = "ERR_NETWORK"):
super().__init__(message, code)
class ConfigError(UpdateError):
"""설정 파일 오류"""
pass
def __init__(self, message: str, code: str = "ERR_CONFIG"):
super().__init__(message, code)
def _parse_ssl_verify(value: Any, default: bool = True) -> bool:
@ -299,6 +310,44 @@ def _resolve_connection_config_path(raw_path: str) -> Path:
return module_candidate
def _load_local_update_policy(config_path: Optional[Path]) -> dict[str, Any]:
"""updater/config.json에서 update_policy를 로드합니다."""
if not config_path or not config_path.exists():
return {}
try:
raw = _strip_json_comments(config_path.read_text(encoding="utf-8"))
config = json.loads(raw)
policy = config.get("update_policy", {})
if isinstance(policy, dict):
return policy
except Exception as e:
logger.warning(f"update_policy 로드 실패, 기본값 사용: {e}")
return {}
def _normalize_update_level(value: Any) -> str:
"""update_level 정규화 (patch/minor/major)"""
level = str(value or "patch").strip().lower()
if level not in {"patch", "minor", "major"}:
return "patch"
return level
def _normalize_level_list(values: Any, default: list[str]) -> list[str]:
"""레벨 목록 정규화"""
if not isinstance(values, list):
values = default
normalized = []
for value in values:
level = _normalize_update_level(value)
if level not in normalized:
normalized.append(level)
if not normalized:
normalized = default
return normalized
# ============================================================================
# 버전 비교 유틸리티
# ============================================================================
@ -380,6 +429,11 @@ class UpdateManager:
version_table: str = "program_version",
ssl_verify: bool = True,
ca_bundle_path: str = "",
preserve_globs: Optional[list[str]] = None,
cleanup_backup_on_success: bool = True,
clean_install_before_copy: bool = False,
allowed_update_levels: Optional[list[str]] = None,
clean_install_levels: Optional[list[str]] = None,
):
"""
UpdateManager 초기화
@ -399,9 +453,24 @@ class UpdateManager:
self.version_table = version_table
self.ssl_verify = ssl_verify
self.ca_bundle_path = ca_bundle_path
self.preserve_globs = list(preserve_globs or ["data/*.sqlite", "data/*.json", "data/*.db"])
self.cleanup_backup_on_success = cleanup_backup_on_success
self.clean_install_before_copy = clean_install_before_copy
self.allowed_update_levels = _normalize_level_list(allowed_update_levels, ["patch", "minor", "major"])
self.clean_install_levels = _normalize_level_list(clean_install_levels, ["minor", "major"])
self._latest_version: Optional[VersionInfo] = None
self._stop_flag = threading.Event()
self.last_error_code = ""
self.last_error_message = ""
def _set_last_error(self, code: str, message: str) -> None:
self.last_error_code = code
self.last_error_message = message
def _clear_last_error(self) -> None:
self.last_error_code = ""
self.last_error_message = ""
@property
def config_path(self) -> Path:
@ -436,10 +505,11 @@ class UpdateManager:
None: 업데이트 없음
"""
try:
self._clear_last_error()
if not self.supabase_url:
raise ConfigError("Supabase URL이 비어있습니다.")
raise ConfigError("Supabase URL이 비어있습니다.", code="ERR_SUPABASE_URL_EMPTY")
if not self.supabase_key:
raise ConfigError("Supabase API 키가 비어있습니다.")
raise ConfigError("Supabase API 키가 비어있습니다.", code="ERR_SUPABASE_KEY_EMPTY")
# Supabase REST API 호출
headers = {
@ -490,19 +560,27 @@ class UpdateManager:
return None
latest = data[0]
update_level = _normalize_update_level(latest.get("update_level", "patch"))
version_info = VersionInfo(
version=latest.get("version", ""),
is_stable=latest.get("is_stable", True),
release_note=latest.get("release_note"),
download_url=latest.get("download_url"),
min_required_version=latest.get("min_required_version")
min_required_version=latest.get("min_required_version"),
update_level=update_level,
)
self._latest_version = version_info
if update_level not in self.allowed_update_levels:
logger.info(
f"업데이트 레벨 정책으로 건너뜀: level={update_level} / 허용={self.allowed_update_levels}"
)
return None
# 버전 비교
if compare_versions(self.current_version, version_info.version) < 0:
logger.info(f"새 버전 발견: {version_info.version}")
logger.info(f"새 버전 발견: {version_info.version} (level={update_level})")
return version_info
else:
logger.info(f"최신 버전 사용 중: {self.current_version}")
@ -510,19 +588,21 @@ class UpdateManager:
except requests.exceptions.Timeout:
logger.warning("업데이트 서버 연결 시간 초과")
raise NetworkError("서버 연결 시간 초과")
raise NetworkError("서버 연결 시간 초과", code="ERR_TIMEOUT")
except requests.exceptions.SSLError as e:
logger.warning(f"업데이트 SSL 검증 실패: {e}")
raise NetworkError(
"SSL 인증서 검증에 실패했습니다. 내부망 인증서 사용 시 update.ssl_verify 또는 update.ca_bundle_path 설정을 확인하세요."
"SSL 인증서 검증에 실패했습니다. 내부망 인증서 사용 시 update.ssl_verify 또는 update.ca_bundle_path 설정을 확인하세요.",
code="ERR_SSL_VERIFY",
)
except requests.exceptions.RequestException as e:
logger.warning(f"업데이트 확인 실패: {e}")
raise NetworkError(f"네트워크 오류: {e}")
raise NetworkError(f"네트워크 오류: {e}", code="ERR_NETWORK_REQUEST")
except ConfigError:
raise
except (KeyError, json.JSONDecodeError) as e:
logger.error(f"버전 정보 파싱 오류: {e}")
self._set_last_error("ERR_RESPONSE_PARSE", str(e))
return None
def is_update_available(self) -> bool:
@ -549,8 +629,10 @@ class UpdateManager:
tuple[bool, str]: (성공 여부, 메시지)
"""
try:
self._clear_last_error()
# 다운로드 URL 확인
if not version_info.download_url:
self._set_last_error("ERR_NO_DOWNLOAD_URL", "다운로드 URL이 없습니다.")
return False, "다운로드 URL이 없습니다."
install_dir = self.install_dir
@ -561,7 +643,14 @@ class UpdateManager:
download_url=version_info.download_url,
target_path=str(install_dir),
version=version_info.version,
restart_exe=restart_exe
restart_exe=restart_exe,
update_level=_normalize_update_level(version_info.update_level),
preserve_globs=copy.deepcopy(self.preserve_globs),
cleanup_backup_on_success=self.cleanup_backup_on_success,
clean_install_before_copy=(
self.clean_install_before_copy
or _normalize_update_level(version_info.update_level) in self.clean_install_levels
),
)
temp_config_path = self.config_path.with_suffix(".tmp")
@ -572,6 +661,10 @@ class UpdateManager:
"target_path": config.target_path,
"version": config.version,
"restart_exe": config.restart_exe,
"update_level": config.update_level,
"preserve_globs": config.preserve_globs,
"cleanup_backup_on_success": config.cleanup_backup_on_success,
"clean_install_before_copy": config.clean_install_before_copy,
},
f,
ensure_ascii=False,
@ -584,6 +677,7 @@ class UpdateManager:
# updater.exe 복사
updater_src = install_dir / self.UPDATER_EXE_NAME
if not updater_src.exists():
self._set_last_error("ERR_UPDATER_EXE_NOT_FOUND", f"updater.exe를 찾을 수 없습니다: {updater_src}")
return False, f"updater.exe를 찾을 수 없습니다: {updater_src}"
shutil.copy2(updater_src, self.temp_updater_path)
@ -593,9 +687,11 @@ class UpdateManager:
except PermissionError as e:
logger.error(f"파일 권한 오류: {e}")
self._set_last_error("ERR_PERMISSION", str(e))
return False, f"파일 권한 오류: {e}"
except Exception as e:
logger.error(f"업데이트 준비 실패: {e}")
self._set_last_error("ERR_PREPARE_UNKNOWN", str(e))
return False, str(e)
def launch_updater(self) -> bool:
@ -606,8 +702,10 @@ class UpdateManager:
bool: 실행 성공 여부
"""
try:
self._clear_last_error()
if not self.temp_updater_path.exists():
logger.error(f"updater.exe가 없습니다: {self.temp_updater_path}")
self._set_last_error("ERR_UPDATER_TEMP_NOT_FOUND", str(self.temp_updater_path))
return False
# updater.exe 실행
@ -622,6 +720,7 @@ class UpdateManager:
except Exception as e:
logger.error(f"updater.exe 실행 실패: {e}")
self._set_last_error("ERR_UPDATER_LAUNCH", str(e))
return False
def start_background_check(
@ -692,11 +791,34 @@ def create_update_manager_from_settings(settings: dict) -> UpdateManager:
supabase_key = str(update_settings.get("supabase_key", "")).strip()
ssl_verify_raw = update_settings.get("ssl_verify", None)
ca_bundle_path = str(update_settings.get("ca_bundle_path", "")).strip()
config_path_raw = str(update_settings.get("connection_config_path", "")).strip()
config_path = _resolve_connection_config_path(config_path_raw) if config_path_raw else None
local_policy = _load_local_update_policy(config_path)
preserve_globs = update_settings.get("preserve_globs", local_policy.get("preserve_globs", ["data/*.sqlite", "data/*.json", "data/*.db"]))
if not isinstance(preserve_globs, list):
preserve_globs = ["data/*.sqlite", "data/*.json", "data/*.db"]
preserve_globs = [str(p).strip() for p in preserve_globs if str(p).strip()]
cleanup_backup_on_success = _parse_ssl_verify(
update_settings.get("cleanup_backup_on_success", local_policy.get("cleanup_backup_on_success", True)),
default=True,
)
clean_install_before_copy = _parse_ssl_verify(
update_settings.get("clean_install_before_copy", local_policy.get("clean_install_before_copy", False)),
default=False,
)
allowed_update_levels = _normalize_level_list(
update_settings.get("allowed_update_levels", local_policy.get("allowed_update_levels", ["patch", "minor", "major"])),
["patch", "minor", "major"],
)
clean_install_levels = _normalize_level_list(
update_settings.get("clean_install_levels", local_policy.get("clean_install_levels", ["minor", "major"])),
["minor", "major"],
)
if not supabase_url or not supabase_key:
config_path_raw = update_settings.get("connection_config_path", "")
env_name = update_settings.get("environment")
config_path = _resolve_connection_config_path(config_path_raw) if config_path_raw else None
conn = load_updater_connection_config(config_path=config_path, environment=env_name)
supabase_url = conn["supabase_url"]
supabase_key = conn["supabase_key"]
@ -719,4 +841,9 @@ def create_update_manager_from_settings(settings: dict) -> UpdateManager:
version_table=update_settings.get("version_table", "program_version"),
ssl_verify=ssl_verify,
ca_bundle_path=ca_bundle_path,
preserve_globs=preserve_globs,
cleanup_backup_on_success=cleanup_backup_on_success,
clean_install_before_copy=clean_install_before_copy,
allowed_update_levels=allowed_update_levels,
clean_install_levels=clean_install_levels,
)

View File

@ -1,5 +1,24 @@
# 업데이트 로그
## v3.2.2 (2026-02-19)
- 업데이트 레벨 정책 도입 (`program_versions.update_level`)
- `patch/minor/major` 레벨 파싱
- `allowed_update_levels`에 없는 레벨은 자동 스킵
- `clean_install_levels` 기반 대규모 업데이트 시 클린 설치 자동 적용
- 오류 코드 체계 도입
- `ERR_SSL_VERIFY`, `ERR_TIMEOUT`, `ERR_NETWORK_REQUEST`
- `ERR_UPDATER_EXE_NOT_FOUND`, `ERR_UPDATER_TEMP_NOT_FOUND`, `ERR_UPDATER_LAUNCH`
- `ERR_NO_DOWNLOAD_URL`, `ERR_PERMISSION`, `ERR_PREPARE_UNKNOWN`
- 메인 앱에서 코드 기반 분기 메시지 처리
- 보존 정책 확장
- 기본 보존: `data/*.sqlite`, `data/*.json`, `data/*.db`
- 정책 위치: `app/updater/config.json:update_policy` + `settings.update` 오버라이드
- 롤백/정리 강화
- 실패 시 롤백을 기본 동작으로 고정 (BadZip/권한 오류 포함)
- 성공 시 백업 폴더 자동 정리 옵션 (`cleanup_backup_on_success`)
- 설정 UI 확장
- 시스템 탭에서 `allowed_update_levels`, `clean_install_levels`, `preserve_globs` 편집 가능
## v3.2.1 (2026-02-18)
- 업데이터 연결정보 외부화
- `app/updater/config.json` 도입 (원격 config + fallback)

View File

@ -12,12 +12,13 @@ CustomTkinter 기반으로 다운로드 진행률과 상태를 표시합니다.
import json
import logging
import fnmatch
import os
import shutil
import subprocess
import tempfile
import zipfile
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
@ -52,6 +53,10 @@ class UpdateConfig:
target_path: str
version: str
restart_exe: str = "voc_noti.exe"
update_level: str = "patch"
preserve_globs: list[str] = field(default_factory=lambda: ["data/*.sqlite", "data/*.json", "data/*.db"])
cleanup_backup_on_success: bool = True
clean_install_before_copy: bool = False
# ============================================================================
@ -233,7 +238,11 @@ class UpdaterGUI(ctk.CTk):
download_url=data.get('download_url', ''),
target_path=data.get('target_path', ''),
version=data.get('version', ''),
restart_exe=data.get('restart_exe', 'voc_noti.exe')
restart_exe=data.get('restart_exe', 'voc_noti.exe'),
update_level=str(data.get('update_level', 'patch') or 'patch').strip().lower(),
preserve_globs=[str(p) for p in (data.get('preserve_globs') or ["data/*.sqlite", "data/*.json", "data/*.db"]) if str(p).strip()],
cleanup_backup_on_success=bool(data.get('cleanup_backup_on_success', True)),
clean_install_before_copy=bool(data.get('clean_install_before_copy', False)),
)
if not config.download_url:
@ -243,7 +252,7 @@ class UpdaterGUI(ctk.CTk):
if not config.version:
raise ConfigError("version이 비어 있습니다.")
logger.info(f"설정 로드 완료: 버전 {config.version}")
logger.info(f"설정 로드 완료: 버전 {config.version} (level={config.update_level})")
return config
except json.JSONDecodeError as e:
@ -322,8 +331,50 @@ class UpdaterGUI(ctk.CTk):
self._update_status("설치 중...", "파일 교체 중...")
self._update_progress(0.75)
def _rollback_from_backup() -> None:
if backup_path and backup_path.exists():
logger.info("롤백 중...")
if target_path.exists():
shutil.rmtree(target_path)
shutil.move(str(backup_path), str(target_path))
try:
preserve_patterns = self.config.preserve_globs or []
def _matches_preserve(rel_path: str) -> bool:
rel = rel_path.replace('\\', '/')
return any(fnmatch.fnmatch(rel, pattern.replace('\\', '/')) for pattern in preserve_patterns)
def _restore_preserved_files(backup_root: Path, target_root: Path) -> None:
if not preserve_patterns:
return
restored = 0
for backup_item in backup_root.rglob('*'):
if not backup_item.is_file():
continue
rel = backup_item.relative_to(backup_root).as_posix()
if not _matches_preserve(rel):
continue
dst = target_root / rel
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(backup_item, dst)
restored += 1
if restored:
logger.info(f"보존 파일 복원: {restored}")
def _clean_target_dir(target_root: Path) -> None:
if not target_root.exists():
return
removed = 0
for item in list(target_root.iterdir()):
if item.is_dir():
shutil.rmtree(item)
else:
item.unlink()
removed += 1
logger.info(f"대규모 업데이트 정리 완료: 기존 항목 {removed}개 삭제")
# 백업 폴더 생성
backup_path = target_path.parent / f"{target_path.name}_backup_{self.config.version}"
if backup_path.exists():
@ -353,6 +404,9 @@ class UpdaterGUI(ctk.CTk):
# 대상 경로 생성
target_path.mkdir(parents=True, exist_ok=True)
if self.config.clean_install_before_copy:
_clean_target_dir(target_path)
# 파일 교체
self._update_progress(0.9, "파일 교체 중...")
for item in source_root.iterdir():
@ -361,9 +415,16 @@ class UpdaterGUI(ctk.CTk):
shutil.copytree(item, dst, dirs_exist_ok=True)
else:
shutil.copy2(item, dst)
if backup_path and backup_path.exists():
_restore_preserved_files(backup_path, target_path)
self._update_progress(0.95, "완료 중...")
logger.info(f"압축 해제 완료: {target_path}")
if self.config.cleanup_backup_on_success and backup_path and backup_path.exists():
shutil.rmtree(backup_path)
logger.info("업데이트 백업 폴더 정리 완료")
# 임시 파일 정리
if zip_path.exists():
@ -374,16 +435,17 @@ class UpdaterGUI(ctk.CTk):
return True, "설치 완료"
except zipfile.BadZipFile as e:
_rollback_from_backup()
if extract_dir.exists():
shutil.rmtree(extract_dir, ignore_errors=True)
return False, f"손상된 zip 파일: {e}"
except PermissionError as e:
_rollback_from_backup()
if extract_dir.exists():
shutil.rmtree(extract_dir, ignore_errors=True)
return False, f"파일 권한 오류: {e}"
except Exception as e:
# 롤백
if backup_path and backup_path.exists():
logger.info("롤백 중...")
if target_path.exists():
shutil.rmtree(target_path)
shutil.move(str(backup_path), str(target_path))
_rollback_from_backup()
if extract_dir.exists():
shutil.rmtree(extract_dir, ignore_errors=True)
return False, f"설치 실패: {e}"

View File

@ -61,6 +61,14 @@ class VOCDatabase:
checked_at TIMESTAMP -- 사용자 확인 시점
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS sync_state (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# 2. 마이그레이션: 컬럼 존재 여부 확인 및 추가
cur.execute("PRAGMA table_info(posts)")
@ -248,15 +256,35 @@ class VOCDatabase:
return cur.fetchall()
def get_posts_needing_detail(self, recheck_hours=3):
"""상세 수집이 필요한 게시글 조회 (내용이 없거나 처리중인 최신글)"""
"""
상세 수집이 필요한 게시글 조회
조건:
1. 내용이 없거나 (content IS NULL OR content = '')
2. 또는 마지막 업데이트가 recheck_hours 이상 경과 (updated_at 시간 체크)
우선순위: is_related DESC, id DESC (최신글/관심글 우선)
Args:
recheck_hours: 재검사 간격(시간 단위). 기본값: 3시간
Returns:
list: (id, title, is_related) 튜플 리스트
"""
cur = self.conn.cursor()
# 내용이 없고 공개된 것 우선
# recheck 간격 계산
from datetime import datetime, timedelta
threshold_time = (datetime.now() - timedelta(hours=recheck_hours)).strftime("%Y-%m-%d %H:%M:%S")
cur.execute('''
SELECT id, title, is_related FROM posts
WHERE content IS NULL OR content = ''
ORDER BY is_public DESC, id DESC
WHERE
(content IS NULL OR content = '')
OR (updated_at IS NOT NULL AND updated_at < ?)
ORDER BY is_related DESC, id DESC
LIMIT 10
''')
''', (threshold_time,))
return cur.fetchall()
def get_all_posts(self, limit=500):
@ -275,6 +303,28 @@ class VOCDatabase:
"""DB 연결 종료"""
if self.conn:
self.conn.close()
def get_sync_cursor(self, key: str) -> str:
"""동기화 커서 조회"""
cur = self.conn.cursor()
cur.execute("SELECT value FROM sync_state WHERE key = ?", (key,))
row = cur.fetchone()
return str(row["value"]) if row and row["value"] else ""
def save_sync_cursor(self, key: str, value: str):
"""동기화 커서 저장 (upsert)"""
cur = self.conn.cursor()
cur.execute(
"""
INSERT INTO sync_state (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET
value=excluded.value,
updated_at=CURRENT_TIMESTAMP
""",
(key, value),
)
self.conn.commit()
# ========================================================================
# 통계 쿼리 메서드 (Statistics Queries)

View File

@ -2,10 +2,26 @@ import customtkinter as ctk
from view.theme import theme_manager
class NotificationDialog(ctk.CTkToplevel):
# 미확인 알림 다이얼로그 싱글톤 참조
_unchecked_dialog = None
def __init__(self, controller, title, msg, voc_id=None):
# 미확인 알림(⚠️ 미확인 VOC)인 경우 이미 열려있으면 기존 다이얼로그 앞으로 가져오기
is_unchecked = title.startswith("⚠️ 미확인 VOC")
if is_unchecked and NotificationDialog._unchecked_dialog is not None:
# 기존 다이얼로그를 앞으로 가져옴
NotificationDialog._unchecked_dialog.lift()
NotificationDialog._unchecked_dialog.focus_force()
return
super().__init__(controller.root)
self.controller = controller
self.voc_id = voc_id
self.is_unchecked = is_unchecked
# 미확인 알림이면 싱글톤 참조 저장
if is_unchecked:
NotificationDialog._unchecked_dialog = self
self.title(title)
self.attributes("-topmost", True) # 항상 위
@ -42,7 +58,7 @@ class NotificationDialog(ctk.CTkToplevel):
self.btn_close = ctk.CTkButton(
self.btn_frame, text="닫기", width=100,
fg_color="gray", command=self.destroy
fg_color="gray", command=self._on_close
)
self.btn_close.pack(side="right", padx=5)
@ -65,14 +81,23 @@ class NotificationDialog(ctk.CTkToplevel):
# 포커스 강제
self.lift()
self.focus_force()
# 다이얼로그 종료 시 싱글톤 참조 정리
self.protocol("WM_DELETE_WINDOW", self._on_close)
def _on_view(self):
if self.voc_id:
self.controller.open_list_view(focus_id=self.voc_id)
self.destroy()
self._on_close()
def _on_open_list(self):
self.controller.open_list_view()
self._on_close()
def _on_close(self):
"""다이얼로그 종료 시 싱글톤 참조 정리"""
if self.is_unchecked and NotificationDialog._unchecked_dialog is self:
NotificationDialog._unchecked_dialog = None
self.destroy()
def _center_window(self):

View File

@ -139,6 +139,13 @@ class SettingsDialog(ctk.CTkToplevel):
str(self.controller.settings.get('noti', {}).get('unchecked_check_interval_minutes', 30))
)
ctk.CTkLabel(self.tab_crawl, text="상세 재수집 주기 (시간)", font=font).pack(pady=5)
self.combo_recheck_hours = ctk.CTkComboBox(self.tab_crawl, values=["1", "2", "3", "5", "6", "12", "24"])
self.combo_recheck_hours.pack(pady=5)
self.combo_recheck_hours.set(
str(self.controller.settings.get('crawling', {}).get('recheck_hours', 3))
)
self.unchecked_delay_var = ctk.BooleanVar(
value=self.controller.settings.get('noti', {}).get('unchecked_delay_enabled', True)
)
@ -256,6 +263,78 @@ class SettingsDialog(ctk.CTkToplevel):
text_color="gray"
).pack(anchor="w", padx=45, pady=(5, 0))
ctk.CTkLabel(self.tab_system, text="", height=1, fg_color="gray").pack(fill="x", padx=20, pady=15)
ctk.CTkLabel(self.tab_system, text="업데이트 정책", font=font_bold).pack(pady=(10, 5), anchor="w", padx=20)
update_settings = self.controller.settings.get('update', {})
ctk.CTkLabel(self.tab_system, text="허용 업데이트 등급 (쉼표 구분)", font=theme_manager.get_font(11)).pack(anchor="w", padx=20)
self.entry_allowed_levels = ctk.CTkEntry(self.tab_system)
self.entry_allowed_levels.pack(fill="x", padx=20, pady=(4, 8))
self.entry_allowed_levels.insert(0, ", ".join(update_settings.get('allowed_update_levels', ['patch', 'minor', 'major'])))
ctk.CTkLabel(self.tab_system, text="클린 설치 적용 등급 (쉼표 구분)", font=theme_manager.get_font(11)).pack(anchor="w", padx=20)
self.entry_clean_levels = ctk.CTkEntry(self.tab_system)
self.entry_clean_levels.pack(fill="x", padx=20, pady=(4, 8))
self.entry_clean_levels.insert(0, ", ".join(update_settings.get('clean_install_levels', ['minor', 'major'])))
ctk.CTkLabel(self.tab_system, text="보존 파일 패턴 (쉼표 구분)", font=theme_manager.get_font(11)).pack(anchor="w", padx=20)
self.entry_preserve_globs = ctk.CTkEntry(self.tab_system)
self.entry_preserve_globs.pack(fill="x", padx=20, pady=(4, 8))
self.entry_preserve_globs.insert(0, ", ".join(update_settings.get('preserve_globs', ['data/*.sqlite', 'data/*.json', 'data/*.db'])))
self.cleanup_backup_var = ctk.BooleanVar(value=bool(update_settings.get('cleanup_backup_on_success', True)))
ctk.CTkSwitch(
self.tab_system,
text="업데이트 성공 시 백업 폴더 자동 정리",
variable=self.cleanup_backup_var,
).pack(anchor="w", padx=20, pady=(4, 6))
self.clean_install_var = ctk.BooleanVar(value=bool(update_settings.get('clean_install_before_copy', True)))
ctk.CTkSwitch(
self.tab_system,
text="업데이트 전 기존 파일 정리(잔존 라이브러리 간섭 방지)",
variable=self.clean_install_var,
).pack(anchor="w", padx=20, pady=(0, 6))
ctk.CTkLabel(
self.tab_system,
text="※ major/minor 업데이트는 기본적으로 클린 설치 권장",
font=theme_manager.get_font(10),
text_color="gray"
).pack(anchor="w", padx=45, pady=(0, 0))
ctk.CTkLabel(self.tab_system, text="", height=1, fg_color="gray").pack(fill="x", padx=20, pady=15)
ctk.CTkLabel(self.tab_system, text="Supabase 동기화", font=font_bold).pack(pady=(10, 5), anchor="w", padx=20)
sync_settings = self.controller.settings.get('sync', {})
self.sync_enabled_var = ctk.BooleanVar(value=bool(sync_settings.get('enabled', False)))
ctk.CTkSwitch(
self.tab_system,
text="크롤링 대신 Supabase 동기화 사용",
variable=self.sync_enabled_var,
).pack(anchor="w", padx=20, pady=(4, 6))
ctk.CTkLabel(self.tab_system, text="동기화 주기(분)", font=theme_manager.get_font(11)).pack(anchor="w", padx=20)
self.combo_sync_interval = ctk.CTkComboBox(self.tab_system, values=["1", "3", "5", "10", "30"])
self.combo_sync_interval.pack(fill="x", padx=20, pady=(4, 8))
self.combo_sync_interval.set(str(sync_settings.get('pull_interval_minutes', 3)))
ctk.CTkLabel(self.tab_system, text="동기화 대상 테이블", font=theme_manager.get_font(11)).pack(anchor="w", padx=20)
self.entry_sync_table = ctk.CTkEntry(self.tab_system)
self.entry_sync_table.pack(fill="x", padx=20, pady=(4, 8))
self.entry_sync_table.insert(0, str(sync_settings.get('table', 'posts')))
ctk.CTkLabel(
self.tab_system,
text="※ URL/KEY는 update 연결정보를 재사용합니다(기본).",
font=theme_manager.get_font(10),
text_color="gray"
).pack(anchor="w", padx=45, pady=(0, 0))
ctk.CTkButton(self.tab_system, text="설정 저장", command=self.save_all).pack(pady=20)
def _select_report_folder(self):
@ -335,6 +414,7 @@ class SettingsDialog(ctk.CTkToplevel):
self.controller.settings['login']['id'] = self.entry_id.get()
self.controller.settings['login']['pw'] = self.entry_pw.get()
self.controller.settings['crawling']['interval_minutes'] = int(self.combo_interval.get())
self.controller.settings['crawling']['recheck_hours'] = int(self.combo_recheck_hours.get())
self.controller.settings['crawling']['keywords'] = [k.strip() for k in self.entry_crawl_kw.get().split(',') if k.strip()]
self.controller.settings['crawling']['target_depts'] = [k.strip() for k in self.entry_dept.get().split(',') if k.strip()]
@ -360,6 +440,38 @@ class SettingsDialog(ctk.CTkToplevel):
self.controller.settings['report'] = {}
self.controller.settings['report']['output_path'] = self.report_path_label.cget("text")
if 'update' not in self.controller.settings:
self.controller.settings['update'] = {}
allowed_levels = [v.strip().lower() for v in self.entry_allowed_levels.get().split(',') if v.strip()]
clean_levels = [v.strip().lower() for v in self.entry_clean_levels.get().split(',') if v.strip()]
preserve_globs = [v.strip() for v in self.entry_preserve_globs.get().split(',') if v.strip()]
valid_levels = {"patch", "minor", "major"}
allowed_levels = [v for v in allowed_levels if v in valid_levels] or ["patch", "minor", "major"]
clean_levels = [v for v in clean_levels if v in valid_levels] or ["minor", "major"]
preserve_globs = preserve_globs or ["data/*.sqlite", "data/*.json", "data/*.db"]
self.controller.settings['update']['allowed_update_levels'] = allowed_levels
self.controller.settings['update']['clean_install_levels'] = clean_levels
self.controller.settings['update']['preserve_globs'] = preserve_globs
self.controller.settings['update']['cleanup_backup_on_success'] = self.cleanup_backup_var.get()
self.controller.settings['update']['clean_install_before_copy'] = self.clean_install_var.get()
if 'sync' not in self.controller.settings:
self.controller.settings['sync'] = {}
self.controller.settings['sync']['enabled'] = self.sync_enabled_var.get()
self.controller.settings['sync']['pull_interval_minutes'] = int(self.combo_sync_interval.get())
self.controller.settings['sync']['table'] = self.entry_sync_table.get().strip() or 'posts'
self.controller.settings['sync'].setdefault('pull_batch_size', 500)
self.controller.settings['sync'].setdefault('timeout_sec', 10)
self.controller.settings['sync'].setdefault('use_update_connection', True)
self.controller.settings['sync'].setdefault('supabase_url', '')
self.controller.settings['sync'].setdefault('supabase_key', '')
self.controller.settings['sync'].setdefault('ssl_verify', True)
self.controller.settings['sync'].setdefault('ca_bundle_path', '')
self.controller.save_settings()
self.controller.update_config_runtime()

View File

@ -249,3 +249,67 @@ python app/setup.py build
추가 스모크 검증:
- 빌드된 `updater.exe`를 개발 경로에 복사 후 `prepare_update()` 실행
- 결과: `PREPARE True` / `%TEMP%/updater.exe` 존재 확인
---
## 10. 업데이트 레벨/보존/클린설치 정책 (2026-02-19)
### 10.1 update_level 정책
- Supabase `program_versions.update_level`을 사용 (`patch`, `minor`, `major`)
- `settings.update.allowed_update_levels`에 없는 레벨은 업데이트 후보에서 스킵
- `settings.update.clean_install_levels`에 포함된 레벨은 클린 설치 강제
기본값:
```json
{
"allowed_update_levels": ["patch", "minor", "major"],
"clean_install_levels": ["minor", "major"]
}
```
### 10.2 보존 정책
- 기본 보존 대상(glob): `data/*.sqlite`, `data/*.json`, `data/*.db`
- 정책 소스 우선순위:
1) `settings.update.*`
2) `app/updater/config.json:update_policy`
### 10.3 대규모 업데이트 간섭 대응
- `clean_install_before_copy=true`면 대상 폴더를 먼저 정리 후 신규 파일 복사
- 목적: 구버전 잔존 DLL/라이브러리 간섭 제거
### 10.4 롤백 기본 동작
- 실패 시 롤백은 기본 동작 (손상 zip, 권한 오류, 예기치 못한 예외 포함)
- 성공 시 `cleanup_backup_on_success=true`면 백업 폴더 자동 삭제
### 10.5 시나리오 소거 체크리스트
- [x] 손상 zip 파일 → 설치 실패 + 롤백
- [x] 파일 권한 부족 → 설치 실패 + 롤백
- [x] 대규모 업데이트 잔존 파일 간섭 → 클린 설치로 제거
- [x] 사용자 데이터 덮어쓰기 위험 → 보존 패턴 복원
- [x] 정책 미스매치(update_level 미허용) → 업데이트 스킵 + 로그
- [x] 미정의 예외 → 포괄 예외 처리 + 실패 메시지 + 롤백
### 10.6 메인 앱 연동 포인트
- 백그라운드 체크 실패는 `NetworkError/ConfigError`로 메인 컨트롤러 콜백 전달
- 업데이트 안내 시 레벨(update_level) 기반 사용자 정책을 UI 메시지로 표기 가능
### 10.7 오류 코드 분기 처리
- UpdateManager 오류 코드 예시:
- 네트워크/인증: `ERR_SSL_VERIFY`, `ERR_TIMEOUT`, `ERR_NETWORK_REQUEST`
- 준비/실행: `ERR_NO_DOWNLOAD_URL`, `ERR_UPDATER_EXE_NOT_FOUND`, `ERR_UPDATER_TEMP_NOT_FOUND`, `ERR_UPDATER_LAUNCH`, `ERR_PERMISSION`, `ERR_PREPARE_UNKNOWN`
- 메인 앱(`Controller`)은 코드 기반으로 사용자 안내 문구를 분기 처리함
### 10.8 설정 UI 반영
- 설정 > 시스템 탭에서 아래 정책을 직접 편집 가능:
- `allowed_update_levels`
- `clean_install_levels`
- `preserve_globs`

View File

@ -0,0 +1,113 @@
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
ROOT_DIR = Path(__file__).resolve().parents[1]
APP_DIR = ROOT_DIR / "app"
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
if str(APP_DIR) not in sys.path:
sys.path.insert(0, str(APP_DIR))
from view.dialogs.settings_dialog import SettingsDialog
class _DummyField:
def __init__(self, value: str):
self._value = value
def get(self):
return self._value
class _DummyVar:
def __init__(self, value):
self._value = value
def get(self):
return self._value
class _DummyLabel:
def __init__(self, text: str):
self._text = text
def cget(self, key: str):
if key == "text":
return self._text
return ""
class TestSettingsDialogUpdatePolicy(unittest.TestCase):
def test_save_all_persists_update_policy_fields(self):
controller = SimpleNamespace()
controller.settings = {
"login": {"id": "old", "pw": "old"},
"crawling": {"interval_minutes": 10, "recheck_hours": 3, "keywords": [], "target_depts": []},
"noti": {"db_check_interval_minutes": 3, "unchecked_check_interval_minutes": 10, "unchecked_delay_enabled": True},
"theme": "Dark",
"report": {"output_path": "D:/reports"},
"update": {},
}
controller.save_settings_called = 0
controller.update_runtime_called = 0
def _save_settings():
controller.save_settings_called += 1
def _update_runtime():
controller.update_runtime_called += 1
controller.save_settings = _save_settings
controller.update_config_runtime = _update_runtime
dialog = object.__new__(SettingsDialog)
dialog.controller = controller
dialog.entry_id = _DummyField("116696")
dialog.entry_pw = _DummyField("pw")
dialog.combo_interval = _DummyField("10")
dialog.combo_recheck_hours = _DummyField("6")
dialog.entry_crawl_kw = _DummyField("1호선, 서면")
dialog.entry_dept = _DummyField("차량")
dialog.combo_noti_interval = _DummyField("3")
dialog.combo_unchecked_interval = _DummyField("10")
dialog.unchecked_delay_var = _DummyVar(True)
dialog.combo_theme = _DummyField("Dark")
dialog.sound_var = _DummyVar(True)
dialog.use_related_filter_var = _DummyVar(True)
dialog.report_path_label = _DummyLabel("D:/reports")
dialog.entry_allowed_levels = _DummyField("patch, major, wrong")
dialog.entry_clean_levels = _DummyField("minor, major")
dialog.entry_preserve_globs = _DummyField("data/*.sqlite, data/*.json, data/*.db")
dialog.cleanup_backup_var = _DummyVar(True)
dialog.clean_install_var = _DummyVar(True)
dialog.sync_enabled_var = _DummyVar(True)
dialog.combo_sync_interval = _DummyField("5")
dialog.entry_sync_table = _DummyField("posts")
dialog.destroy = lambda: None
with patch("view.dialogs.settings_dialog.theme_manager.set_theme", lambda *_args, **_kwargs: None):
SettingsDialog.save_all(dialog)
crawling_settings = controller.settings["crawling"]
self.assertEqual(crawling_settings["recheck_hours"], 6)
update_settings = controller.settings["update"]
self.assertEqual(update_settings["allowed_update_levels"], ["patch", "major"])
self.assertEqual(update_settings["clean_install_levels"], ["minor", "major"])
self.assertEqual(update_settings["preserve_globs"], ["data/*.sqlite", "data/*.json", "data/*.db"])
self.assertTrue(update_settings["cleanup_backup_on_success"])
self.assertTrue(update_settings["clean_install_before_copy"])
sync_settings = controller.settings["sync"]
self.assertTrue(sync_settings["enabled"])
self.assertEqual(sync_settings["pull_interval_minutes"], 5)
self.assertEqual(sync_settings["table"], "posts")
self.assertEqual(controller.save_settings_called, 1)
self.assertEqual(controller.update_runtime_called, 1)
if __name__ == "__main__":
unittest.main()

View File

@ -4,6 +4,7 @@ import tempfile
import unittest
from pathlib import Path
from unittest.mock import PropertyMock, patch
import requests
ROOT_DIR = Path(__file__).resolve().parents[1]
if str(ROOT_DIR) not in sys.path:
@ -105,6 +106,19 @@ class TestUpdateManager(unittest.TestCase):
self.assertEqual(conn["supabase_url"], "https://kong.m1tcloud.cc")
self.assertEqual(conn["supabase_key"], "remote-key")
def test_check_for_updates_raises_ssl_error_code(self):
manager = UpdateManager(
supabase_url="https://kong.m1tcloud.cc",
supabase_key="dummy-key",
current_version="3.0.0",
)
with patch("app.updater.update_manager.requests.get", side_effect=requests.exceptions.SSLError("ssl fail")):
with self.assertRaises(updater_manager.NetworkError) as ctx:
manager.check_for_updates()
self.assertEqual(getattr(ctx.exception, "code", ""), "ERR_SSL_VERIFY")
def test_create_update_manager_from_settings_reads_connection_config(self):
with tempfile.TemporaryDirectory() as td:
cfg = Path(td) / "config.json"
@ -207,6 +221,42 @@ class TestUpdateManager(unittest.TestCase):
self.assertFalse(ok)
self.assertIn("updater.exe", message)
self.assertEqual(manager.last_error_code, "ERR_UPDATER_EXE_NOT_FOUND")
def test_launch_updater_sets_error_code_when_temp_exe_missing(self):
with tempfile.TemporaryDirectory() as td:
manager = UpdateManager(
supabase_url="https://example.supabase.co",
supabase_key="dummy-key",
)
missing_temp = Path(td) / "missing_updater.exe"
with patch.object(
UpdateManager,
"temp_updater_path",
new_callable=PropertyMock,
return_value=missing_temp,
):
ok = manager.launch_updater()
self.assertFalse(ok)
self.assertEqual(manager.last_error_code, "ERR_UPDATER_TEMP_NOT_FOUND")
def test_prepare_update_fails_when_download_url_missing(self):
manager = UpdateManager(
supabase_url="https://example.supabase.co",
supabase_key="dummy-key",
)
version_info = VersionInfo(
version="9.9.9",
is_stable=True,
download_url=None,
)
ok, message = manager.prepare_update(version_info)
self.assertFalse(ok)
self.assertIn("다운로드 URL", message)
self.assertEqual(manager.last_error_code, "ERR_NO_DOWNLOAD_URL")
def test_prepare_update_writes_config_and_copies_updater(self):
with tempfile.TemporaryDirectory() as td:
@ -247,6 +297,39 @@ class TestUpdateManager(unittest.TestCase):
config_data = json.loads(config_path.read_text(encoding="utf-8"))
self.assertEqual(config_data["version"], "9.9.9")
self.assertEqual(config_data["download_url"], "https://example.com/update.zip")
self.assertIn("preserve_globs", config_data)
self.assertIn("data/*.json", config_data["preserve_globs"])
self.assertTrue(config_data["cleanup_backup_on_success"])
def test_check_for_updates_skips_disallowed_update_level(self):
manager = UpdateManager(
supabase_url="https://kong.m1tcloud.cc",
supabase_key="dummy-key",
current_version="3.0.0",
allowed_update_levels=["patch"],
)
class _Resp200:
status_code = 200
def raise_for_status(self):
return None
def json(self):
return [
{
"version": "4.0.0",
"is_stable": True,
"update_level": "major",
"release_note": "major release",
"download_url": "https://example.com/update.zip",
}
]
with patch("app.updater.update_manager.requests.get", return_value=_Resp200()):
info = manager.check_for_updates()
self.assertIsNone(info)
def test_prepare_update_returns_permission_error_when_copy_fails(self):
with tempfile.TemporaryDirectory() as td:
@ -346,6 +429,7 @@ class TestUpdaterGUI(unittest.TestCase):
root = Path(td)
target_dir = root / "app_dir"
target_dir.mkdir(parents=True, exist_ok=True)
(target_dir / "old.txt").write_text("old", encoding="utf-8")
bad_zip = root / "bad.zip"
bad_zip.write_text("not-a-zip", encoding="utf-8")
@ -360,6 +444,7 @@ class TestUpdaterGUI(unittest.TestCase):
ok, message = gui._extract_and_replace(bad_zip)
self.assertFalse(ok)
self.assertIn("손상된 zip", message)
self.assertTrue((target_dir / "old.txt").exists())
def test_extract_and_replace_rolls_back_on_copy_error(self):
gui = build_updater_gui_stub()
@ -394,6 +479,54 @@ class TestUpdaterGUI(unittest.TestCase):
self.assertIn("denied", message)
self.assertTrue((target_dir / "old.txt").exists())
def test_extract_and_replace_preserves_settings_and_db_on_clean_install(self):
gui = build_updater_gui_stub()
with tempfile.TemporaryDirectory() as td:
root = Path(td)
target_dir = root / "app_dir"
data_dir = target_dir / "data"
data_dir.mkdir(parents=True, exist_ok=True)
(data_dir / "settings.json").write_text('{"k":"old"}', encoding="utf-8")
(data_dir / "voc.db").write_text("old-db", encoding="utf-8")
(target_dir / "stale_lib.dll").write_text("stale", encoding="utf-8")
zip_file = root / "update.zip"
source_dir = root / "zip_source"
(source_dir / "data").mkdir(parents=True, exist_ok=True)
(source_dir / "app.exe").write_text("new-app", encoding="utf-8")
(source_dir / "data" / "settings.json").write_text('{"k":"new"}', encoding="utf-8")
(source_dir / "data" / "voc.db").write_text("new-db", encoding="utf-8")
import zipfile
with zipfile.ZipFile(zip_file, "w") as zf:
zf.write(source_dir / "app.exe", arcname="app.exe")
zf.write(source_dir / "data" / "settings.json", arcname="data/settings.json")
zf.write(source_dir / "data" / "voc.db", arcname="data/voc.db")
gui.config = UpdateConfig(
download_url="https://example.com/update.zip",
target_path=str(target_dir),
version="9.9.9",
restart_exe="voc_noti.exe",
preserve_globs=["data/*.sqlite", "data/*.json", "data/*.db"],
cleanup_backup_on_success=True,
clean_install_before_copy=True,
)
ok, message = gui._extract_and_replace(zip_file)
self.assertTrue(ok, message)
self.assertTrue((target_dir / "app.exe").exists())
self.assertFalse((target_dir / "stale_lib.dll").exists())
self.assertEqual((data_dir / "settings.json").read_text(encoding="utf-8"), '{"k":"old"}')
self.assertEqual((data_dir / "voc.db").read_text(encoding="utf-8"), "old-db")
backup_dir = root / "app_dir_backup_9.9.9"
self.assertFalse(backup_dir.exists())
if __name__ == "__main__":
unittest.main()