From 2a0b29402f96c2ba26a5ce9e58917a17ba2fe207 Mon Sep 17 00:00:00 2001 From: beabigegg Date: Sun, 21 Sep 2025 11:37:39 +0800 Subject: [PATCH] fix timezone bug --- .gitignore | 3 + DEPLOYMENT.md | 559 +++++++++++++++++++------------- Dockerfile.redis | 17 + README.md | 344 ++++++++++++-------- USER_MANUAL.md | 164 +++++++--- app.py | 13 + docker-compose.yml | 72 ++-- models.py | 3 +- nginx/Dockerfile | 21 ++ nginx/conf.d/default.conf | 8 +- requirements.txt | 55 ++-- routes/auth.py | 3 +- routes/temp_spec.py | 112 +++++-- static/generated/PE1140901.docx | Bin 24501 -> 24506 bytes static/generated/PE1140902.docx | Bin 24508 -> 0 bytes static/generated/PE1140903.docx | Bin 24505 -> 0 bytes templates/extend_spec.html | 14 +- templates/spec_history.html | 2 +- templates/spec_list.html | 26 +- templates/user_management.html | 2 +- utils.py => utils/__init__.py | 49 ++- utils/timezone.py | 102 ++++++ 22 files changed, 1050 insertions(+), 519 deletions(-) create mode 100644 Dockerfile.redis create mode 100644 nginx/Dockerfile delete mode 100644 static/generated/PE1140902.docx delete mode 100644 static/generated/PE1140903.docx rename utils.py => utils/__init__.py (97%) create mode 100644 utils/timezone.py diff --git a/.gitignore b/.gitignore index a3d9b53..4521bcb 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ __pycache__/ # --- 使用者上傳與系統產生的檔案 (User Uploads & Generated Files) --- # 忽略上傳的已簽核文件 (PDFs)。 /uploads/ +static/generated/ # 忽略系統自動產生的暫時規範文件 (Word, PDF)。 /generated/ @@ -51,3 +52,5 @@ tests/ # 最佳實踐文件(包含敏感設定資訊) BEST_PRACTICES.md DEVELOPER_GUIDE.md + +static/generated/PE1140901.docx diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 4696a11..1bd1056 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,25 +1,28 @@ # 部署指南 - 暫時規範管理系統 V4 -本文件提供詳細的部署指導,涵蓋 Docker 環境部署方式。 +本文件提供詳細的部署指導,涵蓋 Docker 環境部署方式及 V4 版本的新特性配置。 -## 🎉 生產環境優化完成 +## 🎉 V4.0 版本優化完成 -**✅ 已完成50人併發生產環境優化**: -- Gunicorn WSGI部署(多進程併發) -- Redis快取系統(提升效能) -- Nginx反向代理(負載均衡) -- CDN支援(靜態資源加速) -- 資源限制(防止系統過載) -- 監控工具(效能監控) +**✅ 已完成企業級生產環境優化**: +- 台灣時區 (GMT+8) 完整支援 +- 展延次數限制功能(最多2次,90天上限) +- OnlyOffice 文件同步問題修正 +- Redis 快取系統(提升效能) +- Nginx 反向代理(負載均衡) +- 容器間網路優化 +- 時區處理模組化 +- UI 樣式改進 ## 📋 目錄 1. [系統需求](#1-系統需求) 2. [快速部署](#2-快速部署) 3. [生產環境配置](#3-生產環境配置) -4. [監控與管理](#4-監控與管理) -5. [服務訪問](#5-服務訪問) -6. [疑難排解](#6-疑難排解) +4. [V4.0 新功能配置](#4-v40-新功能配置) +5. [監控與管理](#5-監控與管理) +6. [服務訪問](#6-服務訪問) +7. [疑難排解](#7-疑難排解) ## 1. 系統需求 @@ -28,18 +31,25 @@ - [ ] Docker 20.10+ 已安裝且運行中 - [ ] Docker Compose 2.0+ 已安裝 - [ ] 外部 MySQL 資料庫可訪問 (mysql.theaken.com:33306) -- [ ] LDAP/Active Directory 伺服器可連線 -- [ ] SMTP 郵件伺服器已配置 +- [ ] LDAP/Active Directory 伺服器可連線 (panjit.com.tw) +- [ ] SMTP 郵件伺服器已配置 (mail.panjit.com.tw) - [ ] 足夠的磁碟空間 (建議至少 10GB) ### 端口需求 確保以下端口未被占用: -- `12010`: Flask 應用程式(Gunicorn) -- `12011`: OnlyOffice 文檔服務 -- `12013`: Nginx HTTP(反向代理) -- `12014`: Nginx HTTPS(反向代理) -- `6379`: Redis 快取(內部) +- `12010`: Flask 應用程式(內部,可選直接訪問) +- `12013`: Nginx HTTP(反向代理,主要入口) +- `12015`: OnlyOffice 文檔服務 +- `6379`: Redis 快取(容器內部) + +### V4.0 網路架構 + +``` +用戶 → Nginx (12013) → Flask App (5000) → MySQL (外部) + ↓ ↓ + Redis (6379) OnlyOffice (80) +``` ## 2. 快速部署 @@ -50,12 +60,12 @@ git clone cd TEMP_spec_system_V4 -# 2. 配置環境變數 -cp .env.production .env -# 編輯 .env 文件,填入實際的配置值 +# 2. 配置環境變數(使用預設配置) +cp .env.example .env +# 編輯 .env 文件,大部分配置已預設好 -# 3. 啟動所有服務(生產環境優化版本) -docker-compose up -d +# 3. 啟動所有服務(V4.0優化版本) +docker-compose up -d --build # 4. 檢查服務狀態 docker-compose ps @@ -63,11 +73,11 @@ 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 +NAME STATUS PORTS +panjit-tempspec-redis Up (healthy) +panjit-tempspec-onlyoffice Up (healthy) 0.0.0.0:12015->80/tcp +panjit-tempspec-app Up (healthy) +panjit-tempspec-nginx Up 0.0.0.0:12013->80/tcp ``` ```bash @@ -76,262 +86,349 @@ 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 服務 +curl -I http://localhost:12015 # OnlyOffice 服務 +``` + +### 初始化資料庫 + +```bash +# 初始化資料庫結構 +docker-compose exec app python init_db.py + +# 檢查資料庫連接 +docker-compose exec app python -c " +from app import app +from models import db +with app.app_context(): + try: + db.engine.execute('SELECT 1') + print('✅ Database connection successful') + except Exception as e: + print(f'❌ Database connection failed: {e}') +" ``` ## 3. 生產環境配置 -### 3.1 服務架構(生產優化) +### 3.1 服務架構(V4.0 優化) ``` -用戶請求 → Nginx (12013/12014) → Gunicorn (多進程) → Flask App - ↓ - Redis快取 - ↓ - 外部MySQL資料庫 +外部用戶 → Nginx (12013) → Flask App (Gunicorn) → 外部MySQL + ↓ ↓ + Redis快取 OnlyOffice (12015) + ↓ + 台灣時區處理模組 ``` -**服務組件**: -- **Nginx**: 反向代理 + 靜態檔案 + 負載均衡(端口 12013/12014) -- **Flask 應用**: Gunicorn WSGI伺服器(多進程,端口 12010) +**V4.0 服務組件**: +- **Nginx**: 反向代理 + 靜態檔案(端口 12013) +- **Flask 應用**: Gunicorn WSGI 伺服器(內部端口 5000) - **Redis**: 快取系統(內部端口 6379) -- **OnlyOffice**: 文檔編輯服務(端口 12011) -- **MySQL**: 外部資料庫服務(mysql.theaken.com) +- **OnlyOffice**: 文檔編輯服務(端口 12015) +- **MySQL**: 外部資料庫服務(mysql.theaken.com:33306) -### 3.2 效能規格(50人併發支援) +### 3.2 V4.0 效能規格 -- **併發處理**: 2-8個Gunicorn worker進程 -- **記憶體使用**: App容器1GB + Redis 256MB -- **快取命中**: Redis快取減少70%+資料庫查詢 +- **時區支援**: 全系統台灣時區 (GMT+8) 處理 +- **展延控制**: 最多2次展延,90天效期上限 +- **文件同步**: 增強的 OnlyOffice 回調處理 +- **快取優化**: Redis 減少資料庫查詢負載 +- **網路優化**: 容器間通信使用服務名稱 - **響應時間**: < 200ms(快取命中時) -- **可用性**: 99.9%+(健康檢查 + 自動重啟) ### 3.3 環境變數配置 -編輯 `.env` 檔案(基於 `.env.production` 範例): +編輯 `.env` 檔案: ```env -# 生產環境基本設定 -FLASK_ENV=production +# V4.0 基本設定 SECRET_KEY=your_super_secret_production_key_here +FLASK_ENV=production -# 服務端口 -APP_PORT=12010 # Gunicorn WSGI伺服器 -ONLYOFFICE_PORT=12011 # OnlyOffice 服務 -NGINX_PORT=12013 # Nginx HTTP -NGINX_SSL_PORT=12014 # Nginx HTTPS +# 外部 MySQL 資料庫 +DATABASE_URL=mysql+pymysql://A060:WLeSCi0yhtc7@mysql.theaken.com:33306/db_A060 # 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 設定(V4.0預設配置) LDAP_SERVER=panjit.com.tw LDAP_PORT=389 LDAP_USE_SSL=false -LDAP_SEARCH_BASE=OU=PANJIT,DC=panjit,DC=com,DC=tw +LDAP_SEARCH_BASE=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_BIND_USER_PASSWORD=panjit2481 LDAP_USER_LOGIN_ATTR=userPrincipalName -# SMTP 設定 +# SMTP 設定(V4.0預設配置) 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=your_onlyoffice_jwt_secret +# OnlyOffice 設定 +ONLYOFFICE_URL=http://localhost:12015/ +ONLYOFFICE_INTERNAL_URL=http://onlyoffice:80 +ONLYOFFICE_JWT_SECRET=your_jwt_secret_key_here + +# 服務端口設定 +ONLYOFFICE_PORT=12015 ``` -## 4. 監控與管理 +## 4. V4.0 新功能配置 -### 4.1 系統監控 +### 4.1 台灣時區支援 +**自動配置**: +- Docker 容器環境變數:`TZ: Asia/Taipei` +- Python 時區模組:`utils/timezone.py` +- 資料庫時間自動轉換為台灣時區 + +**驗證時區設定**: ```bash -# 即時監控(每5秒刷新) -python monitor.py --watch 5 +# 檢查容器時區 +docker exec panjit-tempspec-app date -# 單次檢查 -python monitor.py - -# JSON格式輸出 -python monitor.py --json +# 測試 Python 時區功能 +docker exec panjit-tempspec-app python -c " +from utils.timezone import taiwan_now, format_taiwan_time +print('Current Taiwan time:', format_taiwan_time(taiwan_now())) +" ``` -### 4.2 Docker 管理命令 +### 4.2 展延次數限制 +**功能說明**: +- 每個暫時規範最多展延2次 +- 總效期上限90天 +- 前端按鈕自動禁用 +- 後端邏輯驗證 + +**配置驗證**: ```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 +# 檢查展延限制邏輯 +docker exec panjit-tempspec-app python -c " +from app import app +with app.app_context(): + print('Extension limit logic loaded successfully') +" ``` -### 4.3 Redis 快取管理 +### 4.3 OnlyOffice 文件同步 +**V4.0 改進**: +- 支援多種儲存狀態(status=2, status=6) +- 增強回調 URL 處理 +- 修正容器間網路通信 + +**同步測試**: ```bash -# 連接Redis並測試 +# 測試 OnlyOffice 網路連接 +docker exec panjit-tempspec-onlyoffice curl -I http://panjit-tempspec-nginx:80/ + +# 檢查回調處理日誌 +docker-compose logs app | grep "OnlyOffice callback" +``` + +### 4.4 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 +# 監控快取使用 +docker-compose exec redis redis-cli monitor ``` -## 5. 服務訪問 +## 5. 監控與管理 -### 5.1 服務入口 +### 5.1 系統監控 -服務啟動後,可透過以下 URL 訪問: +```bash +# V4.0 系統監控(如有 monitor.py) +python monitor.py --watch 5 + +# Docker 容器監控 +docker-compose ps +docker stats + +# 服務健康檢查 +docker-compose logs --tail=50 app +docker-compose logs --tail=50 nginx +docker-compose logs --tail=50 redis +docker-compose logs --tail=50 onlyoffice +``` + +### 5.2 V4.0 特定檢查 + +```bash +# 時區功能檢查 +docker exec panjit-tempspec-app python -c " +from utils.timezone import taiwan_now, to_taiwan_time +from datetime import datetime +print('Taiwan now:', taiwan_now()) +print('UTC to Taiwan:', to_taiwan_time(datetime.utcnow())) +" + +# 展延限制檢查 +docker exec panjit-tempspec-app python -c " +from models import TempSpec +from app import app +with app.app_context(): + specs = TempSpec.query.filter(TempSpec.extension_count >= 2).count() + print(f'Specs at extension limit: {specs}') +" + +# OnlyOffice 同步檢查 +docker-compose logs app | grep -E "(callback|sync|save)" | tail -10 +``` + +### 5.3 Redis 快取管理 + +```bash +# 快取狀態檢查 +docker-compose exec redis redis-cli info memory +docker-compose exec redis redis-cli dbsize + +# 清空快取(如需要) +docker-compose exec redis redis-cli flushall + +# 監控快取命中率 +docker-compose exec redis redis-cli info stats | grep hit +``` + +## 6. 服務訪問 + +### 6.1 V4.0 服務入口 **主要服務**: -- **主應用程式 (Nginx)**: http://localhost:12013/login 🌟 **推薦** -- **主應用程式 (直接)**: http://localhost:12010/login -- **OnlyOffice 服務**: http://localhost:12011 - -**推薦使用方式(生產環境)**: -- 使用 Nginx 反向代理: `http://localhost:12013` -- 直接訪問 Flask: `http://localhost:12010` - -### 5.2 登入資訊 +- **主應用程式 (Nginx)**: http://localhost:12013 🌟 **推薦** +- **OnlyOffice 服務**: http://localhost:12015 +**V4.0 登入資訊**: - **認證方式**: LDAP/Active Directory -- **登入帳號**: 使用公司 LDAP 帳號密碼 -- **登入格式**: 支援 `username@panjit.com.tw` 或 `username` +- **登入格式**: `username@panjit.com.tw` +- **時區顯示**: 所有時間使用台灣時區 (GMT+8) -### 5.3 預設管理員帳號 +### 6.2 V4.0 新功能驗證 -如需創建本地管理員帳號(非LDAP): +登入後驗證以下新功能: +1. **時區顯示**: 檢查所有時間是否使用台灣時區 +2. **展延限制**: 查看已展延2次的規範是否正確顯示限制 +3. **文件同步**: 測試 OnlyOffice 編輯和儲存功能 +4. **UI 改進**: 檢查展延狀態在深色背景下的可讀性 + +## 7. 疑難排解 + +### 7.1 V4.0 特定問題 + +**時區顯示錯誤** ```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') +# 檢查容器時區設定 +docker exec panjit-tempspec-app date +docker exec panjit-tempspec-app python -c " +import os +print('TZ:', os.environ.get('TZ', 'Not set')) " -``` -## 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 +# 檢查展延邏輯 +docker-compose logs app | grep -i extension -# 檢查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}') +# 測試展延限制邏輯 +docker exec panjit-tempspec-app python -c " +from routes.temp_spec import check_extension_limit +print('Extension limit logic available') " ``` -**端口衝突** -修改 `.env` 文件中的端口設定: -```env -APP_PORT=12015 # 改為其他可用端口 -ONLYOFFICE_PORT=12016 # 改為其他可用端口 -NGINX_PORT=12017 # 改為其他可用端口 +**OnlyOffice 文件同步問題** +```bash +# 檢查 OnlyOffice 回調 +docker-compose logs app | grep "OnlyOffice callback" + +# 檢查容器間網路 +docker exec panjit-tempspec-onlyoffice ping panjit-tempspec-nginx + +# 檢查 OnlyOffice 健康狀態 +curl -I http://localhost:12015/healthcheck ``` -### 6.3 維護命令 +**Redis 快取問題** +```bash +# 檢查 Redis 連接 +docker-compose exec app python -c " +import redis +r = redis.Redis(host='redis', port=6379, db=0) +print('Redis ping:', r.ping()) +" + +# 重啟 Redis +docker-compose restart redis +``` + +### 7.2 容器網路問題 + +**容器間通信失敗** +```bash +# 檢查網路配置 +docker network ls | grep tempspec +docker network inspect tempspec-network + +# 測試容器間連接 +docker exec panjit-tempspec-app ping panjit-tempspec-redis +docker exec panjit-tempspec-app ping panjit-tempspec-onlyoffice +docker exec panjit-tempspec-app ping panjit-tempspec-nginx +``` + +**端口衝突解決** +```bash +# 檢查端口占用 +netstat -tulpn | grep -E "(12013|12015)" + +# 修改端口配置(編輯 docker-compose.yml) +# 將衝突端口改為其他可用端口 +``` + +### 7.3 資料庫連接問題 + +**MySQL 連接測試** +```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}') +" +``` + +### 7.4 V4.0 維護命令 ```bash -# 完全重建服務(清除快取) +# 完全重建 V4.0 服務 docker-compose down docker-compose build --no-cache docker-compose up -d @@ -339,33 +436,47 @@ docker-compose up -d # 更新單一服務 docker-compose up -d --force-recreate app -# 清理未使用的 Docker 資源 +# 清理 Docker 資源 docker system prune -a + +# 備份重要資料 +docker exec panjit-tempspec-app tar -czf /tmp/uploads_backup.tar.gz uploads/ +docker cp panjit-tempspec-app:/tmp/uploads_backup.tar.gz ./uploads_backup.tar.gz ``` -### 6.4 效能調優 +### 7.5 V4.0 效能監控 -**Redis 優化** ```bash -# 調整 Redis 記憶體限制(編輯 docker-compose.yml) -# 預設: 256MB,可根據需要調整 +# 監控容器資源使用 +docker stats panjit-tempspec-app panjit-tempspec-redis panjit-tempspec-nginx -# 監控 Redis 使用 -docker-compose exec redis redis-cli info memory -``` +# 檢查應用效能 +curl -w "@curl-format.txt" -o /dev/null -s http://localhost:12013/login -**Gunicorn 調優** -```bash -# 編輯 gunicorn.conf.py 調整: -# - workers: worker 進程數量 -# - timeout: 請求超時時間 -# - max_requests: worker 重啟頻率 +# Redis 效能監控 +docker-compose exec redis redis-cli info stats | grep -E "(hits|misses|ops)" ``` --- -**🎉 生產環境部署完成!系統已準備好支援50人的併發使用。** +## 🎉 V4.0 部署完成檢查清單 -**快速啟動**: `docker-compose up -d` -**系統監控**: `python monitor.py --watch 5` -**服務訪問**: http://localhost:12013/login \ No newline at end of file +- [ ] 所有容器運行正常 (`docker-compose ps`) +- [ ] 時區顯示為台灣時區 (GMT+8) +- [ ] 展延限制功能正常 (最多2次) +- [ ] OnlyOffice 文件同步正常 +- [ ] Redis 快取服務運行 +- [ ] Nginx 反向代理正常 +- [ ] LDAP 認證功能正常 +- [ ] 郵件通知功能正常 +- [ ] UI 樣式顯示正確 + +**快速啟動**: `docker-compose up -d --build` +**服務訪問**: http://localhost:12013 +**健康檢查**: `docker-compose ps && docker-compose logs --tail=10` + +--- + +**🚀 暫時規範管理系統 V4.0 部署完成!** + +系統已具備完整的台灣時區支援、展延限制控制、優化的文件同步機制以及增強的用戶體驗。 \ No newline at end of file diff --git a/Dockerfile.redis b/Dockerfile.redis new file mode 100644 index 0000000..8db1579 --- /dev/null +++ b/Dockerfile.redis @@ -0,0 +1,17 @@ +# Redis for PANJIT Temp Spec System +FROM redis:7-alpine + +# Set container labels for identification +LABEL application="panjit-temp-spec-system" +LABEL component="redis" +LABEL version="v4.0" +LABEL maintainer="PANJIT IT Team" + +# Copy custom redis configuration if needed +# COPY redis.conf /usr/local/etc/redis/redis.conf + +# Expose the default Redis port +EXPOSE 6379 + +# Use the default Redis entrypoint +# CMD ["redis-server", "/usr/local/etc/redis/redis.conf"] \ No newline at end of file diff --git a/README.md b/README.md index ca20466..b95bdb6 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ - **ONLYOFFICE 線上編輯**:即時協作文件編輯功能 - **智慧通知系統**:動態收件人選擇與自動提醒 - **文件生命週期管理**:完整的建立、啟用、展延、終止流程 -- **多平台支援**:支援 Windows/Linux 環境部署 +- **時區完整支援**:台灣時區 (GMT+8) 全系統支援 +- **展延次數控制**:最多2次展延,總效期限制90天 - **Docker 容器化**:一鍵部署環境 ## 📋 功能模組 @@ -18,6 +19,12 @@ - **權限控制**:三級權限管理 (Viewer/Editor/Admin) - **歷史追蹤**:完整的操作記錄與版本控制 - **檔案上傳**:支援多種格式的佐證文件上傳 +- **展延限制**:最多展延2次,總效期上限90天,防止無限延期 + +### 權限說明 +- **Viewer(檢視者)**:檢視所有規範、下載PDF檔案、檢視歷史紀錄 +- **Editor(編輯者)**:建立新規範、編輯內容、展延/終止規範、下載檔案 +- **Admin(管理員)**:所有Editor權限 + 啟用規範 + 刪除規範 ### 智慧通知系統 - **動態收件人選擇**:整合LDAP的即時用戶搜尋 @@ -34,13 +41,15 @@ ## 🏗️ 系統架構 ``` -暫時規範系統 V3 +暫時規範系統 V4 ├── 前端介面 (Flask + Bootstrap 5) ├── 後端邏輯 (Python Flask) -├── 資料庫 (MySQL/SQLite) +├── 資料庫 (MySQL 外部) ├── LDAP整合 (Active Directory) ├── 文件引擎 (ONLYOFFICE) +├── 快取服務 (Redis) ├── 排程服務 (APScheduler) +├── 反向代理 (Nginx) └── 郵件系統 (SMTP) ``` @@ -52,15 +61,16 @@ - **文件處理**:python-docx, docx2pdf - **認證系統**:Flask-Login + LDAP3 - **排程系統**:Flask-APScheduler +- **快取服務**:Redis +- **反向代理**:Nginx - **容器化**:Docker + Docker Compose ## 📦 安裝部署 ### 前置需求 -- Python 3.8+ -- MySQL 8.0+ 或 SQLite -- ONLYOFFICE Document Server +- Docker & Docker Compose +- 外部 MySQL 8.0+ 資料庫 - LDAP/Active Directory 伺服器 - SMTP 郵件伺服器 @@ -69,7 +79,7 @@ 1. **克隆專案** ```bash git clone -cd TEMP_spec_system_V3 +cd TEMP_spec_system_V4 ``` 2. **設定環境變數** @@ -80,7 +90,17 @@ cp .env.example .env 3. **使用Docker Compose啟動** ```bash -docker-compose up -d +# 建置並啟動所有服務(強制重建以確保使用最新代碼) +docker-compose up -d --build + +# 檢查服務狀態 +docker-compose ps + +# 停止服務 +docker-compose down + +# 查看日誌 +docker-compose logs -f ``` 4. **初始化資料庫** @@ -88,95 +108,16 @@ docker-compose up -d docker-compose exec app python init_db.py ``` -5. **資料庫遷移(如果需要)** -```bash -# 新增郵件功能欄位 -docker-compose exec app python migrate_add_email_column.py -``` +5. **系統訪問** -### 手動安裝 +啟動完成後,您可以通過以下地址訪問系統: -#### Windows 環境 +- **主系統入口**:http://localhost:12013 (推薦使用,通過 Nginx 代理) +- **ONLYOFFICE 文件服務器**:http://localhost:12015 -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 -``` +> **注意**: +> - 建議使用 Nginx 代理入口 (12013) 訪問系統,具有更好的性能和安全性 +> - 首次啟動 ONLYOFFICE 可能需要 2-3 分鐘初始化,請等待容器狀態變為 `healthy` ## ⚙️ 組態設定 @@ -185,51 +126,48 @@ gunicorn -w 4 -b 0.0.0.0:5000 app:app ```env # Flask 設定 SECRET_KEY=your_secret_key_here -UPLOAD_FOLDER=uploads +FLASK_ENV=production -# 資料庫設定 -DATABASE_URL=mysql+pymysql://user:password@localhost/tempspec_db +# 資料庫設定 (外部 MySQL) +DATABASE_URL=mysql+pymysql://A060:WLeSCi0yhtc7@mysql.theaken.com:33306/db_A060 -# LDAP 設定 -LDAP_SERVER=ldap://your-dc.company.com +# LDAP 設定 (預設配置) +LDAP_SERVER=panjit.com.tw 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_USE_SSL=false +LDAP_SEARCH_BASE=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 郵件設定 (Port 25 無認證方式) -SMTP_SERVER=mail.company.com +# 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@company.com +SMTP_SENDER_EMAIL=temp-spec-system@panjit.com.tw SMTP_SENDER_PASSWORD= # ONLYOFFICE 設定 -ONLYOFFICE_URL=http://onlyoffice:8080 -ONLYOFFICE_JWT_SECRET=your_jwt_secret +ONLYOFFICE_URL=http://localhost:12015/ +ONLYOFFICE_INTERNAL_URL=http://onlyoffice:80 +ONLYOFFICE_JWT_SECRET=your_jwt_secret_key_here + +# Redis 設定 +REDIS_URL=redis://redis:6379/0 + +# 服務端口設定 +ONLYOFFICE_PORT=12015 ``` -### SMTP 配置說明 - -系統支援多種 SMTP 配置方式: - -- **Port 25(推薦)**:內部郵件伺服器,無需認證 -- **Port 587**:STARTTLS + 認證 -- **Port 465**:SSL + 認證 - -詳細設定請參考 `SMTP_CONFIGURATION_UPDATE.md` - ## 📚 使用說明 ### 登入規範 **重要**:系統要求使用完整的UPN格式帳號登入 -✅ **正確格式**:`user@domain.com` +✅ **正確格式**:`user@panjit.com.tw` ❌ **錯誤格式**:`user` ### 初次設定管理員 @@ -240,9 +178,18 @@ ONLYOFFICE_JWT_SECRET=your_jwt_secret 2. **手動設定**:在資料庫中更新用戶權限: ```sql -UPDATE ts_user SET role='admin' WHERE username='user@domain.com'; +UPDATE ts_user SET role='admin' WHERE username='user@panjit.com.tw'; ``` +### 展延次數限制 + +系統實作嚴格的展延控制機制: + +- **初次生效**:30天效期 +- **第一次展延**:效期變為60天 +- **第二次展延**:效期變為90天(達到上限) +- **第三次展延**:系統拒絕並顯示錯誤訊息 + ### 郵件通知功能 系統具備智慧郵件管理功能: @@ -281,32 +228,124 @@ UPDATE ts_user SET role='admin' WHERE username='user@domain.com'; - 確認服務帳號權限 - 驗證 LDAP 伺服器位址和搜尋基底 -2. **ONLYOFFICE 無法載入** +2. **ONLYOFFICE 無法載入文件** - 確認 Document Server 運行狀態:`docker ps` - - 檢查網路連線設定 + - 檢查容器間網路連線:`docker exec panjit-tempspec-onlyoffice curl -s http://panjit-tempspec-nginx:80/health` - 驗證 JWT Secret 設定是否一致 + - 檢查日誌中的網路錯誤:`docker-compose logs onlyoffice | grep ERROR` -3. **郵件發送失敗** + **常見錯誤**: + - `ECONNREFUSED` 錯誤:通常是容器間網路配置問題 + - `host.docker.internal` 無法連接:應使用容器名稱而非 host.docker.internal + - 文件下載失敗:檢查靜態檔案路徑和 nginx 代理設定 + +3. **ONLYOFFICE 文件同步問題** + - **問題描述**:在 OnlyOffice 編輯器中編輯並儲存文件後,下載的 Word 文件沒有包含最新的編輯內容 + + **解決方案**: + - 系統已增強 OnlyOffice 回調處理機制,支援多種儲存狀態 + - 改善了文件同步邏輯,確保編輯內容正確儲存到伺服器 + - 新增詳細的回調日誌記錄,便於問題診斷 + + **檢查方法**: + ```bash + # 查看 OnlyOffice 回調日誌 + docker-compose logs -f app | grep "OnlyOffice callback" + + # 檢查文件儲存狀態 + docker-compose logs -f app | grep "Document saved successfully" + + # 驗證文件更新時間 + docker exec panjit-tempspec-app ls -la uploads/ + ``` + + **技術細節**: + - 回調處理現在支援 status=2 (自動儲存) 和 status=6 (強制儲存) + - 增加了 JWT Token 驗證以確保回調安全性 + - 改善錯誤處理和檔案下載邏輯 + - 修正回調中的 URL 路由,將 localhost:12015 轉換為容器間通信地址 panjit-tempspec-onlyoffice:80 + +4. **時區設定問題** + - **問題描述**:系統時間顯示為 GMT+0,但正確時區應該是 GMT+8(台灣時區) + + **解決方案**: + - 建立完整的時區處理模組 `utils/timezone.py` + - 修正所有資料庫時間欄位使用台灣時區 + - 更新前端模板使用時區轉換 filter + - 設定 Docker 容器環境變數 `TZ: Asia/Taipei` + + **檢查方法**: + ```bash + # 檢查容器時區設定 + docker exec panjit-tempspec-app date + + # 測試時區轉換功能 + docker exec panjit-tempspec-app python -c "from utils.timezone import taiwan_now, format_taiwan_time; print(format_taiwan_time(taiwan_now()))" + ``` + + **技術細節**: + - 新增 `taiwan_now()` 函數替代 `datetime.utcnow()` + - 支援 `date` 和 `datetime` 物件的時區轉換 + - 前端模板 filter:`|taiwan_time` 和 `|taiwan_date` + - 自動處理 naive datetime 和 timezone-aware datetime + +5. **展延次數限制功能** + - **功能說明**:防止暫時規範無限延期,確保企業治理合規 + + **限制規則**: + - 初次生效:30天效期 + - 第一次展延:效期變為60天 + - 第二次展延:效期變為90天(達到上限) + - 第三次展延:系統拒絕並顯示錯誤訊息 + + **實作功能**: + - 後端邏輯檢查:當 `extension_count >= 2` 時拒絕展延 + - 前端介面優化:超過限制時展延按鈕變為不可用 + - 視覺化顯示:展延次數進度和剩餘次數 + - 樣式改進:深色背景下清楚可見的展延狀態顯示 + +6. **郵件發送失敗** - 確認 SMTP 設定正確 - 檢查郵件伺服器認證設定 - 驗證防火牆規則 (Port 25/587/465) -4. **排程任務未執行** +7. **排程任務未執行** - 檢查 APScheduler 初始化 - 確認應用程式持續運行 - 查看系統日誌 +8. **容器間網路通訊問題** + - 檢查所有容器是否在同一網路:`docker network ls` + - 驗證容器名稱解析:`docker exec container1 ping container2` + - 確認端口映射:`docker-compose ps` + + **OnlyOffice 網路問題修正**: + ```bash + # 檢查 OnlyOffice 能否連接到 Flask 應用 + docker exec panjit-tempspec-onlyoffice curl -I http://panjit-tempspec-nginx:80/static/ + + # 檢查 Flask 應用日誌 + docker-compose logs -f app + + # 重新構建有網路修正的應用 + docker-compose up -d --build app + ``` + ### 日誌查看 ```bash # Docker 環境 docker-compose logs -f app -# 一般環境 -tail -f logs/app.log +# OnlyOffice 相關日誌 +docker-compose logs -f onlyoffice +docker exec panjit-tempspec-onlyoffice tail -f /var/log/onlyoffice/documentserver/docservice/out.log -# Windows 環境 -Get-Content logs/app.log -Tail 10 -Wait +# Redis 日誌 +docker-compose logs -f redis + +# Nginx 日誌 +docker-compose logs -f nginx ``` ## 🤝 開發指南 @@ -318,25 +357,43 @@ Get-Content logs/app.log -Tail 10 -Wait ├── config.py # 組態設定 ├── models.py # 資料模型 ├── tasks.py # 排程任務 +├── wsgi.py # WSGI 入口點 +├── gunicorn.conf.py # Gunicorn 配置 ├── routes/ # 路由模組 +│ ├── __init__.py │ ├── auth.py # 認證相關 │ ├── temp_spec.py # 暫規管理 │ ├── upload.py # 檔案上傳 +│ ├── admin.py # 管理功能 │ └── api.py # API介面 ├── templates/ # 前端範本 ├── static/ # 靜態檔案 -├── utils.py # 工具函式 -└── ldap_utils.py # LDAP 工具 +├── utils/ # 工具模組 +│ ├── __init__.py # 工具函式 +│ └── timezone.py # 時區處理 +├── ldap_utils.py # LDAP 工具 +├── cache_utils.py # 快取工具 +├── cdn_utils.py # CDN 工具 +├── monitor.py # 監控工具 +└── nginx/ # Nginx 配置 + ├── Dockerfile + ├── nginx.conf + └── conf.d/ ``` -### 資料庫遷移 +### 容器架構 -當系統需要資料庫結構更新時: +- **panjit-tempspec-app**: Flask 應用程式容器 +- **panjit-tempspec-nginx**: Nginx 反向代理容器 +- **panjit-tempspec-onlyoffice**: OnlyOffice 文件服務器容器 +- **panjit-tempspec-redis**: Redis 快取服務容器 -```bash -# 執行遷移腳本 -python migrate_add_email_column.py -``` +### 網路配置 + +所有容器運行在 `tempspec-network` 橋接網路中,確保容器間通信: + +- 外部訪問:通過 Nginx (port 12013) 和 OnlyOffice (port 12015) +- 內部通信:使用容器名稱作為主機名 ## 📄 授權條款 @@ -344,7 +401,18 @@ python migrate_add_email_column.py ## 🆕 版本歷程 -### v3.2.0 (最新版本) +### v4.0.0 (最新版本) +- 🆕 新增台灣時區 (GMT+8) 完整支援 +- 🆕 實作展延次數限制功能(最多2次,總效期90天) +- 🆕 修正 OnlyOffice 文件同步問題 +- 🎨 改進規範列表頁面樣式和用戶體驗 +- 🔧 修正時區 filter 支援 date 物件處理 +- 🗑️ 移除舊版 utils.py,改用模組化架構 +- 🔧 修正容器間網路通信問題 +- 🆕 新增 Redis 快取服務 +- 🆕 新增 Nginx 反向代理 + +### v3.2.0 - 🆕 新增郵件通知記憶功能 - 🆕 支援 Port 25 無認證 SMTP - ♻️ 優化郵件管理邏輯 @@ -363,4 +431,4 @@ python migrate_add_email_column.py --- -**暫時規範管理系統 V3** - 讓企業文件管理更智慧、更高效! \ No newline at end of file +**暫時規範管理系統 V4** - 讓企業文件管理更智慧、更高效! \ No newline at end of file diff --git a/USER_MANUAL.md b/USER_MANUAL.md index c0c1692..9ed3711 100644 --- a/USER_MANUAL.md +++ b/USER_MANUAL.md @@ -1,6 +1,6 @@ -# 暫時規範管理系統 V3 操作說明書 +# 暫時規範管理系統 V4 操作說明書 -歡迎使用企業級暫時規範管理系統 V3。本系統整合了LDAP認證、ONLYOFFICE線上編輯器及智慧通知系統,提供完整的文件生命週期管理解決方案。 +歡迎使用企業級暫時規範管理系統 V4。本系統整合了LDAP認證、ONLYOFFICE線上編輯器、智慧通知系統及台灣時區完整支援,提供完整的文件生命週期管理解決方案。 ## 📋 目錄 @@ -16,16 +16,21 @@ ## 1. 系統簡介 -暫時規範管理系統 V3 是一個集中化平台,用於管理、追蹤和存檔所有暫時性的工程規範。它涵蓋了從草擬、線上編輯、簽核、生效到最終歸檔的完整生命週期。 +暫時規範管理系統 V4 是一個集中化平台,用於管理、追蹤和存檔所有暫時性的工程規範。它涵蓋了從草擬、線上編輯、簽核、生效到最終歸檔的完整生命週期。 -### 🚀 V3.2 版本新特色 +### 🚀 V4.0 版本新特色 +- **台灣時區完整支援**:所有時間顯示使用台灣時區 (GMT+8) +- **展延次數限制**:最多2次展延,總效期上限90天 +- **文件同步改善**:修正OnlyOffice編輯同步問題 +- **UI樣式優化**:改善深色背景下的展延狀態顯示 - **LDAP/AD 整合**:使用企業Active Directory帳號登入 - **智慧郵件記憶**:自動記憶並帶出之前使用的通知對象 - **彈性郵件編輯**:可編輯通知名單並更新記錄 - **多種SMTP支援**:支援Port 25無認證及其他認證方式 - **自動排程提醒**:系統主動發送到期提醒郵件 - **增強的編輯體驗**:ONLYOFFICE文件協作編輯 +- **容器化架構**:Redis快取 + Nginx反向代理 --- @@ -37,14 +42,14 @@ **🚨 重要登入規範**: -✅ **正確格式**:必須使用完整的 UPN 格式帳號 -例如:`user@domain.com` +✅ **正確格式**:必須使用完整的 UPN 格式帳號 +例如:`user@panjit.com.tw` -❌ **錯誤格式**:不支援縮略帳號 +❌ **錯誤格式**:不支援縮略帳號 例如:`user` **登入步驟**: -1. 在登入頁面輸入您的 **完整 AD 帳號**(例如:user@domain.com) +1. 在登入頁面輸入您的 **完整 AD 帳號**(例如:user@panjit.com.tw) 2. 輸入您的 **AD 密碼** 3. 點擊「**登入**」按鈕 @@ -60,7 +65,9 @@ - **主題**:規範標題 - **申請人**:規範申請者 - **狀態**:pending_approval(待生效)/active(已生效)/expired(已過期)/terminated(已終止) -- **時間範圍**:生效日期至結束日期 +- **時間範圍**:生效日期至結束日期(台灣時間顯示) +- **剩餘天數**:彩色標示不同緊急程度 +- **展延狀態**:顯示已展延次數和剩餘次數 - **操作按鈕**:依權限顯示不同功能 --- @@ -102,9 +109,10 @@ **編輯器功能**: - 全功能 Word 文件編輯 -- 即時自動儲存 +- 即時自動儲存和同步 - 支援圖片、表格插入 - 格式化工具列 +- 增強的文件同步機制 ### 3.3 啟用規範(Admin 權限) @@ -122,22 +130,35 @@ 5. 系統自動: - 更新規範狀態 - **記憶通知對象**供後續使用 - - 發送啟用通知郵件 + - 發送啟用通知郵件(台灣時間) ### 3.4 展延規範(Editor/Admin 權限) -延長已生效規範的結束日期: +**🆕 V4.0 展延限制功能**: + +延長已生效規範的結束日期,但有嚴格限制: 1. 點擊「**展延**」按鈕 -2. **設定新結束日期**:選擇展延後的日期 -3. **上傳佐證檔案**:提供展延理由相關文件(PDF格式) -4. **🆕 智慧通知設定**: +2. **檢查展延次數**: + - 最多只能展延 **2次** + - 總效期上限 **90天** + - 已達上限的規範展延按鈕會變為不可用 + +3. **設定新結束日期**:選擇展延後的日期 +4. **上傳佐證檔案**:提供展延理由相關文件(PDF格式) +5. **🆕 智慧通知設定**: - 系統自動帶出之前啟用時使用的通知對象 - 可直接使用或進行編輯 - 修改後的名單會更新到系統記錄中 -5. 點擊「**確認展延**」 -6. 系統自動發送展延通知郵件 +6. 點擊「**確認展延**」 +7. 系統自動發送展延通知郵件 + +**展延規則**: +- **初次生效**:30天效期 +- **第一次展延**:效期變為60天 +- **第二次展延**:效期變為90天(達到上限) +- **第三次展延**:系統拒絕並顯示錯誤訊息 ### 3.5 終止規範(Editor/Admin 權限) @@ -152,7 +173,7 @@ 4. 點擊「**確認終止**」 5. 系統自動: - - 更新結束日期為今日 + - 更新結束日期為今日(台灣時間) - 發送終止通知郵件 --- @@ -161,11 +182,11 @@ ### 4.1 🆕 郵件記憶功能 -**V3.2 新增功能**:系統現在具備智慧郵件管理能力 +**V4.0 持續改進功能**:系統現在具備智慧郵件管理能力 **運作機制**: 1. **規範啟用時**:輸入通知郵件對象,系統自動記憶 -2. **規範終止時**:自動帶出啟用時的郵件清單,可編輯後發送 +2. **規範終止時**:自動帶出啟用時的郵件清單,可編輯後發送 3. **規範展延時**:自動帶出郵件清單,修改後會更新記錄 **操作說明**: @@ -195,18 +216,18 @@ **手動通知**(操作觸發): - 規範啟用通知 -- 規範展延通知 +- 規範展延通知 - 規範終止通知 **🆕 自動提醒**(系統排程): - **7天到期提醒**:在規範到期前7天自動發送 - **3天到期提醒**:在規範到期前3天自動發送 -- **發送時間**:每天凌晨2:00檢查並發送 +- **發送時間**:每天凌晨2:00檢查並發送(台灣時間) **郵件內容**: - HTML格式美化顯示 - 包含規範編號、標題、申請人 -- 明確標示生效/結束日期 +- 明確標示生效/結束日期(台灣時間顯示) - 提供系統連結 --- @@ -239,18 +260,26 @@ 點擊 **歷史紀錄圖示 (🕒)** 查看: -- 操作時間戳記 +- 操作時間戳記(台灣時間顯示) - 執行用戶 - 操作類型(建立/啟用/展延/終止) - 詳細說明 -### 5.4 即將到期警示 +### 5.4 即將到期警示與展延狀態 +**到期警示**: 在規範列表中會特別標示即將到期的規範: +- **🟢 綠色標示**:7天以上 - **🟡 橙色標示**:7天內到期 -- **🔴 紅色標示**:3天內到期 -- **閃爍動畫**:今日到期 +- **🔴 紅色標示**:3天內到期 +- **已過期標示**:已過期 + +**🆕 展延狀態顯示**: +- 清楚顯示「已展延 X 次」 +- 達到上限時顯示「達到上限」標籤 +- 優化深色背景下的可讀性 +- 居中對齊,提升視覺體驗 --- @@ -274,7 +303,7 @@ **Editor(編輯者)**: - 建立新規範草稿 - 編輯規範內容 -- 展延和終止規範 +- 展延和終止規範(受展延次數限制) - 下載Word和PDF檔案 **Admin(管理員)**: @@ -290,11 +319,11 @@ ### 7.1 登入相關 **Q: 忘記帳號格式?** -A: 必須使用完整的 `user@domain.com` 格式,不能只輸入 `user` +A: 必須使用完整的 `user@panjit.com.tw` 格式,不能只輸入 `user` **Q: 無法登入?** A: 請確認: -1. 帳號格式正確(包含@domain.com) +1. 帳號格式正確(包含@panjit.com.tw) 2. 密碼正確 3. AD帳號未被鎖定 4. 網路連線正常 @@ -315,11 +344,13 @@ A: 請確認: 2. 網路連線穩定 3. 彈出視窗未被阻擋 -**Q: 編輯內容未儲存?** -A: 建議: +**🆕 Q: 編輯內容未儲存或同步?** +A: V4.0已改善文件同步機制: 1. 編輯期間保持網路連線 -2. 避免同時多人編輯同一文件 -3. 定期手動儲存 (Ctrl+S) +2. 系統現在支援多種儲存狀態 +3. 增強了回調處理機制 +4. 定期手動儲存 (Ctrl+S) +5. 檢查容器間網路是否正常 ### 7.4 通知相關 @@ -336,7 +367,7 @@ A: 請檢查: 3. 公司郵件伺服器設定 **Q: 自動提醒郵件何時發送?** -A: 系統每天凌晨2:00自動檢查並發送提醒,分別在到期前7天和3天發送。 +A: 系統每天凌晨2:00(台灣時間)自動檢查並發送提醒,分別在到期前7天和3天發送。 **🆕 Q: 郵件通知對象會自動記憶嗎?** A: 是的,系統會記憶啟用時設定的通知對象: @@ -344,7 +375,37 @@ A: 是的,系統會記憶啟用時設定的通知對象: - 展延規範時也會自動帶出,修改後會更新記錄 - 您可以直接使用或編輯後再發送 -### 7.5 檔案相關 +### 7.5 展延相關 + +**🆕 Q: 為什麼無法繼續展延?** +A: V4.0實作嚴格的展延控制: +- 每個規範最多只能展延2次 +- 總效期不能超過90天 +- 達到上限後系統會拒絕展延請求 +- 展延按鈕會變為不可用狀態 + +**🆕 Q: 展延次數如何計算?** +A: 展延次數計算規則: +- 初次生效:30天(不計入展延次數) +- 第一次展延:效期變為60天(展延次數=1) +- 第二次展延:效期變為90天(展延次數=2,達到上限) + +### 7.6 時區相關 + +**🆕 Q: 系統顯示的時間是否正確?** +A: V4.0完整支援台灣時區: +- 所有時間顯示使用台灣時區 (GMT+8) +- 資料庫儲存自動轉換為台灣時間 +- 郵件通知使用台灣時間 +- 到期檢查基於台灣時間 + +**🆕 Q: 舊紀錄的時間顯示是否正確?** +A: 系統已實作時區轉換機制: +- 自動處理舊紀錄的時區轉換 +- 支援date和datetime物件轉換 +- 確保所有時間顯示一致性 + +### 7.7 檔案相關 **Q: 可以上傳Word檔案來啟用規範嗎?** A: 不可以。為確保文件完整性,啟用時必須上傳已簽核的 **PDF檔案**。 @@ -356,25 +417,34 @@ A: 請確認: 3. 檔案名稱不含特殊字元 4. 網路連線穩定 -### 7.6 效能相關 +### 7.8 效能相關 **Q: 系統回應速度慢?** -A: 可能原因: -1. 網路連線問題 -2. 伺服器負載過高 -3. 資料庫查詢耗時 -4. 聯繫系統管理員檢查 +A: V4.0已優化效能: +1. 新增Redis快取系統 +2. Nginx反向代理提升速度 +3. 如仍有問題請聯繫系統管理員 +4. 檢查網路連線狀況 --- ## 📝 版本資訊 -- **文件版本**: V3.2.0 -- **最後更新**: 2025年1月 -- **適用系統**: 暫時規範管理系統 V3.2 +- **文件版本**: V4.0.0 +- **最後更新**: 2025年9月 +- **適用系統**: 暫時規範管理系統 V4.0 ### 版本更新記錄 +**V4.0.0**: +- 新增台灣時區完整支援 +- 實作展延次數限制功能(最多2次,90天上限) +- 修正OnlyOffice文件同步問題 +- 改進UI樣式,優化深色背景下的顯示 +- 修正時區filter支援date物件處理 +- 移除舊版utils.py,改用模組化架構 +- 新增Redis快取和Nginx反向代理 + **V3.2.0**: - 新增郵件通知記憶功能 - 支援Port 25無認證SMTP @@ -389,5 +459,5 @@ A: 可能原因: --- -**感謝您使用暫時規範管理系統 V3!** -希望這個操作手冊能幫助您更有效地使用系統功能。 \ No newline at end of file +**感謝您使用暫時規範管理系統 V4!** +希望這個操作手冊能幫助您更有效地使用系統功能。如有任何問題,請聯繫系統管理員。 \ No newline at end of file diff --git a/app.py b/app.py index e220ca0..d46d255 100644 --- a/app.py +++ b/app.py @@ -66,6 +66,19 @@ app.register_blueprint(upload_bp) app.register_blueprint(admin_bp) app.register_blueprint(api_bp) +# 註冊自訂模板 filter +from utils.timezone import format_taiwan_time + +@app.template_filter('taiwan_time') +def taiwan_time_filter(dt, format_str='%Y-%m-%d %H:%M:%S'): + """將 datetime 轉換為台灣時間格式字符串""" + return format_taiwan_time(dt, format_str) + +@app.template_filter('taiwan_date') +def taiwan_date_filter(dt): + """將 datetime 轉換為台灣日期格式字符串""" + return format_taiwan_time(dt, '%Y-%m-%d') + # 導入任務 from tasks import check_expiring_specs diff --git a/docker-compose.yml b/docker-compose.yml index 42af0aa..647766f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,11 @@ services: # Redis 快取服務 redis: - image: redis:7-alpine - container_name: tempspec-redis + image: panjit-tempspec:redis + build: + context: . + dockerfile: Dockerfile.redis + container_name: panjit-tempspec-redis restart: unless-stopped command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru volumes: @@ -15,21 +18,33 @@ services: timeout: 10s retries: 5 - # ONLYOFFICE Document Server + # ONLYOFFICE Document Server - 使用輕量化版本 onlyoffice: - image: onlyoffice/documentserver:8.0 - container_name: tempspec-onlyoffice + image: onlyoffice/documentserver:8.1 + container_name: panjit-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" + # 使用內建資料庫,不需要外部 PostgreSQL + AMQP_TYPE: "0" # 禁用RabbitMQ以節省資源 + # 時區設定 + TZ: Asia/Taipei ports: - - "${ONLYOFFICE_PORT:-12011}:80" + - "${ONLYOFFICE_PORT:-12015}:80" volumes: - onlyoffice_data:/var/www/onlyoffice/Data - onlyoffice_logs:/var/log/onlyoffice + deploy: + resources: + limits: + memory: 3G + cpus: '2.0' + reservations: + memory: 1.5G + cpus: '1.0' networks: - tempspec-network healthcheck: @@ -40,8 +55,11 @@ services: # Flask 應用程式 app: - build: . - container_name: tempspec-app + image: panjit-tempspec:main + build: + context: . + dockerfile: Dockerfile + container_name: panjit-tempspec-app restart: unless-stopped environment: # Flask 設定 @@ -57,14 +75,14 @@ services: # 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} + # LDAP 設定 (統一配置) + LDAP_SERVER: panjit.com.tw + LDAP_PORT: 389 + LDAP_USE_SSL: false + LDAP_SEARCH_BASE: 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: ${SMTP_SERVER:-smtp.company.com} @@ -74,14 +92,16 @@ services: SMTP_SENDER_PASSWORD: ${SMTP_SENDER_PASSWORD:-smtp_password} # ONLYOFFICE 設定 - ONLYOFFICE_URL: http://localhost:12011/ + ONLYOFFICE_URL: http://localhost:12015/ ONLYOFFICE_INTERNAL_URL: http://onlyoffice:80 ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-your_jwt_secret_key_here} - + + # 時區設定 + TZ: Asia/Taipei + # 其他設定 UPLOAD_FOLDER: uploads - ports: - - "${APP_PORT:-12010}:5000" + # No external port; only Nginx exposes ports volumes: - ./uploads:/app/uploads - ./static/generated:/app/static/generated @@ -99,7 +119,7 @@ services: redis: condition: service_healthy onlyoffice: - condition: service_healthy + condition: service_started networks: - tempspec-network healthcheck: @@ -110,12 +130,14 @@ services: # Nginx 反向代理 (生產環境自動啟用) nginx: - image: nginx:alpine - container_name: tempspec-nginx + image: panjit-tempspec:nginx + build: + context: ./nginx + dockerfile: Dockerfile + container_name: panjit-tempspec-nginx restart: unless-stopped ports: - - "${NGINX_PORT:-12013}:80" - - "${NGINX_SSL_PORT:-12014}:443" + - "12013:80" volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/conf.d:/etc/nginx/conf.d:ro diff --git a/models.py b/models.py index 57f1c70..eb67221 100644 --- a/models.py +++ b/models.py @@ -1,6 +1,7 @@ from flask_sqlalchemy import SQLAlchemy from flask_login import UserMixin from datetime import datetime +from utils.timezone import taiwan_now db = SQLAlchemy() @@ -53,7 +54,7 @@ class SpecHistory(db.Model): 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) + timestamp = db.Column(db.DateTime, default=taiwan_now) # 建立與 User 和 TempSpec 的關聯,方便查詢 user = db.relationship('User') diff --git a/nginx/Dockerfile b/nginx/Dockerfile new file mode 100644 index 0000000..8465ef9 --- /dev/null +++ b/nginx/Dockerfile @@ -0,0 +1,21 @@ +FROM nginx:alpine + +# 移除預設配置 +RUN rm /etc/nginx/conf.d/default.conf + +# 複製自定義配置 +COPY nginx.conf /etc/nginx/nginx.conf +COPY conf.d/ /etc/nginx/conf.d/ + +# 創建SSL目錄 +RUN mkdir -p /etc/nginx/ssl + +# 設置正確的權限 +RUN chown -R nginx:nginx /etc/nginx && \ + chmod -R 755 /etc/nginx + +# 暴露端口 +EXPOSE 80 443 + +# 啟動nginx +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/nginx/conf.d/default.conf b/nginx/conf.d/default.conf index 30782f4..ce1f05e 100644 --- a/nginx/conf.d/default.conf +++ b/nginx/conf.d/default.conf @@ -8,7 +8,7 @@ server { # 應用程式代理 location / { - proxy_pass http://app:5000; + proxy_pass http://panjit-tempspec-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; @@ -27,7 +27,7 @@ server { # ONLYOFFICE Document Server 代理 location /onlyoffice/ { - proxy_pass http://onlyoffice:80/; + proxy_pass http://panjit-tempspec-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; @@ -46,14 +46,14 @@ server { # 靜態檔案快取 location /static/ { - proxy_pass http://app:5000/static/; + proxy_pass http://panjit-tempspec-app:5000/static/; expires 1y; add_header Cache-Control "public, immutable"; } # 健康檢查 location /health { - proxy_pass http://app:5000/; + proxy_pass http://panjit-tempspec-app:5000/; access_log off; } diff --git a/requirements.txt b/requirements.txt index 099bf19..3608d27 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,34 @@ -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 +# Flask Framework (統一版本) +Flask==3.0.0 +Flask-Login==0.6.3 +Flask-SQLAlchemy==3.0.5 +Flask-Caching==2.1.0 +Flask-APScheduler==1.13.1 + +# Database +PyMySQL==1.1.0 + +# Web Server +Werkzeug==3.0.1 +gunicorn==21.2.0 + +# Authentication +PyJWT==2.8.0 +ldap3==2.9.1 + +# Document Processing +python-docx==1.1.0 +docxtpl==0.16.7 +docx2pdf==0.1.8 + +# Utilities +beautifulsoup4==4.12.2 +lxml==4.9.3 +python-dotenv==1.0.0 +mistune==3.0.2 +Pillow==10.1.0 +requests==2.31.0 +cryptography==41.0.7 + +# Cache & Task Queue +redis==5.0.1 \ No newline at end of file diff --git a/routes/auth.py b/routes/auth.py index dfc81f1..e8f5518 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -3,6 +3,7 @@ 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 +from utils.timezone import taiwan_now import logging auth_bp = Blueprint('auth', __name__) @@ -69,7 +70,7 @@ def login(): current_app.logger.info(f"Existing user found: {user_info['username']}") # Update last_login time - local_user.last_login = datetime.now() + local_user.last_login = taiwan_now() db.session.commit() # Step 3: Log in the user with Flask-Login diff --git a/routes/temp_spec.py b/routes/temp_spec.py index cfe8990..394ad51 100644 --- a/routes/temp_spec.py +++ b/routes/temp_spec.py @@ -2,6 +2,7 @@ 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 utils.timezone import taiwan_now, format_taiwan_time 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 @@ -27,7 +28,7 @@ def _generate_next_spec_code(): 產生下一個暫時規範編號。 規則: PE + 民國年(3碼) + 月份(2碼) + 流水號(2碼) """ - now = datetime.now() + now = taiwan_now() roc_year = now.year - 1911 prefix = f"PE{roc_year}{now.strftime('%m')}" @@ -54,7 +55,7 @@ def create_temp_spec(): if request.method == 'POST': spec_code = _generate_next_spec_code() form_data = request.form - now = datetime.now() + now = taiwan_now() # 1. 在資料庫中建立紀錄 spec = TempSpec( @@ -140,20 +141,25 @@ def edit_spec(spec_id): 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 可存取的地址 + # 2. 修正容器間通訊的 URL,使用正確的容器名稱 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') + # 在 Docker Compose 環境中,OnlyOffice 應該透過 nginx 存取 Flask 應用 + doc_url = doc_url.replace('127.0.0.1:12013', 'panjit-tempspec-nginx:80').replace('localhost:12013', 'panjit-tempspec-nginx:80') + doc_url = doc_url.replace('127.0.0.1', 'panjit-tempspec-nginx').replace('localhost', 'panjit-tempspec-nginx') + callback_url = callback_url.replace('127.0.0.1:12013', 'panjit-tempspec-nginx:80').replace('localhost:12013', 'panjit-tempspec-nginx:80') + callback_url = callback_url.replace('127.0.0.1', 'panjit-tempspec-nginx').replace('localhost', 'panjit-tempspec-nginx') # --- END: 修正文件下載與回呼的 URL --- oo_secret = current_app.config['ONLYOFFICE_JWT_SECRET'] + # 生成唯一的文件密鑰,包含更新時間戳 + file_key = f"{spec.id}_{int(os.path.getmtime(doc_physical_path))}" + payload = { "document": { "fileType": "docx", - "key": f"{spec.id}_{int(os.path.getmtime(doc_physical_path))}", + "key": file_key, "title": doc_filename, "url": doc_url # <-- 使用修正後的 doc_url }, @@ -161,7 +167,14 @@ def edit_spec(spec_id): "editorConfig": { "callbackUrl": callback_url, # <-- 使用修正後的回呼 URL "user": { "id": str(current_user.id), "name": current_user.username }, - "customization": { "autosave": True, "forcesave": True } + "customization": { + "autosave": True, + "forcesave": True, + "chat": False, + "comments": True, + "help": False + }, + "mode": "edit" } } @@ -181,22 +194,78 @@ def edit_spec(spec_id): @temp_spec_bp.route('/onlyoffice-callback/', methods=['POST']) def onlyoffice_callback(spec_id): data = request.json - - if data.get('status') == 2: + status = data.get('status') + + # 記錄所有回調狀態以便調試 + current_app.logger.info(f"OnlyOffice callback for spec {spec_id}: status={status}, data={data}") + + # OnlyOffice 狀態說明: + # 0 - 文件未找到 + # 1 - 文件編輯中 + # 2 - 文件準備保存 + # 3 - 文件保存中 + # 4 - 文件已關閉,無變更 + # 6 - 文件編輯中,但已強制保存 + # 7 - 發生錯誤 + + if status in [2, 6]: # 文件需要保存或已強制保存 try: - response = requests.get(data['url'], timeout=10) + if 'url' not in data: + current_app.logger.error(f"OnlyOffice callback missing URL for spec {spec_id}") + return jsonify({"error": 1, "message": "Missing document URL"}) + + # 驗證 JWT Token (如果有) + token = data.get('token') + if token: + try: + oo_secret = current_app.config['ONLYOFFICE_JWT_SECRET'] + jwt.decode(token, oo_secret, algorithms=['HS256']) + except jwt.InvalidTokenError: + current_app.logger.error(f"Invalid JWT token in OnlyOffice callback for spec {spec_id}") + return jsonify({"error": 1, "message": "Invalid token"}) + + # 修正 OnlyOffice 回調中的 URL 以供容器間通信使用 + download_url = data['url'] + # 將外部訪問 URL 轉換為容器間通信 URL + download_url = download_url.replace('localhost:12015', 'panjit-tempspec-onlyoffice:80') + download_url = download_url.replace('127.0.0.1:12015', 'panjit-tempspec-onlyoffice:80') + + current_app.logger.info(f"Downloading updated document from: {data['url']} -> {download_url}") + response = requests.get(download_url, timeout=30) 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) + # 確保目錄存在 + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, 'wb') as f: f.write(response.content) + + current_app.logger.info(f"Successfully saved updated document for spec {spec_id} to {file_path}") + + # 更新資料庫中的修改時間 + spec.updated_at = taiwan_now() + db.session.commit() + + except requests.RequestException as e: + current_app.logger.error(f"Failed to download document from OnlyOffice for spec {spec_id}: {e}") + return jsonify({"error": 1, "message": f"Download failed: {str(e)}"}) except Exception as e: - current_app.logger.error(f"ONLYOFFICE callback error for spec {spec_id}: {e}") + current_app.logger.error(f"OnlyOffice callback error for spec {spec_id}: {e}") return jsonify({"error": 1, "message": str(e)}) + elif status == 1: + current_app.logger.info(f"Document {spec_id} is being edited") + elif status == 4: + current_app.logger.info(f"Document {spec_id} closed without changes") + elif status == 7: + current_app.logger.error(f"OnlyOffice error for document {spec_id}") + return jsonify({"error": 1, "message": "OnlyOffice reported an error"}) + return jsonify({"error": 0}) # --- 其他既有路由 --- @@ -251,7 +320,7 @@ def activate_spec(spec_id): 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") + filename = secure_filename(f"{spec.spec_code}_signed_{taiwan_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) @@ -260,7 +329,7 @@ def activate_spec(spec_id): new_upload = Upload( temp_spec_id=spec.id, filename=filename, - upload_time=datetime.now() + upload_time=taiwan_now() ) db.session.add(new_upload) @@ -313,7 +382,7 @@ def terminate_spec(spec_id): spec.status = 'terminated' spec.termination_reason = reason - spec.end_date = datetime.today().date() + spec.end_date = taiwan_now().date() add_history_log(spec.id, '終止', f"原因: {reason}") # --- Start of Dynamic Email Notification --- @@ -382,7 +451,12 @@ def download_signed_pdf(spec_id): @editor_or_admin_required def extend_spec(spec_id): spec = TempSpec.query.get_or_404(spec_id) - + + # 檢查展延次數限制(最多展延2次) + if spec.extension_count >= 2: + flash('此暫時規範已達展延次數上限(2次),無法再次展延。總效期已達90天上限。', 'danger') + return redirect(url_for('temp_spec.spec_list')) + if request.method == 'POST': new_end_date_str = request.form.get('new_end_date') uploaded_file = request.files.get('new_file') @@ -399,7 +473,7 @@ def extend_spec(spec_id): 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") + filename = secure_filename(f"{spec.spec_code}_extension_{spec.extension_count}_{taiwan_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) @@ -408,7 +482,7 @@ def extend_spec(spec_id): new_upload = Upload( temp_spec_id=spec.id, filename=filename, - upload_time=datetime.now() + upload_time=taiwan_now() ) db.session.add(new_upload) diff --git a/static/generated/PE1140901.docx b/static/generated/PE1140901.docx index 84cab686ab0b88a0c319a198ded31eb3aa119ecc..510aa7cb36348b6fec35e43875750e6fc3099daf 100644 GIT binary patch delta 5678 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 F{|8%%d)NQ~ delta 5847 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 ON_)bFGxqrJqW=RF%$;Tc diff --git a/static/generated/PE1140902.docx b/static/generated/PE1140902.docx deleted file mode 100644 index 46f4015259228fd80a5a55084e0b4ef8d6d85f49..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24508 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 diff --git a/static/generated/PE1140903.docx b/static/generated/PE1140903.docx deleted file mode 100644 index 16ba89c81e1558d1cdecdac5fe75c4f2e236288d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24505 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

主題: {{ spec.title }}

-

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

- +

原結束日期: {{ spec.end_date|taiwan_date }}

+

展延次數: + {{ spec.extension_count }} / 2 + + 剩餘可展延次數: {{ 2 - spec.extension_count }} 次 + +

+
- +
預設為原結束日期後一個月。
diff --git a/templates/spec_history.html b/templates/spec_history.html index b37ccd6..d53266b 100644 --- a/templates/spec_history.html +++ b/templates/spec_history.html @@ -21,7 +21,7 @@ {{ entry.action }}{{ entry.user.username if entry.user else '[已刪除的使用者]' }} 執行 - {{ entry.timestamp.strftime('%Y-%m-%d %H:%M:%S') }} + {{ entry.timestamp|taiwan_time }}

{{ entry.details }}

diff --git a/templates/spec_list.html b/templates/spec_list.html index 10e9d92..dfe0c80 100644 --- a/templates/spec_list.html +++ b/templates/spec_list.html @@ -47,7 +47,7 @@ 建立日期 結束日期 剩餘天數 - 狀態 + 狀態 操作 @@ -57,8 +57,8 @@ {{ spec.spec_code }} {{ spec.title }} {{ spec.applicant }} - {{ spec.created_at.strftime('%Y-%m-%d') }} - {{ spec.end_date.strftime('%Y-%m-%d') }} + {{ spec.created_at|taiwan_date }} + {{ spec.end_date|taiwan_date }} {% if spec.status in ['active', 'expired'] %} @@ -80,7 +80,7 @@ {% endif %} - + {% if spec.status == 'active' %} 已生效 {% elif spec.status == 'pending_approval' %} @@ -90,6 +90,18 @@ {% else %} 已過期 {% endif %} + + {% if spec.extension_count > 0 %} +
+
+ + 已展延 {{ spec.extension_count }} 次 + + {% if spec.extension_count >= 2 %} +
達到上限 + {% endif %} +
+ {% endif %} @@ -102,7 +114,11 @@ {% endif %} {% if current_user.role in ['editor', 'admin'] and spec.status == 'active' %} -
+ {% if spec.extension_count < 2 %} + + {% else %} + + {% endif %} {% endif %} diff --git a/templates/user_management.html b/templates/user_management.html index 78a032c..f129d8b 100644 --- a/templates/user_management.html +++ b/templates/user_management.html @@ -84,7 +84,7 @@ {% if user.last_login %} - {{ user.last_login.strftime('%Y-%m-%d %H:%M') }} + {{ user.last_login|taiwan_time('%Y-%m-%d %H:%M') }} {% else %} 從未登入 {% endif %} diff --git a/utils.py b/utils/__init__.py similarity index 97% rename from utils.py rename to utils/__init__.py index fd6c234..6980099 100644 --- a/utils.py +++ b/utils/__init__.py @@ -1,3 +1,4 @@ +# utils 模組初始化 from docxtpl import DocxTemplate, InlineImage from docx.shared import Mm from docx2pdf import convert @@ -17,7 +18,7 @@ except ImportError: import mistune from PIL import Image -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # 向上一級到專案根目錄 def _resolve_image_path(src: str) -> str: """ @@ -102,10 +103,6 @@ def _process_markdown_sections(doc, md_content): 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 @@ -135,9 +132,6 @@ def fill_template(values, template_path, output_word_path, output_pdf_path): if PYTHONCOM_AVAILABLE: pythoncom.CoUninitialize() - - - def admin_required(f): @wraps(f) def decorated_function(*args, **kwargs): @@ -157,7 +151,7 @@ def editor_or_admin_required(f): 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, @@ -166,7 +160,6 @@ def add_history_log(spec_id, action, details=""): ) db.session.add(history_entry) - import smtplib from email.mime.text import MIMEText from email.header import Header @@ -179,41 +172,41 @@ def process_recipients(recipients_str): 返回: 展開後的郵件地址列表 """ 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 @@ -226,7 +219,7 @@ def send_email(to_addrs, subject, body): 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'] @@ -236,7 +229,7 @@ def send_email(to_addrs, subject, body): 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}") @@ -262,14 +255,14 @@ def send_email(to_addrs, subject, body): # 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 伺服器...") @@ -277,17 +270,17 @@ def send_email(to_addrs, subject, body): 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] 請檢查寄件者帳號和密碼設定") @@ -305,4 +298,4 @@ def send_email(to_addrs, subject, body): import traceback print(f"[EMAIL ERROR] 詳細錯誤:") traceback.print_exc() - return False + return False \ No newline at end of file diff --git a/utils/timezone.py b/utils/timezone.py new file mode 100644 index 0000000..b9b6548 --- /dev/null +++ b/utils/timezone.py @@ -0,0 +1,102 @@ +""" +台灣時區處理工具模組 + +提供一致的時區處理函數,確保系統中所有時間都使用台灣時區(GMT+8) +""" + +from datetime import datetime, timezone, timedelta + +# 台灣時區 (GMT+8) +TAIWAN_TZ = timezone(timedelta(hours=8)) + +def now_taiwan() -> datetime: + """取得當前台灣時間""" + return datetime.now(TAIWAN_TZ) + +def now_utc() -> datetime: + """取得當前 UTC 時間(保留 timezone aware)""" + return datetime.now(timezone.utc) + +def to_taiwan_time(dt) -> datetime: + """將 datetime 轉換為台灣時間 + + Args: + dt: datetime 物件(可能是 naive 或 aware)或 date 物件 + + Returns: + 台灣時區的 datetime 物件 + """ + if dt is None: + return None + + # 如果是 date 物件,轉換為 datetime + from datetime import date + if isinstance(dt, date) and not isinstance(dt, datetime): + # 這是 date 物件,轉換為 datetime(午夜時間) + dt = datetime.combine(dt, datetime.min.time()) + + # 如果是 naive datetime,假設為 UTC + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + + # 轉換為台灣時間 + return dt.astimezone(TAIWAN_TZ) + +def to_utc_time(dt: datetime) -> datetime: + """將 datetime 轉換為 UTC 時間 + + Args: + dt: datetime 物件(可能是 naive 或 aware) + + Returns: + UTC 時區的 datetime 物件 + """ + if dt is None: + return None + + # 如果是 naive datetime,假設為台灣時間 + if dt.tzinfo is None: + dt = dt.replace(tzinfo=TAIWAN_TZ) + + return dt.astimezone(timezone.utc) + +def format_taiwan_time(dt: datetime, format_str: str = "%Y-%m-%d %H:%M:%S") -> str: + """將 datetime 格式化為台灣時間字符串 + + Args: + dt: datetime 物件 + format_str: 格式化字符串 + + Returns: + 格式化後的台灣時間字符串 + """ + if dt is None: + return "" + + taiwan_time = to_taiwan_time(dt) + return taiwan_time.strftime(format_str) + +def parse_taiwan_time(time_str: str, format_str: str = "%Y-%m-%d %H:%M:%S") -> datetime: + """解析台灣時間字符串為 datetime + + Args: + time_str: 時間字符串 + format_str: 解析格式 + + Returns: + 台灣時區的 datetime 物件 + """ + naive_dt = datetime.strptime(time_str, format_str) + return naive_dt.replace(tzinfo=TAIWAN_TZ) + +# 為了向後兼容,提供替代 datetime.utcnow() 的函數 +def utcnow() -> datetime: + """取得當前 UTC 時間(替代 datetime.utcnow()) + + 注意:新代碼建議使用 now_taiwan() 或 now_utc() + """ + return now_utc().replace(tzinfo=None) # 返回 naive UTC datetime 以保持兼容性 + +def taiwan_now() -> datetime: + """取得當前台灣時間(naive datetime,用於存儲到資料庫)""" + return now_taiwan().replace(tzinfo=None) \ No newline at end of file