From 4f7f46b07ac59368087a4ec73ddc352fe2b0ca2a Mon Sep 17 00:00:00 2001 From: beabigegg Date: Thu, 28 Aug 2025 08:59:46 +0800 Subject: [PATCH] 2ND --- .claude/settings.local.json | 10 + .dockerignore | 72 ++++ .env.example | 36 ++ DEMO_GUIDE.md | 250 ++++++++++++ DEPLOYMENT.md | 690 ++++++++++++++++++++++++++++++++ Dockerfile | 43 ++ README.md | 584 ++++++++++++++++++++++----- USER_MANUAL.md | 381 +++++++++++++++--- app.py | 41 ++ docker-compose.override.yml | 47 +++ docker-compose.yml | 135 +++++++ group.png | Bin 0 -> 28623 bytes init_db.py | 52 +-- ldap_utils.py | 438 ++++++++++++++++++-- mysql/init/01-init.sql | 18 + nginx/conf.d/default.conf | 83 ++++ nginx/nginx.conf | 71 ++++ requirements.txt | 3 +- routes/admin.py | 112 +++--- routes/api.py | 80 ++++ routes/auth.py | 103 +++-- routes/temp_spec.py | 95 +++-- start-linux.sh | 368 +++++++++++++++++ start-windows.bat | 206 ++++++++++ static/generated/PE1140801.docx | Bin 0 -> 24502 bytes tasks.py | 55 +++ templates/activate_spec.html | 126 +++++- templates/base.html | 6 +- templates/extend_spec.html | 59 +++ templates/login.html | 10 +- templates/register.html | 47 --- templates/terminate_spec.html | 60 +++ templates/user_management.html | 255 +++++++++--- test_api.py | 72 ++++ test_ldap.py | 116 ++++++ test_ldap_search.py | 113 ++++++ test_org_search.py | 126 ++++++ test_startup.py | 137 +++++++ update_admin.py | 37 ++ utils.py | 100 ++++- 代辦.txt | 45 --- 功能升級規劃.txt | 204 ++++++++++ 42 files changed, 4992 insertions(+), 494 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 DEMO_GUIDE.md create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile create mode 100644 docker-compose.override.yml create mode 100644 docker-compose.yml create mode 100644 group.png create mode 100644 mysql/init/01-init.sql create mode 100644 nginx/conf.d/default.conf create mode 100644 nginx/nginx.conf create mode 100644 routes/api.py create mode 100644 start-linux.sh create mode 100644 start-windows.bat create mode 100644 static/generated/PE1140801.docx create mode 100644 tasks.py delete mode 100644 templates/register.html create mode 100644 test_api.py create mode 100644 test_ldap.py create mode 100644 test_ldap_search.py create mode 100644 test_org_search.py create mode 100644 test_startup.py create mode 100644 update_admin.py delete mode 100644 代辦.txt create mode 100644 功能升級規劃.txt diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..df0102a --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(pip install:*)", + "Bash(python:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1f2ed3d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,72 @@ +# Version control +.git +.gitignore + +# Python cache +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +pip-log.txt + +# Virtual environment +venv/ +env/ +.venv/ +.env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Environment files +.env +.env.local +.env.production + +# Logs +logs/ +*.log + +# Runtime data +uploads/* +!uploads/.gitkeep +static/generated/* +!static/generated/.gitkeep + +# Docker +Dockerfile +docker-compose*.yml +.dockerignore + +# Documentation +*.md +docs/ + +# Test files +tests/ +test_* +*_test.py + +# Build artifacts +dist/ +build/ +*.egg-info/ + +# Database +*.db +*.sqlite +*.sqlite3 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4947560 --- /dev/null +++ b/.env.example @@ -0,0 +1,36 @@ +# Flask 應用程式設定 +SECRET_KEY=your-super-secret-and-random-string-here +FLASK_ENV=production +APP_PORT=5000 +UPLOAD_FOLDER=uploads + +# 資料庫設定 +DB_ROOT_PASSWORD=tempspec123 +DB_NAME=tempspec_db +DB_USER=tempspec_user +DB_PASSWORD=tempspec_pass +DB_PORT=3306 + +# LDAP/Active Directory 設定 +LDAP_SERVER=ldap://your-dc.company.com +LDAP_PORT=389 +LDAP_USE_SSL=False +LDAP_SEARCH_BASE=DC=company,DC=com +LDAP_BIND_USER_DN=CN=service,DC=company,DC=com +LDAP_BIND_USER_PASSWORD=service_password +LDAP_USER_LOGIN_ATTR=userPrincipalName + +# SMTP 郵件設定 +SMTP_SERVER=smtp.company.com +SMTP_PORT=587 +SMTP_USE_TLS=True +SMTP_SENDER_EMAIL=noreply@company.com +SMTP_SENDER_PASSWORD=smtp_password + +# ONLYOFFICE Document Server 設定 +ONLYOFFICE_PORT=8080 +ONLYOFFICE_JWT_SECRET=your-onlyoffice-jwt-secret-string + +# Nginx 設定 (可選) +NGINX_PORT=80 +NGINX_SSL_PORT=443 \ No newline at end of file diff --git a/DEMO_GUIDE.md b/DEMO_GUIDE.md new file mode 100644 index 0000000..07dde8a --- /dev/null +++ b/DEMO_GUIDE.md @@ -0,0 +1,250 @@ +# 暫時規範管理系統 V3 - 測試/Demo 啟動指南 + +本指南專門用於快速啟動系統進行測試或演示。 + +## 🚀 快速啟動 (建議用於Demo) + +### 選項1:Docker 一鍵啟動 (最簡單) + +```bash +# 1. 複製並設定環境變數 +cp .env.example .env + +# 2. 編輯 .env 檔案 (重要!) +# 設定以下關鍵參數: +# - LDAP_SERVER=ldap://your-dc.panjit.com.tw +# - LDAP_BIND_USER_DN=CN=service,DC=panjit,DC=com,DC=tw +# - LDAP_BIND_USER_PASSWORD=your_service_password + +# 3. 啟動所有服務 +docker-compose up -d + +# 4. 等待服務啟動 (約30-60秒) +docker-compose logs -f + +# 5. 初始化資料庫 +docker-compose exec app python init_db.py + +# 6. 訪問系統 +# http://localhost:5000 +``` + +### 選項2:本機開發模式 + +```bash +# Windows +start-windows.bat + +# Linux +./start-linux.sh +``` + +## 📋 Demo 前檢查清單 + +### 必要服務檢查 +- [ ] **MySQL**: `docker ps` 確認 tempspec-mysql 運行中 +- [ ] **ONLYOFFICE**: 訪問 http://localhost:8080 確認運行正常 +- [ ] **Flask App**: 訪問 http://localhost:5000 確認可登入 +- [ ] **LDAP連線**: 使用AD帳號測試登入 + +### 權限設定確認 +- [ ] **ymirliu@panjit.com.tw**: 已設定為管理員權限 +- [ ] **權限管理頁面**: 可透過選單訪問 +- [ ] **角色功能**: admin/editor/viewer 功能正常 + +## 🔧 資料庫架構變更說明 + +### ⚠️ 重要:資料表名稱變更 + +如果您有現有的資料庫,請注意以下變更: + +**舊版本 → V3 變更**: +``` +user → ts_user +temp_spec → ts_temp_spec +upload → ts_upload +spec_history → ts_spec_history +``` + +### 資料移轉腳本 (如果需要) + +如果需要從舊版本遷移資料: + +```sql +-- 備份現有資料 +CREATE TABLE user_backup AS SELECT * FROM user; +CREATE TABLE temp_spec_backup AS SELECT * FROM temp_spec; +-- 其他表的備份... + +-- 重命名資料表 (謹慎操作!) +RENAME TABLE user TO ts_user; +RENAME TABLE temp_spec TO ts_temp_spec; +RENAME TABLE upload TO ts_upload; +RENAME TABLE spec_history TO ts_spec_history; +``` + +**建議**: 如果是演示環境,直接重新初始化資料庫即可: + +```bash +# 清除舊資料並重新初始化 +docker-compose down -v # 刪除所有卷 +docker-compose up -d +docker-compose exec app python init_db.py +``` + +## 🧪 Demo 測試流程 + +### 基本功能測試 + +1. **登入測試** +```bash +# 測試AD帳號登入 +用戶: ymirliu@panjit.com.tw (或 ymirliu) +密碼: [AD密碼] +``` + +2. **權限管理測試** + - 登入後訪問 "權限管理" 選單 + - 確認可以看到使用者列表 + - 測試權限變更功能 + +3. **規範建立流程** + - 建立新的暫時規範 + - 測試 ONLYOFFICE 編輯功能 + - 下載 Word 文件 + +4. **智慧通知測試** + - 測試啟用規範時的收件人選擇 + - 確認 LDAP 搜尋功能運作 + - 驗證郵件發送 (如果SMTP已設定) + +5. **排程任務測試** + - 檢查排程任務是否已註冊 + - 模擬到期提醒功能 + +### Demo 劇本建議 + +``` +1. 開場 - 系統介紹 (2分鐘) + "這是企業級暫時規範管理系統V3,集成了AD認證和智慧通知" + +2. 登入演示 (1分鐘) + 展示AD單一登入功能 + +3. 權限管理 (2分鐘) + 展示LDAP整合的權限管理界面 + +4. 規範建立 (3分鐘) + 展示從建立到線上編輯的完整流程 + +5. 智慧通知 (2分鐘) + 展示動態收件人選擇和自動提醒功能 + +6. 總結 (1分鐘) + 強調V3的主要改進和企業級特性 +``` + +## 🛠️ 疑難排解 + +### 常見問題 + +1. **LDAP連線失敗** +```bash +# 檢查LDAP設定 +docker-compose exec app python -c " +from ldap_utils import authenticate_ldap_user +print(authenticate_ldap_user('testuser', 'testpass')) +" +``` + +2. **ONLYOFFICE無法載入** +```bash +# 檢查Document Server狀態 +curl http://localhost:8080/healthcheck +docker logs tempspec-onlyoffice +``` + +3. **資料庫連線問題** +```bash +# 檢查MySQL狀態 +docker-compose exec mysql mysql -u root -p -e "SHOW DATABASES;" +``` + +4. **權限設定問題** +```sql +-- 直接在資料庫中設定管理員 +USE tempspec_db; +UPDATE ts_user SET role='admin' WHERE username='ymirliu@panjit.com.tw'; +``` + +### 快速重置 + +如果需要完全重置系統: + +```bash +# 停止並刪除所有容器和資料 +docker-compose down -v --remove-orphans + +# 清理Docker資源 +docker system prune -f + +# 重新啟動 +docker-compose up -d +sleep 30 +docker-compose exec app python init_db.py + +echo "系統已完全重置!" +``` + +## 📊 系統狀態監控 + +### 檢查系統健康狀態 + +```bash +#!/bin/bash +# health-check.sh + +echo "=== 系統狀態檢查 ===" + +echo "1. Docker 服務狀態:" +docker-compose ps + +echo -e "\n2. 應用程式可訪問性:" +curl -s -o /dev/null -w "%{http_code}" http://localhost:5000 && echo " ✓ Flask App: 正常" || echo " ✗ Flask App: 異常" + +echo -e "\n3. ONLYOFFICE 狀態:" +curl -s -o /dev/null -w "%{http_code}" http://localhost:8080 && echo " ✓ ONLYOFFICE: 正常" || echo " ✗ ONLYOFFICE: 異常" + +echo -e "\n4. MySQL 狀態:" +docker-compose exec mysql mysqladmin ping -h localhost -u root -p$DB_ROOT_PASSWORD --silent && echo " ✓ MySQL: 正常" || echo " ✗ MySQL: 異常" + +echo -e "\n5. 磁碟空間:" +df -h | grep -E "(Filesystem|/dev/)" + +echo -e "\n6. 記憶體使用:" +free -h + +echo -e "\n=== 檢查完成 ===" +``` + +## 📝 Demo 注意事項 + +1. **網路環境**: 確保Demo環境可以連接到公司的LDAP伺服器 +2. **防火牆**: 開放必要的端口 (389/636 for LDAP, 25/587 for SMTP) +3. **測試資料**: 準備一些測試用的暫時規範範例 +4. **備案計畫**: 準備離線Demo資料以防網路問題 +5. **效能考量**: Demo環境建議至少4GB RAM,確保ONLYOFFICE穩定運行 + +## 🎯 成功標準 + +Demo成功的標誌: +- ✅ AD帳號可以正常登入 +- ✅ 權限管理功能運作正常 +- ✅ ONLYOFFICE編輯器可以載入和編輯 +- ✅ 智慧通知功能可以搜尋LDAP用戶 +- ✅ 排程任務已正確註冊 +- ✅ 所有核心功能響應時間 < 3秒 + +--- + +**祝您Demo順利!** 如有任何問題,請參考系統日誌或聯繫技術支援團隊。 \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..5ec4741 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,690 @@ +# 部署指南 - 暫時規範管理系統 V3 + +本文件提供詳細的部署指導,涵蓋不同平台和環境的部署方式。 + +## 📋 目錄 + +1. [快速開始](#1-快速開始) +2. [平台特定部署](#2-平台特定部署) +3. [Docker 部署](#3-docker-部署) +4. [生產環境部署](#4-生產環境部署) +5. [疑難排解](#5-疑難排解) + +## 1. 快速開始 + +### 前置需求檢查清單 + +- [ ] Python 3.8+ 已安裝 +- [ ] Docker 已安裝且運行中 (如使用 Docker 部署) +- [ ] MySQL 8.0+ 或相容的資料庫 +- [ ] ONLYOFFICE Document Server +- [ ] LDAP/Active Directory 伺服器 (企業環境) +- [ ] SMTP 郵件伺服器 + +### 一鍵啟動 + +#### Windows 環境 +```cmd +# 使用自動化腳本啟動 +start-windows.bat + +# 或手動執行 +docker-compose up -d +docker-compose exec app python init_db.py +``` + +#### Linux 環境 +```bash +# 使用自動化腳本啟動 +chmod +x start-linux.sh +./start-linux.sh + +# 或手動執行 +docker-compose up -d +docker-compose exec app python init_db.py +``` + +## 2. 平台特定部署 + +### 2.1 Windows 部署 + +#### 開發環境 + +1. **準備環境** +```cmd +# 建立虛擬環境 +python -m venv venv +venv\Scripts\activate + +# 安裝依賴 +pip install -r requirements.txt +``` + +2. **設定環境變數** +```cmd +copy .env.example .env +# 編輯 .env 檔案設定參數 +``` + +3. **啟動外部服務** +```cmd +# 啟動 MySQL (使用 Docker) +docker run -d --name tempspec-mysql ^ + -e MYSQL_ROOT_PASSWORD=tempspec123 ^ + -e MYSQL_DATABASE=tempspec_db ^ + -e MYSQL_USER=tempspec_user ^ + -e MYSQL_PASSWORD=tempspec_pass ^ + -p 3306:3306 mysql:8.0 + +# 啟動 ONLYOFFICE +docker run -d --name tempspec-onlyoffice ^ + -e JWT_ENABLED=true ^ + -e JWT_SECRET=your_jwt_secret ^ + -p 8080:80 onlyoffice/documentserver +``` + +4. **初始化並啟動應用** +```cmd +python init_db.py +python app.py +``` + +#### 生產環境 (Windows Server + IIS) + +1. **安裝 IIS 和 HttpPlatformHandler** + - 啟用 IIS 功能 + - 安裝 HttpPlatformHandler 模組 + +2. **建立應用程式目錄** +```cmd +mkdir C:\inetpub\wwwroot\tempspec +xcopy /E /I . C:\inetpub\wwwroot\tempspec +``` + +3. **建立 Web.config** +```xml + + + + + + + + + + + + + +``` + +4. **使用 Waitress (建議方式)** +```cmd +pip install waitress +waitress-serve --host=0.0.0.0 --port=5000 app:app +``` + +#### Windows 服務安裝 + +使用 NSSM (Non-Sucking Service Manager): + +```cmd +# 下載 NSSM +# https://nssm.cc/download + +# 安裝服務 +nssm install TempSpecSystem + +# 設定參數 +nssm set TempSpecSystem Application "C:\path\to\python.exe" +nssm set TempSpecSystem AppParameters "app.py" +nssm set TempSpecSystem AppDirectory "C:\path\to\your\app" +nssm set TempSpecSystem DisplayName "Temp Spec System V3" +nssm set TempSpecSystem Description "企業暫時規範管理系統" + +# 啟動服務 +nssm start TempSpecSystem +``` + +### 2.2 Linux 部署 + +#### 開發環境 + +1. **準備環境 (Ubuntu/Debian)** +```bash +# 更新系統 +sudo apt update && sudo apt upgrade -y + +# 安裝必要套件 +sudo apt install python3 python3-pip python3-venv git curl -y + +# 建立虛擬環境 +python3 -m venv venv +source venv/bin/activate + +# 安裝依賴 +pip install -r requirements.txt +``` + +2. **CentOS/RHEL 環境** +```bash +# 安裝 EPEL repository +sudo yum install epel-release -y + +# 安裝必要套件 +sudo yum install python3 python3-pip git curl -y + +# 建立虛擬環境 +python3 -m venv venv +source venv/bin/activate + +# 安裝依賴 +pip install -r requirements.txt +``` + +#### 生產環境 (Nginx + Gunicorn) + +1. **安裝 Gunicorn** +```bash +pip install gunicorn gevent +``` + +2. **建立 Gunicorn 設定檔** +```python +# gunicorn.conf.py +bind = "127.0.0.1:5000" +workers = 4 +worker_class = "gevent" +worker_connections = 1000 +max_requests = 1000 +max_requests_jitter = 50 +timeout = 30 +keepalive = 2 +preload_app = True +``` + +3. **建立 systemd 服務檔案** +```bash +sudo nano /etc/systemd/system/tempspec.service +``` + +```ini +[Unit] +Description=Temp Spec System V3 +After=network.target mysql.service + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/path/to/your/app +Environment="PATH=/path/to/your/app/venv/bin" +ExecStart=/path/to/your/app/venv/bin/gunicorn -c gunicorn.conf.py app:app +ExecReload=/bin/kill -HUP $MAINPID +Restart=always +RestartSec=3 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +``` + +4. **啟動服務** +```bash +sudo systemctl daemon-reload +sudo systemctl enable tempspec +sudo systemctl start tempspec +sudo systemctl status tempspec +``` + +5. **設定 Nginx** +```bash +sudo nano /etc/nginx/sites-available/tempspec +``` + +```nginx +server { + listen 80; + server_name your-domain.com; + + # SSL 重定向 (生產環境建議) + # return 301 https://$server_name$request_uri; + + location / { + proxy_pass http://127.0.0.1:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket 支援 + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # 靜態檔案 + location /static/ { + alias /path/to/your/app/static/; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # 大檔案上傳支援 + client_max_body_size 100M; +} +``` + +6. **啟用站點** +```bash +sudo ln -s /etc/nginx/sites-available/tempspec /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +``` + +## 3. Docker 部署 + +### 3.1 基本 Docker 部署 + +```bash +# 1. 複製環境設定檔 +cp .env.example .env +# 編輯 .env 設定參數 + +# 2. 啟動所有服務 +docker-compose up -d + +# 3. 初始化資料庫 +docker-compose exec app python init_db.py + +# 4. 檢查服務狀態 +docker-compose ps +``` + +### 3.2 開發環境部署 + +```bash +# 使用 override 檔案啟動開發環境 +docker-compose -f docker-compose.yml -f docker-compose.override.yml up -d + +# 查看即時日誌 +docker-compose logs -f app +``` + +### 3.3 生產環境部署 + +```bash +# 啟動包含 Nginx 的完整生產環境 +docker-compose --profile production up -d + +# 檢查所有服務 +docker-compose ps +``` + +### 3.4 Docker Swarm 部署 (高可用性) + +1. **初始化 Swarm** +```bash +docker swarm init +``` + +2. **建立 Docker Stack 檔案** +```yaml +# docker-stack.yml +version: '3.8' + +services: + app: + image: tempspec:latest + deploy: + replicas: 3 + update_config: + parallelism: 1 + delay: 10s + restart_policy: + condition: on-failure + networks: + - tempspec-network + + mysql: + image: mysql:8.0 + deploy: + replicas: 1 + placement: + constraints: + - node.role == manager + volumes: + - mysql_data:/var/lib/mysql + networks: + - tempspec-network + +networks: + tempspec-network: + external: true + +volumes: + mysql_data: +``` + +3. **部署 Stack** +```bash +docker stack deploy -c docker-stack.yml tempspec +``` + +### 3.5 Docker 管理指令 + +```bash +# 查看服務狀態 +docker-compose ps + +# 查看日誌 +docker-compose logs -f [service_name] + +# 重啟服務 +docker-compose restart [service_name] + +# 進入容器 +docker-compose exec app bash + +# 備份資料庫 +docker-compose exec mysql mysqldump -u root -p tempspec_db > backup.sql + +# 清理未使用的資源 +docker system prune -f +docker volume prune -f +``` + +## 4. 生產環境部署 + +### 4.1 負載均衡部署 + +使用多個應用程式實例提高可用性: + +```yaml +# docker-compose.prod.yml +version: '3.8' + +services: + app1: + build: . + environment: + - INSTANCE_ID=1 + # 其他設定... + + app2: + build: . + environment: + - INSTANCE_ID=2 + # 其他設定... + + nginx: + image: nginx:alpine + volumes: + - ./nginx/nginx-lb.conf:/etc/nginx/nginx.conf + ports: + - "80:80" + depends_on: + - app1 + - app2 +``` + +### 4.2 SSL/HTTPS 設定 + +1. **獲取 SSL 證書 (Let's Encrypt)** +```bash +# 安裝 Certbot +sudo apt install certbot python3-certbot-nginx + +# 獲取證書 +sudo certbot --nginx -d your-domain.com +``` + +2. **Docker 環境 SSL 設定** +```yaml +# docker-compose.ssl.yml +version: '3.8' + +services: + nginx: + volumes: + - /etc/letsencrypt:/etc/letsencrypt:ro + - ./nginx/ssl.conf:/etc/nginx/conf.d/default.conf + ports: + - "443:443" + - "80:80" +``` + +### 4.3 監控和日誌 + +1. **集成監控 (可選)** +```yaml +# 添加到 docker-compose.yml + prometheus: + image: prom/prometheus + ports: + - "9090:9090" + volumes: + - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml + + grafana: + image: grafana/grafana + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin +``` + +2. **集中化日誌收集** +```yaml + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:7.14.0 + environment: + - discovery.type=single-node + - "ES_JAVA_OPTS=-Xms512m -Xmx512m" + + kibana: + image: docker.elastic.co/kibana/kibana:7.14.0 + ports: + - "5601:5601" +``` + +### 4.4 備份策略 + +1. **資料庫備份腳本** +```bash +#!/bin/bash +# backup-db.sh + +DATE=$(date +%Y%m%d_%H%M%S) +BACKUP_DIR="/backup/mysql" +CONTAINER_NAME="tempspec-mysql" + +mkdir -p $BACKUP_DIR + +# 備份資料庫 +docker exec $CONTAINER_NAME mysqldump \ + -u root -p$MYSQL_ROOT_PASSWORD \ + tempspec_db > $BACKUP_DIR/tempspec_$DATE.sql + +# 壓縮備份檔案 +gzip $BACKUP_DIR/tempspec_$DATE.sql + +# 刪除 30 天前的備份 +find $BACKUP_DIR -name "*.sql.gz" -mtime +30 -delete + +echo "Database backup completed: tempspec_$DATE.sql.gz" +``` + +2. **設定 cron 自動備份** +```bash +# 編輯 crontab +crontab -e + +# 每天凌晨 2:30 執行備份 +30 2 * * * /path/to/backup-db.sh >> /var/log/tempspec-backup.log 2>&1 +``` + +## 5. 疑難排解 + +### 5.1 常見問題 + +#### 問題:Docker 容器無法啟動 + +**可能原因**: +- 端口被占用 +- 環境變數設定錯誤 +- 磁碟空間不足 + +**解決方案**: +```bash +# 檢查端口占用 +netstat -tulpn | grep :5000 + +# 檢查容器日誌 +docker-compose logs app + +# 檢查磁碟空間 +df -h + +# 清理 Docker 資源 +docker system prune -a +``` + +#### 問題:LDAP 連線失敗 + +**檢查清單**: +- [ ] LDAP 伺服器地址正確 +- [ ] 防火牆開放 389/636 端口 +- [ ] 服務帳號權限足夠 +- [ ] 搜尋基底設定正確 + +**測試 LDAP 連線**: +```bash +# 使用 ldapsearch 測試 +ldapsearch -H ldap://your-dc.company.com -D "CN=service,DC=company,DC=com" -W -b "DC=company,DC=com" + +# 或在 Python 中測試 +python3 -c " +from ldap_utils import authenticate_ldap_user +result = authenticate_ldap_user('testuser', 'testpass') +print('LDAP Test Result:', result) +" +``` + +#### 問題:ONLYOFFICE 編輯器載入失敗 + +**檢查項目**: +- [ ] Document Server 容器運行正常 +- [ ] JWT Secret 設定一致 +- [ ] 網路連線可達 +- [ ] 瀏覽器支援 WebSocket + +**測試方法**: +```bash +# 檢查 ONLYOFFICE 健康狀態 +curl http://localhost:8080/healthcheck + +# 檢查容器狀態 +docker logs tempspec-onlyoffice + +# 測試 JWT 設定 +docker exec tempspec-onlyoffice cat /etc/onlyoffice/documentserver/default.json | grep -i jwt +``` + +#### 問題:排程任務未執行 + +**檢查步驟**: +1. 確認 APScheduler 已初始化 +2. 檢查應用程式日誌 +3. 驗證任務註冊 + +```python +# 在 Flask shell 中檢查任務 +from app import scheduler +print(scheduler.get_jobs()) +``` + +### 5.2 效能調校 + +#### 資料庫最佳化 + +```sql +-- 檢查慢查詢 +SHOW VARIABLES LIKE 'slow_query_log'; +SHOW VARIABLES LIKE 'long_query_time'; + +-- 分析資料表 +ANALYZE TABLE ts_temp_spec; +ANALYZE TABLE ts_user; +ANALYZE TABLE ts_upload; + +-- 建立必要索引 +CREATE INDEX idx_spec_status ON ts_temp_spec(status); +CREATE INDEX idx_spec_end_date ON ts_temp_spec(end_date); +CREATE INDEX idx_history_spec_id ON ts_spec_history(spec_id); +``` + +#### 應用程式最佳化 + +```python +# config.py 調整 +class ProductionConfig(Config): + # 資料庫連線池設定 + SQLALCHEMY_ENGINE_OPTIONS = { + 'pool_size': 20, + 'pool_recycle': 300, + 'pool_pre_ping': True + } + + # Redis 快取 (可選) + CACHE_TYPE = 'redis' + CACHE_REDIS_URL = 'redis://redis:6379/0' +``` + +### 5.3 監控指標 + +重要的監控指標: + +- **應用程式指標** + - 回應時間 + - 錯誤率 + - 記憶體使用率 + - CPU 使用率 + +- **資料庫指標** + - 連線數 + - 查詢執行時間 + - 緩衝池命中率 + +- **系統指標** + - 磁碟使用率 + - 網路流量 + - 檔案描述符使用率 + +```bash +# 系統監控腳本範例 +#!/bin/bash +# monitor.sh + +echo "=== System Status ===" +echo "CPU Usage: $(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | cut -d'%' -f1)" +echo "Memory Usage: $(free | grep Mem | awk '{printf("%.1f%%", $3/$2 * 100.0)}')" +echo "Disk Usage: $(df -h | grep '/$' | awk '{print $5}')" + +echo "=== Docker Status ===" +docker stats --no-stream --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}" + +echo "=== Application Status ===" +curl -s http://localhost:5000/health | jq . +``` + +--- + +本部署指南涵蓋了大部分常見的部署場景。如果遇到特殊情況或需要客製化部署,請參考系統文檔或聯繫技術支援團隊。 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d501ec6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# 使用官方 Python 3.10 運行時作為基礎映像 +FROM python:3.10-slim + +# 設定環境變數 +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + DEBIAN_FRONTEND=noninteractive + +# 設定工作目錄 +WORKDIR /app + +# 更新系統套件並安裝必要的系統依賴 +RUN apt-get update && apt-get install -y \ + gcc \ + default-libmysqlclient-dev \ + pkg-config \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# 複製依賴檔案 +COPY requirements.txt . + +# 安裝 Python 依賴 +RUN pip install --no-cache-dir -r requirements.txt + +# 複製應用程式代碼 +COPY . . + +# 建立必要的目錄 +RUN mkdir -p uploads static/generated logs + +# 設定權限 +RUN chmod +x app.py + +# 暴露端口 +EXPOSE 5000 + +# 健康檢查 +HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:5000/ || exit 1 + +# 啟動命令 +CMD ["python", "app.py"] \ No newline at end of file diff --git a/README.md b/README.md index e9ad889..b0ea826 100644 --- a/README.md +++ b/README.md @@ -1,133 +1,513 @@ -# TEMP Spec System - 暫時規範管理系統 (ONLYOFFICE-Edition) +# 暫時規範管理系統 V3 -這是一個使用 Flask 開發的 Web 應用程式,旨在管理、追蹤和存檔暫時性的工程規範。此版本已整合 ONLYOFFICE Document Server,提供強大的所見即所得(WYSIWYG)線上文件編輯體驗。 +企業級暫時規範生命週期管理系統,提供完整的文件管理、LDAP整合驗證、智慧通知系統及排程提醒功能。 -## 核心功能 +## 🚀 系統特色 -- **使用者權限管理**: 內建三種角色 (`viewer`, `editor`, `admin`),各角色擁有不同操作權限。 -- **規範生命週期**: 支援暫時規範的建立、啟用、展延、終止與刪除。 -- **線上文件編輯**: 整合 ONLYOFFICE Document Server,支援多人協作與專業級的線上 Word 文件編輯。 -- **範本自動填入**: 建立規範時,可將表單資料自動填入 Word 範本中。 -- **檔案管理**: 支援上傳簽核後的文件,並與對應的規範進行關聯。 -- **歷史紀錄**: 詳細記錄每一份規範的所有變更歷史,方便追蹤與稽核。 +- **LDAP/AD 整合驗證**:支援企業Active Directory單一登入 +- **ONLYOFFICE 線上編輯**:即時協作文件編輯功能 +- **智慧通知系統**:動態收件人選擇與自動提醒 +- **文件生命週期管理**:完整的建立、啟用、展延、終止流程 +- **多平台支援**:支援 Windows/Linux 環境部署 +- **Docker 容器化**:一鍵部署環境 ---- +## 📋 功能模組 -## 環境要求 +### 核心功能 +- **文件管理**:Word範本自動化生成與PDF轉換 +- **權限控制**:三級權限管理 (Viewer/Editor/Admin) +- **歷史追蹤**:完整的操作記錄與版本控制 +- **檔案上傳**:支援多種格式的佐證文件上傳 -在部署此應用程式之前,請確保您的系統已安裝以下軟體: +### 智慧通知系統 +- **動態收件人選擇**:整合LDAP的即時用戶搜尋 +- **全流程通知**:啟用、展延、終止操作的自動郵件通知 +- **自動提醒**:3天與7天到期前的主動提醒郵件 +- **排程系統**:每日自動檢查即將到期的規範 -1. **Python**: 建議使用 `Python 3.10` 或更高版本。 -2. **MySQL**: 需要一個 MySQL 資料庫來儲存所有應用程式資料。 -3. **Docker**: **[重要]** 本專案依賴 ONLYOFFICE Document Server,推薦使用 Docker 進行部署和管理。請確保您的伺服器已安裝並運行 Docker。 -4. **Git**: 用於從版本控制系統下載程式碼。 +### 編輯器整合 +- **ONLYOFFICE整合**:支援Word文件的線上即時編輯 +- **Toast UI Editor**:Markdown格式的內容編輯器 +- **圖片支援**:內嵌圖片顯示與編輯功能 ---- +## 🏗️ 系統架構 -## 安裝與設定步驟 - -請依照以下步驟來設定您的開發或生產環境: - -### 1. 下載程式碼 - -```bash -git clone -cd TEMP_spec_system_V2 +``` +暫時規範系統 V3 +├── 前端介面 (Flask + Bootstrap 5) +├── 後端邏輯 (Python Flask) +├── 資料庫 (MySQL/SQLite) +├── LDAP整合 (Active Directory) +├── 文件引擎 (ONLYOFFICE) +├── 排程服務 (APScheduler) +└── 郵件系統 (SMTP) ``` -### 2. 建立並啟用虛擬環境 +## 🛠️ 技術棧 +- **後端框架**:Python Flask 3.x +- **資料庫ORM**:SQLAlchemy +- **前端UI**:Bootstrap 5 + Tom Select +- **文件處理**:python-docx, docx2pdf +- **認證系統**:Flask-Login + LDAP3 +- **排程系統**:Flask-APScheduler +- **容器化**:Docker + Docker Compose + +## 📦 安裝部署 + +### 前置需求 + +- Python 3.8+ +- MySQL 8.0+ 或 SQLite +- ONLYOFFICE Document Server +- LDAP/Active Directory 伺服器 +- SMTP 郵件伺服器 + +### 快速開始 (Docker) + +1. **克隆專案** ```bash -# Windows -python -m venv .venv -.\.venv\Scripts\activate - -# macOS / Linux -python3 -m venv .venv -source .venv/bin/activate +git clone +cd TEMP_spec_system_V3 ``` -### 3. 安裝相依套件 +2. **設定環境變數** +```bash +cp .env.example .env +# 編輯 .env 檔案設定資料庫、LDAP、SMTP 等參數 +``` +3. **使用Docker Compose啟動** +```bash +docker-compose up -d +``` + +4. **初始化資料庫** +```bash +docker-compose exec app python init_db.py +``` + +### 手動安裝 + +#### Windows 環境 + +1. **安裝Python依賴** +```cmd +pip install -r requirements.txt +``` + +2. **設定環境變數** +```cmd +copy .env.example .env +REM 編輯 .env 檔案 +``` + +3. **初始化資料庫** +```cmd +python init_db.py +``` + +4. **啟動 ONLYOFFICE Document Server** +```cmd +docker run -d -p 8080:80 --restart=always ^ + -e JWT_ENABLED=true ^ + -e JWT_SECRET=your-onlyoffice-jwt-secret-string ^ + onlyoffice/documentserver +``` + +5. **啟動應用程式** +```cmd +REM 開發環境 +python app.py + +REM 生產環境 (Windows 建議使用 Waitress) +pip install waitress +waitress-serve --host=0.0.0.0 --port=5000 app:app +``` + +#### Linux 環境 + +1. **安裝Python依賴** ```bash pip install -r requirements.txt ``` -### 4. 設定環境變數 - -複製範例檔案,並填入您的實際設定: - +2. **設定環境變數** ```bash -# Windows -copy .env.example .env - -# macOS / Linux cp .env.example .env +# 編輯 .env 檔案 ``` -編輯 `.env` 檔案,確保包含以下所有欄位: - -```dotenv -# Flask 應用程式的密鑰,用於保護 session -SECRET_KEY="your-super-secret-and-random-string" - -# 資料庫連線 URL -DATABASE_URL="mysql+pymysql://user:password@host:port/dbname" - -# --- ONLYOFFICE 設定 --- -# 您 ONLYOFFICE Document Server 的公開存取位址 -ONLYOFFICE_URL="http://localhost:8080/" - -# 用於保護 ONLYOFFICE 通訊的 JWT 密鑰 (請務必修改為一個新的隨機長字串) -ONLYOFFICE_JWT_SECRET="your-onlyoffice-jwt-secret-string" -``` - -**注意**: 請先在您的 MySQL 中手動建立一個資料庫。 - -### 5. 啟動 ONLYOFFICE Document Server - -使用 Docker 啟動 ONLYOFFICE Document Server,並啟用 JWT 驗證。 - -```bash -docker run -i -t -d -p 8080:80 --restart=always \ - -e JWT_ENABLED=true \ - -e JWT_SECRET="your-onlyoffice-jwt-secret-string" \ - onlyoffice/documentserver -``` - -**[重要]**:指令中的 `JWT_SECRET` 值,必須和您在 `.env` 檔案中設定的 `ONLYOFFICE_JWT_SECRET` **完全一致**。 - -### 6. 初始化資料庫 - -執行初始化腳本來建立所有需要的資料表,並產生一個預設的管理員帳號。 - +3. **初始化資料庫** ```bash python init_db.py ``` -腳本會提示您確認操作。輸入 `yes` 後,它會建立資料表並在終端機中顯示預設 `admin` 帳號的隨機密碼。**請務必記下此密碼**。 +4. **啟動 ONLYOFFICE Document Server** +```bash +docker run -d -p 8080:80 --restart=always \ + -e JWT_ENABLED=true \ + -e JWT_SECRET=your-onlyoffice-jwt-secret-string \ + onlyoffice/documentserver +``` + +5. **啟動應用程式** +```bash +# 開發環境 +python app.py + +# 生產環境 (使用 Gunicorn) +pip install gunicorn +gunicorn -w 4 -b 0.0.0.0:5000 app:app +``` + +### 生產環境部署 + +#### 使用 Nginx + Gunicorn (Linux) + +1. **安裝 Gunicorn** +```bash +pip install gunicorn +``` + +2. **建立 Gunicorn 服務檔案** +```bash +sudo nano /etc/systemd/system/tempspec.service +``` + +內容: +```ini +[Unit] +Description=Temp Spec System +After=network.target + +[Service] +User=www-data +Group=www-data +WorkingDirectory=/path/to/your/app +Environment="PATH=/path/to/your/app/venv/bin" +ExecStart=/path/to/your/app/venv/bin/gunicorn -w 4 -b 127.0.0.1:5000 app:app +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +3. **Nginx 設定** +```nginx +server { + listen 80; + server_name your-domain.com; + + location / { + proxy_pass http://127.0.0.1:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +4. **啟動服務** +```bash +sudo systemctl enable tempspec +sudo systemctl start tempspec +``` + +#### Windows IIS 部署 + +1. **安裝 IIS 與 Python** +2. **安裝 HttpPlatformHandler** +3. **設定 Web.config** +```xml + + + + + + + + + +``` + +## ⚙️ 組態設定 + +### 環境變數 (.env) + +```env +# Flask 設定 +SECRET_KEY=your_secret_key_here +UPLOAD_FOLDER=uploads + +# 資料庫設定 +DATABASE_URL=mysql+pymysql://user:password@localhost/tempspec_db + +# LDAP 設定 +LDAP_SERVER=ldap://your-dc.company.com +LDAP_PORT=389 +LDAP_USE_SSL=False +LDAP_SEARCH_BASE=DC=company,DC=com +LDAP_BIND_USER_DN=CN=service,DC=company,DC=com +LDAP_BIND_USER_PASSWORD=service_password +LDAP_USER_LOGIN_ATTR=userPrincipalName + +# SMTP 郵件設定 +SMTP_SERVER=smtp.company.com +SMTP_PORT=587 +SMTP_USE_TLS=True +SMTP_SENDER_EMAIL=noreply@company.com +SMTP_SENDER_PASSWORD=smtp_password + +# ONLYOFFICE 設定 +ONLYOFFICE_URL=http://onlyoffice:8080 +ONLYOFFICE_JWT_SECRET=your_jwt_secret +``` + +### 特殊注意事項 + +#### Windows 環境差異 + +1. **排程服務限制** + - APScheduler 在 Windows 上運行正常 + - 若使用 Celery,需要額外設定: + ```bash + # Windows 環境需要使用 eventlet 或 solo 執行器 + celery -A app.celery worker --loglevel=info --pool=solo + ``` + +2. **路徑設定** + ```python + # Windows 環境請使用絕對路徑或適當的路徑分隔符 + UPLOAD_FOLDER = r'C:\path\to\uploads' + ``` + +3. **服務安裝** + ```bash + # 使用 NSSM 將 Python 應用程式安裝為 Windows 服務 + nssm install TempSpecSystem python.exe app.py + ``` + +#### Linux 環境最佳化 + +1. **系統服務設定** + ```bash + # 設定系統服務自動啟動 + sudo systemctl enable tempspec.service + ``` + +2. **日誌管理** + ```bash + # 使用 logrotate 管理日誌檔案 + sudo nano /etc/logrotate.d/tempspec + ``` + +3. **效能調校** + ```bash + # Gunicorn 推薦設定 + gunicorn -w 4 -k gevent --worker-connections 1000 -b 0.0.0.0:5000 app:app + ``` + +## 🔐 安全性設定 + +### LDAP 整合 +- 支援 SSL/TLS 加密連線 +- 服務帳號權限最小化原則 +- 自動用戶同步與權限管控 + +### 資料保護 +- JWT Token 驗證 +- 檔案存取權限控制 +- SQL Injection 防護 +- XSS 攻擊防護 + +## 📚 使用說明 + +### 登入規範 + +**重要**:系統要求使用完整的UPN格式帳號登入 + +✅ **正確格式**:`user@domain.com` +❌ **錯誤格式**:`user` + +### 初次設定管理員 + +系統預設所有使用者為 `viewer` 權限。設定管理員的方式: + +1. **程式設定**:修改 `routes/auth.py` 中的預設管理員帳號 + +2. **手動設定**:在資料庫中更新用戶權限: +```sql +UPDATE ts_user SET role='admin' WHERE username='user@domain.com'; +``` + +3. **程式設定**:修改 `routes/auth.py` 中的管理員帳號列表: +```python +# 將特定用戶設為管理員 +if user_info['username'].lower() == 'your_admin@domain.com': + default_role = 'admin' +``` + +### 排程任務說明 + +系統預設每天凌晨 2:00 執行到期檢查任務,可在 `app.py` 中調整: + +```python +@scheduler.task('cron', id='check_expiring_specs_job', hour=2, minute=0) +def scheduled_job(): + check_expiring_specs(app) +``` + +### 自訂提醒天數 + +在 `tasks.py` 中修改提醒時程: + +```python +seven_days_later = today + timedelta(days=7) # 7天前提醒 +three_days_later = today + timedelta(days=3) # 3天前提醒 +``` + +### 預設收件人群組設定 + +在 `tasks.py` 中設定自動提醒的收件人: + +```python +# 修改為實際的 AD 群組名稱 +default_recipients = get_ldap_group_members('TempSpec_Admins') +``` + +## 🐛 疑難排解 + +### 常見問題 + +1. **LDAP 連線失敗** + - 檢查防火牆設定 (通常是 389/636 port) + - 確認服務帳號權限 + - 驗證 LDAP 伺服器位址和搜尋基底 + +2. **ONLYOFFICE 無法載入** + - 確認 Document Server 運行狀態:`docker ps` + - 檢查網路連線設定 + - 驗證 JWT Secret 設定是否一致 + +3. **郵件發送失敗** + - 確認 SMTP 設定正確 + - 檢查郵件伺服器認證 + - 驗證防火牆規則 (通常是 25/587/465 port) + +4. **排程任務未執行** + - 檢查 APScheduler 初始化 + - 確認應用程式持續運行 + - 查看系統日誌 + +5. **檔案上傳失敗** + - 檢查上傳目錄權限 + - 確認檔案大小限制設定 + - 驗證磁碟空間是否足夠 + +### 日誌查看 + +```bash +# Docker 環境 +docker-compose logs -f app + +# 一般環境 +tail -f logs/app.log + +# Windows 環境 +Get-Content logs/app.log -Tail 10 -Wait +``` + +### 效能監控 + +```bash +# 監控資源使用 +htop +docker stats + +# 檢查資料庫效能 +SHOW PROCESSLIST; +SHOW ENGINE INNODB STATUS; +``` + +## 🤝 開發指南 + +### 開發環境設定 + +1. **虛擬環境建立** +```bash +python -m venv venv +source venv/bin/activate # Linux +venv\Scripts\activate # Windows +``` + +2. **安裝開發依賴** +```bash +pip install -r requirements.txt +pip install -r requirements-dev.txt # 如果有開發專用依賴 +``` + +3. **資料庫遷移** +```bash +python init_db.py +``` + +### 程式碼結構 + +``` +├── app.py # 主應用程式 +├── config.py # 組態設定 +├── models.py # 資料模型 +├── tasks.py # 排程任務 +├── routes/ # 路由模組 +│ ├── auth.py # 認證相關 +│ ├── temp_spec.py # 暫規管理 +│ ├── upload.py # 檔案上傳 +│ └── api.py # API介面 +├── templates/ # 前端範本 +├── static/ # 靜態檔案 +├── utils.py # 工具函式 +└── ldap_utils.py # LDAP 工具 +``` + +### 新增功能開發 + +1. **建立新的路由模組** +2. **新增對應的資料模型** +3. **建立前端範本** +4. **撰寫單元測試** + +## 📄 授權條款 + +本專案採用 MIT 授權條款,詳見 [LICENSE](LICENSE) 檔案。 + +## 🆕 版本歷程 + +### v3.0.0 (2024-01-XX) +- 🆕 新增 LDAP/AD 整合驗證 +- 🆕 整合 ONLYOFFICE 線上編輯器 +- 🆕 實作智慧通知系統 +- 🆕 新增自動排程提醒功能 +- 🆕 支援 Docker 容器化部署 +- ♻️ 重構權限管理系統 +- 🗑️ 移除本地帳號管理功能 + +### v2.x.x +- Toast UI Editor 整合 +- 基本文件管理功能 +- 本地帳號系統 + +## 📞 技術支援 + +如有問題或建議,請透過以下方式聯繫: + +- 📧 Email: support@company.com +- 📋 Issue Tracker: GitHub Issues +- 📖 文件wiki: GitHub Wiki --- -## 執行應用程式 - -### 開發模式 - -**請確保您的 ONLYOFFICE Docker 容器已在運行中**,然後在另一個終端機視窗執行 `app.py`: - -```bash -python app.py -``` - -應用程式預設會在 `http://127.0.0.1:5000` 上執行。 - -### 生產環境 - -在生產環境中,建議使用生產級的 WSGI 伺服器,例如 `Gunicorn` (Linux) 或 `Waitress` (Windows)。 - -**使用 Waitress (Windows) 的範例:** - -```bash -pip install waitress -waitress-serve --host=0.0.0.0 --port=8000 app:app -``` \ No newline at end of file +**暫時規範管理系統 V3** - 讓企業文件管理更智慧、更高效! \ No newline at end of file diff --git a/USER_MANUAL.md b/USER_MANUAL.md index d9a6153..830d7dd 100644 --- a/USER_MANUAL.md +++ b/USER_MANUAL.md @@ -1,105 +1,364 @@ -# 系統操作說明書 (User Manual) +# 暫時規範管理系統 V3 操作說明書 -歡迎使用新版「暫時規範管理系統」。本說明書將引導您如何操作整合了 ONLYOFFICE 線上編輯器的新系統。 +歡迎使用企業級暫時規範管理系統 V3。本系統整合了LDAP認證、ONLYOFFICE線上編輯器及智慧通知系統,提供完整的文件生命週期管理解決方案。 + +## 📋 目錄 + +1. [系統簡介](#1-系統簡介) +2. [登入與主畫面](#2-登入與主畫面) +3. [核心操作流程](#3-核心操作流程) +4. [智慧通知系統](#4-智慧通知系統) +5. [進階功能](#5-進階功能) +6. [角色權限說明](#6-角色權限說明) +7. [常見問題](#7-常見問題) + +--- ## 1. 系統簡介 -本系統旨在提供一個集中化平台,用於管理、追蹤和存檔所有暫時性的工程規範。它涵蓋了從草擬、線上編輯、簽核、生效到最終歸檔的完整生命週期。 +暫時規範管理系統 V3 是一個集中化平台,用於管理、追蹤和存檔所有暫時性的工程規範。它涵蓋了從草擬、線上編輯、簽核、生效到最終歸檔的完整生命週期。 + +### 🚀 V3 版本新特色 + +- **LDAP/AD 整合**:使用企業Active Directory帳號登入 +- **智慧通知系統**:動態收件人選擇與自動提醒 +- **自動排程提醒**:系統主動發送到期提醒郵件 +- **增強的編輯體驗**:ONLYOFFICE文件協作編輯 --- ## 2. 登入與主畫面 -### 2.1 登入 +### 2.1 LDAP 登入 -請使用管理員提供的帳號和密碼,在首頁進行登入。 +🆕 **V3 新功能**:系統現在使用企業 Active Directory 進行單一登入。 -### 2.2 主畫面 (暫時規範總表) +**🚨 重要登入規範**: -登入後的主畫面會列出所有暫時規範,功能包含: +✅ **正確格式**:必須使用完整的 UPN 格式帳號 +例如:`user@domain.com` -- **建立新規範按鈕**: (僅 Editor/Admin 可見) 點擊此處開始建立一份新的規範。 -- **搜尋與篩選區**: 可根據「編號」、「主題」或「狀態」快速找到目標。 -- **規範列表**: 顯示每份規範的關鍵資訊。 -- **操作按鈕**: 根據您的「角色」和規範的「狀態」提供不同的操作選項。 +❌ **錯誤格式**:不支援縮略帳號 +例如:`user` + +**登入步驟**: +1. 在登入頁面輸入您的 **完整 AD 帳號**(例如:user@domain.com) +2. 輸入您的 **AD 密碼** +3. 點擊「**登入**」按鈕 + +> **注意**: +> - 首次登入的用戶預設為 `Viewer` 權限 +> - 如需提升權限請聯繫系統管理員 + +### 2.2 主畫面功能 + +登入後進入暫時規範總表,包含以下區域: + +- **🔍 搜尋與篩選區**:根據編號、主題或狀態快速查找 +- **📊 狀態統計**:顯示各狀態規範的數量概覽 +- **📋 規範列表**:詳細顯示所有規範資訊 +- **⚡ 快速操作**:根據權限和規範狀態提供操作按鈕 + +### 2.3 狀態指示說明 + +| 狀態 | 圖示 | 說明 | +|------|------|------| +| 待生效 | 🟡 | 草稿完成,待管理員啟用 | +| 已生效 | 🟢 | 正在生效中的規範 | +| 已過期 | 🔴 | 已自動過期的規範 | +| 已終止 | ⚫ | 提早終止的規範 | --- ## 3. 核心操作流程 -### 3.1 流程總覽 +### 3.1 完整工作流程 -新版系統的標準工作流程如下: - -1. **Editor** 填寫初始資料表單,建立一份新的暫時規範草稿。 -2. 系統將初始資料**自動填入** Word 範本,並在瀏覽器中開啟 **ONLYOFFICE 線上編輯器**。 -3. **Editor** 在線上完成文件的詳細內容撰寫,文件會自動儲存。 -4. 完成後,可下載最終的 Word 文件 (`.docx`),進行線下簽核流程。 -5. 簽核完成後,將文件儲存為 **PDF (`.pdf`) 格式**。 -6. **Admin** 登入系統,上傳簽核完成的 PDF 檔案,正式**啟用**該規範。 +```mermaid +graph TD + A[Editor建立草稿] --> B[ONLYOFFICE線上編輯] + B --> C[下載Word文件] + C --> D[線下簽核流程] + D --> E[Admin上傳PDF啟用] + E --> F[🆕選擇通知對象] + F --> G[規範正式生效] + G --> H[🆕自動到期提醒] +``` ### 3.2 建立新的暫時規範 (Editor / Admin) -1. 在主畫面點擊 **[+ 建立新規範]** 按鈕。 -2. 在「建立新的暫時規範」頁面,填寫所有初始資料,如主題、站別、Package 等。 -3. 點擊 **[建立並開始編輯]** 按鈕。 -4. 系統將會自動重新導向至 ONLYOFFICE 編輯器頁面,您會看到一個已預先填好初始資料的 Word 文件。 -5. 在線上編輯器中完成所有內容的修改與撰寫。您的所有變更都會**自動儲存**。完成後可直接關閉分頁或返回總表。 +1. **建立草稿** + - 點擊 **[+ 暫時規範建立]** 按鈕 + - 填寫基本資料:主題、申請人、站別等 + - 選擇適用的 TCCS 等級和 4M 類別 + - 點擊 **[建立並開始編輯]** + +2. **線上編輯** + - 系統自動開啟 ONLYOFFICE 編輯器 + - 文件已預填初始資料 + - 支援多人協作編輯 + - 所有變更自動儲存 + +3. **完成草稿** + - 編輯完成後可直接關閉編輯器 + - 或點擊 **[完成編輯]** 返回總表 ### 3.3 啟用暫時規範 (僅限 Admin) -當一份規範的線下簽核流程完成後,管理員需執行以下操作使其生效: +🆕 **新增智慧通知功能** -1. 在總表中找到狀態為「**待生效**」的目標規範。 -2. 點擊該規範右側的 **啟用圖示 (✅)**。 -3. 在「啟用暫時規範」頁面,點擊 **[選擇檔案]**,並上傳**已簽核完成的 PDF 檔案**。 -4. 點擊 **[啟用規範]** 按鈕。 -5. 完成後,該規範的狀態會變為「**已生效**」。 +1. **開始啟用流程** + - 找到狀態為「待生效」的規範 + - 點擊 **啟用圖示 (✅)** -### 3.4 管理已生效的規範 (Editor / Admin) +2. **上傳簽核文件** + - 上傳已簽核完成的 PDF 檔案 + - 系統自動驗證檔案格式 -對於「**已生效**」的規範,您可以進行展延或提早終止: +3. **🆕 選擇通知對象** + - 在「郵件通知對象」欄位輸入姓名或Email + - 系統即時搜尋 LDAP 用戶 + - 支援多人選擇 + - 可選擇不發送通知 -- **展延**: - 1. 點擊 **展延圖示 (📅+**)。 - 2. 選擇新的結束日期,並**必須上傳新的佐證文件 (PDF 格式)**。 - 3. 點擊儲存,規範的效期將會延長。 -- **終止**: - 1. 點擊 **終止圖示 (❌)**。 - 2. 填寫提早終止的原因。 - 3. 提交後,規範狀態將變為「**已終止**」。 +4. **確認啟用** + - 點擊 **[上傳並啟用]** + - 系統發送啟用通知郵件 + - 規範狀態變更為「已生效」 -### 3.5 搜尋、篩選與下載 +### 3.4 展延暫時規範 (Editor / Admin) -- **搜尋/篩選**: 在主畫面的搜尋框和下拉選單中操作。 -- **下載**: - - **待生效規範**: - - Editor 和 Admin 可下載仍在編輯中的 **Word** 原始檔。 - - **已生效/已終止/已過期規範**: - - 所有角色都可以下載由 Admin 上傳的**最終簽核版 PDF**。 +🆕 **新增智慧通知功能** -### 3.6 檢視歷史紀錄 +1. **開始展延** + - 點擊 **展延圖示 (📅+)** + - 選擇新的結束日期 -點擊規範最右側的 **歷史紀錄圖示 (🕒)**,可查看該規範的所有變更紀錄。 +2. **上傳佐證文件** + - 必須上傳新的佐證文件 (PDF格式) + +3. **🆕 通知對象選擇** + - 選擇需要通知的相關人員 + - 使用動態搜尋功能選擇收件人 + +4. **確認展延** + - 點擊 **[確認展延]** + - 系統發送展延通知郵件 + +### 3.5 終止暫時規範 (Editor / Admin) + +🆕 **新增智慧通知功能** + +1. **開始終止** + - 點擊 **終止圖示 (❌)** + - 填寫提早結束原因 + +2. **🆕 通知設定** + - 選擇需要通知終止訊息的人員 + +3. **確認終止** + - 點擊 **[確認終止]** + - 系統發送終止通知郵件 + - 規範狀態變為「已終止」 --- -## 4. 使用者管理 (僅限 Admin) +## 4. 智慧通知系統 -管理員可點擊導覽列的 **[帳號管理]** 來新增、編輯或刪除使用者帳號。 +🆕 **V3 全新功能** + +### 4.1 動態收件人選擇 + +所有主要操作(啟用、展延、終止)都支援智慧通知: + +- **即時搜尋**:輸入姓名或Email即時搜尋AD用戶 +- **多人選擇**:支援選擇多位收件人 +- **自動完成**:顯示完整姓名和Email資訊 +- **移除功能**:可隨時移除已選擇的收件人 + +### 4.2 通知郵件內容 + +系統會根據操作類型自動發送相應的通知郵件: + +- **啟用通知**:包含規範編號、主題、生效/結束日期 +- **展延通知**:包含新的結束日期和展延原因 +- **終止通知**:包含終止原因和終止日期 + +### 4.3 🆕 自動到期提醒 + +系統每天自動檢查即將到期的規範: + +- **7天前提醒**:首次到期預警 +- **3天前提醒**:最終到期提醒 +- **自動發送**:無需人工干預 +- **群組通知**:發送給預設管理群組 + +> **管理員設定**:可在 `tasks.py` 中修改提醒天數和收件人群組 --- -## 5. 常見問題 (FAQ) +## 5. 進階功能 -**Q: 為什麼我看不到「建立規範」或「啟用」的按鈕?** -A: 您的帳號權限不足。建立規範需要 `Editor` 或 `Admin` 權限;啟用規範僅限 `Admin`。如有需要,請聯繫系統管理員。 +### 5.1 搜尋與篩選 -**Q: 我忘記密碼了怎麼辦?** -A: 請聯繫系統管理員,請他/她為您重設密碼。 +**搜尋功能**: +- 支援規範編號模糊搜尋 +- 支援主題關鍵字搜尋 +- 即時搜尋結果更新 -**Q: 為什麼編輯器頁面顯示 "Download failed" 或無法儲存?** -A: 這通常是開發環境的網路設定問題。請確認您主機的防火牆是否允許來自 Docker 的連線,或聯繫系統管理員檢查網路設定。 +**篩選功能**: +- 按狀態篩選(待生效/已生效/已過期/已終止) +- 多條件組合篩選 +- 篩選結果分頁顯示 -**Q: 我可以上傳 Word 檔案來啟用規範嗎?** -A: 不行。為了確保文件的最終性和不可修改性,系統規定必須上傳已簽核的 **PDF 檔案**來啟用規範。 \ No newline at end of file +### 5.2 文件下載 + +**Word文件下載**: +- Editor/Admin 可下載編輯中的Word原始檔 +- 適用於線下簽核流程 + +**PDF文件下載**: +- 所有用戶都可下載最終簽核版PDF +- 適用於已生效/已終止的規範 + +### 5.3 歷史紀錄追蹤 + +點擊 **歷史紀錄圖示 (🕒)** 查看: + +- 操作時間戳記 +- 執行用戶 +- 操作類型(建立/啟用/展延/終止) +- 詳細說明 + +### 5.4 🆕 即將到期警示 + +在規範列表中會特別標示即將到期的規範: + +- **🟡 橙色標示**:7天內到期 +- **🔴 紅色標示**:3天內到期 +- **閃爍動畫**:今日到期 + +--- + +## 6. 角色權限說明 + +### 6.1 權限等級 + +| 角色 | 登入 | 檢視 | 建立 | 編輯 | 啟用 | 展延/終止 | 刪除 | +|------|------|------|------|------|------|-----------|------| +| **Viewer** | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| **Editor** | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | +| **Admin** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | + +### 6.2 🆕 LDAP 權限管理 + +- **自動用戶創建**:AD用戶首次登入自動建立帳號 +- **預設權限**:新用戶預設為 `Viewer` 權限 +- **權限提升**:需要管理員手動調整資料庫或修改程式碼 + +**管理員手動提升權限**: +```sql +-- 使用完整UPN格式帳號 +UPDATE ts_user SET role='admin' WHERE username='user@domain.com'; +``` + +--- + +## 7. 常見問題 + +### 7.1 登入相關 + +**Q: 無法使用AD帳號登入?** +A: 請確認: +1. **必須使用完整UPN格式**:例如 user@domain.com(不支援縮略帳號如 user) +2. 密碼正確 +3. 帳號未被鎖定 +4. 聯繫IT部門確認LDAP連線狀態 + +**Q: 忘記密碼怎麼辦?** +A: 系統使用AD驗證,請透過企業標準流程重設AD密碼。 + +### 7.2 權限相關 + +**Q: 為什麼我看不到建立規範按鈕?** +A: 您的權限可能為 `Viewer`,請聯繫管理員提升權限至 `Editor`。 + +**Q: 為什麼我無法啟用規範?** +A: 啟用功能僅限 `Admin` 權限,請聯繫系統管理員操作。 + +### 7.3 編輯器相關 + +**Q: ONLYOFFICE編輯器無法載入?** +A: 請檢查: +1. 瀏覽器是否允許彈出視窗 +2. 網路連線是否正常 +3. 是否安裝最新版瀏覽器 +4. 聯繫IT確認Document Server狀態 + +**Q: 編輯內容未儲存?** +A: ONLYOFFICE有自動儲存功能,但請確認: +1. 編輯期間保持網路連線 +2. 避免同時多人編輯同一文件 +3. 定期手動儲存 (Ctrl+S) + +### 7.4 🆕 通知相關 + +**Q: 搜尋不到AD用戶?** +A: 請確認: +1. 輸入至少2個字元才開始搜尋 +2. 用戶在AD中確實存在 +3. 服務帳號有足夠權限搜尋AD + +**Q: 沒有收到通知郵件?** +A: 請檢查: +1. Email地址是否正確 +2. 垃圾郵件資料夾 +3. 公司郵件伺服器設定 +4. 聯繫IT確認SMTP設定 + +**Q: 自動提醒郵件何時發送?** +A: 系統每天凌晨2:00自動檢查並發送提醒,分別在到期前7天和3天發送。 + +### 7.5 檔案相關 + +**Q: 可以上傳Word檔案來啟用規範嗎?** +A: 不可以。為確保文件完整性,啟用時必須上傳已簽核的 **PDF檔案**。 + +**Q: 檔案上傳失敗?** +A: 請確認: +1. 檔案格式正確(PDF) +2. 檔案大小未超過限制 +3. 檔案名稱不含特殊字元 +4. 網路連線穩定 + +### 7.6 效能相關 + +**Q: 系統回應速度慢?** +A: 可能原因: +1. 網路連線問題 +2. 伺服器負載過高 +3. 資料庫查詢耗時 +4. 聯繫系統管理員檢查 + +--- + +## 🆘 技術支援 + +如遇到本手冊未涵蓋的問題,請透過以下方式聯繫: + +- **📧 系統管理員**: [admin@company.com](mailto:admin@company.com) +- **📞 IT支援專線**: 分機 2345 +- **💬 企業即時通**: #temp-spec-support + +--- + +## 📝 版本資訊 + +- **文件版本**: V3.0.0 +- **最後更新**: 2024年1月 +- **適用系統**: 暫時規範管理系統 V3 + +--- + +**感謝您使用暫時規範管理系統 V3!** +希望這個操作手冊能幫助您更有效地使用系統功能。如有任何建議或回饋,歡迎與我們聯繫。 \ No newline at end of file diff --git a/app.py b/app.py index 3c0c264..b87591d 100644 --- a/app.py +++ b/app.py @@ -1,10 +1,12 @@ from flask import Flask, redirect, url_for, render_template from flask_login import LoginManager, current_user +from flask_apscheduler import APScheduler from models import db, User from routes.auth import auth_bp from routes.temp_spec import temp_spec_bp from routes.upload import upload_bp from routes.admin import admin_bp +from routes.api import api_bp app = Flask(__name__) app.config.from_object('config.Config') @@ -12,6 +14,11 @@ app.config.from_object('config.Config') # 初始化資料庫 db.init_app(app) +# 初始化排程器 +scheduler = APScheduler() +scheduler.init_app(app) +scheduler.start() + # 初始化登入管理 login_manager = LoginManager() login_manager.init_app(app) @@ -40,6 +47,15 @@ app.register_blueprint(auth_bp) app.register_blueprint(temp_spec_bp) app.register_blueprint(upload_bp) app.register_blueprint(admin_bp) +app.register_blueprint(api_bp) + +# 導入任務 +from tasks import check_expiring_specs + +# 註冊排程任務:每天凌晨 2:00 執行一次 +@scheduler.task('cron', id='check_expiring_specs_job', hour=2, minute=0) +def scheduled_job(): + check_expiring_specs(app) # 註冊錯誤處理函式 @app.errorhandler(404) @@ -51,4 +67,29 @@ def forbidden_error(error): return render_template('403.html'), 403 if __name__ == '__main__': + # 設定日誌等級以便偵錯 + import logging + import sys + + # 設定日誌輸出到 console + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s %(levelname)s %(name)s: %(message)s', + handlers=[ + logging.StreamHandler(sys.stdout) + ] + ) + + # 設定 Flask app 的日誌 + app.logger.setLevel(logging.INFO) + app.logger.addHandler(logging.StreamHandler(sys.stdout)) + + # 確保 LDAP 相關的日誌也能輸出 + ldap_logger = logging.getLogger('ldap_utils') + ldap_logger.setLevel(logging.INFO) + + print("=== 暫時規範系統 V3 啟動中 ===") + print("日誌等級: INFO") + print("系統準備就緒,可開始登入測試...") + app.run(debug=True) diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..cf94923 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,47 @@ +# Docker Compose Override 檔案 +# 用於開發環境的特殊設定 + +version: '3.8' + +services: + app: + # 開發環境設定 + environment: + FLASK_ENV: development + FLASK_DEBUG: "true" + + # 開發時啟用代碼熱重載 + volumes: + - .:/app + - /app/__pycache__ + + # 開發用命令 + command: > + sh -c " + echo 'Starting development server...' && + python init_db.py --auto-yes && + python app.py + " + + # 開發時不需要健康檢查 + healthcheck: + disable: true + + mysql: + # 開發環境可以使用不同的密碼 + environment: + MYSQL_ROOT_PASSWORD: dev123 + MYSQL_PASSWORD: dev123 + + # 開發時暴露 MySQL 端口以便外部連接 + ports: + - "3307:3306" + + onlyoffice: + # 開發時可以禁用 JWT 以便測試 + environment: + JWT_ENABLED: "false" + + # 開發時暴露不同端口避免衝突 + ports: + - "8081:80" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3778032 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,135 @@ +version: '3.8' + +services: + # MySQL 資料庫服務 + mysql: + image: mysql:8.0 + container_name: tempspec-mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-tempspec123} + MYSQL_DATABASE: ${DB_NAME:-tempspec_db} + MYSQL_USER: ${DB_USER:-tempspec_user} + MYSQL_PASSWORD: ${DB_PASSWORD:-tempspec_pass} + ports: + - "${DB_PORT:-3306}:3306" + volumes: + - mysql_data:/var/lib/mysql + - ./mysql/init:/docker-entrypoint-initdb.d:ro + command: --default-authentication-plugin=mysql_native_password + networks: + - tempspec-network + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:-tempspec123}"] + interval: 30s + timeout: 10s + retries: 5 + + # ONLYOFFICE Document Server + onlyoffice: + image: onlyoffice/documentserver:latest + container_name: tempspec-onlyoffice + restart: unless-stopped + environment: + JWT_ENABLED: "true" + JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-your_jwt_secret_key_here} + JWT_HEADER: "Authorization" + JWT_IN_BODY: "true" + ports: + - "${ONLYOFFICE_PORT:-8080}:80" + volumes: + - onlyoffice_data:/var/www/onlyoffice/Data + - onlyoffice_logs:/var/log/onlyoffice + networks: + - tempspec-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost/healthcheck"] + interval: 30s + timeout: 10s + retries: 5 + + # Flask 應用程式 + app: + build: . + container_name: tempspec-app + restart: unless-stopped + environment: + # Flask 設定 + FLASK_ENV: ${FLASK_ENV:-production} + SECRET_KEY: ${SECRET_KEY:-your-secret-key-here} + + # 資料庫設定 + DATABASE_URL: mysql+pymysql://${DB_USER:-tempspec_user}:${DB_PASSWORD:-tempspec_pass}@mysql:3306/${DB_NAME:-tempspec_db} + + # LDAP 設定 + LDAP_SERVER: ${LDAP_SERVER:-ldap://your-dc.company.com} + LDAP_PORT: ${LDAP_PORT:-389} + LDAP_USE_SSL: ${LDAP_USE_SSL:-False} + LDAP_SEARCH_BASE: ${LDAP_SEARCH_BASE:-DC=company,DC=com} + LDAP_BIND_USER_DN: ${LDAP_BIND_USER_DN:-CN=service,DC=company,DC=com} + LDAP_BIND_USER_PASSWORD: ${LDAP_BIND_USER_PASSWORD:-service_password} + LDAP_USER_LOGIN_ATTR: ${LDAP_USER_LOGIN_ATTR:-userPrincipalName} + + # SMTP 郵件設定 + SMTP_SERVER: ${SMTP_SERVER:-smtp.company.com} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USE_TLS: ${SMTP_USE_TLS:-True} + SMTP_SENDER_EMAIL: ${SMTP_SENDER_EMAIL:-noreply@company.com} + SMTP_SENDER_PASSWORD: ${SMTP_SENDER_PASSWORD:-smtp_password} + + # ONLYOFFICE 設定 + ONLYOFFICE_URL: http://onlyoffice:80 + ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-your_jwt_secret_key_here} + + # 其他設定 + UPLOAD_FOLDER: uploads + ports: + - "${APP_PORT:-5000}:5000" + volumes: + - ./uploads:/app/uploads + - ./static/generated:/app/static/generated + - ./logs:/app/logs + - ./template_with_placeholders.docx:/app/template_with_placeholders.docx:ro + depends_on: + mysql: + condition: service_healthy + onlyoffice: + condition: service_healthy + networks: + - tempspec-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/"] + interval: 30s + timeout: 10s + retries: 5 + + # Nginx 反向代理 (可選) + nginx: + image: nginx:alpine + container_name: tempspec-nginx + restart: unless-stopped + ports: + - "${NGINX_PORT:-80}:80" + - "${NGINX_SSL_PORT:-443}:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/conf.d:/etc/nginx/conf.d:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + depends_on: + - app + networks: + - tempspec-network + profiles: + - production + +volumes: + mysql_data: + driver: local + onlyoffice_data: + driver: local + onlyoffice_logs: + driver: local + +networks: + tempspec-network: + driver: bridge \ No newline at end of file diff --git a/group.png b/group.png new file mode 100644 index 0000000000000000000000000000000000000000..35f340d0a48fcf08722239d2b0bdd18883550995 GIT binary patch literal 28623 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 G`F{XkpAHTH literal 0 HcmV?d00001 diff --git a/init_db.py b/init_db.py index 37d4e3b..780552a 100644 --- a/init_db.py +++ b/init_db.py @@ -1,40 +1,24 @@ # -*- coding: utf-8 -*- -import os -import secrets -import string from flask import Flask -from werkzeug.security import generate_password_hash -from models import db, User +from models import db from config import Config -def create_default_admin(app): - """在應用程式上下文中建立一個預設的管理員帳號。""" - with app.app_context(): - # 檢查管理員是否已存在 - if User.query.filter_by(username='admin').first(): - print("ℹ️ 'admin' 使用者已存在,跳過建立程序。") - return - - # 產生一個安全隨機的密碼 - password_length = 12 - alphabet = string.ascii_letters + string.digits + '!@#$%^&*()' - password = ''.join(secrets.choice(alphabet) for i in range(password_length)) - - # 建立新使用者 - admin_user = User( - username='admin', - password_hash=generate_password_hash(password), - role='admin' - ) - db.session.add(admin_user) - db.session.commit() - - print("✅ 預設管理員帳號已建立!") - print(" ===================================") - print(f" 👤 使用者名稱: admin") - print(f" 🔑 密碼: {password}") - print(" ===================================") - print(" 請妥善保管此密碼,並在首次登入後考慮變更。") +def create_admin_note(): + """顯示 LDAP 管理員設定說明。""" + print("✅ 資料庫已初始化!") + print(" ===================================") + print(" 📋 LDAP 管理員設定說明") + print(" ===================================") + print(" 由於系統使用 LDAP 驗證,管理員權限需要在首次登入後手動設定。") + print(" ") + print(" 步驟:") + print(" 1. 使用 AD 帳號登入系統") + print(" 2. 直接在資料庫中將該用戶的 role 更新為 'admin'") + print(" 3. 或修改 auth.py 中新用戶的預設權限設定") + print(" ") + print(" SQL 範例:") + print(" UPDATE ts_user SET role='admin' WHERE username='你的AD帳號';") + print(" ===================================") def init_database(app): """初始化資料庫:刪除所有現有資料表並重新建立。""" @@ -66,7 +50,7 @@ if __name__ == '__main__': if confirmation.lower() == 'yes': init_database(app) - create_default_admin(app) + create_admin_note() print("\n🎉 全部完成!") else: print("❌ 操作已取消。") \ No newline at end of file diff --git a/ldap_utils.py b/ldap_utils.py index 7ea83b8..63b1fe6 100644 --- a/ldap_utils.py +++ b/ldap_utils.py @@ -1,4 +1,4 @@ -from ldap3 import Server, Connection, ALL, Tls +from ldap3 import Server, Connection, ALL, Tls, SUBTREE import ssl from flask import current_app @@ -6,16 +6,14 @@ def authenticate_ldap_user(username, password): """ Authenticates a user against the LDAP server using their credentials. Returns a dictionary with user info upon success, otherwise None. + 要求使用完整的UPN格式帳號 (例如: user@panjit.com.tw) """ - # Ensure username is in UPN format (e.g., user@domain.com) - # Assuming the domain part is derivable or static. - # This logic might need adjustment based on how users log in. + # 驗證帳號格式必須包含 @ 符號 if '@' not in username: - # This assumes a fixed domain, which should be configured or improved - domain = current_app.config['LDAP_SEARCH_BASE'].split('dc=')[1].replace(',', '.') - user_upn = f"{username}@{domain}" - else: - user_upn = username + current_app.logger.error(f"Invalid username format: {username}. Must use full UPN format (e.g., user@domain.com)") + return None + + user_upn = username ldap_server = current_app.config['LDAP_SERVER'] ldap_port = current_app.config['LDAP_PORT'] @@ -29,46 +27,190 @@ def authenticate_ldap_user(username, password): server = Server(**server_options, get_info=ALL) try: + print(f"[DEBUG] LDAP 連線資訊:") + print(f" - 伺服器: {ldap_server}:{ldap_port}") + print(f" - SSL: {use_ssl}") + print(f" - 使用者 UPN: {user_upn}") + current_app.logger.info(f"Connecting to LDAP server: {ldap_server}:{ldap_port}") + # Attempt to bind with the user's credentials to authenticate + print(f"[DEBUG] 嘗試 LDAP 連線綁定...") conn = Connection(server, user=user_upn, password=password, auto_bind=True) if conn.bound: + print(f"[DEBUG] LDAP 連線綁定成功!") + current_app.logger.info(f"LDAP bind successful for: {user_upn}") + # Authentication successful. Now, get user details. search_base = current_app.config['LDAP_SEARCH_BASE'] login_attr = current_app.config['LDAP_USER_LOGIN_ATTR'] search_filter = f'({login_attr}={user_upn})' - conn.search(search_base, search_filter, attributes=['dn', 'mail', 'displayName', 'sAMAccountName']) + current_app.logger.debug(f"LDAP search - Base: {search_base}, Filter: {search_filter}") + + conn.search(search_base, search_filter, attributes=['mail', 'displayName', 'sAMAccountName']) if conn.entries: entry = conn.entries[0] user_info = { - 'dn': entry.entry_dn, - 'email': str(entry.mail) if 'mail' in entry else None, - 'display_name': str(entry.displayName) if 'displayName' in entry else None, - 'username': str(entry.sAMAccountName) if 'sAMAccountName' in entry else username + 'dn': entry.entry_dn, # DN 直接從 entry 物件獲取 + 'email': str(entry.mail) if 'mail' in entry and entry.mail else None, + 'display_name': str(entry.displayName) if 'displayName' in entry and entry.displayName else None, + 'username': user_upn # 使用原始UPN作為username } + print(f"[DEBUG] 使用者詳細資訊:") + print(f" - 顯示名稱: {user_info['display_name']}") + print(f" - Email: {user_info['email']}") + print(f" - 使用者名稱: {user_info['username']}") + print(f" - DN: {user_info['dn']}") + current_app.logger.info(f"User details retrieved: {user_info['display_name']} ({user_upn})") conn.unbind() return user_info else: # This case is unlikely if bind succeeded, but handle it just in case + current_app.logger.warning(f"LDAP bind successful but user not found in search: {user_upn}") conn.unbind() return None else: # Authentication failed + print(f"[DEBUG] LDAP 連線綁定失敗! 可能是帳號密碼錯誤") + current_app.logger.warning(f"LDAP bind failed for: {user_upn}") return None except Exception as e: - # Log the exception in a real application - print(f"LDAP authentication error: {e}") + # Log the exception with more detail + print(f"[DEBUG] LDAP 連線發生異常: {str(e)}") + print(f"[DEBUG] 伺服器設定 - {ldap_server}:{ldap_port}, SSL={use_ssl}") + current_app.logger.error(f"LDAP authentication error for {user_upn}: {str(e)}") + current_app.logger.error(f"LDAP server: {ldap_server}, Port: {ldap_port}, SSL: {use_ssl}") return None def get_ldap_group_members(group_name): """ - Retrieves a list of email addresses for members of a given LDAP group. + Retrieves a list of email addresses for members of a given LDAP group or organizational unit. + Uses the application's bind credentials for searching. + Enhanced with detailed debugging. + """ + print(f"[GROUP DEBUG] 開始獲取群組成員: {group_name}") + + ldap_server = current_app.config['LDAP_SERVER'] + ldap_port = current_app.config['LDAP_PORT'] + use_ssl = current_app.config['LDAP_USE_SSL'] + bind_dn = current_app.config['LDAP_BIND_USER_DN'] + bind_password = current_app.config['LDAP_BIND_USER_PASSWORD'] + search_base = current_app.config['LDAP_SEARCH_BASE'] + + print(f"[GROUP DEBUG] LDAP 設定:") + print(f"[GROUP DEBUG] - 伺服器: {ldap_server}:{ldap_port}") + print(f"[GROUP DEBUG] - 搜尋基底: {search_base}") + + server_options = {'host': ldap_server, 'port': ldap_port, 'use_ssl': use_ssl} + if use_ssl: + tls_config = Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1_2) + server_options['tls'] = tls_config + + server = Server(**server_options, get_info=ALL) + + try: + # Bind with the service account + conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True) + + if conn.bound: + print(f"[GROUP DEBUG] LDAP 服務帳號連線成功") + + # 嘗試多種搜尋方式 + emails = [] + + # 1. 首先嘗試按 cn 搜尋群組 + print(f"[GROUP DEBUG] 嘗試搜尋群組 (cn): {group_name}") + group_search_filter = f'(&(objectClass=group)(cn={group_name}))' + conn.search(search_base, group_search_filter, attributes=['member', 'mail']) + + if conn.entries: + print(f"[GROUP DEBUG] 找到群組: {conn.entries[0].entry_dn}") + members_dn = conn.entries[0].member.values if 'member' in conn.entries[0] else [] + print(f"[GROUP DEBUG] 群組有 {len(members_dn)} 個成員") + + # 獲取成員郵件 + for i, member_dn in enumerate(members_dn): + print(f"[GROUP DEBUG] 處理成員 {i+1}: {member_dn}") + try: + member_conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True) + member_conn.search(member_dn, '(objectClass=*)', attributes=['mail', 'sAMAccountName', 'displayName']) + if member_conn.entries and 'mail' in member_conn.entries[0]: + email = str(member_conn.entries[0].mail) + emails.append(email) + print(f"[GROUP DEBUG] 獲取成員郵件: {email}") + elif member_conn.entries: + # 如果沒有 mail 屬性,嘗試用 sAMAccountName 生成 + sam = str(member_conn.entries[0].sAMAccountName) if 'sAMAccountName' in member_conn.entries[0] else None + if sam: + email = f"{sam}@panjit.com.tw" + emails.append(email) + print(f"[GROUP DEBUG] 生成成員郵件: {email}") + member_conn.unbind() + except Exception as member_error: + print(f"[GROUP DEBUG] 處理成員錯誤: {member_error}") + else: + # 2. 如果找不到群組,嘗試按 OU 搜尋組織單位 + print(f"[GROUP DEBUG] 群組未找到,嘗試搜尋組織單位: {group_name}") + ou_search_filter = f'(&(objectClass=organizationalUnit)(|(ou=*{group_name}*)(name=*{group_name}*)))' + conn.search(search_base, ou_search_filter, attributes=['ou', 'name']) + + if conn.entries: + print(f"[GROUP DEBUG] 找到組織單位: {conn.entries[0].entry_dn}") + # 搜尋組織單位下的所有用戶 + ou_dn = conn.entries[0].entry_dn + user_conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True) + user_conn.search( + ou_dn, + '(&(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))', + attributes=['mail', 'sAMAccountName', 'displayName'], + search_scope=SUBTREE, + size_limit=50 + ) + + print(f"[GROUP DEBUG] 在組織單位下找到 {len(user_conn.entries)} 個用戶") + for entry in user_conn.entries: + if 'mail' in entry: + email = str(entry.mail) + emails.append(email) + print(f"[GROUP DEBUG] 獲取用戶郵件: {email}") + elif 'sAMAccountName' in entry: + sam = str(entry.sAMAccountName) + email = f"{sam}@panjit.com.tw" + emails.append(email) + print(f"[GROUP DEBUG] 生成用戶郵件: {email}") + + user_conn.unbind() + else: + print(f"[GROUP DEBUG] 找不到群組或組織單位: {group_name}") + + conn.unbind() + print(f"[GROUP DEBUG] 最終獲取 {len(emails)} 個郵件地址") + return emails + else: + print("[GROUP ERROR] Failed to bind to LDAP with service account.") + return [] + + except Exception as e: + print(f"LDAP group search error: {e}") + return [] + + +def search_ldap_principals(search_term, limit=10): + """ + Searches for LDAP principals (users) based on a search term. + Returns a list of dictionaries with 'name' and 'email' keys. Uses the application's bind credentials for searching. """ + if not search_term or len(search_term.strip()) < 2: + print(f"[DEBUG] search_ldap_principals: 搜尋詞無效 '{search_term}'") + return [] + + print(f"[DEBUG] search_ldap_principals: 搜尋 '{search_term}'") + ldap_server = current_app.config['LDAP_SERVER'] ldap_port = current_app.config['LDAP_PORT'] use_ssl = current_app.config['LDAP_USE_SSL'] @@ -84,35 +226,259 @@ def get_ldap_group_members(group_name): server = Server(**server_options, get_info=ALL) try: + print(f"[DEBUG] LDAP 搜尋設定:") + print(f" - 伺服器: {ldap_server}:{ldap_port}") + print(f" - 搜尋基底: {search_base}") + print(f" - 服務帳號: {bind_dn}") + # Bind with the service account conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True) if conn.bound: - # First, find the group to get its member list - group_search_filter = f'(&(objectClass=group)(cn={group_name}))' - conn.search(search_base, group_search_filter, attributes=['member']) + print(f"[DEBUG] LDAP 服務帳號連線成功") + + # Search for users matching the search term in multiple attributes + search_filter = f'(&(objectClass=user)(objectCategory=person)(|(displayName=*{search_term}*)(sAMAccountName=*{search_term}*)(mail=*{search_term}*))(!(userAccountControl:1.2.840.113556.1.4.803:=2)))' + print(f"[DEBUG] LDAP 搜尋篩選器: {search_filter}") + + conn.search( + search_base, + search_filter, + attributes=['displayName', 'mail', 'sAMAccountName'], + size_limit=limit + ) + + print(f"[DEBUG] LDAP 搜尋找到 {len(conn.entries)} 個條目") - if not conn.entries: - print(f"LDAP group '{group_name}' not found.") - conn.unbind() - return [] - - members_dn = conn.entries[0].member.values - emails = [] - - # For each member DN, fetch their email - for member_dn in members_dn: - member_filter = f'(objectDN={member_dn})' - conn.search(member_dn, '(objectClass=*)', attributes=['mail']) - if conn.entries and 'mail' in conn.entries[0]: - emails.append(str(conn.entries[0].mail)) + results = [] + for i, entry in enumerate(conn.entries): + display_name = str(entry.displayName) if 'displayName' in entry and entry.displayName else None + email = str(entry.mail) if 'mail' in entry and entry.mail else None + sam_account = str(entry.sAMAccountName) if 'sAMAccountName' in entry and entry.sAMAccountName else None + + print(f"[DEBUG] 條目 {i+1}: {display_name}, {email}, {sam_account}") + + # Include entries with display name, generate email if missing + if display_name: + # Generate email from SAM account if not provided + if not email and sam_account: + email = f"{sam_account}@panjit.com.tw" + + result = { + 'name': display_name, + 'email': email or 'No Email', + 'username': sam_account + } + results.append(result) + print(f"[DEBUG] 加入結果: {result}") + else: + print(f"[DEBUG] 跳過條目 (缺少顯示名稱)") conn.unbind() - return emails + print(f"[DEBUG] 最終返回 {len(results)} 個結果") + return results + else: + print("[DEBUG] Failed to bind to LDAP with service account.") + return [] + + except Exception as e: + print(f"[DEBUG] LDAP principal search error: {e}") + import traceback + traceback.print_exc() + return [] + + +def search_ldap_groups(search_term, limit=10): + """ + 搜尋 LDAP 群組 (組織) 用於郵件發送 + Returns a list of dictionaries with 'name' and 'members' keys. + """ + if not search_term or len(search_term.strip()) < 2: + print(f"[DEBUG] search_ldap_groups: 搜尋詞無效 '{search_term}'") + return [] + + print(f"[DEBUG] search_ldap_groups: 搜尋群組 '{search_term}'") + + ldap_server = current_app.config['LDAP_SERVER'] + ldap_port = current_app.config['LDAP_PORT'] + use_ssl = current_app.config['LDAP_USE_SSL'] + bind_dn = current_app.config['LDAP_BIND_USER_DN'] + bind_password = current_app.config['LDAP_BIND_USER_PASSWORD'] + search_base = current_app.config['LDAP_SEARCH_BASE'] + + server_options = {'host': ldap_server, 'port': ldap_port, 'use_ssl': use_ssl} + if use_ssl: + tls_config = Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1_2) + server_options['tls'] = tls_config + + server = Server(**server_options, get_info=ALL) + + try: + print(f"[DEBUG] LDAP 群組搜尋設定:") + print(f" - 伺服器: {ldap_server}:{ldap_port}") + print(f" - 搜尋基底: {search_base}") + + # Bind with the service account + conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True) + + if conn.bound: + print(f"[DEBUG] LDAP 服務帳號連線成功") + + # 先搜尋組織單位,再搜尋群組,分開處理避免複雜的篩選器語法 + results = [] + + # 1. 搜尋組織單位 + ou_filter = f'(&(objectClass=organizationalUnit)(|(ou=*{search_term}*)(name=*{search_term}*)))' + print(f"[DEBUG] LDAP 組織單位搜尋篩選器: {ou_filter}") + + try: + conn.search( + search_base, + ou_filter, + attributes=['ou', 'name', 'mail'], + size_limit=limit//2 + ) + + print(f"[DEBUG] 找到 {len(conn.entries)} 個組織單位") + + for i, entry in enumerate(conn.entries): + ou = str(entry.ou) if 'ou' in entry and entry.ou else None + name = str(entry.name) if 'name' in entry and entry.name else None + group_mail = str(entry.mail) if 'mail' in entry and entry.mail else None + + group_name = ou or name + + if group_name: + # 計算組織單位下的成員數 + member_count = 0 + try: + # 使用新的連線來計算成員,避免覆蓋當前結果 + temp_conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True) + if temp_conn.bound: + temp_conn.search( + entry.entry_dn, + '(&(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))', + attributes=['sAMAccountName'], + size_limit=100, + search_scope=SUBTREE + ) + member_count = len(temp_conn.entries) + temp_conn.unbind() + except Exception as member_err: + print(f"[DEBUG] 計算組織成員數錯誤: {member_err}") + member_count = 0 + + result = { + 'name': group_name, + 'email': group_mail, + 'type': 'organizationalUnit', + 'member_count': member_count, + 'dn': entry.entry_dn, + 'members': [] + } + results.append(result) + print(f"[DEBUG] 加入組織單位: {result}") + + except Exception as e: + print(f"[DEBUG] 組織單位搜尋錯誤: {e}") + + # 2. 搜尋群組 + group_filter = f'(&(objectClass=group)(|(cn=*{search_term}*)(displayName=*{search_term}*)))' + print(f"[DEBUG] LDAP 群組搜尋篩選器: {group_filter}") + + try: + conn.search( + search_base, + group_filter, + attributes=['cn', 'displayName', 'member', 'mail'], + size_limit=limit//2 + ) + + print(f"[DEBUG] 找到 {len(conn.entries)} 個群組") + + for i, entry in enumerate(conn.entries): + cn = str(entry.cn) if 'cn' in entry and entry.cn else None + display_name = str(entry.displayName) if 'displayName' in entry and entry.displayName else None + group_mail = str(entry.mail) if 'mail' in entry and entry.mail else None + members = entry.member.values if 'member' in entry else [] + + group_name = display_name or cn + + print(f"[DEBUG] 群組 {i+1}: CN={cn}, DisplayName={display_name}") + print(f"[DEBUG] 群組名稱: {group_name}, 成員數: {len(members)}") + + if group_name: + result = { + 'name': group_name, + 'email': group_mail, + 'type': 'group', + 'member_count': len(members), + 'dn': entry.entry_dn, + 'members': members[:5] # 只顯示前5個成員作為預覽 + } + results.append(result) + print(f"[DEBUG] 加入群組結果: {result}") + + except Exception as e: + print(f"[DEBUG] 群組搜尋錯誤: {e}") + + conn.unbind() + print(f"[DEBUG] 最終返回 {len(results)} 個群組結果") + return results + else: + print("[DEBUG] Failed to bind to LDAP with service account.") + return [] + + except Exception as e: + print(f"[DEBUG] LDAP group search error: {e}") + import traceback + traceback.print_exc() + return [] + + +def get_group_member_emails(group_dn): + """ + 獲取群組內所有成員的郵件地址 + """ + ldap_server = current_app.config['LDAP_SERVER'] + ldap_port = current_app.config['LDAP_PORT'] + use_ssl = current_app.config['LDAP_USE_SSL'] + bind_dn = current_app.config['LDAP_BIND_USER_DN'] + bind_password = current_app.config['LDAP_BIND_USER_PASSWORD'] + + server_options = {'host': ldap_server, 'port': ldap_port, 'use_ssl': use_ssl} + if use_ssl: + tls_config = Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1_2) + server_options['tls'] = tls_config + + server = Server(**server_options, get_info=ALL) + + try: + conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True) + + if conn.bound: + # First get the group members + conn.search(group_dn, '(objectClass=*)', attributes=['member']) + + if conn.entries and 'member' in conn.entries[0]: + members_dn = conn.entries[0].member.values + emails = [] + + # For each member DN, fetch their email + for member_dn in members_dn: + conn.search(member_dn, '(objectClass=*)', attributes=['mail']) + if conn.entries and 'mail' in conn.entries[0] and conn.entries[0].mail: + emails.append(str(conn.entries[0].mail)) + + conn.unbind() + return emails + + conn.unbind() + return [] else: print("Failed to bind to LDAP with service account.") return [] except Exception as e: - print(f"LDAP group search error: {e}") + print(f"Get group member emails error: {e}") return [] diff --git a/mysql/init/01-init.sql b/mysql/init/01-init.sql new file mode 100644 index 0000000..0a621ee --- /dev/null +++ b/mysql/init/01-init.sql @@ -0,0 +1,18 @@ +-- 初始化暫時規範管理系統資料庫 +-- 此檔案會在 MySQL 容器啟動時自動執行 + +-- 設定字符集和排序規則 +ALTER DATABASE tempspec_db CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + +-- 確保時區設定 +SET time_zone = '+08:00'; + +-- 創建必要的索引以提升效能 +-- 注意:資料表結構由 SQLAlchemy 自動創建,這裡只需要創建額外的索引 + +-- 記錄初始化完成 +INSERT INTO mysql.general_log (event_time, user_host, thread_id, server_id, command_type, argument) +VALUES (NOW(), 'init_script', 0, 1, 'Query', 'TempSpec Database Initialized'); + +-- 輸出初始化資訊 +SELECT 'TempSpec System Database Initialized Successfully' as STATUS; \ No newline at end of file diff --git a/nginx/conf.d/default.conf b/nginx/conf.d/default.conf new file mode 100644 index 0000000..30782f4 --- /dev/null +++ b/nginx/conf.d/default.conf @@ -0,0 +1,83 @@ +# 暫時規範管理系統主站 +server { + listen 80; + server_name localhost; + + # 重定向到 HTTPS (可選,需要 SSL 證書) + # return 301 https://$server_name$request_uri; + + # 應用程式代理 + location / { + proxy_pass http://app:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket 支援 + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # 超時設定 + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # ONLYOFFICE Document Server 代理 + location /onlyoffice/ { + proxy_pass http://onlyoffice:80/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # ONLYOFFICE 特殊設定 + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_redirect off; + + # 大檔案上傳支援 + client_max_body_size 100M; + proxy_request_buffering off; + } + + # 靜態檔案快取 + location /static/ { + proxy_pass http://app:5000/static/; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # 健康檢查 + location /health { + proxy_pass http://app:5000/; + access_log off; + } + + # 錯誤頁面 + error_page 404 /404.html; + error_page 500 502 503 504 /50x.html; +} + +# HTTPS 設定 (需要 SSL 證書) +# server { +# listen 443 ssl http2; +# server_name localhost; +# +# ssl_certificate /etc/nginx/ssl/cert.pem; +# ssl_certificate_key /etc/nginx/ssl/key.pem; +# ssl_protocols TLSv1.2 TLSv1.3; +# ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384; +# ssl_prefer_server_ciphers off; +# ssl_session_cache shared:SSL:10m; +# ssl_session_timeout 10m; +# +# # HSTS +# add_header Strict-Transport-Security "max-age=63072000" always; +# +# # 其餘配置與 HTTP 相同 +# # ... +# } \ No newline at end of file diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..683b3dd --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,71 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; + use epoll; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # 日誌格式 + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + # 基本設定 + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + server_tokens off; + + # 檔案大小限制 + client_max_body_size 100M; + client_body_timeout 120s; + client_header_timeout 120s; + + # Gzip 壓縮 + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_comp_level 6; + gzip_types + application/atom+xml + application/geo+json + application/javascript + application/x-javascript + application/json + application/ld+json + application/manifest+json + application/rdf+xml + application/rss+xml + application/xhtml+xml + application/xml + font/eot + font/otf + font/ttf + image/svg+xml + text/css + text/javascript + text/plain + text/xml; + + # 安全標頭 + add_header X-Frame-Options DENY; + add_header X-Content-Type-Options nosniff; + add_header X-XSS-Protection "1; mode=block"; + add_header Referrer-Policy "strict-origin-when-cross-origin"; + + # 包含站點配置 + include /etc/nginx/conf.d/*.conf; +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 30ce47a..6f3e0cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,5 @@ lxml python-dotenv mistune PyJWT -ldap3 \ No newline at end of file +ldap3 +Flask-APScheduler \ No newline at end of file diff --git a/routes/admin.py b/routes/admin.py index 8087186..12a00b7 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -1,76 +1,96 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash from flask_login import login_required, current_user -from werkzeug.security import generate_password_hash from models import User, db from utils import admin_required admin_bp = Blueprint('admin', __name__, url_prefix='/admin') -@admin_bp.before_request +@admin_bp.route('/users') @login_required @admin_required -def before_request(): - """在處理此藍圖中的任何請求之前,確保使用者是已登入的管理員。""" - pass - -@admin_bp.route('/users') def user_list(): - users = User.query.all() + """顯示所有使用者列表,供管理員管理權限。""" + # MySQL 不支援 nullslast(),改用 COALESCE 處理 NULL 值 + users = User.query.order_by(User.last_login.desc(), User.username).all() return render_template('user_management.html', users=users) -@admin_bp.route('/users/create', methods=['POST']) -def create_user(): - username = request.form.get('username') - password = request.form.get('password') - role = request.form.get('role') - - if not all([username, password, role]): - flash('所有欄位都是必填的!', 'danger') - return redirect(url_for('admin.user_list')) - - if User.query.filter_by(username=username).first(): - flash('該使用者名稱已存在!', 'danger') - return redirect(url_for('admin.user_list')) - - new_user = User( - username=username, - password_hash=generate_password_hash(password), - role=role - ) - db.session.add(new_user) - db.session.commit() - flash('新使用者已成功建立!', 'success') - return redirect(url_for('admin.user_list')) - @admin_bp.route('/users/edit/', methods=['POST']) -def edit_user(user_id): +@login_required +@admin_required +def edit_user_role(user_id): + """編輯使用者權限。僅允許修改角色。""" user = User.query.get_or_404(user_id) new_role = request.form.get('role') - new_password = request.form.get('password') - - if new_role: - # 防止 admin 修改自己的角色,導致失去管理權限 - if user.id == current_user.id and user.role == 'admin' and new_role != 'admin': - flash('無法變更自己的管理員角色!', 'danger') - return redirect(url_for('admin.user_list')) - user.role = new_role - if new_password: - user.password_hash = generate_password_hash(new_password) + if new_role not in ['viewer', 'editor', 'admin']: + flash('無效的權限設定!', 'danger') + return redirect(url_for('admin.user_list')) + # 防止管理員修改自己的角色導致失去管理權限 + if user.id == current_user.id and user.role == 'admin' and new_role != 'admin': + flash('無法變更自己的管理員權限!', 'danger') + return redirect(url_for('admin.user_list')) + + old_role = user.role + user.role = new_role db.session.commit() - flash(f"使用者 '{user.username}' 的資料已更新。", 'success') + + flash(f"使用者 '{user.username}' 的權限已從 '{old_role}' 更新為 '{new_role}'。", 'success') return redirect(url_for('admin.user_list')) @admin_bp.route('/users/delete/', methods=['POST']) +@login_required +@admin_required def delete_user(user_id): - # 避免 admin 刪除自己 + """刪除使用者帳號。""" + # 避免管理員刪除自己 if user_id == current_user.id: flash('無法刪除自己的帳號!', 'danger') return redirect(url_for('admin.user_list')) user = User.query.get_or_404(user_id) + username = user.username + + # 檢查是否為最後一個管理員 + admin_count = User.query.filter_by(role='admin').count() + if user.role == 'admin' and admin_count <= 1: + flash('無法刪除最後一個管理員帳號!', 'danger') + return redirect(url_for('admin.user_list')) + db.session.delete(user) db.session.commit() - flash(f"使用者 '{user.username}' 已被刪除。", 'success') + flash(f"使用者 '{username}' 已被刪除。", 'success') return redirect(url_for('admin.user_list')) + +@admin_bp.route('/users/set-admin', methods=['POST']) +@login_required +@admin_required +def set_admin(): + """設定特定AD帳號為管理員權限。""" + username = request.form.get('username', '').strip() + + if not username: + flash('請輸入有效的AD帳號!', 'danger') + return redirect(url_for('admin.user_list')) + + # 查找或建立使用者 + user = User.query.filter_by(username=username).first() + + if not user: + # 建立新的使用者記錄 + user = User( + username=username, + password_hash='ldap_authenticated', # LDAP使用者不需要本地密碼 + role='admin' + ) + db.session.add(user) + db.session.commit() + flash(f"已為 AD 帳號 '{username}' 建立管理員權限。", 'success') + else: + # 更新現有使用者權限 + old_role = user.role + user.role = 'admin' + db.session.commit() + flash(f"已將 '{username}' 的權限從 '{old_role}' 更新為 'admin'。", 'success') + + return redirect(url_for('admin.user_list')) \ No newline at end of file diff --git a/routes/api.py b/routes/api.py new file mode 100644 index 0000000..11663ce --- /dev/null +++ b/routes/api.py @@ -0,0 +1,80 @@ +from flask import Blueprint, request, jsonify +from flask_login import login_required +from ldap_utils import search_ldap_principals, search_ldap_groups + +api_bp = Blueprint('api', __name__, url_prefix='/api') + +@api_bp.route('/ldap-search', methods=['GET']) +@login_required +def ldap_search(): + """ + API endpoint for searching LDAP principals. + Returns JSON data for Tom Select dropdown. + """ + search_term = request.args.get('q', '').strip() + + print(f"[DEBUG] LDAP API 搜尋請求: '{search_term}'") + + if not search_term or len(search_term) < 2: + print(f"[DEBUG] 搜尋詞太短,返回空結果") + return jsonify([]) + + try: + print(f"[DEBUG] 開始 LDAP 搜尋: '{search_term}'") + + # 搜尋使用者 + user_results = search_ldap_principals(search_term, limit=15) + print(f"[DEBUG] LDAP 使用者搜尋結果數量: {len(user_results)}") + + # 搜尋群組 + group_results = search_ldap_groups(search_term, limit=5) + print(f"[DEBUG] LDAP 群組搜尋結果數量: {len(group_results)}") + + # Format results for Tom Select + formatted_results = [] + + # 加入使用者結果 + for result in user_results: + formatted_result = { + 'value': result['email'], + 'text': f"👤 {result['name']} ({result['email']})", + 'type': 'user' + } + formatted_results.append(formatted_result) + print(f"[DEBUG] 格式化使用者結果: {result['name']} - {result['email']}") + + # 加入群組結果 + for result in group_results: + # 對群組,使用群組名稱作為 value,在發送郵件時再展開成員 + formatted_result = { + 'value': f"group:{result['name']}", # 特殊前綴表示這是群組 + 'text': f"👥 {result['name']} ({result['member_count']} 成員)", + 'type': 'group' + } + formatted_results.append(formatted_result) + print(f"[DEBUG] 格式化群組結果: {result['name']} - {result['member_count']} 成員") + + print(f"[DEBUG] 返回 {len(formatted_results)} 個搜尋結果") + return jsonify(formatted_results) + + except Exception as e: + print(f"[DEBUG] LDAP search API error: {e}") + import traceback + traceback.print_exc() + return jsonify({'error': 'Search failed'}), 500 + +@api_bp.route('/debug-form', methods=['POST']) +@login_required +def debug_form(): + """接收前端表單除錯資訊""" + try: + data = request.get_json() + print(f"[FRONTEND DEBUG] 收到前端除錯資料:") + print(f"[FRONTEND DEBUG] selectedValues: {data.get('selectedValues')}") + print(f"[FRONTEND DEBUG] recipientValue: {data.get('recipientValue')}") + print(f"[FRONTEND DEBUG] hiddenFieldValue: {data.get('hiddenFieldValue')}") + print(f"[FRONTEND DEBUG] tomSelectValue: {data.get('tomSelectValue')}") + return jsonify({'status': 'received'}) + except Exception as e: + print(f"[FRONTEND DEBUG] 錯誤: {e}") + return jsonify({'error': str(e)}), 400 \ No newline at end of file diff --git a/routes/auth.py b/routes/auth.py index 301ad54..dfc81f1 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -1,11 +1,9 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash +from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app from flask_login import login_user, logout_user, login_required, current_user -from werkzeug.security import check_password_hash from ldap_utils import authenticate_ldap_user from models import User, db from datetime import datetime -from werkzeug.security import check_password_hash -from ldap_utils import authenticate_ldap_user, generate_password_hash +import logging auth_bp = Blueprint('auth', __name__) @@ -15,35 +13,82 @@ def login(): return redirect(url_for('temp_spec.spec_list')) if request.method == 'POST': - username = request.form['username'] + username = request.form['username'].strip() password = request.form['password'] - # Step 1: Authenticate against LDAP - user_info = authenticate_ldap_user(username, password) - - if user_info: - # Step 2: User authenticated successfully, find or create local user - local_user = User.query.filter_by(username=user_info['username']).first() - - if not local_user: - # Create a new user in the local database - local_user = User( - username=user_info['username'], - # password_hash is no longer needed for login, can be empty or random - password_hash='ldap_authenticated', - role='viewer' # Default role for new users - ) - db.session.add(local_user) + # 記錄登入嘗試 + print(f"[DEBUG] 登入嘗試 - 帳號: {username}") + current_app.logger.info(f"Login attempt for user: {username}") + + # 驗證帳號格式 + if '@' not in username: + print(f"[DEBUG] 帳號格式錯誤 - 缺少 @ 符號: {username}") + current_app.logger.warning(f"Invalid username format (missing @): {username}") + flash('請使用完整的 AD 帳號格式 (包含 @domain)', 'warning') + return render_template('login.html') + + try: + # Step 1: Authenticate against LDAP + print(f"[DEBUG] 準備進行 LDAP 驗證: {username}") + current_app.logger.info(f"Attempting LDAP authentication for: {username}") - # Update last_login time - local_user.last_login = datetime.now() - db.session.commit() + user_info = authenticate_ldap_user(username, password) + print(f"[DEBUG] LDAP 驗證結果: {user_info}") - # Step 3: Log in the user with Flask-Login - login_user(local_user) - return redirect(url_for('temp_spec.spec_list')) - else: - flash('帳號或密碼錯誤,請重新輸入', 'danger') + if user_info: + print(f"[DEBUG] LDAP 驗證成功: {username}") + current_app.logger.info(f"LDAP authentication successful for: {username}") + + # Step 2: User authenticated successfully, find or create local user + local_user = User.query.filter_by(username=user_info['username']).first() + + if not local_user: + print(f"[DEBUG] 建立新的本地使用者帳號: {user_info['username']}") + current_app.logger.info(f"Creating new local user account: {user_info['username']}") + + # Create a new user in the local database + # 預設權限為 viewer,特殊帳號設為 admin + default_role = 'viewer' # 預設權限 + + # 特殊處理:設定特定帳號為管理員權限 + if user_info['username'].lower() == 'ymirliu@panjit.com.tw': + default_role = 'admin' + print(f"[DEBUG] 特殊帳號:{user_info['username']} 設定為管理員權限") + + local_user = User( + username=user_info['username'], + # password_hash is no longer needed for login, can be empty or random + password_hash='ldap_authenticated', + role=default_role + ) + db.session.add(local_user) + print(f"[DEBUG] 新使用者建立完成,權限: {default_role}") + current_app.logger.info(f"New user created with role: {default_role}") + else: + print(f"[DEBUG] 找到現有使用者: {user_info['username']}") + current_app.logger.info(f"Existing user found: {user_info['username']}") + + # Update last_login time + local_user.last_login = datetime.now() + db.session.commit() + + # Step 3: Log in the user with Flask-Login + login_user(local_user) + print(f"[DEBUG] 使用者登入成功: {username}") + current_app.logger.info(f"User successfully logged in: {username}") + return redirect(url_for('temp_spec.spec_list')) + + else: + # LDAP 驗證失敗 + print(f"[DEBUG] LDAP 驗證失敗: {username}") + current_app.logger.warning(f"LDAP authentication failed for: {username}") + flash('AD帳號或密碼錯誤,請檢查後重新輸入', 'danger') + + except Exception as e: + # 系統錯誤 + print(f"[DEBUG] 系統錯誤: {str(e)}") + current_app.logger.error(f"Login system error for user {username}: {str(e)}") + flash('系統登入發生錯誤,請稍後再試或聯繫系統管理員', 'danger') return render_template('login.html') diff --git a/routes/temp_spec.py b/routes/temp_spec.py index 39fb5d8..d302ca7 100644 --- a/routes/temp_spec.py +++ b/routes/temp_spec.py @@ -3,7 +3,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, from flask_login import login_required, current_user from datetime import datetime, timedelta from models import TempSpec, db, Upload, SpecHistory -from utils import editor_or_admin_required, add_history_log, admin_required, send_email +from utils import editor_or_admin_required, add_history_log, admin_required, send_email, process_recipients from ldap_utils import get_ldap_group_members import os import shutil @@ -269,30 +269,29 @@ def activate_spec(spec_id): db.session.commit() flash(f"規範 '{spec.spec_code}' 已生效!", 'success') - # --- Start of Email Notification Example --- - # Get recipient list from a predefined LDAP group - # NOTE: 'TempSpec_Approvers' is an example group name. Replace with the actual group name. - recipients = get_ldap_group_members('TempSpec_Approvers') - if recipients: - subject = f"[暫規通知] 規範 '{spec.spec_code}' 已正式生效" - # Using f-strings and triple quotes for a readable HTML body - body = f""" - - -

您好,

-

暫時規範 {spec.spec_code} - {spec.title} 已由管理員啟用,並正式生效。

-

詳細資訊請登入系統查看。

-

生效日期: {spec.start_date.strftime('%Y-%m-%d')}
- 結束日期: {spec.end_date.strftime('%Y-%m-%d')}

-

此為系統自動發送的通知郵件,請勿直接回覆。

- - - """ - send_email(recipients, subject, body) - else: - # Log a warning if no recipients were found, but don't block the main process - current_app.logger.warning(f"Could not find recipients in LDAP group 'TempSpec_Approvers' for spec {spec.id}.") - # --- End of Email Notification Example --- + # --- Start of Dynamic Email Notification --- + recipients_str = request.form.get('recipients') + if recipients_str: + recipients = process_recipients(recipients_str) + if recipients: + subject = f"[暫規通知] 規範 '{spec.spec_code}' 已正式生效" + # Using f-strings and triple quotes for a readable HTML body + body = f""" + + +

您好,

+

暫時規範 {spec.spec_code} - {spec.title} 已由管理員啟用,並正式生效。

+

詳細資訊請登入系統查看。

+

生效日期: {spec.start_date.strftime('%Y-%m-%d')}
+ 結束日期: {spec.end_date.strftime('%Y-%m-%d')}

+

申請人: {spec.applicant}

+

此為系統自動發送的通知郵件,請勿直接回覆。

+ + + """ + send_email(recipients, subject, body) + current_app.logger.info(f"Sent activation notification for spec {spec.spec_code} to {len(recipients)} recipients.") + # --- End of Dynamic Email Notification --- return redirect(url_for('temp_spec.spec_list')) return render_template('activate_spec.html', spec=spec) @@ -311,6 +310,30 @@ def terminate_spec(spec_id): spec.termination_reason = reason spec.end_date = datetime.today().date() add_history_log(spec.id, '終止', f"原因: {reason}") + + # --- Start of Dynamic Email Notification --- + recipients_str = request.form.get('recipients') + if recipients_str: + recipients = process_recipients(recipients_str) + if recipients: + subject = f"[暫規通知] 規範 '{spec.spec_code}' 已提早終止" + body = f""" + + +

您好,

+

暫時規範 {spec.spec_code} - {spec.title} 已被提早終止。

+

終止日期: {spec.end_date.strftime('%Y-%m-%d')}

+

申請人: {spec.applicant}

+

終止原因: {reason}

+

詳細資訊請登入系統查看。

+

此為系統自動發送的通知郵件,請勿直接回覆。

+ + + """ + send_email(recipients, subject, body) + current_app.logger.info(f"Sent termination notification for spec {spec.spec_code} to {len(recipients)} recipients.") + # --- End of Dynamic Email Notification --- + db.session.commit() flash(f"規範 '{spec.spec_code}' 已被提早終止。", 'warning') return redirect(url_for('temp_spec.spec_list')) @@ -384,6 +407,28 @@ def extend_spec(spec_id): details += f",並上傳新檔案 '{new_upload.filename}'" add_history_log(spec.id, '展延', details) + # --- Start of Dynamic Email Notification --- + recipients_str = request.form.get('recipients') + if recipients_str: + recipients = process_recipients(recipients_str) + if recipients: + subject = f"[暫規通知] 規範 '{spec.spec_code}' 已展延" + body = f""" + + +

您好,

+

暫時規範 {spec.spec_code} - {spec.title} 已成功展延。

+

新的結束日期為: {spec.end_date.strftime('%Y-%m-%d')}

+

申請人: {spec.applicant}

+

詳細資訊請登入系統查看。

+

此為系統自動發送的通知郵件,請勿直接回覆。

+ + + """ + send_email(recipients, subject, body) + current_app.logger.info(f"Sent extension notification for spec {spec.spec_code} to {len(recipients)} recipients.") + # --- End of Dynamic Email Notification --- + db.session.commit() flash(f"規範 '{spec.spec_code}' 已成功展延!", 'success') return redirect(url_for('temp_spec.spec_list')) diff --git a/start-linux.sh b/start-linux.sh new file mode 100644 index 0000000..2b24fe0 --- /dev/null +++ b/start-linux.sh @@ -0,0 +1,368 @@ +#!/bin/bash + +# ======================================== +# 暫時規範管理系統 V3 - Linux 啟動腳本 +# ======================================== + +set -e # 遇到錯誤立即退出 + +echo +echo "=====================================" +echo "暫時規範管理系統 V3 - Linux 啟動" +echo "=====================================" +echo + +# 顏色定義 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 檢查 Python 是否已安裝 +if ! command -v python3 &> /dev/null; then + echo -e "${RED}[錯誤]${NC} Python3 未安裝" + echo "請安裝 Python 3.8+ 後重新執行此腳本" + echo + echo "Ubuntu/Debian: sudo apt update && sudo apt install python3 python3-pip python3-venv" + echo "CentOS/RHEL: sudo yum install python3 python3-pip" + echo "或使用包管理器安裝" + exit 1 +fi + +# 檢查 Docker 是否已安裝 +if ! command -v docker &> /dev/null; then + echo -e "${YELLOW}[警告]${NC} Docker 未安裝或未運行" + echo "如需使用 Docker 模式,請先安裝 Docker" + echo + USE_DOCKER=false +else + # 檢查 Docker 是否運行 + if ! docker info &> /dev/null; then + echo -e "${YELLOW}[警告]${NC} Docker 未運行" + echo "請啟動 Docker 服務: sudo systemctl start docker" + USE_DOCKER=false + else + USE_DOCKER=true + fi +fi + +# 檢查 docker-compose 是否存在 +if [[ "$USE_DOCKER" == true ]] && ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then + echo -e "${YELLOW}[警告]${NC} docker-compose 未安裝" + USE_DOCKER=false +fi + +# 檢查 .env 檔案 +if [[ ! -f ".env" ]]; then + echo -e "${BLUE}[提示]${NC} 未找到 .env 檔案,正在複製範例檔案..." + cp ".env.example" ".env" + echo + echo -e "${YELLOW}[重要]${NC} 請編輯 .env 檔案並設定正確的參數值:" + echo "- 資料庫連線資訊" + echo "- LDAP/AD 伺服器設定" + echo "- SMTP 郵件伺服器設定" + echo + read -p "是否已完成 .env 檔案設定?(y/N): " confirm + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + echo "請先設定 .env 檔案後再執行此腳本" + exit 1 + fi +fi + +# 提供啟動選項 +echo "請選擇啟動模式:" +echo +if [[ "$USE_DOCKER" == true ]]; then + echo "[1] Docker 容器化部署 (推薦)" + echo "[2] 本地 Python 直接執行" + echo "[3] 僅啟動外部服務 (MySQL + ONLYOFFICE)" + echo "[4] 生產環境部署 (含 Nginx)" + echo "[5] 系統服務安裝 (systemd)" +else + echo "[2] 本地 Python 直接執行" + echo "[5] 系統服務安裝 (systemd)" + echo + echo -e "${YELLOW}注意:Docker 相關選項不可用${NC}" +fi +echo + +read -p "請輸入選項: " choice + +case $choice in + 1) + if [[ "$USE_DOCKER" != true ]]; then + echo -e "${RED}[錯誤]${NC} Docker 不可用,請選擇其他選項" + exit 1 + fi + start_docker + ;; + 2) + start_manual + ;; + 3) + if [[ "$USE_DOCKER" != true ]]; then + echo -e "${RED}[錯誤]${NC} Docker 不可用,請選擇其他選項" + exit 1 + fi + start_services_only + ;; + 4) + if [[ "$USE_DOCKER" != true ]]; then + echo -e "${RED}[錯誤]${NC} Docker 不可用,請選擇其他選項" + exit 1 + fi + start_production + ;; + 5) + install_systemd_service + ;; + *) + if [[ "$USE_DOCKER" == true ]]; then + start_docker + else + start_manual + fi + ;; +esac + +function start_docker() { + echo + echo "=================================" + echo "使用 Docker Compose 啟動系統..." + echo "=================================" + echo + + # 檢查 compose 命令 + if command -v docker-compose &> /dev/null; then + COMPOSE_CMD="docker-compose" + else + COMPOSE_CMD="docker compose" + fi + + # 停止並清理舊的容器 + echo -e "${BLUE}[步驟 1]${NC} 清理舊容器..." + $COMPOSE_CMD down + + # 重新構建並啟動服務 + echo -e "${BLUE}[步驟 2]${NC} 構建並啟動服務..." + $COMPOSE_CMD up --build -d + + # 等待服務啟動 + echo -e "${BLUE}[步驟 3]${NC} 等待服務啟動..." + sleep 30 + + # 初始化資料庫 + echo -e "${BLUE}[步驟 4]${NC} 初始化資料庫..." + $COMPOSE_CMD exec -T app python init_db.py --auto-yes || true + + # 顯示狀態 + echo -e "${BLUE}[步驟 5]${NC} 檢查服務狀態..." + $COMPOSE_CMD ps + + echo + echo -e "${GREEN}============================" + echo "系統啟動完成!" + echo "============================${NC}" + echo + echo "服務訪問地址:" + echo "- 主系統:http://localhost:5000" + echo "- ONLYOFFICE:http://localhost:8080" + echo "- MySQL:localhost:3306" + echo + echo "管理命令:" + echo "- 查看日誌:$COMPOSE_CMD logs -f" + echo "- 停止服務:$COMPOSE_CMD down" + echo "- 重啟服務:$COMPOSE_CMD restart" + echo +} + +function start_services_only() { + echo + echo "===============================" + echo "僅啟動外部服務..." + echo "===============================" + echo + + if command -v docker-compose &> /dev/null; then + COMPOSE_CMD="docker-compose" + else + COMPOSE_CMD="docker compose" + fi + + $COMPOSE_CMD up -d mysql onlyoffice + + echo + echo -e "${GREEN}外部服務已啟動!${NC}" + echo "- MySQL:localhost:3306" + echo "- ONLYOFFICE:http://localhost:8080" + echo + echo "請使用以下命令啟動 Flask 應用:" + echo "python3 app.py" + echo +} + +function start_production() { + echo + echo "===============================" + echo "生產環境部署 (含 Nginx)..." + echo "===============================" + echo + + if command -v docker-compose &> /dev/null; then + COMPOSE_CMD="docker-compose" + else + COMPOSE_CMD="docker compose" + fi + + $COMPOSE_CMD --profile production up --build -d + + echo + echo -e "${GREEN}生產環境已啟動!${NC}" + echo "- 系統入口:http://localhost (透過 Nginx)" + echo "- 直接訪問:http://localhost:5000" + echo +} + +function start_manual() { + echo + echo "===============================" + echo "本地 Python 環境啟動..." + echo "===============================" + echo + + # 檢查虛擬環境 + if [[ -d "venv" ]]; then + echo -e "${BLUE}[步驟 1]${NC} 啟用虛擬環境..." + source venv/bin/activate + else + echo -e "${YELLOW}[警告]${NC} 未找到虛擬環境,建議先建立:" + echo "python3 -m venv venv" + echo "source venv/bin/activate" + echo "pip install -r requirements.txt" + echo + read -p "是否立即建立虛擬環境?(y/N): " create_venv + if [[ "$create_venv" =~ ^[Yy]$ ]]; then + python3 -m venv venv + source venv/bin/activate + echo -e "${GREEN}虛擬環境已建立並啟用${NC}" + fi + fi + + # 安裝依賴 + echo -e "${BLUE}[步驟 2]${NC} 檢查並安裝依賴..." + pip install -r requirements.txt + + # 檢查外部服務 + echo -e "${BLUE}[步驟 3]${NC} 檢查外部服務..." + echo + echo -e "${YELLOW}[重要提醒]${NC} 請確保以下服務已啟動:" + echo + echo "1. MySQL 資料庫伺服器" + echo " - 可使用: docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=123456 mysql:8.0" + echo + echo "2. ONLYOFFICE Document Server" + echo " - 可使用: docker run -d -p 8080:80 onlyoffice/documentserver" + echo + echo "3. LDAP/AD 伺服器 (如果適用)" + echo "4. SMTP 郵件伺服器 (如果適用)" + echo + + read -p "外部服務是否已準備就緒?(y/N): " confirm + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + echo "請先啟動必要的外部服務" + exit 1 + fi + + # 初始化資料庫 + echo -e "${BLUE}[步驟 4]${NC} 初始化資料庫..." + python3 init_db.py || python init_db.py + + # 啟動應用 + echo -e "${BLUE}[步驟 5]${NC} 啟動 Flask 應用..." + echo + echo "啟動中... (Ctrl+C 停止)" + echo "訪問地址: http://localhost:5000" + echo + + # 根據系統環境選擇啟動方式 + if command -v gunicorn &> /dev/null; then + echo "使用 Gunicorn 啟動生產模式..." + gunicorn -w 4 -b 0.0.0.0:5000 app:app + else + echo "使用 Flask 開發伺服器啟動..." + python3 app.py || python app.py + fi +} + +function install_systemd_service() { + echo + echo "===============================" + echo "安裝為系統服務 (systemd)..." + echo "===============================" + echo + + # 檢查是否為 root 用戶 + if [[ $EUID -ne 0 ]]; then + echo -e "${RED}[錯誤]${NC} 需要 root 權限安裝系統服務" + echo "請使用 sudo 執行此腳本" + exit 1 + fi + + # 獲取當前目錄和用戶 + CURRENT_DIR=$(pwd) + CURRENT_USER=${SUDO_USER:-$USER} + + echo "安裝目錄: $CURRENT_DIR" + echo "執行用戶: $CURRENT_USER" + echo + + # 建立服務檔案 + cat > /etc/systemd/system/tempspec.service << EOF +[Unit] +Description=Temp Spec System V3 +After=network.target mysql.service + +[Service] +Type=simple +User=$CURRENT_USER +Group=$CURRENT_USER +WorkingDirectory=$CURRENT_DIR +Environment=PATH=$CURRENT_DIR/venv/bin +ExecStart=$CURRENT_DIR/venv/bin/gunicorn -w 4 -b 0.0.0.0:5000 app:app +ExecReload=/bin/kill -HUP \$MAINPID +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target +EOF + + # 重新載入 systemd + systemctl daemon-reload + + # 啟用服務 + systemctl enable tempspec.service + + echo -e "${GREEN}系統服務安裝完成!${NC}" + echo + echo "管理命令:" + echo "- 啟動服務: sudo systemctl start tempspec" + echo "- 停止服務: sudo systemctl stop tempspec" + echo "- 重啟服務: sudo systemctl restart tempspec" + echo "- 查看狀態: sudo systemctl status tempspec" + echo "- 查看日誌: sudo journalctl -u tempspec -f" + echo + + read -p "是否立即啟動服務?(y/N): " start_now + if [[ "$start_now" =~ ^[Yy]$ ]]; then + systemctl start tempspec + systemctl status tempspec --no-pager + fi +} + +# 捕捉 Ctrl+C +trap 'echo -e "\n${YELLOW}操作已取消${NC}"; exit 130' INT + +echo +echo -e "${GREEN}腳本執行完成!${NC}" \ No newline at end of file diff --git a/start-windows.bat b/start-windows.bat new file mode 100644 index 0000000..e1d0259 --- /dev/null +++ b/start-windows.bat @@ -0,0 +1,206 @@ +@echo off +REM ======================================== +REM 暫時規範管理系統 V3 - Windows 啟動腳本 +REM ======================================== + +echo. +echo ===================================== +echo 暫時規範管理系統 V3 - Windows 啟動 +echo ===================================== +echo. + +REM 檢查 Python 是否已安裝 +python --version >nul 2>&1 +if %ERRORLEVEL% neq 0 ( + echo [錯誤] Python 未安裝或未加入 PATH + echo 請安裝 Python 3.8+ 並重新執行此腳本 + pause + exit /b 1 +) + +REM 檢查 Docker 是否已安裝並運行 +docker --version >nul 2>&1 +if %ERRORLEVEL% neq 0 ( + echo [警告] Docker 未安裝或未運行 + echo 如需使用 Docker 模式,請先安裝並啟動 Docker Desktop + echo. + goto MANUAL_START +) + +REM 檢查是否存在 docker-compose.yml +if not exist "docker-compose.yml" ( + echo [警告] 找不到 docker-compose.yml 檔案 + goto MANUAL_START +) + +REM 檢查 .env 檔案 +if not exist ".env" ( + echo [提示] 未找到 .env 檔案,正在複製範例檔案... + copy ".env.example" ".env" + echo. + echo [重要] 請編輯 .env 檔案並設定正確的參數值: + echo - 資料庫連線資訊 + echo - LDAP/AD 伺服器設定 + echo - SMTP 郵件伺服器設定 + echo. + set /p confirm="是否已完成 .env 檔案設定?(y/N): " + if /i not "%confirm%"=="y" ( + echo 請先設定 .env 檔案後再執行此腳本 + pause + exit /b 1 + ) +) + +REM 提供啟動選項 +echo 請選擇啟動模式: +echo. +echo [1] Docker 容器化部署 (推薦) +echo [2] 本地 Python 直接執行 +echo [3] 僅啟動外部服務 (MySQL + ONLYOFFICE) +echo [4] 生產環境部署 (含 Nginx) +echo. +set /p choice="請輸入選項 [1-4]: " + +if "%choice%"=="1" goto DOCKER_START +if "%choice%"=="2" goto MANUAL_START +if "%choice%"=="3" goto SERVICES_ONLY +if "%choice%"=="4" goto PRODUCTION_START +goto DOCKER_START + +:DOCKER_START +echo. +echo ================================= +echo 使用 Docker Compose 啟動系統... +echo ================================= +echo. + +REM 停止並清理舊的容器 +echo [步驟 1] 清理舊容器... +docker-compose down + +REM 重新構建並啟動服務 +echo [步驟 2] 構建並啟動服務... +docker-compose up --build -d + +REM 等待服務啟動 +echo [步驟 3] 等待服務啟動... +timeout /t 30 /nobreak + +REM 初始化資料庫 +echo [步驟 4] 初始化資料庫... +docker-compose exec app python init_db.py --auto-yes + +REM 顯示狀態 +echo [步驟 5] 檢查服務狀態... +docker-compose ps + +echo. +echo ============================ +echo 系統啟動完成! +echo ============================ +echo. +echo 服務訪問地址: +echo - 主系統:http://localhost:5000 +echo - ONLYOFFICE:http://localhost:8080 +echo - MySQL:localhost:3306 +echo. +echo 查看日誌:docker-compose logs -f +echo 停止服務:docker-compose down +echo. +goto END + +:SERVICES_ONLY +echo. +echo =============================== +echo 僅啟動外部服務... +echo =============================== +echo. + +docker-compose up -d mysql onlyoffice + +echo. +echo 外部服務已啟動! +echo - MySQL:localhost:3306 +echo - ONLYOFFICE:http://localhost:8080 +echo. +echo 請使用以下命令啟動 Flask 應用: +echo python app.py +echo. +goto END + +:PRODUCTION_START +echo. +echo =============================== +echo 生產環境部署 (含 Nginx)... +echo =============================== +echo. + +docker-compose --profile production up --build -d + +echo. +echo 生產環境已啟動! +echo - 系統入口:http://localhost (透過 Nginx) +echo - 直接訪問:http://localhost:5000 +echo. +goto END + +:MANUAL_START +echo. +echo =============================== +echo 本地 Python 環境啟動... +echo =============================== +echo. + +REM 檢查虛擬環境 +if exist "venv\Scripts\activate.bat" ( + echo [步驟 1] 啟用虛擬環境... + call venv\Scripts\activate.bat +) else ( + echo [警告] 未找到虛擬環境,建議先建立: + echo python -m venv venv + echo venv\Scripts\activate + echo pip install -r requirements.txt + echo. +) + +REM 安裝依賴 +echo [步驟 2] 檢查並安裝依賴... +pip install -r requirements.txt + +REM 檢查外部服務 +echo [步驟 3] 檢查外部服務... +echo. +echo [重要提醒] 請確保以下服務已啟動: +echo. +echo 1. MySQL 資料庫伺服器 +echo - 可使用: docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=123456 mysql:8.0 +echo. +echo 2. ONLYOFFICE Document Server +echo - 可使用: docker run -d -p 8080:80 onlyoffice/documentserver +echo. +echo 3. LDAP/AD 伺服器 (如果適用) +echo 4. SMTP 郵件伺服器 (如果適用) +echo. + +set /p confirm="外部服務是否已準備就緒?(y/N): " +if /i not "%confirm%"=="y" ( + echo 請先啟動必要的外部服務 + pause + exit /b 1 +) + +REM 初始化資料庫 +echo [步驟 4] 初始化資料庫... +python init_db.py + +REM 啟動應用 +echo [步驟 5] 啟動 Flask 應用... +echo. +echo 啟動中... (Ctrl+C 停止) +python app.py + +goto END + +:END +echo. +pause \ No newline at end of file diff --git a/static/generated/PE1140801.docx b/static/generated/PE1140801.docx new file mode 100644 index 0000000000000000000000000000000000000000..1612a6cdde3a8df119cc90115be798c193551baf GIT binary patch literal 24502 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 gr)Km$6S3fOr1$C;ljElk? + +

您好,

+

此為自動提醒郵件。

+

暫時規範 {spec.spec_code} - {spec.title} 即將到期。

+

結束日期: {spec.end_date.strftime('%Y-%m-%d')} (剩餘 {remaining_days} 天)

+

申請人: {spec.applicant}

+

請及時處理,如需展延請登入系統操作。

+

此為系統自動發送的通知郵件,請勿直接回覆。

+ + + """ + + # 發送郵件給預設群組 + send_email(default_recipients, subject, body) + print(f"Sent expiry reminder for spec {spec.spec_code} to {len(default_recipients)} recipients.") \ No newline at end of file diff --git a/templates/activate_spec.html b/templates/activate_spec.html index 56745d7..b66aa2c 100644 --- a/templates/activate_spec.html +++ b/templates/activate_spec.html @@ -19,9 +19,133 @@ - + + +
+ + + +
可搜尋姓名或 Email 地址,支援多人選擇
+
+ + 取消 {% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html index 3db4caa..d4ab161 100644 --- a/templates/base.html +++ b/templates/base.html @@ -14,6 +14,8 @@ + +