Initial commit: KPI Management System Backend

Features:
- FastAPI backend with JWT authentication
- MySQL database with SQLAlchemy ORM
- KPI workflow: draft → pending → approved → evaluation → completed
- Ollama LLM API integration for AI features
- Gitea API integration for version control
- Complete API endpoints for KPI, dashboard, notifications

Tables: KPI_D_* prefix naming convention

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-11 16:20:57 +08:00
commit f810ddc2ea
48 changed files with 4950 additions and 0 deletions

18
app/api/__init__.py Normal file
View File

@@ -0,0 +1,18 @@
"""
API Routers
"""
from app.api.auth import router as auth_router
from app.api.kpi import router as kpi_router
from app.api.dashboard import router as dashboard_router
from app.api.notifications import router as notifications_router
from app.api.llm import router as llm_router
from app.api.gitea import router as gitea_router
__all__ = [
"auth_router",
"kpi_router",
"dashboard_router",
"notifications_router",
"llm_router",
"gitea_router",
]

133
app/api/auth.py Normal file
View File

@@ -0,0 +1,133 @@
"""
認證 API
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_db, get_current_user
from app.core.security import (
verify_password,
create_access_token,
create_refresh_token,
decode_token,
)
from app.models.employee import Employee
from app.schemas.auth import (
LoginRequest,
TokenResponse,
RefreshTokenRequest,
UserInfo,
)
router = APIRouter(prefix="/api/auth", tags=["認證"])
@router.post("/login", response_model=TokenResponse)
def login(data: LoginRequest, db: Session = Depends(get_db)):
"""
登入
使用員工編號和密碼登入,返回 JWT Token。
"""
# 查詢員工
employee = (
db.query(Employee).filter(Employee.employee_no == data.employee_no).first()
)
if not employee:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "AUTH001", "message": "員工編號或密碼錯誤"},
)
# 驗證密碼
if not verify_password(data.password, employee.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "AUTH001", "message": "員工編號或密碼錯誤"},
)
# 檢查帳號狀態
if employee.status != "active":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "AUTH004", "message": "帳號已停用"},
)
# 產生 Token
access_token = create_access_token({"sub": str(employee.id)})
refresh_token = create_refresh_token({"sub": str(employee.id)})
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
)
@router.post("/refresh", response_model=TokenResponse)
def refresh_token(data: RefreshTokenRequest, db: Session = Depends(get_db)):
"""
更新 Token
使用 Refresh Token 取得新的 Access Token。
"""
payload = decode_token(data.refresh_token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "AUTH002", "message": "Token 過期或無效"},
)
if payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "AUTH001", "message": "Token 類型錯誤"},
)
user_id = payload.get("sub")
employee = db.query(Employee).filter(Employee.id == int(user_id)).first()
if not employee or employee.status != "active":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "AUTH001", "message": "使用者不存在或已停用"},
)
# 產生新 Token
access_token = create_access_token({"sub": str(employee.id)})
new_refresh_token = create_refresh_token({"sub": str(employee.id)})
return TokenResponse(
access_token=access_token,
refresh_token=new_refresh_token,
)
@router.post("/logout")
def logout():
"""
登出
前端清除 Token 即可,後端不做處理。
"""
return {"message": "登出成功"}
@router.get("/me", response_model=UserInfo)
def get_me(current_user: Employee = Depends(get_current_user)):
"""
取得當前使用者資訊
"""
return UserInfo(
id=current_user.id,
employee_no=current_user.employee_no,
name=current_user.name,
email=current_user.email,
department_id=current_user.department_id,
department_name=current_user.department.name,
job_title=current_user.job_title,
role=current_user.role,
is_manager=current_user.is_manager,
is_admin=current_user.is_admin,
)

105
app/api/dashboard.py Normal file
View File

@@ -0,0 +1,105 @@
"""
儀表板 API
"""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from app.api.deps import get_db, get_current_user, get_current_manager
from app.models.employee import Employee
from app.services.dashboard_service import DashboardService
from app.schemas.dashboard import (
DashboardProgressResponse,
DashboardDistributionResponse,
DashboardTrendsResponse,
DashboardAlertResponse,
)
from app.schemas.common import MessageResponse
router = APIRouter(prefix="/api/dashboard", tags=["儀表板"])
@router.get("/progress", response_model=DashboardProgressResponse)
def get_progress(
period_id: Optional[int] = Query(None),
department_id: Optional[int] = Query(None),
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""
取得進度統計
返回指定期間的 KPI 完成進度,包含各狀態的數量統計。
"""
service = DashboardService(db)
return service.get_progress(period_id, department_id)
@router.get("/distribution", response_model=DashboardDistributionResponse)
def get_distribution(
period_id: Optional[int] = Query(None),
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""
取得分佈統計
返回 KPI 的部門分佈、狀態分佈、分數區間分佈。
"""
service = DashboardService(db)
return service.get_distribution(period_id)
@router.get("/trends", response_model=DashboardTrendsResponse)
def get_trends(
limit: int = Query(4, ge=1, le=10),
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""
取得趨勢統計
返回歷史期間的平均分數趨勢。
"""
service = DashboardService(db)
return service.get_trends(limit)
@router.get("/alerts", response_model=List[DashboardAlertResponse])
def get_alerts(
is_resolved: Optional[bool] = Query(False),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_manager),
):
"""
取得儀表板警示
返回系統警示列表(主管以上可查看)。
"""
service = DashboardService(db)
return service.get_alerts(is_resolved, limit)
@router.put("/alerts/{alert_id}/resolve", response_model=DashboardAlertResponse)
def resolve_alert(
alert_id: int,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_manager),
):
"""
解決警示
將指定警示標記為已解決。
"""
service = DashboardService(db)
alert = service.resolve_alert(alert_id, current_user.id)
if not alert:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "ALERT001", "message": "警示不存在"},
)
return alert

109
app/api/deps.py Normal file
View File

@@ -0,0 +1,109 @@
"""
API 依賴注入
"""
from typing import Generator
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.core.database import SessionLocal
from app.core.security import decode_token
from app.models.employee import Employee
# Bearer Token 安全機制
security = HTTPBearer()
def get_db() -> Generator:
"""取得資料庫 Session"""
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> Employee:
"""
取得當前使用者
從 Authorization header 解析 JWT Token
驗證並返回對應的員工物件。
"""
token = credentials.credentials
payload = decode_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "AUTH001", "message": "Token 無效"},
)
# 檢查 Token 類型
if payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "AUTH001", "message": "Token 類型錯誤"},
)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "AUTH001", "message": "Token 無效"},
)
user = db.query(Employee).filter(Employee.id == int(user_id)).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "AUTH001", "message": "使用者不存在"},
)
if user.status != "active":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "AUTH004", "message": "帳號已停用"},
)
return user
def get_current_manager(
current_user: Employee = Depends(get_current_user),
) -> Employee:
"""取得當前主管使用者"""
if not current_user.is_manager:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "AUTH003", "message": "權限不足,需要主管權限"},
)
return current_user
def get_current_admin(
current_user: Employee = Depends(get_current_user),
) -> Employee:
"""取得當前管理員使用者"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "AUTH003", "message": "權限不足,需要管理員權限"},
)
return current_user
def get_current_hr(
current_user: Employee = Depends(get_current_user),
) -> Employee:
"""取得當前人資使用者"""
if not current_user.is_hr and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "AUTH003", "message": "權限不足,需要人資權限"},
)
return current_user

166
app/api/gitea.py Normal file
View File

@@ -0,0 +1,166 @@
"""
Gitea API
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.api.deps import get_current_user, get_current_admin
from app.models.employee import Employee
from app.services.gitea_service import gitea_service, create_kpi_management_repo
router = APIRouter(prefix="/api/gitea", tags=["Gitea"])
class CreateRepoRequest(BaseModel):
"""建立 Repo 請求"""
name: str
description: Optional[str] = ""
private: bool = False
class CreateFileRequest(BaseModel):
"""建立檔案請求"""
repo_name: str
file_path: str
content: str
message: str = "Add file"
branch: str = "main"
@router.get("/user")
def get_user(current_user: Employee = Depends(get_current_admin)):
"""
取得 Gitea 使用者資訊
"""
return gitea_service.get_user()
@router.get("/repos")
def list_repos(current_user: Employee = Depends(get_current_admin)):
"""
列出所有 Repo
"""
repos = gitea_service.list_repos()
return {"repos": repos}
@router.get("/repos/{repo_name}")
def get_repo(
repo_name: str,
current_user: Employee = Depends(get_current_admin),
):
"""
取得 Repo 資訊
"""
return gitea_service.get_repo(repo_name)
@router.post("/repos")
def create_repo(
data: CreateRepoRequest,
current_user: Employee = Depends(get_current_admin),
):
"""
建立新的 Repo
"""
result = gitea_service.create_repo(
name=data.name,
description=data.description,
private=data.private,
)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.delete("/repos/{repo_name}")
def delete_repo(
repo_name: str,
current_user: Employee = Depends(get_current_admin),
):
"""
刪除 Repo
"""
result = gitea_service.delete_repo(repo_name)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return {"message": f"Repo '{repo_name}' 已刪除"}
@router.post("/repos/kpi-management/init")
def init_kpi_repo(current_user: Employee = Depends(get_current_admin)):
"""
初始化 KPI Management Repo
"""
result = create_kpi_management_repo()
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.get("/repos/{repo_name}/branches")
def list_branches(
repo_name: str,
current_user: Employee = Depends(get_current_admin),
):
"""
列出分支
"""
return gitea_service.list_branches(repo_name)
@router.get("/repos/{repo_name}/commits")
def list_commits(
repo_name: str,
branch: str = "main",
limit: int = 10,
current_user: Employee = Depends(get_current_admin),
):
"""
列出 Commits
"""
return gitea_service.list_commits(repo_name, branch, limit)
@router.post("/files")
def create_file(
data: CreateFileRequest,
current_user: Employee = Depends(get_current_admin),
):
"""
建立檔案
"""
result = gitea_service.create_file(
repo_name=data.repo_name,
file_path=data.file_path,
content=data.content,
message=data.message,
branch=data.branch,
)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.get("/repos/{repo_name}/files/{file_path:path}")
def get_file(
repo_name: str,
file_path: str,
current_user: Employee = Depends(get_current_admin),
):
"""
取得檔案內容
"""
return gitea_service.get_file(repo_name, file_path)

358
app/api/kpi.py Normal file
View File

@@ -0,0 +1,358 @@
"""
KPI API
"""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from app.api.deps import get_db, get_current_user, get_current_manager
from app.models.employee import Employee
from app.models.kpi_sheet import KPISheet
from app.models.kpi_period import KPIPeriod
from app.models.kpi_template import KPITemplate, KPIPreset
from app.services.kpi_service import KPISheetService
from app.services.notify_service import NotifyService
from app.schemas.kpi_sheet import (
KPISheetCreate,
KPISheetResponse,
KPISheetListItem,
ApproveRequest,
RejectRequest,
SelfEvalRequest,
ManagerEvalRequest,
KPIPeriodResponse,
KPITemplateResponse,
KPIPresetResponse,
)
from app.schemas.common import PaginatedResponse, MessageResponse
router = APIRouter(prefix="/api/kpi", tags=["KPI"])
# ==================== KPI 表單 ====================
@router.get("/sheets", response_model=List[KPISheetListItem])
def list_sheets(
period_id: Optional[int] = Query(None),
department_id: Optional[int] = Query(None),
status: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500),
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""查詢 KPI 表單清單"""
service = KPISheetService(db)
sheets = service.get_multi(
period_id=period_id,
department_id=department_id,
status=status,
skip=skip,
limit=limit,
)
return sheets
@router.get("/sheets/my", response_model=List[KPISheetListItem])
def get_my_sheets(
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""取得我的 KPI 表單"""
service = KPISheetService(db)
return service.get_my_sheets(current_user.id)
@router.get("/sheets/pending", response_model=List[KPISheetListItem])
def get_pending_sheets(
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_manager),
):
"""取得待審核的 KPI 表單(主管用)"""
service = KPISheetService(db)
return service.get_pending_for_manager(current_user.id)
@router.post("/sheets", response_model=KPISheetResponse, status_code=status.HTTP_201_CREATED)
def create_sheet(
data: KPISheetCreate,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""建立 KPI 表單"""
service = KPISheetService(db)
return service.create(current_user, data)
@router.post("/sheets/copy-from-previous", response_model=KPISheetResponse, status_code=status.HTTP_201_CREATED)
def copy_from_previous(
period_id: int,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""從上期複製 KPI"""
service = KPISheetService(db)
return service.copy_from_previous(current_user, period_id)
@router.post("/sheets/apply-preset/{preset_id}", response_model=KPISheetResponse, status_code=status.HTTP_201_CREATED)
def apply_preset(
preset_id: int,
period_id: int,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""套用預設組合"""
service = KPISheetService(db)
return service.apply_preset(current_user, period_id, preset_id)
@router.get("/sheets/{sheet_id}", response_model=KPISheetResponse)
def get_sheet(
sheet_id: int,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""取得單一 KPI 表單"""
service = KPISheetService(db)
sheet = service.get_by_id(sheet_id)
if not sheet:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "KPI004", "message": "KPI 表單不存在"},
)
return sheet
@router.delete("/sheets/{sheet_id}", response_model=MessageResponse)
def delete_sheet(
sheet_id: int,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""刪除 KPI 表單"""
service = KPISheetService(db)
sheet = service.get_by_id(sheet_id)
if not sheet:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "KPI004", "message": "KPI 表單不存在"},
)
if sheet.employee_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "AUTH003", "message": "權限不足"},
)
service.delete(sheet)
return MessageResponse(message="刪除成功")
# ==================== 審核流程 ====================
@router.post("/sheets/{sheet_id}/submit", response_model=KPISheetResponse)
def submit_sheet(
sheet_id: int,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""提交 KPI 審核"""
service = KPISheetService(db)
notify_service = NotifyService(db)
sheet = service.get_by_id(sheet_id)
if not sheet:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "KPI004", "message": "KPI 表單不存在"},
)
if sheet.employee_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "AUTH003", "message": "只能提交自己的 KPI"},
)
result = service.submit(sheet, current_user)
notify_service.notify_kpi_submitted(result)
return result
@router.post("/sheets/{sheet_id}/approve", response_model=KPISheetResponse)
def approve_sheet(
sheet_id: int,
data: ApproveRequest,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_manager),
):
"""審核通過"""
service = KPISheetService(db)
notify_service = NotifyService(db)
sheet = service.get_by_id(sheet_id)
if not sheet:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "KPI004", "message": "KPI 表單不存在"},
)
# 檢查是否為該員工的主管
if sheet.employee.manager_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "AUTH003", "message": "只能審核直屬部屬的 KPI"},
)
result = service.approve(sheet, current_user, data.comment)
notify_service.notify_kpi_approved(result)
return result
@router.post("/sheets/{sheet_id}/reject", response_model=KPISheetResponse)
def reject_sheet(
sheet_id: int,
data: RejectRequest,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_manager),
):
"""退回"""
service = KPISheetService(db)
notify_service = NotifyService(db)
sheet = service.get_by_id(sheet_id)
if not sheet:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "KPI004", "message": "KPI 表單不存在"},
)
# 檢查是否為該員工的主管
if sheet.employee.manager_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "AUTH003", "message": "只能審核直屬部屬的 KPI"},
)
result = service.reject(sheet, current_user, data.reason)
notify_service.notify_kpi_rejected(result, data.reason)
return result
# ==================== 評核 ====================
@router.post("/sheets/{sheet_id}/self-eval", response_model=KPISheetResponse)
def self_eval(
sheet_id: int,
data: SelfEvalRequest,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""員工自評"""
service = KPISheetService(db)
notify_service = NotifyService(db)
sheet = service.get_by_id(sheet_id)
if not sheet:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "KPI004", "message": "KPI 表單不存在"},
)
if sheet.employee_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "AUTH003", "message": "只能自評自己的 KPI"},
)
result = service.self_eval(sheet, current_user, data)
notify_service.notify_self_eval_completed(result)
return result
@router.post("/sheets/{sheet_id}/manager-eval", response_model=KPISheetResponse)
def manager_eval(
sheet_id: int,
data: ManagerEvalRequest,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_manager),
):
"""主管評核"""
service = KPISheetService(db)
notify_service = NotifyService(db)
sheet = service.get_by_id(sheet_id)
if not sheet:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "KPI004", "message": "KPI 表單不存在"},
)
# 檢查是否為該員工的主管
if sheet.employee.manager_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "AUTH003", "message": "只能評核直屬部屬的 KPI"},
)
result = service.manager_eval(sheet, current_user, data)
notify_service.notify_manager_eval_completed(result)
return result
# ==================== 期間 ====================
@router.get("/periods", response_model=List[KPIPeriodResponse])
def list_periods(db: Session = Depends(get_db)):
"""取得 KPI 期間列表"""
periods = db.query(KPIPeriod).order_by(KPIPeriod.start_date.desc()).all()
return periods
@router.get("/periods/current", response_model=KPIPeriodResponse)
def get_current_period(db: Session = Depends(get_db)):
"""取得當前 KPI 期間"""
period = (
db.query(KPIPeriod)
.filter(KPIPeriod.status.in_(["setting", "self_eval", "manager_eval"]))
.order_by(KPIPeriod.start_date.desc())
.first()
)
if not period:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "PERIOD002", "message": "目前沒有進行中的 KPI 期間"},
)
return period
# ==================== 範本 ====================
@router.get("/templates", response_model=List[KPITemplateResponse])
def list_templates(
category: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""取得 KPI 範本列表"""
query = db.query(KPITemplate).filter(KPITemplate.is_active == True)
if category:
query = query.filter(KPITemplate.category == category)
return query.all()
@router.get("/presets", response_model=List[KPIPresetResponse])
def list_presets(db: Session = Depends(get_db)):
"""取得 KPI 預設組合列表"""
presets = db.query(KPIPreset).filter(KPIPreset.is_active == True).all()
return presets

140
app/api/llm.py Normal file
View File

@@ -0,0 +1,140 @@
"""
LLM API
"""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.api.deps import get_current_user
from app.models.employee import Employee
from app.services.llm_service import llm_service
router = APIRouter(prefix="/api/llm", tags=["LLM"])
class ChatMessage(BaseModel):
"""聊天訊息"""
role: str # system, user, assistant
content: str
class ChatRequest(BaseModel):
"""聊天請求"""
messages: List[ChatMessage]
model: Optional[str] = None
temperature: float = 0.7
stream: bool = False
class ChatResponse(BaseModel):
"""聊天回應"""
content: str
model: str
class SimpleAskRequest(BaseModel):
"""簡單問答請求"""
question: str
system_prompt: Optional[str] = "You are a helpful assistant."
model: Optional[str] = None
class KPIAnalyzeRequest(BaseModel):
"""KPI 分析請求"""
kpi_data: dict
@router.get("/models")
def list_models(current_user: Employee = Depends(get_current_user)):
"""
列出可用的 LLM 模型
"""
models = llm_service.list_models()
return {"models": models}
@router.post("/chat", response_model=ChatResponse)
def chat(
data: ChatRequest,
current_user: Employee = Depends(get_current_user),
):
"""
聊天完成請求
"""
if data.stream:
raise HTTPException(
status_code=400,
detail="請使用 /chat/stream 端點進行串流請求",
)
messages = [{"role": m.role, "content": m.content} for m in data.messages]
content = llm_service.chat(
messages=messages,
model=data.model,
temperature=data.temperature,
)
return ChatResponse(
content=content,
model=data.model or llm_service.default_model,
)
@router.post("/chat/stream")
async def chat_stream(
data: ChatRequest,
current_user: Employee = Depends(get_current_user),
):
"""
串流聊天請求
"""
messages = [{"role": m.role, "content": m.content} for m in data.messages]
def generate():
for chunk in llm_service.chat_stream(
messages=messages,
model=data.model,
temperature=data.temperature,
):
yield f"data: {chunk}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(
generate(),
media_type="text/event-stream",
)
@router.post("/ask")
def simple_ask(
data: SimpleAskRequest,
current_user: Employee = Depends(get_current_user),
):
"""
簡單問答
"""
response = llm_service.simple_ask(
question=data.question,
system_prompt=data.system_prompt,
model=data.model,
)
return {"answer": response}
@router.post("/analyze-kpi")
def analyze_kpi(
data: KPIAnalyzeRequest,
current_user: Employee = Depends(get_current_user),
):
"""
AI 分析 KPI 數據
"""
analysis = llm_service.analyze_kpi(data.kpi_data)
return {"analysis": analysis}

133
app/api/notifications.py Normal file
View File

@@ -0,0 +1,133 @@
"""
通知 API
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from app.api.deps import get_db, get_current_user
from app.models.employee import Employee
from app.services.notify_service import NotifyService
from app.schemas.notification import (
NotificationResponse,
NotificationPreferenceResponse,
NotificationPreferenceUpdate,
UnreadCountResponse,
)
from app.schemas.common import MessageResponse
router = APIRouter(prefix="/api/notifications", tags=["通知"])
@router.get("", response_model=List[NotificationResponse])
def list_notifications(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""
取得通知列表
返回當前使用者的通知,按時間倒序排列。
"""
service = NotifyService(db)
return service.get_by_recipient(current_user.id, skip, limit)
@router.get("/unread-count", response_model=UnreadCountResponse)
def get_unread_count(
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""
取得未讀數量
返回當前使用者的未讀通知數量。
"""
service = NotifyService(db)
count = service.get_unread_count(current_user.id)
return UnreadCountResponse(count=count)
@router.put("/{notification_id}/read", response_model=MessageResponse)
def mark_as_read(
notification_id: int,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""
標記已讀
將指定通知標記為已讀。
"""
service = NotifyService(db)
success = service.mark_as_read(notification_id, current_user.id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "NOTIFY001", "message": "通知不存在"},
)
return MessageResponse(message="已標記為已讀")
@router.put("/read-all", response_model=MessageResponse)
def mark_all_as_read(
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""
全部標記已讀
將當前使用者的所有通知標記為已讀。
"""
service = NotifyService(db)
count = service.mark_all_as_read(current_user.id)
return MessageResponse(message=f"已將 {count} 則通知標記為已讀")
@router.get("/preferences", response_model=NotificationPreferenceResponse)
def get_preferences(
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""
取得通知偏好
返回當前使用者的通知偏好設定。
"""
service = NotifyService(db)
pref = service.get_preferences(current_user.id)
if not pref:
# 返回預設值
return NotificationPreferenceResponse(
email_enabled=True,
in_app_enabled=True,
reminder_days_before=3,
)
return pref
@router.put("/preferences", response_model=NotificationPreferenceResponse)
def update_preferences(
data: NotificationPreferenceUpdate,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""
更新通知偏好
更新當前使用者的通知偏好設定。
"""
service = NotifyService(db)
return service.update_preferences(
current_user.id,
email_enabled=data.email_enabled,
in_app_enabled=data.in_app_enabled,
reminder_days_before=data.reminder_days_before,
)