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:
donald
2025-12-03 23:53:24 +08:00
commit db0f0bbfe7
50 changed files with 11883 additions and 0 deletions

View File

@@ -0,0 +1,151 @@
"""
認證 API 端點
"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.core.security import (
verify_password,
get_password_hash,
create_access_token,
decode_access_token,
verify_ldap_credentials
)
from app.models import User, Role
from app.schemas.user import LoginRequest, LoginResponse, UserResponse
router = APIRouter()
security = HTTPBearer()
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> User:
"""取得當前登入用戶(依賴注入)"""
token = credentials.credentials
payload = decode_access_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="無效的認證憑證",
headers={"WWW-Authenticate": "Bearer"}
)
user_id = payload.get("user_id")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用戶不存在"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="用戶已停用"
)
return user
def require_roles(*roles: str):
"""角色權限檢查裝飾器"""
def role_checker(current_user: User = Depends(get_current_user)) -> User:
if current_user.role.code not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="權限不足"
)
return current_user
return role_checker
@router.post("/login", response_model=LoginResponse)
def login(request: LoginRequest, db: Session = Depends(get_db)):
"""用戶登入"""
user = db.query(User).filter(User.username == request.username).first()
if request.auth_type == "ad":
# AD/LDAP 認證
ldap_result = verify_ldap_credentials(request.username, request.password)
if not ldap_result:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="AD 認證失敗"
)
# 如果用戶不存在,自動建立(首次 AD 登入)
if not user:
# 取得預設讀者角色
reader_role = db.query(Role).filter(Role.code == "reader").first()
if not reader_role:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="系統角色未初始化"
)
user = User(
username=request.username,
display_name=ldap_result.get("display_name", request.username),
email=ldap_result.get("email"),
auth_type="ad",
role_id=reader_role.id
)
db.add(user)
db.commit()
db.refresh(user)
else:
# 本地認證
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="帳號或密碼錯誤"
)
if user.auth_type.value != "local":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="此帳號請使用 AD 登入"
)
if not verify_password(request.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="帳號或密碼錯誤"
)
# 更新最後登入時間
user.last_login_at = datetime.utcnow()
db.commit()
# 產生 Token
token = create_access_token({
"user_id": user.id,
"username": user.username,
"role": user.role.code
})
return LoginResponse(
token=token,
user=UserResponse.model_validate(user)
)
@router.post("/logout")
def logout(current_user: User = Depends(get_current_user)):
"""用戶登出"""
# JWT 為無狀態,登出僅做記錄
return {"message": "登出成功"}
@router.get("/me", response_model=UserResponse)
def get_current_user_info(current_user: User = Depends(get_current_user)):
"""取得當前用戶資訊"""
return current_user

View File

@@ -0,0 +1,239 @@
"""
群組管理 API 端點
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.db.session import get_db
from app.models import User, Group, Keyword, Subscription
from app.schemas.group import (
GroupCreate, GroupUpdate, GroupResponse, GroupDetailResponse,
GroupListResponse, KeywordCreate, KeywordResponse
)
from app.schemas.user import PaginationResponse
from app.api.v1.endpoints.auth import get_current_user, require_roles
router = APIRouter()
@router.get("", response_model=GroupListResponse)
def list_groups(
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
category: Optional[str] = None,
active_only: bool = True,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""取得群組列表"""
query = db.query(Group)
if category:
query = query.filter(Group.category == category)
if active_only:
query = query.filter(Group.is_active == True)
total = query.count()
groups = query.offset((page - 1) * limit).limit(limit).all()
# 計算關鍵字數和訂閱數
result = []
for g in groups:
keyword_count = db.query(Keyword).filter(Keyword.group_id == g.id).count()
subscriber_count = db.query(Subscription).filter(Subscription.group_id == g.id).count()
group_dict = {
"id": g.id,
"name": g.name,
"description": g.description,
"category": g.category.value,
"is_active": g.is_active,
"keyword_count": keyword_count,
"subscriber_count": subscriber_count
}
result.append(GroupResponse(**group_dict))
return GroupListResponse(
data=result,
pagination=PaginationResponse(
page=page, limit=limit, total=total,
total_pages=(total + limit - 1) // limit
)
)
@router.post("", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
def create_group(
group_in: GroupCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_roles("admin", "editor"))
):
"""新增群組"""
group = Group(
name=group_in.name,
description=group_in.description,
category=group_in.category,
ai_background=group_in.ai_background,
ai_prompt=group_in.ai_prompt,
created_by=current_user.id
)
db.add(group)
db.commit()
db.refresh(group)
# 新增關鍵字
if group_in.keywords:
for kw in group_in.keywords:
keyword = Keyword(group_id=group.id, keyword=kw)
db.add(keyword)
db.commit()
return GroupResponse(
id=group.id,
name=group.name,
description=group.description,
category=group.category.value,
is_active=group.is_active,
keyword_count=len(group_in.keywords) if group_in.keywords else 0,
subscriber_count=0
)
@router.get("/{group_id}", response_model=GroupDetailResponse)
def get_group(
group_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""取得群組詳情"""
group = db.query(Group).filter(Group.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="群組不存在")
keywords = db.query(Keyword).filter(Keyword.group_id == group_id).all()
keyword_count = len(keywords)
subscriber_count = db.query(Subscription).filter(Subscription.group_id == group_id).count()
return GroupDetailResponse(
id=group.id,
name=group.name,
description=group.description,
category=group.category.value,
is_active=group.is_active,
ai_background=group.ai_background,
ai_prompt=group.ai_prompt,
keywords=[KeywordResponse.model_validate(k) for k in keywords],
keyword_count=keyword_count,
subscriber_count=subscriber_count,
created_at=group.created_at,
updated_at=group.updated_at
)
@router.put("/{group_id}", response_model=GroupResponse)
def update_group(
group_id: int,
group_in: GroupUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_roles("admin", "editor"))
):
"""更新群組"""
group = db.query(Group).filter(Group.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="群組不存在")
for field, value in group_in.model_dump(exclude_unset=True).items():
setattr(group, field, value)
db.commit()
db.refresh(group)
keyword_count = db.query(Keyword).filter(Keyword.group_id == group_id).count()
subscriber_count = db.query(Subscription).filter(Subscription.group_id == group_id).count()
return GroupResponse(
id=group.id,
name=group.name,
description=group.description,
category=group.category.value,
is_active=group.is_active,
keyword_count=keyword_count,
subscriber_count=subscriber_count
)
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_group(
group_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_roles("admin"))
):
"""刪除群組"""
group = db.query(Group).filter(Group.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="群組不存在")
db.delete(group)
db.commit()
# ===== 關鍵字管理 =====
@router.get("/{group_id}/keywords", response_model=list[KeywordResponse])
def list_keywords(
group_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""取得群組關鍵字"""
keywords = db.query(Keyword).filter(Keyword.group_id == group_id).all()
return [KeywordResponse.model_validate(k) for k in keywords]
@router.post("/{group_id}/keywords", response_model=KeywordResponse, status_code=status.HTTP_201_CREATED)
def add_keyword(
group_id: int,
keyword_in: KeywordCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_roles("admin", "editor"))
):
"""新增關鍵字"""
group = db.query(Group).filter(Group.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="群組不存在")
# 檢查重複
existing = db.query(Keyword).filter(
Keyword.group_id == group_id,
Keyword.keyword == keyword_in.keyword
).first()
if existing:
raise HTTPException(status_code=400, detail="關鍵字已存在")
keyword = Keyword(group_id=group_id, keyword=keyword_in.keyword)
db.add(keyword)
db.commit()
db.refresh(keyword)
return keyword
@router.delete("/{group_id}/keywords/{keyword_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_keyword(
group_id: int,
keyword_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_roles("admin", "editor"))
):
"""刪除關鍵字"""
keyword = db.query(Keyword).filter(
Keyword.id == keyword_id,
Keyword.group_id == group_id
).first()
if not keyword:
raise HTTPException(status_code=404, detail="關鍵字不存在")
db.delete(keyword)
db.commit()

View File

@@ -0,0 +1,320 @@
"""
報告管理 API 端點
"""
from datetime import date, datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import func
import io
from app.db.session import get_db
from app.models import User, Report, ReportArticle, Group, NewsArticle, Favorite, Comment
from app.schemas.report import (
ReportUpdate, ReportResponse, ReportDetailResponse, ReportReviewResponse,
ReportListResponse, PublishResponse, RegenerateSummaryResponse,
ArticleInReport, GroupBrief
)
from app.schemas.user import PaginationResponse
from app.api.v1.endpoints.auth import get_current_user, require_roles
from app.services.llm_service import generate_summary
from app.services.notification_service import send_report_notifications
router = APIRouter()
@router.get("", response_model=ReportListResponse)
def list_reports(
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
group_id: Optional[int] = None,
status: Optional[str] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""取得報告列表"""
query = db.query(Report).join(Group)
if group_id:
query = query.filter(Report.group_id == group_id)
if status:
query = query.filter(Report.status == status)
if date_from:
query = query.filter(Report.report_date >= date_from)
if date_to:
query = query.filter(Report.report_date <= date_to)
# 讀者只能看到已發布的報告
if current_user.role.code == "reader":
query = query.filter(Report.status == "published")
total = query.count()
reports = query.order_by(Report.report_date.desc()).offset((page - 1) * limit).limit(limit).all()
result = []
for r in reports:
article_count = db.query(ReportArticle).filter(
ReportArticle.report_id == r.id,
ReportArticle.is_included == True
).count()
result.append(ReportResponse(
id=r.id,
title=r.title,
report_date=r.report_date,
status=r.status.value,
group=GroupBrief(id=r.group.id, name=r.group.name, category=r.group.category.value),
article_count=article_count,
published_at=r.published_at
))
return ReportListResponse(
data=result,
pagination=PaginationResponse(
page=page, limit=limit, total=total,
total_pages=(total + limit - 1) // limit
)
)
@router.get("/today", response_model=list[ReportReviewResponse])
def get_today_reports(
db: Session = Depends(get_db),
current_user: User = Depends(require_roles("admin", "editor"))
):
"""取得今日報告(專員審核用)"""
today = date.today()
reports = db.query(Report).filter(Report.report_date == today).all()
result = []
for r in reports:
report_articles = db.query(ReportArticle).filter(ReportArticle.report_id == r.id).all()
articles = []
for ra in report_articles:
article = db.query(NewsArticle).filter(NewsArticle.id == ra.article_id).first()
if article:
articles.append(ArticleInReport(
id=article.id,
title=article.title,
source_name=article.source.name,
url=article.url,
published_at=article.published_at,
is_included=ra.is_included
))
result.append(ReportReviewResponse(
id=r.id,
title=r.title,
report_date=r.report_date,
status=r.status.value,
group=GroupBrief(id=r.group.id, name=r.group.name, category=r.group.category.value),
article_count=len([a for a in articles if a.is_included]),
published_at=r.published_at,
ai_summary=r.ai_summary,
edited_summary=r.edited_summary,
articles=articles
))
return result
@router.get("/{report_id}", response_model=ReportDetailResponse)
def get_report(
report_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""取得報告詳情"""
report = db.query(Report).filter(Report.id == report_id).first()
if not report:
raise HTTPException(status_code=404, detail="報告不存在")
# 讀者只能看已發布的報告
if current_user.role.code == "reader" and report.status.value != "published":
raise HTTPException(status_code=403, detail="無權限查看此報告")
# 取得文章
report_articles = db.query(ReportArticle).filter(ReportArticle.report_id == report_id).all()
articles = []
for ra in report_articles:
article = db.query(NewsArticle).filter(NewsArticle.id == ra.article_id).first()
if article:
articles.append(ArticleInReport(
id=article.id,
title=article.title,
source_name=article.source.name,
url=article.url,
published_at=article.published_at,
is_included=ra.is_included
))
# 檢查是否已收藏
is_favorited = db.query(Favorite).filter(
Favorite.user_id == current_user.id,
Favorite.report_id == report_id
).first() is not None
# 留言數
comment_count = db.query(Comment).filter(
Comment.report_id == report_id,
Comment.is_deleted == False
).count()
return ReportDetailResponse(
id=report.id,
title=report.title,
report_date=report.report_date,
status=report.status.value,
group=GroupBrief(id=report.group.id, name=report.group.name, category=report.group.category.value),
article_count=len([a for a in articles if a.is_included]),
published_at=report.published_at,
ai_summary=report.ai_summary,
edited_summary=report.edited_summary,
articles=articles,
is_favorited=is_favorited,
comment_count=comment_count,
created_at=report.created_at,
updated_at=report.updated_at
)
@router.put("/{report_id}", response_model=ReportResponse)
def update_report(
report_id: int,
report_in: ReportUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_roles("admin", "editor"))
):
"""更新報告"""
report = db.query(Report).filter(Report.id == report_id).first()
if not report:
raise HTTPException(status_code=404, detail="報告不存在")
if report_in.title:
report.title = report_in.title
if report_in.edited_summary is not None:
report.edited_summary = report_in.edited_summary
# 更新文章篩選
if report_in.article_selections:
for sel in report_in.article_selections:
ra = db.query(ReportArticle).filter(
ReportArticle.report_id == report_id,
ReportArticle.article_id == sel["article_id"]
).first()
if ra:
ra.is_included = sel["is_included"]
db.commit()
db.refresh(report)
article_count = db.query(ReportArticle).filter(
ReportArticle.report_id == report_id,
ReportArticle.is_included == True
).count()
return ReportResponse(
id=report.id,
title=report.title,
report_date=report.report_date,
status=report.status.value,
group=GroupBrief(id=report.group.id, name=report.group.name, category=report.group.category.value),
article_count=article_count,
published_at=report.published_at
)
@router.post("/{report_id}/publish", response_model=PublishResponse)
def publish_report(
report_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_roles("admin", "editor"))
):
"""發布報告"""
report = db.query(Report).filter(Report.id == report_id).first()
if not report:
raise HTTPException(status_code=404, detail="報告不存在")
if report.status.value == "published":
raise HTTPException(status_code=400, detail="報告已發布")
report.status = "published"
report.published_at = datetime.utcnow()
report.published_by = current_user.id
db.commit()
# 發送通知
notifications_sent = send_report_notifications(db, report)
return PublishResponse(
published_at=report.published_at,
notifications_sent=notifications_sent
)
@router.post("/{report_id}/regenerate-summary", response_model=RegenerateSummaryResponse)
def regenerate_summary(
report_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_roles("admin", "editor"))
):
"""重新產生 AI 摘要"""
report = db.query(Report).filter(Report.id == report_id).first()
if not report:
raise HTTPException(status_code=404, detail="報告不存在")
# 取得納入的文章
report_articles = db.query(ReportArticle).filter(
ReportArticle.report_id == report_id,
ReportArticle.is_included == True
).all()
articles = []
for ra in report_articles:
article = db.query(NewsArticle).filter(NewsArticle.id == ra.article_id).first()
if article:
articles.append(article)
# 產生摘要
summary = generate_summary(report.group, articles)
report.ai_summary = summary
db.commit()
return RegenerateSummaryResponse(ai_summary=summary)
@router.get("/{report_id}/export")
def export_report_pdf(
report_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""匯出報告 PDF"""
report = db.query(Report).filter(Report.id == report_id).first()
if not report:
raise HTTPException(status_code=404, detail="報告不存在")
# 讀者只能匯出已發布的報告
if current_user.role.code == "reader" and report.status.value != "published":
raise HTTPException(status_code=403, detail="無權限匯出此報告")
# TODO: 實作 PDF 生成
# 暫時返回簡單文字
content = f"""
{report.title}
日期:{report.report_date}
群組:{report.group.name}
{report.edited_summary or report.ai_summary or '無摘要內容'}
"""
buffer = io.BytesIO(content.encode('utf-8'))
return StreamingResponse(
buffer,
media_type="text/plain",
headers={"Content-Disposition": f"attachment; filename=report_{report_id}.txt"}
)

View 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
)

View File

@@ -0,0 +1,91 @@
"""
訂閱管理 API 端點
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import Optional
from app.db.session import get_db
from app.models import User, Group, Subscription
from app.api.v1.endpoints.auth import get_current_user
router = APIRouter()
class SubscriptionResponse(BaseModel):
group_id: int
group_name: str
category: str
email_notify: bool
class Config:
from_attributes = True
class SubscriptionItem(BaseModel):
group_id: int
subscribed: bool
email_notify: Optional[bool] = True
class SubscriptionUpdateRequest(BaseModel):
subscriptions: list[SubscriptionItem]
@router.get("", response_model=list[SubscriptionResponse])
def get_my_subscriptions(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""取得我的訂閱列表"""
subs = db.query(Subscription).filter(Subscription.user_id == current_user.id).all()
result = []
for s in subs:
group = db.query(Group).filter(Group.id == s.group_id).first()
if group:
result.append(SubscriptionResponse(
group_id=group.id,
group_name=group.name,
category=group.category.value,
email_notify=s.email_notify
))
return result
@router.put("")
def update_subscriptions(
request: SubscriptionUpdateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""批次更新訂閱"""
for item in request.subscriptions:
# 檢查群組是否存在
group = db.query(Group).filter(Group.id == item.group_id, Group.is_active == True).first()
if not group:
continue
existing = db.query(Subscription).filter(
Subscription.user_id == current_user.id,
Subscription.group_id == item.group_id
).first()
if item.subscribed:
if existing:
existing.email_notify = item.email_notify
else:
sub = Subscription(
user_id=current_user.id,
group_id=item.group_id,
email_notify=item.email_notify
)
db.add(sub)
else:
if existing:
db.delete(existing)
db.commit()
return {"message": "訂閱更新成功"}

View File

@@ -0,0 +1,195 @@
"""
用戶管理 API 端點
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.db.session import get_db
from app.core.security import get_password_hash
from app.models import User, Role
from app.schemas.user import (
UserCreate, UserUpdate, UserResponse, UserListResponse, PaginationResponse
)
from app.api.v1.endpoints.auth import get_current_user, require_roles
router = APIRouter()
@router.get("", response_model=UserListResponse)
def list_users(
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
role: Optional[str] = None,
auth_type: Optional[str] = None,
search: Optional[str] = None,
current_user: User = Depends(require_roles("admin"))
):
"""取得用戶列表(僅管理員)"""
db = next(get_db())
query = db.query(User)
# 篩選條件
if role:
query = query.join(Role).filter(Role.code == role)
if auth_type:
query = query.filter(User.auth_type == auth_type)
if search:
# 清理輸入,移除特殊字元,防止注入
safe_search = search.strip()[:100] # 限制長度
# SQLAlchemy 的 ilike 已經使用參數化查詢,相對安全
# 但為了額外安全,轉義 SQL 萬用字元
safe_search = safe_search.replace('%', '\\%').replace('_', '\\_')
query = query.filter(
(User.username.ilike(f"%{safe_search}%")) |
(User.display_name.ilike(f"%{safe_search}%"))
)
# 總數
total = query.count()
# 分頁
users = query.offset((page - 1) * limit).limit(limit).all()
return UserListResponse(
data=[UserResponse.model_validate(u) for u in users],
pagination=PaginationResponse(
page=page,
limit=limit,
total=total,
total_pages=(total + limit - 1) // limit
)
)
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(
user_in: UserCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_roles("admin"))
):
"""新增用戶(僅管理員)"""
# 檢查帳號是否重複
existing = db.query(User).filter(User.username == user_in.username).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="帳號已存在"
)
# 檢查角色
role = db.query(Role).filter(Role.id == user_in.role_id).first()
if not role:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="角色不存在"
)
# 本地帳號必須有密碼
if user_in.auth_type == "local" and not user_in.password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="本地帳號必須設定密碼"
)
user = User(
username=user_in.username,
display_name=user_in.display_name,
email=user_in.email,
auth_type=user_in.auth_type,
role_id=user_in.role_id,
password_hash=get_password_hash(user_in.password) if user_in.password else None
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.get("/{user_id}", response_model=UserResponse)
def get_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_roles("admin"))
):
"""取得單一用戶(僅管理員)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="用戶不存在"
)
return user
@router.put("/{user_id}", response_model=UserResponse)
def update_user(
user_id: int,
user_in: UserUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_roles("admin"))
):
"""更新用戶(僅管理員)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="用戶不存在"
)
# 更新欄位
if user_in.display_name is not None:
user.display_name = user_in.display_name
if user_in.email is not None:
user.email = user_in.email
if user_in.role_id is not None:
role = db.query(Role).filter(Role.id == user_in.role_id).first()
if not role:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="角色不存在"
)
user.role_id = user_in.role_id
if user_in.is_active is not None:
user.is_active = user_in.is_active
if user_in.password is not None:
if user.auth_type.value != "local":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="AD 帳號無法修改密碼"
)
user.password_hash = get_password_hash(user_in.password)
db.commit()
db.refresh(user)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_roles("admin"))
):
"""刪除用戶(僅管理員)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="用戶不存在"
)
# 不能刪除自己
if user.id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="無法刪除自己的帳號"
)
db.delete(user)
db.commit()