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:
137
app/core/config.py
Normal file
137
app/core/config.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
應用程式設定模組
|
||||
使用 Pydantic Settings 管理環境變數
|
||||
"""
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""應用程式設定"""
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False
|
||||
)
|
||||
|
||||
# 應用程式
|
||||
app_name: str = "每日報導APP"
|
||||
app_env: Literal["development", "staging", "production"] = "development"
|
||||
debug: bool = Field(
|
||||
default=False, # 預設為 False,更安全
|
||||
description="除錯模式,僅開發環境使用"
|
||||
)
|
||||
secret_key: str = Field(
|
||||
default="change-me-in-production",
|
||||
description="應用程式密鑰,生產環境必須透過環境變數設定"
|
||||
)
|
||||
|
||||
# 資料庫
|
||||
db_host: str = "localhost"
|
||||
db_port: int = 3306
|
||||
db_name: str = "daily_news_app"
|
||||
db_user: str = "root"
|
||||
db_password: str = ""
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
if self.db_host == "sqlite":
|
||||
return f"sqlite:///{self.db_name}.db"
|
||||
return f"mysql+pymysql://{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}?charset=utf8mb4"
|
||||
|
||||
@property
|
||||
def async_database_url(self) -> str:
|
||||
if self.db_host == "sqlite":
|
||||
return f"sqlite+aiosqlite:///{self.db_name}.db"
|
||||
return f"mysql+aiomysql://{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}?charset=utf8mb4"
|
||||
|
||||
# JWT
|
||||
jwt_secret_key: str = Field(
|
||||
default="change-me",
|
||||
description="JWT 簽章密鑰,生產環境必須透過環境變數設定"
|
||||
)
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_access_token_expire_minutes: int = Field(
|
||||
default=480, # 開發環境預設值
|
||||
description="JWT Token 過期時間(分鐘),建議生產環境設為 60-120 分鐘"
|
||||
)
|
||||
|
||||
# LDAP
|
||||
ldap_server: str = ""
|
||||
ldap_port: int = 389
|
||||
ldap_base_dn: str = ""
|
||||
ldap_bind_dn: str = ""
|
||||
ldap_bind_password: str = ""
|
||||
|
||||
# LLM
|
||||
llm_provider: Literal["gemini", "openai", "ollama"] = "gemini"
|
||||
gemini_api_key: str = ""
|
||||
gemini_model: str = "gemini-1.5-pro"
|
||||
openai_api_key: str = ""
|
||||
openai_model: str = "gpt-4o"
|
||||
ollama_endpoint: str = "http://localhost:11434"
|
||||
ollama_model: str = "llama3"
|
||||
|
||||
# SMTP
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 587
|
||||
smtp_username: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_from_email: str = ""
|
||||
smtp_from_name: str = "每日報導系統"
|
||||
|
||||
# 爬蟲
|
||||
crawl_schedule_time: str = "08:00"
|
||||
crawl_request_delay: int = 3
|
||||
crawl_max_retries: int = 3
|
||||
|
||||
# Digitimes
|
||||
digitimes_username: str = ""
|
||||
digitimes_password: str = ""
|
||||
|
||||
# 資料保留
|
||||
data_retention_days: int = 60
|
||||
|
||||
# PDF
|
||||
pdf_logo_path: str = ""
|
||||
pdf_header_text: str = ""
|
||||
pdf_footer_text: str = "本報告僅供內部參考使用"
|
||||
|
||||
# CORS 設定
|
||||
cors_origins: list[str] = Field(
|
||||
default=["http://localhost:3000", "http://localhost:8000"],
|
||||
description="允許的 CORS 來源列表,生產環境必須明確指定,不能使用 *"
|
||||
)
|
||||
|
||||
# 管理員預設密碼
|
||||
admin_password: str = Field(
|
||||
default="admin123",
|
||||
description="管理員預設密碼"
|
||||
)
|
||||
|
||||
|
||||
def validate_secrets():
|
||||
"""驗證生產環境的密鑰設定"""
|
||||
if settings.app_env == "production":
|
||||
if settings.secret_key == "change-me-in-production":
|
||||
raise ValueError("生產環境必須設定 SECRET_KEY 環境變數")
|
||||
if settings.jwt_secret_key == "change-me":
|
||||
raise ValueError("生產環境必須設定 JWT_SECRET_KEY 環境變數")
|
||||
if len(settings.secret_key) < 32:
|
||||
raise ValueError("SECRET_KEY 長度必須至少 32 字元")
|
||||
if len(settings.jwt_secret_key) < 32:
|
||||
raise ValueError("JWT_SECRET_KEY 長度必須至少 32 字元")
|
||||
if settings.jwt_access_token_expire_minutes > 120:
|
||||
import warnings
|
||||
warnings.warn("生產環境 JWT Token 過期時間建議不超過 120 分鐘")
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""取得設定實例(快取)"""
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
81
app/core/logging_config.py
Normal file
81
app/core/logging_config.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
日誌系統設定模組
|
||||
"""
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class SensitiveFilter(logging.Filter):
|
||||
"""過濾敏感資訊的日誌過濾器"""
|
||||
|
||||
def filter(self, record):
|
||||
"""過濾包含敏感資訊的日誌訊息"""
|
||||
sensitive_keywords = ['password', 'secret', 'key', 'token', 'api_key', 'db_password']
|
||||
msg = str(record.getMessage()).lower()
|
||||
|
||||
for keyword in sensitive_keywords:
|
||||
if keyword in msg:
|
||||
# 只記錄錯誤類型,不記錄詳細內容
|
||||
record.msg = f"[敏感資訊已過濾] {record.name}"
|
||||
record.args = ()
|
||||
break
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def setup_logging():
|
||||
"""設定日誌系統"""
|
||||
# 建立 logs 目錄
|
||||
log_dir = Path("logs")
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
|
||||
# 設定日誌等級
|
||||
log_level = logging.DEBUG if settings.debug else logging.INFO
|
||||
|
||||
# 設定日誌格式
|
||||
log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
date_format = '%Y-%m-%d %H:%M:%S'
|
||||
|
||||
# 設定處理器
|
||||
handlers = [
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler('logs/app.log', encoding='utf-8')
|
||||
]
|
||||
|
||||
# 如果是生產環境,也記錄錯誤到單獨的檔案
|
||||
if settings.app_env == "production":
|
||||
error_handler = logging.FileHandler('logs/error.log', encoding='utf-8')
|
||||
error_handler.setLevel(logging.ERROR)
|
||||
handlers.append(error_handler)
|
||||
|
||||
# 設定基本配置
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format=log_format,
|
||||
datefmt=date_format,
|
||||
handlers=handlers
|
||||
)
|
||||
|
||||
# 應用敏感資訊過濾器
|
||||
sensitive_filter = SensitiveFilter()
|
||||
for handler in logging.root.handlers:
|
||||
handler.addFilter(sensitive_filter)
|
||||
|
||||
# 設定第三方庫的日誌等級
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||
|
||||
return logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 初始化日誌系統
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
118
app/core/security.py
Normal file
118
app/core/security.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
安全認證模組
|
||||
處理密碼雜湊、JWT Token、LDAP 認證
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Any
|
||||
from jose import JWTError, jwt
|
||||
import bcrypt
|
||||
from ldap3 import Server, Connection, ALL, NTLM
|
||||
from ldap3.core.exceptions import LDAPException
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
import logging
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""驗證密碼"""
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode('utf-8'),
|
||||
hashed_password.encode('utf-8')
|
||||
)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""產生密碼雜湊"""
|
||||
return bcrypt.hashpw(
|
||||
password.encode('utf-8'),
|
||||
bcrypt.gensalt()
|
||||
).decode('utf-8')
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""建立 JWT Access Token"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.jwt_access_token_expire_minutes)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> Optional[dict]:
|
||||
"""解碼 JWT Access Token"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def verify_ldap_credentials(username: str, password: str) -> Optional[dict]:
|
||||
"""
|
||||
驗證 LDAP/AD 憑證
|
||||
|
||||
Returns:
|
||||
成功時返回用戶資訊 dict,失敗返回 None
|
||||
"""
|
||||
if not settings.ldap_server:
|
||||
return None
|
||||
|
||||
try:
|
||||
server = Server(settings.ldap_server, port=settings.ldap_port, get_info=ALL)
|
||||
|
||||
# 嘗試綁定(使用 NTLM 或簡單綁定)
|
||||
user_dn = f"{username}@{settings.ldap_base_dn.replace('DC=', '').replace(',', '.')}"
|
||||
|
||||
conn = Connection(
|
||||
server,
|
||||
user=user_dn,
|
||||
password=password,
|
||||
authentication=NTLM,
|
||||
auto_bind=True
|
||||
)
|
||||
|
||||
if conn.bound:
|
||||
# 查詢用戶資訊
|
||||
# 轉義特殊字元,防止 LDAP 注入
|
||||
safe_username = escape_filter_chars(username)
|
||||
search_filter = f"(sAMAccountName={safe_username})"
|
||||
conn.search(
|
||||
settings.ldap_base_dn,
|
||||
search_filter,
|
||||
attributes=['displayName', 'mail', 'department']
|
||||
)
|
||||
|
||||
if conn.entries:
|
||||
entry = conn.entries[0]
|
||||
return {
|
||||
"username": username,
|
||||
"display_name": str(entry.displayName) if hasattr(entry, 'displayName') else username,
|
||||
"email": str(entry.mail) if hasattr(entry, 'mail') else None,
|
||||
"department": str(entry.department) if hasattr(entry, 'department') else None
|
||||
}
|
||||
|
||||
conn.unbind()
|
||||
return {"username": username, "display_name": username}
|
||||
|
||||
return None
|
||||
|
||||
except LDAPException as e:
|
||||
logger.error("LDAP 認證失敗", exc_info=True) # 不記錄詳細錯誤
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("LDAP 連線錯誤", exc_info=True) # 不記錄詳細錯誤
|
||||
return None
|
||||
|
||||
|
||||
class TokenData:
|
||||
"""Token 資料結構"""
|
||||
def __init__(self, user_id: int, username: str, role: str):
|
||||
self.user_id = user_id
|
||||
self.username = username
|
||||
self.role = role
|
||||
Reference in New Issue
Block a user