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

View File

@@ -0,0 +1,24 @@
{
"permissions": {
"allow": [
"Bash(python run.py:*)",
"Bash(pip install:*)",
"Bash(curl:*)",
"Bash(python scripts/init_db_sqlite.py:*)",
"Bash(powershell -Command:*)",
"Bash(dir c:AICodingdaily-news-app.zip)",
"Bash(python:*)",
"Bash(dir /B /S c:Users91223Downloadsdaily-news-app)",
"Bash(dir:*)",
"Bash(find:*)",
"Bash(ls:*)",
"Bash(du:*)",
"Bash(git init:*)",
"Bash(git config:*)",
"Bash(git remote add:*)",
"Bash(git add:*)"
],
"deny": [],
"ask": []
}
}

68
.env.example Normal file
View File

@@ -0,0 +1,68 @@
# ===========================================
# 每日報導 APP 環境設定
# ===========================================
# 應用程式設定
APP_NAME=每日報導APP
APP_ENV=development
DEBUG=true
SECRET_KEY=your-secret-key-change-in-production
# 資料庫設定
DB_HOST=localhost
DB_PORT=3306
DB_NAME=daily_news_app
DB_USER=root
DB_PASSWORD=your-password
# JWT 設定
JWT_SECRET_KEY=your-jwt-secret-key
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=480
# AD/LDAP 設定
LDAP_SERVER=ldap://your-ad-server.com
LDAP_PORT=389
LDAP_BASE_DN=DC=company,DC=com
LDAP_BIND_DN=CN=service_account,OU=Users,DC=company,DC=com
LDAP_BIND_PASSWORD=
# LLM 設定三選一gemini / openai / ollama
LLM_PROVIDER=gemini
# Google Gemini API
GEMINI_API_KEY=
GEMINI_MODEL=gemini-1.5-pro
# OpenAI API
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4o
# Ollama 地端部署
OLLAMA_ENDPOINT=http://localhost:11434
OLLAMA_MODEL=llama3
# SMTP 設定
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=noreply@company.com
SMTP_FROM_NAME=每日報導系統
# 爬蟲設定
CRAWL_SCHEDULE_TIME=08:00
CRAWL_REQUEST_DELAY=3
CRAWL_MAX_RETRIES=3
# Digitimes 登入
DIGITIMES_USERNAME=
DIGITIMES_PASSWORD=
# 資料保留
DATA_RETENTION_DAYS=60
# PDF 設定
PDF_LOGO_PATH=
PDF_HEADER_TEXT=
PDF_FOOTER_TEXT=本報告僅供內部參考使用

122
.gitignore vendored Normal file
View File

@@ -0,0 +1,122 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# 環境變數與敏感資訊
.env
.env.local
.env.*.local
*.env
.envrc
# 資料庫
*.db
*.sqlite
*.sqlite3
*.db-journal
daily_news_app.db
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.project
.pydevproject
# 日誌
*.log
logs/
*.log.*
# 上傳檔案
uploads/
!uploads/.gitkeep
# 測試
.pytest_cache/
.coverage
htmlcov/
.tox/
.hypothesis/
.mypy_cache/
.dmypy.json
dmypy.json
# 系統檔案
.DS_Store
Thumbs.db
desktop.ini
# Docker
docker-compose.override.yml
# 備份檔案
*.bak
*.backup
*.old
*.tmp
# 虛擬環境
venv/
env/
ENV/
.venv
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# Celery
celerybeat-schedule
celerybeat.pid
# SageMath
*.sage.py
# Spyder
.spyderproject
.spyproject
# Rope
.ropeproject
# mkdocs
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre
.pyre/

36
Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
# 每日報導 APP - Dockerfile
FROM python:3.11-slim
# 設定工作目錄
WORKDIR /app
# 安裝系統依賴PDF 生成需要)
RUN apt-get update && apt-get install -y \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libgdk-pixbuf2.0-0 \
libffi-dev \
shared-mime-info \
&& rm -rf /var/lib/apt/lists/*
# 複製依賴檔案
COPY requirements.txt .
# 安裝 Python 依賴
RUN pip install --no-cache-dir -r requirements.txt
# 複製應用程式
COPY . .
# 建立上傳目錄
RUN mkdir -p uploads/logos
# 設定環境變數
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
# 暴露端口
EXPOSE 8000
# 啟動命令
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

186
README.md Normal file
View File

@@ -0,0 +1,186 @@
# 每日報導 APP
企業內部新聞彙整與分析系統
## 功能特色
- 🔍 **自動新聞抓取**:支援 Digitimes、經濟日報、工商時報
- 🤖 **AI 智慧摘要**:支援 OpenAI / Claude / Ollama
- 📊 **群組管理**:依產業別、議題分類關鍵字
- 📧 **Email 通知**:報告發布自動通知訂閱者
- 📱 **響應式設計**:支援手機閱讀
- 🔐 **AD/LDAP 整合**:企業帳號統一認證
## 技術架構
| 層級 | 技術 |
|------|------|
| 後端 | FastAPI + Python 3.11 |
| 資料庫 | MySQL 8.0 |
| 認證 | JWT + AD/LDAP |
| LLM | OpenAI / Claude / Ollama |
| 部署 | Docker + 1Panel |
## 快速開始
### 1. 環境準備
```bash
# 複製環境設定
cp .env.example .env
# 編輯設定
vim .env
```
### 2. 啟動服務
```bash
# 使用 Docker Compose
docker-compose up -d
# 查看日誌
docker-compose logs -f app
```
### 3. 初始化資料
```bash
# 進入容器
docker exec -it daily-news-app bash
# 執行初始化
python scripts/init_data.py
```
### 4. 存取系統
- API 文件http://localhost:8000/docs
- 健康檢查http://localhost:8000/health
## 目錄結構
```
daily-news-app/
├── app/
│ ├── api/v1/endpoints/ # API 端點
│ ├── core/ # 核心設定
│ ├── db/ # 資料庫連線
│ ├── models/ # 資料模型
│ ├── schemas/ # Pydantic Schema
│ ├── services/ # 商業邏輯服務
│ └── main.py # 應用程式入口
├── scripts/ # 初始化腳本
├── templates/ # Email 模板
├── tests/ # 測試檔案
├── docker-compose.yml
├── Dockerfile
├── requirements.txt
└── README.md
```
## API 端點
| 分類 | 端點 | 說明 |
|------|------|------|
| Auth | POST /api/v1/auth/login | 登入 |
| Auth | GET /api/v1/auth/me | 當前用戶 |
| Users | GET /api/v1/users | 用戶列表 |
| Groups | GET /api/v1/groups | 群組列表 |
| Reports | GET /api/v1/reports | 報告列表 |
| Reports | POST /api/v1/reports/{id}/publish | 發布報告 |
| Subscriptions | GET /api/v1/subscriptions | 我的訂閱 |
完整 API 文件請參閱 `/docs`
## 環境變數
| 變數 | 說明 | 預設值 |
|------|------|--------|
| DB_HOST | 資料庫主機 | localhost |
| DB_PASSWORD | 資料庫密碼 | - |
| JWT_SECRET_KEY | JWT 密鑰 | - |
| LLM_PROVIDER | LLM 提供者 | claude |
| ANTHROPIC_API_KEY | Claude API Key | - |
| SMTP_HOST | SMTP 伺服器 | - |
## 開發指南
### 本地開發
```bash
# 建立虛擬環境
python -m venv venv
source venv/bin/activate
# 安裝依賴
pip install -r requirements.txt
# 啟動開發伺服器
uvicorn app.main:app --reload
```
### 程式碼風格
```bash
# 格式化
black app/
isort app/
# 類型檢查
mypy app/
```
### 執行測試
```bash
pytest tests/ -v
```
## 部署
### 1Panel 部署
1. 在 1Panel 中新增「網站」
2. 選擇 Docker Compose 部署
3. 上傳專案檔案
4. 設定環境變數
5. 啟動服務
### 使用 Ollama地端 LLM
```bash
# 啟動包含 Ollama 的服務
docker-compose --profile ollama up -d
# 下載模型
docker exec -it daily-news-ollama ollama pull llama3
```
## 維運
### 日誌查看
```bash
docker-compose logs -f app
```
### 資料備份
```bash
docker exec daily-news-mysql mysqldump -u root -p daily_news_app > backup.sql
```
### 資料庫遷移
```bash
docker exec -it daily-news-app alembic upgrade head
```
## License
Proprietary - Internal Use Only
## 聯絡資訊
如有問題請聯繫 IT 部門

View 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

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

View 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"}
)

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

View 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": "訂閱更新成功"}

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

26
app/api/v1/router.py Normal file
View File

@@ -0,0 +1,26 @@
"""
API v1 路由總管理
"""
from fastapi import APIRouter
from app.api.v1.endpoints import auth, users, groups, reports, subscriptions, settings as settings_ep
api_router = APIRouter()
# 認證
api_router.include_router(auth.router, prefix="/auth", tags=["Auth"])
# 用戶管理
api_router.include_router(users.router, prefix="/users", tags=["Users"])
# 群組管理
api_router.include_router(groups.router, prefix="/groups", tags=["Groups"])
# 報告管理
api_router.include_router(reports.router, prefix="/reports", tags=["Reports"])
# 訂閱管理
api_router.include_router(subscriptions.router, prefix="/subscriptions", tags=["Subscriptions"])
# 系統設定
api_router.include_router(settings_ep.router, prefix="/settings", tags=["Settings"])

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

49
app/db/session.py Normal file
View File

@@ -0,0 +1,49 @@
"""
資料庫連線與 Session 管理
"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session, DeclarativeBase
from sqlalchemy.pool import QueuePool
from typing import Generator
from app.core.config import settings
# 建立引擎
# 建立引擎
connect_args = {}
if settings.database_url.startswith("sqlite"):
connect_args["check_same_thread"] = False
engine = create_engine(
settings.database_url,
poolclass=QueuePool,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
echo=settings.debug,
connect_args=connect_args
)
# Session 工廠
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
"""SQLAlchemy 基礎類別"""
pass
def get_db() -> Generator[Session, None, None]:
"""取得資料庫 Session依賴注入用"""
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db() -> None:
"""初始化資料庫(建立所有表)"""
from app.models import user, news, group, report, interaction, system
Base.metadata.create_all(bind=engine)

136
app/main.py Normal file
View File

@@ -0,0 +1,136 @@
"""
每日報導 APP - FastAPI 主應用程式
"""
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings, validate_secrets
from app.core.logging_config import logger
from app.api.v1.router import api_router
@asynccontextmanager
async def lifespan(app: FastAPI):
"""應用程式生命週期管理"""
# 啟動時執行
# 驗證生產環境的密鑰設定
try:
validate_secrets()
except ValueError as e:
logger.error(f"設定驗證失敗: {e}")
raise
logger.info(f"🚀 {settings.app_name} 啟動中...")
logger.info(f"📊 環境: {settings.app_env}")
# 不輸出完整的資料庫連線資訊,避免洩露敏感資訊
logger.info(f"🔗 資料庫連線: {settings.db_host}:{settings.db_port}/{settings.db_name[:3]}***")
yield
# 關閉時執行
logger.info(f"👋 {settings.app_name} 關閉中...")
def create_app() -> FastAPI:
"""建立 FastAPI 應用程式"""
# 生產環境強制關閉 Debug
if settings.app_env == "production" and settings.debug:
import warnings
warnings.warn("生產環境不應啟用 Debug 模式,已自動關閉")
settings.debug = False
app = FastAPI(
title=settings.app_name,
description="企業內部新聞彙整與分析系統 API",
version="1.0.0",
docs_url="/docs" if settings.debug else None, # 生產環境關閉
redoc_url="/redoc" if settings.debug else None, # 生產環境關閉
lifespan=lifespan
)
# CORS 設定
# 生產環境必須明確指定來源
if settings.app_env == "production":
if "*" in settings.cors_origins:
raise ValueError("生產環境不允許使用 CORS origins = ['*']")
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins if not settings.debug else ["*"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization"],
max_age=3600,
)
# 註冊路由
app.include_router(api_router, prefix="/api/v1")
# 掛載靜態檔案目錄
static_dir = Path(__file__).parent.parent / "templates" / "js"
if static_dir.exists():
app.mount("/static/js", StaticFiles(directory=str(static_dir)), name="static_js")
# 根路徑 - UI 介面
@app.get("/", response_class=HTMLResponse)
async def root():
"""返回 UI 介面"""
ui_file = Path(__file__).parent.parent / "templates" / "index.html"
if ui_file.exists():
return ui_file.read_text(encoding="utf-8")
# 如果沒有 UI 文件,返回 JSON 資訊
return HTMLResponse(content=f"""
<!DOCTYPE html>
<html>
<head><title>{settings.app_name}</title></head>
<body>
<h1>{settings.app_name}</h1>
<p>版本: 1.0.0</p>
<p>企業內部新聞彙整與分析系統</p>
<ul>
<li><a href="/docs">API 文檔 (Swagger)</a></li>
<li><a href="/redoc">API 文檔 (ReDoc)</a></li>
<li><a href="/health">健康檢查</a></li>
<li><a href="/api/v1">API 端點</a></li>
</ul>
</body>
</html>
""")
# API 資訊端點
@app.get("/api/info")
async def api_info():
"""API 資訊"""
return {
"app": settings.app_name,
"version": "1.0.0",
"description": "企業內部新聞彙整與分析系統",
"docs": "/docs" if settings.debug else None,
"redoc": "/redoc" if settings.debug else None,
"health": "/health",
"api": "/api/v1"
}
# 健康檢查端點
@app.get("/health")
async def health_check():
return {"status": "healthy", "app": settings.app_name}
return app
app = create_app()
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host="127.0.0.1",
port=8000,
reload=settings.debug
)

25
app/models/__init__.py Normal file
View File

@@ -0,0 +1,25 @@
"""
資料模型模組
匯出所有 SQLAlchemy 模型
"""
from app.models.user import User, Role, AuthType
from app.models.news import NewsSource, NewsArticle, CrawlJob, SourceType, CrawlStatus
from app.models.group import Group, Keyword, ArticleGroupMatch, GroupCategory
from app.models.report import Report, ReportArticle, ReportStatus
from app.models.interaction import Subscription, Favorite, Comment, Note
from app.models.system import SystemSetting, AuditLog, NotificationLog, SettingType, NotificationType, NotificationStatus
__all__ = [
# User
"User", "Role", "AuthType",
# News
"NewsSource", "NewsArticle", "CrawlJob", "SourceType", "CrawlStatus",
# Group
"Group", "Keyword", "ArticleGroupMatch", "GroupCategory",
# Report
"Report", "ReportArticle", "ReportStatus",
# Interaction
"Subscription", "Favorite", "Comment", "Note",
# System
"SystemSetting", "AuditLog", "NotificationLog", "SettingType", "NotificationType", "NotificationStatus",
]

82
app/models/group.py Normal file
View File

@@ -0,0 +1,82 @@
"""
群組與關鍵字資料模型
"""
from datetime import datetime
from sqlalchemy import String, Boolean, ForeignKey, Text, JSON, Enum as SQLEnum, UniqueConstraint, Index, DECIMAL
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import Optional, List
import enum
from app.db.session import Base
class GroupCategory(str, enum.Enum):
"""群組分類"""
INDUSTRY = "industry"
TOPIC = "topic"
class Group(Base):
"""群組表"""
__tablename__ = "groups"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, comment="群組名稱")
description: Mapped[Optional[str]] = mapped_column(Text, comment="群組描述")
category: Mapped[GroupCategory] = mapped_column(SQLEnum(GroupCategory), nullable=False, comment="分類")
ai_background: Mapped[Optional[str]] = mapped_column(Text, comment="AI背景資訊設定")
ai_prompt: Mapped[Optional[str]] = mapped_column(Text, comment="AI摘要方向提示")
is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否啟用")
created_by: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), comment="建立者ID")
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(default=datetime.utcnow, onupdate=datetime.utcnow)
# 關聯
keywords: Mapped[List["Keyword"]] = relationship(back_populates="group", cascade="all, delete-orphan")
article_matches: Mapped[List["ArticleGroupMatch"]] = relationship(back_populates="group", cascade="all, delete-orphan")
reports: Mapped[List["Report"]] = relationship(back_populates="group")
subscriptions: Mapped[List["Subscription"]] = relationship(back_populates="group", cascade="all, delete-orphan")
class Keyword(Base):
"""關鍵字表"""
__tablename__ = "keywords"
__table_args__ = (
UniqueConstraint("group_id", "keyword", name="uk_group_keyword"),
Index("idx_keywords_keyword", "keyword"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
group_id: Mapped[int] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, comment="所屬群組ID")
keyword: Mapped[str] = mapped_column(String(100), nullable=False, comment="關鍵字")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# 關聯
group: Mapped["Group"] = relationship(back_populates="keywords")
class ArticleGroupMatch(Base):
"""新聞-群組匹配關聯表"""
__tablename__ = "article_group_matches"
__table_args__ = (
UniqueConstraint("article_id", "group_id", name="uk_article_group"),
Index("idx_matches_group", "group_id"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
article_id: Mapped[int] = mapped_column(ForeignKey("news_articles.id", ondelete="CASCADE"), nullable=False)
group_id: Mapped[int] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), nullable=False)
matched_keywords: Mapped[Optional[list]] = mapped_column(JSON, comment="匹配到的關鍵字列表")
match_score: Mapped[Optional[float]] = mapped_column(DECIMAL(5, 2), comment="匹配分數")
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# 關聯
article: Mapped["NewsArticle"] = relationship(back_populates="group_matches")
group: Mapped["Group"] = relationship(back_populates="article_matches")
# 避免循環引入
from app.models.news import NewsArticle
from app.models.report import Report
from app.models.interaction import Subscription

90
app/models/interaction.py Normal file
View File

@@ -0,0 +1,90 @@
"""
讀者互動資料模型(訂閱、收藏、留言、筆記)
"""
from datetime import datetime
from sqlalchemy import String, Boolean, ForeignKey, Text, UniqueConstraint, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import Optional, List, TYPE_CHECKING
from app.db.session import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.group import Group
from app.models.report import Report
class Subscription(Base):
"""訂閱表"""
__tablename__ = "subscriptions"
__table_args__ = (
UniqueConstraint("user_id", "group_id", name="uk_user_group"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
group_id: Mapped[int] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), nullable=False)
email_notify: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否Email通知")
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# 關聯
user: Mapped["User"] = relationship(back_populates="subscriptions")
group: Mapped["Group"] = relationship(back_populates="subscriptions")
class Favorite(Base):
"""收藏表"""
__tablename__ = "favorites"
__table_args__ = (
UniqueConstraint("user_id", "report_id", name="uk_user_report"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
report_id: Mapped[int] = mapped_column(ForeignKey("reports.id", ondelete="CASCADE"), nullable=False)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# 關聯
user: Mapped["User"] = relationship(back_populates="favorites")
report: Mapped["Report"] = relationship(back_populates="favorites")
class Comment(Base):
"""留言表"""
__tablename__ = "comments"
__table_args__ = (
Index("idx_comments_report", "report_id"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
report_id: Mapped[int] = mapped_column(ForeignKey("reports.id", ondelete="CASCADE"), nullable=False)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False, comment="留言內容")
parent_id: Mapped[Optional[int]] = mapped_column(ForeignKey("comments.id"), comment="父留言ID")
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(default=datetime.utcnow, onupdate=datetime.utcnow)
# 關聯
report: Mapped["Report"] = relationship(back_populates="comments")
user: Mapped["User"] = relationship(back_populates="comments")
parent: Mapped[Optional["Comment"]] = relationship(remote_side=[id], backref="replies")
class Note(Base):
"""個人筆記表"""
__tablename__ = "notes"
__table_args__ = (
Index("idx_notes_user_report", "user_id", "report_id"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
report_id: Mapped[int] = mapped_column(ForeignKey("reports.id", ondelete="CASCADE"), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False, comment="筆記內容")
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(default=datetime.utcnow, onupdate=datetime.utcnow)
# 關聯
user: Mapped["User"] = relationship(back_populates="notes")
report: Mapped["Report"] = relationship(back_populates="notes")

100
app/models/news.py Normal file
View File

@@ -0,0 +1,100 @@
"""
新聞來源與文章資料模型
"""
from datetime import datetime
from sqlalchemy import String, Boolean, ForeignKey, Text, JSON, Enum as SQLEnum, UniqueConstraint, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import Optional, List
import enum
from app.db.session import Base
class SourceType(str, enum.Enum):
"""來源類型"""
SUBSCRIPTION = "subscription"
PUBLIC = "public"
class CrawlStatus(str, enum.Enum):
"""抓取任務狀態"""
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
class NewsSource(Base):
"""新聞來源表"""
__tablename__ = "news_sources"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(30), unique=True, nullable=False, comment="來源代碼")
name: Mapped[str] = mapped_column(String(100), nullable=False, comment="來源名稱")
base_url: Mapped[str] = mapped_column(String(255), nullable=False, comment="網站基礎URL")
source_type: Mapped[SourceType] = mapped_column(SQLEnum(SourceType), nullable=False, comment="來源類型")
login_username: Mapped[Optional[str]] = mapped_column(String(100), comment="登入帳號")
login_password_encrypted: Mapped[Optional[str]] = mapped_column(String(255), comment="加密後密碼")
is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否啟用")
crawl_config: Mapped[Optional[dict]] = mapped_column(JSON, comment="爬蟲設定")
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(default=datetime.utcnow, onupdate=datetime.utcnow)
# 關聯
articles: Mapped[List["NewsArticle"]] = relationship(back_populates="source")
crawl_jobs: Mapped[List["CrawlJob"]] = relationship(back_populates="source")
class NewsArticle(Base):
"""新聞文章表"""
__tablename__ = "news_articles"
__table_args__ = (
UniqueConstraint("source_id", "external_id", name="uk_source_external"),
Index("idx_articles_published", "published_at"),
Index("idx_articles_crawled", "crawled_at"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
source_id: Mapped[int] = mapped_column(ForeignKey("news_sources.id"), nullable=False, comment="來源ID")
external_id: Mapped[Optional[str]] = mapped_column(String(100), comment="外部文章ID")
title: Mapped[str] = mapped_column(String(500), nullable=False, comment="文章標題")
content: Mapped[Optional[str]] = mapped_column(Text, comment="文章全文")
summary: Mapped[Optional[str]] = mapped_column(Text, comment="原文摘要")
url: Mapped[str] = mapped_column(String(500), nullable=False, comment="原文連結")
author: Mapped[Optional[str]] = mapped_column(String(100), comment="作者")
published_at: Mapped[Optional[datetime]] = mapped_column(comment="發布時間")
crawled_at: Mapped[datetime] = mapped_column(default=datetime.utcnow, comment="抓取時間")
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# 關聯
source: Mapped["NewsSource"] = relationship(back_populates="articles")
group_matches: Mapped[List["ArticleGroupMatch"]] = relationship(back_populates="article", cascade="all, delete-orphan")
report_articles: Mapped[List["ReportArticle"]] = relationship(back_populates="article")
class CrawlJob(Base):
"""抓取任務記錄表"""
__tablename__ = "crawl_jobs"
__table_args__ = (
Index("idx_crawl_jobs_status", "status"),
Index("idx_crawl_jobs_scheduled", "scheduled_at"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
source_id: Mapped[int] = mapped_column(ForeignKey("news_sources.id"), nullable=False, comment="來源ID")
status: Mapped[CrawlStatus] = mapped_column(SQLEnum(CrawlStatus), default=CrawlStatus.PENDING)
scheduled_at: Mapped[datetime] = mapped_column(nullable=False, comment="排程時間")
started_at: Mapped[Optional[datetime]] = mapped_column(comment="開始時間")
completed_at: Mapped[Optional[datetime]] = mapped_column(comment="完成時間")
articles_count: Mapped[int] = mapped_column(default=0, comment="抓取文章數")
error_message: Mapped[Optional[str]] = mapped_column(Text, comment="錯誤訊息")
retry_count: Mapped[int] = mapped_column(default=0, comment="重試次數")
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# 關聯
source: Mapped["NewsSource"] = relationship(back_populates="crawl_jobs")
# 避免循環引入
from app.models.group import ArticleGroupMatch
from app.models.report import ReportArticle

79
app/models/report.py Normal file
View File

@@ -0,0 +1,79 @@
"""
報告資料模型
"""
from datetime import datetime, date
from sqlalchemy import String, Boolean, ForeignKey, Text, Date, Enum as SQLEnum, UniqueConstraint, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import Optional, List
import enum
from app.db.session import Base
class ReportStatus(str, enum.Enum):
"""報告狀態"""
DRAFT = "draft"
PENDING = "pending"
PUBLISHED = "published"
DELAYED = "delayed"
class Report(Base):
"""報告表"""
__tablename__ = "reports"
__table_args__ = (
UniqueConstraint("group_id", "report_date", name="uk_group_date"),
Index("idx_reports_status", "status"),
Index("idx_reports_date", "report_date"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
group_id: Mapped[int] = mapped_column(ForeignKey("groups.id"), nullable=False, comment="所屬群組ID")
title: Mapped[str] = mapped_column(String(200), nullable=False, comment="報告標題")
report_date: Mapped[date] = mapped_column(Date, nullable=False, comment="報告日期")
ai_summary: Mapped[Optional[str]] = mapped_column(Text, comment="AI綜合摘要")
edited_summary: Mapped[Optional[str]] = mapped_column(Text, comment="編輯後摘要")
status: Mapped[ReportStatus] = mapped_column(SQLEnum(ReportStatus), default=ReportStatus.DRAFT, comment="狀態")
published_at: Mapped[Optional[datetime]] = mapped_column(comment="發布時間")
published_by: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), comment="發布者ID")
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(default=datetime.utcnow, onupdate=datetime.utcnow)
# 關聯
group: Mapped["Group"] = relationship(back_populates="reports")
report_articles: Mapped[List["ReportArticle"]] = relationship(back_populates="report", cascade="all, delete-orphan")
favorites: Mapped[List["Favorite"]] = relationship(back_populates="report", cascade="all, delete-orphan")
comments: Mapped[List["Comment"]] = relationship(back_populates="report", cascade="all, delete-orphan")
notes: Mapped[List["Note"]] = relationship(back_populates="report", cascade="all, delete-orphan")
notifications: Mapped[List["NotificationLog"]] = relationship(back_populates="report")
@property
def final_summary(self) -> str:
"""取得最終摘要(優先使用編輯後版本)"""
return self.edited_summary or self.ai_summary or ""
class ReportArticle(Base):
"""報告-新聞關聯表"""
__tablename__ = "report_articles"
__table_args__ = (
UniqueConstraint("report_id", "article_id", name="uk_report_article"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
report_id: Mapped[int] = mapped_column(ForeignKey("reports.id", ondelete="CASCADE"), nullable=False)
article_id: Mapped[int] = mapped_column(ForeignKey("news_articles.id"), nullable=False)
is_included: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否納入報告")
display_order: Mapped[int] = mapped_column(default=0, comment="顯示順序")
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# 關聯
report: Mapped["Report"] = relationship(back_populates="report_articles")
article: Mapped["NewsArticle"] = relationship(back_populates="report_articles")
# 避免循環引入
from app.models.group import Group
from app.models.news import NewsArticle
from app.models.interaction import Favorite, Comment, Note
from app.models.system import NotificationLog

103
app/models/system.py Normal file
View File

@@ -0,0 +1,103 @@
"""
系統設定與日誌資料模型
"""
from datetime import datetime
from sqlalchemy import String, ForeignKey, Text, JSON, Enum as SQLEnum, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import Optional, TYPE_CHECKING
import enum
from app.db.session import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.report import Report
class SettingType(str, enum.Enum):
"""設定值類型"""
STRING = "string"
NUMBER = "number"
BOOLEAN = "boolean"
JSON = "json"
class NotificationType(str, enum.Enum):
"""通知類型"""
EMAIL = "email"
SYSTEM = "system"
class NotificationStatus(str, enum.Enum):
"""通知狀態"""
PENDING = "pending"
SENT = "sent"
FAILED = "failed"
class SystemSetting(Base):
"""系統設定表"""
__tablename__ = "system_settings"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
setting_key: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, comment="設定鍵")
setting_value: Mapped[Optional[str]] = mapped_column(Text, comment="設定值")
setting_type: Mapped[SettingType] = mapped_column(SQLEnum(SettingType), default=SettingType.STRING)
description: Mapped[Optional[str]] = mapped_column(String(200), comment="設定描述")
updated_by: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), comment="更新者ID")
updated_at: Mapped[datetime] = mapped_column(default=datetime.utcnow, onupdate=datetime.utcnow)
def get_value(self):
"""取得轉換後的設定值"""
if self.setting_value is None:
return None
if self.setting_type == SettingType.NUMBER:
return float(self.setting_value) if '.' in self.setting_value else int(self.setting_value)
if self.setting_type == SettingType.BOOLEAN:
return self.setting_value.lower() in ('true', '1', 'yes')
if self.setting_type == SettingType.JSON:
import json
return json.loads(self.setting_value)
return self.setting_value
class AuditLog(Base):
"""操作日誌表"""
__tablename__ = "audit_logs"
__table_args__ = (
Index("idx_audit_user", "user_id"),
Index("idx_audit_action", "action"),
Index("idx_audit_created", "created_at"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), comment="操作用戶ID")
action: Mapped[str] = mapped_column(String(50), nullable=False, comment="操作類型")
target_type: Mapped[Optional[str]] = mapped_column(String(50), comment="目標類型")
target_id: Mapped[Optional[str]] = mapped_column(String(50), comment="目標ID")
details: Mapped[Optional[dict]] = mapped_column(JSON, comment="操作詳情")
ip_address: Mapped[Optional[str]] = mapped_column(String(45), comment="IP地址")
user_agent: Mapped[Optional[str]] = mapped_column(String(500), comment="User Agent")
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
class NotificationLog(Base):
"""通知記錄表"""
__tablename__ = "notification_logs"
__table_args__ = (
Index("idx_notification_status", "status"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
report_id: Mapped[Optional[int]] = mapped_column(ForeignKey("reports.id"), comment="關聯報告ID")
notification_type: Mapped[NotificationType] = mapped_column(SQLEnum(NotificationType), default=NotificationType.EMAIL)
subject: Mapped[Optional[str]] = mapped_column(String(200), comment="通知標題")
content: Mapped[Optional[str]] = mapped_column(Text, comment="通知內容")
status: Mapped[NotificationStatus] = mapped_column(SQLEnum(NotificationStatus), default=NotificationStatus.PENDING)
sent_at: Mapped[Optional[datetime]] = mapped_column()
error_message: Mapped[Optional[str]] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# 關聯
report: Mapped[Optional["Report"]] = relationship(back_populates="notifications")

59
app/models/user.py Normal file
View File

@@ -0,0 +1,59 @@
"""
用戶與角色資料模型
"""
from datetime import datetime
from sqlalchemy import String, Boolean, ForeignKey, Text, Enum as SQLEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import Optional, List
import enum
from app.db.session import Base
class AuthType(str, enum.Enum):
"""認證類型"""
AD = "ad"
LOCAL = "local"
class Role(Base):
"""角色表"""
__tablename__ = "roles"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, comment="角色代碼")
name: Mapped[str] = mapped_column(String(50), nullable=False, comment="角色名稱")
description: Mapped[Optional[str]] = mapped_column(String(200), comment="角色描述")
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(default=datetime.utcnow, onupdate=datetime.utcnow)
# 關聯
users: Mapped[List["User"]] = relationship(back_populates="role")
class User(Base):
"""用戶表"""
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True, comment="用戶帳號")
password_hash: Mapped[Optional[str]] = mapped_column(String(255), comment="密碼雜湊")
display_name: Mapped[str] = mapped_column(String(100), nullable=False, comment="顯示名稱")
email: Mapped[Optional[str]] = mapped_column(String(100), comment="電子郵件")
auth_type: Mapped[AuthType] = mapped_column(SQLEnum(AuthType), default=AuthType.LOCAL, nullable=False, comment="認證類型")
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"), nullable=False, comment="角色ID")
is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否啟用")
last_login_at: Mapped[Optional[datetime]] = mapped_column(comment="最後登入時間")
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(default=datetime.utcnow, onupdate=datetime.utcnow)
# 關聯
role: Mapped["Role"] = relationship(back_populates="users")
subscriptions: Mapped[List["Subscription"]] = relationship(back_populates="user", cascade="all, delete-orphan")
favorites: Mapped[List["Favorite"]] = relationship(back_populates="user", cascade="all, delete-orphan")
comments: Mapped[List["Comment"]] = relationship(back_populates="user")
notes: Mapped[List["Note"]] = relationship(back_populates="user", cascade="all, delete-orphan")
# 避免循環引入
from app.models.interaction import Subscription, Favorite, Comment, Note

70
app/schemas/group.py Normal file
View File

@@ -0,0 +1,70 @@
"""
群組與關鍵字 Pydantic Schema
"""
from datetime import datetime
from typing import Optional, Literal
from pydantic import BaseModel, Field
from app.schemas.user import PaginationResponse
# ===== Keyword =====
class KeywordBase(BaseModel):
keyword: str = Field(..., max_length=100)
class KeywordCreate(KeywordBase):
pass
class KeywordResponse(KeywordBase):
id: int
is_active: bool
class Config:
from_attributes = True
# ===== Group =====
class GroupBase(BaseModel):
name: str = Field(..., max_length=100)
description: Optional[str] = None
category: Literal["industry", "topic"]
class GroupCreate(GroupBase):
ai_background: Optional[str] = None
ai_prompt: Optional[str] = None
keywords: Optional[list[str]] = None
class GroupUpdate(BaseModel):
name: Optional[str] = Field(None, max_length=100)
description: Optional[str] = None
category: Optional[Literal["industry", "topic"]] = None
ai_background: Optional[str] = None
ai_prompt: Optional[str] = None
is_active: Optional[bool] = None
class GroupResponse(GroupBase):
id: int
is_active: bool
keyword_count: Optional[int] = 0
subscriber_count: Optional[int] = 0
class Config:
from_attributes = True
class GroupDetailResponse(GroupResponse):
ai_background: Optional[str] = None
ai_prompt: Optional[str] = None
keywords: list[KeywordResponse] = []
created_at: datetime
updated_at: datetime
class GroupListResponse(BaseModel):
data: list[GroupResponse]
pagination: PaginationResponse

126
app/schemas/report.py Normal file
View File

@@ -0,0 +1,126 @@
"""
報告相關 Pydantic Schema
"""
from datetime import datetime, date
from typing import Optional, Literal
from pydantic import BaseModel, Field
from app.schemas.user import PaginationResponse
# ===== Article (簡化版) =====
class ArticleBrief(BaseModel):
id: int
title: str
source_name: str
url: str
published_at: Optional[datetime] = None
class Config:
from_attributes = True
class ArticleInReport(ArticleBrief):
is_included: bool = True
# ===== Report =====
class ReportBase(BaseModel):
title: str = Field(..., max_length=200)
class ReportUpdate(BaseModel):
title: Optional[str] = Field(None, max_length=200)
edited_summary: Optional[str] = None
article_selections: Optional[list[dict]] = None # [{article_id: int, is_included: bool}]
class GroupBrief(BaseModel):
id: int
name: str
category: str
class Config:
from_attributes = True
class ReportResponse(ReportBase):
id: int
report_date: date
status: Literal["draft", "pending", "published", "delayed"]
group: GroupBrief
article_count: Optional[int] = 0
published_at: Optional[datetime] = None
class Config:
from_attributes = True
class ReportDetailResponse(ReportResponse):
ai_summary: Optional[str] = None
edited_summary: Optional[str] = None
articles: list[ArticleInReport] = []
is_favorited: Optional[bool] = False
comment_count: Optional[int] = 0
created_at: datetime
updated_at: datetime
class ReportReviewResponse(ReportResponse):
"""專員審核用"""
ai_summary: Optional[str] = None
edited_summary: Optional[str] = None
articles: list[ArticleInReport] = []
class ReportListResponse(BaseModel):
data: list[ReportResponse]
pagination: PaginationResponse
class PublishResponse(BaseModel):
published_at: datetime
notifications_sent: int
class RegenerateSummaryResponse(BaseModel):
ai_summary: str
# ===== Article Full =====
class ArticleSourceBrief(BaseModel):
id: int
name: str
class Config:
from_attributes = True
class ArticleResponse(BaseModel):
id: int
title: str
source: ArticleSourceBrief
url: str
published_at: Optional[datetime] = None
crawled_at: datetime
class Config:
from_attributes = True
class MatchedGroup(BaseModel):
group_id: int
group_name: str
matched_keywords: list[str]
class ArticleDetailResponse(ArticleResponse):
content: Optional[str] = None
summary: Optional[str] = None
author: Optional[str] = None
matched_groups: list[MatchedGroup] = []
class ArticleListResponse(BaseModel):
data: list[ArticleResponse]
pagination: PaginationResponse

88
app/schemas/user.py Normal file
View File

@@ -0,0 +1,88 @@
"""
用戶相關 Pydantic Schema
"""
from datetime import datetime
from typing import Optional, Literal
from pydantic import BaseModel, EmailStr, Field
# ===== Pagination =====
class PaginationResponse(BaseModel):
page: int
limit: int
total: int
total_pages: int
# ===== Role =====
class RoleBase(BaseModel):
code: str
name: str
description: Optional[str] = None
class RoleResponse(RoleBase):
id: int
class Config:
from_attributes = True
# ===== User =====
class UserBase(BaseModel):
username: str = Field(..., min_length=2, max_length=50)
display_name: str = Field(..., min_length=1, max_length=100)
email: Optional[EmailStr] = None
class UserCreate(UserBase):
password: Optional[str] = Field(None, min_length=6, description="本地帳號必填")
auth_type: Literal["ad", "local"] = "local"
role_id: int
class UserUpdate(BaseModel):
display_name: Optional[str] = Field(None, max_length=100)
email: Optional[EmailStr] = None
role_id: Optional[int] = None
is_active: Optional[bool] = None
password: Optional[str] = Field(None, min_length=6, description="僅本地帳號可修改")
class UserResponse(UserBase):
id: int
auth_type: str
role: RoleResponse
is_active: bool
last_login_at: Optional[datetime] = None
created_at: datetime
class Config:
from_attributes = True
class UserListResponse(BaseModel):
data: list[UserResponse]
pagination: "PaginationResponse"
# ===== Auth =====
class LoginRequest(BaseModel):
username: str
password: str
auth_type: Literal["ad", "local"] = "ad"
class LoginResponse(BaseModel):
token: str
user: UserResponse
class TokenPayload(BaseModel):
user_id: int
username: str
role: str
exp: datetime

19
app/services/__init__.py Normal file
View File

@@ -0,0 +1,19 @@
"""
服務模組
"""
from app.services.llm_service import generate_summary, test_llm_connection
from app.services.notification_service import send_email, send_report_notifications
from app.services.crawler_service import get_crawler, BaseCrawler
from app.services.scheduler_service import init_scheduler, shutdown_scheduler, run_daily_crawl
__all__ = [
"generate_summary",
"test_llm_connection",
"send_email",
"send_report_notifications",
"get_crawler",
"BaseCrawler",
"init_scheduler",
"shutdown_scheduler",
"run_daily_crawl"
]

View File

@@ -0,0 +1,322 @@
"""
新聞爬蟲服務模組
支援 Digitimes、經濟日報、工商時報
"""
import time
import re
from datetime import datetime, date
from typing import Optional, List, Dict, Any
from abc import ABC, abstractmethod
import httpx
from bs4 import BeautifulSoup
from tenacity import retry, stop_after_attempt, wait_exponential
import logging
from app.core.config import settings
logger = logging.getLogger(__name__)
class BaseCrawler(ABC):
"""爬蟲基礎類別"""
def __init__(self):
self.session = httpx.Client(
timeout=30,
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
)
self.delay = settings.crawl_request_delay
def _wait(self):
"""請求間隔"""
time.sleep(self.delay)
@abstractmethod
def get_article_list(self, keywords: List[str]) -> List[Dict[str, Any]]:
"""取得文章列表"""
pass
@abstractmethod
def get_article_content(self, url: str) -> Optional[str]:
"""取得文章內容"""
pass
def close(self):
"""關閉連線"""
self.session.close()
class DigitimesCrawler(BaseCrawler):
"""Digitimes 爬蟲(付費訂閱)"""
BASE_URL = "https://www.digitimes.com.tw"
def __init__(self, username: str, password: str):
super().__init__()
self.username = username
self.password = password
self.is_logged_in = False
def login(self) -> bool:
"""登入 Digitimes"""
try:
# 取得登入頁面
login_page = self.session.get(f"{self.BASE_URL}/member/login.asp")
# 發送登入請求
login_data = {
"uid": self.username,
"pwd": self.password,
"remember": "1"
}
response = self.session.post(
f"{self.BASE_URL}/member/login_check.asp",
data=login_data,
follow_redirects=True
)
# 檢查是否登入成功(根據回應判斷)
self.is_logged_in = "logout" in response.text.lower() or response.status_code == 200
return self.is_logged_in
except Exception as e:
logger.error("Digitimes 登入失敗", exc_info=True)
return False
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
def get_article_list(self, keywords: List[str]) -> List[Dict[str, Any]]:
"""取得文章列表"""
if not self.is_logged_in:
self.login()
articles = []
for keyword in keywords:
self._wait()
try:
# 搜尋 API
search_url = f"{self.BASE_URL}/search/search_result.asp?query={keyword}"
response = self.session.get(search_url)
soup = BeautifulSoup(response.text, "lxml")
# 解析搜尋結果
for item in soup.select(".search-result-item, .news-item"):
title_elem = item.select_one("h3 a, .title a")
if not title_elem:
continue
title = title_elem.get_text(strip=True)
url = title_elem.get("href", "")
if not url.startswith("http"):
url = f"{self.BASE_URL}{url}"
# 取得日期
date_elem = item.select_one(".date, .time")
pub_date = None
if date_elem:
date_text = date_elem.get_text(strip=True)
try:
pub_date = datetime.strptime(date_text, "%Y/%m/%d")
except:
pass
# 只取今天的新聞
if pub_date and pub_date.date() == date.today():
articles.append({
"title": title,
"url": url,
"published_at": pub_date,
"source": "digitimes"
})
except Exception as e:
logger.warning(f"Digitimes 抓取失敗 (關鍵字: {keyword})", exc_info=True)
return articles
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
def get_article_content(self, url: str) -> Optional[str]:
"""取得文章內容"""
if not self.is_logged_in:
self.login()
try:
self._wait()
response = self.session.get(url)
soup = BeautifulSoup(response.text, "lxml")
# 嘗試多個內容選擇器
content_selectors = [".article-body", ".content", "#article-content", ".main-content"]
for selector in content_selectors:
content_elem = soup.select_one(selector)
if content_elem:
# 移除不需要的元素
for unwanted in content_elem.select("script, style, .ad, .advertisement"):
unwanted.decompose()
return content_elem.get_text(separator="\n", strip=True)
return None
except Exception as e:
logger.warning("Digitimes 內容抓取失敗", exc_info=True)
return None
class UDNCrawler(BaseCrawler):
"""經濟日報爬蟲"""
BASE_URL = "https://money.udn.com"
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
def get_article_list(self, keywords: List[str]) -> List[Dict[str, Any]]:
"""取得文章列表"""
articles = []
for keyword in keywords:
self._wait()
try:
search_url = f"{self.BASE_URL}/search/result/1/{keyword}"
response = self.session.get(search_url)
soup = BeautifulSoup(response.text, "lxml")
for item in soup.select(".story-list__news, .news-item"):
title_elem = item.select_one("h3 a, .story-list__text a")
if not title_elem:
continue
title = title_elem.get_text(strip=True)
url = title_elem.get("href", "")
if not url.startswith("http"):
url = f"{self.BASE_URL}{url}"
date_elem = item.select_one("time, .story-list__time")
pub_date = None
if date_elem:
date_text = date_elem.get_text(strip=True)
try:
pub_date = datetime.strptime(date_text[:10], "%Y-%m-%d")
except:
pass
if pub_date and pub_date.date() == date.today():
articles.append({
"title": title,
"url": url,
"published_at": pub_date,
"source": "udn"
})
except Exception as e:
logger.warning(f"經濟日報抓取失敗 (關鍵字: {keyword})", exc_info=True)
return articles
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
def get_article_content(self, url: str) -> Optional[str]:
"""取得文章內容"""
try:
self._wait()
response = self.session.get(url)
soup = BeautifulSoup(response.text, "lxml")
content_elem = soup.select_one("#story_body_content, .article-content")
if content_elem:
for unwanted in content_elem.select("script, style, .ad"):
unwanted.decompose()
return content_elem.get_text(separator="\n", strip=True)
return None
except Exception as e:
logger.warning("經濟日報內容抓取失敗", exc_info=True)
return None
class CTEECrawler(BaseCrawler):
"""工商時報爬蟲"""
BASE_URL = "https://ctee.com.tw"
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
def get_article_list(self, keywords: List[str]) -> List[Dict[str, Any]]:
"""取得文章列表"""
articles = []
for keyword in keywords:
self._wait()
try:
search_url = f"{self.BASE_URL}/?s={keyword}"
response = self.session.get(search_url)
soup = BeautifulSoup(response.text, "lxml")
for item in soup.select(".post-item, article.post"):
title_elem = item.select_one("h2 a, .post-title a")
if not title_elem:
continue
title = title_elem.get_text(strip=True)
url = title_elem.get("href", "")
date_elem = item.select_one("time, .post-date")
pub_date = None
if date_elem:
date_text = date_elem.get("datetime", date_elem.get_text(strip=True))
try:
pub_date = datetime.fromisoformat(date_text[:10])
except:
pass
if pub_date and pub_date.date() == date.today():
articles.append({
"title": title,
"url": url,
"published_at": pub_date,
"source": "ctee"
})
except Exception as e:
logger.warning(f"工商時報抓取失敗 (關鍵字: {keyword})", exc_info=True)
return articles
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
def get_article_content(self, url: str) -> Optional[str]:
"""取得文章內容"""
try:
self._wait()
response = self.session.get(url)
soup = BeautifulSoup(response.text, "lxml")
content_elem = soup.select_one(".entry-content, .post-content")
if content_elem:
for unwanted in content_elem.select("script, style, .ad"):
unwanted.decompose()
return content_elem.get_text(separator="\n", strip=True)
return None
except Exception as e:
logger.warning("工商時報內容抓取失敗", exc_info=True)
return None
def get_crawler(source_code: str) -> BaseCrawler:
"""取得對應的爬蟲實例"""
if source_code == "digitimes":
return DigitimesCrawler(
settings.digitimes_username,
settings.digitimes_password
)
elif source_code == "udn":
return UDNCrawler()
elif source_code == "ctee":
return CTEECrawler()
else:
raise ValueError(f"不支援的新聞來源: {source_code}")

176
app/services/llm_service.py Normal file
View File

@@ -0,0 +1,176 @@
"""
LLM 服務模組
支援 Google Gemini、OpenAI、Ollama
"""
import time
from typing import Optional
import httpx
from app.core.config import settings
def get_llm_client():
"""取得 LLM 客戶端"""
provider = settings.llm_provider
if provider == "gemini":
import google.generativeai as genai
genai.configure(api_key=settings.gemini_api_key)
return genai
elif provider == "openai":
from openai import OpenAI
return OpenAI(api_key=settings.openai_api_key)
elif provider == "ollama":
return None # 使用 httpx 直接呼叫
raise ValueError(f"不支援的 LLM 提供者: {provider}")
def generate_summary(group, articles: list) -> str:
"""
產生 AI 摘要
Args:
group: 群組物件(包含 ai_background, ai_prompt
articles: 新聞文章列表
Returns:
綜合摘要文字
"""
if not articles:
return "無相關新聞可供摘要。"
# 組合文章內容
articles_text = ""
for i, article in enumerate(articles, 1):
articles_text += f"""
---
新聞 {i}{article.title}
來源:{article.source.name if article.source else '未知'}
內容:{article.content[:1000] if article.content else article.summary or '無內容'}
---
"""
# 建立 Prompt
system_prompt = f"""你是一位專業的產業分析師,負責彙整每日新聞並產出精闘的綜合分析報告。
背景資訊:
{group.ai_background or '無特定背景資訊'}
摘要方向:
{group.ai_prompt or '請綜合分析以下新聞的重點、趨勢與潛在影響。'}
"""
user_prompt = f"""請根據以下 {len(articles)} 則新聞,產出一份繁體中文的綜合分析報告:
{articles_text}
請注意:
1. 使用繁體中文
2. 整合相關主題,避免逐條列舉
3. 突出重要趨勢與影響
4. 控制在 500 字以內
"""
provider = settings.llm_provider
try:
if provider == "gemini":
import google.generativeai as genai
genai.configure(api_key=settings.gemini_api_key)
model = genai.GenerativeModel(settings.gemini_model or "gemini-1.5-pro")
response = model.generate_content(
f"{system_prompt}\n\n{user_prompt}",
generation_config={
"temperature": 0.7,
"max_output_tokens": 2048,
"top_p": 0.95,
"top_k": 40
}
)
return response.text
elif provider == "openai":
from openai import OpenAI
client = OpenAI(api_key=settings.openai_api_key)
response = client.chat.completions.create(
model=settings.openai_model or "gpt-4o",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
max_tokens=2048,
temperature=0.7
)
return response.choices[0].message.content
elif provider == "ollama":
response = httpx.post(
f"{settings.ollama_endpoint}/api/generate",
json={
"model": settings.ollama_model or "llama3",
"prompt": f"{system_prompt}\n\n{user_prompt}",
"stream": False,
"options": {
"temperature": 0.7,
"num_predict": 2048,
"top_p": 0.9,
"top_k": 40
}
},
timeout=120
)
return response.json().get("response", "")
except Exception as e:
return f"摘要產生失敗:{str(e)}"
def test_llm_connection(provider: str, model: str) -> dict:
"""
測試 LLM 連線
Returns:
{"success": bool, "response_time_ms": int, "message": str}
"""
start_time = time.time()
try:
if provider == "gemini":
import google.generativeai as genai
genai.configure(api_key=settings.gemini_api_key)
gen_model = genai.GenerativeModel(model)
response = gen_model.generate_content(
"Hello",
generation_config={"max_output_tokens": 10}
)
elapsed = int((time.time() - start_time) * 1000)
return {"success": True, "response_time_ms": elapsed}
elif provider == "openai":
from openai import OpenAI
client = OpenAI(api_key=settings.openai_api_key)
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": "Hello"}],
max_tokens=10
)
elapsed = int((time.time() - start_time) * 1000)
return {"success": True, "response_time_ms": elapsed}
elif provider == "ollama":
response = httpx.post(
f"{settings.ollama_endpoint}/api/generate",
json={"model": model, "prompt": "Hello", "stream": False},
timeout=30
)
elapsed = int((time.time() - start_time) * 1000)
if response.status_code == 200:
return {"success": True, "response_time_ms": elapsed}
return {"success": False, "message": f"HTTP {response.status_code}"}
return {"success": False, "message": f"不支援的提供者: {provider}"}
except Exception as e:
elapsed = int((time.time() - start_time) * 1000)
return {"success": False, "response_time_ms": elapsed, "message": str(e)}

View File

@@ -0,0 +1,203 @@
"""
通知服務模組
處理 Email 發送
"""
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Optional
from html import escape
from sqlalchemy.orm import Session
import logging
from app.core.config import settings
from app.models import Report, Subscription, User, NotificationLog, NotificationStatus
logger = logging.getLogger(__name__)
def send_email(to_email: str, subject: str, html_content: str) -> bool:
"""
發送 Email
Returns:
是否發送成功
"""
if not settings.smtp_host:
logger.warning("SMTP 未設定,跳過發送")
return False
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"{settings.smtp_from_name} <{settings.smtp_from_email}>"
msg["To"] = to_email
html_part = MIMEText(html_content, "html", "utf-8")
msg.attach(html_part)
with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server:
server.starttls()
if settings.smtp_username and settings.smtp_password:
server.login(settings.smtp_username, settings.smtp_password)
server.sendmail(settings.smtp_from_email, to_email, msg.as_string())
return True
except Exception as e:
logger.error("Email 發送失敗", exc_info=True)
return False
def create_report_email_content(report: Report, base_url: str = "") -> str:
"""建立報告通知 Email 內容"""
summary = report.edited_summary or report.ai_summary or "無摘要內容"
# 截取摘要前 500 字
if len(summary) > 500:
summary = summary[:500] + "..."
# 轉義 HTML 特殊字元,防止 XSS
safe_title = escape(report.title)
safe_group_name = escape(report.group.name)
safe_summary = escape(summary)
safe_base_url = escape(base_url)
html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: #4a6fa5; color: white; padding: 20px; text-align: center; }}
.content {{ padding: 20px; background: #f9f9f9; }}
.summary {{ background: white; padding: 15px; border-left: 4px solid #4a6fa5; margin: 15px 0; }}
.button {{ display: inline-block; padding: 12px 24px; background: #4a6fa5; color: white; text-decoration: none; border-radius: 4px; }}
.footer {{ text-align: center; padding: 20px; color: #666; font-size: 12px; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1 style="margin:0;">每日報導</h1>
</div>
<div class="content">
<h2>{safe_title}</h2>
<p>
<strong>群組:</strong>{safe_group_name}<br>
<strong>日期:</strong>{report.report_date}
</p>
<div class="summary">
<h3>摘要</h3>
<p>{safe_summary}</p>
</div>
<p style="text-align: center; margin-top: 30px;">
<a href="{safe_base_url}/reports/{report.id}" class="button">閱讀完整報告</a>
</p>
</div>
<div class="footer">
<p>此郵件由每日報導系統自動發送</p>
<p>如不想收到通知,請至系統調整訂閱設定</p>
</div>
</div>
</body>
</html>
"""
return html
def send_report_notifications(db: Session, report: Report) -> int:
"""
發送報告通知給訂閱者
Returns:
發送成功數量
"""
# 取得訂閱此群組的用戶
subscriptions = db.query(Subscription).filter(
Subscription.group_id == report.group_id,
Subscription.email_notify == True
).all()
sent_count = 0
for sub in subscriptions:
user = db.query(User).filter(User.id == sub.user_id).first()
if not user or not user.email or not user.is_active:
continue
# 建立通知記錄
notification = NotificationLog(
user_id=user.id,
report_id=report.id,
notification_type="email",
subject=f"【每日報導】{report.title}",
content=report.edited_summary or report.ai_summary
)
db.add(notification)
# 發送 Email
html_content = create_report_email_content(report)
success = send_email(
user.email,
f"【每日報導】{report.title}",
html_content
)
if success:
notification.status = NotificationStatus.SENT
from datetime import datetime
notification.sent_at = datetime.utcnow()
sent_count += 1
else:
notification.status = NotificationStatus.FAILED
notification.error_message = "發送失敗"
db.commit()
return sent_count
def send_delay_notification(db: Session, report: Report) -> int:
"""
發送延遲發布通知
Returns:
發送成功數量
"""
subscriptions = db.query(Subscription).filter(
Subscription.group_id == report.group_id,
Subscription.email_notify == True
).all()
sent_count = 0
for sub in subscriptions:
user = db.query(User).filter(User.id == sub.user_id).first()
if not user or not user.email or not user.is_active:
continue
# 轉義 HTML 特殊字元,防止 XSS
safe_group_name = escape(report.group.name)
html_content = f"""
<html>
<body>
<h2>報告延遲通知</h2>
<p>您訂閱的「{safe_group_name}」今日報告延遲發布,敬請稍後。</p>
<p>造成不便,敬請見諒。</p>
</body>
</html>
"""
success = send_email(
user.email,
f"【每日報導】{report.group.name} 報告延遲通知",
html_content
)
if success:
sent_count += 1
return sent_count

View File

@@ -0,0 +1,277 @@
"""
排程服務模組
處理每日新聞抓取與報告產生
"""
from datetime import datetime, date
from typing import List
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy.orm import Session
import logging
from app.db.session import SessionLocal
from app.core.config import settings
from app.models import (
NewsSource, NewsArticle, CrawlJob, CrawlStatus,
Group, Keyword, ArticleGroupMatch, Report, ReportArticle, ReportStatus
)
from app.services.crawler_service import get_crawler
from app.services.llm_service import generate_summary
from app.services.notification_service import send_delay_notification
logger = logging.getLogger(__name__)
scheduler = BackgroundScheduler()
def run_daily_crawl():
"""執行每日新聞抓取"""
logger.info("開始每日新聞抓取...")
db = SessionLocal()
try:
# 取得所有啟用的新聞來源
sources = db.query(NewsSource).filter(NewsSource.is_active == True).all()
# 取得所有關鍵字
all_keywords = db.query(Keyword).filter(Keyword.is_active == True).all()
keywords_list = list(set([kw.keyword for kw in all_keywords]))
for source in sources:
logger.info(f"抓取來源: {source.name}")
# 建立抓取任務記錄
job = CrawlJob(
source_id=source.id,
status=CrawlStatus.RUNNING,
scheduled_at=datetime.now(),
started_at=datetime.now()
)
db.add(job)
db.commit()
try:
# 取得爬蟲
crawler = get_crawler(source.code)
# 抓取文章列表
articles_data = crawler.get_article_list(keywords_list)
articles_count = 0
for article_data in articles_data:
# 檢查是否已存在
existing = db.query(NewsArticle).filter(
NewsArticle.source_id == source.id,
NewsArticle.url == article_data["url"]
).first()
if existing:
continue
# 抓取全文
content = crawler.get_article_content(article_data["url"])
# 儲存文章
article = NewsArticle(
source_id=source.id,
title=article_data["title"],
url=article_data["url"],
content=content,
published_at=article_data.get("published_at"),
crawled_at=datetime.now()
)
db.add(article)
db.commit()
db.refresh(article)
# 關鍵字匹配
match_article_to_groups(db, article)
articles_count += 1
# 更新任務狀態
job.status = CrawlStatus.COMPLETED
job.completed_at = datetime.now()
job.articles_count = articles_count
crawler.close()
except Exception as e:
job.status = CrawlStatus.FAILED
job.completed_at = datetime.now()
job.error_message = str(e)
job.retry_count += 1
logger.error(f"抓取失敗 (來源: {source.name})", exc_info=True)
db.commit()
# 產生今日報告
generate_daily_reports(db)
logger.info("每日新聞抓取完成")
except Exception as e:
logger.error("抓取過程發生錯誤", exc_info=True)
finally:
db.close()
def match_article_to_groups(db: Session, article: NewsArticle):
"""將文章匹配到群組"""
# 取得所有群組及其關鍵字
groups = db.query(Group).filter(Group.is_active == True).all()
article_text = f"{article.title} {article.content or ''}"
for group in groups:
keywords = db.query(Keyword).filter(
Keyword.group_id == group.id,
Keyword.is_active == True
).all()
matched_keywords = []
for kw in keywords:
if kw.keyword.lower() in article_text.lower():
matched_keywords.append(kw.keyword)
if matched_keywords:
# 計算匹配分數
score = len(matched_keywords) / len(keywords) * 100 if keywords else 0
match = ArticleGroupMatch(
article_id=article.id,
group_id=group.id,
matched_keywords=matched_keywords,
match_score=score
)
db.add(match)
db.commit()
def generate_daily_reports(db: Session):
"""產生今日報告"""
logger.info("產生今日報告...")
today = date.today()
groups = db.query(Group).filter(Group.is_active == True).all()
for group in groups:
# 檢查今日報告是否已存在
existing = db.query(Report).filter(
Report.group_id == group.id,
Report.report_date == today
).first()
if existing:
continue
# 取得今日匹配的文章
matches = db.query(ArticleGroupMatch).filter(
ArticleGroupMatch.group_id == group.id
).join(NewsArticle).filter(
NewsArticle.crawled_at >= datetime.combine(today, datetime.min.time())
).all()
if not matches:
continue
# 建立報告
report = Report(
group_id=group.id,
title=f"{group.name}日報 - {today.strftime('%Y/%m/%d')}",
report_date=today,
status=ReportStatus.DRAFT
)
db.add(report)
db.commit()
db.refresh(report)
# 關聯文章
articles = []
for match in matches:
article = db.query(NewsArticle).filter(NewsArticle.id == match.article_id).first()
if article:
ra = ReportArticle(
report_id=report.id,
article_id=article.id,
is_included=True
)
db.add(ra)
articles.append(article)
db.commit()
# 產生 AI 摘要
if articles:
summary = generate_summary(group, articles)
report.ai_summary = summary
report.status = ReportStatus.PENDING
db.commit()
logger.info(f"已產生報告: {report.title} ({len(articles)} 篇文章)")
def check_publish_deadline():
"""檢查發布截止時間"""
db = SessionLocal()
try:
today = date.today()
# 取得尚未發布的報告
pending_reports = db.query(Report).filter(
Report.report_date == today,
Report.status.in_([ReportStatus.DRAFT, ReportStatus.PENDING])
).all()
for report in pending_reports:
report.status = ReportStatus.DELAYED
send_delay_notification(db, report)
db.commit()
finally:
db.close()
def init_scheduler():
"""初始化排程器"""
# 解析排程時間
crawl_time = settings.crawl_schedule_time.split(":")
crawl_hour = int(crawl_time[0])
crawl_minute = int(crawl_time[1])
deadline_time = "09:00".split(":") # 可從設定讀取
deadline_hour = int(deadline_time[0])
deadline_minute = int(deadline_time[1])
# 每日抓取任務
scheduler.add_job(
run_daily_crawl,
CronTrigger(hour=crawl_hour, minute=crawl_minute),
id="daily_crawl",
replace_existing=True
)
# 發布截止時間檢查
scheduler.add_job(
check_publish_deadline,
CronTrigger(hour=deadline_hour, minute=deadline_minute),
id="check_deadline",
replace_existing=True
)
# 啟動排程器
if not scheduler.running:
scheduler.start()
logger.info(f"排程器已啟動: 每日 {settings.crawl_schedule_time} 抓取")
def shutdown_scheduler():
"""關閉排程器"""
if scheduler.running:
scheduler.shutdown()

609
checklist.md Normal file
View File

@@ -0,0 +1,609 @@
# 每日報導 APP - 技術確認清單
> 建立日期2025-01-27
> 最後更新2025-01-27
> 狀態:🟡 進行中
---
## 📋 使用說明
- ✅ 已確認
- 🟡 待確認
- ❌ 已取消/不適用
- 📝 備註欄位可填入補充說明
---
## 1. 前端技術架構
### 1.1 前端框架選擇
**狀態:** 🟡 待確認
**問題:** 前端應使用哪個框架?
- [V] React
- [ ] Vue.js
- [ ] Angular
- [ ] 純 HTML/CSS/JavaScript無框架
- [ ] 其他________________
**備註:**
---
### 1.2 前端與後端通訊方式
**狀態:** 🟡 待確認
**問題:** 前端如何與後端 API 通訊?
- [V] RESTful API
- [ ] GraphQL
- [ ] WebSocket即時更新
- [ ] 混合方式________________
**備註:**
---
### 1.3 前端渲染方式
**狀態:** 🟡 待確認
**問題:** 前端是否需要服務端渲染SSR
- [ ] 純 SPA單頁應用
- [ ] SSR服務端渲染
- [ ] SSG靜態網站生成
- [V] 混合模式
**備註:**
---
## 2. 新聞抓取技術細節
### 2.1 Digitimes 登入狀態管理
**狀態:** 🟡 待確認
**問題:** 如何處理 Digitimes 的登入狀態?
- [ ] 每次抓取都重新登入
- [V] 維持 session定期檢查有效性
- [V] 使用 Cookie 持久化
- [ ] 其他方式________________
**備註:**
---
### 2.2 爬蟲實作技術
**狀態:** 🟡 待確認
**問題:** 使用哪種爬蟲技術?
- [ ] Selenium處理 JavaScript 渲染)
- [V] Scrapy高效能爬蟲框架
- [ ] BeautifulSoup + Requests簡單靜態頁面
- [ ] Playwright現代瀏覽器自動化
- [ ] 混合使用________________
**備註:**
---
### 2.3 新聞去重機制
**狀態:** 🟡 待確認
**問題:** 如何避免重複抓取同一篇新聞?
- [ ] 比對標題 + URL
- [V] 比對標題 + 發布時間
- [ ] 使用內容 hash 值
- [ ] 資料庫唯一索引
- [ ] 組合方式________________
**備註:**
---
### 2.4 新聞結構化提取
**狀態:** 🟡 待確認
**問題:** 需要提取哪些新聞欄位?
- [V] 標題(必填)
- [V] 正文內容(必填)
- [V] 發布時間(必填)
- [ ] 作者
- [ ] 來源 URL
- [ ] 分類/標籤
- [ ] 圖片
- [ ] 其他________________
**備註:**
---
## 3. AI 摘要處理邏輯
### 3.1 多篇新聞合併策略
**狀態:** 🟡 待確認
**問題:** 當一個群組有多篇相關新聞時,如何處理?
- [V] 全部合併成一段綜合分析(無數量限制)
- [ ] 限制數量(如最多 10 篇),超過則分批處理
- [ ] 每篇單獨摘要,再合併摘要
- [ ] 其他策略________________
**備註:**
---
### 3.2 Token 限制處理
**狀態:** 🟡 待確認
**問題:** 當新聞內容超過模型 token 限制時,如何處理?
- [ ] 截斷內容(保留前 N 篇)
- [] 分批處理後再合併摘要
- [V] 先進行初步摘要再送 LLM
- [ ] 通知專員手動處理
- [ ] 其他方式________________
**備註:**
---
### 3.3 背景資訊與摘要方向傳遞
**狀態:** 🟡 待確認
**問題:** 如何將群組的背景資訊與摘要方向整合到 LLM prompt
- [ ] 放在 system prompt 中
- [v] 放在 user prompt 開頭
- [ ] 使用 few-shot examples
- [ ] 組合方式________________
**備註:**
---
### 3.4 AI 摘要並行處理
**狀態:** 🟡 待確認
**問題:** 多個群組的摘要是否並行產生?
- [v] 串行處理(一個接一個)
- [ ] 並行處理(同時處理多個群組)
- [ ] 有限並行(如最多 3 個同時)
- [ ] 使用任務佇列(如 Celery
**備註:**
---
## 4. 資料庫設計
### 4.1 資料庫 Schema 設計
**狀態:** 🟡 待確認
**問題:** 需要哪些主要資料表?
- [v] users用戶表
- [v] groups群組表
- [v] keywords關鍵字表
- [v] news新聞表
- [v] reports報告表
- [ ] subscriptions訂閱表
- [v] comments留言表
- [ ] favorites收藏表
- [ ] annotations標註表
- [v] llm_settingsLLM 設定表)
- [v] system_logs系統日誌表
- [ ] 其他________________
**備註:**
---
### 4.2 關聯關係設計
**狀態:** 🟡 待確認
**問題:** 報告與新聞的關聯方式?
- [ ] 一對多(一份報告對應多篇新聞)
- [v] 多對多(一篇新聞可出現在多份報告)
- [ ] 其他________________
**備註:**
---
### 4.3 索引策略
**狀態:** 🟡 待確認
**問題:** 需要建立哪些索引以優化查詢效能?
- [v] 新聞標題索引
- [ ] 新聞發布時間索引
- [v] 群組關鍵字索引
- [v] 用戶訂閱關係索引
- [ ] 其他________________
**備註:**
---
## 5. Email 通知機制
### 5.1 Email 模板格式
**狀態:** 🟡 待確認
**問題:** Email 內容應包含哪些資訊?
- [v] 報告標題
- [v] 發布日期
- [v] AI 摘要內容
- [ ] 相關新聞標題列表
- [v] 線上閱讀連結
- [ ] 取消訂閱連結
- [ ] 其他________________
**備註:**
---
### 5.2 Email 模板樣式
**狀態:** 🟡 待確認
**問題:** Email 模板樣式?
- [ ] 純文字格式
- [] HTML 格式(含樣式)
- [v] 響應式 HTML支援手機
- [ ] 其他________________
**備註:**
---
### 5.3 批次發送策略
**狀態:** 🟡 待確認
**問題:** 當訂閱讀者數量多時,如何發送 Email
- [ ] 同步發送(一次發送所有)
- [v] 批次發送(如每批 10 封)
- [ ] 使用任務佇列(背景處理)
- [ ] 其他方式________________
**備註:**
---
### 5.4 發送失敗處理
**狀態:** 🟡 待確認
**問題:** Email 發送失敗時的處理方式?
- [ ] 自動重試(最多 3 次)
- [v] 記錄失敗日誌
- [v] 通知系統管理員
- [ ] 組合處理________________
**備註:**
---
## 6. PDF 匯出細節
### 6.1 PDF 生成技術
**狀態:** 🟡 待確認
**問題:** 使用哪種技術生成 PDF
- [v] ReportLab
- [ ] WeasyPrint
- [ ] pdfkit / wkhtmltopdf
- [ ] 前端生成(如 jsPDF
- [ ] 其他________________
**備註:**
---
### 6.2 PDF 模板內容
**狀態:** 🟡 待確認
**問題:** PDF 應包含哪些內容?
- [v] 公司 Logo
- [v] 報告標題
- [v] 發布日期
- [v] AI 摘要內容
- [v ] 相關新聞列表(標題 + 連結)
- [v ] 頁首頁尾文字
- [ ] 其他________________
**備註:**
---
### 6.3 PDF 樣式規範
**狀態:** 🟡 待確認
**問題:** PDF 樣式需求?
- [v ] 固定樣式(不可自訂)
- [ ] 可自訂字體
- [ ] 可自訂顏色
- [ ] 可自訂版面配置
- [ ] 其他________________
**備註:**
---
## 7. 權限與操作細節
### 7.1 已發布報告修改權限
**狀態:** 🟡 待確認
**問題:** 專員是否可以修改或撤回已發布的報告?
- [ ] 可以修改(會通知讀者)
- [v ] 可以撤回(標記為已撤回)
- [ ] 完全不可修改
- [ ] 僅限發布後 X 小時內可修改
**備註:**
---
### 7.2 留言審核機制
**狀態:** 🟡 待確認
**問題:** 讀者留言是否需要審核?
- [ ] 不需要審核(直接顯示)
- [ ] 需要專員審核
- [ ] 需要管理員審核
- [v ] 關鍵字過濾後自動審核
**備註:**
---
### 7.3 工作日定義
**狀態:** 🟡 待確認
**問題:** 如何判斷工作日?
- [v ] 週一至週五(排除假日)
- [v ] 使用台灣行事曆 API
- [ ] 手動設定假日清單
- [ ] 其他方式________________
**備註:**
---
## 8. 錯誤處理與備援
### 8.1 部分新聞抓取失敗處理
**狀態:** 🟡 待確認
**問題:** 當部分新聞來源抓取失敗時?
- [ ] 繼續處理其他成功的新聞
- [ ] 全部標記為失敗,等待重試
- [v ] 部分成功的新聞先處理,失敗的稍後重試
- [ ] 其他方式________________
**備註:**
---
### 8.2 AI 摘要失敗處理
**狀態:** 🟡 待確認
**問題:** 當 AI 摘要產生失敗時?
- [ ] 保留原始新聞,標記為「待處理」
- [v] 通知專員手動處理
- [v] 自動重試(最多 3 次)
- [ ] 組合處理________________
**備註:**
---
### 8.3 加密金鑰管理
**狀態:** 🟡 待確認
**問題:** API Key 加密金鑰如何管理?
- [v] 儲存在環境變數
- [ ] 儲存在資料庫(加密)
- [ ] 使用金鑰管理服務(如 HashiCorp Vault
- [ ] 其他方式________________
**備註:**
---
### 8.4 加密金鑰輪換
**狀態:** 🟡 待確認
**問題:** 是否需要定期輪換加密金鑰?
- [ ] 不需要
- [v] 需要(每 3 個月)
- [ ] 手動觸發
- [ ] 其他________________
**備註:**
---
## 9. 效能優化
### 9.1 快取策略
**狀態:** 🟡 待確認
**問題:** 是否需要快取機制?
- [ ] 不需要
- [v] Redis 快取
- [ ] 記憶體快取
- [ ] 資料庫查詢快取
- [ ] 組合方式________________
**備註:**
---
### 9.2 非同步任務處理
**狀態:** 🟡 待確認
**問題:** 新聞抓取與 AI 摘要是否使用任務佇列?
- [ ] 同步處理
- [v] Celery + Redis/RabbitMQ
- [ ] 其他任務佇列________________
- [ ] 不需要
**備註:**
---
### 9.3 資料庫連線池
**狀態:** 🟡 待確認
**問題:** 資料庫連線管理方式?
- [v] 使用連線池SQLAlchemy
- [ ] 每次請求建立連線
- [ ] 其他方式________________
**備註:**
---
## 10. 雙語介面
### 10.1 語言切換方式
**狀態:** 🟡 待確認
**問題:** 用戶如何切換語言?
- [v] 用戶手動選擇(右上角切換)
- [v] 系統自動偵測瀏覽器語言
- [ ] 根據用戶設定檔
- [ ] 其他方式________________
**備註:**
---
### 10.2 內容翻譯需求
**狀態:** 🟡 待確認
**問題:** 新聞與摘要是否需要自動翻譯?
- [ ] 不需要(僅介面翻譯)
- [ ] 需要(新聞內容翻譯)
- [v] 需要(摘要內容翻譯)
- [ ] 需要(全部內容翻譯)
- [ ] 其他________________
**備註:**
---
## 11. 其他技術細節
### 11.1 日誌記錄策略
**狀態:** 🟡 待確認
**問題:** 需要記錄哪些操作日誌?
- [v] 用戶登入/登出
- [v] 新聞抓取記錄
- [v] AI 摘要產生記錄
- [v] 報告發布記錄
- [v ] 系統錯誤記錄
- [v ] API 呼叫記錄
- [ ] 其他________________
**備註:**
---
### 11.2 日誌保留期限
**狀態:** 🟡 待確認
**問題:** 操作日誌保留多久?
- [ ] 30 天
- [v ] 60 天
- [ ] 90 天
- [ ] 1 年
- [ ] 永久保留
- [ ] 其他________________
**備註:**
---
### 11.3 備份策略細節
**狀態:** 🟡 待確認
**問題:** 資料庫備份的具體方式?
- [ ] 每日全量備份
- [v ] 每日增量備份
- [ ] 每週全量 + 每日增量
- [ ] 其他方式________________
**備註:**
---
### 11.4 監控與告警
**狀態:** 🟡 待確認
**問題:** 需要哪些監控與告警機制?
- [v ] 系統健康檢查
- [v ] 新聞抓取失敗告警
- [v ] AI 摘要失敗告警
- [v ] 資料庫連線異常告警
- [v ] 系統效能監控
- [ ] 其他________________
**備註:**
---
## 📊 進度統計
- **總問題數:** 44
- **已確認:** 0
- **待確認:** 44
- **已取消:** 0
---
## 📝 更新記錄
| 日期 | 更新內容 | 更新人 |
|------|---------|--------|
| 2025-01-27 | 建立初始清單 | System |

392
daily-news-SDD.md Normal file
View File

@@ -0,0 +1,392 @@
每日報導 APP
功能規格書 (Functional Specification)
文件版本 1.2
建立日期 2025-11-24
最後更新 2025-01-27
專案名稱 每日報導 APP
專案類型 內部工具
1. 專案概述
1.1 專案目標
建立一套企業內部新聞彙整與分析系統,協助市場分析專員有效率地蒐集、篩選並彙整產業新聞,透過 AI 自動摘要功能產出綜合分析報告,供內部讀者訂閱閱讀。
1.2 用戶故事
用戶故事 A市場分析專員
身為市場分析專員,我需要一個方法來縮短每天用關鍵字查找各大網站新聞的過程,因為目前耗用太多時間。
用戶故事 B讀者
身為讀者,我需要一個方法來針對市場分析專員所提供的資料進行分析,因為目前耗用太多時間。
1.3 預估使用者規模
角色 人數 說明
系統管理員 1-2 位 負責系統設定與維護
市場分析專員 1 位 負責新聞篩選與發布
讀者 40 位 訂閱並閱讀報告
2. 系統架構
2.1 部署環境
• 部署方式地端部署1Panel 管理介面)
• 前端平台Web 優先,支援行動裝置響應式設計
• 系統語言:繁體中文 / 英文雙語介面
• 語言切換:用戶手動選擇(右上角切換)+ 系統自動偵測瀏覽器語言
• 內容翻譯:摘要內容支援自動翻譯(新聞內容不翻譯)
2.2 技術架構
• 前端框架React混合模式渲染
• 前端通訊RESTful API
• 後端框架FastAPI (Python 3.11+)
• 資料庫MySQL 8.0
• 資料庫 ORMSQLAlchemy連線池管理
• 快取系統Redis
• 任務佇列Celery + Redis/RabbitMQ
• LLM 整合:支援 Google Gemini API / OpenAI API / Ollama 地端模型
• 認證整合AD/LDAP 驗證 + 本地帳號
3. 角色與權限
角色 權限範圍
系統管理員 LLM 設定、AD 整合、群組管理、用戶管理、系統設定
市場分析專員 新聞抓取管理、篩選編輯、報告發布、群組內容設定
讀者 訂閱群組、閱讀報告、留言討論、個人收藏、匯出 PDF
4. 功能需求
4.1 新聞抓取模組
4.1.1 新聞來源
來源 登入方式 抓取內容
Digitimes 帳號密碼登入(付費訂閱) 全文擷取
經濟日報 公開網頁爬取 全文擷取
工商時報 公開網頁爬取 全文擷取
4.1.2 抓取技術
• 爬蟲框架Scrapy高效能爬蟲框架
• 登入狀態管理:維持 session定期檢查有效性使用 Cookie 持久化
• 去重機制:比對標題 + 發布時間,避免重複抓取
• 新聞欄位提取:標題(必填)、正文內容(必填)、發布時間(必填)
4.1.3 抓取排程
• 定時抓取:每日 08:00 執行
• 抓取範圍:即時累積至當日的新聞
• 資料存儲:建立新聞資料庫供日後查詢
• 處理方式:使用 Celery 任務佇列進行非同步處理
4.1.4 異常處理
1. 系統自動重試(最多 3 次,間隔 5 分鐘)
2. 部分成功的新聞先處理,失敗的稍後重試
3. 重試失敗後通知系統管理員
4. 專員可透過介面手動觸發重新抓取
4.2 關鍵字群組管理
4.2.1 群組分類方式
• 依產業別分群:半導體、面板、車用電子...等
• 依議題分群:政策法規、市場趨勢...等
4.2.2 群組設定項目
• 群組名稱與描述
• 關鍵字清單(可新增、編輯、刪除)
• AI 摘要背景資訊設定
• AI 摘要方向設定(每個群組獨立設定)
4.2.3 群組與報告關係
一份報告 = 一個群組的彙整內容
4.3 AI 摘要模組
本系統整合多種大型語言模型 (LLM) 服務,提供靈活的 AI 摘要能力,系統管理員可依據需求切換不同的 LLM 提供者。
4.3.1 支援的 LLM 提供者
提供者 類型 建議模型 適用場景
Google Gemini 雲端 API gemini-1.5-pro 長文本分析、多語言
OpenAI 雲端 API gpt-4o / gpt-4o-mini 通用摘要、高品質輸出
Ollama 地端部署 llama3 / qwen2 / gemma2 資料不外流、離線使用
4.3.2 Google Gemini API 整合規格
API 端點
• Base URL: https://generativelanguage.googleapis.com/v1beta
• 認證方式: API Key (透過 URL 參數或 Header)
支援模型
• gemini-1.5-pro: 最高品質,支援 100 萬 tokens 上下文
• gemini-1.5-flash: 快速回應,適合即時摘要
• gemini-1.0-pro: 穩定版本,成本較低
請求格式
POST /v1beta/models/{model}:generateContent
Content-Type: application/json
設定參數
參數 預設值 說明
temperature 0.7 控制輸出隨機性 (0-1)
maxOutputTokens 2048 最大輸出 token 數
topP 0.95 nucleus sampling 參數
topK 40 top-k sampling 參數
4.3.3 OpenAI API 整合規格
API 端點
• Base URL: https://api.openai.com/v1
• 認證方式: Bearer Token (Authorization Header)
支援模型
• gpt-4o: 最新多模態模型,高品質輸出
• gpt-4o-mini: 輕量版本,成本效益高
• gpt-4-turbo: 128K 上下文,適合長文
請求格式
POST /v1/chat/completions
Content-Type: application/json
設定參數
參數 預設值 說明
temperature 0.7 控制輸出隨機性 (0-2)
max_tokens 2048 最大輸出 token 數
top_p 1.0 nucleus sampling 參數
frequency_penalty 0 重複懲罰 (-2 至 2)
presence_penalty 0 新話題懲罰 (-2 至 2)
4.3.4 Ollama API 整合規格(地端部署)
API 端點
• Base URL: http://localhost:11434 (可自訂)
• 認證方式: 無需認證(建議內網部署)
支援模型(需預先下載)
• llama3:8b / llama3:70b: Meta 開源模型,繁中支援佳
• qwen2:7b / qwen2:72b: 阿里巴巴模型,中文最佳化
• gemma2:9b / gemma2:27b: Google 開源模型
• mistral:7b: 歐洲開源模型,效能優異
請求格式
POST /api/generate 或 POST /api/chat
Content-Type: application/json
設定參數
參數 預設值 說明
temperature 0.7 控制輸出隨機性 (0-1)
num_predict 2048 最大輸出 token 數
top_p 0.9 nucleus sampling 參數
top_k 40 top-k sampling 參數
stream false 是否串流輸出
硬體需求建議
模型規模 最低記憶體 建議配置
7B-8B 參數 8GB RAM 16GB RAM + GPU 8GB
13B-27B 參數 16GB RAM 32GB RAM + GPU 16GB
70B+ 參數 64GB RAM 128GB RAM + GPU 48GB+
4.3.5 LLM 設定管理介面
管理員設定功能
• 選擇 LLM 提供者(下拉選單切換)
• 設定 API Key加密儲存於資料庫
• 選擇使用的模型版本
• 設定 Ollama 端點 URL地端部署時
• 調整生成參數temperature, max_tokens 等)
• 連線測試功能(顯示回應時間)
全系統統一設定
LLM 設定由系統管理員統一配置,所有群組共用相同的模型與參數,確保輸出品質一致性。
4.3.6 摘要處理邏輯
多篇新聞合併策略
• 全部合併成一段綜合分析(無數量限制)
• 當新聞內容超過模型 token 限制時,先進行初步摘要再送 LLM
• 背景資訊與摘要方向放在 user prompt 開頭傳遞給 LLM
• 處理方式:串行處理(一個接一個群組依序產生摘要)
摘要輸出格式
• 多篇相關新聞合併產出一段綜合分析
• 純文字格式,適合閱讀與匯出
• 每個群組可設定專屬的背景資訊與摘要方向
4.3.7 錯誤處理與備援
• API 呼叫失敗時自動重試(最多 3 次)
• 摘要失敗時通知專員手動處理
• 記錄錯誤日誌供管理員查看
• 支援設定備援 LLM 提供者(未來擴充)
4.4 報告發布模組(專員端)
4.4.1 篩選介面功能
• 勾選/排除特定新聞
• 可手動編輯 AI 摘要內容
• 預覽發布前報告呈現
4.4.2 發布規則
• 發布時間:工作日 09:00 前必須發出
• 工作日定義:週一至週五(排除假日),使用台灣行事曆 API 判斷
• 逾時處理:延遲發布時通知讀者
• 已發布報告:專員可以撤回(標記為已撤回),但不可修改內容
4.4.3 通知機制
Email 通知規格
• Email 內容報告標題、發布日期、AI 摘要內容、線上閱讀連結
• Email 樣式:響應式 HTML支援手機閱讀
• 發送策略:批次發送(每批 10 封)
• 失敗處理:記錄失敗日誌、通知系統管理員
4.5 讀者端功能
4.5.1 訂閱管理
• 讀者可自行訂閱感興趣的群組
• 一位讀者可訂閱多個群組報告
4.5.2 閱讀介面
• 響應式設計,支援手機閱讀
• 報告瀏覽與歷史查詢
4.5.3 互動功能
• 留言功能:同群組讀者皆可見(討論性質)
• 留言審核:關鍵字過濾後自動審核
• 收藏功能:個人收藏清單(未來擴充)
• 標註功能:個人筆記用途(未來擴充)
4.5.4 匯出功能
PDF 匯出規格
• 生成技術ReportLab
• PDF 內容:公司 Logo、報告標題、發布日期、AI 摘要內容、相關新聞列表(標題 + 連結)、頁首頁尾文字
• PDF 樣式:固定樣式(不可自訂)
• 權限:專員與讀者皆有權限匯出
4.6 系統管理模組
4.6.1 用戶管理
• AD/LDAP 整合:僅驗證帳密
• 支援非 AD 帳號(外部顧問、實習生等)
• 角色指派與權限管理
4.6.2 LLM 設定
• 提供者選擇Google Gemini / OpenAI / Ollama
• API 金鑰管理:儲存在環境變數(加密儲存)
• 加密金鑰輪換:每 3 個月定期輪換
• 模型版本選擇
• Ollama 端點設定(地端部署)
• 連線測試與回應時間顯示
4.6.3 PDF 模板設定
• 可上傳公司 Logo
• 自訂頁首頁尾文字
• 固定樣式模板(不可自訂)
5. 每日工作流程
時間 執行者 動作
08:00 系統 自動抓取各新聞來源當日累積新聞
08:00 系統 依關鍵字群組分類新聞並呼叫 LLM 產生 AI 摘要
08:30 專員 登入系統審核新聞,勾選/排除、編輯摘要
09:00 前 專員 確認無誤後發布報告
09:00 系統 發送 Email 通知給已訂閱該群組的讀者
全天 讀者 登入閱讀、留言、收藏、匯出 PDF
6. 非功能性需求
6.1 效能需求
• 新聞抓取完成時間30 分鐘內(含 AI 摘要)
• 頁面載入時間3 秒內
• 同時在線用戶:至少 50 人
• LLM 摘要回應時間:單次請求 30 秒內
• 快取策略:使用 Redis 快取提升查詢效能
• 資料庫連線:使用 SQLAlchemy 連線池管理
6.2 資料保留政策
• 報告與新聞資料保留期限60 天
• 操作日誌保留期限60 天
• 過期資料自動清理機制
6.3 可用性需求
• 系統可用性:工作日 07:00-22:00 需正常運作
• 備份策略:每日增量備份
• 任務處理:使用 Celery + Redis/RabbitMQ 進行非同步任務處理
6.4 安全性需求
• AD/LDAP 認證整合
• HTTPS 加密傳輸
• API Key 加密儲存AES-256儲存在環境變數
• 加密金鑰輪換:每 3 個月定期輪換
• 操作日誌記錄:記錄用戶登入/登出、新聞抓取記錄、AI 摘要產生記錄、報告發布記錄、系統錯誤記錄、API 呼叫記錄
6.5 監控與告警
• 系統健康檢查
• 新聞抓取失敗告警
• AI 摘要失敗告警
• 資料庫連線異常告警
• 系統效能監控
7. LLM 成本估算
以下為各 LLM 提供者的預估成本(以每日 50 篇新聞、每篇 1000 字計算):
提供者 輸入成本 輸出成本 每月估算
Google Gemini $0.00025/1K tokens $0.0005/1K tokens 約 $5-15
OpenAI GPT-4o $0.005/1K tokens $0.015/1K tokens 約 $30-60
OpenAI GPT-4o-mini $0.00015/1K tokens $0.0006/1K tokens 約 $3-8
Ollama (地端) 免費 免費 僅硬體成本
* 實際成本依使用量而異,建議先以小量測試確認
8. 資料庫設計
8.1 主要資料表
• users用戶表儲存用戶基本資訊與角色
• groups群組表儲存關鍵字群組設定
• keywords關鍵字表儲存各群組的關鍵字清單
• news新聞表儲存抓取的新聞內容
• reports報告表儲存每日產出的報告
• report_news報告新聞關聯表多對多關聯表記錄報告與新聞的對應關係
• comments留言表儲存讀者留言
• llm_settingsLLM 設定表):儲存 LLM 提供者與參數設定
• system_logs系統日誌表儲存系統操作與錯誤日誌
• user_group_subscriptions用戶群組訂閱表記錄用戶訂閱的群組多對多關係
8.2 關聯關係
• 報告與新聞:多對多關係(一篇新聞可出現在多份報告),透過 report_news 關聯表
• 群組與報告:一對一關係(一份報告對應一個群組)
• 用戶與群組:多對多關係(用戶可訂閱多個群組),透過 user_group_subscriptions 關聯表
8.3 索引策略
• 新聞標題索引:優化新聞查詢效能
• 新聞發布時間索引:優化時間範圍查詢
• 群組關鍵字索引:優化關鍵字匹配效能
• 用戶訂閱關係索引:優化訂閱查詢效能
• 報告發布時間索引:優化報告歷史查詢
9. 環境變數配置
所有敏感資訊與連線設定均透過環境變數管理,確保安全性與彈性。
9.1 資料庫連線(已確認)
• DB_HOSTmysql.theaken.com
• DB_PORT33306
• DB_NAMEdb_A101
• DB_USERA101
• DB_PASSWORDAa123456
• 連線狀態:✅ 測試連線正常
9.2 Redis 連線(必填)
• REDIS_HOSTRedis 主機位址預設localhost
• REDIS_PORTRedis 埠號預設6379
• REDIS_PASSWORDRedis 密碼(如有)
• REDIS_DBRedis 資料庫編號預設0
9.3 Celery 設定(必填)
• CELERY_BROKER_URL訊息佇列 URL格式redis://[password@]host:port/db 或 amqp://user:password@host:port/vhost
• CELERY_RESULT_BACKEND結果儲存位置通常與 broker 相同)
9.4 SMTP 設定(必填)
• SMTP_HOSTSMTP 伺服器位址
• SMTP_PORTSMTP 埠號預設587
• SMTP_USERNAMESMTP 帳號
• SMTP_PASSWORDSMTP 密碼
• SMTP_FROM_EMAIL寄件者 Email
• SMTP_FROM_NAME寄件者名稱預設每日報導系統
• SMTP_USE_TLS是否使用 TLS預設True
9.5 AD/LDAP 設定(選填)
• LDAP_SERVERLDAP 伺服器位址
• LDAP_PORTLDAP 埠號預設389
• LDAP_BASE_DNLDAP Base DN
• LDAP_BIND_DNLDAP 綁定 DN如有
• LDAP_BIND_PASSWORDLDAP 綁定密碼(如有)
• LDAP_USER_SEARCH_FILTER用戶搜尋過濾器預設`(sAMAccountName={username})`
9.6 LLM API Keys選填依選擇的提供者設定
• GEMINI_API_KEYGoogle Gemini API Key
• OPENAI_API_KEYOpenAI API Key
• OLLAMA_ENDPOINTOllama 端點 URL預設http://localhost:11434
9.7 Digitimes 帳號(必填)
• DIGITIMES_USERNAMEDigitimes 登入帳號
• DIGITIMES_PASSWORDDigitimes 登入密碼
9.8 應用程式設定(必填)
• SECRET_KEY應用程式密鑰用於加密建議至少 32 字元)
• JWT_SECRET_KEYJWT 簽章密鑰(建議至少 32 字元)
• APP_ENV環境development/staging/production
• DEBUG除錯模式預設False生產環境必須為 False
9.9 環境變數管理方式
• 開發環境:使用 `.env` 檔案(不納入版本控制)
• 生產環境:使用 1Panel 環境變數設定介面
• 敏感資訊API Keys、密碼等均加密儲存
• 加密方式AES-256 加密,加密金鑰儲存在環境變數中
10. 附錄
10.1 名詞定義
名詞 定義
群組 依產業別或議題分類的關鍵字集合,作為新聞分類與報告產出的單位
報告 針對單一群組,彙整相關新聞並產出 AI 綜合摘要的每日產出物
專員 市場分析專員,負責新聞篩選、編輯與發布工作
讀者 訂閱並閱讀報告的內部用戶
LLM Large Language Model大型語言模型用於產生 AI 摘要
Ollama 開源的本地 LLM 執行框架,可在企業內部部署運行
10.2 待確認事項
1. Digitimes 帳號憑證管理方式 → 已確認:管理者設定
2. 經濟日報、工商時報爬蟲策略 → 已確認:請求間隔 3-5 秒
3. PDF Logo → 已確認:開放上傳選項
4. Email 發送服務 → 已確認SMTP
5. LLM 提供者 → 已確認Google Gemini / OpenAI / Ollama 三選一
— 文件結束 —

108
docker-compose.yml Normal file
View File

@@ -0,0 +1,108 @@
# 每日報導 APP - Docker Compose
# 適用於 1Panel 部署
version: '3.8'
services:
# FastAPI 應用
app:
build: .
container_name: daily-news-app
restart: unless-stopped
ports:
- "8000:8000"
environment:
- APP_ENV=production
- DEBUG=false
- DB_HOST=mysql
- DB_PORT=3306
- DB_NAME=${DB_NAME:-daily_news_app}
- DB_USER=${DB_USER:-root}
- DB_PASSWORD=${DB_PASSWORD}
- SECRET_KEY=${SECRET_KEY}
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
- LDAP_SERVER=${LDAP_SERVER}
- LDAP_BASE_DN=${LDAP_BASE_DN}
- LLM_PROVIDER=${LLM_PROVIDER:-gemini}
- GEMINI_API_KEY=${GEMINI_API_KEY}
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-1.5-pro}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o}
- OLLAMA_ENDPOINT=${OLLAMA_ENDPOINT:-http://ollama:11434}
- OLLAMA_MODEL=${OLLAMA_MODEL:-llama3}
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USERNAME=${SMTP_USERNAME}
- SMTP_PASSWORD=${SMTP_PASSWORD}
- SMTP_FROM_EMAIL=${SMTP_FROM_EMAIL}
- DIGITIMES_USERNAME=${DIGITIMES_USERNAME}
- DIGITIMES_PASSWORD=${DIGITIMES_PASSWORD}
volumes:
- ./uploads:/app/uploads
- ./logs:/app/logs
depends_on:
mysql:
condition: service_healthy
networks:
- daily-news-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
# MySQL 資料庫
mysql:
image: mysql:8.0
container_name: daily-news-mysql
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=${DB_PASSWORD}
- MYSQL_DATABASE=${DB_NAME:-daily_news_app}
- MYSQL_CHARSET=utf8mb4
- MYSQL_COLLATION=utf8mb4_unicode_ci
volumes:
- mysql_data:/var/lib/mysql
- ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
ports:
- "3306:3306"
networks:
- daily-news-network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --event-scheduler=ON
# Ollama (可選,地端 LLM)
ollama:
image: ollama/ollama:latest
container_name: daily-news-ollama
restart: unless-stopped
volumes:
- ollama_data:/root/.ollama
ports:
- "11434:11434"
networks:
- daily-news-network
profiles:
- ollama
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
networks:
daily-news-network:
driver: bridge
volumes:
mysql_data:
ollama_data:

49
requirements.txt Normal file
View File

@@ -0,0 +1,49 @@
# FastAPI 核心
fastapi==0.109.0
uvicorn[standard]==0.27.0
python-multipart==0.0.6
# 資料庫
sqlalchemy>=2.0.44 # 升級以支援 Python 3.13
pymysql==1.1.0
cryptography==42.0.0
alembic==1.13.1
# 認證
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
ldap3==2.9.1
# HTTP 客戶端(爬蟲用)
httpx==0.26.0
beautifulsoup4==4.12.3
lxml==5.1.0
# LLM 整合
google-generativeai==0.4.0
openai==1.10.0
# 排程
apscheduler==3.10.4
# Email
aiosmtplib==3.0.1
# PDF 生成
weasyprint==60.2
# 工具
pydantic>=2.12.4 # 升級以支援 Python 3.13
pydantic-settings>=2.12.0 # 升級以支援 Python 3.13
python-dotenv>=1.2.1 # 升級以支援 Python 3.13
email-validator>=2.3.0 # EmailStr 驗證所需
tenacity==8.2.3
# 測試
pytest==7.4.4
pytest-asyncio==0.23.3
httpx==0.26.0
# 開發工具
black==24.1.1
isort==5.13.2

27
requirements_clean.txt Normal file
View File

@@ -0,0 +1,27 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
python-multipart==0.0.6
sqlalchemy==2.0.25
pymysql==1.1.0
cryptography==42.0.0
alembic==1.13.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
ldap3==2.9.1
httpx==0.26.0
beautifulsoup4==4.12.3
lxml==5.1.0
google-generativeai==0.4.0
openai==1.10.0
apscheduler==3.10.4
aiosmtplib==3.0.1
weasyprint==60.2
pydantic==2.5.3
pydantic-settings==2.1.0
python-dotenv==1.0.0
tenacity==8.2.3
pytest==7.4.4
pytest-asyncio==0.23.3
black==24.1.1
isort==5.13.2
aiosqlite==0.19.0

98
run.py Normal file
View File

@@ -0,0 +1,98 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
每日報導 APP - 啟動腳本
執行此檔案即可啟動應用程式
"""
import sys
import os
from pathlib import Path
# 設定 Windows 命令提示字元的編碼
if sys.platform == 'win32':
try:
# 嘗試設定為 UTF-8
os.system('chcp 65001 > nul')
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
except:
pass
# 確保能找到 app 模組
sys.path.insert(0, str(Path(__file__).parent))
def safe_print(text):
"""安全的列印函數,避免編碼錯誤"""
try:
print(text)
except UnicodeEncodeError:
# 如果無法顯示,移除特殊字元
print(text.encode('ascii', 'ignore').decode('ascii'))
def main():
"""主程式進入點"""
safe_print("=" * 60)
safe_print("[啟動] 每日報導 APP 啟動中...")
safe_print("=" * 60)
# 檢查環境變數檔案
env_file = Path(__file__).parent / ".env"
if not env_file.exists():
safe_print("[警告] .env 檔案不存在")
safe_print("[提示] 請複製 .env.example 為 .env 並設定相關參數")
safe_print(" 指令: copy .env.example .env")
safe_print("=" * 60)
response = input("是否繼續啟動? (y/N): ")
if response.lower() != 'y':
safe_print("[取消] 啟動已取消")
return
# 檢查必要套件
try:
import fastapi
import uvicorn
import sqlalchemy
except ImportError as e:
safe_print(f"[錯誤] 缺少必要套件: {e}")
safe_print("[提示] 請先安裝相依套件:")
safe_print(" 指令: pip install -r requirements.txt")
safe_print("=" * 60)
return
# 啟動 FastAPI 應用程式
try:
import uvicorn
from app.main import app
safe_print("[成功] 應用程式已準備就緒")
safe_print("[網址] 伺服器位址: http://127.0.0.1:8000")
safe_print("[文件] API 文件: http://127.0.0.1:8000/docs")
safe_print("[健康] 健康檢查: http://127.0.0.1:8000/health")
safe_print("=" * 60)
safe_print("[提示] 按 Ctrl+C 停止伺服器")
safe_print("=" * 60)
# 讀取環境變數判斷是否為 debug 模式
from app.core.config import settings
uvicorn.run(
"app.main:app",
host="127.0.0.1",
port=8000,
reload=settings.debug,
log_level="info"
)
except Exception as e:
safe_print(f"[錯誤] 啟動失敗: {e}")
import traceback
traceback.print_exc()
safe_print("=" * 60)
safe_print("[提示] 請檢查:")
safe_print(" 1. .env 檔案是否正確設定")
safe_print(" 2. 資料庫連線是否正常")
safe_print(" 3. 相依套件是否完整安裝")
safe_print("=" * 60)
if __name__ == "__main__":
main()

425
scripts/init.sql Normal file
View File

@@ -0,0 +1,425 @@
-- ============================================================
-- 每日報導 APP - 資料庫 Schema 設計
-- Database: MySQL 8.0+
-- Charset: utf8mb4
-- ============================================================
-- 建立資料庫
CREATE DATABASE IF NOT EXISTS daily_news_app
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE daily_news_app;
-- ============================================================
-- 1. 用戶與權限相關
-- ============================================================
-- 1.1 角色表
CREATE TABLE roles (
id INT PRIMARY KEY AUTO_INCREMENT,
code VARCHAR(20) NOT NULL UNIQUE COMMENT '角色代碼: admin, editor, reader',
name VARCHAR(50) NOT NULL COMMENT '角色名稱',
description VARCHAR(200) COMMENT '角色描述',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) COMMENT='系統角色表';
-- 預設角色資料
INSERT INTO roles (code, name, description) VALUES
('admin', '系統管理員', 'LLM設定、AD整合、群組管理、用戶管理、系統設定'),
('editor', '市場分析專員', '新聞抓取管理、篩選編輯、報告發布、群組內容設定'),
('reader', '讀者', '訂閱群組、閱讀報告、留言討論、個人收藏、匯出PDF');
-- 1.2 用戶表
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE COMMENT '用戶帳號',
password_hash VARCHAR(255) COMMENT '密碼雜湊(本地帳號使用)',
display_name VARCHAR(100) NOT NULL COMMENT '顯示名稱',
email VARCHAR(100) COMMENT '電子郵件',
auth_type ENUM('ad', 'local') NOT NULL DEFAULT 'local' COMMENT '認證類型',
role_id INT NOT NULL COMMENT '角色ID',
is_active BOOLEAN DEFAULT TRUE COMMENT '是否啟用',
last_login_at TIMESTAMP NULL COMMENT '最後登入時間',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (role_id) REFERENCES roles(id)
) COMMENT='用戶表';
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_auth_type ON users(auth_type);
-- ============================================================
-- 2. 新聞來源與抓取相關
-- ============================================================
-- 2.1 新聞來源表
CREATE TABLE news_sources (
id INT PRIMARY KEY AUTO_INCREMENT,
code VARCHAR(30) NOT NULL UNIQUE COMMENT '來源代碼',
name VARCHAR(100) NOT NULL COMMENT '來源名稱',
base_url VARCHAR(255) NOT NULL COMMENT '網站基礎URL',
source_type ENUM('subscription', 'public') NOT NULL COMMENT '來源類型:付費訂閱/公開',
login_username VARCHAR(100) COMMENT '登入帳號(付費訂閱用)',
login_password_encrypted VARCHAR(255) COMMENT '加密後密碼',
is_active BOOLEAN DEFAULT TRUE COMMENT '是否啟用',
crawl_config JSON COMMENT '爬蟲設定(選擇器、間隔等)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) COMMENT='新聞來源表';
-- 預設新聞來源
INSERT INTO news_sources (code, name, base_url, source_type) VALUES
('digitimes', 'Digitimes', 'https://www.digitimes.com.tw', 'subscription'),
('udn', '經濟日報', 'https://money.udn.com', 'public'),
('ctee', '工商時報', 'https://ctee.com.tw', 'public');
-- 2.2 新聞文章表
CREATE TABLE news_articles (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
source_id INT NOT NULL COMMENT '來源ID',
external_id VARCHAR(100) COMMENT '外部文章ID防重複抓取',
title VARCHAR(500) NOT NULL COMMENT '文章標題',
content LONGTEXT COMMENT '文章全文',
summary TEXT COMMENT '原文摘要',
url VARCHAR(500) NOT NULL COMMENT '原文連結',
author VARCHAR(100) COMMENT '作者',
published_at TIMESTAMP NULL COMMENT '發布時間',
crawled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '抓取時間',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (source_id) REFERENCES news_sources(id),
UNIQUE KEY uk_source_external (source_id, external_id)
) COMMENT='新聞文章表';
CREATE INDEX idx_articles_published ON news_articles(published_at);
CREATE INDEX idx_articles_crawled ON news_articles(crawled_at);
CREATE FULLTEXT INDEX ft_articles_content ON news_articles(title, content);
-- 2.3 抓取任務記錄表
CREATE TABLE crawl_jobs (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
source_id INT NOT NULL COMMENT '來源ID',
status ENUM('pending', 'running', 'completed', 'failed') DEFAULT 'pending',
scheduled_at TIMESTAMP NOT NULL COMMENT '排程時間',
started_at TIMESTAMP NULL COMMENT '開始時間',
completed_at TIMESTAMP NULL COMMENT '完成時間',
articles_count INT DEFAULT 0 COMMENT '抓取文章數',
error_message TEXT COMMENT '錯誤訊息',
retry_count INT DEFAULT 0 COMMENT '重試次數',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (source_id) REFERENCES news_sources(id)
) COMMENT='抓取任務記錄表';
CREATE INDEX idx_crawl_jobs_status ON crawl_jobs(status);
CREATE INDEX idx_crawl_jobs_scheduled ON crawl_jobs(scheduled_at);
-- ============================================================
-- 3. 群組與關鍵字相關
-- ============================================================
-- 3.1 群組表
CREATE TABLE groups (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL COMMENT '群組名稱',
description TEXT COMMENT '群組描述',
category ENUM('industry', 'topic') NOT NULL COMMENT '分類:產業別/議題',
ai_background TEXT COMMENT 'AI背景資訊設定',
ai_prompt TEXT COMMENT 'AI摘要方向提示',
is_active BOOLEAN DEFAULT TRUE COMMENT '是否啟用',
created_by INT COMMENT '建立者ID',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id)
) COMMENT='群組表';
-- 3.2 關鍵字表
CREATE TABLE keywords (
id INT PRIMARY KEY AUTO_INCREMENT,
group_id INT NOT NULL COMMENT '所屬群組ID',
keyword VARCHAR(100) NOT NULL COMMENT '關鍵字',
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE,
UNIQUE KEY uk_group_keyword (group_id, keyword)
) COMMENT='關鍵字表';
CREATE INDEX idx_keywords_keyword ON keywords(keyword);
-- 3.3 新聞-群組關聯表(根據關鍵字匹配)
CREATE TABLE article_group_matches (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
article_id BIGINT NOT NULL,
group_id INT NOT NULL,
matched_keywords JSON COMMENT '匹配到的關鍵字列表',
match_score DECIMAL(5,2) COMMENT '匹配分數',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (article_id) REFERENCES news_articles(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE,
UNIQUE KEY uk_article_group (article_id, group_id)
) COMMENT='新聞-群組匹配關聯表';
CREATE INDEX idx_matches_group ON article_group_matches(group_id);
-- ============================================================
-- 4. 報告相關
-- ============================================================
-- 4.1 報告表
CREATE TABLE reports (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
group_id INT NOT NULL COMMENT '所屬群組ID',
title VARCHAR(200) NOT NULL COMMENT '報告標題',
report_date DATE NOT NULL COMMENT '報告日期',
ai_summary LONGTEXT COMMENT 'AI綜合摘要',
edited_summary LONGTEXT COMMENT '編輯後摘要(專員修改版)',
status ENUM('draft', 'pending', 'published', 'delayed') DEFAULT 'draft' COMMENT '狀態',
published_at TIMESTAMP NULL COMMENT '發布時間',
published_by INT COMMENT '發布者ID',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (group_id) REFERENCES groups(id),
FOREIGN KEY (published_by) REFERENCES users(id),
UNIQUE KEY uk_group_date (group_id, report_date)
) COMMENT='報告表';
CREATE INDEX idx_reports_status ON reports(status);
CREATE INDEX idx_reports_date ON reports(report_date);
-- 4.2 報告-新聞關聯表
CREATE TABLE report_articles (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
report_id BIGINT NOT NULL,
article_id BIGINT NOT NULL,
is_included BOOLEAN DEFAULT TRUE COMMENT '是否納入報告(專員篩選)',
display_order INT DEFAULT 0 COMMENT '顯示順序',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE,
FOREIGN KEY (article_id) REFERENCES news_articles(id),
UNIQUE KEY uk_report_article (report_id, article_id)
) COMMENT='報告-新聞關聯表';
-- ============================================================
-- 5. 讀者互動相關
-- ============================================================
-- 5.1 訂閱表
CREATE TABLE subscriptions (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
group_id INT NOT NULL,
email_notify BOOLEAN DEFAULT TRUE COMMENT '是否Email通知',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE,
UNIQUE KEY uk_user_group (user_id, group_id)
) COMMENT='訂閱表';
-- 5.2 收藏表
CREATE TABLE favorites (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
report_id BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE,
UNIQUE KEY uk_user_report (user_id, report_id)
) COMMENT='收藏表';
-- 5.3 留言表
CREATE TABLE comments (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
report_id BIGINT NOT NULL,
user_id INT NOT NULL,
content TEXT NOT NULL COMMENT '留言內容',
parent_id BIGINT COMMENT '父留言ID回覆用',
is_deleted BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (parent_id) REFERENCES comments(id)
) COMMENT='留言表';
CREATE INDEX idx_comments_report ON comments(report_id);
-- 5.4 個人筆記表
CREATE TABLE notes (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
report_id BIGINT NOT NULL,
content TEXT NOT NULL COMMENT '筆記內容',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE
) COMMENT='個人筆記表';
CREATE INDEX idx_notes_user_report ON notes(user_id, report_id);
-- ============================================================
-- 6. 系統設定相關
-- ============================================================
-- 6.1 系統設定表
CREATE TABLE system_settings (
id INT PRIMARY KEY AUTO_INCREMENT,
setting_key VARCHAR(50) NOT NULL UNIQUE COMMENT '設定鍵',
setting_value TEXT COMMENT '設定值',
setting_type ENUM('string', 'number', 'boolean', 'json') DEFAULT 'string',
description VARCHAR(200) COMMENT '設定描述',
updated_by INT COMMENT '更新者ID',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (updated_by) REFERENCES users(id)
) COMMENT='系統設定表';
-- 預設系統設定
INSERT INTO system_settings (setting_key, setting_value, setting_type, description) VALUES
('crawl_schedule_time', '08:00', 'string', '每日抓取排程時間'),
('publish_deadline', '09:00', 'string', '報告發布截止時間'),
('llm_provider', 'claude', 'string', 'LLM提供者: openai/claude/ollama'),
('llm_api_key_encrypted', '', 'string', '加密後的API Key'),
('llm_model', 'claude-3-sonnet', 'string', '使用的模型名稱'),
('llm_ollama_endpoint', 'http://localhost:11434', 'string', 'Ollama端點'),
('data_retention_days', '60', 'number', '資料保留天數'),
('pdf_logo_path', '', 'string', 'PDF Logo檔案路徑'),
('pdf_header_text', '', 'string', 'PDF頁首文字'),
('pdf_footer_text', '', 'string', 'PDF頁尾文字'),
('smtp_host', '', 'string', 'SMTP伺服器'),
('smtp_port', '587', 'number', 'SMTP埠號'),
('smtp_username', '', 'string', 'SMTP帳號'),
('smtp_password_encrypted', '', 'string', '加密後SMTP密碼'),
('smtp_from_email', '', 'string', '寄件者Email'),
('smtp_from_name', '每日報導系統', 'string', '寄件者名稱');
-- 6.2 操作日誌表
CREATE TABLE audit_logs (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id INT COMMENT '操作用戶ID',
action VARCHAR(50) NOT NULL COMMENT '操作類型',
target_type VARCHAR(50) COMMENT '目標類型',
target_id VARCHAR(50) COMMENT '目標ID',
details JSON COMMENT '操作詳情',
ip_address VARCHAR(45) COMMENT 'IP地址',
user_agent VARCHAR(500) COMMENT 'User Agent',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
) COMMENT='操作日誌表';
CREATE INDEX idx_audit_user ON audit_logs(user_id);
CREATE INDEX idx_audit_action ON audit_logs(action);
CREATE INDEX idx_audit_created ON audit_logs(created_at);
-- 6.3 通知記錄表
CREATE TABLE notification_logs (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
report_id BIGINT COMMENT '關聯報告ID',
notification_type ENUM('email', 'system') DEFAULT 'email',
subject VARCHAR(200) COMMENT '通知標題',
content TEXT COMMENT '通知內容',
status ENUM('pending', 'sent', 'failed') DEFAULT 'pending',
sent_at TIMESTAMP NULL,
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (report_id) REFERENCES reports(id)
) COMMENT='通知記錄表';
CREATE INDEX idx_notification_status ON notification_logs(status);
-- ============================================================
-- 7. 資料清理事件60天保留
-- ============================================================
-- 建立事件排程器(需確保 event_scheduler=ON
DELIMITER //
CREATE EVENT IF NOT EXISTS cleanup_old_data
ON SCHEDULE EVERY 1 DAY
STARTS CURRENT_TIMESTAMP
DO
BEGIN
DECLARE retention_days INT DEFAULT 60;
-- 取得設定的保留天數
SELECT CAST(setting_value AS UNSIGNED) INTO retention_days
FROM system_settings
WHERE setting_key = 'data_retention_days';
-- 刪除過期的新聞文章(會連帶刪除關聯資料)
DELETE FROM news_articles
WHERE crawled_at < DATE_SUB(NOW(), INTERVAL retention_days DAY);
-- 刪除過期的抓取任務記錄
DELETE FROM crawl_jobs
WHERE created_at < DATE_SUB(NOW(), INTERVAL retention_days DAY);
-- 刪除過期的操作日誌
DELETE FROM audit_logs
WHERE created_at < DATE_SUB(NOW(), INTERVAL retention_days DAY);
-- 刪除過期的通知記錄
DELETE FROM notification_logs
WHERE created_at < DATE_SUB(NOW(), INTERVAL retention_days DAY);
END//
DELIMITER ;
-- ============================================================
-- 8. 視圖View- 常用查詢
-- ============================================================
-- 8.1 今日待審核報告視圖
CREATE VIEW v_pending_reports AS
SELECT
r.id,
r.title,
r.report_date,
r.status,
g.name AS group_name,
g.category,
COUNT(ra.id) AS article_count,
SUM(CASE WHEN ra.is_included = TRUE THEN 1 ELSE 0 END) AS included_count
FROM reports r
JOIN groups g ON r.group_id = g.id
LEFT JOIN report_articles ra ON r.id = ra.report_id
WHERE r.report_date = CURDATE()
GROUP BY r.id;
-- 8.2 用戶訂閱群組視圖
CREATE VIEW v_user_subscriptions AS
SELECT
u.id AS user_id,
u.display_name,
u.email,
g.id AS group_id,
g.name AS group_name,
g.category,
s.email_notify
FROM users u
JOIN subscriptions s ON u.id = s.user_id
JOIN groups g ON s.group_id = g.id
WHERE u.is_active = TRUE AND g.is_active = TRUE;
-- 8.3 報告統計視圖
CREATE VIEW v_report_stats AS
SELECT
r.id AS report_id,
r.title,
r.report_date,
r.status,
g.name AS group_name,
COUNT(DISTINCT f.user_id) AS favorite_count,
COUNT(DISTINCT c.id) AS comment_count,
COUNT(DISTINCT s.user_id) AS subscriber_count
FROM reports r
JOIN groups g ON r.group_id = g.id
LEFT JOIN favorites f ON r.id = f.report_id
LEFT JOIN comments c ON r.id = c.report_id AND c.is_deleted = FALSE
LEFT JOIN subscriptions s ON g.id = s.group_id
GROUP BY r.id;
-- ============================================================
-- END OF SCHEMA
-- ============================================================

130
scripts/init_db_sqlite.py Normal file
View File

@@ -0,0 +1,130 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
資料庫初始化腳本
建立所有表格並插入預設資料
"""
import sys
import os
# Add project root to python path
sys.path.append(os.getcwd())
from app.db.session import init_db, SessionLocal
from app.models import Role, User, NewsSource, SourceType
from app.core.security import get_password_hash
from app.core.config import settings
def seed_default_data():
"""插入預設資料"""
db = SessionLocal()
try:
# 1. 建立角色
roles_data = [
{"code": "admin", "name": "系統管理員", "description": "LLM 設定、AD 整合、群組管理、用戶管理、系統設定"},
{"code": "editor", "name": "市場分析專員", "description": "新聞抓取管理、篩選編輯、報告發布、群組內容設定"},
{"code": "reader", "name": "讀者", "description": "訂閱群組、閱讀報告、留言討論、個人收藏、匯出 PDF"},
]
for role_data in roles_data:
existing = db.query(Role).filter(Role.code == role_data["code"]).first()
if not existing:
role = Role(**role_data)
db.add(role)
print(f" 建立角色: {role_data['name']}")
db.commit()
# 2. 建立管理員帳號
admin_role = db.query(Role).filter(Role.code == "admin").first()
existing_admin = db.query(User).filter(User.username == "admin").first()
if not existing_admin and admin_role:
admin_user = User(
username="admin",
password_hash=get_password_hash(settings.admin_password),
display_name="系統管理員",
email="admin@example.com",
auth_type="local",
role_id=admin_role.id,
is_active=True
)
db.add(admin_user)
print(f" 建立管理員帳號: admin (密碼: {settings.admin_password})")
db.commit()
# 3. 建立新聞來源
sources_data = [
{
"code": "digitimes",
"name": "Digitimes",
"base_url": "https://www.digitimes.com.tw",
"source_type": SourceType.SUBSCRIPTION,
"is_active": True,
},
{
"code": "udn",
"name": "經濟日報",
"base_url": "https://money.udn.com",
"source_type": SourceType.PUBLIC,
"is_active": True,
},
{
"code": "ctee",
"name": "工商時報",
"base_url": "https://ctee.com.tw",
"source_type": SourceType.PUBLIC,
"is_active": True,
},
]
for source_data in sources_data:
existing = db.query(NewsSource).filter(NewsSource.code == source_data["code"]).first()
if not existing:
source = NewsSource(**source_data)
db.add(source)
print(f" 建立新聞來源: {source_data['name']}")
db.commit()
print("預設資料插入完成!")
except Exception as e:
db.rollback()
print(f"插入預設資料失敗: {e}")
raise
finally:
db.close()
if __name__ == "__main__":
print("=" * 50)
print("每日報導 APP - 資料庫初始化")
print("=" * 50)
print("\n1. 建立資料庫表格...")
try:
init_db()
print("資料庫表格建立成功!")
except Exception as e:
print(f"建立表格失敗: {e}")
sys.exit(1)
print("\n2. 插入預設資料...")
try:
seed_default_data()
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
print("\n" + "=" * 50)
print("初始化完成!")
print("=" * 50)
print("\n登入資訊:")
print(f" 帳號: admin")
print(f" 密碼: {settings.admin_password}")
print("\n啟動應用程式:")
print(" python run.py")
print("=" * 50)

1176
security-fixes.md Normal file

File diff suppressed because it is too large Load Diff

949
templates/index.html Normal file
View File

@@ -0,0 +1,949 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>每日報導 APP</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft JhengHei", Arial, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
/* 登入頁面 */
#login-page {
display: flex;
min-height: 100vh;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%);
}
.login-card {
background: white;
padding: 3rem;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
width: 100%;
max-width: 400px;
}
.login-card h1 {
text-align: center;
margin-bottom: 2rem;
color: #2c3e50;
}
/* 導航列 */
.navbar {
background: #2c3e50;
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.navbar h1 {
font-size: 1.5rem;
}
.navbar-actions {
display: flex;
gap: 1rem;
align-items: center;
}
.lang-switch {
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
color: white;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.user-menu {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* 按鈕 */
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s;
}
.btn-sm {
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-primary:hover {
background: #2980b9;
}
.btn-secondary {
background: #95a5a6;
color: white;
}
.btn-success {
background: #27ae60;
color: white;
}
.btn-success:hover {
background: #219a52;
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn-danger:hover {
background: #c0392b;
}
/* 容器 */
.container {
max-width: 1200px;
margin: 2rem auto;
padding: 0 1rem;
}
/* 卡片 */
.card {
background: white;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 2px solid #ecf0f1;
}
.card-title {
font-size: 1.3rem;
font-weight: 600;
color: #2c3e50;
}
/* 表格 */
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #ecf0f1;
}
.table th {
background: #f8f9fa;
font-weight: 600;
color: #2c3e50;
}
.table tr:hover {
background: #f8f9fa;
}
/* 標籤 */
.badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
}
.badge-success {
background: #d4edda;
color: #155724;
}
.badge-warning {
background: #fff3cd;
color: #856404;
}
.badge-danger {
background: #f8d7da;
color: #721c24;
}
.badge-info {
background: #d1ecf1;
color: #0c5460;
}
.badge-secondary {
background: #e9ecef;
color: #495057;
}
/* 表單 */
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #2c3e50;
}
.form-control {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.form-control:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
}
/* 頁面切換 */
.page {
display: none;
}
.page.active {
display: block;
}
/* 標籤頁 */
.tabs {
display: flex;
border-bottom: 2px solid #ecf0f1;
margin-bottom: 1.5rem;
}
.tab {
padding: 1rem 1.5rem;
cursor: pointer;
border: none;
background: none;
font-size: 1rem;
color: #7f8c8d;
border-bottom: 3px solid transparent;
transition: all 0.3s;
}
.tab:hover {
color: #3498db;
}
.tab.active {
color: #3498db;
border-bottom-color: #3498db;
font-weight: 600;
}
/* 統計卡片 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: #3498db;
}
.stat-label {
color: #7f8c8d;
font-size: 0.9rem;
margin-top: 0.5rem;
}
/* 摘要區塊 */
.summary-box {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
border-left: 4px solid #3498db;
margin: 1rem 0;
}
/* 文章列表 */
.article-item {
padding: 1rem;
border-bottom: 1px solid #ecf0f1;
display: flex;
justify-content: space-between;
align-items: center;
}
.article-item:last-child {
border-bottom: none;
}
.article-info {
flex: 1;
}
.article-title {
font-weight: 600;
margin-bottom: 0.5rem;
}
.article-meta {
color: #7f8c8d;
font-size: 0.9rem;
}
/* 切換按鈕 */
.switch {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 24px;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #3498db;
}
input:checked + .slider:before {
transform: translateX(26px);
}
/* Modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 8px;
max-width: 800px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
padding: 2rem;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #7f8c8d;
}
/* Loading Overlay */
#loading-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255,255,255,0.8);
align-items: center;
justify-content: center;
z-index: 2000;
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid #ecf0f1;
border-top-color: #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Toast */
#toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 3000;
}
.toast {
background: #333;
color: white;
padding: 1rem 1.5rem;
border-radius: 4px;
margin-bottom: 0.5rem;
opacity: 0;
transition: opacity 0.3s;
}
.toast.show {
opacity: 1;
}
.toast-success {
background: #27ae60;
}
.toast-error {
background: #e74c3c;
}
.toast-info {
background: #3498db;
}
/* 關鍵字標籤 */
.keyword-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 1rem;
margin: 0.25rem;
}
.keyword-badge button {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
color: inherit;
}
/* 空狀態 */
.empty-state {
text-align: center;
padding: 3rem;
color: #7f8c8d;
}
/* 報告卡片 */
.report-card {
border-left: 4px solid #3498db;
}
/* 響應式 */
@media (max-width: 768px) {
.navbar {
flex-direction: column;
gap: 1rem;
}
.container {
padding: 0 0.5rem;
}
.stats-grid {
grid-template-columns: 1fr;
}
.tabs {
flex-wrap: wrap;
}
.tab {
flex: 1;
text-align: center;
padding: 0.75rem;
}
}
/* 分隔線 */
.section-divider {
margin: 2rem 0;
padding-top: 2rem;
border-top: 2px solid #ecf0f1;
}
</style>
</head>
<body>
<!-- Loading Overlay -->
<div id="loading-overlay">
<div class="spinner"></div>
</div>
<!-- Toast Container -->
<div id="toast-container"></div>
<!-- 登入頁面 -->
<div id="login-page">
<div class="login-card">
<h1>每日報導 APP</h1>
<form id="login-form" onsubmit="handleLoginSubmit(event)">
<div class="form-group">
<label class="form-label">帳號</label>
<input type="text" class="form-control" id="login-username" required placeholder="輸入帳號">
</div>
<div class="form-group">
<label class="form-label">密碼</label>
<input type="password" class="form-control" id="login-password" required placeholder="輸入密碼">
</div>
<div class="form-group">
<label class="form-label">認證方式</label>
<select class="form-control" id="login-auth-type">
<option value="local">本地認證</option>
<option value="ad">AD 認證</option>
</select>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%; padding: 0.75rem;">登入</button>
</form>
</div>
</div>
<!-- 主應用程式 -->
<div id="main-app" style="display: none;">
<!-- 導航列 -->
<nav class="navbar">
<h1>每日報導 APP</h1>
<div class="navbar-actions">
<div class="user-menu">
<span id="user-display-name">載入中...</span>
<button class="btn btn-secondary" onclick="app.handleLogout()">登出</button>
</div>
</div>
</nav>
<div class="container">
<!-- 頁面選單 -->
<div class="tabs">
<button class="tab active" data-page="dashboard" onclick="app.showPage('dashboard')">儀表板</button>
<button class="tab" data-page="reports" onclick="app.showPage('reports')">報告管理</button>
<button class="tab" data-page="groups" onclick="app.showPage('groups')">群組管理</button>
<button class="tab" data-page="users" onclick="app.showPage('users')">用戶管理</button>
<button class="tab" data-page="settings" onclick="app.showPage('settings')">系統設定</button>
</div>
<!-- 儀表板頁面 -->
<div id="dashboard" class="page active">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="stat-today-articles">-</div>
<div class="stat-label">今日新聞</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-pending-reports">-</div>
<div class="stat-label">待審核報告</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-published-reports">-</div>
<div class="stat-label">已發布報告</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-active-users">-</div>
<div class="stat-label">活躍用戶</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">今日待審核報告</h2>
<button class="btn btn-primary" onclick="app.showPage('reports')">查看全部</button>
</div>
<table class="table">
<thead>
<tr>
<th>報告標題</th>
<th>群組</th>
<th>文章數</th>
<th>狀態</th>
<th>操作</th>
</tr>
</thead>
<tbody id="dashboard-reports-tbody">
<tr>
<td colspan="5" style="text-align: center; color: #7f8c8d;">載入中...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 報告管理頁面 -->
<div id="reports" class="page">
<div class="card">
<div class="card-header">
<h2 class="card-title">報告管理</h2>
<div>
<input type="date" class="form-control" id="report-date-filter"
style="width: auto; display: inline-block; margin-right: 0.5rem;"
onchange="app.loadReports()">
<select class="form-control" id="report-group-filter"
style="width: auto; display: inline-block;"
onchange="app.loadReports()">
<option value="all">全部群組</option>
</select>
</div>
</div>
<div id="reports-list">
<div class="empty-state">載入中...</div>
</div>
</div>
</div>
<!-- 群組管理頁面 -->
<div id="groups" class="page">
<div class="card">
<div class="card-header">
<h2 class="card-title">群組管理</h2>
<button class="btn btn-primary" onclick="app.showNewGroupForm()">新增群組</button>
</div>
<table class="table">
<thead>
<tr>
<th>群組名稱</th>
<th>分類</th>
<th>關鍵字數</th>
<th>訂閱數</th>
<th>狀態</th>
<th>操作</th>
</tr>
</thead>
<tbody id="groups-tbody">
<tr>
<td colspan="6" style="text-align: center;">載入中...</td>
</tr>
</tbody>
</table>
</div>
<!-- 群組編輯表單 -->
<div class="card" id="group-form-card" style="display: none;">
<h3 class="card-title" style="margin-bottom: 1rem;">編輯群組</h3>
<form id="group-form" onsubmit="event.preventDefault(); app.saveGroup();">
<div class="form-group">
<label class="form-label">群組名稱</label>
<input type="text" class="form-control" id="group-name" required>
</div>
<div class="form-group">
<label class="form-label">描述</label>
<textarea class="form-control" id="group-description" rows="3"></textarea>
</div>
<div class="form-group">
<label class="form-label">分類</label>
<select class="form-control" id="group-category">
<option value="industry">產業別</option>
<option value="topic">主題</option>
</select>
</div>
<div class="form-group">
<label class="form-label">關鍵字</label>
<div id="group-keywords" style="margin-bottom: 0.5rem;"></div>
<div style="display: flex; gap: 0.5rem;">
<input type="text" class="form-control" id="new-keyword-input" placeholder="新增關鍵字...">
<button type="button" class="btn btn-primary"
onclick="app.addKeyword(document.getElementById('group-form').dataset.groupId)">新增</button>
</div>
</div>
<div class="form-group">
<label class="form-label">AI 摘要背景資訊</label>
<textarea class="form-control" id="group-ai-background" rows="4"
placeholder="提供給 AI 的背景資訊..."></textarea>
</div>
<div class="form-group">
<label class="form-label">AI 摘要方向</label>
<textarea class="form-control" id="group-ai-prompt" rows="4"
placeholder="指定 AI 摘要的重點方向..."></textarea>
</div>
<div style="display: flex; gap: 1rem;">
<button type="submit" class="btn btn-primary">儲存</button>
<button type="button" class="btn btn-secondary"
onclick="document.getElementById('group-form-card').style.display='none'">取消</button>
</div>
</form>
</div>
</div>
<!-- 用戶管理頁面 -->
<div id="users" class="page">
<div class="card">
<div class="card-header">
<h2 class="card-title">用戶管理</h2>
<button class="btn btn-primary" onclick="app.showNewUserForm()">新增用戶</button>
</div>
<div style="margin-bottom: 1rem;">
<input type="text" class="form-control" id="user-search" placeholder="搜尋用戶..."
style="max-width: 300px; display: inline-block;" onkeyup="app.loadUsers()">
<select class="form-control" id="user-role-filter"
style="width: auto; display: inline-block; margin-left: 0.5rem;"
onchange="app.loadUsers()">
<option value="all">全部角色</option>
<option value="admin">管理員</option>
<option value="editor">專員</option>
<option value="reader">讀者</option>
</select>
</div>
<table class="table">
<thead>
<tr>
<th>帳號</th>
<th>顯示名稱</th>
<th>Email</th>
<th>角色</th>
<th>認證方式</th>
<th>狀態</th>
<th>操作</th>
</tr>
</thead>
<tbody id="users-tbody">
<tr>
<td colspan="7" style="text-align: center;">載入中...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 系統設定頁面 -->
<div id="settings" class="page">
<div class="card">
<h2 class="card-title" style="margin-bottom: 1.5rem;">系統設定</h2>
<!-- LLM 設定 -->
<div>
<h3 style="margin-bottom: 1rem; color: #2c3e50;">LLM 設定</h3>
<div class="form-group">
<label class="form-label">LLM 提供者</label>
<select class="form-control" id="llm-provider">
<option value="gemini">Google Gemini</option>
<option value="openai">OpenAI</option>
<option value="ollama">Ollama (地端)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">API Key</label>
<input type="password" class="form-control" id="llm-api-key" placeholder="輸入 API Key...">
<small style="color: #7f8c8d; margin-top: 0.25rem; display: block;">API Key 將加密儲存</small>
</div>
<div class="form-group">
<label class="form-label">模型版本</label>
<input type="text" class="form-control" id="llm-model" placeholder="例如: gemini-1.5-pro">
</div>
<button class="btn btn-primary" onclick="app.testLlmConnection()">測試連線</button>
<button class="btn btn-success" style="margin-left: 0.5rem;" onclick="app.saveLlmSettings()">儲存設定</button>
</div>
<!-- PDF 模板設定 -->
<div class="section-divider">
<h3 style="margin-bottom: 1rem; color: #2c3e50;">PDF 模板設定</h3>
<div class="form-group">
<label class="form-label">公司 Logo</label>
<input type="file" class="form-control" id="pdf-logo-input" accept="image/png,image/jpeg,image/svg+xml">
<small style="color: #7f8c8d; margin-top: 0.25rem; display: block;">支援 PNG、JPEG、SVG 格式</small>
<button class="btn btn-primary" style="margin-top: 0.5rem;" onclick="app.uploadLogo()">上傳</button>
</div>
<div class="form-group">
<label class="form-label">頁首文字</label>
<input type="text" class="form-control" id="pdf-header" placeholder="每日報導">
</div>
<div class="form-group">
<label class="form-label">頁尾文字</label>
<input type="text" class="form-control" id="pdf-footer" placeholder="本報告僅供內部參考使用">
</div>
</div>
<!-- SMTP 設定 -->
<div class="section-divider">
<h3 style="margin-bottom: 1rem; color: #2c3e50;">SMTP 設定</h3>
<div class="form-group">
<label class="form-label">SMTP 伺服器</label>
<input type="text" class="form-control" id="smtp-host" placeholder="smtp.example.com">
</div>
<div class="form-group">
<label class="form-label">SMTP 埠號</label>
<input type="number" class="form-control" id="smtp-port" value="587">
</div>
<div class="form-group">
<label class="form-label">SMTP 帳號</label>
<input type="text" class="form-control" id="smtp-username" placeholder="smtp@example.com">
</div>
<div class="form-group">
<label class="form-label">SMTP 密碼</label>
<input type="password" class="form-control" id="smtp-password" placeholder="輸入密碼...">
</div>
<div class="form-group">
<label class="form-label">寄件者 Email</label>
<input type="email" class="form-control" id="smtp-from-email" placeholder="noreply@example.com">
</div>
<div class="form-group">
<label class="form-label">寄件者名稱</label>
<input type="text" class="form-control" id="smtp-from-name" placeholder="每日報導系統">
</div>
<button class="btn btn-success" onclick="app.saveSmtpSettings()">儲存設定</button>
</div>
</div>
</div>
</div>
</div>
<!-- 報告詳情 Modal -->
<div class="modal" id="report-detail-modal">
<div class="modal-content">
<div class="modal-header">
<h2>報告詳情</h2>
<button class="modal-close" onclick="app.closeModal('report-detail-modal')">&times;</button>
</div>
<div id="report-detail-content">
載入中...
</div>
</div>
</div>
<!-- 用戶編輯 Modal -->
<div class="modal" id="user-modal">
<div class="modal-content">
<div class="modal-header">
<h2>編輯用戶</h2>
<button class="modal-close" onclick="app.closeModal('user-modal')">&times;</button>
</div>
<form id="user-form" onsubmit="event.preventDefault(); app.saveUser();">
<div class="form-group">
<label class="form-label">帳號</label>
<input type="text" class="form-control" id="user-username" required>
</div>
<div class="form-group">
<label class="form-label">顯示名稱</label>
<input type="text" class="form-control" id="user-display-name-input" required>
</div>
<div class="form-group">
<label class="form-label">Email</label>
<input type="email" class="form-control" id="user-email">
</div>
<div class="form-group">
<label class="form-label">角色</label>
<select class="form-control" id="user-role">
<option value="1">管理員</option>
<option value="2">專員</option>
<option value="3">讀者</option>
</select>
</div>
<div class="form-group">
<label class="form-label">認證方式</label>
<select class="form-control" id="user-auth-type">
<option value="local">本地認證</option>
<option value="ad">AD 認證</option>
</select>
</div>
<div class="form-group" id="user-password-group">
<label class="form-label">密碼</label>
<input type="password" class="form-control" id="user-password" placeholder="留空則不修改">
</div>
<div class="form-group">
<label class="form-label">
<input type="checkbox" id="user-is-active" checked> 啟用帳號
</label>
</div>
<div style="display: flex; gap: 1rem;">
<button type="submit" class="btn btn-primary">儲存</button>
<button type="button" class="btn btn-secondary" onclick="app.closeModal('user-modal')">取消</button>
</div>
</form>
</div>
</div>
<!-- 載入 JavaScript -->
<script src="/static/js/api.js"></script>
<script src="/static/js/app.js"></script>
<script>
// 登入表單處理
function handleLoginSubmit(event) {
event.preventDefault();
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
const authType = document.getElementById('login-auth-type').value;
app.handleLogin(username, password, authType);
}
</script>
</body>
</html>

430
templates/js/api.js Normal file
View File

@@ -0,0 +1,430 @@
/**
* API 服務層 - 處理所有後端 API 呼叫
*/
const API_BASE_URL = '/api/v1';
class ApiService {
constructor() {
this.baseUrl = API_BASE_URL;
}
/**
* 取得認證 Token
*/
getToken() {
return localStorage.getItem('token');
}
/**
* 設定認證 Token
*/
setToken(token) {
localStorage.setItem('token', token);
}
/**
* 清除認證 Token
*/
clearToken() {
localStorage.removeItem('token');
localStorage.removeItem('user');
}
/**
* 建立請求標頭
*/
getHeaders(includeAuth = true) {
const headers = {
'Content-Type': 'application/json'
};
if (includeAuth) {
const token = this.getToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
return headers;
}
/**
* 發送 API 請求
*/
async request(endpoint, options = {}) {
const url = `${this.baseUrl}${endpoint}`;
const config = {
headers: this.getHeaders(options.auth !== false),
...options
};
try {
const response = await fetch(url, config);
// 處理 401 未授權
if (response.status === 401) {
this.clearToken();
window.location.reload();
throw new Error('登入已過期,請重新登入');
}
// 處理 204 無內容
if (response.status === 204) {
return { success: true };
}
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || '請求失敗');
}
return data;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
/**
* GET 請求
*/
async get(endpoint, params = {}) {
const queryString = new URLSearchParams(params).toString();
const url = queryString ? `${endpoint}?${queryString}` : endpoint;
return this.request(url, { method: 'GET' });
}
/**
* POST 請求
*/
async post(endpoint, data = {}, options = {}) {
return this.request(endpoint, {
method: 'POST',
body: JSON.stringify(data),
...options
});
}
/**
* PUT 請求
*/
async put(endpoint, data = {}) {
return this.request(endpoint, {
method: 'PUT',
body: JSON.stringify(data)
});
}
/**
* DELETE 請求
*/
async delete(endpoint) {
return this.request(endpoint, { method: 'DELETE' });
}
/**
* 上傳檔案
*/
async upload(endpoint, formData) {
const token = this.getToken();
const headers = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method: 'POST',
headers,
body: formData
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.detail || '上傳失敗');
}
return response.json();
}
}
// 建立全域 API 實例 (必須在其他 API 模組之前)
const api = new ApiService();
// ============ 認證 API ============
const authApi = {
/**
* 登入
*/
async login(username, password, authType = 'local') {
const response = await api.post('/auth/login', {
username,
password,
auth_type: authType
}, { auth: false });
if (response.token) {
api.setToken(response.token);
localStorage.setItem('user', JSON.stringify(response.user));
}
return response;
},
/**
* 登出
*/
async logout() {
try {
await api.post('/auth/logout');
} finally {
api.clearToken();
}
},
/**
* 取得當前用戶
*/
async getMe() {
return api.get('/auth/me');
},
/**
* 檢查是否已登入
*/
isLoggedIn() {
return !!api.getToken();
},
/**
* 取得本地儲存的用戶資訊
*/
getUser() {
const user = localStorage.getItem('user');
return user ? JSON.parse(user) : null;
}
};
// ============ 用戶管理 API ============
const usersApi = {
/**
* 取得用戶列表
*/
async getList(params = {}) {
return api.get('/users', params);
},
/**
* 取得單一用戶
*/
async getById(userId) {
return api.get(`/users/${userId}`);
},
/**
* 建立用戶
*/
async create(userData) {
return api.post('/users', userData);
},
/**
* 更新用戶
*/
async update(userId, userData) {
return api.put(`/users/${userId}`, userData);
},
/**
* 刪除用戶
*/
async delete(userId) {
return api.delete(`/users/${userId}`);
}
};
// ============ 群組管理 API ============
const groupsApi = {
/**
* 取得群組列表
*/
async getList(params = {}) {
return api.get('/groups', params);
},
/**
* 取得群組詳情
*/
async getById(groupId) {
return api.get(`/groups/${groupId}`);
},
/**
* 建立群組
*/
async create(groupData) {
return api.post('/groups', groupData);
},
/**
* 更新群組
*/
async update(groupId, groupData) {
return api.put(`/groups/${groupId}`, groupData);
},
/**
* 刪除群組
*/
async delete(groupId) {
return api.delete(`/groups/${groupId}`);
},
/**
* 取得群組關鍵字
*/
async getKeywords(groupId) {
return api.get(`/groups/${groupId}/keywords`);
},
/**
* 新增關鍵字
*/
async addKeyword(groupId, keyword) {
return api.post(`/groups/${groupId}/keywords`, { keyword });
},
/**
* 刪除關鍵字
*/
async deleteKeyword(groupId, keywordId) {
return api.delete(`/groups/${groupId}/keywords/${keywordId}`);
}
};
// ============ 訂閱管理 API ============
const subscriptionsApi = {
/**
* 取得我的訂閱
*/
async getMySubscriptions() {
return api.get('/subscriptions');
},
/**
* 更新訂閱
*/
async update(subscriptions) {
return api.put('/subscriptions', { subscriptions });
}
};
// ============ 報告管理 API ============
const reportsApi = {
/**
* 取得報告列表
*/
async getList(params = {}) {
return api.get('/reports', params);
},
/**
* 取得今日報告
*/
async getToday() {
return api.get('/reports/today');
},
/**
* 取得報告詳情
*/
async getById(reportId) {
return api.get(`/reports/${reportId}`);
},
/**
* 更新報告
*/
async update(reportId, reportData) {
return api.put(`/reports/${reportId}`, reportData);
},
/**
* 發布報告
*/
async publish(reportId) {
return api.post(`/reports/${reportId}/publish`);
},
/**
* 重新產生 AI 摘要
*/
async regenerateSummary(reportId) {
return api.post(`/reports/${reportId}/regenerate-summary`);
},
/**
* 匯出報告 PDF
*/
async exportPdf(reportId) {
const token = api.getToken();
const response = await fetch(`${API_BASE_URL}/reports/${reportId}/export`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('匯出失敗');
}
return response.blob();
}
};
// ============ 系統設定 API ============
const settingsApi = {
/**
* 取得系統設定
*/
async get() {
return api.get('/settings');
},
/**
* 更新系統設定
*/
async update(settings) {
return api.put('/settings', settings);
},
/**
* 測試 LLM 連線
*/
async testLlm() {
return api.post('/settings/llm/test');
},
/**
* 上傳 PDF Logo
*/
async uploadLogo(file) {
const formData = new FormData();
formData.append('logo', file);
return api.upload('/settings/pdf/logo', formData);
},
/**
* 取得管理員儀表板數據
*/
async getAdminDashboard() {
return api.get('/settings/dashboard/admin');
}
};
// 匯出所有 API 到 window
window.api = api;
window.authApi = authApi;
window.usersApi = usersApi;
window.groupsApi = groupsApi;
window.subscriptionsApi = subscriptionsApi;
window.reportsApi = reportsApi;
window.settingsApi = settingsApi;

1010
templates/js/app.js Normal file

File diff suppressed because it is too large Load Diff

981
ui-preview.html Normal file
View File

@@ -0,0 +1,981 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>每日報導 APP - UI 預覽</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft JhengHei", Arial, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
/* 導航列 */
.navbar {
background: #2c3e50;
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.navbar h1 {
font-size: 1.5rem;
}
.navbar-actions {
display: flex;
gap: 1rem;
align-items: center;
}
.lang-switch {
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
color: white;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.user-menu {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-primary:hover {
background: #2980b9;
}
.btn-secondary {
background: #95a5a6;
color: white;
}
.btn-success {
background: #27ae60;
color: white;
}
.btn-danger {
background: #e74c3c;
color: white;
}
/* 容器 */
.container {
max-width: 1200px;
margin: 2rem auto;
padding: 0 1rem;
}
/* 卡片 */
.card {
background: white;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 2px solid #ecf0f1;
}
.card-title {
font-size: 1.3rem;
font-weight: 600;
color: #2c3e50;
}
/* 表格 */
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #ecf0f1;
}
.table th {
background: #f8f9fa;
font-weight: 600;
color: #2c3e50;
}
.table tr:hover {
background: #f8f9fa;
}
/* 標籤 */
.badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
}
.badge-success {
background: #d4edda;
color: #155724;
}
.badge-warning {
background: #fff3cd;
color: #856404;
}
.badge-danger {
background: #f8d7da;
color: #721c24;
}
.badge-info {
background: #d1ecf1;
color: #0c5460;
}
/* 表單 */
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #2c3e50;
}
.form-control {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.form-control:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
}
/* 分頁 */
.pagination {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-top: 1.5rem;
}
.page-link {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
background: white;
color: #3498db;
text-decoration: none;
border-radius: 4px;
}
.page-link:hover {
background: #f8f9fa;
}
.page-link.active {
background: #3498db;
color: white;
border-color: #3498db;
}
/* 頁面切換 */
.page {
display: none;
}
.page.active {
display: block;
}
/* 標籤頁 */
.tabs {
display: flex;
border-bottom: 2px solid #ecf0f1;
margin-bottom: 1.5rem;
}
.tab {
padding: 1rem 1.5rem;
cursor: pointer;
border: none;
background: none;
font-size: 1rem;
color: #7f8c8d;
border-bottom: 3px solid transparent;
transition: all 0.3s;
}
.tab:hover {
color: #3498db;
}
.tab.active {
color: #3498db;
border-bottom-color: #3498db;
font-weight: 600;
}
/* 統計卡片 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: #3498db;
}
.stat-label {
color: #7f8c8d;
font-size: 0.9rem;
margin-top: 0.5rem;
}
/* 響應式 */
@media (max-width: 768px) {
.navbar {
flex-direction: column;
gap: 1rem;
}
.container {
padding: 0 0.5rem;
}
.stats-grid {
grid-template-columns: 1fr;
}
}
/* 摘要區塊 */
.summary-box {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
border-left: 4px solid #3498db;
margin: 1rem 0;
}
/* 文章列表 */
.article-item {
padding: 1rem;
border-bottom: 1px solid #ecf0f1;
display: flex;
justify-content: space-between;
align-items: center;
}
.article-item:last-child {
border-bottom: none;
}
.article-info {
flex: 1;
}
.article-title {
font-weight: 600;
margin-bottom: 0.5rem;
}
.article-meta {
color: #7f8c8d;
font-size: 0.9rem;
}
/* 切換按鈕 */
.switch {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 24px;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #3498db;
}
input:checked + .slider:before {
transform: translateX(26px);
}
</style>
</head>
<body>
<!-- 導航列 -->
<nav class="navbar">
<h1>📰 每日報導 APP</h1>
<div class="navbar-actions">
<button class="lang-switch" onclick="toggleLanguage()">🌐 中文 / English</button>
<div class="user-menu">
<span>👤 張三 (專員)</span>
<button class="btn btn-secondary" onclick="showPage('logout')">登出</button>
</div>
</div>
</nav>
<div class="container">
<!-- 頁面選單 -->
<div class="tabs">
<button class="tab active" onclick="showPage('dashboard')">儀表板</button>
<button class="tab" onclick="showPage('reports')">報告管理</button>
<button class="tab" onclick="showPage('groups')">群組管理</button>
<button class="tab" onclick="showPage('users')">用戶管理</button>
<button class="tab" onclick="showPage('settings')">系統設定</button>
</div>
<!-- 儀表板頁面 -->
<div id="dashboard" class="page active">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">12</div>
<div class="stat-label">今日新聞</div>
</div>
<div class="stat-card">
<div class="stat-value">5</div>
<div class="stat-label">待審核報告</div>
</div>
<div class="stat-card">
<div class="stat-value">3</div>
<div class="stat-label">已發布報告</div>
</div>
<div class="stat-card">
<div class="stat-value">42</div>
<div class="stat-label">訂閱讀者</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">今日待審核報告</h2>
<button class="btn btn-primary" onclick="showPage('reports')">查看全部</button>
</div>
<table class="table">
<thead>
<tr>
<th>報告標題</th>
<th>群組</th>
<th>文章數</th>
<th>狀態</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td>半導體日報 - 2025/01/27</td>
<td>半導體</td>
<td>8</td>
<td><span class="badge badge-warning">待審核</span></td>
<td>
<button class="btn btn-primary" style="padding: 0.25rem 0.75rem; font-size: 0.85rem;">審核</button>
</td>
</tr>
<tr>
<td>面板日報 - 2025/01/27</td>
<td>面板</td>
<td>5</td>
<td><span class="badge badge-warning">待審核</span></td>
<td>
<button class="btn btn-primary" style="padding: 0.25rem 0.75rem; font-size: 0.85rem;">審核</button>
</td>
</tr>
<tr>
<td>車用電子日報 - 2025/01/27</td>
<td>車用電子</td>
<td>6</td>
<td><span class="badge badge-success">已發布</span></td>
<td>
<button class="btn btn-secondary" style="padding: 0.25rem 0.75rem; font-size: 0.85rem;">查看</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 報告管理頁面 -->
<div id="reports" class="page">
<div class="card">
<div class="card-header">
<h2 class="card-title">報告管理</h2>
<div>
<input type="date" class="form-control" style="width: auto; display: inline-block; margin-right: 0.5rem;">
<select class="form-control" style="width: auto; display: inline-block;">
<option>全部群組</option>
<option>半導體</option>
<option>面板</option>
<option>車用電子</option>
</select>
</div>
</div>
<div class="card" style="margin-top: 1rem;">
<h3 style="margin-bottom: 1rem;">半導體日報 - 2025/01/27</h3>
<div style="display: flex; gap: 1rem; margin-bottom: 1rem;">
<span class="badge badge-info">半導體</span>
<span class="badge badge-warning">待審核</span>
<span>8 篇文章</span>
</div>
<div class="summary-box">
<h4 style="margin-bottom: 0.5rem;">AI 摘要</h4>
<p>根據今日新聞分析半導體產業呈現以下趨勢台積電宣布擴大先進製程產能預期將帶動相關供應鏈成長。記憶體價格持續上漲DRAM 與 NAND Flash 需求強勁。中國半導體自主化政策持續推進,但技術突破仍面臨挑戰...</p>
</div>
<div style="margin-top: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h4 style="margin: 0;">相關新聞</h4>
<button class="btn btn-primary" onclick="toggleManualUpload()" style="padding: 0.5rem 1rem; font-size: 0.9rem;">+ 手動上傳新聞</button>
</div>
<!-- 手動上傳新聞表單 -->
<div id="manual-upload-form" style="display: none; background: #f8f9fa; padding: 1.5rem; border-radius: 8px; margin-bottom: 1rem; border: 2px dashed #3498db;">
<h5 style="margin-bottom: 1rem; color: #2c3e50;">手動上傳純文字新聞</h5>
<div class="form-group" style="margin-bottom: 1rem;">
<label class="form-label">新聞標題 *</label>
<input type="text" class="form-control" id="manual-title" placeholder="輸入新聞標題...">
</div>
<div class="form-group" style="margin-bottom: 1rem;">
<label class="form-label">新聞來源</label>
<select class="form-control" id="manual-source" style="max-width: 200px;">
<option value="manual">手動輸入</option>
<option value="digitimes">Digitimes</option>
<option value="udn">經濟日報</option>
<option value="ctee">工商時報</option>
</select>
</div>
<div class="form-group" style="margin-bottom: 1rem;">
<label class="form-label">來源 URL選填</label>
<input type="url" class="form-control" id="manual-url" placeholder="https://...">
</div>
<div class="form-group" style="margin-bottom: 1rem;">
<label class="form-label">發布時間</label>
<input type="datetime-local" class="form-control" id="manual-date" style="max-width: 250px;">
</div>
<div class="form-group" style="margin-bottom: 1rem;">
<label class="form-label">新聞內容 *</label>
<textarea class="form-control" id="manual-content" rows="8" placeholder="貼上或輸入新聞全文內容..."></textarea>
<small style="color: #7f8c8d; margin-top: 0.25rem; display: block;">支援純文字格式,建議至少 100 字</small>
</div>
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-success" onclick="addManualArticle()">新增到報告</button>
<button class="btn btn-secondary" onclick="toggleManualUpload()">取消</button>
</div>
</div>
<div class="article-item">
<div class="article-info">
<div class="article-title">台積電宣布擴大 3 奈米產能</div>
<div class="article-meta">Digitimes | 2025-01-27 08:30</div>
</div>
<label class="switch">
<input type="checkbox" checked>
<span class="slider"></span>
</label>
</div>
<div class="article-item">
<div class="article-info">
<div class="article-title">記憶體價格持續上漲DRAM 需求強勁</div>
<div class="article-meta">經濟日報 | 2025-01-27 09:15</div>
</div>
<label class="switch">
<input type="checkbox" checked>
<span class="slider"></span>
</label>
</div>
<div class="article-item">
<div class="article-info">
<div class="article-title">中國半導體自主化面臨技術挑戰</div>
<div class="article-meta">工商時報 | 2025-01-27 10:00</div>
</div>
<label class="switch">
<input type="checkbox">
<span class="slider"></span>
</label>
</div>
</div>
<div style="margin-top: 1.5rem; display: flex; gap: 1rem;">
<button class="btn btn-primary">編輯摘要</button>
<button class="btn btn-success">發布報告</button>
<button class="btn btn-secondary">重新產生摘要</button>
<button class="btn btn-secondary">匯出 PDF</button>
</div>
</div>
</div>
</div>
<!-- 群組管理頁面 -->
<div id="groups" class="page">
<div class="card">
<div class="card-header">
<h2 class="card-title">群組管理</h2>
<button class="btn btn-primary">新增群組</button>
</div>
<table class="table">
<thead>
<tr>
<th>群組名稱</th>
<th>分類</th>
<th>關鍵字數</th>
<th>訂閱數</th>
<th>狀態</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td>半導體</td>
<td>產業別</td>
<td>15</td>
<td>28</td>
<td><span class="badge badge-success">啟用</span></td>
<td>
<button class="btn btn-primary" style="padding: 0.25rem 0.75rem; font-size: 0.85rem;">編輯</button>
</td>
</tr>
<tr>
<td>面板</td>
<td>產業別</td>
<td>12</td>
<td>18</td>
<td><span class="badge badge-success">啟用</span></td>
<td>
<button class="btn btn-primary" style="padding: 0.25rem 0.75rem; font-size: 0.85rem;">編輯</button>
</td>
</tr>
<tr>
<td>車用電子</td>
<td>產業別</td>
<td>10</td>
<td>15</td>
<td><span class="badge badge-success">啟用</span></td>
<td>
<button class="btn btn-primary" style="padding: 0.25rem 0.75rem; font-size: 0.85rem;">編輯</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 群組編輯表單 -->
<div class="card">
<h3 class="card-title" style="margin-bottom: 1rem;">編輯群組:半導體</h3>
<div class="form-group">
<label class="form-label">群組名稱</label>
<input type="text" class="form-control" value="半導體">
</div>
<div class="form-group">
<label class="form-label">描述</label>
<textarea class="form-control" rows="3">半導體產業相關新聞與分析</textarea>
</div>
<div class="form-group">
<label class="form-label">關鍵字</label>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.5rem;">
<span class="badge badge-info" style="padding: 0.5rem 1rem;">台積電 <button style="background: none; border: none; margin-left: 0.5rem; cursor: pointer;">×</button></span>
<span class="badge badge-info" style="padding: 0.5rem 1rem;">半導體 <button style="background: none; border: none; margin-left: 0.5rem; cursor: pointer;">×</button></span>
<span class="badge badge-info" style="padding: 0.5rem 1rem;">晶圓 <button style="background: none; border: none; margin-left: 0.5rem; cursor: pointer;">×</button></span>
<span class="badge badge-info" style="padding: 0.5rem 1rem;">DRAM <button style="background: none; border: none; margin-left: 0.5rem; cursor: pointer;">×</button></span>
</div>
<input type="text" class="form-control" placeholder="新增關鍵字...">
</div>
<div class="form-group">
<label class="form-label">AI 摘要背景資訊</label>
<textarea class="form-control" rows="4" placeholder="提供給 AI 的背景資訊,幫助產生更準確的摘要...">半導體產業是台灣重要的支柱產業,主要關注台積電、聯發科等龍頭企業動態,以及全球半導體供應鏈變化。</textarea>
</div>
<div class="form-group">
<label class="form-label">AI 摘要方向</label>
<textarea class="form-control" rows="4" placeholder="指定 AI 摘要的重點方向...">請重點分析產業趨勢、技術發展、市場動態與供應鏈變化。</textarea>
</div>
<div style="display: flex; gap: 1rem;">
<button class="btn btn-primary">儲存</button>
<button class="btn btn-secondary">取消</button>
</div>
</div>
</div>
<!-- 用戶管理頁面 -->
<div id="users" class="page">
<div class="card">
<div class="card-header">
<h2 class="card-title">用戶管理</h2>
<button class="btn btn-primary">新增用戶</button>
</div>
<div style="margin-bottom: 1rem;">
<input type="text" class="form-control" placeholder="搜尋用戶..." style="max-width: 300px; display: inline-block;">
<select class="form-control" style="width: auto; display: inline-block; margin-left: 0.5rem;">
<option>全部角色</option>
<option>管理員</option>
<option>專員</option>
<option>讀者</option>
</select>
</div>
<table class="table">
<thead>
<tr>
<th>帳號</th>
<th>顯示名稱</th>
<th>Email</th>
<th>角色</th>
<th>認證方式</th>
<th>狀態</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td>admin</td>
<td>系統管理員</td>
<td>admin@company.com</td>
<td><span class="badge badge-danger">管理員</span></td>
<td>本地</td>
<td><span class="badge badge-success">啟用</span></td>
<td>
<button class="btn btn-primary" style="padding: 0.25rem 0.75rem; font-size: 0.85rem;">編輯</button>
</td>
</tr>
<tr>
<td>editor01</td>
<td>張三</td>
<td>editor01@company.com</td>
<td><span class="badge badge-warning">專員</span></td>
<td>AD</td>
<td><span class="badge badge-success">啟用</span></td>
<td>
<button class="btn btn-primary" style="padding: 0.25rem 0.75rem; font-size: 0.85rem;">編輯</button>
</td>
</tr>
<tr>
<td>user001</td>
<td>李四</td>
<td>user001@company.com</td>
<td><span class="badge badge-info">讀者</span></td>
<td>AD</td>
<td><span class="badge badge-success">啟用</span></td>
<td>
<button class="btn btn-primary" style="padding: 0.25rem 0.75rem; font-size: 0.85rem;">編輯</button>
</td>
</tr>
</tbody>
</table>
<div class="pagination">
<a href="#" class="page-link">上一頁</a>
<a href="#" class="page-link active">1</a>
<a href="#" class="page-link">2</a>
<a href="#" class="page-link">3</a>
<a href="#" class="page-link">下一頁</a>
</div>
</div>
</div>
<!-- 系統設定頁面 -->
<div id="settings" class="page">
<div class="card">
<h2 class="card-title" style="margin-bottom: 1.5rem;">系統設定</h2>
<!-- LLM 設定 -->
<div style="margin-bottom: 2rem;">
<h3 style="margin-bottom: 1rem; color: #2c3e50;">LLM 設定</h3>
<div class="form-group">
<label class="form-label">LLM 提供者</label>
<select class="form-control">
<option>Google Gemini</option>
<option>OpenAI</option>
<option>Ollama (地端)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">API Key</label>
<input type="password" class="form-control" placeholder="輸入 API Key...">
<small style="color: #7f8c8d; margin-top: 0.25rem; display: block;">API Key 將加密儲存</small>
</div>
<div class="form-group">
<label class="form-label">模型版本</label>
<select class="form-control">
<option>gemini-1.5-pro</option>
<option>gemini-1.5-flash</option>
<option>gemini-1.0-pro</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Temperature</label>
<input type="number" class="form-control" value="0.7" step="0.1" min="0" max="1">
</div>
<div class="form-group">
<label class="form-label">Max Tokens</label>
<input type="number" class="form-control" value="2048">
</div>
<button class="btn btn-primary">測試連線</button>
<button class="btn btn-success" style="margin-left: 0.5rem;">儲存設定</button>
</div>
<!-- PDF 模板設定 -->
<div style="margin-bottom: 2rem; padding-top: 2rem; border-top: 2px solid #ecf0f1;">
<h3 style="margin-bottom: 1rem; color: #2c3e50;">PDF 模板設定</h3>
<div class="form-group">
<label class="form-label">公司 Logo</label>
<input type="file" class="form-control" accept="image/png,image/jpeg,image/svg+xml">
<small style="color: #7f8c8d; margin-top: 0.25rem; display: block;">支援 PNG、JPEG、SVG 格式,建議大小 200x60px</small>
</div>
<div class="form-group">
<label class="form-label">頁首文字</label>
<input type="text" class="form-control" value="每日報導">
</div>
<div class="form-group">
<label class="form-label">頁尾文字</label>
<input type="text" class="form-control" value="本報告僅供內部參考使用">
</div>
<button class="btn btn-success">儲存設定</button>
</div>
<!-- SMTP 設定 -->
<div style="margin-bottom: 2rem; padding-top: 2rem; border-top: 2px solid #ecf0f1;">
<h3 style="margin-bottom: 1rem; color: #2c3e50;">SMTP 設定</h3>
<div class="form-group">
<label class="form-label">SMTP 伺服器</label>
<input type="text" class="form-control" placeholder="smtp.example.com">
</div>
<div class="form-group">
<label class="form-label">SMTP 埠號</label>
<input type="number" class="form-control" value="587">
</div>
<div class="form-group">
<label class="form-label">SMTP 帳號</label>
<input type="text" class="form-control" placeholder="smtp@example.com">
</div>
<div class="form-group">
<label class="form-label">SMTP 密碼</label>
<input type="password" class="form-control" placeholder="輸入密碼...">
</div>
<div class="form-group">
<label class="form-label">寄件者 Email</label>
<input type="email" class="form-control" placeholder="noreply@example.com">
</div>
<div class="form-group">
<label class="form-label">寄件者名稱</label>
<input type="text" class="form-control" value="每日報導系統">
</div>
<button class="btn btn-primary">測試連線</button>
<button class="btn btn-success" style="margin-left: 0.5rem;">儲存設定</button>
</div>
</div>
</div>
<!-- 登出確認 -->
<div id="logout" class="page">
<div class="card" style="text-align: center; max-width: 400px; margin: 5rem auto;">
<h2 style="margin-bottom: 1rem;">確認登出</h2>
<p style="margin-bottom: 2rem; color: #7f8c8d;">您確定要登出系統嗎?</p>
<div style="display: flex; gap: 1rem; justify-content: center;">
<button class="btn btn-danger" onclick="showPage('dashboard')">確認登出</button>
<button class="btn btn-secondary" onclick="showPage('dashboard')">取消</button>
</div>
</div>
</div>
</div>
<script>
// 頁面切換
function showPage(pageId) {
// 隱藏所有頁面
document.querySelectorAll('.page').forEach(page => {
page.classList.remove('active');
});
// 顯示選中的頁面
document.getElementById(pageId).classList.add('active');
// 更新標籤頁狀態
document.querySelectorAll('.tab').forEach(tab => {
tab.classList.remove('active');
});
// 根據頁面 ID 設定對應標籤為 active
const tabMap = {
'dashboard': 0,
'reports': 1,
'groups': 2,
'users': 3,
'settings': 4
};
if (tabMap[pageId] !== undefined) {
document.querySelectorAll('.tab')[tabMap[pageId]].classList.add('active');
}
}
// 語言切換
let currentLang = 'zh';
function toggleLanguage() {
currentLang = currentLang === 'zh' ? 'en' : 'zh';
const btn = document.querySelector('.lang-switch');
btn.textContent = currentLang === 'zh' ? '🌐 中文 / English' : '🌐 Chinese / 英文';
// 這裡可以實作實際的語言切換邏輯
console.log('切換語言至:', currentLang);
}
// 手動上傳新聞表單切換
function toggleManualUpload() {
const form = document.getElementById('manual-upload-form');
if (form.style.display === 'none') {
form.style.display = 'block';
// 設定預設時間為今天
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
document.getElementById('manual-date').value = now.toISOString().slice(0, 16);
} else {
form.style.display = 'none';
// 清空表單
document.getElementById('manual-title').value = '';
document.getElementById('manual-url').value = '';
document.getElementById('manual-content').value = '';
document.getElementById('manual-source').value = 'manual';
}
}
// 新增手動上傳的新聞
function addManualArticle() {
const title = document.getElementById('manual-title').value.trim();
const content = document.getElementById('manual-content').value.trim();
const source = document.getElementById('manual-source').value;
const url = document.getElementById('manual-url').value.trim();
const date = document.getElementById('manual-date').value;
if (!title || !content) {
alert('請填寫新聞標題和內容');
return;
}
if (content.length < 100) {
if (!confirm('新聞內容少於 100 字,確定要新增嗎?')) {
return;
}
}
// 模擬新增新聞到列表
const articleList = document.querySelector('.article-item').parentElement;
const newArticle = document.createElement('div');
newArticle.className = 'article-item';
const sourceNames = {
'manual': '手動輸入',
'digitimes': 'Digitimes',
'udn': '經濟日報',
'ctee': '工商時報'
};
const dateStr = date ? new Date(date).toLocaleString('zh-TW') : new Date().toLocaleString('zh-TW');
newArticle.innerHTML = `
<div class="article-info">
<div class="article-title">${title}</div>
<div class="article-meta">${sourceNames[source]} | ${dateStr}</div>
</div>
<label class="switch">
<input type="checkbox" checked>
<span class="slider"></span>
</label>
`;
// 插入到列表最前面
articleList.insertBefore(newArticle, articleList.firstChild);
// 關閉表單並清空
toggleManualUpload();
alert('新聞已新增到報告中');
}
// 模擬資料載入
console.log('UI 預覽頁面已載入');
console.log('這是靜態預覽頁面,不連接資料庫');
</script>
</body>
</html>

504
執行步驟.md Normal file
View File

@@ -0,0 +1,504 @@
# 每日報導 APP - 執行步驟指南
本文檔提供系統的詳細執行步驟,包含本地開發和生產部署兩種方式。
---
## 📋 前置需求
### 系統需求
- **作業系統**Linux / macOS / Windows
- **Python**3.11 或以上版本
- **Docker**20.10 或以上版本(使用 Docker 部署時)
- **Docker Compose**2.0 或以上版本(使用 Docker 部署時)
- **MySQL**8.0 或以上版本(生產環境,或使用 Docker 時自動安裝)
### 必要帳號與 API Key
- **LLM API Key**(三選一):
- Google Gemini API Key
- OpenAI API Key
- 或使用 Ollama本地部署無需 API Key
- **Digitimes 帳號**(如需抓取 Digitimes 新聞)
- **SMTP 設定**(如需發送 Email 通知)
---
## 🚀 方式一:本地開發環境
### 步驟 1環境準備
#### 1.1 複製專案(如尚未複製)
```bash
# 如果專案在 Git 倉庫
git clone <repository-url>
cd daily-news-app
# 或直接進入專案目錄
cd /Users/peelerwu/Documents/AICoding/daily-news-app
```
#### 1.2 建立 Python 虛擬環境
```bash
# 建立虛擬環境
python3 -m venv venv
# 啟動虛擬環境
# macOS/Linux:
source venv/bin/activate
# Windows:
# venv\Scripts\activate
```
#### 1.3 安裝 Python 依賴套件
```bash
pip install -r requirements.txt
```
### 步驟 2環境變數設定
#### 2.1 建立 `.env` 檔案
```bash
# 複製範例檔案(如果存在)
cp .env.example .env
# 或手動建立
touch .env
```
#### 2.2 編輯 `.env` 檔案
使用文字編輯器開啟 `.env`,填入以下設定:
```env
# ============================================
# 應用程式設定
# ============================================
APP_ENV=development
DEBUG=true
SECRET_KEY=your-secret-key-here-min-32-chars-change-in-production
JWT_SECRET_KEY=your-jwt-secret-key-here-min-32-chars-change-in-production
# ============================================
# 資料庫設定(開發環境可使用 SQLite
# ============================================
# 選項 1使用 SQLite簡單適合開發
DB_HOST=sqlite
DB_NAME=daily_news_app
# 選項 2使用 MySQL需先啟動 MySQL
# DB_HOST=localhost
# DB_PORT=3306
# DB_NAME=daily_news_app
# DB_USER=root
# DB_PASSWORD=your-mysql-password
# ============================================
# LLM 設定(選擇一個)
# ============================================
# 選項 1使用 Gemini推薦費用較低
LLM_PROVIDER=gemini
GEMINI_API_KEY=your-gemini-api-key-here
GEMINI_MODEL=gemini-1.5-pro
# 選項 2使用 OpenAI
# LLM_PROVIDER=openai
# OPENAI_API_KEY=your-openai-api-key-here
# OPENAI_MODEL=gpt-4o
# 選項 3使用 Ollama本地部署
# LLM_PROVIDER=ollama
# OLLAMA_ENDPOINT=http://localhost:11434
# OLLAMA_MODEL=llama3
# ============================================
# SMTP 設定Email 通知,選填)
# ============================================
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=your-smtp-username
SMTP_PASSWORD=your-smtp-password
SMTP_FROM_EMAIL=noreply@example.com
SMTP_FROM_NAME=每日報導系統
# ============================================
# LDAP/AD 設定(選填,如需企業認證)
# ============================================
LDAP_SERVER=ldap.example.com
LDAP_PORT=389
LDAP_BASE_DN=DC=example,DC=com
LDAP_BIND_DN=
LDAP_BIND_PASSWORD=
# ============================================
# Digitimes 帳號(選填,如需抓取 Digitimes
# ============================================
DIGITIMES_USERNAME=your-digitimes-username
DIGITIMES_PASSWORD=your-digitimes-password
# ============================================
# 其他設定
# ============================================
CORS_ORIGINS=["http://localhost:3000","http://localhost:8000"]
```
#### 2.3 產生強隨機密鑰(生產環境必做)
```bash
# 使用 Python 產生
python3 -c "import secrets; print('SECRET_KEY=' + secrets.token_urlsafe(32)); print('JWT_SECRET_KEY=' + secrets.token_urlsafe(32))"
```
將產生的密鑰填入 `.env` 檔案。
### 步驟 3資料庫初始化
#### 3.1 使用 SQLite開發環境推薦
```bash
# 執行初始化腳本
python scripts/init_db_sqlite.py
```
這會自動建立 SQLite 資料庫檔案 `daily_news_app.db` 並建立所有資料表。
#### 3.2 使用 MySQL如需
```bash
# 1. 確保 MySQL 服務已啟動
# macOS (Homebrew):
brew services start mysql
# Linux:
sudo systemctl start mysql
# Windows: 從服務管理員啟動 MySQL
# 2. 建立資料庫
mysql -u root -p
CREATE DATABASE daily_news_app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
EXIT;
# 3. 執行初始化 SQL
mysql -u root -p daily_news_app < scripts/init.sql
```
### 步驟 4啟動應用程式
#### 4.1 使用啟動腳本(推薦)
```bash
python run.py
```
#### 4.2 或使用 uvicorn 直接啟動
```bash
uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
```
#### 4.3 驗證啟動成功
開啟瀏覽器訪問:
- **API 文件**http://127.0.0.1:8000/docs
- **健康檢查**http://127.0.0.1:8000/health
- **根路徑**http://127.0.0.1:8000/
### 步驟 5初始化資料可選
如果需要建立預設用戶或測試資料:
```bash
# 進入 Python 互動環境
python
# 執行初始化(範例)
from app.db.session import SessionLocal
from app.models.user import User, Role
from app.core.security import get_password_hash
db = SessionLocal()
# 建立管理員用戶
admin_role = db.query(Role).filter(Role.code == "admin").first()
if admin_role:
admin_user = User(
username="admin",
password_hash=get_password_hash("admin123"),
display_name="系統管理員",
email="admin@example.com",
auth_type="local",
role_id=admin_role.id,
is_active=True
)
db.add(admin_user)
db.commit()
db.close()
```
---
## 🐳 方式二Docker 部署(生產環境推薦)
### 步驟 1環境準備
#### 1.1 確認 Docker 已安裝
```bash
docker --version
docker-compose --version
```
#### 1.2 建立 `.env` 檔案
在專案根目錄建立 `.env` 檔案,內容參考「方式一」的步驟 2.2,但需調整以下設定:
```env
# 生產環境設定
APP_ENV=production
DEBUG=false
# 資料庫設定Docker Compose 會自動建立 MySQL
DB_HOST=mysql
DB_PORT=3306
DB_NAME=daily_news_app
DB_USER=root
DB_PASSWORD=your-strong-mysql-password-here
# 必須使用強隨機密鑰
SECRET_KEY=your-strong-secret-key-min-32-chars
JWT_SECRET_KEY=your-strong-jwt-secret-key-min-32-chars
# 其他設定與方式一相同
```
### 步驟 2啟動服務
#### 2.1 使用 Docker Compose 啟動
```bash
# 啟動所有服務(應用程式 + MySQL
docker-compose up -d
# 查看日誌
docker-compose logs -f app
# 查看所有服務狀態
docker-compose ps
```
#### 2.2 使用 Ollama可選本地 LLM
```bash
# 啟動包含 Ollama 的服務
docker-compose --profile ollama up -d
# 下載模型
docker exec -it daily-news-ollama ollama pull llama3
```
### 步驟 3資料庫初始化
#### 3.1 等待 MySQL 就緒
```bash
# 檢查 MySQL 健康狀態
docker-compose ps mysql
```
#### 3.2 執行初始化 SQL
```bash
# 方法 1使用 docker exec
docker exec -i daily-news-mysql mysql -uroot -p${DB_PASSWORD} daily_news_app < scripts/init.sql
# 方法 2進入容器執行
docker exec -it daily-news-mysql bash
mysql -uroot -p
# 輸入密碼後
USE daily_news_app;
SOURCE /docker-entrypoint-initdb.d/init.sql;
EXIT;
```
**注意**:如果 `docker-compose.yml` 中已設定 `init.sql` 掛載MySQL 容器啟動時會自動執行。
### 步驟 4驗證部署
```bash
# 檢查應用程式健康狀態
curl http://localhost:8000/health
# 查看應用程式日誌
docker-compose logs -f app
```
### 步驟 5存取系統
- **API 文件**http://localhost:8000/docs生產環境可能已關閉
- **健康檢查**http://localhost:8000/health
- **API 端點**http://localhost:8000/api/v1
---
## 🔧 常用操作
### 查看日誌
```bash
# 本地開發
tail -f logs/app.log
# Docker
docker-compose logs -f app
```
### 停止服務
```bash
# 本地開發
# 按 Ctrl+C 停止
# Docker
docker-compose down
# 停止並刪除資料(謹慎使用)
docker-compose down -v
```
### 重啟服務
```bash
# Docker
docker-compose restart app
# 或重新建立
docker-compose up -d --force-recreate app
```
### 進入容器
```bash
# 進入應用程式容器
docker exec -it daily-news-app bash
# 進入 MySQL 容器
docker exec -it daily-news-mysql bash
```
### 資料庫備份
```bash
# MySQL 備份
docker exec daily-news-mysql mysqldump -uroot -p${DB_PASSWORD} daily_news_app > backup_$(date +%Y%m%d).sql
# SQLite 備份
cp daily_news_app.db backup_$(date +%Y%m%d).db
```
### 資料庫還原
```bash
# MySQL 還原
docker exec -i daily-news-mysql mysql -uroot -p${DB_PASSWORD} daily_news_app < backup_20240101.sql
```
---
## 🧪 測試系統
### 1. 測試 API 端點
```bash
# 健康檢查
curl http://localhost:8000/health
# 登入(需先建立用戶)
curl -X POST http://localhost:8000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}'
```
### 2. 測試新聞抓取
```bash
# 進入容器或虛擬環境
python
# 執行測試
from app.services.crawler_service import CrawlerService
crawler = CrawlerService()
# 測試抓取(需先設定新聞來源)
```
### 3. 測試 LLM 摘要
```bash
# 進入容器或虛擬環境
python
# 執行測試
from app.services.llm_service import LLMService
llm = LLMService()
# 測試摘要生成
```
---
## ⚠️ 常見問題
### 問題 1資料庫連線失敗
**症狀**:啟動時出現資料庫連線錯誤
**解決方法**
1. 檢查 `.env` 中的資料庫設定是否正確
2. 確認 MySQL 服務已啟動(本地開發)
3. 確認 Docker 容器中的 MySQL 已就緒Docker 部署)
4. 檢查防火牆設定
### 問題 2LLM API 呼叫失敗
**症狀**:生成摘要時出現 API 錯誤
**解決方法**
1. 檢查 API Key 是否正確設定
2. 檢查 API Key 是否有足夠額度
3. 檢查網路連線
4. 查看日誌了解詳細錯誤
### 問題 3Email 發送失敗
**症狀**:通知無法發送
**解決方法**
1. 檢查 SMTP 設定是否正確
2. 檢查 SMTP 伺服器是否需要 TLS/SSL
3. 檢查防火牆是否阻擋 SMTP 埠號
4. 查看 `notification_logs` 表了解錯誤詳情
### 問題 4新聞抓取失敗
**症狀**:定時抓取沒有執行或失敗
**解決方法**
1. 檢查排程服務是否正常啟動
2. 檢查新聞來源設定是否正確
3. 檢查 Digitimes 帳號是否有效(如需)
4. 查看 `crawl_jobs` 表了解錯誤詳情
### 問題 5權限錯誤
**症狀**:無法執行某些操作
**解決方法**
1. 檢查用戶角色是否正確
2. 檢查 JWT Token 是否有效
3. 檢查 API 端點的權限設定
---
## 📝 後續設定
### 1. 建立初始用戶
使用 API 或直接操作資料庫建立管理員用戶。
### 2. 設定新聞來源
在系統設定中配置新聞來源Digitimes、經濟日報、工商時報
### 3. 建立群組
建立產業別或議題群組,並設定關鍵字。
### 4. 設定排程
確認排程服務正常運作,每日定時抓取和生成報告。
### 5. 測試完整流程
1. 手動觸發新聞抓取
2. 檢查新聞是否正確匹配到群組
3. 生成測試報告
4. 測試 Email 通知
---
## 🔗 相關資源
- **API 文件**http://localhost:8000/docs
- **專案 README**`README.md`
- **系統解析**`系統解析.md`
- **系統設計文檔**`daily-news-SDD.md`
---
## 📞 支援
如有問題,請聯繫 IT 部門或查看專案文檔。

233
安全修復完成報告.md Normal file
View File

@@ -0,0 +1,233 @@
# 安全修復完成報告
> **修復日期:** 2025-01-27
> **修復狀態:** ✅ 主要安全問題已修復
---
## ✅ 已完成的修復項目
### 🔴 立即修復(上線前必須)- 已完成
#### 1. ✅ 建立 `.gitignore` 檔案
- **檔案:** `.gitignore`
- **狀態:** ✅ 已完成
- **內容:** 排除所有敏感檔案(`.env``*.db``__pycache__/``*.log` 等)
#### 2. ✅ 建立 `.env.example` 檔案
- **檔案:** `.env.example`
- **狀態:** ⚠️ 檔案被 globalignore 阻擋,需要手動建立
- **說明:** 請手動建立此檔案,內容請參考 `security-fixes.md` 第 154-214 行
#### 3. ✅ 修復硬編碼密鑰問題
- **檔案:** `app/core/config.py`
- **狀態:** ✅ 已完成
- **修改內容:**
- 使用 `Field()` 標註預設值說明
- 新增 `validate_secrets()` 函數驗證生產環境密鑰
-`main.py` 啟動時自動驗證
#### 4. ✅ 修復 Debug 模式問題
- **檔案:** `app/core/config.py`, `app/main.py`
- **狀態:** ✅ 已完成
- **修改內容:**
- `debug` 預設值改為 `False`
- 生產環境強制關閉 Debug
- API 文件在生產環境自動關閉
#### 5. ✅ 修正 CORS 設定
- **檔案:** `app/core/config.py`, `app/main.py`
- **狀態:** ✅ 已完成
- **修改內容:**
- 新增 `cors_origins` 設定項
- 生產環境禁止使用 `*`
- 限制允許的 HTTP 方法和標頭
---
### 🟡 高優先級修復 - 已完成
#### 6. ✅ 建立 logging 系統並替換所有 print()
- **檔案:**
- `app/core/logging_config.py` (新建)
- `app/main.py`
- `app/core/security.py`
- `app/services/notification_service.py`
- `app/services/crawler_service.py`
- `app/services/scheduler_service.py`
- **狀態:** ✅ 已完成
- **修改內容:**
- 建立完整的 logging 系統
- 實作敏感資訊過濾器
- 所有 `print()` 已替換為 `logger.info/warning/error()`
#### 7. ✅ 修復 LDAP 注入風險
- **檔案:** `app/core/security.py`
- **狀態:** ✅ 已完成
- **修改內容:**
- 使用 `escape_filter_chars()` 轉義用戶輸入
- 防止 LDAP 注入攻擊
#### 8. ✅ 加強檔案上傳安全
- **檔案:** `app/api/v1/endpoints/settings.py`
- **狀態:** ✅ 已完成
- **修改內容:**
- 檔案大小限制5MB
- 檢查檔案類型content_type + 副檔名)
- 使用 Magic Number 驗證真實檔案類型
- 使用 hash 產生安全檔案名稱
- 使用絕對路徑,防止路徑遍歷
- 驗證檔案路徑在允許目錄內
#### 9. ✅ Email XSS 防護
- **檔案:** `app/services/notification_service.py`
- **狀態:** ✅ 已完成
- **修改內容:**
- 使用 `html.escape()` 轉義所有用戶輸入
- 防止 Email 內容中的 XSS 攻擊
#### 10. ✅ 輸入驗證加強
- **檔案:** `app/api/v1/endpoints/users.py`
- **狀態:** ✅ 已完成
- **修改內容:**
- 搜尋輸入長度限制
- 轉義 SQL 萬用字元(`%``_`
---
## 📋 待完成項目(建議後續處理)
### 中優先級(建議上線後盡快修復)
1. **速率限制Rate Limiting**
- 需要安裝 `slowapi`
- 在登入端點實作速率限制
- 參考:`security-fixes.md` 第 755-788 行
2. **密碼強度檢查**
- 建立 `app/utils/password_validator.py`
- 在用戶建立/修改密碼時驗證強度
- 參考:`security-fixes.md` 第 685-751 行
3. **依賴項安全掃描**
- 執行 `pip-audit -r requirements.txt`
- 修復已知漏洞
- 參考:`security-fixes.md` 第 891-916 行
### 低優先級(持續改進)
1. **AD 用戶自動建立審核機制**
- 參考:`security-fixes.md` 第 921-966 行
2. **報告發布時間驗證**
- 參考:`security-fixes.md` 第 970-1007 行
---
## 📝 修復檔案清單
### 新建檔案
-`.gitignore`
-`app/core/logging_config.py`
-`ui-preview.html` (UI 預覽頁面)
### 修改檔案
-`app/core/config.py` - 密鑰驗證、Debug 設定、CORS 設定
-`app/main.py` - Debug 檢查、CORS 設定、logging 初始化
-`app/core/security.py` - LDAP 注入修復、logging
-`app/services/notification_service.py` - XSS 防護、logging
-`app/services/crawler_service.py` - logging
-`app/services/scheduler_service.py` - logging
-`app/api/v1/endpoints/settings.py` - 檔案上傳安全加強
-`app/api/v1/endpoints/users.py` - 輸入驗證加強
---
## ⚠️ 重要提醒
### 必須手動完成的項目
1. **建立 `.env.example` 檔案**
- 由於檔案被 globalignore 阻擋,請手動建立
- 內容請參考 `security-fixes.md` 或使用以下命令:
```bash
# 複製並修改
cp .env.example .env
# 然後填入實際的環境變數值
```
2. **產生強隨機密鑰**
```python
import secrets
print(f"SECRET_KEY={secrets.token_urlsafe(32)}")
print(f"JWT_SECRET_KEY={secrets.token_urlsafe(32)}")
```
3. **設定生產環境變數**
- 確保 `APP_ENV=production`
- 確保 `DEBUG=false`
- 設定強隨機的 `SECRET_KEY` 和 `JWT_SECRET_KEY`
- 設定正確的 `CORS_ORIGINS`(不能是 `*`
4. **檢查 Git 歷史**
```bash
# 檢查是否有敏感檔案被提交
git log --all --full-history -- .env
git log --all --full-history -- "*.db"
```
---
## 🎨 UI 預覽
已建立 `ui-preview.html` 檔案,包含以下頁面預覽:
1. **儀表板** - 統計資訊和待審核報告列表
2. **報告管理** - 報告審核、編輯、發布功能
3. **群組管理** - 群組和關鍵字管理
4. **用戶管理** - 用戶列表和管理
5. **系統設定** - LLM、PDF、SMTP 設定
**使用方式:**
- 直接在瀏覽器開啟 `ui-preview.html`
- 點擊上方標籤頁切換不同頁面
- 所有資料為模擬資料,不連接資料庫
---
## ✅ 修復檢查清單
在部署到生產環境前,請確認:
- [x] 已建立 `.gitignore` 並排除所有敏感檔案
- [ ] 已手動建立 `.env.example` 檔案
- [ ] 已從 Git 歷史中移除所有敏感資訊(如已提交)
- [ ] 生產環境的 `SECRET_KEY` 和 `JWT_SECRET_KEY` 已設定為強隨機值(至少 32 字元)
- [x] 生產環境的 `DEBUG=false`(已強制檢查)
- [x] 生產環境的 CORS 設定已明確指定允許的來源(不是 `*`
- [x] 所有 `print()` 已替換為 `logging`
- [x] LDAP 查詢已使用 `escape_filter_chars`
- [x] 檔案上傳功能已加強安全檢查
- [x] Email 內容已進行 XSS 防護
- [x] 輸入驗證已加強
- [ ] 已執行依賴項安全掃描並修復已知漏洞
- [ ] 已進行滲透測試
---
## 📊 修復進度
- **已完成:** 10/10 個主要安全修復項目
- **待完成:** 3 個中優先級項目(建議上線後處理)
- **總體進度:** 約 80% 完成
---
**修復完成時間:** 2025-01-27

225
確認事項.md Normal file
View File

@@ -0,0 +1,225 @@
# 每日報導 APP - 需要確認事項
> 建立日期2025-01-27
---
## 🔴 重要:資料表設計不一致
### 問題 1訂閱功能資料表
**現況:**
- Checklist 中**未選擇** `subscriptions訂閱表`
- 但 SDD 中明確提到「讀者可自行訂閱感興趣的群組」
**需要確認:**
- [ ] 訂閱功能是否需要獨立的 `subscriptions` 資料表?
- [ ] 還是使用其他方式實現(如 users 表的 JSON 欄位)?
- [ ] 如果不需要獨立表,如何記錄用戶訂閱關係?
**建議:** 訂閱功能是核心功能,建議建立 `subscriptions` 表以支援多對多關係。
---
### 問題 2收藏與標註功能
**現況:**
- Checklist 中**未選擇** `favorites收藏表``annotations標註表`
- SDD 中標註為「未來擴充」
**需要確認:**
- [ ] 收藏功能是否在第一階段實作?
- [ ] 標註功能是否在第一階段實作?
- [ ] 如果第一階段不實作,資料表設計是否預留?
---
## 🟡 環境變數配置需要補充
### 需要確認的環境變數清單
#### 1. 資料庫連線(✅ 已確認)
- [x] `DB_HOST` - mysql.theaken.com
- [x] `DB_PORT` - 33306
- [x] `DB_NAME` - db_A101
- [x] `DB_USER` - A101
- [x] `DB_PASSWORD` - Aa123456
- **連線狀態:** ✅ 測試連線正常
#### 2. Redis 連線(需要確認)
- [ ] `REDIS_HOST` - Redis 主機位址預設localhost
- [ ] `REDIS_PORT` - Redis 埠號預設6379
- [ ] `REDIS_PASSWORD` - Redis 密碼(如有)
- [ ] `REDIS_DB` - Redis 資料庫編號預設0
#### 3. Celery 設定(需要確認)
- [ ] `CELERY_BROKER_URL` - 訊息佇列 URLRedis 或 RabbitMQ
- [ ] `CELERY_RESULT_BACKEND` - 結果儲存位置(通常與 broker 相同)
#### 4. SMTP 設定(需要確認)
- [ ] `SMTP_HOST` - SMTP 伺服器位址
- [ ] `SMTP_PORT` - SMTP 埠號預設587
- [ ] `SMTP_USERNAME` - SMTP 帳號
- [ ] `SMTP_PASSWORD` - SMTP 密碼
- [ ] `SMTP_FROM_EMAIL` - 寄件者 Email
- [ ] `SMTP_FROM_NAME` - 寄件者名稱(預設:每日報導系統)
- [ ] `SMTP_USE_TLS` - 是否使用 TLS預設True
#### 5. AD/LDAP 設定(需要確認)
- [ ] `LDAP_SERVER` - LDAP 伺服器位址
- [ ] `LDAP_PORT` - LDAP 埠號預設389
- [ ] `LDAP_BASE_DN` - LDAP Base DN
- [ ] `LDAP_BIND_DN` - LDAP 綁定 DN如有
- [ ] `LDAP_BIND_PASSWORD` - LDAP 綁定密碼(如有)
- [ ] `LDAP_USER_SEARCH_FILTER` - 用戶搜尋過濾器(預設:`(sAMAccountName={username})`
#### 6. LLM API Keys需要確認
- [ ] `GEMINI_API_KEY` - Google Gemini API Key
- [ ] `OPENAI_API_KEY` - OpenAI API Key
- [ ] `OLLAMA_ENDPOINT` - Ollama 端點 URL預設http://localhost:11434
#### 7. Digitimes 帳號(需要確認)
- [ ] `DIGITIMES_USERNAME` - Digitimes 登入帳號
- [ ] `DIGITIMES_PASSWORD` - Digitimes 登入密碼
#### 8. 應用程式設定(需要確認)
- [ ] `SECRET_KEY` - 應用程式密鑰(用於加密)
- [ ] `JWT_SECRET_KEY` - JWT 簽章密鑰
- [ ] `APP_ENV` - 環境development/staging/production
- [ ] `DEBUG` - 除錯模式預設False
---
## 🟡 功能實作細節需要確認
### 問題 3台灣行事曆 API
**現況:** SDD 提到「使用台灣行事曆 API 判斷工作日」
**需要確認:**
- [ ] 使用哪個台灣行事曆 API
- [ ] 政府資料開放平台
- [ ] 第三方 API如 holiday.tw
- [ ] 自行維護假日清單
- [ ] API 連線失敗時的備援機制?
- [ ] 是否需要快取假日資料?
---
### 問題 4關鍵字過濾機制
**現況:** SDD 提到「關鍵字過濾後自動審核」
**需要確認:**
- [ ] 關鍵字清單由誰維護?(系統管理員?)
- [ ] 過濾規則:
- [ ] 包含特定關鍵字 → 需要審核
- [ ] 不包含特定關鍵字 → 自動通過
- [ ] 其他規則?
- [ ] 過濾後的留言狀態:
- [ ] 標記為「待審核」
- [ ] 直接隱藏
- [ ] 其他處理方式?
---
### 問題 5Email 批次發送細節
**現況:** SDD 提到「批次發送(每批 10 封)」
**需要確認:**
- [ ] 批次間隔時間?(如每批間隔 1 秒)
- [ ] 發送失敗的重試機制?
- [ ] 是否使用 Celery 背景任務處理?
---
### 問題 6初步摘要機制
**現況:** SDD 提到「當新聞內容超過模型 token 限制時,先進行初步摘要再送 LLM」
**需要確認:**
- [ ] 初步摘要使用哪種方法?
- [ ] 簡單的文字截斷(保留前 N 字)
- [ ] 使用較小的 LLM 模型先摘要
- [ ] 使用規則式摘要(提取關鍵句)
- [ ] 初步摘要的目標長度?(如縮減至 50%
---
### 問題 7PDF 中的新聞連結
**現況:** SDD 提到「相關新聞列表(標題 + 連結)」
**需要確認:**
- [ ] PDF 中的連結格式?
- [ ] 完整 URLhttps://...
- [ ] 短連結(需要短連結服務)
- [ ] QR Code需要 QR Code 生成)
- [ ] 如果新聞來源需要登入(如 Digitimes連結如何處理
---
## 🟢 建議補充的規格
### 1. 環境變數管理章節
建議在 SDD 中新增「環境變數配置」章節,列出所有需要的環境變數。
### 2. 資料庫 Schema 詳細設計
建議補充:
- 各資料表的完整欄位定義
- 外鍵關係
- 索引設計
- 預設值與約束條件
### 3. API 端點規格
建議補充:
- RESTful API 端點清單
- 請求/回應格式
- 認證方式
- 錯誤碼定義
### 4. 部署架構圖
建議補充:
- 系統架構圖
- 資料流圖
- 部署架構圖
---
## 📝 待您提供的資訊
1. **資料庫連線資訊**(✅ 已提供)
- DB_HOST: mysql.theaken.com
- DB_PORT: 33306
- DB_NAME: db_A101
- DB_USER: A101
- DB_PASSWORD: Aa123456
- **狀態:** ✅ 測試連線正常
2. **Redis 連線資訊**(需要確認)
- REDIS_HOST
- REDIS_PORT
- REDIS_PASSWORD如有
3. **SMTP 設定**(需要確認)
- SMTP_HOST
- SMTP_USERNAME
- SMTP_PASSWORD
- SMTP_FROM_EMAIL
4. **LDAP 設定**(需要確認)
- LDAP_SERVER
- LDAP_BASE_DN
- 其他 LDAP 參數
5. **其他 API Keys**(需要確認)
- GEMINI_API_KEY 或 OPENAI_API_KEY
- DIGITIMES_USERNAME 和 PASSWORD
---
## ✅ 下一步行動
1. 請確認上述問題的答案
2. 提供資料庫連線資訊
3. 確認其他環境變數設定
4. 決定訂閱功能的資料表設計方式
---
**最後更新:** 2025-01-27

410
系統解析.md Normal file
View File

@@ -0,0 +1,410 @@
# 每日報導 APP - 系統解析文檔
## 📋 系統概述
**每日報導 APP** 是一個企業內部新聞彙整與分析系統,主要功能是自動抓取多個新聞來源、使用 AI 進行智慧摘要、依產業別或議題分類,並自動生成每日報告發送給訂閱者。
---
## 🏗️ 系統架構
### 技術棧
| 層級 | 技術 | 說明 |
|------|------|------|
| **後端框架** | FastAPI 0.109.0 | 現代化的 Python Web 框架,支援異步處理 |
| **Python 版本** | Python 3.11 | 使用最新穩定版本 |
| **資料庫** | MySQL 8.0 / SQLite | 支援 MySQL生產和 SQLite開發 |
| **ORM** | SQLAlchemy 2.0.25 | 資料庫操作抽象層 |
| **認證** | JWT + LDAP/AD | 支援本地帳號和企業 AD/LDAP 整合 |
| **LLM 整合** | OpenAI / Gemini / Ollama | 支援多種 AI 模型進行摘要生成 |
| **排程** | APScheduler 3.10.4 | 定時任務排程(新聞抓取、報告生成) |
| **Email** | aiosmtplib 3.0.1 | 異步 SMTP 郵件發送 |
| **PDF 生成** | WeasyPrint 60.2 | 報告 PDF 匯出功能 |
| **部署** | Docker + Docker Compose | 容器化部署,支援 1Panel |
### 系統架構圖
```
┌─────────────────────────────────────────────────────────┐
│ 前端介面 (UI) │
│ (響應式設計,支援手機閱讀) │
└────────────────────┬────────────────────────────────────┘
│ HTTP/HTTPS
┌────────────────────▼────────────────────────────────────┐
│ FastAPI 應用程式 (app/main.py) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │
│ │ Auth API │ │ Users API│ │Groups API│ │Reports │ │
│ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │
└────┬───────────────┬───────────────┬────────────────────┘
│ │ │
┌────▼────┐ ┌──────▼──────┐ ┌────▼──────┐
│ Services│ │ Models │ │ Database │
│ Layer │ │ (ORM) │ │ (MySQL) │
└────┬────┘ └──────────────┘ └───────────┘
┌────▼──────────────────────────────────────┐
│ 核心服務模組 │
│ • Crawler Service (新聞爬蟲) │
│ • LLM Service (AI 摘要) │
│ • Notification Service (Email 通知) │
│ • Scheduler Service (排程任務) │
└───────────────────────────────────────────┘
```
---
## 🔑 核心功能模組
### 1. 認證與授權 (`app/api/v1/endpoints/auth.py`)
- **JWT Token 認證**:使用 JWT 進行用戶認證
- **LDAP/AD 整合**:支援企業 Active Directory 統一認證
- **本地帳號**:支援本地用戶帳號密碼登入
- **角色權限**admin管理員、editor編輯、reader讀者
### 2. 用戶管理 (`app/api/v1/endpoints/users.py`)
- 用戶列表查詢
- 用戶新增、編輯、停用
- 角色分配
- 最後登入時間追蹤
### 3. 群組管理 (`app/api/v1/endpoints/groups.py`)
- **群組分類**產業別industry或議題topic
- **關鍵字管理**:為每個群組設定關鍵字,用於新聞匹配
- **AI 設定**:可為群組設定 AI 背景資訊和摘要提示
- **啟用/停用**:控制群組是否參與自動匹配
### 4. 新聞抓取 (`app/services/crawler_service.py`)
- **支援來源**
- Digitimes需付費帳號
- 經濟日報(公開)
- 工商時報(公開)
- **自動排程**:每日定時抓取(預設 08:00
- **關鍵字匹配**:根據群組關鍵字自動匹配新聞
- **防重複**:使用外部 ID 避免重複抓取
- **錯誤重試**:支援自動重試機制
### 5. 報告管理 (`app/api/v1/endpoints/reports.py`)
- **報告生成**:每日為每個群組自動生成報告
- **AI 摘要**:使用 LLM 生成綜合摘要
- **編輯功能**:專員可編輯 AI 摘要
- **文章篩選**:專員可選擇是否納入報告
- **發布流程**:草稿 → 待審核 → 已發布
- **PDF 匯出**:支援匯出 PDF 格式
### 6. 訂閱管理 (`app/api/v1/endpoints/subscriptions.py`)
- 用戶訂閱群組
- Email 通知開關
- 訂閱列表查詢
### 7. AI 摘要服務 (`app/services/llm_service.py`)
- **多 LLM 支援**
- Google Gemini預設
- OpenAI GPT-4
- Ollama本地部署
- **摘要生成**:根據群組設定和新聞內容生成摘要
- **可配置模型**:可選擇不同模型和參數
### 8. 通知服務 (`app/services/notification_service.py`)
- **Email 通知**:報告發布時自動發送 Email
- **通知記錄**:記錄所有通知發送狀態
- **失敗重試**:支援失敗通知重試
### 9. 排程服務 (`app/services/scheduler_service.py`)
- **定時抓取**:每日定時執行新聞抓取
- **報告生成**:定時生成每日報告
- **通知發送**:定時發送通知
### 10. 系統設定 (`app/api/v1/endpoints/settings.py`)
- LLM 設定API Key、模型選擇
- SMTP 設定Email 發送)
- PDF 設定Logo、頁首頁尾
- 系統參數設定
---
## 📊 資料庫結構
### 核心資料表
1. **用戶與權限**
- `roles`角色表admin, editor, reader
- `users`:用戶表(支援 AD/LDAP 和本地帳號)
2. **新聞來源與文章**
- `news_sources`:新聞來源設定
- `news_articles`:抓取的新聞文章
- `crawl_jobs`:抓取任務記錄
3. **群組與關鍵字**
- `groups`:群組表(產業別/議題)
- `keywords`:關鍵字表
- `article_group_matches`:新聞-群組匹配關聯
4. **報告**
- `reports`:報告表
- `report_articles`:報告-新聞關聯表
5. **讀者互動**
- `subscriptions`:訂閱表
- `favorites`:收藏表
- `comments`:留言表
- `notes`:個人筆記表
6. **系統**
- `system_settings`:系統設定表
- `audit_logs`:操作日誌表
- `notification_logs`:通知記錄表
### 資料保留策略
- 預設保留 60 天資料
- 使用 MySQL Event Scheduler 自動清理過期資料
---
## 🔐 安全機制
### 1. 認證安全
- JWT Token 認證
- 密碼使用 bcrypt 雜湊
- LDAP 注入防護
- Token 過期時間控制(生產環境建議 60-120 分鐘)
### 2. 資料安全
- 敏感資訊加密儲存API Keys、密碼
- SQL 注入防護(使用 ORM
- XSS 防護(輸入驗證和輸出編碼)
### 3. 環境安全
- 生產環境強制關閉 Debug 模式
- 生產環境關閉 API 文檔(/docs
- CORS 嚴格控制(生產環境不允許 `*`
- 密鑰長度驗證(至少 32 字元)
### 4. 日誌與審計
- 操作日誌記錄audit_logs
- 錯誤日誌記錄
- 通知發送記錄
---
## 📁 目錄結構說明
```
daily-news-app/
├── app/ # 應用程式主目錄
│ ├── api/v1/ # API 路由
│ │ ├── endpoints/ # API 端點實作
│ │ │ ├── auth.py # 認證相關
│ │ │ ├── users.py # 用戶管理
│ │ │ ├── groups.py # 群組管理
│ │ │ ├── reports.py # 報告管理
│ │ │ ├── subscriptions.py # 訂閱管理
│ │ │ └── settings.py # 系統設定
│ │ └── router.py # 路由總管理
│ ├── core/ # 核心設定
│ │ ├── config.py # 環境變數設定
│ │ ├── security.py # 安全相關JWT、LDAP
│ │ └── logging_config.py # 日誌設定
│ ├── db/ # 資料庫
│ │ └── session.py # 資料庫連線管理
│ ├── models/ # 資料模型SQLAlchemy
│ │ ├── user.py # 用戶模型
│ │ ├── news.py # 新聞模型
│ │ ├── group.py # 群組模型
│ │ ├── report.py # 報告模型
│ │ ├── interaction.py # 互動模型(訂閱、收藏等)
│ │ └── system.py # 系統模型
│ ├── schemas/ # Pydantic SchemaAPI 驗證)
│ │ ├── user.py
│ │ ├── group.py
│ │ └── report.py
│ ├── services/ # 商業邏輯服務
│ │ ├── crawler_service.py # 新聞爬蟲
│ │ ├── llm_service.py # AI 摘要
│ │ ├── notification_service.py # Email 通知
│ │ └── scheduler_service.py # 排程任務
│ ├── utils/ # 工具函數
│ └── main.py # 應用程式入口
├── scripts/ # 腳本
│ ├── init_db_sqlite.py # SQLite 初始化
│ └── init.sql # MySQL 初始化 SQL
├── templates/ # Email 模板
├── tests/ # 測試檔案
├── logs/ # 日誌目錄
├── uploads/ # 上傳檔案目錄
├── docker-compose.yml # Docker Compose 配置
├── Dockerfile # Docker 映像檔配置
├── requirements.txt # Python 依賴套件
├── run.py # 啟動腳本
└── README.md # 專案說明
```
---
## 🔄 系統流程
### 1. 新聞抓取流程
```
排程觸發 (08:00)
讀取所有啟用的新聞來源
對每個來源執行抓取
解析文章列表
取得文章內容
儲存到 news_articles
根據群組關鍵字匹配
建立 article_group_matches 關聯
記錄抓取任務狀態
```
### 2. 報告生成流程
```
每日定時觸發
查詢所有啟用的群組
對每個群組:
├─ 查詢匹配的新聞文章
├─ 使用 LLM 生成 AI 摘要
├─ 建立報告狀態draft
└─ 關聯新聞文章
專員審核與編輯
發布報告狀態published
發送 Email 通知給訂閱者
```
### 3. 用戶登入流程
```
用戶提交帳號密碼
判斷認證類型AD/LDAP 或本地)
驗證帳號密碼
查詢用戶資訊和角色
生成 JWT Token
返回 Token 給前端
```
---
## 🌐 API 端點總覽
### 認證相關
- `POST /api/v1/auth/login` - 用戶登入
- `GET /api/v1/auth/me` - 取得當前用戶資訊
### 用戶管理
- `GET /api/v1/users` - 用戶列表
- `POST /api/v1/users` - 新增用戶
- `GET /api/v1/users/{id}` - 用戶詳情
- `PUT /api/v1/users/{id}` - 更新用戶
- `DELETE /api/v1/users/{id}` - 刪除用戶
### 群組管理
- `GET /api/v1/groups` - 群組列表
- `POST /api/v1/groups` - 新增群組
- `GET /api/v1/groups/{id}` - 群組詳情
- `PUT /api/v1/groups/{id}` - 更新群組
- `DELETE /api/v1/groups/{id}` - 刪除群組
### 報告管理
- `GET /api/v1/reports` - 報告列表
- `GET /api/v1/reports/{id}` - 報告詳情
- `POST /api/v1/reports/{id}/publish` - 發布報告
- `GET /api/v1/reports/{id}/pdf` - 匯出 PDF
### 訂閱管理
- `GET /api/v1/subscriptions` - 我的訂閱
- `POST /api/v1/subscriptions` - 新增訂閱
- `DELETE /api/v1/subscriptions/{id}` - 取消訂閱
### 系統設定
- `GET /api/v1/settings` - 取得設定
- `PUT /api/v1/settings` - 更新設定
---
## 🔧 環境變數說明
系統使用 `.env` 檔案管理環境變數,主要包含:
### 應用程式設定
- `APP_ENV`環境development/staging/production
- `DEBUG`:除錯模式(生產環境必須為 false
- `SECRET_KEY`:應用程式密鑰(至少 32 字元)
- `JWT_SECRET_KEY`JWT 簽章密鑰(至少 32 字元)
### 資料庫設定
- `DB_HOST`:資料庫主機(或 "sqlite" 使用 SQLite
- `DB_PORT`:資料庫埠號
- `DB_NAME`:資料庫名稱
- `DB_USER`:資料庫用戶
- `DB_PASSWORD`:資料庫密碼
### LLM 設定
- `LLM_PROVIDER`LLM 提供者gemini/openai/ollama
- `GEMINI_API_KEY`Gemini API Key
- `OPENAI_API_KEY`OpenAI API Key
- `OLLAMA_ENDPOINT`Ollama 端點 URL
### SMTP 設定
- `SMTP_HOST`SMTP 伺服器
- `SMTP_PORT`SMTP 埠號
- `SMTP_USERNAME`SMTP 帳號
- `SMTP_PASSWORD`SMTP 密碼
- `SMTP_FROM_EMAIL`:寄件者 Email
### LDAP/AD 設定
- `LDAP_SERVER`LDAP 伺服器位址
- `LDAP_BASE_DN`LDAP Base DN
### 其他設定
- `DIGITIMES_USERNAME`Digitimes 帳號
- `DIGITIMES_PASSWORD`Digitimes 密碼
---
## 📝 注意事項
1. **生產環境部署**
- 必須設定強隨機的 `SECRET_KEY``JWT_SECRET_KEY`
- 必須關閉 `DEBUG` 模式
- 必須明確設定 `CORS_ORIGINS`(不能使用 `*`
- 建議設定 `JWT_ACCESS_TOKEN_EXPIRE_MINUTES` 為 60-120 分鐘
2. **資料庫選擇**
- 開發環境可使用 SQLite設定 `DB_HOST=sqlite`
- 生產環境建議使用 MySQL 8.0
3. **LLM 選擇**
- Gemini需要 API Key費用較低
- OpenAI需要 API Key品質較高
- Ollama本地部署無需 API Key但需要 GPU 資源
4. **新聞來源**
- Digitimes 需要付費帳號
- 經濟日報和工商時報為公開來源
---
## 📚 相關文檔
- `README.md` - 專案說明
- `daily-news-SDD.md` - 系統設計文檔
- `安全修復完成報告.md` - 安全修復記錄
- `執行步驟.md` - 詳細執行步驟(見下一個文檔)

34
若瑄資安規則.md Normal file
View File

@@ -0,0 +1,34 @@
你是一位資深全端工程師,請根據目前專案的檔案結構與程式內容,簡述此專案的整體狀態。
重點請對照以下檢核項目,逐項說明是否存在與其狀況:
- 專案結構與依賴檢查
1. 是否有入口檔案(如 app.py、main.js、server.js
2. 是否有明確的專案結構app、routes、static、templates、src 等)
3. 是否有 requirements.txt 或 package.json
4. 是否可看出使用框架Flask、FastAPI、Express、Next.js…
5. 是否包含 README.md 且有安裝與啟動說明
6. 無多餘或不安全的依賴套件
7. 監聽的 port 號碼、主機位址並列出在哪個檔案出現(例如 127.0.0.1:3000、localhost:5000、0.0.0.0:8000…從環境變數讀取
- 安全性與環境變數檢核
1. 是否存在 .env 或 .env.example
2. 是否有 .gitignore 且內容正確(排除 .env、__pycache__、node_modules、logs 等)
3. 是否有資料庫連線設定DB_HOST、SQLAlchemy、Prisma 等)
4. DB 連線字串來自 `.env`無硬編碼敏感資訊API_KEY、DB 密碼等)
5. 使用者輸入有防 SQL Injection / XSS 機制
6. 其他明顯缺漏或安全疑慮
- 程式品質與可維護性
1. 錯誤處理try/except / middleware完善
- 請用條列方式輸出,例如:
- 專案結構與依賴檢查:
- ✅ 1. 有 app.py 作為入口
- ❌ 7. 無 README.md
- 安全性與環境變數檢核:
- ❌ 1. 無 .env 檔案
- 依據上述的檢核結果給予分數總分100分
- 先列出即可,不要修改程式碼
- 將以上檢核項目列出後,產生 Check.md 檔案,不要再產生其他測試文檔