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:
151
app/api/v1/endpoints/auth.py
Normal file
151
app/api/v1/endpoints/auth.py
Normal 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
|
||||
239
app/api/v1/endpoints/groups.py
Normal file
239
app/api/v1/endpoints/groups.py
Normal 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()
|
||||
320
app/api/v1/endpoints/reports.py
Normal file
320
app/api/v1/endpoints/reports.py
Normal 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"}
|
||||
)
|
||||
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
|
||||
)
|
||||
91
app/api/v1/endpoints/subscriptions.py
Normal file
91
app/api/v1/endpoints/subscriptions.py
Normal 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": "訂閱更新成功"}
|
||||
195
app/api/v1/endpoints/users.py
Normal file
195
app/api/v1/endpoints/users.py
Normal 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()
|
||||
Reference in New Issue
Block a user