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>
This commit is contained in:
295
app/api/v1/endpoints/settings.py
Normal file
295
app/api/v1/endpoints/settings.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
系統設定 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
|
||||
)
|
||||
Reference in New Issue
Block a user