feat: Add AI report generation with DIFY integration
- Add Users table for display name resolution from AD authentication - Integrate DIFY AI service for report content generation - Create docx assembly service with image embedding from MinIO - Add REST API endpoints for report generation and download - Add WebSocket notifications for generation progress - Add frontend UI with progress modal and download functionality - Add integration tests for report generation flow Report sections (Traditional Chinese): - 事件摘要 (Summary) - 時間軸 (Timeline) - 參與人員 (Participants) - 處理過程 (Resolution Process) - 目前狀態 (Current Status) - 最終處置結果 (Final Resolution) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
資料表結構:
|
||||
- user_sessions: 儲存使用者 session 資料,包含加密密碼用於自動刷新
|
||||
- users: 永久儲存使用者資訊 (用於報告生成時的姓名解析)
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Index
|
||||
from datetime import datetime
|
||||
@@ -29,3 +30,39 @@ class UserSession(Base):
|
||||
DateTime, default=datetime.utcnow, nullable=False, comment="Last API request time"
|
||||
)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Permanent user information for display name resolution in reports
|
||||
|
||||
This table stores user information from AD API and persists even after
|
||||
session expiration. Used for:
|
||||
- Displaying user names (instead of emails) in generated reports
|
||||
- Tracking user metadata (office location, job title)
|
||||
"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
user_id = Column(
|
||||
String(255), primary_key=True, comment="User email address (e.g., ymirliu@panjit.com.tw)"
|
||||
)
|
||||
display_name = Column(
|
||||
String(255), nullable=False, comment="Display name from AD (e.g., 'ymirliu 劉念蓉')"
|
||||
)
|
||||
office_location = Column(
|
||||
String(100), nullable=True, comment="Office location from AD (e.g., '高雄')"
|
||||
)
|
||||
job_title = Column(
|
||||
String(100), nullable=True, comment="Job title from AD"
|
||||
)
|
||||
last_login_at = Column(
|
||||
DateTime, nullable=True, comment="Last login timestamp"
|
||||
)
|
||||
created_at = Column(
|
||||
DateTime, default=datetime.utcnow, nullable=False, comment="First login timestamp"
|
||||
)
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index("ix_users_display_name", "display_name"),
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.modules.auth.schemas import LoginRequest, LoginResponse, LogoutResponse
|
||||
from app.modules.auth.services.ad_client import ad_auth_service
|
||||
from app.modules.auth.services.encryption import encryption_service
|
||||
from app.modules.auth.services.session_service import session_service
|
||||
from app.modules.auth.services.user_service import upsert_user
|
||||
from fastapi import Header
|
||||
from typing import Optional
|
||||
|
||||
@@ -30,10 +31,11 @@ async def login(request: LoginRequest, db: Session = Depends(get_db)):
|
||||
|
||||
流程:
|
||||
1. 呼叫 AD API 驗證憑證
|
||||
2. 加密密碼(用於自動刷新)
|
||||
3. 生成 internal token (UUID)
|
||||
4. 儲存 session 到資料庫
|
||||
5. 回傳 internal token 和 display_name
|
||||
2. 儲存/更新使用者資訊到 users 表(用於報告姓名解析)
|
||||
3. 加密密碼(用於自動刷新)
|
||||
4. 生成 internal token (UUID)
|
||||
5. 儲存 session 到資料庫
|
||||
6. 回傳 internal token 和 display_name
|
||||
"""
|
||||
try:
|
||||
# Step 1: Authenticate with AD API
|
||||
@@ -52,10 +54,19 @@ async def login(request: LoginRequest, db: Session = Depends(get_db)):
|
||||
detail="Authentication service unavailable",
|
||||
)
|
||||
|
||||
# Step 2: Encrypt password for future auto-refresh
|
||||
# Step 2: Upsert user info for report generation (permanent storage)
|
||||
upsert_user(
|
||||
db=db,
|
||||
user_id=ad_result["email"],
|
||||
display_name=ad_result["username"],
|
||||
office_location=ad_result.get("office_location"),
|
||||
job_title=ad_result.get("job_title"),
|
||||
)
|
||||
|
||||
# Step 3: Encrypt password for future auto-refresh
|
||||
encrypted_password = encryption_service.encrypt_password(request.password)
|
||||
|
||||
# Step 3 & 4: Generate internal token and create session
|
||||
# Step 4 & 5: Generate internal token and create session
|
||||
user_session = session_service.create_session(
|
||||
db=db,
|
||||
username=request.username,
|
||||
|
||||
@@ -31,6 +31,9 @@ class ADAuthService:
|
||||
Dict containing:
|
||||
- token: AD authentication token
|
||||
- username: Display name from AD
|
||||
- email: User email address
|
||||
- office_location: Office location (optional)
|
||||
- job_title: Job title (optional)
|
||||
- expires_at: Estimated token expiry datetime
|
||||
|
||||
Raises:
|
||||
@@ -58,6 +61,9 @@ class ADAuthService:
|
||||
ad_token = token_data.get("access_token")
|
||||
user_info = token_data.get("userInfo", {})
|
||||
display_name = user_info.get("name") or username
|
||||
email = user_info.get("email") or username
|
||||
office_location = user_info.get("officeLocation")
|
||||
job_title = user_info.get("jobTitle")
|
||||
|
||||
if not ad_token:
|
||||
raise ValueError("No token received from AD API")
|
||||
@@ -74,7 +80,14 @@ class ADAuthService:
|
||||
# Fallback: assume 1 hour if not provided
|
||||
expires_at = datetime.utcnow() + timedelta(hours=1)
|
||||
|
||||
return {"token": ad_token, "username": display_name, "expires_at": expires_at}
|
||||
return {
|
||||
"token": ad_token,
|
||||
"username": display_name,
|
||||
"email": email,
|
||||
"office_location": office_location,
|
||||
"job_title": job_title,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
# Authentication failed (401) or other HTTP errors
|
||||
|
||||
89
app/modules/auth/services/user_service.py
Normal file
89
app/modules/auth/services/user_service.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""User service for permanent user information storage
|
||||
|
||||
This service handles upsert operations for the users table,
|
||||
which stores display names and metadata for report generation.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.modules.auth.models import User
|
||||
|
||||
|
||||
def upsert_user(
|
||||
db: Session,
|
||||
user_id: str,
|
||||
display_name: str,
|
||||
office_location: Optional[str] = None,
|
||||
job_title: Optional[str] = None,
|
||||
) -> User:
|
||||
"""Create or update user record with AD information
|
||||
|
||||
This function is called on every successful login to keep
|
||||
user information up to date. Uses SQLAlchemy merge for
|
||||
atomic upsert operation.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: User email address (primary key)
|
||||
display_name: Display name from AD API
|
||||
office_location: Office location from AD API (optional)
|
||||
job_title: Job title from AD API (optional)
|
||||
|
||||
Returns:
|
||||
User: The created or updated user record
|
||||
"""
|
||||
# Check if user exists
|
||||
existing_user = db.query(User).filter(User.user_id == user_id).first()
|
||||
|
||||
if existing_user:
|
||||
# Update existing user
|
||||
existing_user.display_name = display_name
|
||||
existing_user.office_location = office_location
|
||||
existing_user.job_title = job_title
|
||||
existing_user.last_login_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(existing_user)
|
||||
return existing_user
|
||||
else:
|
||||
# Create new user
|
||||
new_user = User(
|
||||
user_id=user_id,
|
||||
display_name=display_name,
|
||||
office_location=office_location,
|
||||
job_title=job_title,
|
||||
last_login_at=datetime.utcnow(),
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
return new_user
|
||||
|
||||
|
||||
def get_user_by_id(db: Session, user_id: str) -> Optional[User]:
|
||||
"""Get user by user_id (email)
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: User email address
|
||||
|
||||
Returns:
|
||||
User or None if not found
|
||||
"""
|
||||
return db.query(User).filter(User.user_id == user_id).first()
|
||||
|
||||
|
||||
def get_display_name(db: Session, user_id: str) -> str:
|
||||
"""Get display name for a user, falling back to email if not found
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: User email address
|
||||
|
||||
Returns:
|
||||
Display name or email address as fallback
|
||||
"""
|
||||
user = get_user_by_id(db, user_id)
|
||||
if user:
|
||||
return user.display_name
|
||||
return user_id # Fallback to email if user not in database
|
||||
Reference in New Issue
Block a user