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

137
app/core/config.py Normal file
View 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()

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