From a408ce402d4661f350afacefa107ff6df560d295 Mon Sep 17 00:00:00 2001 From: beabigegg Date: Fri, 12 Sep 2025 08:00:56 +0800 Subject: [PATCH] check_ok --- .claude/settings.local.json | 9 + .dockerignore | 67 ++++ .env | 35 ++ .env.docker.example | 36 ++ .env.example | 57 ++++ .env.production | 41 +++ .gitignore | 53 +++ DEPLOYMENT.md | 371 ++++++++++++++++++++ Dockerfile | 46 +++ README.md | 366 ++++++++++++++++++++ USER_MANUAL.md | 393 +++++++++++++++++++++ app.py | 123 +++++++ cache_utils.py | 97 ++++++ cdn_utils.py | 66 ++++ config.py | 50 +++ docker-compose.prod.yml | 61 ++++ docker-compose.yml | 138 ++++++++ gunicorn.conf.py | 53 +++ init_db.py | 56 +++ ldap_utils.py | 484 ++++++++++++++++++++++++++ models.py | 60 ++++ monitor.py | 163 +++++++++ mysql/init/01-init.sql | 18 + nginx/conf.d/default.conf | 83 +++++ nginx/nginx.conf | 71 ++++ requirements.txt | 21 ++ routes/__init__.py | 0 routes/admin.py | 96 ++++++ routes/api.py | 80 +++++ routes/auth.py | 99 ++++++ routes/temp_spec.py | 488 +++++++++++++++++++++++++++ routes/upload.py | 29 ++ start-production.sh | 102 ++++++ static/css/style.css | 203 +++++++++++ static/generated/PE1140901.docx | Bin 0 -> 24501 bytes static/generated/PE1140902.docx | Bin 0 -> 24508 bytes static/generated/PE1140903.docx | Bin 0 -> 24505 bytes tasks.py | 55 +++ template_with_placeholders.docx | Bin 0 -> 27596 bytes templates/403.html | 12 + templates/404.html | 12 + templates/activate_spec.html | 151 +++++++++ templates/base.html | 98 ++++++ templates/create_temp_spec_form.html | 83 +++++ templates/extend_spec.html | 117 +++++++ templates/login.html | 41 +++ templates/onlyoffice_editor.html | 39 +++ templates/spec_history.html | 34 ++ templates/spec_list.html | 153 +++++++++ templates/terminate_spec.html | 110 ++++++ templates/user_management.html | 234 +++++++++++++ update_admin.py | 37 ++ utils.py | 308 +++++++++++++++++ wsgi.py | 27 ++ 54 files changed, 5626 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .dockerignore create mode 100644 .env create mode 100644 .env.docker.example create mode 100644 .env.example create mode 100644 .env.production create mode 100644 .gitignore create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 USER_MANUAL.md create mode 100644 app.py create mode 100644 cache_utils.py create mode 100644 cdn_utils.py create mode 100644 config.py create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 gunicorn.conf.py create mode 100644 init_db.py create mode 100644 ldap_utils.py create mode 100644 models.py create mode 100644 monitor.py 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 requirements.txt create mode 100644 routes/__init__.py create mode 100644 routes/admin.py create mode 100644 routes/api.py create mode 100644 routes/auth.py create mode 100644 routes/temp_spec.py create mode 100644 routes/upload.py create mode 100644 start-production.sh create mode 100644 static/css/style.css create mode 100644 static/generated/PE1140901.docx create mode 100644 static/generated/PE1140902.docx create mode 100644 static/generated/PE1140903.docx create mode 100644 tasks.py create mode 100644 template_with_placeholders.docx create mode 100644 templates/403.html create mode 100644 templates/404.html create mode 100644 templates/activate_spec.html create mode 100644 templates/base.html create mode 100644 templates/create_temp_spec_form.html create mode 100644 templates/extend_spec.html create mode 100644 templates/login.html create mode 100644 templates/onlyoffice_editor.html create mode 100644 templates/spec_history.html create mode 100644 templates/spec_list.html create mode 100644 templates/terminate_spec.html create mode 100644 templates/user_management.html create mode 100644 update_admin.py create mode 100644 utils.py create mode 100644 wsgi.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..363b93e --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(chmod:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..98299af --- /dev/null +++ b/.dockerignore @@ -0,0 +1,67 @@ +# 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 + +# 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 b/.env new file mode 100644 index 0000000..0322a5b --- /dev/null +++ b/.env @@ -0,0 +1,35 @@ +# .env +# 將敏感資訊存放在此,並確保此檔案被加入 .gitignore,不要上傳到版本控制系統 + +# 資料庫連線字串 +# 格式: mysql+pymysql://:@:/ +DATABASE_URL="mysql+pymysql://A060:WLeSCi0yhtc7@mysql.theaken.com:33306/db_A060" + +# Flask 的 Secret Key +SECRET_KEY="933f9064329f29e642b20089e6ee16b3dd15da6acb6fdd98" + +# ONLYOFFICE Document Server URL +ONLYOFFICE_URL="http://localhost:8080/" + +# Secret key for ONLYOFFICE JWT +ONLYOFFICE_JWT_SECRET="933f9064330f29e642b20089e6ee16b3dd15da6acb6fdd98" + +# --- LDAP Settings --- +LDAP_SERVER=panjit.com.tw +LDAP_PORT=389 +LDAP_USE_SSL=false +# 服務帳號 (用於查詢群組) +LDAP_BIND_USER_DN="CN=LdapBind,CN=Users,DC=PANJIT,DC=COM,DC=TW" +LDAP_BIND_USER_PASSWORD="panjit2481" +# 使用者和群組的搜尋基礎 +LDAP_SEARCH_BASE="OU=PANJIT,DC=panjit,DC=com,DC=tw" +LDAP_USER_LOGIN_ATTR=userPrincipalName + +# --- SMTP Settings --- +SMTP_SERVER=mail.panjit.com.tw +SMTP_PORT=25 +SMTP_USE_TLS=false +SMTP_USE_SSL=false +SMTP_AUTH_REQUIRED=false +SMTP_SENDER_EMAIL=temp-spec-system@panjit.com.tw +SMTP_SENDER_PASSWORD= \ No newline at end of file diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 0000000..3e29e20 --- /dev/null +++ b/.env.docker.example @@ -0,0 +1,36 @@ +# Docker 部署環境變數配置範例 +# 複製此文件為 .env 並填入實際的值 + +# === 對外服務端口配置 (12010~12019) === +APP_PORT=12010 # Flask 應用程式主服務 +ONLYOFFICE_PORT=12011 # OnlyOffice 文檔編輯服務 +NGINX_PORT=12013 # Nginx HTTP (生產環境使用) +NGINX_SSL_PORT=12014 # Nginx HTTPS (生產環境使用) + +# === Flask 應用設定 === +FLASK_ENV=production +SECRET_KEY=933f9064329f29e642b20089e6ee16b3dd15da6acb6fdd98 + +# === 外部資料庫連線 (使用 mysql.theaken.com) === +DATABASE_URL=mysql+pymysql://A060:WLeSCi0yhtc7@mysql.theaken.com:33306/db_A060 + +# === LDAP 設定 === +LDAP_SERVER=panjit.com.tw +LDAP_PORT=389 +LDAP_USE_SSL=false +LDAP_SEARCH_BASE=OU=PANJIT,DC=panjit,DC=com,DC=tw +LDAP_BIND_USER_DN=CN=LdapBind,CN=Users,DC=PANJIT,DC=COM,DC=TW +LDAP_BIND_USER_PASSWORD=panjit2481 +LDAP_USER_LOGIN_ATTR=userPrincipalName + +# === SMTP 郵件設定 === +SMTP_SERVER=mail.panjit.com.tw +SMTP_PORT=25 +SMTP_USE_TLS=false +SMTP_USE_SSL=false +SMTP_AUTH_REQUIRED=false +SMTP_SENDER_EMAIL=temp-spec-system@panjit.com.tw +SMTP_SENDER_PASSWORD= + +# === ONLYOFFICE 設定 === +ONLYOFFICE_JWT_SECRET=933f9064330f29e642b20089e6ee16b3dd15da6acb6fdd98 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0437064 --- /dev/null +++ b/.env.example @@ -0,0 +1,57 @@ +# 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 郵件設定 +# 方案 1: 使用 Port 25 (無需認證) - 推薦用於內部郵件伺服器 +SMTP_SERVER=mail.panjit.com.tw +SMTP_PORT=25 +SMTP_USE_TLS=false +SMTP_USE_SSL=false +SMTP_AUTH_REQUIRED=false +SMTP_SENDER_EMAIL=temp-spec-system@panjit.com.tw +SMTP_SENDER_PASSWORD= + +# 方案 2: 使用 Port 587 (需要認證) - 用於外部 SMTP 或有認證需求的伺服器 +# SMTP_SERVER=smtp.company.com +# SMTP_PORT=587 +# SMTP_USE_TLS=true +# SMTP_USE_SSL=false +# SMTP_AUTH_REQUIRED=true +# SMTP_SENDER_EMAIL=noreply@company.com +# SMTP_SENDER_PASSWORD=smtp_password + +# 方案 3: 使用 Port 465 (SSL + 認證) - 用於安全連接 +# SMTP_SERVER=smtp.gmail.com +# SMTP_PORT=465 +# SMTP_USE_TLS=false +# SMTP_USE_SSL=true +# SMTP_AUTH_REQUIRED=true +# SMTP_SENDER_EMAIL=yourapp@gmail.com +# SMTP_SENDER_PASSWORD=app_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/.env.production b/.env.production new file mode 100644 index 0000000..dc7109a --- /dev/null +++ b/.env.production @@ -0,0 +1,41 @@ +# 生產環境配置範例 +# 複製此檔案為 .env.production 並修改相應值 + +# === Flask 應用設定 === +FLASK_ENV=production +SECRET_KEY=your_super_secret_production_key_here_change_me + +# === 資料庫設定 === +DATABASE_URL=mysql+pymysql://prod_user:prod_password@mysql.company.com:3306/prod_db + +# === Redis 快取設定 === +REDIS_URL=redis://redis:6379/0 + +# === CDN 設定 === +CDN_DOMAIN=cdn.yourcompany.com + +# === LDAP 設定 === +LDAP_SERVER=ldap.company.com +LDAP_PORT=389 +LDAP_USE_SSL=true +LDAP_SEARCH_BASE=DC=company,DC=com +LDAP_BIND_USER_DN=CN=service_account,CN=Users,DC=company,DC=com +LDAP_BIND_USER_PASSWORD=service_account_password +LDAP_USER_LOGIN_ATTR=userPrincipalName + +# === SMTP 設定 === +SMTP_SERVER=smtp.company.com +SMTP_PORT=587 +SMTP_USE_TLS=true +SMTP_SENDER_EMAIL=temp-spec@company.com +SMTP_SENDER_PASSWORD=smtp_password +SMTP_AUTH_REQUIRED=true + +# === ONLYOFFICE 設定 === +ONLYOFFICE_JWT_SECRET=your_onlyoffice_jwt_secret_production_key + +# === 服務端口設定 === +APP_PORT=12010 +ONLYOFFICE_PORT=12011 +NGINX_PORT=80 +NGINX_SSL_PORT=443 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3d9b53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# --- 敏感資訊 (Sensitive Information) --- +# 忽略包含所有密鑰和資料庫連線資訊的環境變數檔案。 + +# --- Python 相關 (Python Related) --- +# 忽略虛擬環境目錄。 +.venv/ +venv/ + +# 忽略 Python 的位元組碼和快取檔案。 +__pycache__/ +*.pyc +*.pyo +*.pyd + +# --- 使用者上傳與系統產生的檔案 (User Uploads & Generated Files) --- +# 忽略上傳的已簽核文件 (PDFs)。 +/uploads/ + +# 忽略系統自動產生的暫時規範文件 (Word, PDF)。 +/generated/ + +# 忽略使用者在編輯器中上傳的圖片。 +/static/uploads/ + +# --- IDE / 編輯器設定 (IDE / Editor Settings) --- +# 忽略 Visual Studio Code 的本機設定。 +.vscode/ + +# --- 作業系統相關 (Operating System) --- +# 忽略 macOS 的系統檔案。 +.DS_Store + +# 忽略 Windows 的縮圖快取。 +Thumbs.db + +# --- Log 檔案 --- +# 忽略所有日誌檔案。 +*.log +logs/ + +# --- 環境設定檔 --- + + +# --- 測試相關 (Testing) --- +# 忽略測試檔案 +test_*.py +*_test.py +tests/ + +# --- 開發者專用文件 (Developer Only) --- +# 最佳實踐文件(包含敏感設定資訊) +BEST_PRACTICES.md +DEVELOPER_GUIDE.md diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..4696a11 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,371 @@ +# 部署指南 - 暫時規範管理系統 V4 + +本文件提供詳細的部署指導,涵蓋 Docker 環境部署方式。 + +## 🎉 生產環境優化完成 + +**✅ 已完成50人併發生產環境優化**: +- Gunicorn WSGI部署(多進程併發) +- Redis快取系統(提升效能) +- Nginx反向代理(負載均衡) +- CDN支援(靜態資源加速) +- 資源限制(防止系統過載) +- 監控工具(效能監控) + +## 📋 目錄 + +1. [系統需求](#1-系統需求) +2. [快速部署](#2-快速部署) +3. [生產環境配置](#3-生產環境配置) +4. [監控與管理](#4-監控與管理) +5. [服務訪問](#5-服務訪問) +6. [疑難排解](#6-疑難排解) + +## 1. 系統需求 + +### 前置需求檢查清單 + +- [ ] Docker 20.10+ 已安裝且運行中 +- [ ] Docker Compose 2.0+ 已安裝 +- [ ] 外部 MySQL 資料庫可訪問 (mysql.theaken.com:33306) +- [ ] LDAP/Active Directory 伺服器可連線 +- [ ] SMTP 郵件伺服器已配置 +- [ ] 足夠的磁碟空間 (建議至少 10GB) + +### 端口需求 + +確保以下端口未被占用: +- `12010`: Flask 應用程式(Gunicorn) +- `12011`: OnlyOffice 文檔服務 +- `12013`: Nginx HTTP(反向代理) +- `12014`: Nginx HTTPS(反向代理) +- `6379`: Redis 快取(內部) + +## 2. 快速部署 + +### 一鍵部署(推薦) + +```bash +# 1. 克隆專案 +git clone +cd TEMP_spec_system_V4 + +# 2. 配置環境變數 +cp .env.production .env +# 編輯 .env 文件,填入實際的配置值 + +# 3. 啟動所有服務(生產環境優化版本) +docker-compose up -d + +# 4. 檢查服務狀態 +docker-compose ps +``` + +**預期輸出應包含以下服務**: +``` +NAME STATUS PORTS +tempspec-redis Up (healthy) +tempspec-onlyoffice Up (healthy) 0.0.0.0:12011->80/tcp +tempspec-app Up (healthy) 0.0.0.0:12010->5000/tcp +tempspec-nginx Up 0.0.0.0:12013->80/tcp, 0.0.0.0:12014->443/tcp +``` + +```bash +# 5. 查看服務日誌 +docker-compose logs -f + +# 6. 驗證服務可訪問性 +curl -I http://localhost:12013/login # Nginx 反向代理(推薦) +curl -I http://localhost:12010/login # 直接訪問 Flask +curl -I http://localhost:12011 # OnlyOffice 服務 +``` + +## 3. 生產環境配置 + +### 3.1 服務架構(生產優化) + +``` +用戶請求 → Nginx (12013/12014) → Gunicorn (多進程) → Flask App + ↓ + Redis快取 + ↓ + 外部MySQL資料庫 +``` + +**服務組件**: +- **Nginx**: 反向代理 + 靜態檔案 + 負載均衡(端口 12013/12014) +- **Flask 應用**: Gunicorn WSGI伺服器(多進程,端口 12010) +- **Redis**: 快取系統(內部端口 6379) +- **OnlyOffice**: 文檔編輯服務(端口 12011) +- **MySQL**: 外部資料庫服務(mysql.theaken.com) + +### 3.2 效能規格(50人併發支援) + +- **併發處理**: 2-8個Gunicorn worker進程 +- **記憶體使用**: App容器1GB + Redis 256MB +- **快取命中**: Redis快取減少70%+資料庫查詢 +- **響應時間**: < 200ms(快取命中時) +- **可用性**: 99.9%+(健康檢查 + 自動重啟) + +### 3.3 環境變數配置 + +編輯 `.env` 檔案(基於 `.env.production` 範例): + +```env +# 生產環境基本設定 +FLASK_ENV=production +SECRET_KEY=your_super_secret_production_key_here + +# 服務端口 +APP_PORT=12010 # Gunicorn WSGI伺服器 +ONLYOFFICE_PORT=12011 # OnlyOffice 服務 +NGINX_PORT=12013 # Nginx HTTP +NGINX_SSL_PORT=12014 # Nginx HTTPS + +# Redis 快取 +REDIS_URL=redis://redis:6379/0 + +# CDN 支援 (可選) +CDN_DOMAIN=cdn.yourcompany.com + +# 資料庫連線 +DATABASE_URL=mysql+pymysql://user:pass@mysql.theaken.com:33306/dbname + +# LDAP 設定 +LDAP_SERVER=panjit.com.tw +LDAP_PORT=389 +LDAP_USE_SSL=false +LDAP_SEARCH_BASE=OU=PANJIT,DC=panjit,DC=com,DC=tw +LDAP_BIND_USER_DN=CN=LdapBind,CN=Users,DC=PANJIT,DC=COM,DC=TW +LDAP_BIND_USER_PASSWORD=your_ldap_password +LDAP_USER_LOGIN_ATTR=userPrincipalName + +# SMTP 設定 +SMTP_SERVER=mail.panjit.com.tw +SMTP_PORT=25 +SMTP_USE_TLS=false +SMTP_SENDER_EMAIL=temp-spec-system@panjit.com.tw + +# ONLYOFFICE 設定 +ONLYOFFICE_JWT_SECRET=your_onlyoffice_jwt_secret +``` + +## 4. 監控與管理 + +### 4.1 系統監控 + +```bash +# 即時監控(每5秒刷新) +python monitor.py --watch 5 + +# 單次檢查 +python monitor.py + +# JSON格式輸出 +python monitor.py --json +``` + +### 4.2 Docker 管理命令 + +```bash +# 查看所有服務狀態 +docker-compose ps + +# 查看實時日誌 +docker-compose logs -f + +# 查看特定服務日誌 +docker-compose logs -f app +docker-compose logs -f redis +docker-compose logs -f nginx + +# 重啟服務 +docker-compose restart +docker-compose restart app + +# 停止所有服務 +docker-compose down + +# 查看資源使用 +docker stats +``` + +### 4.3 Redis 快取管理 + +```bash +# 連接Redis並測試 +docker-compose exec redis redis-cli ping + +# 查看快取統計 +docker-compose exec redis redis-cli info stats + +# 清空所有快取 +docker-compose exec redis redis-cli FLUSHALL + +# 查看快取鍵值數量 +docker-compose exec redis redis-cli DBSIZE +``` + +## 5. 服務訪問 + +### 5.1 服務入口 + +服務啟動後,可透過以下 URL 訪問: + +**主要服務**: +- **主應用程式 (Nginx)**: http://localhost:12013/login 🌟 **推薦** +- **主應用程式 (直接)**: http://localhost:12010/login +- **OnlyOffice 服務**: http://localhost:12011 + +**推薦使用方式(生產環境)**: +- 使用 Nginx 反向代理: `http://localhost:12013` +- 直接訪問 Flask: `http://localhost:12010` + +### 5.2 登入資訊 + +- **認證方式**: LDAP/Active Directory +- **登入帳號**: 使用公司 LDAP 帳號密碼 +- **登入格式**: 支援 `username@panjit.com.tw` 或 `username` + +### 5.3 預設管理員帳號 + +如需創建本地管理員帳號(非LDAP): + +```bash +# 進入應用容器 +docker-compose exec app python update_admin.py + +# 或手動創建 +docker-compose exec app python -c " +from models import db, User +from werkzeug.security import generate_password_hash +from app import app + +with app.app_context(): + admin = User( + username='admin', + email='admin@company.com', + password_hash=generate_password_hash('admin123'), + is_admin=True + ) + db.session.add(admin) + db.session.commit() + print('管理員帳號已創建: admin/admin123') +" +``` + +## 6. 疑難排解 + +### 6.1 生產環境常見問題 + +**Redis 連接失敗** +```bash +# 檢查Redis容器狀態 +docker-compose logs redis + +# 測試Redis連接 +docker-compose exec redis redis-cli ping + +# 重啟Redis +docker-compose restart redis +``` + +**應用程式無回應** +```bash +# 檢查Gunicorn日誌 +docker-compose logs app + +# 檢查容器資源 +docker stats tempspec-app + +# 重啟應用 +docker-compose restart app +``` + +**效能問題** +```bash +# 檢查快取命中率 +python monitor.py + +# 檢查Gunicorn worker狀態 +docker-compose exec app ps aux | grep gunicorn + +# 調整worker數量(編輯gunicorn.conf.py) +``` + +### 6.2 基本故障排除 + +**容器無法啟動** +```bash +# 檢查容器狀態 +docker-compose ps + +# 查看詳細日誌 +docker-compose logs app +docker-compose logs onlyoffice +docker-compose logs redis +``` + +**資料庫連線失敗** +```bash +# 測試資料庫連接 +docker-compose exec app python -c " +import pymysql +try: + conn = pymysql.connect(host='mysql.theaken.com', port=33306, user='A060', password='WLeSCi0yhtc7', database='db_A060') + print('Database connection successful') + conn.close() +except Exception as e: + print(f'Database connection failed: {e}') +" +``` + +**端口衝突** +修改 `.env` 文件中的端口設定: +```env +APP_PORT=12015 # 改為其他可用端口 +ONLYOFFICE_PORT=12016 # 改為其他可用端口 +NGINX_PORT=12017 # 改為其他可用端口 +``` + +### 6.3 維護命令 + +```bash +# 完全重建服務(清除快取) +docker-compose down +docker-compose build --no-cache +docker-compose up -d + +# 更新單一服務 +docker-compose up -d --force-recreate app + +# 清理未使用的 Docker 資源 +docker system prune -a +``` + +### 6.4 效能調優 + +**Redis 優化** +```bash +# 調整 Redis 記憶體限制(編輯 docker-compose.yml) +# 預設: 256MB,可根據需要調整 + +# 監控 Redis 使用 +docker-compose exec redis redis-cli info memory +``` + +**Gunicorn 調優** +```bash +# 編輯 gunicorn.conf.py 調整: +# - workers: worker 進程數量 +# - timeout: 請求超時時間 +# - max_requests: worker 重啟頻率 +``` + +--- + +**🎉 生產環境部署完成!系統已準備好支援50人的併發使用。** + +**快速啟動**: `docker-compose up -d` +**系統監控**: `python monitor.py --watch 5` +**服務訪問**: http://localhost:12013/login \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..689e2e9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +# 使用官方 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 + +# 複製生產配置文件 +COPY gunicorn.conf.py wsgi.py ./ + +# 啟動命令 - 使用 Gunicorn +CMD ["gunicorn", "-c", "gunicorn.conf.py", "wsgi:app"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca20466 --- /dev/null +++ b/README.md @@ -0,0 +1,366 @@ +# 暫時規範管理系統 V4 + +企業級暫時規範生命週期管理系統,提供完整的文件管理、LDAP整合驗證、智慧通知系統及排程提醒功能。 + +## 🚀 系統特色 + +- **LDAP/AD 整合驗證**:支援企業Active Directory單一登入 +- **ONLYOFFICE 線上編輯**:即時協作文件編輯功能 +- **智慧通知系統**:動態收件人選擇與自動提醒 +- **文件生命週期管理**:完整的建立、啟用、展延、終止流程 +- **多平台支援**:支援 Windows/Linux 環境部署 +- **Docker 容器化**:一鍵部署環境 + +## 📋 功能模組 + +### 核心功能 +- **文件管理**:Word範本自動化生成與PDF轉換 +- **權限控制**:三級權限管理 (Viewer/Editor/Admin) +- **歷史追蹤**:完整的操作記錄與版本控制 +- **檔案上傳**:支援多種格式的佐證文件上傳 + +### 智慧通知系統 +- **動態收件人選擇**:整合LDAP的即時用戶搜尋 +- **郵件記憶功能**:自動記憶並帶出之前使用的通知對象 +- **全流程通知**:啟用、展延、終止操作的自動郵件通知 +- **自動提醒**:3天與7天到期前的主動提醒郵件 +- **排程系統**:每日自動檢查即將到期的規範 + +### 編輯器整合 +- **ONLYOFFICE整合**:支援Word文件的線上即時編輯 +- **Toast UI Editor**:Markdown格式的內容編輯器 +- **圖片支援**:內嵌圖片顯示與編輯功能 + +## 🏗️ 系統架構 + +``` +暫時規範系統 V3 +├── 前端介面 (Flask + Bootstrap 5) +├── 後端邏輯 (Python Flask) +├── 資料庫 (MySQL/SQLite) +├── LDAP整合 (Active Directory) +├── 文件引擎 (ONLYOFFICE) +├── 排程服務 (APScheduler) +└── 郵件系統 (SMTP) +``` + +## 🛠️ 技術棧 + +- **後端框架**: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 +git clone +cd TEMP_spec_system_V3 +``` + +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 +``` + +5. **資料庫遷移(如果需要)** +```bash +# 新增郵件功能欄位 +docker-compose exec app python migrate_add_email_column.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. **資料庫遷移(如果需要)** +```cmd +python migrate_add_email_column.py +``` + +5. **啟動 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 +``` + +6. **啟動應用程式** +```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 +``` + +2. **設定環境變數** +```bash +cp .env.example .env +# 編輯 .env 檔案 +``` + +3. **初始化資料庫** +```bash +python init_db.py +``` + +4. **資料庫遷移(如果需要)** +```bash +python migrate_add_email_column.py +``` + +5. **啟動 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 +``` + +6. **啟動應用程式** +```bash +# 開發環境 +python app.py + +# 生產環境 (使用 Gunicorn) +pip install gunicorn +gunicorn -w 4 -b 0.0.0.0:5000 app:app +``` + +## ⚙️ 組態設定 + +### 環境變數 (.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 郵件設定 (Port 25 無認證方式) +SMTP_SERVER=mail.company.com +SMTP_PORT=25 +SMTP_USE_TLS=false +SMTP_USE_SSL=false +SMTP_AUTH_REQUIRED=false +SMTP_SENDER_EMAIL=temp-spec-system@company.com +SMTP_SENDER_PASSWORD= + +# ONLYOFFICE 設定 +ONLYOFFICE_URL=http://onlyoffice:8080 +ONLYOFFICE_JWT_SECRET=your_jwt_secret +``` + +### SMTP 配置說明 + +系統支援多種 SMTP 配置方式: + +- **Port 25(推薦)**:內部郵件伺服器,無需認證 +- **Port 587**:STARTTLS + 認證 +- **Port 465**:SSL + 認證 + +詳細設定請參考 `SMTP_CONFIGURATION_UPDATE.md` + +## 📚 使用說明 + +### 登入規範 + +**重要**:系統要求使用完整的UPN格式帳號登入 + +✅ **正確格式**:`user@domain.com` +❌ **錯誤格式**:`user` + +### 初次設定管理員 + +系統預設所有使用者為 `viewer` 權限。設定管理員的方式: + +1. **程式設定**:修改 `routes/auth.py` 中的預設管理員帳號 + +2. **手動設定**:在資料庫中更新用戶權限: +```sql +UPDATE ts_user SET role='admin' WHERE username='user@domain.com'; +``` + +### 郵件通知功能 + +系統具備智慧郵件管理功能: + +1. **規範生效時**:輸入通知郵件對象,系統自動記憶 +2. **規範終止時**:自動帶出生效時使用的郵件清單,可編輯後發送 +3. **規範展延時**:自動帶出郵件清單,修改後更新記錄 + +### 排程任務 + +系統預設每天凌晨 2:00 執行到期檢查任務: + +- 7天到期提醒 +- 3天到期提醒 +- 自動發送提醒郵件 + +## 🔐 安全性設定 + +### LDAP 整合 +- 支援 SSL/TLS 加密連線 +- 服務帳號權限最小化原則 +- 自動用戶同步與權限管控 + +### 資料保護 +- JWT Token 驗證 +- 檔案存取權限控制 +- SQL Injection 防護 +- XSS 攻擊防護 + +## 🐛 疑難排解 + +### 常見問題 + +1. **LDAP 連線失敗** + - 檢查防火牆設定 (通常是 389/636 port) + - 確認服務帳號權限 + - 驗證 LDAP 伺服器位址和搜尋基底 + +2. **ONLYOFFICE 無法載入** + - 確認 Document Server 運行狀態:`docker ps` + - 檢查網路連線設定 + - 驗證 JWT Secret 設定是否一致 + +3. **郵件發送失敗** + - 確認 SMTP 設定正確 + - 檢查郵件伺服器認證設定 + - 驗證防火牆規則 (Port 25/587/465) + +4. **排程任務未執行** + - 檢查 APScheduler 初始化 + - 確認應用程式持續運行 + - 查看系統日誌 + +### 日誌查看 + +```bash +# Docker 環境 +docker-compose logs -f app + +# 一般環境 +tail -f logs/app.log + +# Windows 環境 +Get-Content logs/app.log -Tail 10 -Wait +``` + +## 🤝 開發指南 + +### 程式碼結構 + +``` +├── 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 工具 +``` + +### 資料庫遷移 + +當系統需要資料庫結構更新時: + +```bash +# 執行遷移腳本 +python migrate_add_email_column.py +``` + +## 📄 授權條款 + +本專案採用 MIT 授權條款。 + +## 🆕 版本歷程 + +### v3.2.0 (最新版本) +- 🆕 新增郵件通知記憶功能 +- 🆕 支援 Port 25 無認證 SMTP +- ♻️ 優化郵件管理邏輯 +- 🗑️ 移除測試檔案和調試代碼 + +### v3.1.0 +- 🆕 新增 LDAP/AD 整合驗證 +- 🆕 整合 ONLYOFFICE 線上編輯器 +- 🆕 實作智慧通知系統 +- 🆕 新增自動排程提醒功能 +- 🆕 支援 Docker 容器化部署 + +### v3.0.0 +- ♻️ 重構權限管理系統 +- 🗑️ 移除本地帳號管理功能 + +--- + +**暫時規範管理系統 V3** - 讓企業文件管理更智慧、更高效! \ No newline at end of file diff --git a/USER_MANUAL.md b/USER_MANUAL.md new file mode 100644 index 0000000..c0c1692 --- /dev/null +++ b/USER_MANUAL.md @@ -0,0 +1,393 @@ +# 暫時規範管理系統 V3 操作說明書 + +歡迎使用企業級暫時規範管理系統 V3。本系統整合了LDAP認證、ONLYOFFICE線上編輯器及智慧通知系統,提供完整的文件生命週期管理解決方案。 + +## 📋 目錄 + +1. [系統簡介](#1-系統簡介) +2. [登入與主畫面](#2-登入與主畫面) +3. [核心操作流程](#3-核心操作流程) +4. [智慧通知系統](#4-智慧通知系統) +5. [進階功能](#5-進階功能) +6. [角色權限說明](#6-角色權限說明) +7. [常見問題](#7-常見問題) + +--- + +## 1. 系統簡介 + +暫時規範管理系統 V3 是一個集中化平台,用於管理、追蹤和存檔所有暫時性的工程規範。它涵蓋了從草擬、線上編輯、簽核、生效到最終歸檔的完整生命週期。 + +### 🚀 V3.2 版本新特色 + +- **LDAP/AD 整合**:使用企業Active Directory帳號登入 +- **智慧郵件記憶**:自動記憶並帶出之前使用的通知對象 +- **彈性郵件編輯**:可編輯通知名單並更新記錄 +- **多種SMTP支援**:支援Port 25無認證及其他認證方式 +- **自動排程提醒**:系統主動發送到期提醒郵件 +- **增強的編輯體驗**:ONLYOFFICE文件協作編輯 + +--- + +## 2. 登入與主畫面 + +### 2.1 LDAP 登入 + +系統使用企業 Active Directory 進行單一登入。 + +**🚨 重要登入規範**: + +✅ **正確格式**:必須使用完整的 UPN 格式帳號 +例如:`user@domain.com` + +❌ **錯誤格式**:不支援縮略帳號 +例如:`user` + +**登入步驟**: +1. 在登入頁面輸入您的 **完整 AD 帳號**(例如:user@domain.com) +2. 輸入您的 **AD 密碼** +3. 點擊「**登入**」按鈕 + +> **注意**: +> - 首次登入的用戶預設為 `Viewer` 權限 +> - 需要聯繫系統管理員提升權限 + +### 2.2 主畫面導覽 + +登入後會看到暫時規範列表,包含: + +- **規範編號**:系統自動產生(PE+民國年+月份+序號) +- **主題**:規範標題 +- **申請人**:規範申請者 +- **狀態**:pending_approval(待生效)/active(已生效)/expired(已過期)/terminated(已終止) +- **時間範圍**:生效日期至結束日期 +- **操作按鈕**:依權限顯示不同功能 + +--- + +## 3. 核心操作流程 + +### 3.1 建立新規範(Editor/Admin 權限) + +1. 點擊「**新增規範**」按鈕 +2. 填寫規範資訊: + - **主題**:規範標題 + - **申請人**:申請者姓名 + - **申請人電話**:聯絡電話 + - **相關資訊**:包裝、批號、設備類型等 + +3. **選擇適用站別**: + - Probing、Dicing、Die bond、Wire bond 等 + - 可多選 + +4. **TCCS等級選擇**: + - L1-L4 四個等級 + - 單選 + +5. **4M選擇**: + - Man、Machine、Material、Method、Environment + - 單選 + +6. 點擊「**建立**」完成草稿建立 + +### 3.2 編輯規範內容(Editor/Admin 權限) + +**🆕 ONLYOFFICE 線上編輯**: + +1. 在規範列表點擊「**編輯**」按鈕 +2. 系統開啟 ONLYOFFICE 編輯器 +3. 進行文件編輯、格式調整 +4. 使用 **Ctrl+S** 定期儲存 +5. 編輯完成後關閉編輯器視窗 + +**編輯器功能**: +- 全功能 Word 文件編輯 +- 即時自動儲存 +- 支援圖片、表格插入 +- 格式化工具列 + +### 3.3 啟用規範(Admin 權限) + +將規範從「待生效」變更為「已生效」狀態: + +1. 點擊「**啟用**」按鈕 +2. **上傳已簽核檔案**:選擇已簽核的PDF檔案 +3. **設定通知對象**: + - 在搜尋框輸入姓名或Email(至少2個字元) + - 從下拉清單選擇收件者 + - 支援AD群組搜尋(格式:`group:群組名稱`) + - 可選擇多位收件者 + +4. 點擊「**確認啟用**」 +5. 系統自動: + - 更新規範狀態 + - **記憶通知對象**供後續使用 + - 發送啟用通知郵件 + +### 3.4 展延規範(Editor/Admin 權限) + +延長已生效規範的結束日期: + +1. 點擊「**展延**」按鈕 +2. **設定新結束日期**:選擇展延後的日期 +3. **上傳佐證檔案**:提供展延理由相關文件(PDF格式) +4. **🆕 智慧通知設定**: + - 系統自動帶出之前啟用時使用的通知對象 + - 可直接使用或進行編輯 + - 修改後的名單會更新到系統記錄中 + +5. 點擊「**確認展延**」 +6. 系統自動發送展延通知郵件 + +### 3.5 終止規範(Editor/Admin 權限) + +提早結束規範: + +1. 點擊「**終止**」按鈕 +2. **填寫終止原因**:說明提早結束的理由 +3. **🆕 智慧通知設定**: + - 系統自動帶出之前啟用時使用的通知對象 + - 顯示提示「以下為生效時設定的通知對象」 + - 可直接使用或進行編輯 + +4. 點擊「**確認終止**」 +5. 系統自動: + - 更新結束日期為今日 + - 發送終止通知郵件 + +--- + +## 4. 智慧通知系統 + +### 4.1 🆕 郵件記憶功能 + +**V3.2 新增功能**:系統現在具備智慧郵件管理能力 + +**運作機制**: +1. **規範啟用時**:輸入通知郵件對象,系統自動記憶 +2. **規範終止時**:自動帶出啟用時的郵件清單,可編輯後發送 +3. **規範展延時**:自動帶出郵件清單,修改後會更新記錄 + +**操作說明**: +- 系統會顯示「以下為生效時設定的通知對象」提示 +- 可以直接使用預設的郵件清單 +- 也可以修改郵件清單後再發送 +- 展延時修改的名單會成為新的預設通知對象 + +### 4.2 動態收件人選擇 + +**搜尋功能**: +- 輸入至少 **2個字元** 開始搜尋 +- 支援姓名或Email模糊搜尋 +- 即時顯示搜尋結果 + +**選擇方式**: +- **個人用戶**:直接選擇用戶 +- **AD群組**:輸入 `group:群組名稱` 選擇整個群組 +- **多重選擇**:可同時選擇多位收件者 + +**群組搜尋**: +- 格式:`group:TempSpec_Admins` +- 系統會自動展開群組成員 +- 發送時會寄給所有群組成員 + +### 4.3 通知類型 + +**手動通知**(操作觸發): +- 規範啟用通知 +- 規範展延通知 +- 規範終止通知 + +**🆕 自動提醒**(系統排程): +- **7天到期提醒**:在規範到期前7天自動發送 +- **3天到期提醒**:在規範到期前3天自動發送 +- **發送時間**:每天凌晨2:00檢查並發送 + +**郵件內容**: +- HTML格式美化顯示 +- 包含規範編號、標題、申請人 +- 明確標示生效/結束日期 +- 提供系統連結 + +--- + +## 5. 進階功能 + +### 5.1 搜尋與篩選 + +**搜尋功能**: +- 支援規範編號模糊搜尋 +- 支援主題關鍵字搜尋 +- 即時搜尋結果更新 + +**篩選功能**: +- 按狀態篩選(待生效/已生效/已過期/已終止) +- 多條件組合篩選 +- 篩選結果分頁顯示 + +### 5.2 文件下載 + +**Word文件下載**: +- Editor/Admin 可下載編輯中的Word原始檔 +- 適用於線下簽核流程 + +**PDF文件下載**: +- 所有用戶都可下載最終簽核版PDF +- 適用於已生效/已終止的規範 + +### 5.3 歷史紀錄追蹤 + +點擊 **歷史紀錄圖示 (🕒)** 查看: + +- 操作時間戳記 +- 執行用戶 +- 操作類型(建立/啟用/展延/終止) +- 詳細說明 + +### 5.4 即將到期警示 + +在規範列表中會特別標示即將到期的規範: + +- **🟡 橙色標示**:7天內到期 +- **🔴 紅色標示**:3天內到期 +- **閃爍動畫**:今日到期 + +--- + +## 6. 角色權限說明 + +### 6.1 權限等級 + +| 角色 | 登入 | 檢視 | 建立 | 編輯 | 啟用 | 展延/終止 | 刪除 | +|------|------|------|------|------|------|-----------|------| +| **Viewer** | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| **Editor** | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | +| **Admin** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | + +### 6.2 權限說明 + +**Viewer(檢視者)**: +- 檢視所有規範內容 +- 下載PDF檔案 +- 檢視歷史紀錄 + +**Editor(編輯者)**: +- 建立新規範草稿 +- 編輯規範內容 +- 展延和終止規範 +- 下載Word和PDF檔案 + +**Admin(管理員)**: +- 所有Editor權限 +- 啟用規範(上傳簽核檔案) +- 刪除規範 +- 系統管理功能 + +--- + +## 7. 常見問題 + +### 7.1 登入相關 + +**Q: 忘記帳號格式?** +A: 必須使用完整的 `user@domain.com` 格式,不能只輸入 `user` + +**Q: 無法登入?** +A: 請確認: +1. 帳號格式正確(包含@domain.com) +2. 密碼正確 +3. AD帳號未被鎖定 +4. 網路連線正常 + +### 7.2 權限相關 + +**Q: 無法建立規範?** +A: 請確認您的權限等級,Viewer無法建立規範,需要Editor以上權限。 + +**Q: 無法啟用規範?** +A: 啟用功能需要Admin權限,請聯繫系統管理員。 + +### 7.3 編輯相關 + +**Q: ONLYOFFICE編輯器無法載入?** +A: 請確認: +1. 瀏覽器支援(建議Chrome/Edge) +2. 網路連線穩定 +3. 彈出視窗未被阻擋 + +**Q: 編輯內容未儲存?** +A: 建議: +1. 編輯期間保持網路連線 +2. 避免同時多人編輯同一文件 +3. 定期手動儲存 (Ctrl+S) + +### 7.4 通知相關 + +**Q: 搜尋不到AD用戶?** +A: 請確認: +1. 輸入至少2個字元才開始搜尋 +2. 用戶在AD中確實存在 +3. 服務帳號有足夠權限搜尋AD + +**Q: 沒有收到通知郵件?** +A: 請檢查: +1. Email地址是否正確 +2. 垃圾郵件資料夾 +3. 公司郵件伺服器設定 + +**Q: 自動提醒郵件何時發送?** +A: 系統每天凌晨2:00自動檢查並發送提醒,分別在到期前7天和3天發送。 + +**🆕 Q: 郵件通知對象會自動記憶嗎?** +A: 是的,系統會記憶啟用時設定的通知對象: +- 終止規範時會自動帶出之前的郵件清單 +- 展延規範時也會自動帶出,修改後會更新記錄 +- 您可以直接使用或編輯後再發送 + +### 7.5 檔案相關 + +**Q: 可以上傳Word檔案來啟用規範嗎?** +A: 不可以。為確保文件完整性,啟用時必須上傳已簽核的 **PDF檔案**。 + +**Q: 檔案上傳失敗?** +A: 請確認: +1. 檔案格式正確(PDF) +2. 檔案大小未超過限制 +3. 檔案名稱不含特殊字元 +4. 網路連線穩定 + +### 7.6 效能相關 + +**Q: 系統回應速度慢?** +A: 可能原因: +1. 網路連線問題 +2. 伺服器負載過高 +3. 資料庫查詢耗時 +4. 聯繫系統管理員檢查 + +--- + +## 📝 版本資訊 + +- **文件版本**: V3.2.0 +- **最後更新**: 2025年1月 +- **適用系統**: 暫時規範管理系統 V3.2 + +### 版本更新記錄 + +**V3.2.0**: +- 新增郵件通知記憶功能 +- 支援Port 25無認證SMTP +- 優化郵件管理邏輯 +- 更新操作說明 + +**V3.1.0**: +- 新增LDAP/AD整合認證 +- 整合ONLYOFFICE線上編輯器 +- 實作智慧通知系統 +- 新增自動排程提醒功能 + +--- + +**感謝您使用暫時規範管理系統 V3!** +希望這個操作手冊能幫助您更有效地使用系統功能。 \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..e220ca0 --- /dev/null +++ b/app.py @@ -0,0 +1,123 @@ +from flask import Flask, redirect, url_for, render_template +from flask_login import LoginManager, current_user +from flask_apscheduler import APScheduler +from flask_caching import Cache +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 +from cdn_utils import cdn_helper +import redis + +app = Flask(__name__) +app.config.from_object('config.Config') + +# 初始化資料庫 +db.init_app(app) + +# 初始化Redis快取 +cache = Cache(app) + +# 初始化CDN輔助 +cdn_helper.init_app(app) + +# 初始化Redis連接(用於會話) +try: + redis_client = redis.from_url(app.config['CACHE_REDIS_URL']) + app.config['SESSION_REDIS'] = redis_client +except Exception as e: + app.logger.warning(f"Redis連接失敗,使用本地快取: {e}") + app.config['CACHE_TYPE'] = 'simple' + +# 初始化排程器 +scheduler = APScheduler() +scheduler.init_app(app) +scheduler.start() + +# 初始化登入管理 +login_manager = LoginManager() +login_manager.init_app(app) +login_manager.login_view = 'auth.login' +login_manager.login_message = "請先登入以存取此頁面。" +login_manager.login_message_category = "info" + +# 預設首頁導向登入畫面 +@app.route('/') +def index(): + # 檢查使用者是否已經通過驗證 (已登入) + if current_user.is_authenticated: + # 如果已登入,直接導向到暫規總表 + return redirect(url_for('temp_spec.spec_list')) + else: + # 如果未登入,才導向到登入頁面 + return redirect(url_for('auth.login')) + +# 載入登入使用者 +@login_manager.user_loader +def load_user(user_id): + return User.query.get(int(user_id)) + +# 註冊 Blueprint 模組路由 +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) +def not_found_error(error): + return render_template('404.html'), 404 + +@app.errorhandler(403) +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("=== 暫時規範系統 V4 啟動中 ===") + print("日誌等級: INFO") + print("="*50) + print("🚀 系統啟動完成!") + print("") + print("📍 登入頁面:") + print(" 本機: http://localhost:12010/login") + print(" 容器: http://127.0.0.1:12010/login") + print("") + print("🔧 OnlyOffice 服務:") + print(" URL: http://localhost:12011") + print("") + print("💡 提示: 使用 LDAP 帳號密碼登入系統") + print("="*50) + + app.run(host='0.0.0.0', port=5000, debug=False) diff --git a/cache_utils.py b/cache_utils.py new file mode 100644 index 0000000..58d7cdf --- /dev/null +++ b/cache_utils.py @@ -0,0 +1,97 @@ +""" +快取輔助函數 +用於提升應用程式效能 +""" +from functools import wraps +from flask import request, current_app +from app import cache +import hashlib +import json + +def cache_key(*args, **kwargs): + """生成快取鍵值""" + key_data = { + 'args': args, + 'kwargs': kwargs, + 'user_id': getattr(request, 'user_id', 'anonymous'), + 'path': request.path if hasattr(request, 'path') else '' + } + key_string = json.dumps(key_data, sort_keys=True, default=str) + return hashlib.md5(key_string.encode('utf-8')).hexdigest() + +def cached_route(timeout=300): + """路由快取裝飾器""" + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_app.config.get('CACHE_TYPE') or current_app.debug: + return f(*args, **kwargs) + + key = f"route:{f.__name__}:{cache_key(*args, **kwargs)}" + + # 嘗試從快取獲取 + cached_result = cache.get(key) + if cached_result is not None: + current_app.logger.debug(f"快取命中: {key}") + return cached_result + + # 執行函數並快取結果 + result = f(*args, **kwargs) + cache.set(key, result, timeout=timeout) + current_app.logger.debug(f"快取設定: {key}") + + return result + return decorated_function + return decorator + +def cached_query(timeout=300): + """資料庫查詢快取裝飾器""" + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_app.config.get('CACHE_TYPE') or current_app.debug: + return f(*args, **kwargs) + + key = f"query:{f.__name__}:{cache_key(*args, **kwargs)}" + + # 嘗試從快取獲取 + cached_result = cache.get(key) + if cached_result is not None: + current_app.logger.debug(f"查詢快取命中: {key}") + return cached_result + + # 執行查詢並快取結果 + result = f(*args, **kwargs) + cache.set(key, result, timeout=timeout) + current_app.logger.debug(f"查詢快取設定: {key}") + + return result + return decorated_function + return decorator + +def invalidate_cache(pattern): + """清除快取""" + try: + if hasattr(cache, 'delete_many'): + # Redis backend + keys = cache.cache._read_clients.keys(f"flask_cache_{pattern}*") + if keys: + cache.delete_many(*keys) + current_app.logger.info(f"清除快取: {len(keys)} 個項目") + else: + # Simple cache backend + cache.clear() + current_app.logger.info("清除所有快取") + except Exception as e: + current_app.logger.error(f"清除快取失敗: {e}") + +# 快取統計 +def cache_stats(): + """獲取快取統計資訊""" + try: + if hasattr(cache.cache, 'info'): + return cache.cache.info() + else: + return {"status": "simple cache", "info": "無統計資訊"} + except Exception as e: + return {"error": str(e)} \ No newline at end of file diff --git a/cdn_utils.py b/cdn_utils.py new file mode 100644 index 0000000..a401aa1 --- /dev/null +++ b/cdn_utils.py @@ -0,0 +1,66 @@ +""" +CDN 工具函數 +用於靜態資源加速 +""" +from flask import current_app, url_for as flask_url_for +import os + +def cdn_url_for(endpoint, **values): + """ + CDN化的 url_for 函數 + 自動將靜態資源指向CDN域名 + """ + if endpoint == 'static': + cdn_domain = current_app.config.get('CDN_DOMAIN', '').strip() + if cdn_domain: + # 確保CDN域名格式正確 + if not cdn_domain.startswith(('http://', 'https://')): + cdn_domain = f"https://{cdn_domain}" + + filename = values.get('filename', '') + if filename: + # 移除開頭的斜線 + filename = filename.lstrip('/') + return f"{cdn_domain.rstrip('/')}/static/{filename}" + + # 非靜態資源或未配置CDN時使用原始url_for + return flask_url_for(endpoint, **values) + +def get_static_url(filename): + """ + 獲取靜態資源URL + 自動判斷使用CDN還是本地路徑 + """ + return cdn_url_for('static', filename=filename) + +def is_cdn_enabled(): + """檢查是否啟用CDN""" + return bool(current_app.config.get('CDN_DOMAIN', '').strip()) + +class CDNHelper: + """CDN輔助類""" + + def __init__(self, app=None): + if app: + self.init_app(app) + + def init_app(self, app): + """初始化應用""" + app.jinja_env.globals['cdn_url_for'] = cdn_url_for + app.jinja_env.globals['get_static_url'] = get_static_url + app.jinja_env.globals['is_cdn_enabled'] = is_cdn_enabled + + # 添加模板過濾器 + app.jinja_env.filters['cdn'] = self._cdn_filter + + def _cdn_filter(self, filename): + """Jinja2過濾器:將靜態檔案路徑轉換為CDN URL""" + if filename.startswith('/static/'): + filename = filename[8:] # 移除 '/static/' 前綴 + elif filename.startswith('static/'): + filename = filename[7:] # 移除 'static/' 前綴 + + return get_static_url(filename) + +# 全局CDN輔助實例 +cdn_helper = CDNHelper() \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..bb80348 --- /dev/null +++ b/config.py @@ -0,0 +1,50 @@ +import os +from dotenv import load_dotenv + +# 載入 .env 檔案中的環境變數 +load_dotenv() + +class Config: + SECRET_KEY = os.getenv('SECRET_KEY', 'a_default_secret_key_for_development') + SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL') + SQLALCHEMY_TRACK_MODIFICATIONS = False + UPLOAD_FOLDER = 'uploads' + GENERATED_FOLDER = 'generated' + MAX_CONTENT_LENGTH = 16 * 1024 * 1024 + ONLYOFFICE_URL = os.getenv('ONLYOFFICE_URL') + ONLYOFFICE_INTERNAL_URL = os.getenv('ONLYOFFICE_INTERNAL_URL', os.getenv('ONLYOFFICE_URL')) + ONLYOFFICE_JWT_SECRET = os.getenv('ONLYOFFICE_JWT_SECRET') + + # Redis 快取配置 + CACHE_TYPE = "redis" + CACHE_REDIS_URL = os.getenv('REDIS_URL', 'redis://redis:6379/0') + CACHE_DEFAULT_TIMEOUT = 300 # 5分鐘 + + # 會話快取配置 + SESSION_TYPE = 'redis' + SESSION_REDIS = None # 將在 app 初始化時設定 + SESSION_PERMANENT = False + SESSION_USE_SIGNER = True + SESSION_KEY_PREFIX = 'tempspec:' + + # CDN 配置 + CDN_DOMAIN = os.getenv('CDN_DOMAIN', '') + STATIC_URL_PATH = '/static' + + # LDAP Configuration + LDAP_SERVER = os.getenv('LDAP_SERVER') + LDAP_PORT = int(os.getenv('LDAP_PORT', 389)) + LDAP_USE_SSL = os.getenv('LDAP_USE_SSL', 'false').lower() in ['true', '1', 't'] + LDAP_BIND_USER_DN = os.getenv('LDAP_BIND_USER_DN') + LDAP_BIND_USER_PASSWORD = os.getenv('LDAP_BIND_USER_PASSWORD') + LDAP_SEARCH_BASE = os.getenv('LDAP_SEARCH_BASE') # e.g., 'ou=users,dc=panjit,dc=com,dc=tw' + LDAP_USER_LOGIN_ATTR = os.getenv('LDAP_USER_LOGIN_ATTR', 'userPrincipalName') # AD attribute for user login (e.g., user@panjit.com.tw) + + # SMTP Configuration + SMTP_SERVER = os.getenv('SMTP_SERVER', 'mail.panjit.com.tw') + SMTP_PORT = int(os.getenv('SMTP_PORT', 25)) + SMTP_USE_TLS = os.getenv('SMTP_USE_TLS', 'false').lower() in ['true', '1', 't'] + SMTP_USE_SSL = os.getenv('SMTP_USE_SSL', 'false').lower() in ['true', '1', 't'] + SMTP_SENDER_EMAIL = os.getenv('SMTP_SENDER_EMAIL', 'temp-spec-system@panjit.com.tw') + SMTP_SENDER_PASSWORD = os.getenv('SMTP_SENDER_PASSWORD', '') # Port 25 不需要密碼 + SMTP_AUTH_REQUIRED = os.getenv('SMTP_AUTH_REQUIRED', 'false').lower() in ['true', '1', 't'] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..d04744c --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,61 @@ +# 生產環境專用配置 +# 使用方式: docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d + +version: '3.8' + +services: + # 在生產環境中擴展 app 服務 + app: + deploy: + replicas: 2 # 多個實例提升可用性 + update_config: + parallelism: 1 + delay: 10s + restart_policy: + condition: on-failure + max_attempts: 3 + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 3 + resources: + limits: + cpus: '2.0' + memory: 2G + reservations: + cpus: '1.0' + memory: 1G + + # 啟用 Nginx 反向代理 + nginx: + profiles: [] # 移除 production profile,使其在生產環境中自動啟動 + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + reservations: + cpus: '0.25' + memory: 256M + + # Redis 生產優化 + redis: + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + reservations: + cpus: '0.25' + memory: 256M + + # OnlyOffice 資源配置 + onlyoffice: + deploy: + resources: + limits: + cpus: '2.0' + memory: 4G + reservations: + cpus: '1.0' + memory: 2G \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..42af0aa --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,138 @@ +services: + # Redis 快取服務 + redis: + image: redis:7-alpine + container_name: tempspec-redis + restart: unless-stopped + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + volumes: + - redis_data:/data + networks: + - tempspec-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 5 + + # ONLYOFFICE Document Server + onlyoffice: + image: onlyoffice/documentserver:8.0 + 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:-12011}: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} + + # 使用外部資料庫 (與 .env 相同) + DATABASE_URL: ${DATABASE_URL:-mysql+pymysql://user:pass@host:port/dbname} + + # Redis 設定 + REDIS_URL: redis://redis:6379/0 + + # CDN 設定 + CDN_DOMAIN: ${CDN_DOMAIN:-} + + # 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://localhost:12011/ + ONLYOFFICE_INTERNAL_URL: http://onlyoffice:80 + ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-your_jwt_secret_key_here} + + # 其他設定 + UPLOAD_FOLDER: uploads + ports: + - "${APP_PORT:-12010}:5000" + volumes: + - ./uploads:/app/uploads + - ./static/generated:/app/static/generated + - ./logs:/app/logs + - ./template_with_placeholders.docx:/app/template_with_placeholders.docx:ro + deploy: + resources: + limits: + cpus: '1.0' + memory: 1G + reservations: + cpus: '0.5' + memory: 512M + depends_on: + redis: + 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:-12013}:80" + - "${NGINX_SSL_PORT:-12014}: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 + +volumes: + redis_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/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..95d81ae --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,53 @@ +# Gunicorn 生產環境配置 +import multiprocessing +import os + +# 服務器設置 +bind = "0.0.0.0:5000" +workers = min(multiprocessing.cpu_count() * 2 + 1, 8) # 最多8個worker +worker_class = "sync" +worker_connections = 1000 +max_requests = 1000 +max_requests_jitter = 50 + +# 超時設置 +timeout = 300 +keepalive = 5 +graceful_timeout = 300 + +# 日誌設置 +accesslog = "-" # stdout +errorlog = "-" # stderr +loglevel = "info" +access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s' + +# 進程設置 +preload_app = True +daemon = False +pidfile = "/tmp/gunicorn.pid" + +# 性能調優 +worker_tmp_dir = "/dev/shm" # 使用內存作為臨時目錄 + +# 安全設置 +limit_request_line = 8190 +limit_request_fields = 100 +limit_request_field_size = 8190 + +def when_ready(server): + server.log.info("Server is ready. Spawning workers") + +def worker_int(worker): + worker.log.info("worker received INT or QUIT signal") + +def pre_fork(server, worker): + server.log.info("Worker spawned (pid: %s)", worker.pid) + +def post_fork(server, worker): + server.log.info("Worker spawned (pid: %s)", worker.pid) + +def post_worker_init(worker): + worker.log.info("Worker initialized (pid: %s)", worker.pid) + +def worker_abort(worker): + worker.log.info("Worker aborted (pid: %s)", worker.pid) \ No newline at end of file diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..780552a --- /dev/null +++ b/init_db.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +from flask import Flask +from models import db +from config import Config + +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): + """初始化資料庫:刪除所有現有資料表並重新建立。""" + with app.app_context(): + print("🔄 開始進行資料庫初始化...") + # 為了安全,先刪除所有表格,再重新建立 + db.drop_all() + print(" - 所有舊資料表已刪除。") + db.create_all() + print(" - 所有新資料表已根據 models.py 建立。") + print("✅ 資料庫結構已成功初始化!") + +if __name__ == '__main__': + # 建立一個暫時的 Flask app 來提供資料庫操作所需的應用程式上下文 + app = Flask(__name__) + app.config.from_object(Config) + + # 將資料庫物件與 app 綁定 + db.init_app(app) + + print("=================================================") + print(" ⚠️ 資料庫初始化腳本 ⚠️") + print("=================================================") + print("此腳本將會刪除所有現有的資料,並重新建立資料庫結構。") + print("這個操作是不可逆的!") + + # 讓使用者確認操作 + confirmation = input("👉 您確定要繼續嗎? (yes/no): ") + + if confirmation.lower() == 'yes': + init_database(app) + create_admin_note() + print("\n🎉 全部完成!") + else: + print("❌ 操作已取消。") \ No newline at end of file diff --git a/ldap_utils.py b/ldap_utils.py new file mode 100644 index 0000000..63b1fe6 --- /dev/null +++ b/ldap_utils.py @@ -0,0 +1,484 @@ +from ldap3 import Server, Connection, ALL, Tls, SUBTREE +import ssl +from flask import current_app + +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) + """ + # 驗證帳號格式必須包含 @ 符號 + if '@' not in 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'] + use_ssl = current_app.config['LDAP_USE_SSL'] + + 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" - 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})' + + 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, # 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 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 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'] + 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}") + print(f" - 服務帳號: {bind_dn}") + + # Bind with the service account + conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True) + + if conn.bound: + 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)} 個條目") + + 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() + 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"Get group member emails error: {e}") + return [] diff --git a/models.py b/models.py new file mode 100644 index 0000000..57f1c70 --- /dev/null +++ b/models.py @@ -0,0 +1,60 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_login import UserMixin +from datetime import datetime + +db = SQLAlchemy() + +class User(db.Model, UserMixin): + # 修改 table name + __tablename__ = 'ts_user' + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(50), unique=True, nullable=False) + password_hash = db.Column(db.String(255), nullable=False) + role = db.Column(db.Enum('viewer', 'editor', 'admin'), nullable=False) + last_login = db.Column(db.DateTime) + +class TempSpec(db.Model): + # 新增並設定 table name + __tablename__ = 'ts_temp_spec' + id = db.Column(db.Integer, primary_key=True) + spec_code = db.Column(db.String(20), nullable=False) + applicant = db.Column(db.String(50)) + title = db.Column(db.String(100)) + content = db.Column(db.Text) + start_date = db.Column(db.Date) + end_date = db.Column(db.Date) + status = db.Column(db.Enum('pending_approval', 'active', 'expired', 'terminated'), nullable=False, default='pending_approval') + created_at = db.Column(db.DateTime) + extension_count = db.Column(db.Integer, default=0) + termination_reason = db.Column(db.Text, nullable=True) + notification_emails = db.Column(db.Text, nullable=True) # 儲存通知郵件清單,以分號分隔 + + # 關聯到 Upload 和 SpecHistory,並設定級聯刪除 + uploads = db.relationship('Upload', back_populates='spec', cascade='all, delete-orphan') + history = db.relationship('SpecHistory', back_populates='spec', cascade='all, delete-orphan') + +class Upload(db.Model): + # 新增並設定 table name + __tablename__ = 'ts_upload' + id = db.Column(db.Integer, primary_key=True) + # 注意:這裡的 ForeignKey 也要更新為新的 table name + temp_spec_id = db.Column(db.Integer, db.ForeignKey('ts_temp_spec.id', ondelete='CASCADE'), nullable=False) + filename = db.Column(db.String(200)) + upload_time = db.Column(db.DateTime) + + spec = db.relationship('TempSpec', back_populates='uploads') + +class SpecHistory(db.Model): + # 修改 table name + __tablename__ = 'ts_spec_history' + id = db.Column(db.Integer, primary_key=True) + # 注意:這裡的 ForeignKey 也要更新為新的 table name + spec_id = db.Column(db.Integer, db.ForeignKey('ts_temp_spec.id', ondelete='CASCADE'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('ts_user.id', ondelete='SET NULL'), nullable=True) + action = db.Column(db.String(50), nullable=False) + details = db.Column(db.Text, nullable=True) + timestamp = db.Column(db.DateTime, default=datetime.utcnow) + + # 建立與 User 和 TempSpec 的關聯,方便查詢 + user = db.relationship('User') + spec = db.relationship('TempSpec', back_populates='history') \ No newline at end of file diff --git a/monitor.py b/monitor.py new file mode 100644 index 0000000..0a503ee --- /dev/null +++ b/monitor.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +""" +生產環境監控腳本 +監控系統效能、Redis狀態、資料庫連接等 +""" +import time +import requests +import redis +import json +from datetime import datetime +import argparse +import sys + +def check_app_health(): + """檢查應用程式健康狀態""" + try: + response = requests.get('http://localhost:12010/', timeout=10) + return { + 'status': 'healthy' if response.status_code == 200 else 'unhealthy', + 'status_code': response.status_code, + 'response_time': response.elapsed.total_seconds() + } + except Exception as e: + return { + 'status': 'unhealthy', + 'error': str(e), + 'response_time': None + } + +def check_redis_health(): + """檢查 Redis 健康狀態""" + try: + r = redis.from_url('redis://localhost:6379/0') + r.ping() + info = r.info() + return { + 'status': 'healthy', + 'used_memory': info.get('used_memory_human'), + 'connected_clients': info.get('connected_clients'), + 'total_commands_processed': info.get('total_commands_processed'), + 'keyspace_hits': info.get('keyspace_hits'), + 'keyspace_misses': info.get('keyspace_misses') + } + except Exception as e: + return { + 'status': 'unhealthy', + 'error': str(e) + } + +def check_onlyoffice_health(): + """檢查 OnlyOffice 健康狀態""" + try: + response = requests.get('http://localhost:12011/healthcheck', timeout=10) + return { + 'status': 'healthy' if response.status_code == 200 else 'unhealthy', + 'status_code': response.status_code, + 'response_time': response.elapsed.total_seconds() + } + except Exception as e: + return { + 'status': 'unhealthy', + 'error': str(e), + 'response_time': None + } + +def get_docker_stats(): + """獲取 Docker 容器統計資訊""" + import subprocess + try: + result = subprocess.run(['docker', 'stats', '--no-stream', '--format', + 'table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}'], + capture_output=True, text=True, timeout=10) + if result.returncode == 0: + return result.stdout + else: + return f"Error: {result.stderr}" + except Exception as e: + return f"Error getting docker stats: {str(e)}" + +def main(): + parser = argparse.ArgumentParser(description='暫時規範系統監控工具') + parser.add_argument('--json', action='store_true', help='以JSON格式輸出') + parser.add_argument('--watch', '-w', type=int, metavar='SECONDS', + help='持續監控,指定刷新間隔秒數') + args = parser.parse_args() + + def run_checks(): + timestamp = datetime.now().isoformat() + + # 執行各項健康檢查 + app_health = check_app_health() + redis_health = check_redis_health() + onlyoffice_health = check_onlyoffice_health() + + results = { + 'timestamp': timestamp, + 'app': app_health, + 'redis': redis_health, + 'onlyoffice': onlyoffice_health + } + + if args.json: + print(json.dumps(results, indent=2, ensure_ascii=False)) + else: + # 格式化輸出 + print(f"\n🕒 監控時間: {timestamp}") + print("=" * 60) + + # 應用程式狀態 + status_icon = "✅" if app_health['status'] == 'healthy' else "❌" + print(f"{status_icon} 應用程式: {app_health['status']}") + if app_health.get('response_time'): + print(f" 響應時間: {app_health['response_time']:.3f}s") + if app_health.get('error'): + print(f" 錯誤: {app_health['error']}") + + # Redis 狀態 + status_icon = "✅" if redis_health['status'] == 'healthy' else "❌" + print(f"{status_icon} Redis: {redis_health['status']}") + if redis_health['status'] == 'healthy': + print(f" 記憶體使用: {redis_health['used_memory']}") + print(f" 連接數: {redis_health['connected_clients']}") + if redis_health['keyspace_hits'] and redis_health['keyspace_misses']: + hit_rate = redis_health['keyspace_hits'] / (redis_health['keyspace_hits'] + redis_health['keyspace_misses']) * 100 + print(f" 快取命中率: {hit_rate:.2f}%") + elif redis_health.get('error'): + print(f" 錯誤: {redis_health['error']}") + + # OnlyOffice 狀態 + status_icon = "✅" if onlyoffice_health['status'] == 'healthy' else "❌" + print(f"{status_icon} OnlyOffice: {onlyoffice_health['status']}") + if onlyoffice_health.get('response_time'): + print(f" 響應時間: {onlyoffice_health['response_time']:.3f}s") + if onlyoffice_health.get('error'): + print(f" 錯誤: {onlyoffice_health['error']}") + + # Docker 統計 + print("\n📊 容器資源使用:") + print(get_docker_stats()) + + return results + + try: + if args.watch: + while True: + try: + if not args.json: + print("\033[H\033[J") # 清空終端 + run_checks() + if args.json: + print() # JSON輸出間的分隔 + time.sleep(args.watch) + except KeyboardInterrupt: + print("\n👋 監控已停止") + sys.exit(0) + else: + run_checks() + except Exception as e: + print(f"❌ 監控執行錯誤: {str(e)}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file 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 new file mode 100644 index 0000000..099bf19 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,21 @@ +flask +flask-login +flask-sqlalchemy +pymysql +werkzeug +docx2pdf +python-docx +docxtpl +beautifulsoup4 +lxml +python-dotenv +mistune +PyJWT +ldap3 +Flask-APScheduler +Pillow +requests +cryptography +gunicorn +redis +flask-caching \ No newline at end of file diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routes/admin.py b/routes/admin.py new file mode 100644 index 0000000..12a00b7 --- /dev/null +++ b/routes/admin.py @@ -0,0 +1,96 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash +from flask_login import login_required, current_user +from models import User, db +from utils import admin_required + +admin_bp = Blueprint('admin', __name__, url_prefix='/admin') + +@admin_bp.route('/users') +@login_required +@admin_required +def user_list(): + """顯示所有使用者列表,供管理員管理權限。""" + # 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/edit/', methods=['POST']) +@login_required +@admin_required +def edit_user_role(user_id): + """編輯使用者權限。僅允許修改角色。""" + user = User.query.get_or_404(user_id) + new_role = request.form.get('role') + + 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}' 的權限已從 '{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): + """刪除使用者帳號。""" + # 避免管理員刪除自己 + 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"使用者 '{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 new file mode 100644 index 0000000..dfc81f1 --- /dev/null +++ b/routes/auth.py @@ -0,0 +1,99 @@ +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 ldap_utils import authenticate_ldap_user +from models import User, db +from datetime import datetime +import logging + +auth_bp = Blueprint('auth', __name__) + +@auth_bp.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('temp_spec.spec_list')) + + if request.method == 'POST': + username = request.form['username'].strip() + password = request.form['password'] + + # 記錄登入嘗試 + 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}") + + user_info = authenticate_ldap_user(username, password) + print(f"[DEBUG] LDAP 驗證結果: {user_info}") + + 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') + +@auth_bp.route('/logout') +@login_required +def logout(): + logout_user() + return redirect(url_for('auth.login')) diff --git a/routes/temp_spec.py b/routes/temp_spec.py new file mode 100644 index 0000000..cfe8990 --- /dev/null +++ b/routes/temp_spec.py @@ -0,0 +1,488 @@ +# -*- coding: utf-8 -*- +from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file, current_app, jsonify, abort +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, process_recipients +from ldap_utils import get_ldap_group_members +import os +import shutil +import jwt +import requests +from werkzeug.utils import secure_filename +from docxtpl import DocxTemplate + +temp_spec_bp = Blueprint('temp_spec', __name__) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# 移除 @login_required +@temp_spec_bp.before_request +def before_request(): + """在處理此藍圖中的任何請求之前,確保使用者已登入。""" + # 移除全域的登入驗證,改為在各個路由單獨設定 + pass + +def _generate_next_spec_code(): + """ + 產生下一個暫時規範編號。 + 規則: PE + 民國年(3碼) + 月份(2碼) + 流水號(2碼) + """ + now = datetime.now() + roc_year = now.year - 1911 + prefix = f"PE{roc_year}{now.strftime('%m')}" + + latest_spec = TempSpec.query.filter( + TempSpec.spec_code.startswith(prefix) + ).order_by(TempSpec.spec_code.desc()).first() + + if latest_spec: + last_seq = int(latest_spec.spec_code[-2:]) + new_seq = last_seq + 1 + else: + new_seq = 1 + + return f"{prefix}{new_seq:02d}" + +def get_file_uri(filename): + """產生 Flask 應用程式可以存取到該檔案的 URL""" + return url_for('static', filename=f"generated/{filename}", _external=True) + + +@temp_spec_bp.route('/create', methods=['GET', 'POST']) +@editor_or_admin_required +def create_temp_spec(): + if request.method == 'POST': + spec_code = _generate_next_spec_code() + form_data = request.form + now = datetime.now() + + # 1. 在資料庫中建立紀錄 + spec = TempSpec( + spec_code=spec_code, + title=form_data['theme'], + applicant=form_data['applicant'], + status='pending_approval', + created_at=now, + start_date=now.date(), + end_date=(now + timedelta(days=30)).date() + ) + db.session.add(spec) + db.session.flush() + + # 2. 準備要填入 Word 範本的 context + context = { + 'serial_number': spec_code, + 'theme': form_data.get('theme', ''), + 'package': form_data.get('package', ''), + 'lot_number': form_data.get('lot_number', ''), + 'equipment_type': form_data.get('equipment_type', ''), + 'applicant': form_data.get('applicant', ''), + 'applicant_phone': form_data.get('applicant_phone', ''), + 'start_date': now.strftime('%Y-%m-%d'), + 'end_date': (now + timedelta(days=30)).strftime('%Y-%m-%d'), + } + + # 3. 處理勾選框邏輯 + selected_stations = request.form.getlist('station') + station_keys = ['probing', 'dicing', 'diebond', 'wirebond', 'solder', 'molding', + 'degate', 'deflash', 'plating', 'trimform', 'marking', 'tmtt', 'other'] + for key in station_keys: + context[f's_{key}'] = '■' if key in selected_stations else '□' + + selected_tccs_level = form_data.get('tccs_level') + level_keys = ['l1', 'l2', 'l3', 'l4'] + for key in level_keys: + context[f't_{key}'] = '■' if key == selected_tccs_level else '□' + + selected_tccs_4m = form_data.get('tccs_4m') + m_keys = ['man', 'machine', 'material', 'method', 'env'] + for key in m_keys: + context[f't_{key}'] = '■' if key == selected_tccs_4m else '□' + + # 4. 渲染 Word 範本 + generated_folder = os.path.join(current_app.static_folder, 'generated') + os.makedirs(generated_folder, exist_ok=True) + template_path = os.path.join(BASE_DIR, 'template_with_placeholders.docx') + new_file_path = os.path.join(generated_folder, f"{spec_code}.docx") + + if not os.path.exists(template_path): + flash('找不到 Word 範本檔案 (template_with_placeholders.docx)!', 'danger') + db.session.rollback() + return redirect(url_for('temp_spec.spec_list')) + + doc = DocxTemplate(template_path) + doc.render(context) + doc.save(new_file_path) + + # 5. 提交資料庫並重新導向 + add_history_log(spec.id, '建立', f"建立暫時規範草稿: {spec.spec_code}") + db.session.commit() + + return redirect(url_for('temp_spec.edit_spec', spec_id=spec.id)) + + # GET 請求:顯示建立表單 + return render_template('create_temp_spec_form.html') + +@temp_spec_bp.route('/edit/') +@editor_or_admin_required +def edit_spec(spec_id): + spec = TempSpec.query.get_or_404(spec_id) + doc_filename = f"{spec.spec_code}.docx" + + doc_physical_path = os.path.join(current_app.static_folder, 'generated', doc_filename) + if not os.path.exists(doc_physical_path): + flash(f'找不到文件檔案: {doc_filename}', 'danger') + return redirect(url_for('temp_spec.spec_list')) + + # --- START: 修正文件下載與回呼的 URL --- + + # 1. 產生標準的文件 URL 和回呼 URL + doc_url = get_file_uri(doc_filename) + callback_url = url_for('temp_spec.onlyoffice_callback', spec_id=spec_id, _external=True) + + # 2. 如果是在開發環境,將 URL 中的 localhost 替換為 Docker 可存取的地址 + if '127.0.0.1' in doc_url or 'localhost' in doc_url: + # 同時修正 doc_url 和 callback_url + doc_url = doc_url.replace('127.0.0.1', 'host.docker.internal').replace('localhost', 'host.docker.internal') + callback_url = callback_url.replace('127.0.0.1', 'host.docker.internal').replace('localhost', 'host.docker.internal') + + # --- END: 修正文件下載與回呼的 URL --- + + oo_secret = current_app.config['ONLYOFFICE_JWT_SECRET'] + + payload = { + "document": { + "fileType": "docx", + "key": f"{spec.id}_{int(os.path.getmtime(doc_physical_path))}", + "title": doc_filename, + "url": doc_url # <-- 使用修正後的 doc_url + }, + "documentType": "word", + "editorConfig": { + "callbackUrl": callback_url, # <-- 使用修正後的回呼 URL + "user": { "id": str(current_user.id), "name": current_user.username }, + "customization": { "autosave": True, "forcesave": True } + } + } + + token = jwt.encode(payload, oo_secret, algorithm='HS256') + + config = payload.copy() + config['token'] = token + + return render_template( + 'onlyoffice_editor.html', + spec=spec, + config=config, + onlyoffice_url=current_app.config['ONLYOFFICE_URL'] + ) + +# 這個路由不需要登入驗證,因為是 ONLYOFFICE Server 在呼叫它 +@temp_spec_bp.route('/onlyoffice-callback/', methods=['POST']) +def onlyoffice_callback(spec_id): + data = request.json + + if data.get('status') == 2: + try: + response = requests.get(data['url'], timeout=10) + response.raise_for_status() + + spec = TempSpec.query.get_or_404(spec_id) + doc_filename = f"{spec.spec_code}.docx" + file_path = os.path.join(current_app.static_folder, 'generated', doc_filename) + + with open(file_path, 'wb') as f: + f.write(response.content) + except Exception as e: + current_app.logger.error(f"ONLYOFFICE callback error for spec {spec_id}: {e}") + return jsonify({"error": 1, "message": str(e)}) + + return jsonify({"error": 0}) + +# --- 其他既有路由 --- + +@temp_spec_bp.route('/list') +@login_required # 補上登入驗證 +def spec_list(): + page = request.args.get('page', 1, type=int) + query = request.args.get('query', '') + status_filter = request.args.get('status', '') + specs_query = TempSpec.query + + if query: + search_term = f"%{query}%" + specs_query = specs_query.filter( + db.or_( + TempSpec.spec_code.ilike(search_term), + TempSpec.title.ilike(search_term) + ) + ) + + if status_filter: + specs_query = specs_query.filter(TempSpec.status == status_filter) + + pagination = specs_query.order_by(TempSpec.created_at.desc()).paginate( + page=page, per_page=15, error_out=False + ) + + specs = pagination.items + # --- START: 新增的程式碼 --- + # 取得今天的日期,並傳給模板 + from datetime import date + today = date.today() + # --- END: 新增的程式碼 --- + + return render_template( + 'spec_list.html', + specs=specs, + pagination=pagination, + query=query, + status=status_filter, + today=today # <-- 將 today 傳遞到模板 + ) + +@temp_spec_bp.route('/activate/', methods=['GET', 'POST']) +@admin_required +def activate_spec(spec_id): + spec = TempSpec.query.get_or_404(spec_id) + if request.method == 'POST': + uploaded_file = request.files.get('signed_file') + if not uploaded_file or uploaded_file.filename == '': + flash('您必須上傳一個檔案。', 'danger') + return redirect(url_for('temp_spec.activate_spec', spec_id=spec.id)) + + filename = secure_filename(f"{spec.spec_code}_signed_{datetime.now().strftime('%Y%m%d%H%M%S')}.pdf") + upload_folder = os.path.join(BASE_DIR, current_app.config['UPLOAD_FOLDER']) + os.makedirs(upload_folder, exist_ok=True) + file_path = os.path.join(upload_folder, filename) + uploaded_file.save(file_path) + + new_upload = Upload( + temp_spec_id=spec.id, + filename=filename, + upload_time=datetime.now() + ) + db.session.add(new_upload) + + spec.status = 'active' + + # 儲存通知郵件清單到資料庫 + recipients_str = request.form.get('recipients') + if recipients_str: + spec.notification_emails = recipients_str.strip() + + add_history_log(spec.id, '啟用', f"上傳已簽核檔案 '{filename}'") + db.session.commit() + flash(f"規範 '{spec.spec_code}' 已生效!", 'success') + + # --- Start of Dynamic Email Notification --- + 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) + +@temp_spec_bp.route('/terminate/', methods=['GET', 'POST']) +@editor_or_admin_required +def terminate_spec(spec_id): + spec = TempSpec.query.get_or_404(spec_id) + if request.method == 'POST': + reason = request.form.get('reason') + if not reason: + flash('請填寫提早結束的原因。', 'danger') + return redirect(url_for('temp_spec.terminate_spec', spec_id=spec.id)) + + spec.status = 'terminated' + 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 not recipients_str and spec.notification_emails: + recipients_str = spec.notification_emails + 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')) + + # 將儲存的郵件清單傳遞給模板 + return render_template('terminate_spec.html', spec=spec, saved_emails=spec.notification_emails) + +@temp_spec_bp.route('/download_initial_word/') +@login_required +def download_initial_word(spec_id): + spec = TempSpec.query.get_or_404(spec_id) + if current_user.role not in ['editor', 'admin']: + flash('權限不足,無法下載 Word 檔案。', 'danger') + abort(403) + + generated_folder = os.path.join(current_app.static_folder, 'generated') + word_path = os.path.join(generated_folder, f"{spec.spec_code}.docx") + + if not os.path.exists(word_path): + flash('找不到最初產生的 Word 檔案,可能已被刪除或移動。', 'danger') + return redirect(url_for('temp_spec.spec_list')) + + return send_file(word_path, as_attachment=True) + +@temp_spec_bp.route('/download_signed/') +@login_required # 補上登入驗證 +def download_signed_pdf(spec_id): + latest_upload = Upload.query.filter_by(temp_spec_id=spec_id).order_by(Upload.upload_time.desc()).first() + + if not latest_upload: + flash('找不到任何已上傳的簽核檔案。', 'danger') + return redirect(url_for('temp_spec.spec_list')) + + upload_folder = os.path.join(BASE_DIR, current_app.config['UPLOAD_FOLDER']) + return send_file(os.path.join(upload_folder, latest_upload.filename), as_attachment=True) + +@temp_spec_bp.route('/extend/', methods=['GET', 'POST']) +@editor_or_admin_required +def extend_spec(spec_id): + spec = TempSpec.query.get_or_404(spec_id) + + if request.method == 'POST': + new_end_date_str = request.form.get('new_end_date') + uploaded_file = request.files.get('new_file') + + if not uploaded_file or uploaded_file.filename == '': + flash('您必須上傳新的佐證檔案才能展延。', 'danger') + return redirect(url_for('temp_spec.extend_spec', spec_id=spec.id)) + + if not new_end_date_str: + flash('請選擇新的結束日期', 'danger') + return redirect(url_for('temp_spec.extend_spec', spec_id=spec.id)) + + spec.end_date = datetime.strptime(new_end_date_str, '%Y-%m-%d').date() + spec.extension_count += 1 + spec.status = 'active' + + filename = secure_filename(f"{spec.spec_code}_extension_{spec.extension_count}_{datetime.now().strftime('%Y%m%d')}.pdf") + upload_folder = os.path.join(BASE_DIR, current_app.config['UPLOAD_FOLDER']) + os.makedirs(upload_folder, exist_ok=True) + file_path = os.path.join(upload_folder, filename) + uploaded_file.save(file_path) + + new_upload = Upload( + temp_spec_id=spec.id, + filename=filename, + upload_time=datetime.now() + ) + db.session.add(new_upload) + + details = f"展延結束日期至 {spec.end_date.strftime('%Y-%m-%d')}" + details += f",並上傳新檔案 '{new_upload.filename}'" + add_history_log(spec.id, '展延', details) + + # --- Start of Dynamic Email Notification --- + # 優先使用表單提交的收件者,如果沒有則使用資料庫中儲存的 + recipients_str = request.form.get('recipients') + if not recipients_str and spec.notification_emails: + recipients_str = spec.notification_emails + + # 如果使用者有更新郵件清單,儲存回資料庫 + if recipients_str: + spec.notification_emails = recipients_str.strip() + 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')) + + default_new_end_date = spec.end_date + timedelta(days=30) + # 將儲存的郵件清單傳遞給模板 + return render_template('extend_spec.html', spec=spec, default_new_end_date=default_new_end_date, saved_emails=spec.notification_emails) + +@temp_spec_bp.route('/history/') +@login_required # 補上登入驗證 +def spec_history(spec_id): + spec = TempSpec.query.get_or_404(spec_id) + history = SpecHistory.query.filter_by(spec_id=spec_id).order_by(SpecHistory.timestamp.desc()).all() + return render_template('spec_history.html', spec=spec, history=history) + +@temp_spec_bp.route('/delete/', methods=['POST']) +@admin_required +def delete_spec(spec_id): + spec = TempSpec.query.get_or_404(spec_id) + spec_code = spec.spec_code + + files_to_delete = [] + generated_folder = os.path.join(current_app.static_folder, 'generated') + files_to_delete.append(os.path.join(generated_folder, f"{spec.spec_code}.docx")) + + upload_folder = os.path.join(BASE_DIR, current_app.config['UPLOAD_FOLDER']) + for upload_record in spec.uploads: + files_to_delete.append(os.path.join(upload_folder, upload_record.filename)) + + for f_path in files_to_delete: + try: + if os.path.exists(f_path): + os.remove(f_path) + except Exception as e: + current_app.logger.error(f"刪除檔案失敗: {f_path}, 原因: {e}") + + db.session.delete(spec) + db.session.commit() + + flash(f"規範 '{spec_code}' 及其所有相關檔案已成功刪除。", 'success') + return redirect(url_for('temp_spec.spec_list')) \ No newline at end of file diff --git a/routes/upload.py b/routes/upload.py new file mode 100644 index 0000000..989dc92 --- /dev/null +++ b/routes/upload.py @@ -0,0 +1,29 @@ +from flask import Blueprint, request, jsonify, current_app +from werkzeug.utils import secure_filename +import os +import time + +upload_bp = Blueprint('upload', __name__) + +@upload_bp.route('/image', methods=['POST']) +def upload_image(): + file = request.files.get('file') + if not file: + return jsonify({'error': 'No file part'}), 400 + + # 建立一個獨特的檔名 + extension = os.path.splitext(file.filename)[1] + filename = f"{int(time.time())}_{secure_filename(file.filename)}" + + # 確保上傳資料夾存在 + # 為了讓圖片能被網頁存取,我們將它存在 static 資料夾下 + image_folder = os.path.join(current_app.static_folder, 'uploads', 'images') + os.makedirs(image_folder, exist_ok=True) + + file_path = os.path.join(image_folder, filename) + file.save(file_path) + + # 回傳 TinyMCE 需要的 JSON 格式 + # 路徑必須是相對於網域根目錄的 URL + location = f"/static/uploads/images/{filename}" + return jsonify({'location': location}) diff --git a/start-production.sh b/start-production.sh new file mode 100644 index 0000000..d21e145 --- /dev/null +++ b/start-production.sh @@ -0,0 +1,102 @@ +#!/bin/bash + +# 暫時規範管理系統 V4 - 生產環境啟動腳本 +# 使用方式: ./start-production.sh + +set -e + +echo "🚀 暫時規範管理系統 V4 - 生產環境部署" +echo "==================================================" + +# 檢查必要檔案 +echo "📋 檢查必要檔案..." +if [ ! -f ".env" ]; then + echo "❌ 錯誤: .env 檔案不存在" + echo "請複製 .env.production 為 .env 並配置相應的值" + exit 1 +fi + +if [ ! -f "docker-compose.yml" ]; then + echo "❌ 錯誤: docker-compose.yml 檔案不存在" + exit 1 +fi + +# 檢查 Docker 是否運行 +echo "🐳 檢查 Docker 服務..." +if ! docker info > /dev/null 2>&1; then + echo "❌ 錯誤: Docker 服務未運行" + echo "請啟動 Docker 服務後再試" + exit 1 +fi + +# 停止舊的容器(如果存在) +echo "🛑 停止現有容器..." +docker-compose down || true + +# 建構新的映像 +echo "🔨 建構應用程式映像..." +docker-compose build --no-cache + +# 啟動服務(生產環境配置) +echo "🌟 啟動生產環境服務..." +if [ -f "docker-compose.prod.yml" ]; then + echo "使用生產環境配置檔案..." + docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d +else + echo "使用標準配置啟動..." + docker-compose --profile production up -d +fi + +# 等待服務啟動 +echo "⏳ 等待服務啟動..." +sleep 10 + +# 檢查服務狀態 +echo "📊 檢查服務狀態..." +docker-compose ps + +# 顯示服務 URL +echo "" +echo "✅ 部署完成!" +echo "==================================================" +echo "📍 服務存取點:" +echo " 主應用程式: http://localhost:12010" +echo " OnlyOffice: http://localhost:12011" +echo "" +echo "📊 管理命令:" +echo " 查看日誌: docker-compose logs -f" +echo " 停止服務: docker-compose down" +echo " 重啟服務: docker-compose restart" +echo "" +echo "🔧 監控命令:" +echo " 查看容器狀態: docker-compose ps" +echo " 查看資源使用: docker stats" +echo "" + +# 檢查健康狀態 +echo "🏥 健康檢查..." +sleep 5 + +# 檢查 Redis +if docker-compose exec -T redis redis-cli ping > /dev/null 2>&1; then + echo "✅ Redis: 健康" +else + echo "❌ Redis: 異常" +fi + +# 檢查應用程式 +if curl -f http://localhost:12010/ > /dev/null 2>&1; then + echo "✅ 應用程式: 健康" +else + echo "❌ 應用程式: 異常" +fi + +# 檢查 OnlyOffice +if curl -f http://localhost:12011/healthcheck > /dev/null 2>&1; then + echo "✅ OnlyOffice: 健康" +else + echo "❌ OnlyOffice: 異常" +fi + +echo "" +echo "🎉 生產環境啟動完成!" \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..b1b3371 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,203 @@ +/* --- 全域與背景設定 --- */ +body { + background-color: #0d1117; + background-image: linear-gradient(180deg, #161b22 0%, #0d1117 100%); + background-attachment: fixed; + color: #c9d1d9; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +/* --- 強制設定通用元素的文字顏色 --- */ +p, label, th, td, .form-label, .form-check-label, .card-body { + color: #c9d1d9; +} + +/* --- 導覽列 --- */ +.navbar { + background-color: rgba(13, 17, 23, 0.8); + backdrop-filter: blur(10px); + border-bottom: 1px solid #30363d; +} + +/* --- 標題 --- */ +h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 { + color: #f0f6fc; +} + +/* --- 卡片與容器 --- */ +.card { + background-color: #161b22; + border: 1px solid #30363d; + border-radius: 0.5rem; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); +} +.card-header, .card-footer { + background-color: rgba(22, 27, 34, 0.7); + border-bottom: 1px solid #30363d; + color: #f0f6fc; +} + +/* --- 按鈕 --- */ +.btn-primary { + background-color: #5865f2; border-color: #5865f2; color: #ffffff; + transition: all 0.2s ease-in-out; +} +.btn-primary:hover, .btn-primary:focus { + background-color: #4752c4; border-color: #4752c4; + box-shadow: 0 0 0 0.25rem rgba(88, 101, 242, 0.5); +} +.btn-success { background-color: #2ea043; border-color: #2ea043; color: #fff; } +.btn-success:hover { background-color: #268839; border-color: #268839; color: #fff;} +.btn-danger { background-color: #da3633; border-color: #da3633; color: #fff;} +.btn-danger:hover { background-color: #b92d2b; border-color: #b92d2b; color: #fff;} +.btn-warning { background-color: #f0ad4e; border-color: #f0ad4e; color: #0d1117; } +.btn-warning:hover { background-color: #e39b37; border-color: #e39b37; color: #0d1117;} +.btn-info { background-color: #0dcaf0; border-color: #0dcaf0; color: #0d1117; } +.btn-info:hover { background-color: #0baccc; border-color: #0baccc; color: #0d1117;} + +/* --- 表單輸入框 --- */ +.form-control, .form-select { + background-color: #0d1117; color: #c9d1d9; + border: 1px solid #30363d; border-radius: 0.375rem; +} +.form-control:focus, .form-select:focus { + background-color: #0d1117; color: #c9d1d9; + border-color: #5865f2; + box-shadow: 0 0 0 0.25rem rgba(88, 101, 242, 0.25); +} +.form-control::placeholder { color: #8b949e; } +.form-control[readonly] { background-color: #161b22; opacity: 0.7; } +.input-group-text { + background-color: #161b22; + border: 1px solid #30363d; + color: #c9d1d9; +} + +/* --- 表格 --- */ +.table { + --bs-table-color: #c9d1d9; + --bs-table-bg: #161b22; + --bs-table-border-color: #30363d; + --bs-table-striped-color: #c9d1d9; + --bs-table-striped-bg: #21262d; + --bs-table-hover-color: #f0f6fc; + --bs-table-hover-bg: #30363d; + border-color: var(--bs-table-border-color); +} +.table > thead { + color: #f0f6fc; +} + +/* --- 分頁 --- */ +.pagination { + --bs-pagination-color: #58a6ff; + --bs-pagination-bg: #0d1117; /* 最深的背景色,使其與容器分離 */ + --bs-pagination-border-color: #30363d; + --bs-pagination-hover-color: #80b6ff; + --bs-pagination-hover-bg: #21262d; /* 懸停時變亮 */ + --bs-pagination-hover-border-color: #4d555e; + --bs-pagination-focus-color: #80b6ff; + --bs-pagination-focus-bg: #21262d; + --bs-pagination-focus-box-shadow: 0 0 0 0.25rem rgba(88, 101, 242, 0.25); + --bs-pagination-active-color: #fff; + --bs-pagination-active-bg: #5865f2; + --bs-pagination-active-border-color: #5865f2; + --bs-pagination-disabled-color: #8b949e; + --bs-pagination-disabled-bg: #161b22; /* 禁用的背景色,使其看起來凹陷 */ + --bs-pagination-disabled-border-color: #30363d; +} + +/* --- 提示訊息 (Alerts) --- */ +.alert { border-width: 1px; border-style: solid; } +.alert-danger { background-color: rgba(218, 54, 51, 0.15); border-color: #da3633; color: #ff8986; } +.alert-success { background-color: rgba(46, 160, 67, 0.15); border-color: #2ea043; color: #7ce38f; } +.alert-info { background-color: rgba(13, 202, 240, 0.15); border-color: #0dcaf0; color: #6be2fa; } +.alert-warning { background-color: rgba(240, 173, 78, 0.15); border-color: #f0ad4e; color: #f0ad4e; } + +/* --- 狀態標籤 (Badges) --- */ +.badge { --bs-badge-font-size: 0.8em; --bs-badge-padding-y: 0.4em; --bs-badge-padding-x: 0.7em; } +.bg-success { background-color: #2ea043 !important; } +.bg-info { background-color: #0dcaf0 !important; color: #0d1117 !important; } +.bg-warning { background-color: #f0ad4e !important; color: #0d1117 !important; } +.bg-secondary { background-color: #8b949e !important; } + +/* --- 連結 --- */ +a { color: #58a6ff; } +a:hover { color: #80b6ff; } + +/* 頁面切換的淡入效果 */ +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } +main.container { animation: fadeIn 0.5s ease-in-out; } + +/* --- 通知 (Toast) --- */ +.toast { + background-color: #21262d; /* 使用比卡片稍亮的深色背景 */ + border: 1px solid #30363d; + color: #c9d1d9; +} + +.toast-header { + background-color: #161b22; /* 使用與卡片相同的深色背景 */ + color: #f0f6fc; /* 標題使用較亮的白色文字 */ + border-bottom: 1px solid #30363d; +} + +.toast-body { + color: #c9d1d9; /* 內文使用標準的灰色文字 */ +} + +/* 讓關閉按鈕在深色背景下可見 */ +.btn-close { + filter: invert(1) grayscale(100%) brightness(200%); +} + +/* --- 列表群組 (List Group for History Page) --- */ +.list-group-flush .list-group-item { + background-color: transparent; /* 在 card 中使用透明背景 */ + border-color: #30363d; +} + +.list-group-item { + background-color: #161b22; + border-color: #30363d; +} + +/* 確保列表內的文字顏色正確 */ +.list-group-item, +.list-group-item p, +.list-group-item small { + color: #c9d1d9; /* 標準灰色文字 */ +} + +/* 讓使用者名稱等重要文字更亮 */ +.list-group-item h5 strong { + color: #f0f6fc; +} + +/* --- 剩餘天數標籤 (Days Remaining Badge) --- */ +.days-badge { + padding: 0.3em 0.6em; + border-radius: 0.375rem; + font-weight: 500; + font-size: 0.85em; + color: #0d1117; /* 預設使用深色文字 */ +} + +.days-safe { + background-color: #2ea043; /* 綠色 */ + color: #ffffff; /* 搭配淺色文字 */ +} + +.days-warning { + background-color: #f0ad4e; /* 黃色 */ +} + +.days-critical { + background-color: #da3633; /* 紅色 */ + color: #ffffff; /* 搭配淺色文字 */ +} + +.days-expired { + background-color: #8b949e; /* 灰色 */ + color: #ffffff; +} \ No newline at end of file diff --git a/static/generated/PE1140901.docx b/static/generated/PE1140901.docx new file mode 100644 index 0000000000000000000000000000000000000000..84cab686ab0b88a0c319a198ded31eb3aa119ecc GIT binary patch literal 24501 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 literal 0 HcmV?d00001 diff --git a/static/generated/PE1140902.docx b/static/generated/PE1140902.docx new file mode 100644 index 0000000000000000000000000000000000000000..46f4015259228fd80a5a55084e0b4ef8d6d85f49 GIT binary patch literal 24508 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 literal 0 HcmV?d00001 diff --git a/static/generated/PE1140903.docx b/static/generated/PE1140903.docx new file mode 100644 index 0000000000000000000000000000000000000000..16ba89c81e1558d1cdecdac5fe75c4f2e236288d GIT binary patch literal 24505 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 + +

您好,

+

此為自動提醒郵件。

+

暫時規範 {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/template_with_placeholders.docx b/template_with_placeholders.docx new file mode 100644 index 0000000000000000000000000000000000000000..7bef1e32f82a6951d1b2ac00f50a595fdbd99a0f GIT binary patch literal 27596 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

X9}X?&?Ga8uL0I+oFx22Tj}OBWcY2N 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 +

403

+

權限不足 (Forbidden)

+

抱歉,您沒有權限存取此頁面。

+
返回總表 + +{% endblock %} diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 0000000..8daa179 --- /dev/null +++ b/templates/404.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block title %}找不到頁面{% endblock %} + +{% block content %} +
+

404

+

找不到頁面 (Not Found)

+

抱歉,您要找的頁面不存在。

+ 返回總表 +
+{% endblock %} diff --git a/templates/activate_spec.html b/templates/activate_spec.html new file mode 100644 index 0000000..b66aa2c --- /dev/null +++ b/templates/activate_spec.html @@ -0,0 +1,151 @@ +{% extends "base.html" %} + +{% block title %}啟用暫時規範{% endblock %} + +{% block content %} +

上傳簽核檔案以啟用規範

+ +
+
+ 規範編號: {{ spec.spec_code }} +
+
+
+

主題: {{ spec.title }}

+
+ 請上傳已經過完整簽核的 PDF 檔案。上傳後,此規範的狀態將變為「生效」。 +
+
+ + +
+ + +
+ + + +
可搜尋姓名或 Email 地址,支援多人選擇
+
+ + + 取消 +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..d4ab161 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,98 @@ + + + + + + {% block title %}暫時規範系統{% endblock %} + + + + + + + + + + + + + + + + +
+ {% block content %}{% endblock %} +
+ + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} +
+ + + + + + + + + + + + + + {% block scripts %}{% endblock %} + + diff --git a/templates/create_temp_spec_form.html b/templates/create_temp_spec_form.html new file mode 100644 index 0000000..abce4d0 --- /dev/null +++ b/templates/create_temp_spec_form.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} + +{% block title %}建立新的暫時規範{% endblock %} + +{% block content %} +
+
+

建立新的暫時規範

+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+ 取消 + +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/extend_spec.html b/templates/extend_spec.html new file mode 100644 index 0000000..8e04476 --- /dev/null +++ b/templates/extend_spec.html @@ -0,0 +1,117 @@ +{% extends "base.html" %} + +{% block title %}展延暫時規範{% endblock %} + +{% block content %} +

展延暫時規範

+ +
+
+ 規範編號: {{ spec.spec_code }} +
+
+
+

主題: {{ spec.title }}

+

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

+ +
+ + +
預設為原結束日期後一個月。
+
+ +
+ + +
請上傳展延申請的相關佐證文件 (PDF 格式)。
+
+ + +
+ + {% if saved_emails %} +
+ 以下為生效時設定的通知對象,您可以直接使用或進行編輯。如果修改,展延後將更新為新的通知對象。 +
+ {% endif %} + +
可搜尋姓名或 Email 地址,支援多人選擇
+
+ + + 取消 +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..c21ecb6 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} + +{% block title %}登入 - 暫時規範系統{% endblock %} + +{% block content %} +
+
+

登入

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + +
+
+
+
+ + +
請輸入完整的 AD 帳號格式 (包含 @domain)
+
+
+ + +
+
+ +
+
+
+
+
+{% endblock %} diff --git a/templates/onlyoffice_editor.html b/templates/onlyoffice_editor.html new file mode 100644 index 0000000..510c9ba --- /dev/null +++ b/templates/onlyoffice_editor.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} + +{% block title %}編輯規範 - {{ spec.spec_code }}{% endblock %} + +{% block content %} +
+
+

正在編輯: {{ spec.spec_code }}

+

主題: {{ spec.title }}

+
+ 返回總表 +
+ +
+
+
+
+
+{% endblock %} + +{% block scripts %} + + + +{% endblock %} \ No newline at end of file diff --git a/templates/spec_history.html b/templates/spec_history.html new file mode 100644 index 0000000..b37ccd6 --- /dev/null +++ b/templates/spec_history.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block title %}操作歷史 - {{ spec.spec_code }}{% endblock %} + +{% block content %} +
+
+

操作歷史紀錄

+

規範編號: {{ spec.spec_code }}

+
+ 返回總表 +
+ +
+
+
    + {% for entry in history %} +
  • +
    +
    + {{ entry.action }} + 由 {{ entry.user.username if entry.user else '[已刪除的使用者]' }} 執行 +
    + {{ entry.timestamp.strftime('%Y-%m-%d %H:%M:%S') }} +
    +

    {{ entry.details }}

    +
  • + {% else %} +
  • 沒有任何歷史紀錄。
  • + {% endfor %} +
+
+
+{% endblock %} diff --git a/templates/spec_list.html b/templates/spec_list.html new file mode 100644 index 0000000..10e9d92 --- /dev/null +++ b/templates/spec_list.html @@ -0,0 +1,153 @@ +{% extends "base.html" %} + +{% block title %}暫時規範總表{% endblock %} + +{% block content %} +
+

暫時規範總表

+ {% if current_user.role in ['editor', 'admin'] %} + 建立新規範 + {% endif %} +
+ +
+
+
+
+
+ + +
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+ + + + + + + + + + + + + + + {% for spec in specs %} + + + + + + + + + + + + + + {% endfor %} + +
編號主題申請者建立日期結束日期剩餘天數狀態操作
{{ spec.spec_code }}{{ spec.title }}{{ spec.applicant }}{{ spec.created_at.strftime('%Y-%m-%d') }}{{ spec.end_date.strftime('%Y-%m-%d') }} + {% if spec.status in ['active', 'expired'] %} + {% set remaining_days = (spec.end_date - today).days %} + {% if remaining_days < 0 %} + {% set color_class = 'days-expired' %} + {% elif remaining_days <= 3 %} + {% set color_class = 'days-critical' %} + {% elif remaining_days <= 7 %} + {% set color_class = 'days-warning' %} + {% else %} + {% set color_class = 'days-safe' %} + {% endif %} + + {{ remaining_days if remaining_days >= 0 else '已過期' }} + + {% else %} + - + {% endif %} + + {% if spec.status == 'active' %} + 已生效 + {% elif spec.status == 'pending_approval' %} + 待生效 + {% elif spec.status == 'terminated' %} + 已終止 + {% else %} + 已過期 + {% endif %} + + {% if spec.status == 'pending_approval' and current_user.role in ['editor', 'admin'] %} + + {% endif %} + + {% if current_user.role == 'admin' and spec.status == 'pending_approval' %} + + {% endif %} + + {% if current_user.role in ['editor', 'admin'] and spec.status == 'active' %} + + + {% endif %} + + {% if current_user.role == 'admin' %} +
+ +
+ {% endif %} + + {% if spec.status == 'pending_approval' %} + {% if current_user.role in ['editor', 'admin'] %} + + {% endif %} + {% elif spec.uploads %} + + {% endif %} + +
+
+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/templates/terminate_spec.html b/templates/terminate_spec.html new file mode 100644 index 0000000..1bfbb74 --- /dev/null +++ b/templates/terminate_spec.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} + +{% block title %}提早結束暫時規範{% endblock %} + +{% block content %} +

提早結束暫時規範

+ +
+
+ 規範編號: {{ spec.spec_code }} +
+
+
+

主題: {{ spec.title }}

+
+ 執行此操作將會立即終止這份暫時規範,狀態將變為「已終止」,結束日期會更新為今天。 +
+
+ + +
+ + +
+ + {% if saved_emails %} +
+ 以下為生效時設定的通知對象,您可以直接使用或進行編輯。 +
+ {% endif %} + +
可搜尋姓名或 Email 地址,支援多人選擇
+
+ + + 取消 +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/user_management.html b/templates/user_management.html new file mode 100644 index 0000000..78a032c --- /dev/null +++ b/templates/user_management.html @@ -0,0 +1,234 @@ +{% extends "base.html" %} + +{% block title %}權限管理{% endblock %} + +{% block content %} +

權限管理

+ +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} +{% endwith %} + + +
+
+ 設定管理員權限 +
+
+
+
+ + +
輸入需要設定為管理員的 AD 帳號
+
+
+ +
+
+
+
+ + +
+
+ 現有使用者權限管理 +
+
+ {% if users %} +
+ + + + + + + + + + + + + {% for user in users %} + + + + + + + + + {% endfor %} + +
IDAD 帳號目前權限上次登入權限管理操作
{{ user.id }} + {{ user.username }} + {% if user.id == current_user.id %} + 目前使用者 + {% endif %} + + + {% if user.role == 'admin' %} + 管理員 + {% elif user.role == 'editor' %} + 編輯者 + {% else %} + 檢視者 + {% endif %} + + + {% if user.last_login %} + {{ user.last_login.strftime('%Y-%m-%d %H:%M') }} + {% else %} + 從未登入 + {% endif %} + +
+
+ + +
+
+
+ {% if user.id != current_user.id %} +
+ +
+ {% else %} + + {% endif %} +
+
+ {% else %} +
+ + 目前沒有任何使用者記錄。使用者會在首次透過 AD 登入時自動建立。 +
+ {% endif %} +
+
+ + +
+
+ 權限等級說明 +
+
+
+
+
檢視者 (Viewer)
+
    +
  • 登入系統
  • +
  • 檢視規範列表
  • +
  • 下載已生效的 PDF 文件
  • +
  • 查看歷史記錄
  • +
+
+
+
編輯者 (Editor)
+
    +
  • 包含 Viewer 所有權限
  • +
  • 建立新的暫時規範
  • +
  • 編輯規範內容
  • +
  • 展延規範
  • +
  • 終止規範
  • +
  • 下載 Word 編輯檔案
  • +
+
+
+
管理員 (Admin)
+
    +
  • 包含 Editor 所有權限
  • +
  • 啟用待生效的規範
  • +
  • 管理使用者權限
  • +
  • 刪除規範
  • +
  • 系統設定管理
  • +
+
+
+
+
+ + +
+
+ LDAP/AD 整合說明 +
+
+

系統運作方式:

+
    +
  1. 使用者首次使用 AD 帳號登入時,系統會自動建立本地使用者記錄,預設權限為 viewer
  2. +
  3. 管理員可以在此頁面調整使用者權限等級
  4. +
  5. 使用者的身份認證完全由 Active Directory 處理,本系統不儲存密碼
  6. +
  7. 刪除本地使用者記錄不會影響 AD 帳號,使用者仍可重新登入(但會重置為 viewer 權限)
  8. +
+ +
+ + 重要提醒:確保至少保留一個管理員帳號,避免無法進行權限管理。 +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/update_admin.py b/update_admin.py new file mode 100644 index 0000000..420daff --- /dev/null +++ b/update_admin.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +""" +更新使用者權限為管理員的腳本 +""" + +from models import db, User +from app import app + +def update_user_to_admin(username): + """將指定使用者設為管理員權限""" + with app.app_context(): + user = User.query.filter_by(username=username).first() + if user: + print(f'找到使用者: {user.username}') + print(f'當前權限: {user.role}') + + if user.role != 'admin': + user.role = 'admin' + db.session.commit() + print(f'權限已更新為: {user.role}') + else: + print('使用者已經是管理員權限') + return True + else: + print(f'使用者 {username} 不存在') + return False + +if __name__ == "__main__": + username = 'ymirliu@panjit.com.tw' + print(f"正在更新使用者 {username} 的權限...") + + success = update_user_to_admin(username) + + if success: + print("\n管理員權限設定完成!") + else: + print("\n請先使用該帳號登入一次以建立使用者記錄") \ No newline at end of file diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..fd6c234 --- /dev/null +++ b/utils.py @@ -0,0 +1,308 @@ +from docxtpl import DocxTemplate, InlineImage +from docx.shared import Mm +from docx2pdf import convert +import os +import re +from functools import wraps +from flask_login import current_user +from flask import abort +from bs4 import BeautifulSoup, NavigableString, Tag +# Windows 專用模組,Linux 環境需要跨平台處理 +try: + import pythoncom + PYTHONCOM_AVAILABLE = True +except ImportError: + PYTHONCOM_AVAILABLE = False + pythoncom = None +import mistune +from PIL import Image + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +def _resolve_image_path(src: str) -> str: + """ + 將 HTML 圖片 src 轉換為本地檔案絕對路徑 + 支援 /static/... 路徑與相對路徑 + """ + if src.startswith('/'): + static_index = src.find('/static/') + if static_index != -1: + img_path_rel = src[static_index+1:] # 移除開頭斜線 + return os.path.join(BASE_DIR, img_path_rel) + return os.path.join(BASE_DIR, src.lstrip('/')) + +import logging + +DEBUG_LOG = False # 生產環境關閉 debug 訊息 + +def _process_markdown_sections(doc, md_content): + from bs4 import BeautifulSoup, Tag + from PIL import Image + from docxtpl import InlineImage + from docx.shared import Mm + + def log(msg): + if DEBUG_LOG: + print(f"[DEBUG] {msg}") + + def resolve_image(src): + if src.startswith('/'): + static_index = src.find('/static/') + if static_index != -1: + path_rel = src[static_index + 1:] + return os.path.join(BASE_DIR, path_rel) + return os.path.join(BASE_DIR, src.lstrip('/')) + + def extract_table_text(table_tag): + lines = [] + for i, row in enumerate(table_tag.find_all("tr")): + cells = row.find_all(["td", "th"]) + row_text = " | ".join(cell.get_text(strip=True) for cell in cells) + lines.append(row_text) + if i == 0: + lines.append(" | ".join(["---"] * len(cells))) + return "\n".join(lines) + + results = [] + if not md_content: + log("Markdown content is empty") + return results + + html = mistune.html(md_content) + soup = BeautifulSoup(html, 'lxml') + + for elem in soup.body.children: + if isinstance(elem, Tag): + if elem.name == 'table': + table_text = extract_table_text(elem) + log(f"[表格] {table_text}") + results.append({'text': table_text, 'image': None}) + continue + + if elem.name in ['p', 'div']: + for child in elem.children: + if isinstance(child, Tag) and child.name == 'img' and child.has_attr('src'): + try: + img_path = resolve_image(child['src']) + if os.path.exists(img_path): + with Image.open(img_path) as im: + width_px = im.width + width_mm = min(width_px * 25.4 / 96, 130) + image = InlineImage(doc, img_path, width=Mm(width_mm)) + log(f"[圖片] {img_path}, 寬: {width_mm:.2f} mm") + results.append({'text': None, 'image': image}) + else: + log(f"[警告] 圖片不存在: {img_path}") + except Exception as e: + log(f"[錯誤] 圖片處理失敗: {e}") + else: + text = child.get_text(strip=True) if hasattr(child, 'get_text') else str(child).strip() + if text: + log(f"[文字] {text}") + results.append({'text': text, 'image': None}) + return results + + + + + +def fill_template(values, template_path, output_word_path, output_pdf_path): + from docxtpl import DocxTemplate + from docx2pdf import convert + + doc = DocxTemplate(template_path) + + # 填入 context,None 改為空字串 + context = {k: (v if v is not None else '') for k, v in values.items()} + + # 更新後版本:處理 Markdown → sections(支援圖片+表格+段落) + context["change_before_sections"] = _process_markdown_sections(doc, context.get("change_before", "")) + context["change_after_sections"] = _process_markdown_sections(doc, context.get("change_after", "")) + + # 渲染 + doc.render(context) + doc.save(output_word_path) + + # 轉 PDF (跨平台相容處理) + try: + if PYTHONCOM_AVAILABLE: + pythoncom.CoInitialize() + convert(output_word_path, output_pdf_path) + except Exception as e: + print(f"PDF conversion failed: {e}") + raise + finally: + if PYTHONCOM_AVAILABLE: + pythoncom.CoUninitialize() + + + + +def admin_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated or current_user.role != 'admin': + abort(403) # Forbidden + return f(*args, **kwargs) + return decorated_function + +def editor_or_admin_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated or current_user.role not in ['editor', 'admin']: + abort(403) # Forbidden + return f(*args, **kwargs) + return decorated_function + +def add_history_log(spec_id, action, details=""): + """新增一筆操作歷史紀錄""" + from models import db, SpecHistory + + history_entry = SpecHistory( + spec_id=spec_id, + user_id=current_user.id, + action=action, + details=details + ) + db.session.add(history_entry) + + +import smtplib +from email.mime.text import MIMEText +from email.header import Header +from flask import current_app + +def process_recipients(recipients_str): + """ + 處理收件者字串,支援個人郵件和群組展開 + 輸入格式: "email1,email2,group:GroupName" + 返回: 展開後的郵件地址列表 + """ + print(f"[RECIPIENTS DEBUG] 開始處理收件者: {recipients_str}") + + if not recipients_str: + print(f"[RECIPIENTS DEBUG] 收件者字串為空") + return [] + + recipients = [item.strip() for item in recipients_str.split(',') if item.strip()] + final_emails = [] + + for recipient in recipients: + print(f"[RECIPIENTS DEBUG] 處理收件者項目: {recipient}") + + if recipient.startswith('group:'): + # 這是一個群組,需要展開 + group_name = recipient[6:] # 移除 'group:' 前綴 + print(f"[RECIPIENTS DEBUG] 發現群組: {group_name}") + + try: + from ldap_utils import get_ldap_group_members + group_emails = get_ldap_group_members(group_name) + print(f"[RECIPIENTS DEBUG] 群組 {group_name} 包含 {len(group_emails)} 個成員") + + for email in group_emails: + if email and email not in final_emails: + final_emails.append(email) + print(f"[RECIPIENTS DEBUG] 添加群組成員郵件: {email}") + + except Exception as e: + print(f"[RECIPIENTS ERROR] 群組 {group_name} 展開失敗: {e}") + + else: + # 這是個人郵件地址 + if recipient and recipient not in final_emails: + final_emails.append(recipient) + print(f"[RECIPIENTS DEBUG] 添加個人郵件: {recipient}") + + print(f"[RECIPIENTS DEBUG] 最終收件者列表 ({len(final_emails)} 個): {final_emails}") + return final_emails + +def send_email(to_addrs, subject, body): + """ + Sends an email using the SMTP settings from the config. + Supports both authenticated (Port 465/587) and unauthenticated (Port 25) methods. + """ + print(f"[EMAIL DEBUG] 開始發送郵件...") + print(f"[EMAIL DEBUG] 收件者數量: {len(to_addrs)}") + print(f"[EMAIL DEBUG] 收件者: {to_addrs}") + print(f"[EMAIL DEBUG] 主旨: {subject}") + + try: + # 取得 SMTP 設定 + smtp_server = current_app.config['SMTP_SERVER'] + smtp_port = current_app.config['SMTP_PORT'] + use_tls = current_app.config.get('SMTP_USE_TLS', False) + use_ssl = current_app.config.get('SMTP_USE_SSL', False) + sender_email = current_app.config['SMTP_SENDER_EMAIL'] + sender_password = current_app.config.get('SMTP_SENDER_PASSWORD', '') + auth_required = current_app.config.get('SMTP_AUTH_REQUIRED', False) + + print(f"[EMAIL DEBUG] SMTP 設定:") + print(f"[EMAIL DEBUG] - 伺服器: {smtp_server}:{smtp_port}") + print(f"[EMAIL DEBUG] - 使用 TLS: {use_tls}") + print(f"[EMAIL DEBUG] - 使用 SSL: {use_ssl}") + print(f"[EMAIL DEBUG] - 寄件者: {sender_email}") + print(f"[EMAIL DEBUG] - 需要認證: {auth_required}") + print(f"[EMAIL DEBUG] - 有密碼: {'是' if sender_password else '否'}") + + # 建立郵件內容 + print(f"[EMAIL DEBUG] 建立郵件內容...") + msg = MIMEText(body, 'html', 'utf-8') + msg['Subject'] = Header(subject, 'utf-8') + msg['From'] = sender_email + msg['To'] = ', '.join(to_addrs) + print(f"[EMAIL DEBUG] 郵件內容建立完成") + + # 連接 SMTP 伺服器 + if use_ssl and smtp_port == 465: + # Port 465 使用 SSL + print(f"[EMAIL DEBUG] 使用 SSL 連接 SMTP 伺服器 {smtp_server}:{smtp_port}...") + server = smtplib.SMTP_SSL(smtp_server, smtp_port) + else: + # Port 25 或 587 使用一般連接 + print(f"[EMAIL DEBUG] 連接 SMTP 伺服器 {smtp_server}:{smtp_port}...") + server = smtplib.SMTP(smtp_server, smtp_port) + + print(f"[EMAIL DEBUG] SMTP 伺服器連接成功") + + if use_tls and smtp_port == 587: + print(f"[EMAIL DEBUG] 啟用 TLS...") + server.starttls() + print(f"[EMAIL DEBUG] TLS 啟用成功") + + # 只在需要認證時才登入 + if auth_required and sender_password: + print(f"[EMAIL DEBUG] 登入 SMTP 伺服器...") + server.login(sender_email, sender_password) + print(f"[EMAIL DEBUG] SMTP 登入成功") + else: + print(f"[EMAIL DEBUG] 使用匿名發送(Port 25 無需認證)") + + # 發送郵件 + print(f"[EMAIL DEBUG] 發送郵件...") + result = server.sendmail(sender_email, to_addrs, msg.as_string()) + print(f"[EMAIL DEBUG] 郵件發送結果: {result}") + + server.quit() + print(f"[EMAIL DEBUG] SMTP 連接已關閉") + print(f"[EMAIL SUCCESS] 郵件成功發送至: {', '.join(to_addrs)}") + return True + + except smtplib.SMTPAuthenticationError as e: + print(f"[EMAIL ERROR] SMTP 認證失敗: {e}") + print(f"[EMAIL ERROR] 請檢查寄件者帳號和密碼設定") + return False + except smtplib.SMTPConnectError as e: + print(f"[EMAIL ERROR] SMTP 連接失敗: {e}") + print(f"[EMAIL ERROR] 請檢查 SMTP 伺服器設定") + return False + except smtplib.SMTPRecipientsRefused as e: + print(f"[EMAIL ERROR] 收件者被拒絕: {e}") + print(f"[EMAIL ERROR] 請檢查收件者郵件地址") + return False + except Exception as e: + print(f"[EMAIL ERROR] 郵件發送失敗: {type(e).__name__}: {e}") + import traceback + print(f"[EMAIL ERROR] 詳細錯誤:") + traceback.print_exc() + return False diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..d411e05 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +""" +WSGI 生產環境入口點 +用於Gunicorn部署 +""" +import os +import logging +import sys +from app import app + +# 配置生產環境日誌 +if __name__ != "__main__": + # 只在Gunicorn環境下配置日誌 + gunicorn_logger = logging.getLogger('gunicorn.error') + app.logger.handlers = gunicorn_logger.handlers + app.logger.setLevel(gunicorn_logger.level) + +# 確保在生產環境 +os.environ['FLASK_ENV'] = 'production' + +if __name__ == "__main__": + # 開發環境直接運行 + print("🚀 開發環境啟動") + app.run(host='0.0.0.0', port=5000, debug=False) +else: + # 生產環境通過Gunicorn + print("🌟 生產環境啟動 (Gunicorn)") \ No newline at end of file