VOC_Monitor/docs/api_contract.md

592 lines
23 KiB
Markdown

# 🤝 API & Module Contract
이 문서는 모듈 간의 통신 규약과 데이터 규격을 정의합니다.
모든 AI 에이전트는 코드를 생성하거나 수정하기 전 본 문서를 참조하여 인터페이스의 정합성을 유지해야 합니다.
---
## 1. 데이터 모델 (Pydantic Schemas)
### 1.1 VOCPost
**위치**: `app/models/model.py`
```python
class VOCPost(BaseModel):
id: str # 게시글 ID (Primary Key)
title: str # 제목
writer: str # 작성자
department: str # 담당 부서 (예: "차량", "시설")
date: str # 작성일 (YYYY-MM-DD HH:MM:SS)
status: str # 처리 상태 (예: "접수", "처리중", "완료")
channel: str # 접수 경로 (예: "인터넷", "전화")
is_public: int # 공개 여부 (0: 비공개, 1: 공개)
content: Optional[str] # 상세 내용 (초기엔 None, 상세 조회 후 채워짐)
attachment: Optional[str] # 첨부파일명
is_related: int = 0 # 관심글 여부 (0: 일반, 1: 관심)
```
**사용처**:
- `VOCScraper`: 크롤링 결과 → VOCPost 변환
- `AppController`: 비즈니스 로직 적용 (is_related 판별)
- `VOCDatabase`: DB 저장/조회
### 1.2 StatisticsReport (신규)
**위치**: `app/models/statistics.py`
```python
class StatisticsReport(BaseModel):
"""
VOC 통계 분석 보고서 데이터 모델
Attributes:
period_start (str): 분석 기간 시작일 (YYYY-MM-DD)
period_end (str): 분석 기간 종료일 (YYYY-MM-DD)
total_count (int): 총 VOC 건수
by_department (dict): 부서별 통계 {부서명: 건수}
by_status (dict): 상태별 통계 {상태: 건수}
by_date (dict): 날짜별 통계 {날짜: 건수}
top_keywords (list): 빈출 키워드 TOP 10 [(키워드, 빈도), ...]
charts (dict): 차트 이미지 경로 {차트명: 파일경로}
generated_at (str): 보고서 생성 시각 (YYYY-MM-DD HH:MM:SS)
"""
period_start: str = Field(..., description="분석 기간 시작일 (YYYY-MM-DD)")
period_end: str = Field(..., description="분석 기간 종료일 (YYYY-MM-DD)")
total_count: int = Field(0, description="총 VOC 건수", ge=0)
by_department: dict = Field(default_factory=dict, description="부서별 통계")
by_status: dict = Field(default_factory=dict, description="상태별 통계")
by_date: dict = Field(default_factory=dict, description="날짜별 통계")
top_keywords: list = Field(default_factory=list, description="빈출 키워드 TOP 10")
charts: dict = Field(default_factory=dict, description="차트 이미지 경로")
generated_at: Optional[str] = Field(None, description="보고서 생성 시각")
@validator('period_start', 'period_end')
def validate_date_format(cls, v):
"""날짜 형식 검증 (YYYY-MM-DD)"""
import re
from datetime import datetime
pattern = r'^\d{4}-\d{2}-\d{2}$'
if not re.match(pattern, v):
raise ValueError(f"날짜 형식이 잘못되었습니다: {v} (YYYY-MM-DD 형식 필요)")
try:
datetime.strptime(v, "%Y-%m-%d")
except ValueError as e:
raise ValueError(f"유효하지 않은 날짜입니다: {v}")
return v
@root_validator
def validate_period(cls, values):
"""기간 유효성 검증"""
from datetime import datetime, timedelta
start = values.get('period_start')
end = values.get('period_end')
if start and end:
start_date = datetime.strptime(start, "%Y-%m-%d")
end_date = datetime.strptime(end, "%Y-%m-%d")
# 시작일이 종료일보다 늦으면 에러
if start_date > end_date:
raise ValueError("시작일이 종료일보다 늦을 수 없습니다.")
# 기간이 1년을 초과하면 에러
if (end_date - start_date).days > 365:
raise ValueError("분석 기간은 최대 1년까지만 가능합니다.")
return values
```
**사용처**:
- `StatisticsService`: 통계 데이터 집계 및 보고서 생성
- `StatisticsDialog`: UI에서 보고서 미리보기
- `AppController`: 보고서 생성 요청 처리
### 1.3 StatisticsOptions (신규)
**위치**: `app/models/statistics.py`
```python
class StatisticsOptions(BaseModel):
"""
통계 분석 옵션 모델
Attributes:
period_start (str): 분석 기간 시작일
period_end (str): 분석 기간 종료일
include_department (bool): 부서별 통계 포함 여부
include_status (bool): 상태별 통계 포함 여부
include_keywords (bool): 키워드 분석 포함 여부
include_charts (bool): 차트 생성 여부
output_format (str): 출력 형식 ("excel" | "pdf" | "both")
output_path (str): 저장 경로
"""
period_start: str
period_end: str
include_department: bool = True
include_status: bool = True
include_keywords: bool = True
include_charts: bool = True
output_format: str = Field("excel", pattern="^(excel|pdf|both)$")
output_path: Optional[str] = None
```
---
## 2. 모듈 간 인터페이스
### 2.1 Controller → Manager (위임 패턴)
**Controller 리팩토링 (v3.0)** - AppController는 이제 Manager들에게 작업을 위임합니다.
| 호출자 | 메서드 | 입력 | 출력 | 설명 |
|--------|--------|------|------|------|
| `AppController` | `report_manager.request_create_report(data, parent)` | `dict, Widget` | `None` | 보고서 생성 요청 (ReportManager 위임) |
| `AppController` | `file_manager.open_attachment(data, parent)` | `dict, Widget` | `None` | 첨부파일 열기 (FileManager 위임) |
| `AppController` | `ui_manager.open_list_view(focus_id)` | `str` (선택) | `None` | 히스토리 창 열기 (UIManager 위임) |
| `AppController` | `ui_manager.open_settings(icon, item)` | `any, any` | `None` | 설정 창 열기 (UIManager 위임) |
| `AppController` | `ui_manager.request_detail_popup(voc_id)` | `str` | `None` | 상세 팝업 열기 (UIManager 위임) |
| `AppController` | `scheduler.run_crawling_cycle()` | 없음 | 없음 | 크롤링 사이클 실행 (SchedulerManager 위임) |
| `AppController` | `notifier.show_popup(title, msg, voc_id)` | `str, str, str` | 없음 | 팝업 알림 표시 (NotificationManager 위임) |
| `AppController` | `update_manager.check_for_updates()` | 없음 | `Optional[VersionInfo]` | 업데이트 확인 |
| `AppController` | `update_manager.prepare_update(version_info)` | `VersionInfo` | `tuple[bool, str]` | 업데이트 실행 준비 |
| `AppController` | `update_manager.launch_updater()` | 없음 | `bool` | updater.exe 실행 |
### 2.2 Controller ↔ Service
| 호출자 | 메서드 | 입력 | 출력 | 설명 |
|--------|--------|------|------|------|
| `AppController` | `ReportService.create_hwp_report(data)` | `dict` (VOC 데이터) | `tuple[str, str]` | HWP 보고서 생성 |
| `AppController` | `ReportService.create_pdf_report(data)` | `dict` | `tuple[bool, str]` | PDF 보고서 생성 |
| `AppController` | `ReportService.print_voc_detail(data)` | `dict` | `tuple[bool, str]` | 프린터 출력 |
| `AppController` | `VOCScraper.fetch_list_pages(max_pg, kw, dept)` | `int, list, list` | `dict` | 목록 페이지 크롤링 |
| `AppController` | `VOCScraper.fetch_detail_content(voc_id)` | `str` | `dict` | 상세 내용 크롤링 |
### 2.2 Service ↔ Utils
| 호출자 | 메서드 | 입력 | 출력 | 설명 |
|--------|--------|------|------|------|
| `ReportService` | `VOCParser.parse_basic_info(text)` | `str` | `dict` | 호선, 편성, 호차, 열차번호 추출 |
| `ReportService` | `VOCParser.infer_line_and_direction(text)` | `str` | `tuple[str, str, str]` | 호선, 방향, 역명 유추 |
| `ReportService` | `DateScheduleUtils.parse_schedule_type(text, date)` | `str, str` | `tuple[str, str]` | 날짜 및 스케줄 타입 판별 |
| `ReportService` | `TrainAnalyzer.classify_train_details(num, dir)` | `str, str` | `dict` | 열차 종별/방향 분석 |
| `ReportService` | `TrainAnalyzer.get_schedule_remarks(...)` | `dict, str, date` | `str` | 입고 정보 비고 생성 |
| `ReportService` | `TimetableService.find_train_by_time(...)` | `str, str, str, str` | `dict` | 시각표 기반 열차 검색 |
### 2.3 Controller ↔ StatisticsService (신규)
| 호출자 | 메서드 | 입력 | 출력 | 설명 |
|--------|--------|------|------|------|
| `AppController` | `StatisticsService.generate_report(options)` | `StatisticsOptions` | `tuple[bool, str]` | 통계 보고서 생성 |
| `AppController` | `StatisticsService.get_stats_summary(start, end)` | `str, str` | `StatisticsReport` | 통계 요약 조회 |
| `AppController` | `StatisticsService.export_to_excel(report, path)` | `StatisticsReport, str` | `tuple[bool, str]` | Excel 파일 생성 |
| `AppController` | `StatisticsService.export_to_pdf(report, path)` | `StatisticsReport, str` | `tuple[bool, str]` | PDF 파일 생성 |
### 2.4 StatisticsService ↔ Database (신규)
| 호출자 | 메서드 | 입력 | 출력 | 설명 |
|--------|--------|------|------|------|
| `StatisticsService` | `VOCDatabase.get_stats_by_period(start, end)` | `str, str` | `list[dict]` | 기간별 통계 조회 |
| `StatisticsService` | `VOCDatabase.get_stats_by_department(start, end)` | `str, str` | `list[dict]` | 부서별 통계 조회 |
| `StatisticsService` | `VOCDatabase.get_stats_by_status(start, end)` | `str, str` | `list[dict]` | 상태별 통계 조회 |
| `StatisticsService` | `VOCDatabase.get_all_texts_in_period(start, end)` | `str, str` | `list[str]` | 키워드 분석용 텍스트 조회 |
### 2.5 StatisticsService ↔ ChartGenerator (신규)
| 호출자 | 메서드 | 입력 | 출력 | 설명 |
|--------|--------|------|------|------|
| `StatisticsService` | `ChartGenerator.create_bar_chart(data, title)` | `dict, str` | `str` | 막대 그래프 생성 (파일 경로 반환) |
| `StatisticsService` | `ChartGenerator.create_line_chart(data, title)` | `dict, str` | `str` | 선 그래프 생성 |
| `StatisticsService` | `ChartGenerator.create_pie_chart(data, title)` | `dict, str` | `str` | 파이 차트 생성 |
| `StatisticsService` | `ChartGenerator.create_wordcloud(texts, title)` | `list[str], str` | `str` | 워드 클라우드 생성 |
---
## 3. 반환값 규약
### 3.1 성공/실패 패턴
**기본 패턴**: `tuple[bool, str]`
- `(True, "성공 메시지")`: 성공
- `(False, "에러 메시지")`: 실패
**예외**: `ReportService.create_hwp_report()`
- `("FILE_EXISTS", file_path)`: 기존 파일 존재
- `(True, message)`: 정상 생성
- `(False, error_message)`: 생성 실패
### 3.2 크롤링 결과 패턴
```python
{
"status": "success" | "session_expired" | "error",
"data": [VOCPost, ...], # status == "success"
"error": str # status == "error"
}
```
---
## 4. 예외 처리 규약
### 4.1 예외 계층 구조
```
VOCMonitorError (기본)
├── DataError
│ ├── DataValidationError
│ └── DatabaseError
├── ScraperError
│ ├── LoginFailedError
│ ├── SessionExpiredError
│ └── PageLoadError
├── ReportError
│ ├── ReportGenerationError
│ ├── TemplateNotFoundError
│ ├── TrainInfoNotFoundError (부분 실패)
│ └── TimetableServiceError (부분 실패)
├── SystemError
│ ├── ConfigurationError
│ └── DependencyError
└── UIError
└── DialogCancelledError (사용자 취소)
```
### 4.2 부분 실패 예외
다음 예외는 `is_partial_failure = True` 속성을 가지며, 전체 작업을 중단하지 않습니다:
- `TrainInfoNotFoundError`
- `TimetableServiceError`
**처리 방법**:
```python
try:
train_info = get_train_info(train_number)
except TrainInfoNotFoundError as e:
if is_partial_failure(e):
logger.warning(f"부분 실패: {e.message}")
train_info = None # 기본값 사용
else:
raise # 전체 중단
```
### 4.3 에러 코드 체계
| 코드 | 설명 | 로그 레벨 |
|------|------|-----------|
| `DATA_VALIDATION_ERROR` | 데이터 검증 실패 | ERROR |
| `DATABASE_ERROR` | DB 작업 실패 | ERROR |
| `LOGIN_FAILED` | 로그인 실패 | ERROR |
| `SESSION_EXPIRED` | 세션 만료 | WARNING |
| `TRAIN_INFO_NOT_FOUND` | 열차 정보 미조회 | WARNING |
| `TIMETABLE_SERVICE_ERROR` | 시각표 조회 실패 | WARNING |
| `TEMPLATE_NOT_FOUND` | 템플릿 파일 없음 | ERROR |
| `DIALOG_CANCELLED` | 사용자 취소 | INFO |
---
## 5. 설정 파일 규약 (settings.json)
### 5.1 구조
```json
{
"login": {
"id": "user_id",
"pw": "password"
},
"crawling": {
"interval_minutes": 10,
"max_pages": 2,
"target_depts": ["차량"],
"keywords": ["지연", "불만"],
"filter_mode": "OR",
"recheck_hours": 3,
"headless_mode": true,
"max_retries": 3,
"retry_delay": 2
},
"noti": {
"sound": true,
"db_check_interval_minutes": 3,
"unchecked_check_interval_minutes": 10,
"unchecked_delay_enabled": true,
"use_related_filter": true
},
"update": {
"connection_config_path": "app/updater/config.json",
"environment": "main",
"check_interval_hours": 1,
"program_id": "voc_monitor",
"version_table": "program_versions"
},
"report": {
"output_path": "D:/Reports"
},
"report_options": {
"office": "신평차량사업소",
"team": "검수1",
"reporter_name": "홍길동",
"reporter_tel": "051-1234"
},
"master_data": {
"stations_L1": ["서면", "부산역", "노포", ...],
"office_tel_map": {
"신평차량사업소": "051-1111",
"노포차량사업소": "051-2222"
}
}
}
```
### 5.2 설정 항목 설명
| 설정 항목 | 타입 | 기본값 | 설명 |
|-----------|------|--------|------|
| `crawling.interval_minutes` | int | 10 | 크롤링 주기 (분) |
| `crawling.max_pages` | int | 2 | 최대 수집 페이지 수 |
| `crawling.target_depts` | list | ["차량"] | 관심 부서 목록 |
| `crawling.keywords` | list | ["1호선", "1호선 역사명..."] | 관심 키워드 목록 (관심글 판정 기준) |
| `crawling.filter_mode` | str | "OR" | 필터링 모드 ("AND" 또는 "OR") |
| `crawling.recheck_hours` | int | 3 | 상세 재수집 주기 (시간) |
| `crawling.headless_mode` | bool | true | 헤드리스 모드 여부 |
| `crawling.max_retries` | int | 3 | 실패 시 최대 재시도 횟수 |
| `crawling.retry_delay` | int | 2 | 재시도 간격 (초) |
| `noti.db_check_interval_minutes` | int | 3 | 신규 알림 DB 체크 주기 (분) |
| `noti.unchecked_check_interval_minutes` | int | 10 | 미확인 글 재알림 주기 (분) |
| `noti.unchecked_delay_enabled` | bool | true | 미확인 글 30분 경과 조건 사용 여부 |
| `noti.use_related_filter` | bool | true | 관심 조건(키워드/부서) 기반 알림만 사용할지 여부 |
| `update.connection_config_path` | str | "app/updater/config.json" | 업데이터 연결 설정 파일 경로 |
| `update.environment` | str | "main" | 원격 연결 설정 환경 이름 |
| `update.check_interval_hours` | int | 1 | 업데이트 체크 주기(시간) |
| `update.program_id` | str | "voc_monitor" | 버전 조회 program_id |
| `update.version_table` | str | "program_versions" | 버전 정보 테이블명 |
**알림 설정 분류 원칙:**
- `crawling.keywords`, `crawling.target_depts`: 관심글 판정 기준 (중복 없이 단일 기준)
- `noti.*`: 알림 주기/조건 토글/사운드 등 알림 동작 제어
**업데이터 연결 설정 원칙:**
- Supabase URL/KEY는 코드에 하드코딩하지 않고 `app/updater/config.json` + `settings.json(update.*)`로 관리
- 원격 config 로드 실패 시 로컬 fallback을 사용
**빌드 계약:**
- `app/update_build_setup.py`는 단일 `updater.exe`를 생성해야 함
- 메인 패키징(`app/setup.py`)은 `updater_build/dist/updater.exe``updater.exe` 이름으로 포함해야 함
**filter_mode 설명:**
- `"OR"`: 키워드 **또는** 부서가 매칭되면 관심글로 표시 (기본값)
- `"AND"`: 키워드 **그리고** 부서가 모두 매칭되어야 관심글로 표시
### 5.3 접근 방법
```python
# Controller에서 로드
self.settings = json.load(open(settings_file))
# 값 접근
crawl_interval = self.settings['crawling']['interval_minutes']
filter_mode = self.settings['crawling'].get('filter_mode', 'OR')
stations = self.settings['master_data']['stations_L1']
```
---
## 6. 데이터베이스 규약
### 6.1 테이블 스키마
```sql
CREATE TABLE voc_posts (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
writer TEXT,
department TEXT,
date TEXT,
status TEXT,
channel TEXT,
is_public INTEGER DEFAULT 1,
content TEXT,
attachment TEXT,
is_related INTEGER DEFAULT 0,
checked_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
```
### 6.2 주요 쿼리 메서드
| 메서드 | 설명 | 반환 |
|--------|------|------|
| `upsert_post(data)` | 게시글 저장/업데이트 | `None` |
| `get_all_posts()` | 전체 조회 | `list[sqlite3.Row]` |
| `get_post_by_id(voc_id)` | ID로 조회 | `sqlite3.Row` |
| `get_new_posts_since(timestamp)` | 특정 시간 이후 신규 | `list[sqlite3.Row]` |
| `get_unchecked_related_posts()` | 확인하지 않은 관심글 조회 | `list[sqlite3.Row]` |
| `mark_as_checked(voc_id)` | 읽음 처리 | `None` |
---
## 7. UI 이벤트 규약
### 7.1 Controller → View
| 메서드 | 트리거 | 파라미터 |
|--------|--------|----------|
| `open_list_view(focus_id)` | 트레이 메뉴 클릭 | `focus_id: str` (선택) |
| `open_settings()` | 트레이 메뉴 클릭 | 없음 |
| `check_updates_manual()` | 트레이 메뉴 클릭(업데이트 확인) | 없음 |
| `request_detail_popup(voc_id)` | 테이블 더블클릭 | `voc_id: str` |
### 7.2 View → Controller
| 메서드 | 트리거 | 파라미터 |
|--------|--------|----------|
| `request_create_report(data, parent)` | 보고서 생성 버튼 | `data: dict, parent: Widget` |
| `request_print_voc(data)` | 인쇄 버튼 | `data: dict` |
| `open_attachment(data, parent)` | 첨부파일 버튼 | `data: dict, parent: Widget` |
| `mark_as_read(voc_id)` | 상세 조회 | `voc_id: str` |
---
## 8. 자동 업데이트 모듈 (Updater Module) ⭐ 신규
### 8.1 모듈 구조
```
app/updater/
├── __init__.py # 모듈 초기화
├── __version__.py # 버전 정보
├── update_manager.py # 메인 프로그램용 업데이트 관리자
├── updater_gui.py # updater.exe용 GUI
└── updatelog.md # 업데이트 로그
```
### 8.2 데이터 모델
#### UpdateConfig
**위치**: `app/updater/update_manager.py`
```python
class UpdateConfig(BaseModel):
"""
업데이트 구성 모델
updater.exe로 전달되는 구성 정보
Attributes:
download_url (str): 다운로드 URL (zip 파일)
target_path (str): 설치 대상 경로
version (str): 업데이트할 버전
restart_exe (str): 업데이트 후 실행할 실행파일명
"""
download_url: str = Field(..., description="다운로드 URL")
target_path: str = Field(..., description="설치 대상 경로")
version: str = Field(..., description="업데이트 버전")
restart_exe: str = Field(default="voc_noti.exe", description="재시작 실행파일")
```
#### VersionInfo
**위치**: `app/updater/update_manager.py`
```python
class VersionInfo(BaseModel):
"""
Supabase에서 조회한 버전 정보
Attributes:
version (str): 버전 번호
is_stable (bool): 안정판 여부
release_note (str): 배포 노트
download_url (str): 다운로드 URL
min_required_version (str): 최소 요구 버전
"""
version: str
is_stable: bool
release_note: Optional[str] = None
download_url: str
min_required_version: Optional[str] = None
```
### 8.3 인터페이스 정의
#### UpdateManager (메인 프로그램용)
| 메서드 | 입력 | 출력 | 설명 |
|--------|------|------|------|
| `check_for_updates()` | 없음 | `Optional[VersionInfo]` | Supabase에서 최신 버전 조회 |
| `is_update_available()` | 없음 | `bool` | 업데이트 필요 여부 |
| `prepare_update(version_info)` | `VersionInfo` | `tuple[bool, str]` | 업데이트 준비 (config.json 생성) |
| `launch_updater()` | 없음 | `bool` | updater.exe 실행 |
| `start_background_check(callback)` | `Callable` | `None` | 백그라운드 업데이트 체크 시작 |
**보강된 계약 사항**:
- `check_for_updates()``supabase_url`, `supabase_key` 누락 시 `ConfigError`를 발생시킴
- `prepare_update()``updater.exe` 미존재 시 `(False, "...")`를 반환
- `prepare_update()`는 config 파일을 임시 파일 생성 후 원자적으로 교체 저장함
#### UpdaterGUI (updater.exe용)
| 메서드 | 입력 | 출력 | 설명 |
|--------|------|------|------|
| `load_config()` | 없음 | `UpdateConfig` | config.json 로드 |
| `download_zip(url, progress_callback)` | `str, Callable` | `tuple[bool, str]` | zip 다운로드 |
| `extract_and_replace(zip_path, target_path)` | `str, str` | `tuple[bool, str]` | 압축 해제 및 파일 교체 |
| `restart_main_app(exe_name)` | `str` | `bool` | 메인 앱 재실행 |
| `show_error(message)` | `str` | `None` | 에러 다이얼로그 표시 |
### 8.4 통신 프로토콜
**config.json 형식** (`%TEMP%/voc_updater_config.json`):
```json
{
"download_url": "https://supabase.../voc_noti_3.2.0.zip",
"target_path": "C:/Program Files/voc_noti",
"version": "3.2.0",
"restart_exe": "voc_noti.exe"
}
```
### 8.5 Supabase API
**테이블**: `program_version`
**조회 쿼리**:
```sql
SELECT * FROM program_version
WHERE program_id = 'voc_monitor'
AND is_stable = true
ORDER BY created_at DESC
LIMIT 1
```
### 8.6 예외 처리
| 예외 | 상황 | 처리 |
|------|------|------|
| `NetworkError` | Supabase 연결 실패 | 로그 기록, 다음 체크까지 대기 |
| `DownloadError` | 다운로드 실패 | 사용자 알림, 재시도 옵션 |
| `ExtractError` | 압축 해제 실패 | 롤백, 에러 로그 |
| `ConfigError` | config.json 읽기 실패 | 에러 다이얼로그, 종료 |
---
## 9. 변경 이력
| 날짜 | 버전 | 변경 내용 |
|------|------|-----------|
| 2026-02-18 | 3.3 | **알림 설정 확장** - `noti.db_check_interval_minutes`, `noti.unchecked_check_interval_minutes`, `noti.unchecked_delay_enabled` 규격 추가 |
| 2026-02-18 | 3.2 | **자동 업데이트 모듈 명세 추가** - UpdateManager, UpdaterGUI 인터페이스 정의, config.json 프로토콜 |
| 2026-02-18 | 3.1 | **크롤링 시스템 개선** - ScraperService 하드코딩 제거 (`target_depts`, `keywords` settings.json 연동), 필터링 로직 고도화 (AND/OR 모드, `_check_filter_match` 메서드), 예외처리 강화 |
| 2026-02-18 | 3.0 | **Controller 리팩토링 완료** - Manager 분리 (ReportManager, FileManager, UIManager), 알람 시스템 개선 (키워드 필터링 제거, 미확인 글 알림, 중복 방지, 영속화) |
| 2026-02-17 | 2.0 | 전면 재작성 (에러 처리 체계 추가) |
| 2026-02-14 | 1.5 | 시각표 서비스 추가 |
| 2026-02-11 | 1.0 | 초기 작성 |
---
작성자: KH.Choi
최종 수정: 2026-02-18
버전: 3.3