Files
daily-news-app/app/api/v1/endpoints/settings.py
donald db0f0bbfe7 Initial commit: Daily News App
企業內部新聞彙整與分析系統
- 自動新聞抓取 (Digitimes, 經濟日報, 工商時報)
- AI 智慧摘要 (OpenAI/Claude/Ollama)
- 群組管理與訂閱通知
- 已清理 Python 快取檔案

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 23:53:24 +08:00

296 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
系統設定 API 端點
"""
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import Optional
import os
import hashlib
from pathlib import Path
import logging
from app.db.session import get_db
from app.models import User, SystemSetting
from app.api.v1.endpoints.auth import get_current_user, require_roles
from app.services.llm_service import test_llm_connection
logger = logging.getLogger(__name__)
router = APIRouter()
class SystemSettingsResponse(BaseModel):
crawl_schedule_time: Optional[str] = None
publish_deadline: Optional[str] = None
llm_provider: Optional[str] = None
llm_model: Optional[str] = None
llm_ollama_endpoint: Optional[str] = None
data_retention_days: Optional[int] = None
pdf_logo_path: Optional[str] = None
pdf_header_text: Optional[str] = None
pdf_footer_text: Optional[str] = None
smtp_host: Optional[str] = None
smtp_port: Optional[int] = None
smtp_username: Optional[str] = None
smtp_from_email: Optional[str] = None
smtp_from_name: Optional[str] = None
class SystemSettingsUpdate(BaseModel):
crawl_schedule_time: Optional[str] = None
publish_deadline: Optional[str] = None
llm_provider: Optional[str] = None
llm_api_key: Optional[str] = None
llm_model: Optional[str] = None
llm_ollama_endpoint: Optional[str] = None
data_retention_days: Optional[int] = None
pdf_header_text: Optional[str] = None
pdf_footer_text: Optional[str] = None
smtp_host: Optional[str] = None
smtp_port: Optional[int] = None
smtp_username: Optional[str] = None
smtp_password: Optional[str] = None
smtp_from_email: Optional[str] = None
smtp_from_name: Optional[str] = None
class LLMTestResponse(BaseModel):
success: bool
provider: str
model: str
response_time_ms: int
message: Optional[str] = None
def get_setting_value(db: Session, key: str) -> Optional[str]:
"""取得設定值"""
setting = db.query(SystemSetting).filter(SystemSetting.setting_key == key).first()
return setting.setting_value if setting else None
def set_setting_value(db: Session, key: str, value: str, user_id: int):
"""設定值"""
setting = db.query(SystemSetting).filter(SystemSetting.setting_key == key).first()
if setting:
setting.setting_value = value
setting.updated_by = user_id
else:
setting = SystemSetting(setting_key=key, setting_value=value, updated_by=user_id)
db.add(setting)
@router.get("", response_model=SystemSettingsResponse)
def get_settings(
db: Session = Depends(get_db),
current_user: User = Depends(require_roles("admin"))
):
"""取得系統設定"""
retention = get_setting_value(db, "data_retention_days")
smtp_port = get_setting_value(db, "smtp_port")
return SystemSettingsResponse(
crawl_schedule_time=get_setting_value(db, "crawl_schedule_time"),
publish_deadline=get_setting_value(db, "publish_deadline"),
llm_provider=get_setting_value(db, "llm_provider"),
llm_model=get_setting_value(db, "llm_model"),
llm_ollama_endpoint=get_setting_value(db, "llm_ollama_endpoint"),
data_retention_days=int(retention) if retention else None,
pdf_logo_path=get_setting_value(db, "pdf_logo_path"),
pdf_header_text=get_setting_value(db, "pdf_header_text"),
pdf_footer_text=get_setting_value(db, "pdf_footer_text"),
smtp_host=get_setting_value(db, "smtp_host"),
smtp_port=int(smtp_port) if smtp_port else None,
smtp_username=get_setting_value(db, "smtp_username"),
smtp_from_email=get_setting_value(db, "smtp_from_email"),
smtp_from_name=get_setting_value(db, "smtp_from_name")
)
@router.put("")
def update_settings(
settings_in: SystemSettingsUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_roles("admin"))
):
"""更新系統設定"""
updates = settings_in.model_dump(exclude_unset=True)
for key, value in updates.items():
if value is not None:
# 敏感欄位需加密(簡化處理,實際應使用加密)
if key in ["llm_api_key", "smtp_password"]:
key = f"{key.replace('_key', '').replace('_password', '')}_encrypted"
set_setting_value(db, key, str(value), current_user.id)
db.commit()
return {"message": "設定更新成功"}
@router.post("/llm/test", response_model=LLMTestResponse)
def test_llm(
db: Session = Depends(get_db),
current_user: User = Depends(require_roles("admin"))
):
"""測試 LLM 連線"""
provider = get_setting_value(db, "llm_provider") or "claude"
model = get_setting_value(db, "llm_model") or "claude-3-sonnet"
result = test_llm_connection(provider, model)
return LLMTestResponse(
success=result["success"],
provider=provider,
model=model,
response_time_ms=result.get("response_time_ms", 0),
message=result.get("message")
)
@router.post("/pdf/logo")
async def upload_pdf_logo(
logo: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(require_roles("admin"))
):
"""上傳 PDF Logo加強安全檢查"""
# 1. 檢查檔案大小(限制 5MB
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
content = await logo.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(status_code=400, detail="檔案大小超過 5MB 限制")
# 2. 檢查檔案類型(基本檢查,建議安裝 python-magic 進行更嚴格的檢查)
allowed_content_types = ["image/png", "image/jpeg", "image/svg+xml"]
if logo.content_type not in allowed_content_types:
raise HTTPException(status_code=400, detail=f"不支援的檔案類型: {logo.content_type},僅支援 PNG、JPEG、SVG")
# 3. 檢查檔案副檔名(額外安全層)
file_ext = logo.filename.split(".")[-1].lower() if "." in logo.filename else ""
allowed_extensions = ["png", "jpg", "jpeg", "svg"]
if file_ext not in allowed_extensions:
raise HTTPException(status_code=400, detail=f"不支援的檔案副檔名: {file_ext}")
# 4. 使用安全的檔案名稱(使用 hash避免路徑遍歷和檔案名稱衝突
file_hash = hashlib.sha256(content).hexdigest()[:16]
safe_filename = f"company_logo_{file_hash}.{file_ext}"
# 5. 使用絕對路徑,避免路徑遍歷
# 取得專案根目錄
project_root = Path(__file__).parent.parent.parent.parent.resolve()
upload_dir = (project_root / "uploads" / "logos").resolve()
upload_dir.mkdir(parents=True, exist_ok=True)
file_path = upload_dir / safe_filename
# 6. 確保檔案路徑在允許的目錄內(防止路徑遍歷)
try:
file_path.resolve().relative_to(upload_dir.resolve())
except ValueError:
raise HTTPException(status_code=400, detail="無效的檔案路徑")
# 7. 檢查檔案內容的魔術數字Magic Number以驗證真實檔案類型
# PNG: 89 50 4E 47
# JPEG: FF D8 FF
# SVG: 檢查是否為 XML 格式
magic_numbers = {
b'\x89PNG\r\n\x1a\n': 'png',
b'\xff\xd8\xff': 'jpg',
}
file_type_detected = None
for magic, ext in magic_numbers.items():
if content.startswith(magic):
file_type_detected = ext
break
# SVG 檢查(開頭應該是 <?xml 或 <svg
if content.startswith(b'<?xml') or content.startswith(b'<svg'):
file_type_detected = 'svg'
# 如果檢測到的檔案類型與副檔名不符,拒絕上傳
if file_type_detected and file_type_detected != file_ext:
raise HTTPException(
status_code=400,
detail=f"檔案類型與副檔名不符:檢測到 {file_type_detected},但副檔名為 {file_ext}"
)
# 8. 儲存檔案
try:
with open(file_path, "wb") as f:
f.write(content)
logger.info(f"Logo 上傳成功: {safe_filename}")
except Exception as e:
logger.error(f"Logo 儲存失敗: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="檔案儲存失敗")
# 9. 更新設定(使用相對路徑)
relative_path = f"uploads/logos/{safe_filename}"
set_setting_value(db, "pdf_logo_path", relative_path, current_user.id)
db.commit()
return {"logo_path": relative_path}
# ===== Dashboard =====
class AdminDashboardResponse(BaseModel):
today_articles: int
active_users: int
pending_reports: int
system_health: list[dict]
@router.get("/dashboard/admin", response_model=AdminDashboardResponse)
def get_admin_dashboard(
db: Session = Depends(get_db),
current_user: User = Depends(require_roles("admin"))
):
"""管理員儀表板"""
from datetime import date
from app.models import NewsArticle, Report, CrawlJob
today = date.today()
# 今日文章數
today_articles = db.query(NewsArticle).filter(
NewsArticle.crawled_at >= today
).count()
# 活躍用戶數
active_users = db.query(User).filter(User.is_active == True).count()
# 待發布報告
pending_reports = db.query(Report).filter(
Report.status.in_(["draft", "pending"])
).count()
# 系統狀態
from app.models import NewsSource
sources = db.query(NewsSource).filter(NewsSource.is_active == True).all()
system_health = []
for source in sources:
last_job = db.query(CrawlJob).filter(
CrawlJob.source_id == source.id
).order_by(CrawlJob.created_at.desc()).first()
system_health.append({
"name": f"{source.name} 爬蟲",
"status": "正常" if last_job and last_job.status.value == "completed" else "異常",
"last_run": last_job.completed_at.strftime("%H:%M") if last_job and last_job.completed_at else "-"
})
# LLM 狀態
system_health.append({
"name": f"LLM 服務 ({get_setting_value(db, 'llm_provider') or 'Claude'})",
"status": "正常",
"last_run": "-"
})
return AdminDashboardResponse(
today_articles=today_articles,
active_users=active_users,
pending_reports=pending_reports,
system_health=system_health
)